FastAPI applications don’t inherently know how to tell other services they’re talking to that they’re part of the same request.
Let’s see OpenTelemetry in action with a simple FastAPI app and a client making requests to it.
First, our FastAPI app:
from fastapi import FastAPI
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
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
import uvicorn
# Initialize TracerProvider
resource = Resource(attributes={"service.name": "my-fastapi-service"})
tracer_provider = TracerProvider(resource=resource)
# Configure exporter and processor
span_processor = BatchSpanProcessor(
OTLPSpanExporter(endpoint="http://localhost:4317", insecure=True)
)
tracer_provider.add_span_processor(span_processor)
trace.set_tracer_provider(tracer_provider)
# Instrument FastAPI
app = FastAPI()
FastAPIInstrumentor().instrument_app(app=app)
@app.get("/")
async def read_root():
return {"Hello": "World"}
@app.get("/items/{item_id}")
async def read_item(item_id: int, q: str | None = None):
return {"item_id": item_id, "q": q}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
And a client using httpx to call it:
import httpx
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
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.httpx import HTTPXClient
# Initialize TracerProvider for the client
resource = Resource(attributes={"service.name": "my-client-service"})
tracer_provider = TracerProvider(resource=resource)
# Configure exporter and processor
span_processor = BatchSpanProcessor(
OTLPSpanExporter(endpoint="http://localhost:4317", insecure=True)
)
tracer_provider.add_span_processor(span_processor)
trace.set_tracer_provider(tracer_provider)
# Instrument httpx client
client = httpx.Client()
HTTPXClient().instrument_client(client=client)
try:
response = client.get("http://localhost:8000/")
response.raise_for_status()
print(f"Root response: {response.json()}")
response = client.get("http://localhost:8000/items/5?q=somequery")
response.raise_for_status()
print(f"Items response: {response.json()}")
finally:
client.close()
To run this, you’ll need a tracing backend like Jaeger or Tempo listening on http://localhost:4317 (the default OTLP gRPC port). Start it first, then run the client script. You should see traces appear in your tracing UI, showing requests originating from the client, hitting the FastAPI service, and detailing the internal workings of the FastAPI route handlers.
The core problem OpenTelemetry solves is the inability of distributed systems to correlate requests across service boundaries. Without it, a single user request that traverses multiple microservices becomes a set of isolated, unlinked events. Each service logs its own activity, but there’s no inherent mechanism to say "this log entry in service A is a direct consequence of this log entry in service B." OpenTelemetry provides a standardized way to propagate context (like trace IDs and span IDs) through network calls, allowing tracing backends to stitch together the complete journey of a request.
The FastAPIInstrumentor and HTTPXClient classes are the magic here. When instrument_app or instrument_client is called, they wrap the respective libraries. For FastAPI, this means it automatically creates a span for each incoming request, extracting relevant information like the HTTP method, path, and status code. Crucially, it also looks for incoming trace context headers (like traceparent) and continues an existing trace if found, or starts a new one if not. When the httpx client makes a request, HTTPXClient intercepts it, creates a child span, injects the current trace context into the outgoing request headers, and then sends the request. This propagation is what links the spans together.
The TracerProvider is the central registry for all tracing SDK components. It’s responsible for creating Tracer instances, which are used to create spans. The Resource defines attributes for the entire service, like its name, which helps in filtering and grouping traces in the backend. The OTLPSpanExporter is configured to send spans to a specific endpoint using the OpenTelemetry Protocol (OTLP). The BatchSpanProcessor buffers spans before sending them, which is more efficient than sending each span individually, especially under high load.
The key to understanding how this works is the concept of the "current span" and context propagation. When a request enters the instrumented FastAPI app, a span is created. If this request was initiated by another instrumented service (like our httpx client), the trace context is injected into the HTTP headers. The FastAPIInstrumentor reads these headers, establishes the parent-child relationship between the incoming span and the new request’s span, and makes the new span the "current" span within that request’s context. Subsequent operations within that request that also use OpenTelemetry will automatically create child spans of this "current" span.
One crucial detail often overlooked is how the traceparent header is handled. OpenTelemetry uses the W3C Trace Context standard. This header contains the trace ID, span ID of the parent, and sampling decision. When HTTPXClient makes a call, it injects this header with the current trace’s ID and its own span ID as the parent. When the FastAPI app receives it, FastAPIInstrumentor extracts this header, using the trace ID to continue the existing trace and the span ID to correctly link the new server-side span as a child of the client-side span. If no traceparent header is present on an incoming request, a new trace is started.
The next step is to instrument outgoing calls made from your FastAPI service to other services, ensuring end-to-end visibility.