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
1212import atexit
1313import os
14+ from collections .abc import Sequence
1415
1516from loguru import logger
1617from opentelemetry import trace
1718from opentelemetry .instrumentation .httpx import HTTPXClientInstrumentor
19+ from opentelemetry .instrumentation .starlette import StarletteInstrumentor
1820from 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
24117def 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 ()
0 commit comments