Add OpenTelemetry tracing and metrics for MCP tool calls - #24
Conversation
| def _get_meta(ctx: Any) -> dict: | ||
| try: | ||
| return ctx.request_context.request.params.meta or {} | ||
| except Exception: | ||
| return {} | ||
|
|
||
|
|
||
| def _extract_trace_context(meta: dict): | ||
| if not meta: | ||
| return None | ||
| try: | ||
| from opentelemetry.propagate import extract | ||
| carrier = {} | ||
| if "traceparent" in meta: | ||
| carrier["traceparent"] = meta["traceparent"] | ||
| if "tracestate" in meta: | ||
| carrier["tracestate"] = meta["tracestate"] | ||
| return extract(carrier) if carrier else None | ||
| except Exception: | ||
| return None |
There was a problem hiding this comment.
Hey @diego-ferrand I was interiorizing with the PR and I got a pretty big alert on how are we handling the meta withing the context.
This is so far the research I could put through:
_extract_trace_context never extracts the parent context, so server spans are never linked to the caller's trace. Local spans/metrics still work, which is why this
fails quietly rather than erroring.
Root cause: request.params.meta is not a dict — it's the SDK's RequestParams.Meta Pydantic model (alias="_meta", extra="allow"). The _meta → .meta
mapping is handled correctly by the alias, but traceparent/tracestate arrive as model attributes, not dict keys. The current code does dict-style access on it:
if "traceparent" in meta: # always False on a BaseModel
carrier["traceparent"] = meta["traceparent"] # 'Meta' object is not subscriptableSo carrier is always empty → extract() is never called → parent_ctx is always None.
Reproduced against the real SDK type:
type(meta): mcp.types.RequestParams.Meta
"traceparent" in meta => False
meta["traceparent"] => TypeError: 'Meta' object is not subscriptable
_extract_trace_context => None
Suggested fix — normalize to a dict first (handles both the Pydantic-model and plain-dict shapes, so we don't have to assume the client/transport):
def _extract_trace_context(meta):
if not meta:
return None
if hasattr(meta, "model_dump"):
meta = meta.model_dump()
...model_dump() surfaces the extras correctly:
{'progressToken': None, 'traceparent': '00-...-01', 'tracestate': 'rojo=...'}
Tests: tests/test_telemetry.py currently has no coverage for traceparent / _get_meta / _extract_trace_context, which is how this regressed without anything
failing. A MagicMock ctx would fake in/[] and mask the bug, so the test should build the actual SDK type.
Non-breaking for everything else, but blocking for the OTEL propagation goal — it defeats the whole point of passing traceparent.
This pull request introduces OpenTelemetry-based telemetry support to the
perfecto-mcpproject, enabling distributed tracing and metrics collection for tool calls. The changes include the addition of a newtelemetry.pymodule, integration of telemetry initialization into the main application, updates to dependencies and build configuration, and comprehensive unit tests for the telemetry logic.Telemetry Integration and Implementation:
Added a new
telemetry.pymodule that initializes OpenTelemetry tracing and metrics, provides helper functions for exporting to OTLP (gRPC or HTTP), and wraps tool calls with tracing and metrics instrumentation. The module is robust to missing dependencies and can be disabled via environment variables. (telemetry.py)Integrated telemetry initialization by calling
init_telemetry("perfecto-mcp", __version__)at application startup inmain.py. (main.py) [1] [2]Dependency and Build Updates:
Added OpenTelemetry dependencies (
opentelemetry-api,opentelemetry-sdk,opentelemetry-exporter-otlp) topyproject.toml, and registered the newtelemetrymodule for inclusion in the package. Also added a development dependency group for pytest and configured Python path for testing. (pyproject.toml) [1] [2]Updated the PyInstaller build process to include all necessary OpenTelemetry modules and submodules, ensuring the telemetry functionality is packaged correctly. (
build.py)Testing:
tests/test_telemetry.py)Documentation:
Dockerfileto clarify telemetry defaults and how to override or disable them via environment variables. (Dockerfile)