Skip to content

Introduction

What a wide event is, and why one per operation beats a line per step.

Updated View as Markdown

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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close