Outbound rows from background work fell back to `bg:<asyncio task name>`.
Nothing in main.py passes `name=` to `create_task`, so every loop keeps one
auto-assigned name - `Task-5` - for its entire life, and every call it ever
makes is written with that same `request_id`. Measured: fifteen ACME calls
across five renewal ticks came out as one id.
That is not a cosmetic grouping problem. `GET /api/request-logs/{id}` returns
every other row sharing the id as `related`, up to 100, and the UI presents
that list as "the calls this request triggered" - it is the feature's headline.
An operator opening a failed renewal was therefore shown up to a hundred
unrelated calls, possibly spanning days, labelled as the trace of the one they
were reading. In a forensics tool a confidently wrong trace is worse than no
trace. Task numbers are reused across restarts too, so `bg:Task-5` could mean a
different loop after a redeploy.
begin_background_trace(label) opens `bg:<label>:<uuid12>` for one iteration and
is called at the top of the three loops that make outbound calls:
complete_pending_acme_orders, check_letsencrypt_renewals, monitor_agent_status.
The loop task is dedicated, so the next iteration overwrites it and there is
nothing to reset.
The fallback for background code that has not been wrapped now mints a unique
id per call instead of reusing the task name. That errs toward too little
grouping rather than too much: a row that stands alone is honest, a row falsely
grouped with a hundred others is not.
Verified: five ticks of three calls produce five distinct ids with the three
calls of each tick sharing one, and four calls from an unwrapped task produce
four distinct ids.
Until now the only record of what happened was `user_activity_logs`, which
stores non-GET 2xx operations with no bodies. When something failed you could
see that a counter went up, never what was sent or what came back.
This adds one queryable timeline covering both directions:
- inbound: every API call, including GETs and including 4xx/5xx, with the
user, client IP, status, duration and — redacted, size-capped — the request
and response bodies.
- outbound: every HTTP call the backend makes, tagged with who it went to
(ACME/Let's Encrypt, Cloudflare, GoDaddy, HAProxy stats, agents, the ACME
diagnostics probe).
Outbound rows inherit the inbound request's id, so one operator action and the
CA/DNS calls it triggered read as a single trace: opening a failed "Request
Certificate" shows the exact POST /acme/new-order and the CA's 429 underneath.
Implementation notes:
- Capture is a pure-ASGI middleware that TEES the request and response streams
rather than draining them. `await request.body()` inside a BaseHTTPMiddleware
would consume the receive channel and break the raw-body agent heartbeat
handler. Registered last so it is outermost: it then sees the final
client-visible response and seeds correlation_id_context before the error
handler reads it.
- Rows are written by a batching background writer with a bounded queue, so the
request path never awaits the database and a saturated logger drops rows
visibly (surfaced on the page) instead of blocking. Redaction runs on the
writer, off the request coroutine.
- Secrets never land: headers are an allowlist with Authorization/Cookie kept
only as a presence marker; body keys and value shapes are redacted
(passwords, tokens, api_token, API keys, private-key PEMs, JWTs); the ACME
JWS request body is never stored, because a stored protected+signature pair
is a replayable credential — a summary is logged instead; DNS-provider errors
record only the exception type; the ACME HTTP-01 challenge endpoint is
excluded so key_authorization is never captured.
- Retention is operator-configurable in Settings -> Request Log: separate day
counts for successful and failed rows (7 / 30) plus a hard row cap (500k),
whichever is reached first. Pruned in batches under a Postgres advisory lock,
with the day counts bound as parameters, never interpolated.
- New permissions requestlog.read / requestlog.manage. super_admin and
security_admin get both, operator gets read, viewer gets neither.
Schema: one new table (request_logs) plus its settings seed, SCHEMA_VERSION
10 -> 11, auto-migrated. No existing table altered, no agent or rendered-config
change. Kill switches: REQUEST_LOG_ENABLED=false (middleware never registered)
or the `enabled` toggle in Settings.
Tests: 245 new (7 backend files + 1 frontend), full suite 1655 backend +
17 frontend passing.