Logging¶
To help you learn more about what's happening within your application, arvel provides robust logging
services that let you log messages to files, the system error log, and even structured aggregators.
arvel logs are structured — every line is an event name plus a set of key/value fields, not a
formatted sentence — so the same log reads cleanly in your terminal during development and parses as
JSON in production. It's a structlog pipeline behind the Log facade;
you never configure structlog yourself.
2026-07-11T19:20:03.114Z [info ] order.paid order_id=42 total=4200 # dev (console)
{"event": "order.paid", "order_id": 42, "total": 4200, "level": "info", "timestamp": "…"} # prod
Configuration¶
The output renderer is chosen once at boot from the environment:
app.env == "production"→ JSON (one object per line, for log aggregators).- otherwise → the pretty console renderer (colorized
event key=value), for local dev.
So a local run prints the readable format; set APP_ENV=production (or call
configure_logging(json_logs=True) yourself) to force JSON. In JSON mode an exception's exc_info is
rendered as structured frames (a machine-parseable exception field), and frame locals are
excluded — they routinely hold request data, passwords, and tokens, which must never reach the logs.
Writing log messages¶
Five levels, lowest to highest — same signature on each:
Log.debug("cache.miss", key=key) # diagnostic; usually filtered out in prod
Log.info("order.paid", order_id=42) # normal, expected events
Log.warning("rate_limit.near", pct=92) # something to keep an eye on
Log.error("payment.failed", order_id=42) # a failure that needs attention
Log.critical("db.unreachable", host=dsn) # the app can't function
The first argument is the event name — a short, stable, low-cardinality string you'll search and
group by ("order.paid", "user.login.failed"). Everything else is keyword context — the
structured fields. Don't pass an object as the event — it gets stringified and you lose the fields:
Log.info(user) # ⚠ event becomes the model's repr string
Log.info("user.loaded", user=user.to_dict()) # ✓ fields land under `user`
Context values must be serializable — a raw Model isn't, so convert it with
.to_dict(), which also honors __hidden__ so fields like password never
leak into the log.
Two global shorthands (see Helpers) resolve the same facade:
from arvel import logger, info
logger().info("order.paid", order_id=42) # logger() → the Log facade, to chain on
info("order.paid", order_id=42) # info() → the info-level shortcut
Contextual information¶
Per-logger — bind() returns a new logger that carries the given fields on every call, handy for
a unit of work:
log = Log.bind(request_id=rid, user_id=user.id)
log.info("checkout.start") # both fields ride along
log.info("checkout.done", total=total) # …and here
Ambient (request/task-wide) — with_context() binds fields into the current async context, so
every subsequent log event in this request/task carries them without threading a logger through.
Backed by contextvars, so concurrent requests never bleed into each other:
Log.with_context(request_id=rid, tenant=tenant.slug)
# … any code here logs request_id + tenant automatically …
Log.clear_context() # typically at the end of the request
Scoped (a block / one unit of work) — bound_context() is a context manager that binds fields
for the duration of a with block and restores the prior context on exit — so it nests cleanly
and never clobbers surrounding context (unlike with_context/clear_context, which set/wipe
globally):
with Log.bound_context(job_id=job_id):
# every log line in here carries job_id; anything already bound (e.g. request_id) survives
...
# job_id is gone again here
Request & queue correlation¶
Two things are wired for you:
- HTTP —
RequestContextMiddlewarebinds arequest_id(auuidv7, or an incomingX-Request-ID) for every request, so all of a request's log lines share it. - Queue — when you dispatch a job, the request's entire bound log context (its
request_idplus anything else you bound —user_id,tenant_id, …) is captured and rides the job across the broker. On the worker the job re-binds all of it, plus its own freshjob_id(auuidv7). So a job's logs carry the same context as the request that dispatched it, and you can trace one API request to every queue job it spawned:
{"event": "checkout.start", "request_id": "018f…", "user_id": 42} # in the request
{"event": "SendReceipt.handled", "request_id": "018f…", "user_id": 42, "job_id": "018f…"} # in the job
Bound values are coerced JSON-safe before they ride the broker, so keep them small identifiers.
To pin a specific exception type to a quieter level when it's reported, use the exception handler's
level() — see Error Handling.
Writing to specific channels¶
channel() tags lines with a channel field to separate concerns (audit, billing, …) so you can
filter them downstream:
Log.channel("billing").info("invoice.sent", invoice_id=inv.id)
# {"event": "invoice.sent", "channel": "billing", "invoice_id": …, …}
Secrets never go in logs
Structured context makes it easy to log too much. Never put passwords, tokens, API keys, or full
request bodies into log context. Log identifiers (user_id), not credentials.
See also¶
- Helpers — the
logger()/info()shorthands. - Error Handling — how uncaught exceptions are reported and levelled.
- Telemetry — logs exported over OTLP with the active trace context attached.
- Context — the same
contextvarsmechanism for non-logging ambient state.