Request Log
The Request Log records every request that reached /v1/*: what was asked for,
what answered, what it cost, and — the part worth coming here for — what was
tried, in what order, and what was never called.
Administrative changes are not here. They are in the Audit Trail.
What counts as a request
Four outcomes, and all four are recorded:
| Outcome | When |
|---|---|
| Answered | The request succeeded |
| From cache | Served from the semantic cache without reaching a provider |
| Refused | A guard blocked it |
| Failed | The gateway returned 4xx or 5xx, a stream failed mid-flight, or the client disconnected |
Requests that failed or were abandoned are kept. The recorder fires both when a response finishes and when the connection closes, guarded so a normal response is not counted twice. Previously only the first fired, so a client that timed out before the gateway finished its retry ladder produced no row at all — the requests you most wanted to see were the ones that were not there.
An abandoned request is recorded with status 499 and an error of:
{ "code": "CLIENT_DISCONNECTED", "message": "The client closed the connection before a response was sent." }It appears on the Failed tab reading Failed · 499, with that message inline
on the row. There is no separate “aborted” tab; the status enum has four values
and inventing a fifth for the console alone would put the page out of step with
the data.
A stream that failed after its headers were already flushed is also recorded as
failed, despite the 200 on the wire.
The routing trail
This is what the detail drawer is for. Every request carries an ordered list of the providers in its chain, each classified as one of three things:
| Rung | Meaning |
|---|---|
| Answered | This provider served the request |
| Failed | This provider was called and returned an error — the error is shown verbatim |
| Not called | This provider was in the chain and was never reached |
“Not called” covers two different situations, and the drawer distinguishes them. A provider later in a chain that succeeded earlier was simply not needed. A provider skipped because its circuit breaker was open was not called even though its turn came — the drawer says so, and notes the breaker closes again once a trial request succeeds.
Above the list, a one-line summary: “Served by openai on attempt 2 of 3, after 1 failed attempt and 1 never called”, or “The chain was exhausted — 2 tried and failed, 1 never called”. When the chain is empty it says why: served from cache, refused by a guard, or failed before routing ran.
Per-attempt failure reasons are recorded. The error string on each rung is a declared field; it was previously undeclared and silently stripped on write, so the trail recorded that an attempt failed and never why.
Guard results
Each entry records what the guards decided, rendered as prose rather than enum values: Refused the request, Flagged it, Recorded only, or No match — with the confidence as a percentage.
Cost
Cost is computed when the row is written, from the model’s configured pricing at that moment. Per-unit capabilities add their own rate — per image, per minute of audio.
A model with no pricing row records a cost of 0, not null. That is the same
trap least-cost routing has: an unpriced model looks free rather than looking
unknown. If your cost figures look implausibly low, check the
model catalogue for unpriced rows before you trust them.
Request and response bodies
Not captured by default. Set requestLogging.captureBodies on Settings
to turn it on. With it off, the drawer says so rather than showing an empty
panel.
Retention
This is the part that surprises people, so it is stated plainly.
A daily job hard-deletes request log entries older than seven days, at 02:00, on any deployment running the worker. It ignores the per-deployment retention setting.
The Settings page offers a retention field accepting 1 to 3650 days, and it does control the expiry stamp written onto each row — but the cleanup job runs independently and takes the shorter of the two in practice. Treat the request log as a rolling seven-day window and export anything you need to keep for longer.
Audit entries are unaffected; they have their own, much longer retention.
If the s3-backups entitlement is present, a weekly job archives
audit_logs, request_logs and usage_records to S3 on Sundays at 03:00.
Reading it in the console
Tabs: All, Failed, Refused, From cache, Succeeded. The count badge shows on the selected tab only — it is the total for the query that produced the rows, and the gateway is not asked for four more counts nobody looked at.
Filters: search (request ID, model or provider), provider, a “slower than” preset, and a date range. The end date covers the whole day in local time. Filters are mirrored into the URL, so a filtered view is a shareable link. Two further filters, model and API key, are set by deep link rather than typed.
Columns: When, Outcome, Provider / model, Latency, Tokens, Cost, Request ID. When, Latency and Cost are sortable.
Detail drawer: time, latency, provider, model, HTTP status, cost, token split, request dialect (OpenAI or Anthropic), the routing rule and strategy that chose the chain, the API key and user, the request ID, then the routing trail, guard results, cache key, and the bodies if captured.
Endpoints
| Method | Path | Permission |
|---|---|---|
GET | /api/admin/requests | usage:read |
GET | /api/admin/requests/:requestId | usage:read |
The detail route is keyed on the gateway request ID, not the database ID.
GET /requests accepts page, limit (capped at 100), sort (timestamp,
latencyMs, cost, model, provider, statusCode), order, status
(success, error, cached, blocked — errors is accepted as a synonym for
error), provider, model, apiKeyId, userId, startDate, endDate,
minLatency, maxLatency, minCost, maxCost and q.
The list response is a projection — bodies, the routing trail, guard results and the cache key are only on the detail route.
There is no export endpoint and no delete endpoint. Entries are removed by retention alone.
Own-only callers
A caller holding usage:read with an own-only grant — the built-in user role,
and any API key with the usage:read capability — has userId forced to their
own on both routes, and a userId query parameter from them is ignored. They
see their own traffic and nothing else.
Related
- Audit Trail — administrative changes
- Routing — the rules that build the chain
- Cache — why an entry reads “From cache”
- Models — where cost figures come from