Skip to content

feat(telemetry): optional OpenTelemetry instrumentation for MCP operations#129

Open
getlarge wants to merge 6 commits intoplatformatic:mainfrom
getlarge:feat/otel-instrumentation
Open

feat(telemetry): optional OpenTelemetry instrumentation for MCP operations#129
getlarge wants to merge 6 commits intoplatformatic:mainfrom
getlarge:feat/otel-instrumentation

Conversation

@getlarge
Copy link
Copy Markdown
Contributor

@getlarge getlarge commented Apr 8, 2026

Closes #128.

Adds optional OpenTelemetry tracing for MCP operations. Zero cost when no tracer is configured — @opentelemetry/api is an optional peer dep and is only loaded via dynamic import() the first time a span is created.

What's included

  • telemetry?: { tracer: TracerLike } on MCPPluginOptionsTracerLike is a structural interface so consumers don't need @opentelemetry/api installed just to import plugin types
  • handleRequest wraps each JSON-RPC dispatch in an active span when a tracer is configured, with MCP semconv attributes (mcp.method.name, mcp.session.id, plus mcp.tool.name / mcp.resource.uri / mcp.prompt.name per method)
  • withSpan, buildSpanAttributes, MCP_ATTR, TracerLike, HandlerDependencies re-exported from the package root for advanced consumers
  • Unit, type-level, and end-to-end integration tests

Usage

import { trace } from '@opentelemetry/api'
import mcpPlugin from '@platformatic/mcp'

await app.register(mcpPlugin, {
  telemetry: { tracer: trace.getTracer('my-mcp-server', '1.0.0') },
})

Design note: why inlined attribute keys instead of @opentelemetry/semantic-conventions

src/telemetry-constants.ts inlines the six MCP semconv keys as string constants rather than importing from @opentelemetry/semantic-conventions. Reasoning:

  • As of @opentelemetry/semantic-conventions@1.40.0, only ATTR_MCP_METHOD_NAME, ATTR_MCP_PROTOCOL_VERSION, ATTR_MCP_RESOURCE_URI, and ATTR_MCP_SESSION_ID are exported. mcp.tool.name and mcp.prompt.name are in the MCP spec but haven't landed in the JS semconv package yet — importing would force a partial-import + hardcoded-strings mix anyway.
  • MCP attrs live under semantic-conventions/experimental, an explicitly unstable export path. Coupling to it trades six stable local strings for drift risk on every semconv release.
  • The MCP spec itself is the source of truth the semconv package tracks, and the keys are stable there.

There's a // Revisit once all six attrs are exported from a stable semconv path comment in telemetry-constants.ts. Happy to switch to the semconv imports once coverage + path stability catch up, or earlier if you'd prefer to couple to the experimental path now.

Commits

Split into logical chunks for review:

  1. chore: add @opentelemetry/api as optional peer dependency
  2. feat(telemetry): add telemetry module with dynamic OTel loading
  3. feat(telemetry): add TracerLike and telemetry option to MCPPluginOptions
  4. feat(telemetry): instrument MCP handlers with OTel spans
  5. feat(telemetry): export telemetry utilities from package index
  6. test(telemetry): add unit and integration tests

Open questions

  • Span kind for the top-level dispatch: INTERNAL (current) or SERVER? Leaning INTERNAL since the Fastify HTTP span is already SERVER.
  • SSE broadcast path instrumentation: deferred to a follow-up. Happy to add it here if you'd rather land it together.

Test plan

  • npm run typecheck clean
  • npm run lint clean
  • 13/13 telemetry tests pass (test/telemetry*.ts)
  • CI green

getlarge and others added 6 commits April 8, 2026 21:25
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- withSpan() helper wraps fn execution in an active OTel span, loading
  @opentelemetry/api dynamically on first use so users without the peer
  dep pay zero cost
- MCP_ATTR constants and buildSpanAttributes() live in telemetry-constants.ts
  with no OTel dependency, so they can be imported statically by any module
- Attribute keys are inlined rather than sourced from
  @opentelemetry/semantic-conventions: the JS semconv package only exports
  4 of the 6 MCP attrs as of 1.40.0, under an explicitly unstable /experimental
  path. See telemetry-constants.ts for the full rationale.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
TracerLike is a structural interface compatible with @opentelemetry/api's
Tracer, defined locally so consumers don't need @opentelemetry/api installed
just to import plugin types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- handleRequest wraps each JSON-RPC dispatch in withSpan when a tracer
  is configured on HandlerDependencies
- Span names match the JSON-RPC method; span attributes use MCP semconv
  keys (mcp.method.name, mcp.session.id, plus mcp.tool.name /
  mcp.resource.uri / mcp.prompt.name for method-specific dispatches)
- The telemetry module is loaded lazily via dynamic import inside the
  per-request wrap, so users without a tracer configured never touch
  @opentelemetry/api
- Routes thread opts.telemetry?.tracer into processMessage
- HandlerDependencies is now exported for advanced consumers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Export MCP_ATTR, buildSpanAttributes, withSpan, TracerLike, and
HandlerDependencies from the package root so advanced consumers can
build their own span wiring on top of the plugin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- test/telemetry.test.ts: unit tests for withSpan (success/error paths,
  no-tracer fallthrough) and buildSpanAttributes
- test/telemetry-types.test.ts: type-level tests for MCPPluginOptions.telemetry
- test/telemetry-integration.test.ts: end-to-end plugin -> tracer wiring,
  driving tools/call and tools/list through the HTTP surface and asserting
  span names, attributes, and per-method extras (mcp.tool.name)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add optional OpenTelemetry instrumentation for MCP operations

1 participant