Instrumenting Web Frameworks with OpenTelemetry

A service emits spans, the exporter is healthy, and yet the traces are useless. Every operation is named HTTP GET, so the latency histogram lumps a cheap health check together with a slow report export. The request arrives carrying a traceparent header from the upstream gateway, but the handler’s span shows up as a brand-new root — the trace fractures at your front door. Meanwhile the operations index in the backend has ballooned to hundreds of thousands of distinct operation names because each request path contains an order ID. These are not exotic failures. They are the default outcome of pointing an instrumentation package at a web framework without understanding what the server span is supposed to capture.

This guide builds the mental model that every HTTP framework instrumentation shares — regardless of language — then shows the concrete differences between FastAPI, Express, Spring Boot, and gRPC. Get the model right once and the per-framework guides become mechanical.

Prerequisites

  • A configured OpenTelemetry SDK: a TracerProvider, a resource with service.name, and a working OTLP exporter. This guide assumes spans already reach a backend.
  • OpenTelemetry SDK 1.20+ (Python), @opentelemetry/sdk-node 0.45+ (Node.js), or OpenTelemetry Java agent 1.30+.
  • A reachable Jaeger or Tempo backend, or an OpenTelemetry Collector accepting OTLP.
  • Familiarity with the span lifecycle and parent-child relationships, since the server span is the parent that every downstream span in the request inherits from.
  • A decision framework for auto vs manual instrumentation — this guide extends it to the framework entry point specifically.

The Server Span: What Framework Instrumentation Actually Does

Framework instrumentation exists to create and manage one specific object: the server span. This is the span with span.kind = SERVER that represents the handling of a single inbound request from the moment the framework begins dispatch to the moment the response is committed. Everything the request touches downstream — a database query, an outbound HTTP call, a manual business span — nests underneath it because the server span is written into the active context slot for the duration of the handler.

A framework instrumentation package performs four jobs, and every one of them is a job you would otherwise have to do by hand:

  1. Hook the dispatch boundary. It intercepts the framework at the layer where a request has been parsed but the handler has not yet run — an ASGI/WSGI callable in Python, a middleware in Express, a Filter or HandlerInterceptor in Spring, a ServerInterceptor in gRPC. This is where the server span starts and where it ends after the response.

  2. Extract inbound context. It reads the incoming carrier — HTTP headers, gRPC metadata — through the configured propagator and uses any W3C TraceContext traceparent to make the server span a child of the caller’s span. Without this step every service starts its own disconnected trace and cross-service correlation collapses.

  3. Name the span by route, not path. It sets the span name and the http.route attribute from the matched route templateGET /orders/{id} — not the concrete path GET /orders/8a3f2c. This single decision is what keeps operation cardinality bounded and makes aggregation possible.

  4. Record the outcome. It maps the response status code to span status, tags http.response.status_code, and records unhandled exceptions as span events, setting ERROR status on server errors.

The instrumentation for the HTTP client is a separate concern with the mirror-image job: it injects the traceparent header into outbound requests and creates CLIENT spans. A fully instrumented service runs both — a server instrumentation for what comes in, a client instrumentation for what goes out — and the propagator is the shared machinery that connects the two across the wire.

Why route-based naming is non-negotiable

Operation name is a primary index dimension in every trace backend. If the span name embeds a high-cardinality value, three things break at once: the backend’s index bloats and query latency climbs, latency aggregation becomes meaningless because no two requests share an operation, and tail-based sampling policies that key on operation name stop matching. The route template is the natural low-cardinality grouping — one name per handler, dozens or hundreds total, no matter how many distinct IDs flow through. Semantic conventions reserve http.route for exactly this value, and well-behaved instrumentation sets both the span name and that attribute from the router’s matched pattern.

The diagram below shows the shape every framework instrumentation produces: one inbound request, one server span at the root of the service’s local trace, and child spans for the work the handler performs.

Framework instrumentation: inbound request to server span to child spans An inbound HTTP request carrying a traceparent header enters the framework dispatch layer. The instrumentation extracts the context and creates a SERVER span named by route template. Inside the handler, a manual business span and two auto client spans (database and outbound HTTP) nest as children, and the outbound call injects a traceparent into the next service. Inbound HTTP request header: traceparent 00-abc…-01 dispatch layer extract + start span SERVER span — GET /orders/{id} http.route set from matched template · written to context slot manual: validate_order child via active context CLIENT: db SELECT auto driver hook CLIENT: POST /charge injects traceparent next service continues same trace

Framework Comparison

The mental model is identical; the seams differ. The table below maps the four frameworks this section covers to their instrumentation package, whether the server span is created automatically, the concurrency model that governs context propagation, and the attribute that carries the route template.

Framework Instrumentation package Auto server span Concurrency / context model Route attribute source
FastAPI (Python) opentelemetry-instrumentation-fastapi Yes — ASGI middleware asyncio event loop; context via contextvars Starlette route path_formathttp.route
Express (Node.js) @opentelemetry/instrumentation-express + -http Yes — layer + HTTP hooks Single-threaded loop; AsyncLocalStorage Matched router layer path → http.route
Spring Boot (Java) OpenTelemetry Java agent (Spring WebMVC/WebFlux) Yes — servlet filter / WebFilter Thread-per-request (MVC) or Reactor (WebFlux) @RequestMapping template → http.route
gRPC (multi-language) opentelemetry-instrumentation-grpc / interceptors Yes — server interceptor Language-dependent; per-call context Full method package.Service/Methodrpc.method

Two structural differences are worth internalising. First, Python and Node ship instrumentation as a library you register in code or via a launcher, whereas Java ships a bytecode agent attached with -javaagent that patches classes at load time — no code change at all. Second, gRPC is not HTTP semantics: there is no route path, so the operation is the fully-qualified RPC method and the relevant attributes are rpc.system, rpc.service, and rpc.method rather than http.route. The propagation carrier is gRPC metadata rather than HTTP headers, though the traceparent key and W3C format are the same.

Step-by-Step Implementation

The following steps are framework-agnostic. Each per-framework guide linked at the end substitutes the concrete package and hook, but the sequence never changes.

Step 1: Install the SDK and the framework instrumentation

Install the base SDK, the OTLP exporter, the framework server instrumentation, and — separately — the HTTP client instrumentation for outbound calls. The generic Python shape:

pip install opentelemetry-sdk \
            opentelemetry-exporter-otlp-proto-grpc \
            opentelemetry-instrumentation-<framework> \
            opentelemetry-instrumentation-httpx

The framework package owns the server span; the client package owns outbound propagation. Installing only one leaves half the trace missing.

Step 2: Register instrumentation before the application loads

Instrumentation works by patching or wrapping framework internals. That patch must be applied before the framework module is imported and the app object is constructed, otherwise the un-patched originals are already bound.

# tracing.py — imported or launched before the app module
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry import trace

provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)
# The framework instrumentor is applied here, before `app` is created.

For agent-based runtimes (Java, or Python’s opentelemetry-instrument launcher) this ordering is guaranteed by the launcher; for in-code registration it is your responsibility.

Step 3: Configure the propagator to extract inbound context

Set the propagator explicitly and identically across every service, so the server span continues the caller’s trace instead of starting a new one.

export OTEL_PROPAGATORS=tracecontext,baggage

With this set, the instrumentation reads the inbound traceparent, reconstructs the remote span context, and parents the server span to it. Mismatched propagators between two services are the single most common cause of a trace that silently splits at a service boundary.

Step 4: Confirm route-template naming

Verify the instrumentation names spans from the route template, not the raw path. Most packages do this by default when they can access the router’s matched pattern, but the pattern is only available after routing resolves — instrumentation registered at the wrong layer sees only the raw path. If your spans are named GET /orders/8a3f, the fix is layer placement, covered per framework in the child guides.

Step 5: Capture status and exceptions

Confirm that server errors set span status to ERROR and that unhandled exceptions are recorded. Good instrumentation does this automatically: a 5xx response or an exception propagating out of the handler marks the span. Business-level failures that still return a 200 — a payment declined, a validation soft-fail — are yours to record with a manual attribute or span event inside the handler.

Step 6: Verify end to end

Send a request and inspect the trace, as in the next section.

Verification

Issue a request against an instrumented route and query the backend for the resulting trace.

curl -s -H "traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" \
     http://localhost:8000/orders/42

Then confirm the server span in Jaeger:

curl -s "http://localhost:16686/api/traces?service=orders-api&limit=1" \
  | jq '.data[0].spans[] | {op: .operationName, kind: (.tags[]|select(.key=="span.kind").value), route: (.tags[]|select(.key=="http.route").value)}'

A correct result shows exactly one span with span.kind = server, an operation name equal to the route template (GET /orders/{id}, not the literal /orders/42), and — because the request carried a traceparent — a traceID of 4bf92f3577b34da6a3ce929d0e0e4736 inherited from the upstream caller rather than a freshly generated one. Child client spans for any database or outbound calls should share that trace ID.

Edge Cases & Gotchas

  1. Double instrumentation. Running an auto-instrumentation agent and calling the in-code instrumentor, or enabling both a generic HTTP instrumentation and the framework package as server-span producers, yields two nested SERVER spans per request. Pick one server-span owner. The HTTP instrumentation should be scoped to client spans only.

  2. High-cardinality span names. When the route template is unavailable at the instrumentation’s hook point, packages fall back to the raw path and cardinality explodes. Ensure the instrumentation runs after route resolution, and never manually override the span name with a value containing an ID, email, or timestamp.

  3. Middleware / interceptor ordering. The tracing hook must sit at the outermost layer so it reads raw inbound headers before authentication, rate limiting, or body-parsing middleware run — and so its span wraps their execution time. Registered too far inside, it both misses the inbound traceparent and under-reports latency.

  4. Health checks and readiness probes. Kubernetes liveness/readiness traffic can dominate span volume. Exclude these paths at the instrumentation layer (for example OTEL_PYTHON_EXCLUDED_URLS=healthz,readyz) rather than sampling them away downstream, to avoid paying the ingest cost at all.

  5. Streaming and long-lived connections. WebSocket upgrades and Server-Sent Event responses do not fit the request/response server-span shape. The server span may stay open for the connection’s lifetime or close at upgrade; decide deliberately and instrument message handling with manual spans.

  6. Async and threaded boundaries inside the handler. The server span lives in the active context slot, but crossing into a thread pool or background task drops it. This is a general problem covered in handling async boundaries; framework instrumentation gets you the server span, not context survival through every internal hop.

Performance & Scale Notes

Framework instrumentation adds two costs. The first is a one-time patching cost at startup — module wrapping in Python/Node, bytecode transformation in Java — that lengthens cold start by tens to a few hundred milliseconds but costs nothing per request thereafter. The second is a per-request cost of starting and ending the server span, extracting the propagation header, and setting attributes: typically 5–20 µs, negligible until you exceed tens of thousands of requests per second per instance.

At scale the bottleneck is never the server span itself but the export pipeline behind it. Route-based naming directly reduces backend cost by collapsing cardinality, so it is a performance optimisation as much as a correctness one. Apply head-based sampling at the TracerProvider so the sampling decision is made once, at the server span, and inherited consistently by every child — sampling per-child produces incomplete traces. For very high-throughput services, exclude probe paths before they generate spans and route exports through a local OpenTelemetry Collector to keep the flush path off the request thread.

Troubleshooting FAQ

Why do I see two server spans for every request?

The framework is instrumented twice — usually a generic HTTP-layer instrumentation and the framework-specific package both create a server span, or the app is started with both an auto-instrumentation agent and an in-code instrumentor call. Enable one entry-point instrumentation and let the framework package own the server span; the HTTP client instrumentation should only produce client spans for outbound calls.

Why is span cardinality exploding with thousands of unique operation names?

Spans are being named from the raw request path — /orders/8a3f, /orders/9b21 — instead of the route template /orders/{id}. Configure the instrumentation to use the matched route so all requests to one handler collapse into a single operation name. Path-based naming defeats aggregation and inflates the storage backend’s index.

The inbound traceparent header is present but the server span still starts a new trace — why?

The configured propagator does not include tracecontext, or middleware ordering places the instrumentation after a component that consumes and drops the header. Set OTEL_PROPAGATORS=tracecontext,baggage on every service and register the tracing middleware at the outermost layer so it reads the raw inbound headers before anything else touches them.

Should I use auto-instrumentation or write the server span by hand?

Use the framework’s official instrumentation package for the server span in almost all cases — it handles route templating, status mapping, and header extraction correctly. Reserve manual spans for business operations inside the handler that auto-instrumentation cannot see. Hand-writing the entry span is only justified for frameworks with no maintained instrumentation, and is covered in auto vs manual instrumentation.


↑ Back to SDK Implementation & Context Propagation