Coverage for src/ecnl/observability/tracing.py: 100%
68 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-28 08:19 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-28 08:19 +0000
1"""OpenTelemetry SDK bootstrap for the ECNL MCP server.
3Mirrors the auth-server / login-ui pattern from identity-platform-go.
4``setup_tracing`` is the single entry point — called once at startup
5from :mod:`ecnl.server` before any tool dispatch — and is intentionally
6a no-op when ``MCP_TRACING_ENABLED`` is unset or false. That keeps the
7SDK out of the import graph for the stdio transport's tight-loop tests
8and lets a single env-var flip turn observability on at deploy time
9without code changes.
11Two instrumentations are wired automatically on bootstrap:
13* ``HTTPXClientInstrumentor`` — every ``httpx.AsyncClient`` emits a
14 client span per request and injects the W3C ``traceparent`` header.
15 The AthleteOne and discovery outbound adapters all use httpx, so this
16 closes the outbound half of the trace chain in one call.
17* ``StarletteInstrumentor`` — the ``streamable-http`` MCP transport
18 uses Starlette under the hood. Wiring it here means every inbound
19 tool call becomes a server span and the W3C ``traceparent`` from
20 the upstream caller (Claude → MCP gateway → here) is honoured.
22The stdio transport does not run an HTTP server, so the Starlette
23instrumentation is a no-op there. The httpx instrumentation still
24applies to outbound calls.
25"""
27from __future__ import annotations
29import logging
30import os
31from collections.abc import Callable
33logger = logging.getLogger(__name__)
36def _env_flag(name: str, default: bool = False) -> bool:
37 """Read an env var as a boolean.
39 Accepts ``1``, ``true``, ``yes``, ``on`` (case-insensitive) as true.
40 Empty / unset / any other value is false. Mirrors the convention the
41 Go services use so operators don't have to remember a second one.
42 """
43 value = os.environ.get(name, "").strip().lower()
44 if not value:
45 return default
46 return value in {"1", "true", "yes", "on"}
49def _resolve_endpoint() -> str:
50 """Return the OTLP exporter endpoint.
52 Prefers ``MCP_TRACING_EXPORTER_ENDPOINT`` so the service can be
53 pointed at a non-default collector without touching the standard
54 ``OTEL_*`` chain; otherwise defers to ``OTEL_EXPORTER_OTLP_ENDPOINT``.
55 Returns the empty string when nothing is configured — the caller
56 then falls back to the stdout exporter so spans are still visible
57 during local development.
58 """
59 return os.environ.get("MCP_TRACING_EXPORTER_ENDPOINT") or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "")
62def setup_tracing(service_name: str) -> Callable[[], None]:
63 """Bootstrap the global ``TracerProvider``.
65 Idempotent and safe to call from any process; the SDK's own
66 ``set_tracer_provider`` rejects a second registration so a duplicate
67 call is logged and ignored.
69 Args:
70 service_name: Recorded as the ``service.name`` resource
71 attribute on every emitted span. Conventionally the MCP
72 server's package name (``jk-mcp-nwsl``).
74 Returns:
75 A no-arg callable that flushes buffered spans and shuts the
76 SDK down. Wire it into the process's graceful-shutdown path so
77 spans aren't dropped on SIGTERM.
78 """
79 if not _env_flag("MCP_TRACING_ENABLED"):
80 logger.debug("MCP_TRACING_ENABLED unset — tracing remains disabled")
81 return _noop_shutdown
83 # Imports are local so a deployment that never enables tracing does
84 # not pay the import cost. The opentelemetry-* packages pull in a
85 # non-trivial dependency graph and slow cold-start measurably when
86 # imported unconditionally.
87 from opentelemetry import trace
88 from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
89 from opentelemetry.sdk.resources import Resource
90 from opentelemetry.sdk.trace import TracerProvider
91 from opentelemetry.sdk.trace.export import BatchSpanProcessor
93 resource = Resource.create(_resource_attrs(service_name))
94 provider = TracerProvider(resource=resource, sampler=_sampler())
95 provider.add_span_processor(BatchSpanProcessor(_build_exporter()))
96 trace.set_tracer_provider(provider)
98 HTTPXClientInstrumentor().instrument()
99 _instrument_starlette_if_available()
101 logger.info(
102 "opentelemetry tracing enabled",
103 extra={
104 "service_name": service_name,
105 "exporter_endpoint": _resolve_endpoint() or "stdout",
106 },
107 )
108 return provider.shutdown
111def _build_exporter():
112 """Construct the configured OTLP exporter or fall back to stdout.
114 Endpoint resolution mirrors the Go ``go-platform/otel`` package: an
115 operator-set ``MCP_TRACING_EXPORTER_ENDPOINT`` wins, then the
116 standard ``OTEL_EXPORTER_OTLP_ENDPOINT``, then stdout. The protocol
117 is gRPC by default — set ``MCP_TRACING_EXPORTER_PROTOCOL=http`` to
118 use the HTTP exporter instead, mirroring the Go side's switch.
119 """
120 from opentelemetry.sdk.trace.export import ConsoleSpanExporter
122 endpoint = _resolve_endpoint()
123 if not endpoint:
124 return ConsoleSpanExporter()
126 protocol = os.environ.get("MCP_TRACING_EXPORTER_PROTOCOL", "grpc").lower()
127 if protocol == "http":
128 from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
130 return OTLPSpanExporter(endpoint=endpoint)
131 from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
133 insecure = _env_flag("MCP_TRACING_EXPORTER_INSECURE")
134 return OTLPSpanExporter(endpoint=endpoint, insecure=insecure)
137def _resource_attrs(service_name: str) -> dict[str, str]:
138 """Build the resource attribute set.
140 ``service.name`` is always set; ``service.version`` and
141 ``deployment.environment.name`` come in from env when present.
142 Empty values are dropped so the SDK does not emit blank attrs.
143 """
144 attrs: dict[str, str] = {"service.name": service_name}
145 version = os.environ.get("MCP_TRACING_SERVICE_VERSION") or os.environ.get("OTEL_SERVICE_VERSION", "")
146 if version:
147 attrs["service.version"] = version
148 environment = os.environ.get("MCP_TRACING_ENVIRONMENT") or os.environ.get("OTEL_DEPLOYMENT_ENVIRONMENT_NAME", "")
149 if environment:
150 attrs["deployment.environment.name"] = environment
151 return attrs
154def _sampler():
155 """Return the head-based parent-based + ratio sampler.
157 ``MCP_TRACING_SAMPLER_RATIO`` accepts a float in [0, 1]. Zero or
158 unset defaults to 1.0 — sampling everything is the right default
159 for a deployment that has tracing explicitly turned on; an
160 operator who needs fewer spans can dial it down without touching
161 code.
162 """
163 from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased
165 raw = os.environ.get("MCP_TRACING_SAMPLER_RATIO", "").strip()
166 try:
167 ratio = float(raw) if raw else 1.0
168 except ValueError:
169 logger.warning("MCP_TRACING_SAMPLER_RATIO=%r is not a number; falling back to 1.0", raw)
170 ratio = 1.0
171 return ParentBased(root=TraceIdRatioBased(min(max(ratio, 0.0), 1.0)))
174def _instrument_starlette_if_available() -> None:
175 """Wire Starlette instrumentation if the package is installed.
177 FastMCP's ``streamable-http`` transport runs on Starlette, so this
178 is the inbound-span source for the HTTP path. ``stdio`` deployments
179 don't need it and shouldn't pay the import cost when the package
180 isn't installed — the import is wrapped in a try/except so a
181 minimal deployment that never serves HTTP can omit the
182 ``starlette`` extra without breaking startup.
183 """
184 try:
185 from opentelemetry.instrumentation.starlette import StarletteInstrumentor
186 except ImportError:
187 logger.debug("opentelemetry-instrumentation-starlette not installed; skipping inbound instrumentation")
188 return
189 # StarletteInstrumentor patches the Starlette class itself, so any
190 # Starlette app constructed after this call — including FastMCP's
191 # internal one — gets instrumented automatically.
192 StarletteInstrumentor().instrument()
195def _noop_shutdown() -> None:
196 """No-op shutdown for the disabled-tracing path."""
197 return None