widelog emits one structured event per operation instead of a line per step. Every field you attach during a request lands in the same JSON object, alongside how long the operation took and any error that ended it. It has no dependencies outside the standard library.
The shape of the problem
A single request usually leaves a trail like this:
print("Request received")
print(f"User: {user.id}")
print("Cart loaded")
print("Payment failed")
raise Exception("Something went wrong")Five lines, interleaved with every other concurrent request, and none of them knows about the others. To answer “what else was true when this failed?” you have to reconstruct the request by correlating timestamps.
One event instead
This is the whole program. Click Run and it executes in your browser, on real CPython compiled to WebAssembly, with the same widelog source the package ships.
from widelog import ErrorCatalog, ErrorSpec, init, use_logger, wide_event
init(service="checkout", environment="local")
class BillingErrors(ErrorCatalog, prefix="billing"):
INSUFFICIENT_FUNDS = ErrorSpec(
status=402,
message=lambda available, required: f"Insufficient funds: {available} of {required}",
why="The account balance did not cover the cart total",
fix="Add funds and retry",
)
def load_cart(user_id):
return {"items": 3, "total": 9999}
def charge(card, token):
# Nobody passed this function a logger. It writes to the same event anyway.
use_logger().set(payment={"method": "card", "last4": card[-4:], "token": token})
raise BillingErrors.INSUFFICIENT_FUNDS(available=5000, required=9999)
with wide_event(method="POST", path="/api/checkout") as log:
log.set(user={"id": "u_123", "plan": "premium"})
log.set(cart=load_cart("u_123"))
try:
charge("4242424242424242", "idem_abc")
except Exception as exc:
log.error(exc)One event comes out, carrying everything the request collected:
{
"timestamp": "2026-07-30T04:22:51.213Z",
"level": "error",
"service": "checkout",
"environment": "local",
"duration_ms": 0.1,
"method": "POST",
"path": "/api/checkout",
"user": { "id": "u_123", "plan": "premium" },
"cart": { "items": 3, "total": 9999 },
"payment": { "method": "card", "last4": "4242", "token": "[REDACTED]" },
"error": {
"message": "Insufficient funds: 5000 of 9999",
"status": 402,
"code": "billing.INSUFFICIENT_FUNDS",
"why": "The account balance did not cover the cart total",
"fix": "Add funds and retry",
"type": "WidelogError",
"stack": ["main.py:29 in <module>", "main.py:22 in charge"]
}
}Four things happened there without being asked for. charge() never received a logger and still
wrote to the event. The token came out redacted. The error carried its own status, code, why,
and fix, because the catalog declared them once. And the level became error on its own.
Querying this is filtering rather than correlating: status = 402 and user.plan = "premium" is one
condition over one table.
What you get
Ambient context
use_logger() reads a contextvars slot, so a helper five frames deep adds fields without
being handed a logger.
Errors that explain themselves
why says what went wrong, fix says what to do next. Declare them once in a catalog.
One adapter per runtime
ASGI covers FastAPI, Starlette, Litestar, and Django-async. A decorator covers AWS Lambda.
Secrets stay out
Key names ending in token, password, secret, and friends are redacted at any depth.