Native OpenTelemetry & Exceptions
FastAPI v0.162.2 embeds zero-patch OpenTelemetry instrumentation directly inside fastapi.telemetry (TelemetryConfig and get_telemetry_data()) and pairs it with a two-tier exception boundary that separates client 4xx / 422 errors from 500 server contract breaches.
Source Code
from drawlib.canvas import save, setup
from drawlib.diagrams.architecture import ArchitectureDiagram, Node, NodeGroup, PhosphorIcon
from drawlib.styles import Styles
from drawlib.text import text
setup(width=182, height=104)
text(
(91.0, 99.0),
"FastAPI v0.162.2 Native Telemetry Pipeline (fastapi.telemetry)",
style=Styles.DarkBold.patch(text_size=13.2),
)
d = ArchitectureDiagram(
node_style=Styles.Primary,
node_text_style=Styles.DarkBold.patch(text_size=10.2),
edge_style=Styles.DarkBold,
edge_text_style=Styles.DarkBold.patch(text_size=10.0),
node_card_style=Styles.Neutral,
)
caller = d.add(
Node("Caller\n(traceparent)", width=24, height=18, icon=PhosphorIcon.GLOBE, icon_size=7.0),
xy=(13.0, 52.0),
)
# Central FastAPI Runtime Group (bottom-left at (32, 4))
runtime = d.add(
NodeGroup(
title="FastAPI TelemetryConfig",
padding=5.5,
style=Styles.Neutral,
text_style=Styles.DarkBold.patch(text_size=10.8, halign="left"),
),
xy=(32.0, 4.0),
)
root_span = runtime.add(
Node(
"Root Server Span\nGET /orders/{order_id}",
width=34,
height=17,
icon=PhosphorIcon.ACTIVITY,
icon_size=7.0,
card_style=Styles.PrimaryFlat,
style=Styles.WhiteBold,
text_style=Styles.WhiteBold.patch(text_size=10.2),
),
xy=(22.5, 58.0),
)
ctx_reader = runtime.add(
Node(
"get_telemetry_data()\nrequest • body • values",
width=34,
height=17,
icon=PhosphorIcon.DATABASE,
icon_size=7.0,
card_style=Styles.PrimaryNeutral,
),
xy=(22.5, 20.0),
)
# Nested Operation Spans Group inside runtime (global left = 32 + 52 = 84)
ops = runtime.add(
NodeGroup(
title="operation_spans=True",
padding=4.5,
style=Styles.White,
text_style=Styles.PrimaryBold.patch(text_size=10.5, halign="left"),
),
xy=(52.0, 5.0),
)
s_dep = ops.add(Node("fastapi.dependencies", width=34, height=10.5, card_style=Styles.PrimaryNeutral), xy=(21.5, 56.5))
s_end = ops.add(Node("fastapi.endpoint", width=34, height=10.5, card_style=Styles.Neutral), xy=(21.5, 41.5))
s_ser = ops.add(Node("fastapi.serialization", width=34, height=10.5, card_style=Styles.Neutral), xy=(21.5, 26.5))
s_bg = ops.add(Node("fastapi.background_task", width=34, height=10.5, card_style=Styles.SecondaryNeutral), xy=(21.5, 11.5))
# Right OpenTelemetry SDK Providers Group
otel_sdk = d.add(
NodeGroup(
title="OpenTelemetry SDK",
padding=5.0,
style=Styles.Neutral,
text_style=Styles.DarkBold.patch(text_size=10.8, halign="left"),
),
xy=(138.0, 9.0),
)
p_trace = otel_sdk.add(
Node("TracerProvider\nSpans & Status", width=26, height=16, icon=PhosphorIcon.TREE_STRUCTURE, icon_size=6.5, card_style=Styles.PrimaryNeutral),
xy=(18.0, 56.0),
)
p_meter = otel_sdk.add(
Node("MeterProvider\nduration & active", width=26, height=16, icon=PhosphorIcon.CHART_BAR, icon_size=6.5),
xy=(18.0, 34.0),
)
p_logs = otel_sdk.add(
Node("LoggerProvider\n422 WARN / 500 ERR", width=26, height=16, icon=PhosphorIcon.WARNING_CIRCLE, icon_size=6.5, card_style=Styles.SecondaryNeutral),
xy=(18.0, 12.0),
)
d.connect(caller, root_span, padding=1.2)
d.connect(root_span, ctx_reader, label="Context", padding=1.2)
j_root = d.junction((78.0, 62.0))
d.connect(root_span, j_root, arrow="-", padding=(1.2, 0.0))
j_rows = [d.junction((78.0, gy)) for gy in (65.5, 50.5, 35.5, 20.5)]
d.connect(j_root, j_rows[0], arrow="-")
d.connect(j_root, j_rows[1], arrow="-")
d.connect(j_rows[1], j_rows[2], arrow="-")
d.connect(j_rows[2], j_rows[3], arrow="-")
for j_row, child_span in zip(j_rows, (s_dep, s_end, s_ser, s_bg)):
d.connect(j_row, child_span, padding=(0.0, 1.2))
d.connect(s_dep, p_trace, padding=1.2)
d.connect(s_ser, p_meter, padding=1.2)
d.connect(s_bg, p_logs, padding=1.2)
d.draw(xy=(3.0, 3.0))
save()
1. Native fastapi.telemetry Architecture
Prior to native telemetry, instrumenting FastAPI required external monkey-patching middlewares that could only see raw ASGI messages—missing fine-grained visibility into dependency resolution, Pydantic response serialization, or validation failures. In FastAPI v0.162.2, FastAPI(telemetry=TelemetryConfig(...)) instruments the request lifecycle natively against OpenTelemetry semantic conventions (1.44.0):
- Root Server & WebSocket Spans (
tracing=True): Extracts W3Ctraceparentandbaggageheaders, redacts sensitive query keys (sig,X-Amz-Signature,X-Goog-Signature), and updates the span name to"{METHOD} {http.route}"(e.g.,GET /orders/{order_id}) once a route matches. - Four Granular Child Spans (
operation_spans=True): Automatically wraps internal phases withcode.function.nameattributes:fastapi.dependencies: Total time spent resolving theDependantDAG (solve_dependencies).fastapi.endpoint: Coroutineawaitor worker-thread execution time (run_endpoint_function).fastapi.serialization: Pydantic validation and Rust JSON serialization (serialize_response).fastapi.background_task: Each post-response task insideBackgroundTasks.
- Metrics & Structured Logs (
metrics=True,logs=True): Recordshttp.server.request.durationhistograms andhttp.server.active_requestscounters, emitsWARNlog events (fastapi.validation.failed) on422errors, and emitsERRORlog events (http.server.request.exception) on unhandled crashes. - Synchronous Context Inspection (
get_telemetry_data()): Exposes a read-onlyTelemetryDataobject (request,websocket,body,values,errors) to custom span/log processors during active request execution without leaking payloads into exported spans by default, and clears references (data = None) immediately on teardown.
from typing import Annotated
from fastapi import BackgroundTasks, Depends, FastAPI, Header
from fastapi.telemetry import TelemetryConfig, get_telemetry_data
from fastapi.testclient import TestClient
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from pydantic import BaseModel
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
telemetry_cfg: TelemetryConfig = {
"tracer_provider": provider,
"tracing": True,
"operation_spans": True,
"metrics": False,
"logs": False,
"exclude": lambda scope: scope.get("path") == "/healthz",
}
app = FastAPI(title="Orders Service", telemetry=telemetry_cfg)
class OrderOut(BaseModel):
order_id: int
tenant: str
traced_route: bool
async def resolve_tenant(x_tenant: Annotated[str, Header()] = "acme") -> str:
return x_tenant
@app.get("/healthz")
async def healthz() -> dict[str, str]:
return {"status": "ok"}
@app.get("/orders/{order_id}", response_model=OrderOut)
async def get_order(
order_id: int,
tenant: Annotated[str, Depends(resolve_tenant)],
tasks: BackgroundTasks,
) -> OrderOut:
tdata = get_telemetry_data()
has_context = (
tdata is not None
and tdata.request is not None
and tdata.values is not None
)
tasks.add_task(lambda: None)
return OrderOut(order_id=order_id, tenant=tenant, traced_route=has_context)
client = TestClient(app)
assert client.get("/healthz").status_code == 200
assert len(exporter.get_finished_spans()) == 0 # Excluded via scope filter
res = client.get("/orders/99", headers={"X-Tenant": "globex"})
assert res.json()["traced_route"] is True
span_names = {s.name for s in exporter.get_finished_spans()}
assert {
"GET /orders/{order_id}",
"fastapi.dependencies",
"fastapi.endpoint",
"fastapi.serialization",
"fastapi.background_task",
}.issubset(span_names)
2. Exception Hierarchy & RFC 9457 Error Envelopes
FastAPI intentionally splits exception handling across two distinct middleware boundaries in the ASGI middleware onion:
Source Code
from drawlib.canvas import save, setup
from drawlib.diagrams.flow import End, FlowDiagram, Process
from drawlib.styles import Styles
from drawlib.text import text
setup(width=170, height=92)
text(
(85.0, 87.5),
"FastAPI Exception Routing: Handled Client Errors vs Server Contract Breaches",
style=Styles.DarkBold.patch(text_size=13.0),
)
node_txt = Styles.DarkBold.patch(text_size=10.2)
flow = FlowDiagram(
node_style=Styles.Neutral.patch(text_size=10.2),
edge_style=Styles.DarkBold,
edge_text_style=Styles.DarkBold.patch(text_size=10.0),
width=162,
height=78,
lane_orientation="vertical",
)
# 3 Swimlanes: 54 + 54 + 54 = 162 (centerlines X = 27, 81, 135)
flow.add_lane(
"1. APIRoute Execution",
width=54,
style=Styles.Neutral,
header_style=Styles.PrimaryNeutral,
text_style=Styles.PrimaryBold.patch(text_size=10.5, halign="left"),
header_size=7.0,
)
flow.add_lane(
"2. ExceptionMiddleware",
width=54,
style=Styles.Neutral,
header_style=Styles.SecondaryNeutral,
text_style=Styles.SecondaryBold.patch(text_size=10.5),
header_size=7.0,
)
flow.add_lane(
"3. Outer 500 & Telemetry",
width=54,
style=Styles.Neutral,
header_style=Styles.PrimaryNeutral,
text_style=Styles.PrimaryBold.patch(text_size=10.5, halign="left"),
header_size=7.0,
)
req_val = flow.add(
Process("RequestValidationError\n(Invalid Input / Body)", width=41, height=13, style=Styles.White, text_style=node_txt),
xy=(27, 59),
)
dom_err = flow.add(
Process("HTTPException or\nCustom Domain Error", width=41, height=13, style=Styles.White, text_style=node_txt),
xy=(27, 37),
)
resp_val = flow.add(
Process("ResponseValidationError\nor Unhandled Exception", width=41, height=13, style=Styles.SecondaryNeutral, text_style=node_txt),
xy=(27, 15),
)
h_422 = flow.add(
Process("422 Problem JSON\n(+ OTel WARN Event)", width=41, height=13, style=Styles.PrimaryNeutral, text_style=node_txt),
xy=(81, 59),
)
h_4xx = flow.add(
Process("4xx / 409 JSON\n(Span Not Marked 500)", width=41, height=13, style=Styles.White, text_style=node_txt),
xy=(81, 37),
)
otel_err = flow.add(
Process("ExceptionTelemetryMiddleware\n(OTel ERROR Log + Span Error)", width=45, height=14, style=Styles.PrimaryNeutral, text_style=node_txt),
xy=(135, 42),
)
srv_500 = flow.add(
End("ServerErrorMiddleware\n500 Contract Breach Guard", width=45, height=13, style=Styles.PrimaryFlat, text_style=Styles.WhiteBold.patch(text_size=10.2)),
xy=(135, 15),
)
req_val.connect(h_422, label="422", padding=1.0)
dom_err.connect(h_4xx, label="4xx", padding=1.0)
resp_val.connect(otel_err, label="Bypasses 4xx", start_side="right", end_side="left", padding=1.0).via((104, 15), (104, 42))
otel_err.connect(srv_500, label="Re-raises", start_side="bottom", end_side="top", padding=1.0)
flow.draw(xy=(4.0, 4.0))
save()
Why ResponseValidationError Returns 500 (Not 422)
- Client Mistakes (
RequestValidationError->422,HTTPException->4xx): Caught insideExceptionMiddleware. FastAPI deliberately leaves the root OpenTelemetry span status unset for4xxresponses (since the server operated correctly) while emitting aWARNvalidation event. - Server Contract Breaches (
ResponseValidationError->500): If your backend returns data missing a required field or containing an invalid type againstresponse_model, FastAPI raisesResponseValidationError. Because it represents a server bug (never a client fault), it bypassesExceptionMiddleware, triggersExceptionTelemetryMiddleware(StatusCode.ERROR+ stack trace log), and returns a generic500 Internal Server ErrorviaServerErrorMiddleware—preventing accidental data leaks while failing CI tests loudly!
Standardizing Errors with RFC 9457 (application/problem+json)
In multi-team APIs, register custom handlers for domain exceptions and RequestValidationError to emit machine-readable RFC 9457 Problem Details envelopes without exposing internal stack traces or database errors to clients:
from typing import Annotated, Any
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError, ResponseValidationError
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
import pytest
app = FastAPI()
class InsufficientInventoryError(Exception):
def __init__(self, sku: str, requested: int, available: int) -> None:
self.sku = sku
self.requested = requested
self.available = available
@app.exception_handler(InsufficientInventoryError)
async def inventory_error_handler(
request: Request, exc: InsufficientInventoryError
) -> JSONResponse:
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
media_type="application/problem+json",
content={
"type": "https://api.example.com/problems/insufficient-inventory",
"title": "Insufficient Inventory",
"status": 409,
"detail": f"SKU '{exc.sku}' has {exc.available} units; {exc.requested} requested.",
"instance": request.url.path,
},
)
@app.exception_handler(RequestValidationError)
async def validation_problem_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
fields = [
{"loc": ".".join(str(p) for p in err["loc"]), "msg": err["msg"]}
for err in exc.errors()
]
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
media_type="application/problem+json",
content={
"type": "https://api.example.com/problems/invalid-parameters",
"title": "Request Validation Failed",
"status": 422,
"instance": request.url.path,
"errors": fields,
},
)
class OrderItem(BaseModel):
sku: str
qty: Annotated[int, Field(gt=0)]
class PublicInvoice(BaseModel):
invoice_id: str
total_cents: int
@app.post("/reservations")
async def reserve_stock(item: OrderItem) -> dict[str, Any]:
if item.qty > 5:
raise InsufficientInventoryError(item.sku, item.qty, available=5)
return {"reserved": item.sku, "qty": item.qty}
@app.get("/broken-contract", response_model=PublicInvoice)
async def broken_contract() -> Any:
return {"invoice_id": "inv-001"} # Missing `total_cents`!
client = TestClient(app)
assert client.post("/reservations", json={"sku": "SSD-2TB", "qty": 0}).status_code == 422
assert client.post("/reservations", json={"sku": "SSD-2TB", "qty": 12}).status_code == 409
with pytest.raises(ResponseValidationError):
client.get("/broken-contract")
Proceed to Testing Patterns & Production ASGI to verify lifespans, async transports, dependency overrides, and graceful shutdown probes.