A person wearing headphones points at a mission-control console with a radar graph, gauges labeled Latency, a Traces and Logs panel, a Python logo, and a green Status OK screen.

How to Integrate OpenTelemetry With a FastAPI App

by Yuka Tamura Updated Reading time estimate 19m intermediate devops web-dev

Adding observability to your applications is crucial for diagnosing issues in production. In this tutorial, you’ll learn how to integrate OpenTelemetry—an open-source observability framework—into a FastAPI application. After following along, you’ll be able to instrument FastAPI with OpenTelemetry to export traces and correlate them with your application logs, allowing you to explore your telemetry in a dedicated dashboard:

Jaeger UI showing a FastAPI request trace with a custom span and correlated logs
A Fully Traced FastAPI Request in the Jaeger UI

The image above previews the Jaeger UI that you’ll build toward over the course of this tutorial.

As your application grows, tracking down bugs across different services can become challenging. By adding observability, you gain clear insights into how your application performs and exactly where errors happen.

To achieve this, you’ll use OpenTelemetry, often called OTel. This vendor-neutral framework provides a standard way to collect telemetry data from your applications. It supports multiple programming languages, including Python, and exports data to various backends like Jaeger, Prometheus, and Grafana.

For this tutorial, you’ll use Jaeger, a popular open-source backend, to visualize your data. Together, OpenTelemetry and Jaeger let you analyze request paths, troubleshoot errors, and understand system bottlenecks.

Prerequisites

To follow along with this tutorial, you’ll need a couple of tools on your system:

  • Python 3.10 or higher
  • Docker to run the Jaeger backend container

Even if you’re new to Docker, you’ll only run a single command to start a ready-made container, and this tutorial walks you through it. If you’d like to go deeper, then you can explore the Docker tutorials later.

You should also be comfortable with a basic FastAPI app. If you need a refresher on the framework, then check out the introductory guide to get started with FastAPI. If you prefer learning by video, Real Python’s Start Building With FastAPI course covers the same essentials.

You can download the complete source code for the examples in this tutorial to use as a reference:

Take the Quiz: Test your knowledge with our interactive “How to Integrate OpenTelemetry With a FastAPI App” quiz. You’ll receive a score upon completion to help you track your learning progress:


Interactive Quiz

How to Integrate OpenTelemetry With a FastAPI App

Instrument a FastAPI app with OpenTelemetry to export traces, add custom spans, and correlate your application logs with traces.

Step 1: Build Your FastAPI OpenTelemetry Pipeline

To begin, you need a backend to receive and display your traces. Jaeger’s all-in-one Docker image bundles everything into a single container for local development.

Open your terminal and start the Jaeger container using Docker:

Language: Shell
$ docker run -d --name jaeger \
    -p 16686:16686 \
    -p 4317:4317 \
    -p 4318:4318 \
    jaegertracing/all-in-one:latest

This command launches Jaeger in the background. The port mappings each serve a distinct purpose. Port 16686 exposes the web UI that you’ll use to explore your traces, while ports 4317 and 4318 accept incoming telemetry via the OpenTelemetry Protocol (OTLP) over gRPC and HTTP, respectively. Keeping these apart matters later because your application will send data to an OTLP port, and you’ll view the results on the UI port.

Next, you’ll install the FastAPI framework along with the OpenTelemetry libraries. Before you do, create and activate a virtual environment so that the packages don’t end up in your system Python:

Language: Windows PowerShell
PS> python -m venv venv
PS> .\venv\Scripts\Activate.ps1
Language: Shell
$ python -m venv venv
$ source venv/bin/activate

With your virtual environment active, install the packages. You’ll need the core OpenTelemetry distribution, the specific FastAPI instrumentation package, and the OTLP exporter to send your data to Jaeger:

Language: Shell
(venv) $ python -m pip install fastapi uvicorn \
         opentelemetry-distro \
         opentelemetry-instrumentation-fastapi \
         opentelemetry-exporter-otlp

Now that the tools are installed, you can build your application. Create a file named main.py and add the following code:

Language: Python Filename: main.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def read_root():
    return {"message": "Hello, OpenTelemetry!"}

Notice that this is a plain FastAPI application. There’s no tracing code in it at all, and that’s the point: you’ll instrument it from the outside without touching your source code.

To make that work, three OpenTelemetry components need to come together:

  • Instrumentation: The opentelemetry-instrumentation-fastapi package you installed hooks into FastAPI’s request handling and generates telemetry data for each incoming HTTP request.
  • SDK: The OpenTelemetry SDK receives that data, records it, and batches it for delivery.
  • Exporter: The OTLP exporter formats the recorded data and sends it to a backend like Jaeger.

Here’s how these components connect and where your data flows:

Diagram showing telemetry flowing from a FastAPI app through OpenTelemetry instrumentation, SDK, and exporter into Jaeger
How Telemetry Flows From Your FastAPI App Into Jaeger

Everything in the top box runs inside your Python process, wired up by the opentelemetry-instrument tool. Only the OTLP data crosses the process boundary. It travels down to the Jaeger container, where you can explore it in the web UI.

Wiring these components up by hand requires a fair amount of boilerplate setup code. Instead, you can use the opentelemetry-instrument CLI tool, which reads your environment variables and configures the whole pipeline automatically. This keeps your application code focused on business logic.

Wrap your standard Uvicorn startup command with the CLI tool and pass the necessary environment variables to configure your exporter:

Language: Shell
$ OTEL_SERVICE_NAME="fastapi-otel-demo" \
  OTEL_METRICS_EXPORTER="none" \
  OTEL_TRACES_EXPORTER="otlp" \
  OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317" \
  OTEL_EXPORTER_OTLP_INSECURE="true" \
  opentelemetry-instrument python -m uvicorn main:app --reload

Here’s what each environment variable does:

  • OTEL_SERVICE_NAME: Labels your telemetry so that you can tell this application apart from other services in the Jaeger UI.
  • OTEL_METRICS_EXPORTER: Disables metrics, which are numerical measurements like request counts or memory usage. OpenTelemetry collects them alongside traces by default, but Jaeger only accepts traces, so the metrics pipeline would repeatedly fail and fill your terminal with export errors.
  • OTEL_TRACES_EXPORTER: Selects the OTLP exporter for traces.
  • OTEL_EXPORTER_OTLP_ENDPOINT: Points the exporter at port 4317, which is the gRPC port that you exposed when starting the Jaeger container. The Python exporter uses gRPC by default, so you target 4317 rather than the HTTP port 4318.
  • OTEL_EXPORTER_OTLP_INSECURE: Allows an unencrypted local connection, which is fine for development but not something you’d use in production.

Together, these variables tell opentelemetry-instrument what to collect, how to export it, and where to send it. The --reload flag automatically restarts the server whenever you change a source file, so you won’t need to stop and start it manually after every edit.

With the server running, open your browser and go to http://localhost:8000/. You’ll see the successful JSON response: {"message": "Hello, OpenTelemetry!"}.

Now, open the Jaeger UI at http://localhost:16686. Under the Service dropdown, select fastapi-otel-demo and click Find Traces. You’ll see that OpenTelemetry has automatically captured your HTTP request without you writing any tracing code.

While auto-instrumentation gives you a great overview of your HTTP requests, it reveals little about your specific application logic. You’ll fix that next.

Step 2: Add Custom Spans for Business Logic

In OpenTelemetry, a trace tracks the complete journey of a request as it flows through your system, while a span represents a single unit of work within that trace. Auto-instrumentation creates spans for your overall HTTP request lifecycles.

However, auto-instrumentation treats your application endpoints like black boxes. If an endpoint takes three seconds to return a response, then auto-instrumentation will show you the total time, but it won’t tell you why it took so long. Was it a slow database query, a lagging external API, or heavy CPU processing?

To get these answers, you need to manually create custom spans. Custom spans allow you to wrap specific pieces of your internal business logic and measure their exact performance.

More importantly, they let you attach custom attributes to the telemetry data. By tagging spans with business-specific context, such as a user ID or a database name, you can quickly filter and search through thousands of traces. This helps you find exactly what you need when debugging a production issue.

Update your main.py to add a simulated database query and a FastAPI endpoint:

Language: Python Filename: main.py
from fastapi import FastAPI
from opentelemetry import trace

app = FastAPI()
tracer = trace.get_tracer(__name__)

def simulate_db_query(user_id: int):
    with tracer.start_as_current_span("db_query") as span:
        span.set_attribute("db.system", "postgresql")
        span.set_attribute("user.id", user_id)

        if user_id == 999:
            raise ValueError("Database connection lost!")

        return {"name": "Demo User", "role": "admin"}

@app.get("/users/{user_id}")
def get_user(user_id: int):
    try:
        data = simulate_db_query(user_id)
        return {"status": "success", "data": data}
    except ValueError:
        return {"status": "error", "message": "Failed to fetch user"}

@app.get("/")
# ...

Here, you call trace.get_tracer(__name__) to get a tracer instance, which serves as your entry point for creating spans. Passing __name__ tags every span that this tracer creates with the name of the current module. When you later browse traces that stretch across dozens of files, this tells you exactly which part of your codebase produced each span.

You then use start_as_current_span() to track the simulate_db_query() function. You also use set_attribute() to attach custom attributes to the span, such as the database system and the specific user ID. Capturing these details lets you narrow down traces in the backend later.

Notice the attribute name db.system. This isn’t an arbitrary label. It comes from OpenTelemetry’s semantic conventions, which define standard attribute names for common operations like database queries and HTTP calls.

For purely business-specific data like user.id, inventing your own attribute names is perfectly fine. For well-known concepts, though, you should stick to the conventions. Observability backends recognize these standard names and can build filters and visualizations on top of them automatically. In short, following the conventions keeps your telemetry portable across tools.

Save your changes (the --reload flag picks them up automatically) and visit http://localhost:8000/users/1. Back in the Jaeger UI, open the newest trace and expand it—you’ll find the db_query span nested inside the request span, along with the db.system and user.id attributes that you just set.

Crucially, you simulate an error condition for user 999. When the exception propagates out of the with block, start_as_current_span() automatically records it on the span with its full stack trace and sets the span status to error. This detailed context is invaluable when you’re tracking down a failure in a live system.

The waterfall below shows both requests the way Jaeger renders them. Switch between the healthy call and the failing one for user 999 to watch the db_query span nest inside the request span and turn red when the exception is recorded:

Interactive diagram — enable JavaScript to view.

What if you handle an exception instead of letting it escape? Expand the collapsible section below to learn how to record it manually:

Automatic exception recording only works when the exception propagates out of the with block, where the context manager can see it. If you catch and handle it inside the span instead, then OpenTelemetry never sees it, and it’s up to you to record the exception yourself:

Language: Python
def simulate_db_query(user_id: int):
    with tracer.start_as_current_span("db_query") as span:
        try:
            row = fetch_user_row(user_id)
        except ConnectionError as exc:
            span.record_exception(exc)
            span.set_status(trace.Status(trace.StatusCode.ERROR))
            return {"name": "Unknown User", "role": "guest"}
        return row

In this variation, the endpoint returns fallback data instead of failing the request. You call .record_exception() with the caught exception and mark the span as failed with .set_status(). Make sure that you pass the exception you actually caught, because a freshly created exception carries no real stack trace.

You won’t need this manual approach in the rest of this tutorial, but it’s a common pattern whenever you handle errors gracefully while keeping visibility into them.

Step 3: Correlate Logs With Traces

To get a complete observability picture, you need to correlate your standard Python logs with your OpenTelemetry traces. You can achieve this by extracting both the trace ID and the span ID from the current span context.

Update your main.py to configure structured logging and inject the trace ID and span ID:

Language: Python Filename: main.py
import logging

from fastapi import FastAPI
from opentelemetry import trace

app = FastAPI()
tracer = trace.get_tracer(__name__)

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

def simulate_db_query(user_id: int):
    with tracer.start_as_current_span("db_query") as span:
        span.set_attribute("db.system", "postgresql")
        span.set_attribute("user.id", user_id)

        trace_id = format(span.get_span_context().trace_id, "032x")
        span_id = format(span.get_span_context().span_id, "016x")

        if user_id == 999:
            logger.error(
                "DB query failed for user %s. trace_id=%s, span_id=%s",
                user_id,
                trace_id,
                span_id,
            )
            raise ValueError("Database connection lost!")

        logger.info(
            "Executing DB query for user %s. trace_id=%s, span_id=%s",
            user_id,
            trace_id,
            span_id,
        )
        return {"name": "Demo User", "role": "admin"}

@app.get("/users/{user_id}")
def get_user(user_id: int):
    logger.info("Received request for user %s", user_id)
    # ...

You extract the current trace_id and span_id from the span context. Notice that you use the built-in format() function with the format specifiers "032x" and "016x". These convert the internal integer IDs into a 32-character and a 16-character lowercase hexadecimal string, respectively.

Why this specific format? OpenTelemetry adheres to the W3C Trace Context specification. This is an open standard that defines how trace context should be formatted and propagated between services. By formatting trace IDs as 32-character hex strings and span IDs as 16-character hex strings, you ensure that your logs speak a universal language.

Whether you’re exporting data to Jaeger or any other observability backend, formatting your IDs to this W3C standard guarantees that the backend can instantly recognize and stitch your logs and traces together.

You then explicitly inject both identifiers into your log messages. Now, every log message generated during a request can be cross-referenced and attached to its exact span in your Jaeger UI.

To see this log correlation and exception tracking in action, open your browser and go to http://localhost:8000/users/999 to trigger the simulated error. The browser shows the fallback response: {"status": "error", "message": "Failed to fetch user"}.

Then, check your server’s terminal output. You’ll see how the request unfolded:

Terminal output showing INFO logs followed by an ERROR log containing trace_id and span_id values
The Error Log Carries Both the Trace ID and Span ID

The info logs confirm that the request arrived and completed with a 200 OK response, because get_user() caught the exception and served a friendly error payload. The line that matters most for correlation is the error log:

Language: Text
ERROR:main:DB query failed for user 999. trace_id=xx..., span_id=xx...

This single line carries both IDs that you need. Copy the trace_id from your terminal and paste it into the search bar in your Jaeger UI. You’ll find the exact trace. The span_id from your log tells you which span within that trace emitted the message. Expand the trace to inspect the failing span:

Jaeger UI showing the db_query span with a recorded exception and stack trace
The Failing db_query Span With Its Recorded Exception

This seamless correlation between terminal logs and trace visualization is exactly what makes debugging in production so powerful.

Step 4: Configure Sampling to Manage Overhead

In a production environment, recording 100 percent of your traces can introduce significant CPU and network overhead. To prevent performance bottlenecks, you should configure a sampling strategy.

You can enable probability sampling without changing a single line of your Python code. Since environment variables are only read once at startup, the --reload flag won’t pick up this change. Stop your server manually and restart it with the sampler variables set:

Language: Shell
$ OTEL_SERVICE_NAME="fastapi-otel-demo" \
  OTEL_METRICS_EXPORTER="none" \
  OTEL_TRACES_EXPORTER="otlp" \
  OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317" \
  OTEL_EXPORTER_OTLP_INSECURE="true" \
  OTEL_TRACES_SAMPLER="traceidratio" \
  OTEL_TRACES_SAMPLER_ARG="0.1" \
  opentelemetry-instrument python -m uvicorn main:app --reload

By adding traceidratio as your sampler and setting the argument to 0.1, you instruct OpenTelemetry to process only 10 percent of the incoming requests.

The traceidratio sampler makes its keep-or-drop decision based on the trace ID itself. Every span within a request shares the same trace ID, so a trace is either captured completely or not at all. You’ll never end up with a fragmented trace that’s missing half of its spans.

To see how this affects your application, send several requests to http://localhost:8000/users/1. You’ll see your formatted logs in the terminal for every request.

Next, open your Jaeger UI at http://localhost:16686 and verify that only about one in ten traces appears. This strategy effectively reduces the load on your observability backend while still providing enough data to monitor your application’s overall health.

Troubleshooting

While setting up OpenTelemetry and Jaeger, you might run into a few common issues. Keep these solutions in mind to quickly resolve them:

  • No traces in the Jaeger UI: Ensure that the OTLP exporter is sending data to Jaeger’s OTLP receiver port (typically 4317 for gRPC or 4318 for HTTP), not the web UI port (16686).
  • No trace_id in terminal logs: Verify that you’re correctly extracting the OpenTelemetry context and initializing the logger after instrumentation is fully set up.

Most issues come down to a port mismatch or an initialization-order problem, so these two checks will clear up the majority of setup hiccups. With your telemetry pipeline running smoothly, you’re ready to review what you’ve built and where to go from here.

Next Steps

You now know how to integrate OpenTelemetry into a FastAPI application. In this tutorial, you configured auto-instrumentation, exported telemetry data to a Jaeger backend, created custom spans for your business logic, correlated your logs with traces, and optimized performance using trace sampling.

If you want to dive deeper into how standard logging behaves before you layer telemetry on top, then Real Python’s guide to logging in Python covers handlers, formatters, and log levels in depth. To explore more infrastructure and deployment topics to prepare your app for production, browse the DevOps tutorials.

For more advanced instrumentation techniques, consult the OpenTelemetry Python documentation. With these tools under your belt, you’re well equipped to diagnose system bottlenecks and track down elusive bugs across your backend services.

Frequently Asked Questions

Now that you have some experience with OpenTelemetry and FastAPI in Python, you can use the questions and answers below to check your understanding and recap what you’ve learned.

These FAQs are related to the most important concepts you’ve covered in this tutorial. Click the Show/Hide toggle beside each question to reveal the answer.

The fastest way is auto-instrumentation. Install the opentelemetry-instrumentation-fastapi package and launch your app through the opentelemetry-instrument command-line tool, which reads your environment variables and wires up the tracing pipeline for you. This captures every HTTP request without adding any tracing code to your app.

Auto-instrumentation lets OpenTelemetry generate telemetry data for your app without any manual tracing code. A packaged agent hooks into your framework’s request handling at startup, so libraries like FastAPI get traced automatically. You add custom spans by hand only when you want visibility into your own business logic.

A trace tracks the complete journey of a request as it moves through your system, while a span represents a single unit of work within that trace. One trace usually holds many spans, such as a parent span for the overall request and nested spans for operations like a database query.

A trace ID identifies an entire request as it flows through your system, and every span in that request shares the same trace ID. A span ID identifies one specific unit of work inside the trace, so it changes from span to span. Including both in your logs lets your backend attach each log line to the exact operation that produced it.

OpenTelemetry collects and exports telemetry data from your application, but it doesn’t store or display that data on its own. Jaeger is a backend that receives the exported traces and provides a UI to search and visualize them. The two tools work together, with OpenTelemetry handling instrumentation and Jaeger handling storage and visualization.

Take the Quiz: Test your knowledge with our interactive “How to Integrate OpenTelemetry With a FastAPI App” quiz. You’ll receive a score upon completion to help you track your learning progress:


Interactive Quiz

How to Integrate OpenTelemetry With a FastAPI App

Instrument a FastAPI app with OpenTelemetry to export traces, add custom spans, and correlate your application logs with traces.

🐍 Python Tricks 💌

Get a short & sweet Python Trick delivered to your inbox every couple of days. No spam ever. Unsubscribe any time. Curated by the Real Python team.

Python Tricks Dictionary Merge

About Yuka Tamura

Yuka is a software engineer with a diverse full-stack background. She loves breaking down complex topics to help you grow.

» More about Yuka

Each tutorial at Real Python is created by a team of developers so that it meets our high quality standards. The team members who worked on this tutorial are:

Master Real-World Python Skills With Unlimited Access to Real Python

Locked learning resources

Join us and get access to thousands of tutorials, hands-on video courses, and a community of expert Pythonistas:

Level Up Your Python Skills »

Master Real-World Python Skills
With Unlimited Access to Real Python

Locked learning resources

Join us and get access to thousands of tutorials, hands-on video courses, and a community of expert Pythonistas:

Level Up Your Python Skills »

What Do You Think?

Rate this article:

What’s your #1 takeaway or favorite thing you learned? How are you going to put your newfound skills to use? Leave a comment below and let us know.

Commenting Tips: The most useful comments are those written with the goal of learning from or helping out other students. Get tips for asking good questions and get answers to common questions in our support portal.


Looking for a real-time conversation? Visit the Real Python Community Chat or join the next “Office Hours” Live Q&A Session. Happy Pythoning!

Keep Learning

Related Topics: intermediate devops web-dev