OpenTelemetry FastAPI: instrument your API in 10 minutes

opentelemetry-fastapi

OpenTelemetry FastAPI integration is what you reach for when your FastAPI service stops being a single endpoint and starts being a real piece of distributed infrastructure that you need to understand at runtime. Logs alone don’t tell you why a request took 3 seconds. APM tools tell you the what but not always the why. Distributed traces tied to your existing observability backend tell you both, and OpenTelemetry is the open-standard way to produce those traces from any Python service, FastAPI included.

I instrumented my first FastAPI app with OpenTelemetry last year and made the predictable mistakes: missed the auto-instrumentation library entirely, wrote too many manual spans, picked a backend before understanding what I actually wanted to see. The setup is genuinely fast once you know the shape of it. What follows is the working setup, the parts auto-instrumentation handles for you, the parts you need to do manually, and the export configuration that lights up your existing observability stack.

Quick answer: how to instrument FastAPI with OpenTelemetry

Install opentelemetry-instrumentation-fastapi and opentelemetry-sdk. Call FastAPIInstrumentor.instrument_app(app) after creating your FastAPI app to enable auto-instrumentation of every route. Configure an OTLP exporter pointing at your backend (Jaeger, Tempo, Datadog, Honeycomb, or any OTLP-compatible system). The full minimum setup is about 15 lines of Python. Auto-instrumentation covers HTTP request spans automatically; custom spans are needed for internal business logic you want visible in traces.


What OpenTelemetry adds to FastAPI

A FastAPI service without instrumentation gives you logs and whatever metrics you’ve added manually. That’s enough for debugging a single endpoint in isolation, but it breaks down once requests start moving between services. A user clicks a button, the frontend calls your FastAPI service, which calls another service, which calls a database, and somewhere in that chain something is slow. Logs alone can’t tell you which step.

OpenTelemetry adds three things to fill that gap: traces that connect spans across services, metrics that aggregate behavior over time, and propagation that links related work together with shared trace IDs. The FastAPI integration handles the trace piece automatically, generating a span for every incoming HTTP request and propagating context to downstream services through headers. The metrics and logs pieces are separate but related, exposed through the same SDK.

What this gets you in practice is the ability to ask questions you couldn’t answer before. Which endpoints are slow? Which database queries are dominating a particular trace? Where in a multi-service workflow is the latency actually coming from? OpenTelemetry doesn’t answer those questions on its own – the backend (Jaeger, Tempo, Datadog, or whatever you’re using) does that part – but the FastAPI instrumentation generates the data that makes the answers possible.


Auto-instrumentation setup

The fastest path to working traces is the official auto-instrumentation library. Install it alongside the SDK:

pip install opentelemetry-api opentelemetry-sdk \
            opentelemetry-instrumentation-fastapi \
            opentelemetry-exporter-otlp

The minimal setup adds three things to your FastAPI app: a tracer provider, an exporter, and the instrumentation call:

from fastapi import FastAPI
from opentelemetry import trace
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.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.sdk.resources import Resource

resource = Resource.create({"service.name": "my-fastapi-service"})
provider = TracerProvider(resource=resource)
provider.add_span_processor(
    BatchSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4317"))
)
trace.set_tracer_provider(provider)

app = FastAPI()
FastAPIInstrumentor.instrument_app(app)

@app.get("/items/{item_id}")
async def get_item(item_id: int):
    return {"item_id": item_id}

That’s the whole setup. Every request to /items/{item_id} (and every other route you add) now generates a trace with timing, status code, request path, and a few other attributes attached automatically. The OTLPSpanExporter ships the traces to whatever backend is listening at localhost:4317, which is the default OTLP gRPC port. If your backend uses HTTP transport instead of gRPC, swap the exporter for opentelemetry-exporter-otlp-proto-http and point at the HTTP endpoint.

The service.name resource attribute is the piece worth setting deliberately. It’s the name that appears in your backend’s service list, and getting it consistent across deployments matters for searchability later.


Custom spans for the work that matters

Auto-instrumentation covers the HTTP layer. It doesn’t know anything about what happens inside your route handlers – database queries, external API calls, business logic. For those, you add manual spans where they help you understand the trace.

The pattern is straightforward:

from opentelemetry import trace

tracer = trace.get_tracer(__name__)

@app.get("/items/{item_id}")
async def get_item(item_id: int):
    with tracer.start_as_current_span("fetch_from_db") as span:
        span.set_attribute("item_id", item_id)
        item = await db.fetch_item(item_id)
    
    with tracer.start_as_current_span("enrich_with_metadata"):
        item = enrich(item)
    
    return item

Two custom spans, each visible in your trace as a nested operation under the auto-generated HTTP span. The trace timeline now shows that the database fetch took 80ms and the enrichment step took 12ms, which is the data you needed to figure out where time was actually going.

The discipline that matters is not adding too many spans. Every span adds processing overhead and visual clutter in your backend’s trace UI. The right granularity is “things you’d want to see broken out in a flame graph,” which usually means major operations like database calls, external HTTP requests, and computationally expensive transformations. Wrapping every function in a span is a common new-instrumentor mistake that produces unreadable traces.

Other libraries you’re already using probably have their own auto-instrumentation packages. opentelemetry-instrumentation-sqlalchemy, opentelemetry-instrumentation-requests, opentelemetry-instrumentation-redis, and dozens of others auto-generate spans for the libraries they wrap. Installing the relevant ones gives you database query timings, outbound HTTP call timings, and so on without writing any spans yourself.


Exporting traces to your backend

The exporter configuration depends on where your traces are going. The OTLP exporter shown in the auto-instrumentation example works with any OTLP-compatible backend, which covers most modern observability platforms.

For Jaeger or Tempo (self-hosted), point the OTLP endpoint at your local Jaeger or Tempo OTLP receiver. Both accept OTLP natively in current versions. The exporter configuration is the line OTLPSpanExporter(endpoint="http://jaeger:4317") or whatever hostname your deployment uses.

For Datadog, install Datadog’s OpenTelemetry collector or use the Datadog Agent’s OTLP ingest endpoint. Point your exporter at the agent. Datadog also offers a direct exporter (opentelemetry-exporter-datadog), but the OTLP path is more portable if you might switch backends later.

For Honeycomb, Grafana Cloud, New Relic, or any other OTLP-compatible SaaS, point the OTLP exporter at their ingest endpoint with the appropriate API key in the headers. Each vendor’s docs cover the specifics.

The portability of OTLP is the practical reason most teams settle on it. Switching backends becomes a configuration change rather than a code change, which matters more than it sounds like it would. Vendors that don’t support OTLP are increasingly the minority.


Common OpenTelemetry FastAPI gotchas

A few things trip up first-time instrumentors and show up consistently in real deployments.

Async context propagation is the most common issue. OpenTelemetry uses context vars to track the current span, and if you’re running tasks outside the request context (background tasks, fire-and-forget calls), the trace context can get lost. Use opentelemetry.context.attach explicitly when spawning work outside the request lifecycle, or accept that those operations won’t show up in the parent trace.

The BatchSpanProcessor buffers traces before sending. If your service crashes before the buffer flushes, you lose the in-flight traces. The default flush interval is reasonable for production but problematic when debugging locally; switch to SimpleSpanProcessor for development to see traces immediately, then back to BatchSpanProcessor for production performance.

Sampling decisions made early in the stack can drop your traces. Backend ingest tiers often sample, and the sampling decision is made on the first span. If your trace gets dropped at sampling, no downstream spans appear either. For high-traffic services, set up tail-based sampling in your collector rather than letting the backend’s head-based sampler decide.

Custom span names that include user data will explode your cardinality. A span named "GET /items/123" looks fine in isolation; a million traces with millions of different IDs in the span name will overwhelm your backend. Use route templates ("GET /items/{item_id}") which auto-instrumentation handles correctly, and put the actual ID in span attributes instead.

FAQ

If you’ve instrumented a FastAPI service with OpenTelemetry and run it in production for a while, the writeup on what surprised you (sampling decisions, performance impact, backend-specific issues) is worth more than another quickstart tutorial. The published material in this space is heavy on initial setup and light on what happens after the traces start flowing.

Rohit Shukla

Written by

Rohit Shukla

👋 Hi, I’m Rohit Shukla! I am a full-stack developer with expertise in Angular, Golang, Java, and I am passionate about building scalable applications, backend systems, and APIs. Over 4 the years, I have worked on various projects, improving my skills in modern web technologies, AI and cloud computing.

Leave a Reply

Your email address will not be published. Required fields are marked *