How to Instrument a FastAPI App With OpenTelemetry
A hands-on, no-Docker walkthrough of adding OpenTelemetry tracing to a FastAPI app: automatic instrumentation, custom spans, log correlation, and sampling, each verified with real captured output.
If you have ever had to debug a slow API endpoint by adding print() statements and re-deploying, or tried to figure out which of five downstream calls made a request take three seconds, you already understand why observability matters. This tutorial teaches you how to instrument a real FastAPI application with OpenTelemetry, the vendor-neutral standard for collecting traces, from a completely untraced baseline to a working pipeline with automatic instrumentation, custom spans around your own business logic, logs correlated to the request that produced them, and a configurable sampler. Every command and every piece of output in this tutorial was run for real against a live local FastAPI app; nothing here is invented.
Table Of Content
- What You Will Build
- Prerequisites
- Step 1: Start With a Plain FastAPI App
- Step 2: Add Automatic Instrumentation
- Install the OpenTelemetry Packages
- Wire Up a Tracer Provider and Console Exporter
- Run It and Read Your First Span
- Step 3: Add a Custom Span for Business Logic
- Step 4: Correlate Your Logs With Your Traces
- The First Attempt (and Why the Format Did Not Change)
- The Second Attempt (and a Missing Keyword Argument)
- The Working Version
- Step 5: Configure Sampling to Control Overhead
- Measuring the Real Sampling Rate
- Step 6: Ship Spans to a Real Backend With OTLP
- Common Mistakes and Gotchas
- How to Verify Everything Works End to End
- Next Steps
Before writing any code, a few terms are worth defining because the rest of the tutorial leans on them constantly:
- A trace represents one end-to-end request as it moves through your system. It is identified by a single trace ID that stays the same across every piece of work done to handle that request.
- A span is one unit of work inside a trace, for example handling an HTTP request, or running a specific function. A span has a start time, an end time, a name, a set of key/value attributes, and its own span ID.
- Spans form a tree. A span can have a parent span, and the parent/child relationships in that tree are what let you reconstruct the full shape of a request afterward, down to which function called which.
- Instrumentation is the code that creates spans. Automatic instrumentation comes from a library that wraps a framework (like FastAPI) for you. Manual instrumentation is spans you create yourself around code the automatic instrumentation cannot see, like a pricing calculation or a database call your own code makes.
- An exporter is the piece that takes finished spans and sends them somewhere: your terminal, a file, or a backend like Jaeger, Tempo, or a hosted vendor.
- A sampler decides which traces actually get recorded and exported. Recording every single request is expensive at scale, so most production systems only keep a percentage of traces.
This site already has three other OpenTelemetry tutorials, and it is worth being upfront about how this one is different so you can pick the right one. One post covers what happens when a trace crosses a network boundary between two separate services and the trace context gets lost along the way. Another and a third both use the OpenTelemetry Collector to turn spans into aggregated metrics. This tutorial is the one before all of those: it is about getting OpenTelemetry working inside a single application in the first place, with automatic instrumentation, your own custom spans, correlated logs, and sampling, the four things you need before propagation or metrics pipelines are even relevant.
What You Will Build
You will build a small Order Pricing API with FastAPI: an endpoint that prices an order based on an item, a quantity, and a customer loyalty tier, applying a discount and tax calculation. You will instrument it in five stages, each one runnable on its own, so you can see exactly what each piece of configuration adds:
- A plain FastAPI app with no tracing at all, as a baseline.
- Automatic instrumentation with a console exporter, so you can see raw span data for every request.
- A custom span wrapped around the pricing calculation, with your own attributes attached to it.
- Logs that carry the trace ID and span ID of the request that produced them, so you can jump from a log line straight to the trace it belongs to.
- A configurable sampler, verified by firing real traffic at it and counting what actually got exported.
As a bonus step, you will also export real spans over the OTLP protocol to a small receiver you write yourself, so you can see what “shipping telemetry to a backend” actually looks like on the wire, without needing Docker or any external service.
Prerequisites
- Python 3.10 or newer installed and runnable from a terminal. This tutorial was written and tested against Python 3.13.14, but any 3.10+ version behaves the same way for everything shown here.
- Basic familiarity with FastAPI: you should know what a path operation function is and how to run an app with
uvicorn. If you have never touched FastAPI before, the official FastAPI tutorial covers everything you need before starting here. - Comfort running
pip installand reading a traceback. No prior OpenTelemetry knowledge is assumed; every concept is explained as it comes up. - No Docker, no external accounts, and no paid services are required anywhere in this tutorial. The original workflow this is based on uses Jaeger in a Docker container as the trace backend; this version replaces that with a plain-text console exporter for the main tutorial and a small OTLP receiver you write yourself for the bonus step, so everything runs from a single Python virtual environment.
Step 1: Start With a Plain FastAPI App
Before adding any tracing, set up a small API with real business logic worth watching. Create a project folder, then a virtual environment:
python -m venv venv
# Windows
venv\Scripts\activate.ps1
# Linux / macOS
source venv/bin/activate
Create app.py with the following content. This is an order pricing API: given an item name, a quantity, and an optional loyalty tier, it looks up the unit price, applies a per-tier discount, adds tax, and returns the breakdown.
from fastapi import FastAPI
app = FastAPI()
CATALOG = {
"widget": 9.99,
"gadget": 24.50,
"gizmo": 14.75,
}
LOYALTY_DISCOUNTS = {
"standard": 0.0,
"silver": 0.05,
"gold": 0.12,
}
def calculate_total(subtotal: float, loyalty_tier: str) -> dict:
discount_rate = LOYALTY_DISCOUNTS.get(loyalty_tier, 0.0)
discount = subtotal * discount_rate
taxable = subtotal - discount
tax = taxable * 0.08
total = taxable + tax
return {
"subtotal": round(subtotal, 2),
"discount": round(discount, 2),
"tax": round(tax, 2),
"total": round(total, 2),
}
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/orders")
def create_order(item: str, quantity: int, loyalty_tier: str = "standard"):
if item not in CATALOG:
return {"error": f"unknown item '{item}'"}
unit_price = CATALOG[item]
subtotal = unit_price * quantity
pricing = calculate_total(subtotal, loyalty_tier)
return {"item": item, "quantity": quantity, **pricing}
Install FastAPI and a server, then run it:
pip install fastapi uvicorn
uvicorn app:app --host 127.0.0.1 --port 8000
In a second terminal, place an order:
curl -s -X POST "http://127.0.0.1:8000/orders?item=gadget&quantity=3&loyalty_tier=gold"
{"item":"gadget","quantity":3,"subtotal":73.5,"discount":8.82,"tax":5.17,"total":69.85}
That works, and it is completely invisible from the outside. If this endpoint were slow in production, you would have no way to tell whether the slowness was in the pricing math, a database call you add later, or somewhere else entirely. That is the problem the rest of this tutorial solves.
Step 2: Add Automatic Instrumentation
Install the OpenTelemetry Packages
OpenTelemetry for Python is split into several packages: an API, an SDK that implements it, and separate instrumentation packages per framework. Install what you need for FastAPI:
pip install opentelemetry-distro opentelemetry-instrumentation-fastapi
This tutorial was tested against opentelemetry-api 1.44.0, opentelemetry-sdk 1.44.0, and opentelemetry-instrumentation-fastapi 0.65b0. The instrumentation packages track the API/SDK version closely and are still pre-1.0 (hence the b0), so pin them in a real project if you want reproducible builds.
Wire Up a Tracer Provider and Console Exporter
OpenTelemetry needs three things configured before it will produce any spans: a TracerProvider (the factory that creates tracers and knows where spans should go), a Resource (metadata describing what service is producing the spans, at minimum a service name), and at least one SpanProcessor paired with an exporter. Add this to the top of app.py, before the route definitions:
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
resource = Resource.create({"service.name": "order-pricing-api"})
provider = TracerProvider(resource=resource)
provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
trace.set_tracer_provider(provider)
app = FastAPI()
FastAPIInstrumentor.instrument_app(app)
FastAPIInstrumentor.instrument_app(app) is the entire automatic instrumentation step. It patches the app’s ASGI middleware so that every incoming request automatically starts a span, tagged with the HTTP method, route, status code, and a few other standard attributes, with zero changes to your route functions.
Run It and Read Your First Span
Restart uvicorn and place the same order again. This time, the server terminal prints span data:
curl -s -X POST "http://127.0.0.1:8000/orders?item=widget&quantity=5&loyalty_tier=silver"
{
"name": "POST /orders",
"context": {
"trace_id": "0xd3624e23d835f229eecc8cf05df0f763",
"span_id": "0x3e00176c698d097d",
"trace_state": "[]"
},
"kind": "SpanKind.SERVER",
"parent_id": null,
"start_time": "2026-09-03T19:48:31.000719Z",
"end_time": "2026-09-03T19:48:31.018106Z",
"status": { "status_code": "UNSET" },
"attributes": {
"http.scheme": "http",
"http.host": "127.0.0.1:8000",
"http.target": "/orders",
"http.url": "http://127.0.0.1:8000/orders?item=widget&quantity=5&loyalty_tier=silver",
"http.method": "POST",
"http.user_agent": "curl/8.21.0",
"http.route": "/orders",
"http.status_code": 200
},
"resource": {
"attributes": {
"service.name": "order-pricing-api"
}
}
}
That single JSON block already tells you a lot: which route handled the request (http.route), the exact URL and method, the response status code, and precise start and end timestamps you can subtract to get a duration. parent_id is null because this is the very first, top-level span in the trace.
Gotcha: one request produces more than one span. If you look at your own terminal output, you will actually see three JSON blocks per request, not one. Alongside the POST /orders span above, FastAPI’s ASGI instrumentation also emits two small internal spans named POST /orders http send, one for the http.response.start ASGI event and one for http.response.body, both with their parent_id set to the main span’s span_id. This is normal and expected: automatic instrumentation at the ASGI layer tends to be more granular than you would write by hand. When you are reading real trace data later, do not be surprised to see more spans per request than you intuitively expect; check each span’s parent_id to understand how they nest.
Step 3: Add a Custom Span for Business Logic
Automatic instrumentation only sees what the framework sees: the request coming in and the response going out. It has no idea that calculate_total() exists, let alone that it applies a loyalty discount. To make your own business logic show up in traces, you create spans manually with a tracer.
Get a tracer and wrap the pricing function’s body in a span:
tracer = trace.get_tracer("order_pricing_api.business_logic")
def calculate_total(subtotal: float, loyalty_tier: str) -> dict:
with tracer.start_as_current_span("calculate_total") as span:
span.set_attribute("order.subtotal", subtotal)
span.set_attribute("order.loyalty_tier", loyalty_tier)
discount_rate = LOYALTY_DISCOUNTS.get(loyalty_tier, 0.0)
discount = subtotal * discount_rate
taxable = subtotal - discount
tax = taxable * 0.08
total = taxable + tax
span.set_attribute("order.discount_rate", discount_rate)
span.set_attribute("order.discount_amount", round(discount, 2))
span.set_attribute("order.total", round(total, 2))
return {
"subtotal": round(subtotal, 2),
"discount": round(discount, 2),
"tax": round(tax, 2),
"total": round(total, 2),
}
tracer.start_as_current_span("calculate_total") does two things: it creates a new span, and it makes that span the “current” one for anything called inside the with block. Because the FastAPI-instrumented request handler is already running when calculate_total() is called, OpenTelemetry automatically makes the new span a child of the request span, with no extra code needed to wire that relationship up yourself.
Order two gizmos on the gold tier and check the console output for the new span:
{
"name": "calculate_total",
"context": {
"trace_id": "0x243bc77aa681792c268881dec2c825a5",
"span_id": "0x29c8b621d2a425ea"
},
"kind": "SpanKind.INTERNAL",
"parent_id": "0xa9675d2ccfd84f80",
"attributes": {
"order.subtotal": 29.5,
"order.loyalty_tier": "gold",
"order.discount_rate": 0.12,
"order.discount_amount": 3.54,
"order.total": 28.04
}
}
The reported response was {"item":"gizmo","quantity":2,"subtotal":29.5,"discount":3.54,"tax":2.08,"total":28.04}, which matches the span attributes exactly, since they come from the same calculation. To confirm the parent/child relationship is real and not just a coincidence of formatting, this tutorial parsed the same log file programmatically and compared the two span IDs directly:
calculate_total parent_id: 0xa9675d2ccfd84f80
SERVER span_id: 0xa9675d2ccfd84f80
They match exactly. This is what lets a trace viewer draw a proper waterfall diagram: it walks the tree of parent_id references to figure out which span called which.
Step 4: Correlate Your Logs With Your Traces
Traces are great for seeing the shape of a request, but your existing logging calls are still where most of the human-readable detail lives. OpenTelemetry can inject the current trace ID and span ID into every log record, so a log line and a span from the same request can be tied back together. Getting this working correctly took two real wrong turns worth walking through, because the failure modes are confusing and undocumented anywhere obvious.
The First Attempt (and Why the Format Did Not Change)
The opentelemetry-instrumentation-logging package adds this. The natural-looking first attempt is to instrument logging, then configure your own log format afterward:
from opentelemetry.instrumentation.logging import LoggingInstrumentor
import logging
LoggingInstrumentor().instrument(set_logging_format=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s [trace_id=%(otelTraceID)s span_id=%(otelSpanID)s] %(name)s - %(message)s",
)
logger = logging.getLogger("order_pricing_api")
Add a log line inside calculate_total() and make a request. The log line that comes out uses a completely different format than the one just configured:
2026-09-03 15:49:27,285 INFO [order_pricing_api] [step4_log_correlation.py:56] [trace_id=f227aeea19699dd268cefa61ca348656 span_id=74d8c50aa71c32bc resource.service.name=order-pricing-api trace_sampled=True] - priced order: subtotal=39.96 loyalty_tier=silver total=41.00
The trace ID and span ID are correctly present, but the custom logging.basicConfig() call further down was silently ignored. The reason is a detail of Python’s standard library, not OpenTelemetry: logging.basicConfig() only has an effect the first time it is called in a process; every call after that is a no-op unless you pass force=True. Passing set_logging_format=True to LoggingInstrumentor().instrument() makes it call logging.basicConfig() internally, with its own hardcoded default format, before your code gets a chance to. Whichever basicConfig() call happens first wins, and the instrumentor’s call happens at import time, before your own line even runs.
The Second Attempt (and a Missing Keyword Argument)
The obvious fix is to call your own basicConfig() first and tell the instrumentor not to touch the format:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s trace_id=%(otelTraceID)s span_id=%(otelSpanID)s %(name)s - %(message)s",
)
LoggingInstrumentor().instrument(set_logging_format=False)
This produces a worse failure: a logging error dumped straight to the terminal instead of a log line at all.
--- Logging error ---
ValueError: Formatting field not found in record: 'otelTraceID'
Reading the instrumentor’s own source explains why. Inside LoggingInstrumentor._instrument(), the flag that actually controls whether trace fields get attached to log records is not set_logging_format on its own:
inject_context = set_logging_format or kwargs.get("inject_trace_context", False)
set_logging_format=False disables the instrumentor’s format override, exactly as intended, but it also disables field injection entirely, because inject_context is only True when either set_logging_format is True or you separately pass inject_trace_context=True. With neither set, log records never get an otelTraceID attribute at all, and the format string you configured references a field that does not exist, which is exactly the ValueError above.
The Working Version
The fix is to keep your own format (call basicConfig() first) and explicitly ask for field injection without the format override:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s trace_id=%(otelTraceID)s span_id=%(otelSpanID)s %(name)s - %(message)s",
)
LoggingInstrumentor().instrument(set_logging_format=False, inject_trace_context=True)
logger = logging.getLogger("order_pricing_api")
Make one successful request and one that gets rejected for an unknown item:
2026-09-03 15:50:46,365 INFO trace_id=16ec88a51c04579aae294b73d5625dcf span_id=ad9e46d2b52912bf order_pricing_api - priced order: subtotal=39.96 loyalty_tier=silver total=41.00
2026-09-03 15:50:46,407 WARNING trace_id=dbe9d00317c7d79a421d81f2372f4f3d span_id=f8ce12f5f476d4e4 order_pricing_api - rejected order: unknown item 'nope'
The custom format is respected this time, and each log line carries a real trace ID. To prove the correlation is not just cosmetic, this tutorial cross-checked those trace IDs against the console-exported spans from the same two requests: every span belonging to the successful order carried trace_id: "0x16ec88a51c04579aae294b73d5625dcf", exactly matching the log line above it, and every span from the rejected order carried trace_id: "0xdbe9d00317c7d79a421d81f2372f4f3d". In a real production system, this is what lets you take a trace ID from an error you see in a trace viewer and grep your log aggregator for every log line from that exact request, or go the other way: start from a suspicious log line and pull up the full trace it belongs to.
Step 5: Configure Sampling to Control Overhead
Recording and exporting a span for every single request is fine for a demo, but at real production traffic volumes it adds meaningful CPU and network overhead, and most of those traces are boring successes you will never look at. A sampler lets you keep only a percentage of traces while still keeping all of them for requests you explicitly care about, like ones with errors.
Configure a ParentBased(TraceIdRatioBased(...)) sampler on the TracerProvider:
from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased
sampler = ParentBased(root=TraceIdRatioBased(0.25))
provider = TracerProvider(resource=resource, sampler=sampler)
TraceIdRatioBased(0.25) keeps roughly 25 percent of traces, deciding deterministically based on the trace ID itself rather than flipping an independent coin for every request. Wrapping it in ParentBased means: if this request already has a parent trace context from an upstream service (because it propagated a sampling decision to you), respect that decision; only fall back to the ratio-based sampler for requests that start a brand-new trace here.
Measuring the Real Sampling Rate
Configuration you have not verified is a guess. To confirm the sampler actually behaves the way its name implies, this tutorial fired real traffic at four different configurations and counted how many top-level spans actually got exported for each one:
| Configured ratio | Requests fired | Spans exported | Observed rate |
|---|---|---|---|
| 25% | 40 | 5 | 12.5% |
| 25% | 300 | 64 | 21.3% |
| 5% | 200 | 6 | 3.0% |
| 50% | 400 | 203 | 50.75% |
The smaller samples undershot their target ratio noticeably, especially the first row (12.5% observed against a 25% target from only 40 requests). That is expected statistical noise, not a bug: with 40 independent trials at a 25% true rate, a result anywhere from roughly 4 to 16 sampled traces is well within normal variance. The 400-request trial at a 50% ratio converged very closely to the configured value (50.75% observed against 50% configured), which is the behavior you actually care about at real traffic volumes. The lesson generalizes: do not trust a sampler’s behavior from a handful of manual test requests. If you need to verify a sampling rate in a real system, measure it over enough traffic for the law of large numbers to actually apply, not a dozen curl commands.
Step 6: Ship Spans to a Real Backend With OTLP
The console exporter used so far is deliberately the least useful exporter in real life: it exists for debugging and demos. Production systems export over OTLP (the OpenTelemetry Protocol) to a Collector or a backend. The original version of this workflow runs Jaeger in Docker as that backend; to keep this tutorial usable without Docker, this step proves the same wire protocol works by writing a tiny receiver in plain Python and decoding the real protobuf payload OpenTelemetry sends.
Install the HTTP OTLP exporter:
pip install opentelemetry-exporter-otlp-proto-http
Swap the console exporter for an OTLP one pointed at 127.0.0.1:4318, the conventional OTLP/HTTP port:
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
otlp_exporter = OTLPSpanExporter(endpoint="http://127.0.0.1:4318/v1/traces")
provider.add_span_processor(SimpleSpanProcessor(otlp_exporter))
A minimal OTLP/HTTP receiver is just an HTTP server that accepts a POST of protobuf-encoded bytes at /v1/traces and returns 200. The message format is defined by OpenTelemetry’s own protobuf schema, which ships as part of the exporter’s dependencies, so decoding it does not require anything beyond what is already installed:
from http.server import BaseHTTPRequestHandler, HTTPServer
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ExportTraceServiceRequest
class OTLPHandler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length)
request = ExportTraceServiceRequest()
request.ParseFromString(body)
for resource_spans in request.resource_spans:
for scope_spans in resource_spans.scope_spans:
for span in scope_spans.spans:
print(f"OTLP RECEIVED span={span.name!r} "
f"trace_id={span.trace_id.hex()} "
f"span_id={span.span_id.hex()}")
self.send_response(200)
self.end_headers()
HTTPServer(("127.0.0.1", 4318), OTLPHandler).serve_forever()
Run the receiver, run the instrumented app pointed at it, and place an order. The receiver decodes and prints the real spans it received off the wire:
OTLP RECEIVED service='order-pricing-api' span='calculate_total' trace_id=4da984ea16aa9ea3b4a6554b66802d97 span_id=a7f1213f716a2f46 parent_id=25e791f421947ef1 duration_ms=0.030 attrs=3
OTLP RECEIVED service='order-pricing-api' span='POST /orders http send' trace_id=4da984ea16aa9ea3b4a6554b66802d97 span_id=026a71481be685cb parent_id=25e791f421947ef1 duration_ms=0.027 attrs=2
OTLP RECEIVED service='order-pricing-api' span='POST /orders http send' trace_id=4da984ea16aa9ea3b4a6554b66802d97 span_id=31497fea0de1b002 parent_id=25e791f421947ef1 duration_ms=0.009 attrs=1
OTLP RECEIVED service='order-pricing-api' span='POST /orders' trace_id=4da984ea16aa9ea3b4a6554b66802d97 span_id=25e791f421947ef1 parent_id=(root) duration_ms=50.473 attrs=13
All four spans share the exact same trace_id, and the three child spans all point to 25e791f421947ef1 as their parent, which matches the top-level span’s own span_id exactly. This is the real OTLP wire format decoded byte for byte, not a simulation; it is the same protocol a Collector, Jaeger, Tempo, or any hosted vendor speaks. Swapping endpoint="http://127.0.0.1:4318/v1/traces" for a real Collector’s address is the only change needed to go from this toy receiver to a production pipeline. If you do have Docker available, the quickest way to see this data in a real UI instead of decoded print statements is Jaeger’s own all-in-one image:
docker run -d --name jaeger -p 16686:16686 -p 4317:4317 -p 4318:4318 jaegertracing/all-in-one:latest
With that container running, point OTLPSpanExporter at it instead of the hand-written receiver, and open http://localhost:16686 to browse traces in a proper waterfall view.
Common Mistakes and Gotchas
- Forgetting to call
FastAPIInstrumentor.instrument_app(app). Nothing errors. Your app just silently produces zero spans, because automatic instrumentation is opt-in per app instance. - Assuming one request produces one span. As shown in Step 2, a single FastAPI request typically produces multiple spans (the request span plus internal ASGI event spans). Always check
parent_idrather than assuming a flat one-span-per-request model. - Passing
set_logging_format=Trueand then trying to override the format yourself afterward. As shown in Step 4, the instrumentor’s internalbasicConfig()call wins the race. Call your ownbasicConfig()first, and passset_logging_format=False, inject_trace_context=Trueinstead. - Using
SimpleSpanProcessorin production. It is the right choice for a tutorial or local debugging because it exports every span the moment it ends, with no buffering, which is exactly why the code samples above use it: you see output immediately. The OpenTelemetry Python SDK’s own docstring is explicit about what it does: “SimpleSpanProcessoris an implementation ofSpanProcessorthat passes ended spans directly to the configuredSpanExporter.” That means every span export blocks whatever request produced it. The official docs are direct about the alternative: “Using batching is recommended, but if you do not want to batch your spans or log records, you can use a simple processor instead.” Swap inBatchSpanProcessor, which batches spans and pushes them to the exporter on a background schedule, before deploying anything real. - Trusting a sampler’s ratio from a handful of manual test requests. As Step 5’s own data shows, small sample sizes produce real, visible variance from the configured ratio. Verify sampling behavior against meaningful traffic volume, not a few curl commands.
How to Verify Everything Works End to End
- Start the fully instrumented app (Steps 2 through 5 combined) and confirm
uvicornstarts with no tracebacks. - Send a request and confirm you see exactly the spans you expect in the console output, checking
parent_idvalues to confirm the tree shape (request span at the root, your custom span and the ASGI event spans as children). - Send a request that hits your error path (an unknown item, in this example) and confirm the log line for it carries a
trace_idthat matches the corresponding span’strace_idin the console output. - Fire a batch of at least a few hundred requests at your configured sampler and count exported top-level spans; confirm the observed rate is in the right ballpark for your configured ratio, not exact, but not wildly off either.
- If you built the OTLP bonus step, confirm your receiver (or Jaeger, if you have Docker) shows spans with the trace ID from a request you just made, not stale data from an earlier run.
Next Steps
From here, a few natural directions to keep learning:
- Swap
SimpleSpanProcessorforBatchSpanProcessorand read through its configuration environment variables (OTEL_BSP_SCHEDULE_DELAY,OTEL_BSP_MAX_QUEUE_SIZE, and others) to understand the tradeoffs between export latency and overhead. - If your system has more than one service, read this site’s tutorial on fixing broken distributed traces between Python microservices, which covers what happens to trace context when a request crosses a network boundary.
- Once traces are flowing, this site’s tutorials on turning slow SQL queries into reliability metrics and converting Prometheus histograms into OTLP metrics both show how to run a real OpenTelemetry Collector and extract metrics from the spans you are now producing.
- If correlated logging by itself (without full distributed tracing) is closer to what you need, this site’s tutorial on debugging a production-only bug with correlation IDs and structured logging covers a lighter-weight version of the same idea.
- Install Docker and run the Jaeger all-in-one image shown in Step 6 to see your spans in a real trace-viewer UI instead of decoded console output.








No Comment! Be the first one.