Request ids and the development capture¶
Debugging with an agent is cheap when the context is already written down
and expensive when the agent has to rebuild it from logs and code. Karni
writes it down for you in development, with no configuration: one id per
request that follows the work everywhere, and a capture of every request
and every crash under .karni/ in the project. karni trace,
karni replay and karni explain --failure read it
(trace and replay).
This is a debugging layer, not monitoring: it runs only with DEBUG on,
writes plain files, needs no service, and keeps a bounded amount.
Request ids¶
Every request gets an id from karni.tracing.RequestIdMiddleware (first in
Karni's MIDDLEWARE). The id travels with everything the request causes:
| Where | How it gets there |
|---|---|
| Logs | bound into structlog's context, and django-structlog's RequestMiddleware logs the same id |
| The response | X-Request-ID: <id> header, which is what you hand to karni trace |
| Celery tasks enqueued during the request | a karni_request_id message header (before_task_publish), restored in the worker (task_prerun) |
| Their retries | a retry is published from inside the running task, so it carries the same id, minutes later too |
| A task with no id (beat, a shell) | gets a fresh one, so its own email and changes still correlate |
X-Karni-Request-ID on every EmailMessage.send() (send_mail, account emails, …) |
|
| Outgoing HTTP (a webhook you send) | karni.tracing.outgoing_headers() gives {"X-Request-ID": id} for your call |
| Incoming webhooks | a delivery is a new request with its own id, and the payments module links it to the checkout it settles (see linking) |
| Change history | ChangeLog.request_id and ChangeBatch.request_id. Search by it in the admin's Change log |
import requests
from karni.debug import trace_event
from karni.tracing import outgoing_headers
response = requests.post(url, json=payload, headers={**outgoing_headers()}, timeout=10)
trace_event("webhook.sent", url=url, status=response.status_code)
Outside a request, with karni.tracing.bound_request_id() as rid: gives a
management command or a script an id of its own.
Which id is used. A client's X-Request-ID is accepted only when it
can be trusted. That is always in development (DEBUG on). With DEBUG off
it is only when KARNI_REQUEST_ID_TRUST_HEADER = True and NUM_PROXIES is
not 0. This is the same trust model as X-Forwarded-For (security).
Turn it on only when the proxy in front overwrites the header on every
request (in Caddy: header_up X-Request-ID {http.request.uuid} inside
reverse_proxy). Otherwise the id is a fresh UUID and the client's value is
replaced, so nobody can write ids into your production logs. Ids are 1–128
letters, digits, _ and - (they name files). Anything else is replaced,
even in development. karni.W920 warns when RequestIdMiddleware comes
after django-structlog's middleware.
What .karni/ holds¶
With DEBUG on, under the project root (BASE_DIR, else the directory
above the settings package):
.karni/requests/<request_id>.json every request (static files and CORS preflights excepted)
.karni/failures/<failure_id>.json every unhandled exception, in a request or a Celery task
.karni/trace/<YYYY-MM-DD>.jsonl one event per line, each with its request id
The rest of .karni/ (config.toml, modules/) is project configuration
and stays committed. The three capture directories each carry their own
.gitignore, and the scaffold's .gitignore and .dockerignore list them.
The request record¶
A contract other tools read (version 1, exactly these keys):
{"version": 1, "request_id": "5f0c2e9a-…", "at": "2026-10-21T10:14:03.512+00:00",
"method": "POST", "path": "/api/bookings/3/pay/", "query": {"expand": ["tier"]},
"headers": {"Authorization": "Bearer [redacted]", "Content-Type": "application/json"},
"body": {"note": "window seat"}, "content_type": "application/json",
"user": {"id": "7", "email": "ada@example.com", "username": null,
"is_staff": false, "is_superuser": false},
"view": "events.viewsets.BookingViewSet", "resource": "booking", "action": "pay",
"view_kwargs": {"pk": "3"}, "status": 403, "duration_ms": 18.4,
"error_code": "permission_denied", "failure_id": null}
view is the dotted path of the view (the viewset for a DRF viewset, or ""
when no URL matched). resource is the router basename and action is the
DRF action. body is the parsed JSON, a form as {field: value} (an upload as
"<file name.pdf, 1234 bytes>"), a short note for a body that is not
captured (another content type, over 64 KiB), or null. error_code is the
Karni code the response carries (E801), else DRF's error code
(permission_denied, not_authenticated, not_found, invalid), else
null. failure_id names the failure snapshot when the request raised.
The failure snapshot¶
Written on an unhandled exception (got_request_exception, and
task_failure for a Celery task that failed for good). It holds what an
agent would otherwise reconstruct:
- The request (method, path, query, headers, body) or the task (name, id, retries, its arguments by parameter name).
- The resolved view, viewset, router basename, action and serializer class.
- The user with their roles and their grants on the resource's model.
- The SQL the request ran, with timings, and the repeated statements grouped (statement text with placeholders, since parameters are never recorded).
- The exception, its chain, a Karni code when it has one, and the full traceback, outermost first. Frames of the project and of Karni (and its modules) carry their local variables, rendered safely (no query runs, a queryset is not evaluated, a model instance shows its fields) and truncated. Library and standard-library frames do not.
origin: the frame it failed in. That is the innermost project frame, else the innermost Karni frame.- The relevant settings:
DEBUG, each database's engine, name, host and port (never a password), the project's own apps and everyKARNI_*setting. Also the Python, Django, DRF and Karni versions.
The trace¶
One JSON object per line: v, at (UTC), request_id, event, pid,
the event's fields, and optionally refs. Events Karni writes:
| Event | Fields |
|---|---|
request.start / request.end |
method, path, and on end: status, duration_ms, view, user, error_code, failure_id |
task.enqueued |
task, task_id, retries, eta, queue |
task.started |
task, task_id, retries, eager |
task.retried |
task, task_id, retries, reason |
task.succeeded / task.failed |
task, task_id, duration_ms, and on failed: exception, message, failure_id |
email.sent |
to, cc and bcc counts, subject, sent |
audit.batch |
batch_id, kind, label |
webhook.received / webhook.matched (karni-payments) |
provider, event_id, event_type, plus refs |
payment.checkout (karni-payments) |
provider, amount, currency, plus refs |
Your code adds its own with karni.debug.trace_event("name", field=value,
refs=[...]). It does nothing with DEBUG off.
Linking requests¶
A ref names a row: karni_payments.Payment:<pk>. Two requests whose events
share a ref are linked in karni trace: the checkout that created a payment
and the webhook delivery, minutes later, that settled it.
Redaction¶
Everything under .karni/ passes the same rules as the
change history:
- By name. A key, form field, query parameter, header, setting or local
variable whose name contains one of
KARNI_AUDIT_REDACT(password,token,secret,keyby default), or that a model lists inKarni.sensitive, is recorded as"[redacted]". Nested bodies are included. - Credential headers.
Authorization(the scheme is kept:Bearer [redacted]),Proxy-Authorization,Cookie(cookie names kept),Set-Cookie,X-CSRFToken. - By value.
SECRET_KEYand its fallbacks, every string setting with a sensitive name, and database and broker passwords are known secrets. So is every value redacted by name in the request being captured (the password in the body, the bearer token, the cookie values). A string equal to one is replaced, and a longer string containing one has it cut out, wherever it appears (a local under an innocent name, an exception message, SQL text).
tests/test_debug.py::test_no_secret_reaches_the_capture_directory sends
passwords (JSON, form and multipart bodies), a token in the query, a real
bearer token, a session cookie, a CSRF header, a Karni.sensitive value and
a pattern-named field, crashes with all of them and SECRET_KEY in the
locals and the exception message, and asserts none of their bytes appear in
any file under .karni/.
Limits and switches¶
| Setting | Default | What it does |
|---|---|---|
KARNI_DEBUG_CAPTURE |
True |
False turns the capture off. With DEBUG off it never runs |
KARNI_DEBUG_CAPTURE_KEEP |
200 |
request records kept, and failure snapshots kept (the oldest go first) |
KARNI_DEBUG_TRACE_DAYS |
7 |
days of trace files kept |
KARNI_REQUEST_ID_TRUST_HEADER |
False |
accept a proxy's X-Request-ID with DEBUG off |
KARNI_REQUEST_ID_EMAILS |
True |
stamp X-Karni-Request-ID on outgoing email |
Inert in production. With DEBUG off, CaptureMiddleware raises
MiddlewareNotUsed when Django builds the handler, so it is not in the
chain at all, and trace_event returns before touching the disk.
tests/test_debug.py::test_capture_is_inert_when_debug_is_off makes
requests, crashes and traces with DEBUG off and asserts .karni/ was
never created.
Escape hatches. Subclass karni.debug.middleware.CaptureMiddleware and
override should_capture(request) or max_body_bytes. Or remove
RequestIdMiddleware or CaptureMiddleware from MIDDLEWARE.