What structured logging is and why plain-text logs fail at scale
A log line is usually written for a human reading a terminal: logger.info(f"GET /orders/42 returned 200 in 38ms"). Structured logging treats each line as an event record with named fields. The message becomes one short key, and the data lives in its own keys: request_id, path, status_code, duration_ms.
Here is the same event both ways:
- Text:
2024-05-01 12:00:03 INFO GET /orders/42 returned 200 in 38ms - JSON:
{"event": "request_finished", "request_id": "a1b2c3", "method": "GET", "path": "/orders/42", "status_code": 200, "duration_ms": 38}
The text line breaks as soon as someone edits the wording. If "returned 200 in" becomes "-> 200 (38 ms)", every regex in your dashboards and alerts stops matching, and nothing warns you. The JSON line keeps a fixed schema. A log aggregator indexes each field directly, so a query like status_code >= 500 AND path = "/orders/*", or a p95 of duration_ms grouped by path, needs no parsing rules. A shared request_id also lets you pull every line from one request out of output from many interleaved workers.
| Concern | Plain-text lines | JSON event records |
|---|---|---|
| Filtering | Regex per message format | Field queries |
| Aggregation (counts, percentiles) | Extract numbers first | Native on numeric fields |
| Survives message rewording | No | Yes |
| Correlating across instances | Manual grep | Join on request_id |
| Readability in a raw terminal | Good | Poor without a renderer |
Structured logs pay off most for teams that run several replicas or services behind a central aggregator, where nobody reads raw output line by line. A single-process CLI tool or a small internal script that someone tails locally can stay with plain text. The JSON overhead buys little there.
import logging
import structlog
# Plain text: the data is baked into the sentence, so any parser
# has to regex it back out, and rewording the message breaks that parser.
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logging.getLogger("plain").info(f"GET /orders/42 returned 200 in {38}ms")
# Structured: each call emits one event record with named fields.
structlog.configure(
processors=[
structlog.processors.add_log_level, # adds "level"
structlog.processors.TimeStamper(fmt="iso", utc=True), # adds "timestamp"
structlog.processors.JSONRenderer(sort_keys=True), # one JSON object per line
]
)
log = structlog.get_logger()
# The message is a short, stable event name. The data lives in its own keys,
# so an aggregator can filter on status_code or compute percentiles on
# duration_ms without any parsing rules.
log.info(
"request_finished",
request_id="a1b2c3",
method="GET",
path="/orders/42",
status_code=200, # stays an int, so numeric queries work
duration_ms=38,
)
# Output shape (timestamp will differ):
# {"duration_ms": 38, "event": "request_finished", "level": "info",
# "method": "GET", "path": "/orders/42", "request_id": "a1b2c3",
# "status_code": 200, "timestamp": "2024-05-01T12:00:03.000000Z"}
The same request event logged twice: once as an f-string line through the standard library and once as a JSON record with typed, named fields through structlog's JSONRenderer.
How the pipeline works: structlog, the stdlib bridge and contextvars
Every log call passes through two possible paths that end at one renderer. Your application code calls a structlog logger, which sends an event dict (a plain Python dictionary) through a processor chain. Each processor is a function that receives the dict and returns a modified one. Typical processors merge context, add the level and timestamp, and format exceptions. Uvicorn, SQLAlchemy, httpx and other libraries log through the standard logging module instead. They never touch structlog directly.
The bridge is structlog.stdlib.ProcessorFormatter, attached to a single handler on the root logger. It runs foreign stdlib records through a pre-chain of the same processors and hands structlog's own events to the final renderer. Both paths emit identical JSON on one stream, so the aggregator sees one schema rather than two.
request ──► ASGI middleware ──► bind_contextvars(request_id=...)
│ │
▼ ▼
route handler contextvars (per task)
│ │ │
structlog log stdlib log │
│ │ │
▼ ▼ │
processor chain ◄── merge_contextvars
│
▼
ProcessorFormatter on root handler ──► JSON / console renderer ──► stdout
Per-request fields such as request_id live in contextvars. When asyncio creates a task, it copies the current context into that task. Values bound inside one request stay in that request's copy. A thread-local would fail here. A single event-loop thread interleaves many concurrent requests, so they would all share one thread-local slot. Request A's ID would then appear on request B's log lines whenever B resumed after A had written to it.
request A request B (one event-loop thread)
| |
v v
+---------------------------------------------+
| ASGI middleware |
| bind_contextvars(request_id=...) per task |
+---------------------------------------------+
| |
v v
+---------------+ +---------------+
| ctx copy A | | ctx copy B |
| request_id=A | | request_id=B |
+---------------+ +---------------+
| route handlers and libraries log
v v
+---------------------+ +------------------------+
| structlog logger | | stdlib logger |
| processor chain: | | (uvicorn, httpx, |
| merge_contextvars, | | SQLAlchemy) |
| level, timestamp | +------------------------+
+---------------------+ |
| v
| +------------------------+
| | root handler with |
+------------------>| ProcessorFormatter |
| (pre-chain for stdlib) |
+------------------------+
|
v
JSON / console renderer ---> stdout
Each request's asyncio task holds its own context copy, and both structlog and stdlib log calls reach one ProcessorFormatter on the root handler, so every line shares one schema.
Configuring structlog once for JSON in production and console output locally
That is why the configuration starts with structlog.contextvars.merge_contextvars, which reads per-task context instead of thread-local state. Put every setting in one module, such as logging_config.py, with a single configure_logging() function that you call once at startup, before the app object is created.
The shared processor chain
Build one list of processors that both structlog loggers and stdlib loggers use: merge_contextvars, add_log_level and TimeStamper(fmt="iso", utc=True). Choose the renderer from an environment variable such as LOG_FORMAT. In production, append format_exc_info and then JSONRenderer(). Locally, use ConsoleRenderer() only. It formats tracebacks itself and warns if format_exc_info runs before it.
Bridging the stdlib
End the structlog chain with ProcessorFormatter.wrap_for_formatter and attach one StreamHandler to the root logger. Give that handler a ProcessorFormatter whose foreign_pre_chain is the shared list, so records from uvicorn, SQLAlchemy or httpx get the same timestamps, levels and context. Pass log_config=None to uvicorn, or clear the handlers on uvicorn, uvicorn.error and uvicorn.access and set propagate=True on each. Otherwise uvicorn installs its own formatters.
Common misconfigurations
- Duplicate lines. These appear when uvicorn's handlers and the root handler both emit the same record.
- Escaped JSON inside
event. This happens whenJSONRendererruns in the structlog chain and again in the formatter, so the second pass encodes the first pass's output as a string. - Missing library logs. Records disappear when
dictConfigkeepsdisable_existing_loggers=Trueor when the root level filters them out.
Dev and prod render through different code paths. Check output from your own code, uvicorn and at least one library in both modes.
# logging_config.py: call configure_logging() once, before creating the app
import logging, os, sys
import structlog
def configure_logging() -> None:
# Shared by structlog loggers and foreign (stdlib) records
shared = [
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
]
if os.getenv("LOG_FORMAT", "console") == "json":
final = [structlog.processors.format_exc_info, structlog.processors.JSONRenderer()]
else:
final = [structlog.dev.ConsoleRenderer()] # renders tracebacks itself
# No renderer here: rendering happens once, in the formatter
structlog.configure(
processors=shared + [structlog.stdlib.ProcessorFormatter.wrap_for_formatter],
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
formatter = structlog.stdlib.ProcessorFormatter(
foreign_pre_chain=shared,
processors=[structlog.stdlib.ProcessorFormatter.remove_processors_meta, *final],
)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(formatter)
root = logging.getLogger()
root.handlers = [handler] # replace, don't append: avoids duplicate lines
root.setLevel(logging.INFO)
# Route uvicorn through the root handler (or run uvicorn with log_config=None)
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
lg = logging.getLogger(name)
lg.handlers.clear()
lg.propagate = True
A single configure_logging() that picks JSON or console rendering from LOG_FORMAT and routes structlog, uvicorn and other stdlib loggers through one ProcessorFormatter.
Implementing request ID and access-log middleware as pure ASGI
The middleware is a plain callable that takes scope, receive and send. For any scope type other than http, it passes the call through untouched. For HTTP, it first calls structlog.contextvars.clear_contextvars() so no values leak from a previous request handled on the same task. It then reads X-Request-ID and accepts it only if it is 1–64 characters drawn from [A-Za-z0-9._-]. Anything else is replaced with uuid.uuid4().hex. This blocks log injection and keeps oversized headers out of your index. Next it binds request_id, method, path and the client address. It wraps send so that on http.response.start it records the status code and appends the request ID to the response headers. Finally, in a finally block, it emits one access event with status_code and duration_ms computed from time.perf_counter(), which is monotonic and immune to wall-clock jumps.
That event replaces uvicorn's access log. Start uvicorn with --no-access-log so each request produces one line, not two with different shapes.
Pure ASGI is preferable to BaseHTTPMiddleware. That class has historically run the endpoint in a separate task, so contextvars set downstream did not propagate back. It also had known problems with streaming responses and background tasks. A raw ASGI callable has none of these indirections.
| ID policy | Correlation across services | Spoofing risk | Use when |
|---|---|---|---|
| Trust any client ID | Full | High | Internal-only traffic |
| Trust only upstream proxy | Full behind your edge | Low, if the edge strips or overwrites the header | Typical production |
| Always generate | None upstream | None | Public edge services |
import re, time, uuid
import structlog
logger = structlog.get_logger("access")
# Accept only short IDs from a safe alphabet, which blocks log injection.
_VALID_ID = re.compile(r"[A-Za-z0-9._-]{1,64}")
class RequestIDMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http": # lifespan and websocket pass through untouched
return await self.app(scope, receive, send)
structlog.contextvars.clear_contextvars() # drop leftovers from a prior request
raw = dict(scope["headers"]).get(b"x-request-id", b"").decode("latin-1")
request_id = raw if _VALID_ID.fullmatch(raw) else uuid.uuid4().hex
client = scope.get("client") # (host, port) tuple, or None
structlog.contextvars.bind_contextvars(
request_id=request_id, method=scope["method"], path=scope["path"],
client=f"{client[0]}:{client[1]}" if client else None)
status_code = 500 # stays 500 if the app raises before it starts a response
start = time.perf_counter() # monotonic, so wall-clock jumps cannot skew it
async def send_wrapper(message):
nonlocal status_code
if message["type"] == "http.response.start":
status_code = message["status"]
headers = list(message.get("headers", []))
headers.append((b"x-request-id", request_id.encode("ascii")))
message = {**message, "headers": headers} # echo the ID to the caller
await send(message)
try:
await self.app(scope, receive, send_wrapper)
finally: # exactly one access event per request, even when the app raises
logger.info("access", status_code=status_code,
duration_ms=round((time.perf_counter() - start) * 1000, 2))
# Usage: app.add_middleware(RequestIDMiddleware), then run: uvicorn main:app --no-access-log
A pure ASGI middleware validates or generates X-Request-ID, binds request context with structlog, echoes the ID in the response, and emits a single access event that replaces uvicorn's access log.
Logging exceptions and propagating context beyond the request coroutine
Inside a handler, call logger.exception("payment_failed") from an except block, or pass exc_info=True to any level. Either way the exception travels as a value on the event rather than as text glued to the message. With format_exc_info or dict_tracebacks in the processor chain, the JSON renderer emits the traceback as an exception field, so a single log record holds the error, its stack and the bound request_id. Your log backend can then group and filter errors by those fields.
Register a catch-all handler for Exception that does two things: log with exc_info, and return a 500 body containing request_id. A user who reports that ID gives you a direct lookup key. Starlette runs the catch-all handler in its outermost error middleware, which can execute after your request-ID middleware has cleared its contextvars. For that reason, store the ID on scope["state"] as well and read it from request.state in the handler.
Context propagates in two cases:
- asyncio tasks copy the current context when you create them.
- Sync endpoints and dependencies run in Starlette's threadpool with a copied context, so their logs carry
request_id.
Context does not propagate in these cases:
- Thread pools you manage yourself.
loop.run_in_executordoes not copy context. Wrap the call incontextvars.copy_context().run. - Separate processes and message queues. Put
request_idin the message payload and bind it again in the consumer.
BackgroundTasks run after the response is sent, and how they interact with your middleware's cleanup depends on how that middleware is built. Write a test that asserts request_id is present on a background task's log line rather than assuming it is.
Testing the setup with pytest and keeping sensitive data out of logs
Testing logging means asserting on structured fields, not matching rendered strings. structlog.testing.capture_logs() is the quickest helper, but it temporarily replaces your processor chain, so merge_contextvars never runs and request_id will not appear. To exercise the real pipeline, append a structlog.testing.LogCapture instance to your configured processors in a fixture. If you route through the stdlib bridge, you can also read caplog.records and parse each message.
Asserting request isolation
Send two requests at once with asyncio.gather over an httpx.AsyncClient bound to the app through ASGITransport. Then assert three things:
- Each response's
X-Request-IDheader matches therequest_idon its own access-log entry. - The two IDs differ.
- No log entry carries the other request's ID.
This test fails if middleware binds context to a shared object instead of a contextvar, which is a common regression.
Redaction as a backstop
Add a processor before the renderer that walks the event dict and replaces values under keys such as authorization, password, token, cookie and api_key with a fixed placeholder. Match keys case-insensitively and recurse into nested dicts. Treat this as a safety net, not the policy. The policy is that middleware never binds full request bodies or header maps, because any key the deny-list does not anticipate leaks.
A fixed field schema
Define an allow-list of access-log fields, for example method, route template, status, duration and request ID, and assert in a test that entries contain nothing else. Log the route template (/users/{id}) rather than the raw path. Leave out query strings, user agents and payloads: they carry PII and unbounded cardinality, which hurts indexing cost and query performance in most log backends.
When to avoid it, alternatives, and impact on cost and engineering time
All of this has a price. Bridging stdlib loggers into structlog means maintaining two configurations that must agree on processors and output format. Every log call builds a dict, runs it through processors and serializes it to JSON. Profile that cost on your own hot paths instead of assuming it is negligible. Stale context is the subtler failure: if a contextvar is not cleared, or a worker thread inherits a stale copy, one request's request_id appears on another request's lines.
JSON also makes logs larger, and aggregators bill by volume:
Illustrative estimate (assumed numbers, not a benchmark): daily GB = requests/day × lines/request × bytes/line ÷ 10⁹. With 5,000,000 requests, 4 lines and 600 bytes, that is 12 GB/day. At an assumed $0.50/GB, it costs $6/day, or about $180 a month.
| Option | Good fit | Main trade-off |
|---|---|---|
stdlib logging + JSON formatter |
Small services, few dependencies | Context binding is manual and easy to skip |
| structlog without the stdlib bridge | Greenfield code you fully control | Third-party library logs stay unstructured |
| Distributed tracing | Latency and cross-service call graphs | Usually sampled, so it does not record every event |
A request ID does not replace tracing. It identifies one hop, while a trace links spans across services. When tracing is present, log trace_id and span_id next to request_id so you can move from a log line to its trace.
The return on this work comes during incidents. With a consistent schema, an on-call engineer filters on one request_id and gets that request's full history. Without one, they grep several services and line up events by timestamp, which breaks down under concurrency and clock skew.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to fastapi structured logging, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.