How to Add OpenTelemetry to a FastAPI Service

OpenTelemetry turns a FastAPI service's invisible request lifecycle into a chain of named spans you can inspect in any APM. Auto-instrumentation covers the inbound side; a single context manager adds custom spans for the calls in between.

Backend Engineerpythonfastapiopentelemetry

How OpenTelemetry tracing works in FastAPI

A trace is a tree of spans. For a FastAPI service that makes a downstream HTTP call, the tree looks like:

GET /api/process            <- root span (auto-created by FastAPIInstrumentor)
  downstream_call           <- manual span (tracer.start_as_current_span)
    GET /api/downstream     <- auto-created span in the inner request

All three share the same trace_id. The W3C traceparent header carries that ID across the HTTP boundary, so the inner service picks up the parent context automatically when HTTPXClientInstrumentor is active.

Set up the TracerProvider

For local inspection and testing, use the InMemorySpanExporter - no external collector needed:

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter

memory_exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(memory_exporter))
trace.set_tracer_provider(provider)

tracer = trace.get_tracer("myservice")

Auto-instrument FastAPI and httpx

Two one-liners wire up automatic spans for every request and every outbound HTTP call:

from fastapi import FastAPI
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor

app = FastAPI()
FastAPIInstrumentor.instrument_app(app)   # root span per request
HTTPXClientInstrumentor().instrument()    # injects traceparent into httpx calls

Call both before the app starts handling requests - typically at module level.

Add a custom span around a downstream call

Wrap the downstream HTTP call in start_as_current_span to create a child span, then attach metadata with set_attribute:

import httpx

@app.get("/api/process")
def process():
    with tracer.start_as_current_span("downstream_call") as span:
        url = "http://localhost:8000/api/downstream"
        span.set_attribute("downstream.url", url)
        with httpx.Client() as client:
            r = client.get(url)
    return {"result": "ok", "downstream": r.json()}

Because HTTPXClientInstrumentor is active, the httpx.Client.get() call automatically injects the W3C traceparent header. The downstream handler - also auto-instrumented - reads that header and creates its own span under the same trace ID.

Inspect captured spans

Expose a /_spans endpoint to see what was recorded:

@app.get("/_spans")
def list_spans():
    out = []
    for s in memory_exporter.get_finished_spans():
        ctx = s.get_span_context()
        out.append({
            "name": s.name,
            "trace_id": format(ctx.trace_id, "032x"),
            "span_id": format(ctx.span_id, "016x"),
            "parent_span_id": format(s.parent.span_id, "016x") if s.parent else None,
            "attributes": dict(s.attributes) if s.attributes else {},
        })
    return out

Hit /api/process, then /_spans - you should see the downstream_call span and the two auto-generated request spans, all sharing a trace_id.

Production: swap in an OTLP exporter

For production, replace InMemorySpanExporter with an OTLP exporter pointing at a collector:

from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace.export import BatchSpanProcessor

provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint="http://otel-collector:4317")))

The auto-instrumentation packages cover Postgres, Redis, Kafka, and most popular libraries - so you rarely need manual spans except around your own business logic.

Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.

Try it in a workspace →

What you'll practice

FAQ

How do I add OpenTelemetry tracing to a FastAPI app?

Wire a TracerProvider with a span processor, call trace.set_tracer_provider(provider), then FastAPIInstrumentor.instrument_app(app). That gives you automatic root spans for every request. Add HTTPXClientInstrumentor().instrument() to also auto-instrument outbound httpx calls.

How do I propagate trace context across HTTP calls in Python?

Install opentelemetry-instrumentation-httpx and call HTTPXClientInstrumentor().instrument() once at startup. It automatically injects the W3C traceparent header into every httpx request, so the downstream service creates its span as a child of the caller's span.

What is the difference between auto-instrumentation and a manual span in OpenTelemetry?

Auto-instrumentation (FastAPIInstrumentor, HTTPXClientInstrumentor) adds spans for every request/response without changing your handler code. A manual span - tracer.start_as_current_span('name') - wraps a specific block you care about and lets you attach custom attributes like URLs, user IDs, or query results.

Keep learning

Return the Correct HTTP Status CodeBackend projectRepair Broken API PaginationBackend projectAdd CORS Middleware to an Express APIBackend projectBackend roadmapStep by step to hiredBackend interview questionsSTAR answersAll Backend projectsProjects hub

Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.

Open the workspace →