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

1"""OpenTelemetry SDK bootstrap for the ECNL MCP server. 

2 

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. 

10 

11Two instrumentations are wired automatically on bootstrap: 

12 

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. 

21 

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""" 

26 

27from __future__ import annotations 

28 

29import logging 

30import os 

31from collections.abc import Callable 

32 

33logger = logging.getLogger(__name__) 

34 

35 

36def _env_flag(name: str, default: bool = False) -> bool: 

37 """Read an env var as a boolean. 

38 

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"} 

47 

48 

49def _resolve_endpoint() -> str: 

50 """Return the OTLP exporter endpoint. 

51 

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", "") 

60 

61 

62def setup_tracing(service_name: str) -> Callable[[], None]: 

63 """Bootstrap the global ``TracerProvider``. 

64 

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. 

68 

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``). 

73 

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 

82 

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 

92 

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) 

97 

98 HTTPXClientInstrumentor().instrument() 

99 _instrument_starlette_if_available() 

100 

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 

109 

110 

111def _build_exporter(): 

112 """Construct the configured OTLP exporter or fall back to stdout. 

113 

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 

121 

122 endpoint = _resolve_endpoint() 

123 if not endpoint: 

124 return ConsoleSpanExporter() 

125 

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 

129 

130 return OTLPSpanExporter(endpoint=endpoint) 

131 from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter 

132 

133 insecure = _env_flag("MCP_TRACING_EXPORTER_INSECURE") 

134 return OTLPSpanExporter(endpoint=endpoint, insecure=insecure) 

135 

136 

137def _resource_attrs(service_name: str) -> dict[str, str]: 

138 """Build the resource attribute set. 

139 

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 

152 

153 

154def _sampler(): 

155 """Return the head-based parent-based + ratio sampler. 

156 

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 

164 

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))) 

172 

173 

174def _instrument_starlette_if_available() -> None: 

175 """Wire Starlette instrumentation if the package is installed. 

176 

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() 

193 

194 

195def _noop_shutdown() -> None: 

196 """No-op shutdown for the disabled-tracing path.""" 

197 return None