WidelogMiddleware is a plain ASGI middleware, so it works with FastAPI, Starlette, Litestar,
and Django’s async stack.
from widelog import WidelogMiddleware, init, use_logger
init(service="checkout")
app.add_middleware(WidelogMiddleware)
@app.post("/api/checkout")
async def checkout():
log = use_logger()
log.set(user={"id": user.id, "plan": "premium"})
log.set(cart={"items": 3, "total": 9999})
return {"ok": True}The middleware opens the event before your handler runs and emits it when the response starts,
recording method, path, status, and duration_ms without being told. A 5xx response also
raises the event’s level to error.
Fields merge
log.set(user={"id": "u_123"})
log.set(user={"plan": "premium"}) # user now has both keysDictionaries merge key by key at any depth. Lists concatenate. Everything else overwrites. The values are copied on the way in, so widelog never writes back into the objects you passed it.
No logger to pass around
use_logger() reads a contextvars slot, so any function in the call stack writes to the same
event without taking a logger argument:
async def charge(cart):
use_logger().set(payment={"method": "card"})This is ambient context rather than dependency injection, and the choice is deliberate.
FastAPI’s Depends resolves only in a handler signature, so injecting the logger would mean
threading log through every helper that wants to add a field.
Concurrency
Each request runs in its own task, and a task copies the context it was created from, so two requests in flight never share an event. Nothing is keyed on a thread or a request id.
await asyncio.gather(checkout(a), checkout(b)) # two events, no crosstalkWork that is not a request
Anything with a beginning and an end is an operation. Use wide_event() directly for jobs,
consumers, and scripts:
from widelog import wide_event
with wide_event(job="nightly-reconcile") as log:
log.set(rows=len(rows))The event is emitted when the block exits, including when it raises.
Outside any operation, use_logger() returns a standalone logger that emits on every call, so
a stray use_logger().info(...) in library code is never silently dropped. Those one-off lines
carry no duration_ms, because they measure nothing.
Sending events somewhere
The default sink writes NDJSON to stdout. Point sink at anything callable to send events on:
init(service="checkout", sink=my_backend.send)