otel

package module
v0.3.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 7, 2026 License: Apache-2.0 Imports: 11 Imported by: 0

README

ext/otel — OpenTelemetry adapter for SEP-414

Go module that adapts the OpenTelemetry Go SDK to mcpkit's dependency-free core.TracerProvider contract. Pairs with the SEP-414 server-side propagation surface that landed in PR 649 (P2): once an adapter is wired via server.WithTracerProvider, every JSON-RPC dispatch emits an inbound span and every outbound message (notification, sampling, elicitation, roots) carries _meta.traceparent / _meta.tracestate derived from the active span.

Looking for the SEP-414 design + phase status? See docs/SEP_414_OTEL.md. This README is the adapter's API reference; the design doc is the rollout narrative.

Why a sub-module

OpenTelemetry brings ~10 transitive dependencies (SDK, attribute, codes, exporters, ...). Keeping the adapter out of the base module matches the established ext/auth / ext/tasks / ext/ui precedent: servers that don't trace pay nothing.

Quickstart

import (
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    "go.opentelemetry.io/otel/exporters/stdout/stdouttrace"

    "github.com/panyam/mcpkit/core"
    "github.com/panyam/mcpkit/server"
    mcpotel "github.com/panyam/mcpkit/ext/otel"
)

exp, err := stdouttrace.New()
if err != nil { /* handle */ }
otelTP := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exp))
defer otelTP.Shutdown(ctx)

srv := server.NewServer(
    core.ServerInfo{Name: "my-server", Version: "0.1.0"},
    server.WithTracerProvider(mcpotel.NewProvider(otelTP)),
)
srv.RegisterTool(def, handler)
srv.Run(":8787")

Swap stdouttrace for any OTel exporter (OTLP, Jaeger, Datadog, ...) and the surface is unchanged — the adapter consumes an otel/trace.TracerProvider, not a specific exporter.

What the adapter does

  • Implements core.TracerProvider. mcpotel.NewProvider(otelTP) returns a value that mcpkit's server dispatch can call into. The internal Tracer is created once at construction; StartSpan is a single OTel Tracer.Start call plus a context plumbing step.
  • Bridges W3C trace context to OTel SpanContext, both ways. Inbound: the SEP-414 P2 trace middleware extracts params._meta.traceparent (or the SEP-2028 HTTP header bridge) into ctx; the adapter parses it into an otel/trace.SpanContext and installs it as the new span's parent. Outbound: after StartSpan, the adapter updates ctx via core.WithTraceContext so the SEP-414 P2 outbound _meta injection wraps stamp every server-to-client message with the child span's traceparent.
  • Maps core.Attribute to attribute.String. P1's contract scopes attributes to string/string; typed attributes arrive when the core surface widens.
  • Enforces idempotent End. Mcpkit's core.Span contract treats a second End as a no-op. The wrapper short-circuits before the underlying SDK can log its "span already ended" warning.
  • RecordError(err) emits both an OTel exception event and sets the span status to codes.Error. This matches the OTel idiom — backends use Status.Code for filtering / counting error spans, separately from the recorded event payload.

Options

Option Effect
mcpotel.WithInstrumentationName(name string) Override the OTel instrumentation library name (default: "github.com/panyam/mcpkit/server").

Metrics (issue 7)

mcpotel.NewMeterProvider(otelMP) is the sibling to NewProvider for the core.MeterProvider seam. Wire it via server.WithMeterProvider and the dispatch path will emit the canonical MCP server metrics:

import (
    sdkmetric "go.opentelemetry.io/otel/sdk/metric"
    "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"

    "github.com/panyam/mcpkit/core"
    "github.com/panyam/mcpkit/server"
    mcpotel "github.com/panyam/mcpkit/ext/otel"
)

exp, err := otlpmetricgrpc.New(ctx)
if err != nil { /* handle */ }
otelMP := sdkmetric.NewMeterProvider(
    sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exp)),
)
defer otelMP.Shutdown(ctx)

srv := server.NewServer(
    core.ServerInfo{Name: "my-server", Version: "0.1.0"},
    server.WithTracerProvider(mcpotel.NewProvider(otelTP)),
    server.WithMeterProvider(mcpotel.NewMeterProvider(otelMP)),
)

Canonical instruments emitted from server/metrics_middleware.go:

Metric Type Unit Labels Meaning
mcp.tool.calls int64 counter 1 tool Every tools/call dispatch.
mcp.jsonrpc.errors int64 counter 1 code Every JSON-RPC error response.
mcp.tool.duration float64 histogram ms tool Handler-attributable tool latency.
mcp.sessions.active int64 up-down counter 1 Currently active streamable HTTP sessions.
Exemplars (default-on)

Every measurement forwards the active context to the underlying OTel instrument. The OTel SDK's default exemplar filter (AlwaysOnSampleParent) reads the active span via the same ctx accessor core.SpanFromContext consumes and stamps an exemplar on the measurement — Grafana + Mimir render clickable dots that pivot to the matching Tempo trace, closing the metric ↔ trace loop the SEP-414 work opened.

Option Effect
mcpotel.WithMeterInstrumentationName(name string) Override the OTel instrumentation library name for the meter scope (default: "github.com/panyam/mcpkit/server").
Coexistence with traces

MeterProvider and Provider are independent objects backed by distinct OTel SDK providers (MeterProvider and TracerProvider). Typical wiring uses a single OTel resource (service.name, deployment.environment, ...) across both so Grafana's pivot finds both halves of the same service.

Verification

make test-otel              # unit tests against a real OTel SDK pipeline
make test-otel-example      # smoke test for the stdout example

The runnable demo lives at examples/otel/stdout and prints exported spans as JSON on stdout — no exporter infrastructure required.

Out of scope (other SEP-414 phases)

  • P3 — client-side spans. Outbound client.Call wrapping + inbound server-to-client request parent extraction. Lives in client/. Tracked under issue 312.
  • P5 — examples/otel/{jaeger,otlp} walkthroughs. Polished end-to-end docs with collector + UI screenshots. The minimal stdout example here is enough to verify the adapter; the polished walkthroughs land separately.
  • Conformance suite testconf-otel. Brand-neutral OTel scenarios in the panyam/mcpconformance fork. Tracked as issue 429.

Documentation

Overview

Package otel adapts the OpenTelemetry Go SDK (go.opentelemetry.io/otel) to mcpkit's dependency-free core.TracerProvider contract. Wire a Provider constructed from a real otelsdk.TracerProvider into the server via server.WithTracerProvider, and the SEP-414 P2 trace middleware will emit inbound spans on every JSON-RPC dispatch plus propagate W3C trace context on every outbound notification / server-to-client request.

Phase 4 deliverable for SEP-414 (issue 312). Phase 2 (PR 649) shipped the dispatch-path wiring; this package is the SDK-backed adapter that turns those spans into something an exporter (stdout, OTLP, Jaeger, ...) can publish.

Phase 3 (client-side spans) and Phase 5 (the polished examples/otel/ walkthrough doc) are tracked separately on issue 312.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func NewTracerProvider

func NewTracerProvider(exporter sdktrace.SpanExporter, opts ...TracerProviderOption) *sdktrace.TracerProvider

NewTracerProvider builds an `*sdktrace.TracerProvider` with the supplied exporter and options. The returned TracerProvider is ready to hand to mcpotel.NewProvider(otelTP) for wiring into server.WithTracerProvider or client.WithTracerProvider.

Panics if exporter is nil — silently constructing a TracerProvider that emits no spans loses signals without surfacing the misconfiguration. Same fail-fast convention as mcpotel.NewProvider(nil otelTP).

The default span processor is batched (production-aligned). Pass WithSyncer to switch to sync exporting. The default Resource is the SDK default (no service.name); pass WithServiceName to set it.

Future options (`WithDeploymentEnvironment`, `WithServiceVersion`, `WithResource` escape hatch) compose against the same internal config — they will land when a real consumer asks. Issue 663 (P6 umbrella) tracks the SEP-414 surface work that consumes this helper.

Types

type MeterOption

type MeterOption func(*meterProviderConfig)

MeterOption mutates a meterProviderConfig during NewMeterProvider. Exported so user-side libraries can layer their own helpers without depending on the unexported config shape — matches the trace-side Option pattern.

func WithMeterInstrumentationName

func WithMeterInstrumentationName(name string) MeterOption

WithMeterInstrumentationName overrides the OTel instrumentation library name used when constructing the Meter from the OTel MeterProvider. Backends index metrics by this name; leave the default unless your server embeds mcpkit inside a larger SDK and you want a more specific identifier.

Empty name reverts to the package default.

type MeterProvider

type MeterProvider struct {
	// contains filtered or unexported fields
}

MeterProvider wraps an OpenTelemetry MeterProvider and exposes it through the dependency-free core.MeterProvider contract. Construct via NewMeterProvider; pair with the OTel SDK side (sdk/metric).

MeterProvider is safe for concurrent use. The internal Meter is created once at construction so subsequent Int64Counter / Float64Histogram / Int64UpDownCounter factory calls never re-look up the Meter on the hot path.

func NewMeterProvider

func NewMeterProvider(otelMP otelmetric.MeterProvider, opts ...MeterOption) *MeterProvider

NewMeterProvider constructs a MeterProvider backed by the given OTel MeterProvider. Panics if otelMP is nil — a wrapper without a backing provider would silently lose measurements, so the check fails fast at wiring time. Matches the NewProvider trace-side contract.

Typical wiring:

import (
    sdkmetric "go.opentelemetry.io/otel/sdk/metric"
    "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
    mcpotel "github.com/panyam/mcpkit/ext/otel"
)

exp, _ := otlpmetricgrpc.New(ctx)
otelMP := sdkmetric.NewMeterProvider(sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exp)))
defer otelMP.Shutdown(ctx)

srv := server.NewServer(info, server.WithMeterProvider(mcpotel.NewMeterProvider(otelMP)))

func (*MeterProvider) Float64Histogram

func (p *MeterProvider) Float64Histogram(name string, opts ...core.InstrumentOption) core.Float64Histogram

Float64Histogram implements core.MeterProvider. See Int64Counter for the construction-failure handling.

func (*MeterProvider) Int64Counter

func (p *MeterProvider) Int64Counter(name string, opts ...core.InstrumentOption) core.Int64Counter

Int64Counter implements core.MeterProvider. Each call constructs a fresh OTel instrument; instrument-creation errors fall back to a no-op counter so a misconfigured Meter never crashes the dispatch install — matches the trace-side defensive contract on Provider. The fallback path logs no warning because OTel instrument creation only fails on duplicate-with-conflicting-config errors that the SDK already loudly surfaces via its own logger.

func (*MeterProvider) Int64UpDownCounter

func (p *MeterProvider) Int64UpDownCounter(name string, opts ...core.InstrumentOption) core.Int64UpDownCounter

Int64UpDownCounter implements core.MeterProvider. See Int64Counter for the construction-failure handling.

func (*MeterProvider) OTelMeterProvider

func (p *MeterProvider) OTelMeterProvider() otelmetric.MeterProvider

OTelMeterProvider returns the underlying OpenTelemetry MeterProvider the wrapper was constructed with. Used by callers that need to hand the same MeterProvider to downstream libraries which take an OTel MeterProvider directly — so the whole process shares one metrics pipeline.

The returned value is the same pointer NewMeterProvider was called with; nil-checks on the caller side are unnecessary (NewMeterProvider panics on nil).

type Option

type Option func(*providerConfig)

Option mutates a providerConfig during NewProvider. The Option type is exported so user-side libraries can layer their own helpers (e.g., reading instrumentation name from a config struct) without depending on the unexported config shape.

func WithInstrumentationName

func WithInstrumentationName(name string) Option

WithInstrumentationName overrides the OTel instrumentation library name used when constructing the Tracer from the OTel TracerProvider. The instrumentation name is what observability backends use to group spans by emitting library — leave the default unless your server embeds mcpkit inside a larger SDK and you want a more specific identifier.

Empty name reverts to the package default.

type Provider

type Provider struct {
	// contains filtered or unexported fields
}

Provider wraps an OpenTelemetry TracerProvider and exposes it through the dependency-free core.TracerProvider contract that mcpkit's dispatch path expects. One Provider per server is the common case; the underlying OTel TracerProvider is the unit of exporter configuration and span batching, so multiple Providers backed by the same OTel TracerProvider share the same exporter pipeline.

Provider is safe for concurrent use. The internal Tracer is created once at construction time so StartSpan never pays the Tracer-lookup cost on the hot path.

func NewProvider

func NewProvider(otelTP oteltrace.TracerProvider, opts ...Option) *Provider

NewProvider constructs a Provider backed by the given OTel TracerProvider. Panics if otelTP is nil — a Provider without a real backing TracerProvider would silently lose spans, so the check fails fast at wiring time.

Typical wiring:

import (
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    "go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
    mcpotel "github.com/panyam/mcpkit/ext/otel"
)

exp, _ := stdouttrace.New()
otelTP := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exp))
defer otelTP.Shutdown(ctx)

srv := server.NewServer(info, server.WithTracerProvider(mcpotel.NewProvider(otelTP)))

func (*Provider) OTelTracerProvider

func (p *Provider) OTelTracerProvider() oteltrace.TracerProvider

OTelTracerProvider returns the underlying OpenTelemetry TracerProvider the Provider was constructed with. Used by callers that need to hand the same TracerProvider to downstream libraries which take an OTel TracerProvider directly — oneauth's keys.WithTracerProvider, future grpc/http instrumentation, etc. — so all instrumentation in the process shares one span pipeline.

The returned value is the same pointer NewProvider was called with; nil-checks on the caller side are unnecessary (NewProvider panics on nil).

func (*Provider) StartSpan

func (p *Provider) StartSpan(ctx context.Context, name string, attrs ...core.Attribute) (context.Context, core.Span)

StartSpan implements core.TracerProvider. The returned context carries BOTH the OTel span context (so any OTel-instrumented library called by the handler sees the new span as parent) AND a mcpkit core.TraceContext updated to reflect the new span (so the SEP-414 P2 outbound _meta injection wraps stamp the wire with the child span's traceparent rather than the inbound parent's).

Inbound trace context resolution:

  • If ctx already carries a non-zero core.TraceContext (attached by the server's traceMiddleware after _meta extraction or the SEP-2028 HTTP header bridge), it is parsed into an OTel SpanContext and installed as the parent via trace.ContextWithSpanContext.
  • Otherwise the OTel SDK's default propagation behavior applies (the span starts a fresh trace).

Attribute mapping: each core.Attribute becomes an attribute.String key. P1's contract scopes attributes to string/string; typed attributes arrive when the core surface widens.

func (*Provider) StartSpanLinked

func (p *Provider) StartSpanLinked(ctx context.Context, name string, links []core.Link, attrs ...core.Attribute) (context.Context, core.Span)

StartSpanLinked implements core.LinkedTracerProvider — the option-at-start variant of StartSpan that attaches one or more causal Links to the new span (in addition to any parent extracted from ctx). Each Link entry whose TraceContext is zero or fails W3C validation is silently dropped (caller does not need to pre-filter), so callers can build the slice from raw inputs of varying validity. Per-link Attributes are mapped through as attribute.String entries on the resulting OTel link.

Implementation: routes the same parent-install + span-start + active-span publish dance as StartSpan, with a WithLinks option appended to the startOpts slice. Empty / nil links behaves identically to StartSpan.

type Span

type Span struct {
	// contains filtered or unexported fields
}

Span implements core.Span by delegating to an underlying go.opentelemetry.io/otel/trace.Span. The wrapper exists for two reasons: to narrow OTel's broader Span surface to the three-method contract mcpkit middleware consumes, and to enforce the "End is exactly-once" invariant from core/trace.go even though OTel's implementations log a noisy warning rather than treating double-End as a contract violation.

Safe for concurrent SetAttribute / RecordError calls (the underlying OTel SDK Span is thread-safe). End uses a CAS to guarantee the underlying End() runs at most once.

func (s *Span) AddLink(link core.Link)

AddLink attaches a causal Link to the span mid-flight. Delegates to the underlying OTel SDK Span.AddLink (available since OTel Go v1.30). No-op after End, matching the contract on every other Span method — the OTel SDK's "AddLink must happen before the span is read" guarantee is satisfied by the ended-guard here PLUS the underlying SDK's own enforcement.

Invalid Link entries (zero or malformed TraceContext) are silently dropped, matching the core.Span.AddLink contract — defensive call sites can build Link slices from raw inputs without pre-filtering.

Per-link Attributes flow through as attribute.String entries on the OTel link, where observability backends render them as link metadata (Jaeger / Tempo / Honeycomb show these specially in their link UI panels, separate from span attributes).

func (*Span) End

func (s *Span) End()

End closes the span. Subsequent calls are no-ops per the core.Span contract — the wrapper short-circuits a second call before reaching the OTel SDK, preventing the SDK's "span already ended" log warning from firing in test loops or unusual handler unwinding paths.

func (*Span) RecordError

func (s *Span) RecordError(err error)

RecordError attaches err to the span via OTel's RecordError (which emits an "exception" event) AND sets the span status to codes.Error. Mirrors the OTel idiom that an unhandled error should both surface as an event and degrade the span's status — observability backends use Status.Code to filter / count error spans.

Nil err is a no-op (matches the core.Span contract).

func (*Span) SetAttribute

func (s *Span) SetAttribute(k, v string)

SetAttribute records a string-valued attribute on the underlying OTel span. No-op after End. Mirrors the core.Span contract; numeric and boolean attributes are outside the P1 surface — callers stringify upstream.

type TracerProviderOption

type TracerProviderOption func(*tracerProviderConfig)

TracerProviderOption mutates the SDK TracerProvider construction performed by NewTracerProvider. Pass any combination of the With... options below; later options override earlier ones when they touch the same config field.

func WithServiceName

func WithServiceName(name string) TracerProviderOption

WithServiceName sets the OpenTelemetry `service.name` Resource attribute on the constructed TracerProvider. Observability backends (Grafana / Tempo / Jaeger / Honeycomb) index spans by this attribute as the primary axis — without it, traces appear under `unknown_service:<binary>`.

An empty name is treated as "leave default" — the caller may pass through a config-file value without branching on whether the field is populated. The SDK's default Resource still applies in that case (which usually means `unknown_service:<binary>` in Grafana, but at least the call site doesn't crash).

func WithSyncer

func WithSyncer() TracerProviderOption

WithSyncer switches the TracerProvider from the default batched span processor to a synchronous one: every span ships immediately on End(), no batch-flush window. Slightly slower per-span at the transport layer (gRPC for OTLP, line write for stdout), but the trade-off is the right one for teaching demos and tests where an operator who kills the process seconds after `tools/call` returns must see their spans in the backend, not wait for a batch flush that never happens.

Real high-throughput servers should stay on the default (WithBatcher) and handle SIGTERM with an explicit ForceFlush + Shutdown sequence.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL