FastAPI error tracking with OTLP-native ingest.
FastAPI is async Python built on Starlette, which means errors surface in more places than a traditional WSGI app: async route handlers, dependency injection failures, exception handlers, background tasks, and long-lived WebSocket connections. This guide covers two instrumentation paths — sentry-sdk[fastapi] and OpenTelemetry auto-instrumentation — explains which to pick and when, and shows how urgentry accepts both without code changes.
20 seconds. Install sentry-sdk[fastapi]. Call sentry_sdk.init at the top of main.py before app = FastAPI() with your DSN and StarletteIntegration plus FastApiIntegration. Swap the DSN to your urgentry DSN. Nothing else changes.
60 seconds. The FastApiIntegration hooks into Starlette’s middleware layer and captures unhandled exceptions from async route handlers, dependency injection failures, and middleware errors automatically. What it does not capture: exceptions you catch and return as HTTP responses, errors in BackgroundTasks functions unless you propagate the hub explicitly, and exceptions inside WebSocket or SSE async generators unless you add explicit capture calls. A second path exists via OpenTelemetry’s FastAPIInstrumentor, which records exceptions as OTLP span events. urgentry accepts both paths in the same binary.
Three things cause grief on day one. First: custom exception handlers registered with @app.exception_handler swallow exceptions before Sentry sees them. Second: dependency injection failures look like generic 500s in the UI because FastAPI wraps them. Third: uvicorn --reload can duplicate events during a reload window. All three are covered below, with fixes.
Where FastAPI errors surface
FastAPI dispatches an HTTP request through Starlette’s middleware stack, resolves dependencies via its dependency injection system, calls the route handler (an async function), and returns the response. Errors can originate at any point in this chain.
Async route handlers. The most common surface. A raised exception propagates up to Starlette’s exception middleware, which decides whether to invoke a registered exception handler or fall back to the 500 response. If an exception handler is registered for that exception type, Starlette calls it and the exception never propagates further. If no handler matches, Starlette’s ServerErrorMiddleware catches it and emits a 500. The Sentry integration hooks into the ServerErrorMiddleware path.
Dependency injection. FastAPI resolves dependencies before calling the route handler. A dependency that raises an exception terminates the resolution chain. The error surfaces as a 500 in the response, but the exception type is often wrapped or obscured by FastAPI’s internal dependency machinery. If you see a flood of generic RuntimeError or HTTPException events with no useful context, look at your dependencies first.
Exception handlers. @app.exception_handler(SomeError) registers a function that runs when SomeError propagates out of a handler. FastAPI calls the handler before the exception reaches Starlette’s middleware stack. That means the Sentry integration never sees it. Exceptions handled this way require explicit capture_exception calls inside the handler.
Starlette middleware. Pure Starlette middleware added with app.add_middleware runs above the FastAPI layer. Exceptions from middleware propagate to Starlette’s ServerErrorMiddleware and are captured by the Sentry integration if installed. Custom BaseHTTPMiddleware subclasses that catch exceptions internally are not.
Background tasks and async workers. FastAPI’s BackgroundTasks run after the response is sent, outside the request context. The Sentry hub is not propagated automatically. ARQ and Celery workers run in separate processes entirely. These surfaces require explicit context setup.
WebSockets and SSE. WebSocket connections and server-sent events keep a connection alive across multiple request/response cycles. Errors in the message-handling loop do not surface through the normal HTTP exception path. They require explicit capture inside the async generator or connection handler.
Path A: sentry-sdk with FastAPI integration
This is the recommended path for most teams. The Sentry SDK ships automatic breadcrumb capture, error fingerprinting, user context, release tracking, and session-level grouping out of the box. The FastAPI integration requires two extra integrations beyond the base SDK: StarletteIntegration (which handles the ASGI layer) and FastApiIntegration (which handles FastAPI-specific routing and dependency context).
Install
pip install "sentry-sdk[fastapi]"
The extras bracket pulls in sentry-sdk plus the optional dependencies for the Starlette and FastAPI integrations. Pin to a minor version in your requirements file:
sentry-sdk[fastapi]~=2.0
Wire up in main.py
Call sentry_sdk.init before app = FastAPI(). The SDK patches Starlette’s middleware chain at import time; if FastAPI instantiates before the SDK initializes, the integration misses the hook point.
import os
import sentry_sdk
from sentry_sdk.integrations.starlette import StarletteIntegration
from sentry_sdk.integrations.fastapi import FastApiIntegration
from fastapi import FastAPI
sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN"),
integrations=[
StarletteIntegration(
transaction_style="endpoint", # group by route path, not function name
),
FastApiIntegration(),
],
environment=os.environ.get("APP_ENV", "production"),
release=os.environ.get("APP_VERSION"),
traces_sample_rate=0.1,
send_default_pii=False,
)
app = FastAPI()
A note on each parameter. transaction_style="endpoint" groups performance transactions by route path (/users/{user_id}) rather than by function name. This is the right default for REST APIs where many paths share similar logic. traces_sample_rate=0.1 traces one in ten requests for performance monitoring. All errors are captured regardless of this rate. send_default_pii=False omits request bodies, cookies, and user identifiers from events; set to True only after reviewing your data retention obligations.
What the integration captures automatically
Once initialized, the integration covers these surfaces without additional code.
Unhandled route exceptions. Any exception from an async route handler that is not caught by a registered exception handler reaches Starlette’s ServerErrorMiddleware. The integration captures it with the full stack trace, the HTTP method, the resolved route path, the path parameters, and the query string.
Middleware exceptions. Exceptions from app.add_middleware layers that propagate out to ServerErrorMiddleware are captured. The event includes which middleware raised.
Breadcrumbs from HTTP requests. Outbound HTTP requests made with httpx or requests are recorded as breadcrumbs automatically if those libraries are installed. Each breadcrumb includes the method, URL, and response status.
User context. If you set sentry_sdk.set_user in a dependency or middleware, that context attaches to all events from the request. The FastApiIntegration does not extract user information automatically (it has no way to know your auth model), but it provides the scope for you to attach it.
Path B: OpenTelemetry FastAPI instrumentation
OpenTelemetry provides auto-instrumentation for FastAPI through the opentelemetry-instrumentation-fastapi package. This path records requests as spans and exceptions as span events in the OTLP model. It is the right choice when your team already runs an OTel pipeline and wants a single instrumentation path across all services.
Install
pip install \
opentelemetry-sdk \
opentelemetry-instrumentation-fastapi \
opentelemetry-exporter-otlp-proto-http
Wire up
import os
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.semconv.resource import ResourceAttributes
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from fastapi import FastAPI
resource = Resource.create({
ResourceAttributes.SERVICE_NAME: os.environ.get("SERVICE_NAME", "my-fastapi-service"),
ResourceAttributes.SERVICE_VERSION: os.environ.get("APP_VERSION", "unknown"),
})
provider = TracerProvider(resource=resource)
exporter = OTLPSpanExporter(
endpoint=os.environ.get(
"OTEL_EXPORTER_OTLP_ENDPOINT",
"http://localhost:4318"
) + "/v1/traces",
)
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
app = FastAPI()
FastAPIInstrumentor.instrument_app(app)
The FastAPIInstrumentor wraps the app after instantiation (unlike the Sentry integration, which must run before). It creates a span for each request and records exceptions as span events using the OTel semconv exception event format. Point OTEL_EXPORTER_OTLP_ENDPOINT at your urgentry instance to receive those spans as issues.
For exception recording inside route handlers:
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
@app.get("/orders/{order_id}")
async def get_order(order_id: str):
with tracer.start_as_current_span("fetch-order") as span:
try:
order = await db.fetch(order_id)
except OrderNotFoundError as exc:
span.record_exception(exc)
span.set_status(trace.StatusCode.ERROR, str(exc))
raise HTTPException(status_code=404, detail="Order not found")
return order
The trade-off vs the Sentry SDK
The OTel path gives you vendor-neutral instrumentation and trace continuity across every service in your stack. What it does not provide out of the box: automatic breadcrumbs from HTTP client calls, automatic user context attachment, session-level grouping, and the Sentry SDK’s default fingerprinting heuristics. You get exceptions as span events, grouped by urgentry into issues using the semconv attributes as the fingerprint seed. The issue UX is the same; the instrumentation surface is narrower.
For FastAPI specifically, the auto-instrumentor also does not capture dependency injection failures as well as the Sentry integration does, because those failures do not always surface as span errors on the route span.
Which path to pick
Pick sentry-sdk[fastapi] for most teams. Here is the decision logic.
Choose sentry-sdk when: your team’s primary workflow is error triage. The Sentry SDK ships an error-first model: every unhandled exception gets a fingerprint, every fingerprinted group is an issue, every issue has a resolved/regression lifecycle. Breadcrumbs show what the user did before the error. User context shows who was affected. You get all of this with three lines of configuration.
Choose OTel when: you already run OpenTelemetry instrumentation across your stack and want a unified SDK. If every other service in your platform emits OTLP, adding a second SDK to FastAPI for the error channel creates friction. The OTel path keeps the instrumentation surface consistent. urgentry receives both, so you do not lose the issue UX.
Both at once: the sentry-sdk now uses the OTel API internally for span creation. You can run both in the same app without conflict. The practical reason to do this is trace continuity: if upstream services emit OTel traces and you want FastAPI errors to link to those traces, running both lets the Sentry SDK inherit the OTel trace context. Commit to one as primary and treat the other as supplementary.
Capturing handled exceptions
The integration captures exceptions that escape your code. For exceptions you catch and convert to HTTP responses or business outcomes, call capture_exception explicitly.
import sentry_sdk
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
app = FastAPI()
@app.get("/payments/{payment_id}/charge")
async def charge_payment(payment_id: str):
try:
result = await payment_provider.charge(payment_id)
except PaymentDeclinedError:
# Expected outcome, not a bug โ do not capture
return JSONResponse({"error": "Payment declined"}, status_code=402)
except PaymentProviderError as exc:
# Our problem โ capture it
sentry_sdk.capture_exception(exc)
return JSONResponse({"error": "Payment service unavailable"}, status_code=503)
return {"status": "charged", "transaction_id": result.transaction_id}
For context-rich capture inside async handlers, use sentry_sdk.push_scope:
@app.post("/exports/{export_id}/generate")
async def generate_export(export_id: str, format: str = "csv"):
try:
result = await run_export(export_id, format=format)
except ExportError as exc:
with sentry_sdk.push_scope() as scope:
scope.set_tag("export.format", format)
scope.set_extra("export_id", export_id)
sentry_sdk.capture_exception(exc)
raise HTTPException(status_code=500, detail="Export failed")
return result
The push_scope context manager creates a scope that applies only to this capture. Tags and extras set inside it do not attach to subsequent events on the same request. Use set_tag for indexed values you will filter on; use set_extra for context you want visible but do not need to search by.
For attaching a span around a specific operation inside an async handler:
@app.get("/reports/{report_id}")
async def get_report(report_id: str):
with sentry_sdk.start_span(op="db.query", description="fetch report") as span:
span.set_data("report_id", report_id)
try:
report = await db.fetch_report(report_id)
except DatabaseError as exc:
sentry_sdk.capture_exception(exc)
raise HTTPException(status_code=500, detail="Database error")
return report
Async tasks and background jobs
FastAPI’s BackgroundTasks mechanism schedules functions to run after the response is sent. The async request context ends when the response goes out. The Sentry hub attached to that request is not automatically available inside the background task.
from fastapi import BackgroundTasks
import sentry_sdk
async def send_confirmation_email(user_id: str, hub: sentry_sdk.Hub):
# Hub was passed in from the request context โ use it directly
with sentry_sdk.Hub(hub):
try:
await mailer.send(user_id)
except MailError as exc:
sentry_sdk.capture_exception(exc)
@app.post("/users/{user_id}/confirm")
async def confirm_user(user_id: str, background_tasks: BackgroundTasks):
# Clone the current hub before the request context closes
current_hub = sentry_sdk.Hub.current
background_tasks.add_task(send_confirmation_email, user_id, current_hub)
return {"status": "confirmation queued"}
The key step is cloning or referencing the hub before add_task returns. Once the response is sent, the request scope closes and the hub resets. Any exception inside the background task that is not captured with a valid hub sends to the global hub, which carries no request context.
ARQ workers
ARQ runs tasks in a separate worker process. Initialize the SDK inside the worker startup hook:
# worker.py
import os
import sentry_sdk
from sentry_sdk.integrations.arq import ArqIntegration
async def startup(ctx):
sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN"),
integrations=[ArqIntegration()],
environment=os.environ.get("APP_ENV", "production"),
release=os.environ.get("APP_VERSION"),
traces_sample_rate=0.1,
)
class WorkerSettings:
on_startup = startup
functions = [my_task_function]
The ArqIntegration patches task execution so any exception raised inside a task body is captured before ARQ marks the task as failed. The event includes the task name, job ID, and retry count.
Celery workers
Celery runs in a separate process pool. Add CeleryIntegration and reinitialize in the forked worker process:
# celery_app.py
import os
from celery import Celery
from celery.signals import worker_process_init
import sentry_sdk
from sentry_sdk.integrations.celery import CeleryIntegration
celery = Celery("tasks", broker=os.environ.get("CELERY_BROKER_URL"))
@worker_process_init.connect
def init_sentry(**kwargs):
sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN"),
integrations=[CeleryIntegration()],
environment=os.environ.get("APP_ENV", "production"),
release=os.environ.get("APP_VERSION"),
)
@celery.task
async def process_data(record_id: str):
# CeleryIntegration captures any exception raised here
result = await heavy_computation(record_id)
return result
The worker_process_init signal fires in each forked Celery worker after the fork. Without this, forked workers inherit the parent’s hub with broken transport connections.
WebSockets and SSE endpoints
WebSocket connections stay open across multiple message exchanges. The Sentry integration’s per-request scope does not persist across the full WebSocket lifetime. Exceptions inside the message loop require explicit capture.
from fastapi import WebSocket
import sentry_sdk
@app.websocket("/ws/feed/{channel_id}")
async def websocket_feed(websocket: WebSocket, channel_id: str):
await websocket.accept()
with sentry_sdk.configure_scope() as scope:
scope.set_tag("channel_id", channel_id)
try:
while True:
data = await websocket.receive_text()
try:
result = await process_message(channel_id, data)
await websocket.send_text(result)
except MessageProcessingError as exc:
# Capture the per-message failure without closing the connection
sentry_sdk.capture_exception(exc)
await websocket.send_text('{"error": "message processing failed"}')
except Exception as exc:
# Capture connection-level failures (disconnects, protocol errors)
sentry_sdk.capture_exception(exc)
raise
finally:
# Clean up scope on connection close
pass
Two exception surfaces exist here. Per-message failures (a message that fails to process but should not kill the connection) call capture_exception and continue the loop. Connection-level failures (a protocol error, an abrupt disconnect) propagate and are captured in the outer try/except. The finally block is the cleanup path; add any resource release here.
Server-sent events
SSE endpoints use async generators to stream responses. Exceptions inside an async generator are harder to capture because the generator runs after the response has started streaming:
from fastapi.responses import StreamingResponse
import sentry_sdk
async def event_stream(topic: str):
try:
async for event in subscribe(topic):
yield f"data: {event}\n\n"
except SubscriptionError as exc:
sentry_sdk.capture_exception(exc)
yield "data: {\"error\": \"stream interrupted\"}\n\n"
return
@app.get("/events/{topic}")
async def stream_events(topic: str):
return StreamingResponse(
event_stream(topic),
media_type="text/event-stream",
)
The generator’s except block is the only place to capture SSE errors. The Sentry integration’s middleware hook does not see exceptions that originate inside a streaming response generator after the response has started.
Point the DSN at urgentry
This is the shortest section in the guide. In your urgentry instance, create a project and copy its DSN. Set SENTRY_DSN in your production environment to that value:
SENTRY_DSN=https://<public_key>@errors.example.com/<project_id>
The sentry_sdk.init call in main.py reads from os.environ.get("SENTRY_DSN"). No code change is needed. The SDK sends the same Sentry envelope it always has. urgentry receives it, parses it, and creates issues.
If you also use the OTel path, set:
OTEL_EXPORTER_OTLP_ENDPOINT=https://errors.example.com
urgentry listens for OTLP/HTTP on port 4318 by default. Both signal paths land in the same issue store. One urgentry instance handles both.
Three FastAPI gotchas
1. Exception handlers swallow exceptions before Sentry sees them
FastAPI’s @app.exception_handler decorator registers a function that Starlette calls when a specific exception type propagates out of a route handler. Starlette calls the registered handler before the exception reaches ServerErrorMiddleware, which is where the Sentry integration hooks. The Sentry integration never sees exceptions that a handler catches.
This catches teams by surprise. You register a handler for DatabaseError to return a clean JSON response. The errors stop showing up in urgentry. The integration is not broken; the exception was handled before it reached the integration.
Fix: add capture_exception inside every exception handler you register:
import sentry_sdk
from fastapi import Request
from fastapi.responses import JSONResponse
@app.exception_handler(DatabaseError)
async def database_error_handler(request: Request, exc: DatabaseError):
sentry_sdk.capture_exception(exc) # capture before returning the response
return JSONResponse(
status_code=503,
content={"error": "Database temporarily unavailable"},
)
Apply this pattern to every exception_handler registration that handles errors you want to track.
2. Dependency injection failures look like generic 500s
FastAPI resolves dependencies by calling each dependency function in order. If a dependency raises an exception, FastAPI wraps it and returns a 500. The exception type in the Sentry event is often not the original exception class but a FastAPI or Starlette wrapper, and the stack trace may point at FastAPI internals rather than your dependency code.
Diagnose this by adding explicit error handling inside the dependency itself:
from fastapi import Depends
import sentry_sdk
async def get_db_session():
try:
session = await db.connect()
yield session
except ConnectionError as exc:
sentry_sdk.capture_exception(exc)
raise
finally:
await session.close()
@app.get("/users/{user_id}")
async def get_user(user_id: str, session=Depends(get_db_session)):
return await session.fetch_user(user_id)
The capture_exception call inside the dependency captures the original exception with the correct type and the dependency’s own stack frame. The re-raise still propagates the error to FastAPI, which sends the 500 response. You get a useful event and a correct HTTP response.
3. uvicorn --reload duplicates capture events
Uvicorn’s --reload flag starts a main process that watches for file changes and restarts worker processes when it detects them. Both the old process (shutting down) and the new process (starting up) run simultaneously during the reload window. If an error occurs during that window, both processes may call sentry_sdk.init and both may capture the event, sending duplicates to urgentry.
The fix is to not use --reload in any environment where event accuracy matters, including staging. Use a proper process supervisor (systemd, supervisord, or Docker with --restart=always) that restarts the process cleanly on failure. For local development, --reload is fine; for any environment connected to urgentry, use a single-worker process or a supervisor that replaces the process atomically.
If you must use --reload in staging, add a before_send hook that stamps each event with the process PID and deduplicates on the urgentry side, or suppress events from processes that are shutting down using a module-level flag.
FAQ
Does sentry-sdk[fastapi] capture errors from async route handlers automatically?
Yes. The FastAPI integration hooks into Starlette’s middleware layer and captures any exception that propagates out of an async route handler, dependency, or middleware without being caught. Exceptions you catch inside a handler and return as HTTP responses are not captured automatically; call capture_exception explicitly for those.
Can I run both sentry-sdk and OpenTelemetry in the same FastAPI app?
Yes. The sentry-sdk now uses the OTel API internally for span creation, and the two SDKs coexist without conflict. Point the Sentry DSN at urgentry and point the OTel exporter at urgentry’s OTLP endpoint (port 4318). Both signal paths land in the same issue store.
Does uvicorn --reload cause duplicate error events?
It can. The --reload flag starts a second worker process that also calls sentry_sdk.init. If an error fires during the reload window, both the old process and the new one may capture it. Use a process supervisor instead of --reload in any environment where event accuracy matters.
How do I capture errors in FastAPI BackgroundTasks?
Background tasks run outside the request context. Clone the hub before the request ends and pass it to the task function, or use sentry_sdk.push_scope inside the task. The request context closes when the response is sent; any hub reference must be captured before that point.
Does urgentry work with sentry-sdk[fastapi] without code changes?
The only change is the DSN. Set the dsn parameter in sentry_sdk.init (or the SENTRY_DSN environment variable) to your urgentry project DSN. Every integration, every capture_exception call, and every SDK option works without modification. urgentry implements the same Sentry envelope ingest endpoints the SDK targets.
Sources
- FastAPI error handling documentation — the canonical reference for exception handlers,
HTTPException, and custom error responses. - sentry-sdk FastAPI integration documentation —
FastApiIntegrationandStarletteIntegrationconstructor options, supported FastAPI versions, and auto-instrumented surfaces. - opentelemetry-instrumentation-fastapi documentation —
FastAPIInstrumentorsetup, span naming, and excluded routes configuration. - OTel semantic conventions: exceptions on spans — defines
exception.type,exception.message,exception.stacktrace, and the reserved event nameexception. - FSL-1.1-Apache-2.0 license text — the FSL license under which urgentry is distributed.
- urgentry SDK setup documentation — DSN creation, project setup, and the OTLP endpoint for mixed Sentry/OTel deployments.
- urgentry compatibility matrix — the full list of 218 Sentry API operations confirmed compatible with urgentry ingest.
Ready to wire up your FastAPI app?
urgentry runs as a single binary, accepts sentry-sdk[fastapi] and OTel out of the box, and takes under ten minutes to start receiving events. Your sentry_sdk.init call stays exactly as written above. Only the DSN changes.