CubeAPM
CubeAPM CubeAPM

How to Instrument a FastAPI App with OpenTelemetry 

How to Instrument a FastAPI App with OpenTelemetry 

Table of Contents

FastAPI is an ASGI framework built on Starlette, and OpenTelemetry has first-class support for it via the opentelemetry-instrumentation-fastapi package. Instrumentation can be done in two ways: automatically using the opentelemetry-instrument command with zero code changes, or programmatically by calling FastAPIInstrumentor.instrument_app(app) inside your application. Both approaches produce the same spans and metrics. This guide covers both paths end-to-end, including manual span creation, log correlation, and OTLP export to the Collector.

Key Takeaways

  • The latest packages as of May 19, 2026: opentelemetry-api==1.42.0, opentelemetry-sdk==1.42.0, opentelemetry-instrumentation-fastapi==0.63b0. All require Python 3.10 or above. Python 3.12 is recommended since Python 3.10 reaches EOL in October 2026
  • FastAPI instrumentation is built on top of opentelemetry-instrumentation-asgi. Install both explicitly to avoid dependency resolution issues
  • The OTel Python SDK defaults to HTTP/protobuf transport (opentelemetry-exporter-otlp-proto-http), not gRPC. The HTTP exporter is lighter to install since gRPC adds the grpcio dependency. Use HTTP unless your environment specifically requires gRPC
  • Auto-instrumentation via opentelemetry-instrument requires opentelemetry-distro in addition to the instrumentation packages. The distro package provides the opentelemetry-instrument command
  • FastAPIInstrumentor.instrument_app(app) must be called after the FastAPI app is created but before it starts serving requests. Calling it before app = FastAPI() has no effect
  • Use OTEL_PYTHON_FASTAPI_EXCLUDED_URLS to exclude health check and readiness endpoints from tracing to avoid noise in your traces

What Gets Instrumented Automatically

When you instrument a FastAPI app with OpenTelemetry, the following are captured without any additional code:

SignalWhat is captured
TracesOne span per HTTP request: method, route template, status code, exceptions
Metricshttp.server.request.duration (histogram), http.server.active_requests (gauge)
Context propagationIncoming W3C TraceContext headers are read and used to continue parent traces from upstream services

The route template (/items/{item_id}) is used for span names rather than the filled path (/items/42). This is intentional and prevents high-cardinality span names that would make aggregation unusable.

Request and response bodies are not captured by default. Capturing them requires custom middleware or request/response hooks, covered in the manual instrumentation section below.

Step 1: Install the Required Packages

pip install opentelemetry-api==1.42.0 \

            opentelemetry-sdk==1.42.0 \

            opentelemetry-instrumentation-fastapi==0.63b0 \

            opentelemetry-instrumentation-asgi==0.63b0 \

            opentelemetry-exporter-otlp-proto-http==1.41.1

If your environment requires gRPC transport instead of HTTP, replace the last line with:

pip install opentelemetry-exporter-otlp-proto-grpc==1.41.1

For zero-code auto-instrumentation, also install the distro package:
pip install opentelemetry-distro==0.63b0

Pin all packages in requirements.txt to keep builds reproducible:

opentelemetry-api==1.42.0

opentelemetry-sdk==1.42.0

opentelemetry-instrumentation-fastapi==0.63b0

opentelemetry-instrumentation-asgi==0.63b0

opentelemetry-exporter-otlp-proto-http==1.41.1

Step 2A: Programmatic Instrumentation (Recommended)

This approach instruments the app from within your application code. It gives you full control over SDK configuration and works in all deployment environments without changes to your startup command.

Create main.py:

import fastapi

from opentelemetry import trace, metrics

from opentelemetry.sdk.trace import TracerProvider

from opentelemetry.sdk.trace.export import BatchSpanProcessor

from opentelemetry.sdk.metrics import MeterProvider

from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader

from opentelemetry.sdk.resources import Resource

from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter

from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter

from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor

# Define service resource attributes

resource = Resource.create({

    "service.name": "payment-api",

    "service.version": "1.0.0",

    "deployment.environment": "production",

})

# Set up tracing

tracer_provider = TracerProvider(resource=resource)

tracer_provider.add_span_processor(

    BatchSpanProcessor(

        OTLPSpanExporter(endpoint="http://otel-collector:4318/v1/traces")

    )

)

trace.set_tracer_provider(tracer_provider)

# Set up metrics

metric_reader = PeriodicExportingMetricReader(

    OTLPMetricExporter(endpoint="http://otel-collector:4318/v1/metrics"),

    export_interval_millis=60000,

)

meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])

metrics.set_meter_provider(meter_provider)

# Create the FastAPI app

app = fastapi.FastAPI()

# Instrument the app — must be called after app is created

FastAPIInstrumentor.instrument_app(app)

@app.get("/items/{item_id}")

async def get_item(item_id: int):

    return {"item_id": item_id}

@app.get("/health")

async def health():

    return {"status": "ok"}

Note: the HTTP exporter sends to signal-specific paths (/v1/traces, /v1/metrics, /v1/logs) on port 4318. The gRPC exporter sends all signals to port 4317 on a single endpoint.

The BatchSpanProcessor buffers spans and exports them in batches. Do not use SimpleSpanProcessor in production – it exports every span synchronously on the request thread, which adds latency to every response.

Step 2B: Zero-Code Auto-Instrumentation

This approach requires no changes to your application code. It is the right choice when you cannot modify the application, or when you want consistent instrumentation across many services without per-service SDK setup.

Configure via environment variables and run with opentelemetry-instrument:

OTEL_SERVICE_NAME=payment-api \

OTEL_SERVICE_VERSION=1.0.0 \

OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 \

OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \

OTEL_PYTHON_FASTAPI_EXCLUDED_URLS="health,readyz,metrics" \

opentelemetry-instrument uvicorn main:app --host 0.0.0.0 --port 8000

In Docker, set the environment variables in your docker-compose.yml:

services:

  payment-api:

    image: payment-api:latest

    environment:

      OTEL_SERVICE_NAME: payment-api

      OTEL_SERVICE_VERSION: "1.0.0"

      OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318

      OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf

      OTEL_PYTHON_FASTAPI_EXCLUDED_URLS: "health,readyz,metrics"

    command: >

      opentelemetry-instrument

      uvicorn main:app --host 0.0.0.0 --port 8000

In Kubernetes, the OpenTelemetry Operator can inject auto-instrumentation via an annotation, removing the need to change your Dockerfile or command:

apiVersion: apps/v1

kind: Deployment

metadata:

  name: payment-api

spec:

  template:

    metadata:

      annotations:

        instrumentation.opentelemetry.io/inject-python: "true"

The Operator injects the OTel Python agent at pod startup and reads configuration from an Instrumentation CRD in the same namespace.

Step 3: Configure the OTel Collector

The Collector receives OTLP from your FastAPI app and routes telemetry to your backends. Save this as otel-collector-config.yaml:

receivers:

  otlp:

    protocols:

      grpc:

        endpoint: 0.0.0.0:4317

      http:

        endpoint: 0.0.0.0:4318

processors:

  batch:

    timeout: 5s

    send_batch_size: 512

exporters:

  otlp:

    endpoint: your-backend:4317

service:

  pipelines:

    traces:

      receivers: [otlp]

      processors: [batch]

      exporters: [otlp]

    metrics:

      receivers: [otlp]

      processors: [batch]

      exporters: [otlp]

    logs:

      receivers: [otlp]

      processors: [batch]

      exporters: [otlp]

Step 4: Add Manual Spans for Business Logic

Auto-instrumentation captures the HTTP request lifecycle. It cannot capture what happens inside your endpoint handlers. Add manual spans to trace database queries, external API calls, or any business logic that matters for performance debugging.

from opentelemetry import trace

tracer = trace.get_tracer("payment-api", "1.0.0")

@app.post("/payments")

async def create_payment(payment: PaymentRequest):

    with tracer.start_as_current_span("payment.validate") as span:

        span.set_attribute("payment.amount", payment.amount)

        span.set_attribute("payment.currency", payment.currency)

        validate_payment(payment)

    with tracer.start_as_current_span("payment.charge") as span:

        span.set_attribute("payment.provider", "stripe")

        result = await charge_payment(payment)

        span.set_attribute("payment.transaction_id", result.transaction_id)

    return {"transaction_id": result.transaction_id}

Spans created inside an active span automatically become child spans. The trace viewer in your backend will show the full hierarchy: the HTTP request span at the top, with payment.validate and payment.charge nested beneath it.

Recording exceptions on a span:

with tracer.start_as_current_span("payment.charge") as span:

    try:

        result = await charge_payment(payment)

    except PaymentDeclinedError as e:

        span.record_exception(e)

        span.set_status(trace.StatusCode.ERROR, str(e))

        raise

record_exception captures the exception type, message, and stack trace as span events. set_status(ERROR) marks the span as failed, so it appears correctly in error rate dashboards.

Step 5: Add Custom Metrics

The FastAPI instrumentation automatically records http.server.request.duration. Add your own business metrics alongside it:

from opentelemetry import metrics

meter = metrics.get_meter("payment-api", "1.0.0")

payment_counter = meter.create_counter(

    "payments.processed",

    unit="1",

    description="Total number of payments processed",

)

payment_value = meter.create_histogram(

    "payments.value",

    unit="USD",

    description="Distribution of payment values",

)

@app.post("/payments")

async def create_payment(payment: PaymentRequest):

    result = await process_payment(payment)

    payment_counter.add(1, {

        "payment.currency": payment.currency,

        "payment.status": result.status,

    })

    payment_value.record(payment.amount, {

        "payment.currency": payment.currency,

    })

    return result

Step 6: Correlate Logs with Traces

The OTel Python SDK can inject the current trace ID and span ID into your log records automatically, enabling log-to-trace correlation in your backend.

import logging

from opentelemetry._logs import set_logger_provider

from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler

from opentelemetry.sdk._logs.export import BatchLogRecordProcessor

from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter

# Set up log provider alongside tracer and meter provider setup

logger_provider = LoggerProvider(resource=resource)

logger_provider.add_log_record_processor(

    BatchLogRecordProcessor(

        OTLPLogExporter(endpoint="http://otel-collector:4318/v1/logs")

    )

)

set_logger_provider(logger_provider)

# Attach OTel handler to the root logger

handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider)

logging.getLogger().addHandler(handler)

logging.getLogger().setLevel(logging.INFO)

After this setup, every log record emitted during a traced request automatically carries the trace_id and span_id of the active span. In a backend that supports log-trace correlation, you can jump from a slow span directly to the logs from that request.

Excluding URLs from Tracing

Health check, readiness, and liveness endpoints should be excluded to prevent them from appearing in your traces and inflating request count metrics.

Via environment variable (works with both auto and programmatic instrumentation):

OTEL_PYTHON_FASTAPI_EXCLUDED_URLS="health,readyz,livez,metrics"

Via code:

FastAPIInstrumentor.instrument_app(

    app,

    excluded_urls="health,readyz,livez,metrics"

)

The value is a comma-separated list of regex patterns matched against the full request URL path. health matches /health, /healthz, and /api/health.

Common Setup Problems

ProblemLikely causeFix
No spans appearing in the backendSDK not initialized before the app starts handling requests, or OTLP exporter cannot reach the CollectorConfirm trace.set_tracer_provider() is called before app = FastAPI(). Check Collector logs for connection errors on ports 4317/4318
opentelemetry-instrument command not foundopentelemetry-distro not installedRun pip install opentelemetry-distro==0.63b0
Spans appear but have no route attributeopentelemetry-instrumentation-asgi not installed or wrong versionInstall opentelemetry-instrumentation-asgi==0.63b0 explicitly alongside the FastAPI package
Health check endpoints flooding tracesNot excluded from instrumentationSet OTEL_PYTHON_FASTAPI_EXCLUDED_URLS=health,readyz,livez
Manual spans not appearing as children of the request spantracer.start_as_current_span() called outside the request contextEnsure manual spans are created inside the endpoint handler function, not at module level
Logs not correlated with tracesLog provider not configured, or LoggingHandler not attached to the root loggerAdd the log provider setup from Step 6 and confirm the handler is attached before your first log call
Metrics not exportedPeriodicExportingMetricReader export interval too long, or meter provider not set globallyCall metrics.set_meter_provider(meter_provider) after creating it. Default export interval is 60 seconds, so data may not appear immediately

From Traces to Root Cause: Where CubeAPM Fits

OpenTelemetry instrumentation tells you a request was slow. A 1.2-second span on POST /payments is visible in any trace backend. What is harder is answering the next question: is it slow because of the database query inside it, the external API call, or the business logic itself? And is it slow for all users or only for requests with a specific payment currency or amount?

CubeAPM has a dedicated FastAPI instrumentation page (docs.cubeapm.com/instrumentation) and accepts OTLP directly from your existing FastAPI setup without any additional configuration. Because it stores traces, metrics, and logs together using the shared context OpenTelemetry provides, you can start from a slow span, see the breakdown of child spans inside it, filter by any attribute you added (such as payment.currency or payment.amount), and jump to the logs from that exact request. It runs self-hosted inside your own infrastructure at $0.15/GB ingestion pricing, so no data leaves your environment.

Summary

Instrumenting a FastAPI application with OpenTelemetry takes three things: installing the packages, initializing the SDK with a tracer provider and exporter, and calling FastAPIInstrumentor.instrument_app(app). Auto-instrumentation via opentelemetry-instrument is the fastest path when you cannot modify application code. Programmatic instrumentation gives you more control and is easier to configure in production. Add manual spans for business logic and custom metrics for domain-level visibility that automatic instrumentation cannot provide.

StepWhat to doKey detail
Install packagesopentelemetry-api, opentelemetry-sdk, opentelemetry-instrumentation-fastapi, opentelemetry-instrumentation-asgi, opentelemetry-exporter-otlp-proto-httpSDK 1.42.0, instrumentation 0.63b0, exporter 1.41.1. Python 3.12 recommended
Initialize SDKCreate TracerProvider, MeterProvider, set globally before app creationUse BatchSpanProcessor, not SimpleSpanProcessor in production
Instrument appFastAPIInstrumentor.instrument_app(app) after app = FastAPI()Or use opentelemetry-instrument command with opentelemetry-distro installed
Export to CollectorHTTP exporter pointing at port 4318. gRPC exporter points at port 4317HTTP is the recommended default. gRPC adds a grpcio dependency
Add manual spanstracer.start_as_current_span(“name”) inside endpoint handlersCall span.record_exception() and span.set_status(ERROR) on failure
Add custom metricsmeter.create_counter() or meter.create_histogram()Attach business-relevant attributes as labels
Correlate logsAdd LoggingHandler to root loggerInjects trace ID and span ID into every log record automatically
Exclude noisy URLsOTEL_PYTHON_FASTAPI_EXCLUDED_URLS=health,readyz,livezComma-separated regex patterns matched against the URL path

Disclaimer: Package versions and API signatures are verified against PyPI (opentelemetry-api 1.42.0 and opentelemetry-sdk 1.42.0 released May 19, 2026; opentelemetry-instrumentation-fastapi 0.63b0 released May 19, 2026; opentelemetry-exporter-otlp-proto-http 1.41.1 released April 24, 2026), the OpenTelemetry Python Contrib documentation (opentelemetry-python-contrib.readthedocs.io), and CubeAPM instrumentation documentation (docs.cubeapm.com/instrumentation) as of May 2026.

Also read:

What is the Difference Between OpenTelemetry and Prometheus?

What Is OpenTelemetry and How Does It Work? 

What RabbitMQ Monitoring Tools Work with Prometheus and Grafana? 

×
×