Skip to content

Commit b20b1ba

Browse files
authored
Add privacy-safe configurable OpenTelemetry tracing (#24)
* Add privacy-safe OpenTelemetry tracing * Make MCP access telemetry configurable * Fix cryptography security advisory
1 parent f86892b commit b20b1ba

10 files changed

Lines changed: 530 additions & 111 deletions

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ dependencies = [
1313
"opentelemetry-sdk==1.43.0",
1414
"opentelemetry-exporter-otlp-proto-http==1.43.0",
1515
"opentelemetry-instrumentation-httpx==0.64b0",
16+
"opentelemetry-instrumentation-starlette==0.64b0",
1617
"opentelemetry-semantic-conventions==0.64b0",
1718
]
1819

@@ -45,6 +46,10 @@ fallback_version = "3.0.2"
4546
# Older versions silently ignore the whole [tool.uv] section on parse error.
4647
required-version = "==0.11.28"
4748
exclude-newer = "7 days"
49+
# Security exception: cryptography 50.0.0 fixes GHSA-g6cj-pr64-35w5 and was
50+
# released inside the normal quarantine window. Keep the exception package-
51+
# scoped so all unrelated dependencies remain subject to the seven-day delay.
52+
exclude-newer-package = { cryptography = "2026-08-01T00:00:00Z" }
4853
# Security minimums are older than the global seven-day quarantine. Exact direct
4954
# pins plus exclude-newer keep every future lock update inside the same policy.
5055
constraint-dependencies = [

src/codealive_mcp_server.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ def _package_version() -> str:
5353
return "unknown"
5454

5555

56+
def _environment_flag(name: str, *, default: bool) -> bool:
57+
value = os.getenv(name)
58+
if value is None:
59+
return default
60+
return value.strip().lower() not in {"false", "0", "no", "off"}
61+
62+
5663
# Initialize FastMCP server with lifespan and enhanced system instructions
5764
mcp = FastMCP(
5865
name="CodeAlive MCP Server",
@@ -301,6 +308,13 @@ def main():
301308
allowed_origins=allowed_origins or None,
302309
uvicorn_config={
303310
"forwarded_allow_ips": "*",
311+
# Access logs scale linearly with unauthenticated traffic. Keep
312+
# the upstream default for self-hosted operators, while allowing
313+
# hardened deployments to rely on sampled traces and safe events.
314+
"access_log": _environment_flag(
315+
"CODEALIVE_MCP_ACCESS_LOG_ENABLED",
316+
default=True,
317+
),
304318
},
305319
)
306320
else:

src/core/logging.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77

88
import logging
9+
import os
910
import sys
1011
import uuid
1112
from typing import Any, Dict, List, Optional, Tuple, Union
@@ -115,13 +116,23 @@ def setup_logging(debug: bool = False) -> None:
115116
# Intercept stdlib logging
116117
logging.basicConfig(handlers=[_InterceptHandler()], level=0, force=True)
117118

119+
# FastMCP validation and exception records can contain rejected argument
120+
# values. In production its logger is disabled in favor of the privacy-safe
121+
# CodeAlive middleware logs. Stop propagation explicitly because FastMCP's
122+
# disabled setting otherwise leaves child loggers attached to the root.
123+
fastmcp_logger = logging.getLogger("fastmcp")
124+
if os.environ.get("FASTMCP_LOG_ENABLED", "").lower() in {"false", "0", "no"}:
125+
fastmcp_logger.handlers.clear()
126+
fastmcp_logger.addHandler(logging.NullHandler())
127+
fastmcp_logger.propagate = False
128+
else:
129+
fastmcp_logger.propagate = True
130+
118131
logger.info("Logging initialized at {level} level", level=_current_level)
119132

120133

121134
def setup_debug_logging() -> bool:
122135
"""Backward-compatible helper: enable debug logging if ``DEBUG_MODE`` env is set."""
123-
import os
124-
125136
if os.environ.get("DEBUG_MODE", "").lower() in ["true", "1", "yes"]:
126137
setup_logging(debug=True)
127138
return True

src/core/observability.py

Lines changed: 122 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,150 @@
11
"""OpenTelemetry setup for CodeAlive MCP server.
22
3-
Initialises a ``TracerProvider`` with an OTLP/HTTP exporter when the
4-
``OTEL_EXPORTER_OTLP_ENDPOINT`` env var is set. Otherwise tracing is
5-
configured as a no-op so the rest of the code can call ``trace.get_tracer()``
6-
unconditionally.
3+
Initialises a ``TracerProvider`` with an OTLP/HTTP exporter when either the
4+
generic or traces-specific OTLP endpoint is configured. Otherwise tracing is
5+
configured without an exporter so the rest of the code can call
6+
``trace.get_tracer()`` unconditionally.
77
8-
HTTPX client instrumentation is always enabled so outbound HTTP calls
9-
automatically get ``traceparent`` headers injected.
8+
Starlette and HTTPX instrumentation connect inbound MCP requests to outbound
9+
CodeAlive API calls without recording request or response bodies.
1010
"""
1111

1212
import atexit
1313
import os
14+
from collections.abc import Sequence
1415

1516
from loguru import logger
1617
from opentelemetry import trace
1718
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
19+
from opentelemetry.instrumentation.starlette import StarletteInstrumentor
1820
from opentelemetry.sdk.resources import Resource
19-
from opentelemetry.sdk.trace import TracerProvider
21+
from opentelemetry.sdk.trace import Event, ReadableSpan, TracerProvider
22+
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
23+
from opentelemetry.trace import Status
2024

2125
_SERVICE_NAME = "codealive-mcp"
2226

27+
_SENSITIVE_ATTRIBUTE_PREFIXES = (
28+
"enduser.",
29+
"http.request.header.",
30+
"http.response.header.",
31+
)
32+
_SENSITIVE_ATTRIBUTES = {
33+
"client.address",
34+
"http.url",
35+
"mcp.session.id",
36+
"mcp.resource.uri",
37+
"network.peer.address",
38+
"url.full",
39+
"url.query",
40+
"user_agent.original",
41+
}
42+
43+
44+
class _SanitizingSpanExporter(SpanExporter):
45+
"""Remove client data added by framework auto-instrumentation before export."""
46+
47+
def __init__(self, delegate: SpanExporter) -> None:
48+
self._delegate = delegate
49+
50+
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
51+
return self._delegate.export(tuple(self._sanitize(span) for span in spans))
52+
53+
def shutdown(self) -> None:
54+
self._delegate.shutdown()
55+
56+
def force_flush(self, timeout_millis: int = 30000) -> bool:
57+
return self._delegate.force_flush(timeout_millis)
58+
59+
@staticmethod
60+
def _sanitize(span: ReadableSpan) -> ReadableSpan:
61+
attributes = {
62+
key: value
63+
for key, value in span.attributes.items()
64+
if key not in _SENSITIVE_ATTRIBUTES
65+
and not key.startswith(_SENSITIVE_ATTRIBUTE_PREFIXES)
66+
}
67+
events = tuple(
68+
Event(
69+
event.name,
70+
{"exception.type": event.attributes["exception.type"]}
71+
if event.name == "exception"
72+
and event.attributes
73+
and "exception.type" in event.attributes
74+
else {},
75+
event.timestamp,
76+
)
77+
for event in span.events
78+
)
79+
return ReadableSpan(
80+
name=span.name,
81+
context=span.context,
82+
parent=span.parent,
83+
resource=span.resource,
84+
attributes=attributes,
85+
events=events,
86+
links=span.links,
87+
kind=span.kind,
88+
status=Status(span.status.status_code),
89+
start_time=span.start_time,
90+
end_time=span.end_time,
91+
instrumentation_scope=span.instrumentation_scope,
92+
)
93+
94+
95+
def _resource_attributes() -> dict[str, str]:
96+
"""Build low-cardinality resource identity from deployment metadata only."""
97+
attributes = {
98+
"service.name": os.environ.get("OTEL_SERVICE_NAME", _SERVICE_NAME),
99+
"k8s.container.name": "mcp-server",
100+
}
101+
optional_attributes = {
102+
"service.version": os.environ.get("CODEALIVE_MCP_VERSION"),
103+
"service.instance.id": os.environ.get("POD_NAME")
104+
or os.environ.get("HOSTNAME"),
105+
"deployment.environment.name": os.environ.get("DEPLOYMENT_ENVIRONMENT")
106+
or os.environ.get("ENVIRONMENT"),
107+
"k8s.namespace.name": os.environ.get("POD_NAMESPACE"),
108+
"k8s.pod.name": os.environ.get("POD_NAME"),
109+
"k8s.node.name": os.environ.get("NODE_NAME"),
110+
}
111+
attributes.update(
112+
{key: value for key, value in optional_attributes.items() if value}
113+
)
114+
return attributes
115+
23116

24117
def init_tracing() -> None:
25118
"""Bootstrap OpenTelemetry tracing.
26119
27-
* If ``OTEL_EXPORTER_OTLP_ENDPOINT`` is set, traces are exported via
28-
OTLP/HTTP (protobuf) to that endpoint.
29-
* Otherwise a no-op provider is configured (zero overhead).
120+
* If a generic or traces-specific OTLP endpoint is set, traces are exported
121+
via OTLP/HTTP (protobuf). The exporter reads the standard OTel env vars so
122+
it can apply the correct ``/v1/traces`` path semantics.
123+
* Otherwise a provider without an exporter is configured (no network I/O).
30124
* HTTPX client instrumentation is always enabled so that ``traceparent``
31125
propagates to the CodeAlive backend regardless of whether traces are
32126
exported.
33127
"""
34-
otlp_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
128+
otlp_endpoint = os.environ.get(
129+
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"
130+
) or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
35131

36-
resource = Resource.create({"service.name": _SERVICE_NAME})
132+
resource = Resource.create(_resource_attributes())
37133

38134
if otlp_endpoint:
39-
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
135+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
136+
OTLPSpanExporter,
137+
)
40138
from opentelemetry.sdk.trace.export import BatchSpanProcessor
41139

42-
exporter = OTLPSpanExporter(endpoint=otlp_endpoint)
140+
# Do not pass the endpoint explicitly. The exporter distinguishes the
141+
# signal-specific URL from the generic base URL and appends /v1/traces
142+
# only where the OTel environment-variable contract requires it.
143+
exporter = OTLPSpanExporter()
43144
provider = TracerProvider(resource=resource)
44-
provider.add_span_processor(BatchSpanProcessor(exporter))
145+
provider.add_span_processor(
146+
BatchSpanProcessor(_SanitizingSpanExporter(exporter))
147+
)
45148
trace.set_tracer_provider(provider)
46149

47150
logger.info(
@@ -59,5 +162,8 @@ def init_tracing() -> None:
59162
# Flush pending spans on process exit
60163
atexit.register(provider.shutdown)
61164

62-
# Auto-instrument httpx so outbound requests carry traceparent
165+
# Instrument before FastMCP creates its Starlette app. Neither integration
166+
# captures bodies by default; health endpoints are excluded through the
167+
# standard OTEL_PYTHON_STARLETTE_EXCLUDED_URLS deployment setting.
168+
StarletteInstrumentor().instrument()
63169
HTTPXClientInstrumentor().instrument()

src/middleware/observability_middleware.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
- ``gen_ai.operation.name`` = ``"execute_tool"``
55
- ``gen_ai.tool.name`` = tool name
66
- ``mcp.tool.name`` = tool name (MCP-specific alias)
7-
- ``mcp.method`` = ``"tools/call"``
7+
- ``mcp.method.name`` = ``"tools/call"``
88
99
The middleware also injects ``trace_id`` into loguru context via
1010
``logger.contextualize`` so that every log emitted during the tool
@@ -60,7 +60,27 @@ def _extract_tool_arguments(context: "MiddlewareContext") -> dict[str, Any]:
6060

6161

6262
class ObservabilityMiddleware(Middleware):
63-
"""Wrap each ``tools/call`` in an OTel span and log its outcome."""
63+
"""Trace MCP requests and nested tool execution without recording payloads."""
64+
65+
async def on_request(self, context: "MiddlewareContext", call_next: "CallNext"):
66+
method = context.method or "unknown"
67+
with _tracer.start_as_current_span(
68+
f"mcp {method}",
69+
record_exception=False,
70+
set_status_on_exception=False,
71+
attributes={"mcp.method.name": method},
72+
) as span:
73+
try:
74+
result = await call_next(context)
75+
except Exception as exc:
76+
error_type = type(exc).__name__
77+
span.set_attribute("error.type", error_type)
78+
span.set_status(StatusCode.ERROR, error_type)
79+
span.add_event("exception", {"exception.type": error_type})
80+
raise
81+
82+
span.set_status(StatusCode.OK)
83+
return result
6484

6585
async def on_call_tool(self, context: "MiddlewareContext", call_next: "CallNext"):
6686
tool_name = getattr(context.message, "name", "unknown")
@@ -75,7 +95,7 @@ async def on_call_tool(self, context: "MiddlewareContext", call_next: "CallNext"
7595
"gen_ai.operation.name": "execute_tool",
7696
"gen_ai.tool.name": tool_name,
7797
"mcp.tool.name": tool_name,
78-
"mcp.method": "tools/call",
98+
"mcp.method.name": "tools/call",
7999
},
80100
) as span:
81101
# Inject trace_id into loguru so every log inside the tool carries it

src/tests/test_http_transport_security.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,20 @@ def test_http_main_enables_guard_and_reads_environment_allowlists(monkeypatch):
9797
assert options["host_origin_protection"] is True
9898
assert options["allowed_hosts"] == ["mcp.codealive.ai", "codealive-mcp-server"]
9999
assert options["allowed_origins"] == ["https://mcp.codealive.ai"]
100+
assert options["uvicorn_config"]["access_log"] is True
101+
102+
103+
def test_http_main_can_disable_per_request_access_logs(monkeypatch):
104+
run = MagicMock()
105+
monkeypatch.setattr(server.mcp, "run", run)
106+
monkeypatch.setattr(server, "setup_logging", MagicMock())
107+
monkeypatch.setattr(server, "init_tracing", MagicMock())
108+
monkeypatch.setenv("CODEALIVE_MCP_ACCESS_LOG_ENABLED", "false")
109+
monkeypatch.setattr(sys, "argv", ["codealive-mcp", "--transport", "http"])
110+
111+
server.main()
112+
113+
assert run.call_args.kwargs["uvicorn_config"]["access_log"] is False
100114

101115

102116
def test_http_main_fails_closed_when_oauth_exchange_secret_is_missing(monkeypatch):

src/tests/test_logging.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import io
44
import json
5+
import logging
56
import sys
67
from unittest.mock import MagicMock
78

@@ -106,6 +107,35 @@ def test_setup_logging_sets_level(self):
106107

107108
logger.remove(handler_id)
108109

110+
def test_fastmcp_logs_do_not_propagate_when_disabled(self, monkeypatch, capsys):
111+
monkeypatch.setenv("FASTMCP_LOG_ENABLED", "false")
112+
sink = io.StringIO()
113+
setup_logging()
114+
logger.remove()
115+
handler_id = logger.add(sink, level="DEBUG", serialize=True)
116+
117+
logging.getLogger("fastmcp.server.server").warning(
118+
"Invalid arguments: secret query text"
119+
)
120+
121+
assert "secret query text" not in sink.getvalue()
122+
assert "secret query text" not in capsys.readouterr().err
123+
logger.remove(handler_id)
124+
125+
def test_fastmcp_logs_propagate_by_default_for_self_hosted(self, monkeypatch):
126+
monkeypatch.delenv("FASTMCP_LOG_ENABLED", raising=False)
127+
sink = io.StringIO()
128+
setup_logging()
129+
logger.remove()
130+
handler_id = logger.add(sink, level="DEBUG", serialize=True)
131+
132+
logging.getLogger("fastmcp.server.server").warning(
133+
"Self-hosted framework diagnostic"
134+
)
135+
136+
assert "Self-hosted framework diagnostic" in sink.getvalue()
137+
logger.remove(handler_id)
138+
109139
def test_setup_debug_logging_env_var(self, monkeypatch):
110140
monkeypatch.setenv("DEBUG_MODE", "true")
111141
assert setup_debug_logging() is True

0 commit comments

Comments
 (0)