Logs API
The Logs API provides access to the structured request log written by the log phase of the gateway. Each entry records full identity, routing, security, token usage, cost, and timing for one inference request.
Base URL: https://<your-gateway-host>/admin/v1
GET /logs
Returns an array of log entries in reverse-chronological order (newest first). Required role: admin or tenant_admin. Non-admin callers are always scoped to their own tenant server-side; a spoofed tenant_id is ignored, and a caller with no tenant is refused 403 { "error": "forbidden" } (the query never runs). A platform admin may target one tenant with tenant_id or omit it (and an empty value) for a global, all-tenant view. The global view (and the CSV export) excludes synthetic fixture tenants — e.g. the e2e-synthetic monitoring prober — so it reflects real traffic; a tenant-scoped view (tenant_id=…) still returns them. The console export's column set is listed under Exporting from the console.
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
tenant_id |
string | — | Filter by tenant UUID. Honoured only for callers with the admin role; every other caller (including tenant_admin) is scoped to their own tenant server-side, and a caller with no tenant is refused 403. |
gateway_id |
string | — | Filter by gateway UUID. |
provider |
string | — | Filter by provider name (e.g. openai, anthropic). |
model |
string | — | Filter by model name (e.g. gpt-4o). |
status |
integer | — | Filter by HTTP status code returned to the client (e.g. 200, 429). |
blocked |
integer | — | 1 to return only blocked requests. Omit to return all. |
guardrail_outcome |
string | — | Filter by guardrail result. One of: blocked, scrubbed, flagged, degraded, compliance, any; any other value is rejected with 400. degraded matches requests that reached a provider without the protection they were configured to get — any gap marker below, a guardrail_verdict of error or indeterminate, or a truncated meta. A request refused before dispatch is never degraded, however loudly the detector failed: nothing left. compliance matches only the incident markers (today: pii_unmasked_egress_under_mandate) — the auditor's question, unmixed with scanner timeouts, and never time-bounded by default. any matches either: a guardrail fired or there was a gap. See Protection markers below. |
since |
integer | — | Return only entries at or after this time, as Unix milliseconds. Defaults to 30 days ago when guardrail_outcome=degraded and no since is given: that filter reads the meta markers, which no index can serve, so an unbounded query would scan the whole request log. An explicit since is always honoured, however far back, and the admin console pre-fills the visible From date when you select that filter so the window is never applied silently. compliance and any are not defaulted. |
until |
integer | — | Return only entries at or before this time, as Unix milliseconds. Use with since for a date range. |
metadata_only |
boolean | false |
Omit the heavy content columns from each entry — the prompt, the response, and also block_reason and guardrail_verdict. The meta protection markers are still returned as booleans; only guardrail_error's detector name is withheld, so an export filtered by the markers still says why each row is in it. Use for bulk export. |
limit |
integer | 50 |
Maximum number of entries to return. Maximum: 200. |
offset |
integer | 0 |
Skip this many entries (for pagination). |
💡 Note: All filters are optional and can be combined. The result is always ordered newest-first.
LogEntry fields
| Field | Type | Description |
|---|---|---|
id |
string | Unique log entry ID. |
ts |
integer | Request timestamp as Unix milliseconds (matches the since/until query bounds, which are compared directly against this column). |
tenant_id |
string | Tenant UUID. |
gateway_id |
string | Gateway UUID. |
provider |
string | Provider that handled the request (after routing). For a row served from the gateway's response cache (cached = 1), this is the provider that produced the cached answer, not the request's routing alias — a cache hit and its original miss are attributed to the same provider. |
model |
string | Model sent to the provider (after routing/rewrite). |
status |
integer | HTTP status code returned to the client. |
cached |
integer | 1 if the response was served from cache, 0 otherwise. |
blocked |
integer | 1 if the request was blocked before reaching the provider, 0 otherwise. |
rate_limited |
integer | 1 if the request hit a rate limit, 0 otherwise. |
blocked_by |
string | null | Which middleware blocked the request (rate_limit, ip_allowlist, guardrail, quota). |
block_reason |
string | null | Human-readable block reason. |
meta |
object | The guardrail protection markers for this request — see Protection markers below. When present it always carries at least guardrail_gap, and it is never null. Under metadata_only it carries the boolean markers but not guardrail_error. |
guardrail_verdict |
string | null | Verdict of the guardrail pipeline. null when no guardrail produced one — note that a keyword, regex, or PII guardrail can run, and even block, without writing this field; consult blocked_by and detectors_fired for those. One of safe, unsafe, error, or indeterminate. safe and unsafe are content verdicts from Prompt Guard. error means a guardrail backend could not be reached or failed. indeterminate means a guardrail backend answered, and the answer could not be read as a verdict — for example a classifier replica returning repetition garbage behind HTTP 200. When several guardrails run in one request, the health verdicts (indeterminate, then error) take precedence over the content verdicts, so a degraded scan is never masked by a later clean one. |
detectors_fired |
array | Names of all guardrails that produced a non-pass verdict (blocked, scrubbed, or flagged). Empty array when no guardrail fired. |
scrub_applied |
integer | 1 if a scrub action was applied by any guardrail, 0 otherwise. |
input_tokens |
integer | Prompt tokens consumed. |
output_tokens |
integer | Completion tokens generated. |
cache_creation_tokens |
integer | Prompt-cache write tokens (Anthropic) — the total of both TTL tiers (5-minute and 1-hour). |
cache_read_tokens |
integer | Prompt-cache read tokens (Anthropic). |
cache_deletion_tokens |
integer | Prompt-cache deletion tokens (Anthropic). |
cost_usd |
number | Estimated cost in USD for this request. |
latency_ms |
integer | End-to-end request latency in milliseconds. |
upstream_latency_ms |
integer | Time waiting for the provider only. |
guardrail_latency_ms |
integer | null | Time spent in Tier 2 guardrail sidecar calls, if any. |
upstream_attempts |
integer | Provider dials attempted (1 = no retries, >1 = retries/fallbacks, 0 = never dispatched). Failed dials counted from a later release on; older rows may show 0 despite real dials. |
fallback_provider |
string | null | Provider used after the primary failed, if any. |
fallback_model |
string | null | Model used on the fallback provider, if any. |
saved_cost_usd |
number | For cached requests: the cost that would have been incurred without the cache. |
request_size_bytes |
integer | Size of the request body in bytes. |
trace_id |
string | null | Links this log entry to a detailed execution trace. null when gateway tracing is disabled. See Traces API. |
client_request_id |
string | null | The caller's own correlation id — the X-Request-Id request header it sent, when that value conformed (a single header, ^[A-Za-z0-9._\-:+]{1,64}$). null (the key is absent on the wire, like every other null field) when none was sent or the value was rejected. Never the entry's id, which the gateway always mints itself (see client_request_id). |
leg_kind |
string | null | Set when the entry is a request the interface made on its own behalf rather than a message a user sent: followup_suggestions (the follow-up chips under an answer), title_generation (the automatic conversation title), or compaction (an automatic history summarisation). null for an ordinary turn and for every API caller. These are real, guardrailed model calls and are metered like any other — the field exists because they follow the turn they belong to, so "the newest entry" is often one of them rather than the request being investigated. Reported for filtering client-side; to query by it, use the leg kind on the legs of a request, which is indexed. |
is_streaming |
integer | 1 if the response was streamed, 0 otherwise. |
api_variant |
string | null | API surface the request used (e.g. the OpenAI-compatible or native Anthropic variant). |
user_agent |
string | null | User-Agent header of the client request, if sent. |
client_ip |
string | null | Client IP address recorded for the request. |
referer |
string | null | Referer header of the client request, if sent. |
response_raw |
string | null | Raw LLM response body before PII (personally identifiable information) token restoration. Only present when pii_protector is active and log_payloads: true. |
prompt |
string | null | The request's messages, flattened one per line as <role>: <content> (a structured content block array is JSON-serialised; a legacy completions prompt string is recorded as-is). null when log_payloads: false or the body carried neither messages nor prompt. Inline binary is never stored: a base64 attachment (a data:…;base64, URL, a native source.type: "base64" block, or any unbroken base64 run of 1 KiB or more) is replaced by [binary omitted: N bytes], leaving the block type and media_type in place so the entry still shows what was attached. Bounded (see below). See Accepted shapes. |
The following fields are only returned by GET /logs/{id} (single-entry endpoint), not by the list endpoint:
| Field | Type | Description |
|---|---|---|
response |
string | null | Provider response body. null when log_payloads: false. |
prompt_scrubbed |
string | null | Request messages after PII tokenization (tokens visible, original values absent). Only present when pii_protector detects PII. Inline binary is replaced by the same [binary omitted: N bytes] marker as prompt. |
💡 Note: Request and response body content is only stored when
log_payloads: trueis set in the gateway config (the default). When disabled, all fields above are still logged but the prompt/response text is omitted. Individual requests can suppress payload logging with thex-aig-collect-log-payload: falseheader.
prompt — accepted shapes and what is rejected
The request body is untrusted input; the log entry is written for every request, including ones the gateway refused with 400, so the flatten is total — no body shape can cost the entry.
| Body shape | Recorded prompt |
|---|---|
messages is an array |
one line per element, <role>: <content>. A content string is taken as-is; an array or object is JSON-serialised; a missing, null, numeric or boolean content contributes an empty line. A role that is not a string (missing, null, an object, a boolean) is recorded as ?. An element that is not an object is recorded as ?:. A content block cjson cannot serialise (NaN, Infinity, over-nesting) contributes an empty line. |
messages is present but not an array (a string, a number, an object) |
Malformed, not absent: the value is JSON-serialised and recorded ("" if it cannot be serialised), and the gateway logs a warning. It is never silently dropped. |
the body itself is a JSON scalar (42, true, "text") |
Malformed, not absent: the scalar is JSON-serialised and recorded, with a warning. (Such a request is refused with 400 before dispatch; its log entry is still written.) A body that is a JSON array carries neither messages nor prompt and is recorded as absent (null). |
messages absent or null, legacy prompt is a string |
the string, as-is. |
prompt is present but not a string (an object, an array, a number, a boolean) |
Malformed, not absent: JSON-serialised and recorded, with a warning. A non-string never reaches the database column. |
neither messages nor prompt (or both null) |
null — absent is not malformed. |
In every row above, inline binary is replaced after flattening: any ;base64, introducer and the bytes that follow it (at any length), and any unbroken run of the base64 alphabet (A-Z a-z 0-9 + / =) of 1 KiB or more, become [binary omitted: N bytes]. The scan sees through JSON escaping — a \/ for /, re-escaped up to eight nesting levels — so a serialised block array cannot smuggle bytes through by the encoding the gateway itself produces. Ordinary text survives because whitespace and punctuation break a run: prose, code, hashes, short IDs, JWTs (- and _ break a run) are untouched. What the rule does take, deliberately, is any 1 KiB+ unbroken stretch of that alphabet even when it is not an attachment — a very long number, a hex blob, a one-line PEM body — and what it does not take: a native source.type: "base64" block whose data is shorter than 1 KiB (under 768 bytes of binary: a favicon), and text a client pre-encoded with escapes the gateway never emits (\u002f, MIME line-wrapping) — accepted gaps, since the purpose is bounding what the gateway's own encoding stores, not defeating a client that chooses to bloat its own log. The four payload columns (prompt, response, response_raw, prompt_scrubbed) share one 12 MiB budget per entry, measured in escaped bytes — the entry is written as a single statement under the database's 16 MiB packet limit, and an entry past it was lost outright (not truncated) before this rule existed. Columns are filled in that priority order; a column cut to fit is named in the row's stored meta.aig_payload_truncated (an array of column names — again queryable by SQL, not projected by the API), so "the client sent less" and "the gateway cut it" are distinguishable. Invalid UTF-8 is scrubbed in the same pass. So an oversized or malformed payload can never cost the entry; at worst its payload text is shortened and the row says so. The same binary replacement is applied to prompt_scrubbed; response and response_raw are bounded but not scanned for binary — response is the delivered answer and is read back as the agent run result.
The number of replacements is recorded in the row's stored meta JSON as aig_prompt_binary_stripped (absent when 0) — queryable as JSON_EXTRACT(meta, '$.aig_prompt_binary_stripped') on request_log, like the other operational flags that ride meta (spend_read_degraded, empty_response); it is not a protection marker and is not projected into the API's meta object. So "the client sent no attachment" and "the attachment bytes were removed from this entry" are distinguishable in the database. Attachment content itself is not held by the request log at all: for a client that posted the bytes inline through the API, this entry is not a copy of them.
model_version — the served model revision (audit column)
request_log.model_version records the exact model revision the provider actually ran — for example claude-3-5-sonnet-20241022 when the request asked for the alias claude-3-5-sonnet-latest. It complements model (the resolved/requested id that was sent to the provider): comparing the two reveals what an alias resolved to at run time.
- Source & accepted shape. Captured from the provider's response — untrusted third-party output. On a streamed turn it is the served
modelechoed in the stream: Anthropic'smessage_start.message.model, or the top-levelmodelon an OpenAI-family chunk (every OpenAI-compatible provider surfaces it through the shared chunk parser). On a buffered turn it is the parsed body'smodel(every provider). Only a non-empty string is accepted; it is clamped to 128 characters. A missing value, a JSONnull, a number, or an empty string is rejected and the column is leftNULL(no distinct served revision observed). On a multi-leg turn (tool loop / failover) the last leg that carried a served revision wins, somodel_versionmatches the model that produced the delivered answer (consistent withmodel/fallback_model). - Coverage. Populated for Anthropic turns of every shape (streaming, compat, native buffered); for OpenAI-family compatible streaming turns (the served
modelechoed on every chunk, surfaced through the shared chunk parser); and for any native buffered (stream:false) turn whose provider echoesmodel. It is leftNULLonly when the provider echoes no usablemodel(e.g. a buffered provider that omits it). For a pinned model the served id already equals the requestedmodel, somodel_versionsimply confirms it; the column earns its keep on aliases (e.g.-latest) where the two differ. - Access. This is an audit column on
request_log, intended for direct SQL / analytics queries (SELECT model, model_version FROM request_log …). It is not currently returned byGET /logsorGET /logs/{id}.
client_request_id — the caller's own correlation id
request_log.client_request_id records the X-Request-Id request header the caller sent, when it conformed to the accepted shape (a single header matching ^[A-Za-z0-9._\-:+]{1,64}$; see the per-request headers). It is correlation only: the entry's id — and the X-Request-Id response header — is always the gateway's own id, so a caller cannot choose, repeat, or overflow the key of its own log row (before this change a repeated or over-long client id silently lost the row). A rejected value (empty, over 64 characters, other characters, or the header sent more than once) is dropped and logged server-side; the entry is written without a client id (client_request_id null — absent on the wire) exactly as when nothing was sent. The value is always returned as a string, even when it looks numeric ("007" stays "007"). Unlike the audit columns below it is returned by GET /logs and GET /logs/{id}, appears in the Logs page CSV export (Client Request ID), the tenant export (request_logs.csv) and JSON-format SIEM events; there is no API filter on it and no index — look a customer's id up in SQL or your SIEM within a time window.
token_agent_id — the bound-agent principal (audit column)
request_log.token_agent_id records the agent a per-agent token was bound to when it authenticated the request — the distinct verifiable principal behind the call. It is distinct from agent_id (the invoked agent): for a per-agent token invoking its own agent they match; for a normal/shared token it is NULL. See Per-agent tokens. Like model_version, it is an audit column on request_log for direct SQL/analytics, not returned by GET /logs.
aborted — client-cancelled vs empty completion (audit column)
request_log.aborted is 1 when the client cancelled the request after the response began streaming (for example pressing Stop), and 0 otherwise. It exists to tell a cancel apart from a genuine empty completion among status: 200 rows: a post-header stream cancel is status: 200 with zero token counts and aborted: 1, whereas a real empty answer is status: 200, output_tokens: 0, aborted: 0. A cancel detected before the response streamed lands as status: 499 (already counted by the client_aborted stats metric). Like model_version and token_agent_id, aborted is an audit column on request_log for direct SQL/analytics — it is not returned by GET /logs.
Protection markers — the meta object
GET /logs and GET /logs/{id} return a meta object answering one question: did the guardrail
layer fully protect this request? A blocked or scrubbed request is easy to see — blocked,
scrub_applied and detectors_fired all record it. A request the guardrails did not check is
not: a detector that failed open, a masking pass that was deliberately skipped, or a scrub that
could not be committed sets none of those columns, so it used to be indistinguishable from a clean
pass. These markers are that missing fact.
guardrail_gap is always present. guardrail_gap_severity is present only when there is a gap.
Every other key is either true or absent, never false — except guardrail_error, which is
an object when a detector failed and absent otherwise.
meta key |
Severity | Meaning |
|---|---|---|
guardrail_gap |
— | Always present (whenever this object is). true when the request reached a provider without the protection it was configured to get. false means the layer protected this request; it never means "not reported". The discriminator is dispatch, not blocked: a request refused before dispatch is never a gap — nothing left, even when the refusal happened because the detector errored and its fail mode is closed. A request blocked on the response phase, or by a rate limit, after the prompt already went to the provider, still is one. |
guardrail_gap_severity |
— | Present only when guardrail_gap is true: compliance, or degraded. The most severe gap marker on the row wins. info is a marker severity, not a gap severity, and never appears here. Absent whenever guardrail_gap is false. |
pii_unmasked_egress_under_mandate |
compliance | Unmasked personal data reached a third-party provider while a PII mandate was in force. An incident, not a diagnostic. |
pii_scan_degraded |
degraded | A PII masking layer could not run for this request — on the request/response phase, or at a tool seam whose fail-open forward let a web-search query or a tool result proceed unscanned. |
guardrail_degraded |
degraded | A detector errored and the request proceeded unchecked (fail-open). Also written for a tool-seam fail-open forward (the web-search query, a tool result, the prompt-injection scan of tool results) — for a PII forward only where the forwarded bytes actually leave the estate (not on a wholly-local model leg — a prompt-injection pass-through is recorded on every leg, since an unscanned injected result steers a local model just as well), not for a detector the agentic-fetch inner leg's own request phase runs again over the document (an enabled request-phase masker / an enabled injection classifier — that phase records any outage; a disabled or response-only one is re-run by nobody, so its forward at the seam is recorded), and never for content that did not leave after all: a query or result a later detector withholds, or a web-search turn whose later query is withheld (every query is then withheld). |
guardrail_error |
degraded | An object naming which detector failed: { "name": …, "error_class": … }. Last-writer-wins across the phases and seams of one turn: a tool-seam forward overwrites a request-phase detector's record on the same row. |
scrub_uncommittable |
degraded | A scrub matched but could not be written back without corrupting the document (a keyword/pattern hit a JSON key or delimiter), so it was not applied. On the request phase the request proceeded unmasked by that detector (tier 2 still masks); on the response phase the response was blocked instead of delivered corrupted. |
presidio_no_scannable_fields |
degraded | The request body carried text only in a carrier the scanner cannot read, so nothing was scanned. |
presidio_response_shape_unrecognized |
degraded | The response body decoded to JSON but matched no provider shape the content-only walker knows, so it was passed unscanned rather than rewritten (which would corrupt structure). Content may have reached the caller unmasked; the response phase never blocks. |
aig_meta_truncated |
degraded | request_log.meta exceeded its storage cap and was truncated, so some keys were dropped. Every other marker on such a row is therefore unknown, not false — and since guardrail_gap: false is a positive statement that the request was protected, a truncated row reports guardrail_gap: true with this marker as the reason rather than making a claim it cannot support. |
pii_mandate_masker_unavailable |
info | The masker that satisfies an active PII mandate could not run, so the request was refused rather than sent unprotected — or, at a tool seam, the model-generated web-search query or a tool result was withheld mid-turn rather than forwarded unscanned. Not a gap and not degraded — nothing egressed, which is the point. Recorded because the alternative outcome is invisible, and so that "this gateway refuses (or withholds) when Presidio is down" is attributable to the mandate rather than looking like a broken guardrail. This is the one place a detector's own fail_open: true does not win. A tool-seam withhold sets neither blocked nor a verdict nor any gap marker, so the withhold itself contributes to no guardrail_outcome filter arm (any included — the row can still match scrubbed when its request phase masked); it is visible as this key, as its badge in the log browser, and as a PiiServiceFailure row in the model-error triage queue. |
pii_floor_forced_by_mandate |
info | The German PERSON / LOCATION floor was applied to the model leg (a request- or response-phase scan, or a tool result bound for the model) because this request is under a PII mandate, not because the detector is German-calibrated. Correct behaviour and the point of the mandate — but it has a visible cost, since both types drop to the German 0.6 threshold and ordinary place names in English text are masked. Surfaced so an operator can explain masking they did not configure without reading source. Not a gap: protection was added, not lost. On a local-only gateway of an enforcing tenant a chat turn's model leg is exempt from the mandate (first-party inference), so this key is absent there even though the mandate is in force — see the next row; an agent-shaped run on that gateway (an invoke, a scheduled prompt task, the workflow copilot, a draft preview) is not exempt and does record it. Stamped only once every field's scan answered; an outage that a fail_open: true detector forwards leaves it absent. |
pii_egress_floor_forced_by_mandate |
info | The egress-leg twin of the row above: the floor was applied to the model-generated web-search query — bytes that leave for the search provider — because a mandate is in force, whatever the detector's language says. This is the leg a local-only exemption does not reach, so on a local-only gateway of an enforcing tenant a row can carry this key beside pii_floor_off_by_language: the model leg ran without the floor (exempt), the search query ran with it. Stamped only after the query scan succeeded; on a Presidio outage the query is withheld under a mandate, and where no mandate is in force a fail_open: true forward leaves it absent and records the gap markers instead. It records that the floor ran on the scan: on a gateway with two pii_protector detectors, a second detector's fail-closed withhold can still stop the query from egressing after the first stamped it. Not a gap. |
pii_floor_off_by_language |
info | The mirror of the row above: some PII detector on this request ran without the German PERSON / LOCATION floor, because it carries an explicit non-German language and no mandate applied to that leg (on a local-only gateway of an enforcing tenant that is a chat turn's leg, never an agent-shaped run's). PERSON and LOCATION were then held to the English 0.9 confidence instead of the German 0.6 (or, on a detector whose ticked list omits them and whose selection does not bind, not analysed at all), so German names and cities scoring 0.6–0.9 were not detected — and, on a masking detector, not masked. An explicit entity_score_thresholds override can still set a different bar for either type. Not a gap — the layer did exactly what it was configured to do — but the configuration reduces protection, and without this marker a request that lost the floor to a dropdown looked identical to one that never had a floor to lose. Read it as a fact about the request, not about one detector: on a gateway with two PII detectors it means some detector ran without the floor. It also appears for a response-phase scan, and for a presidio detector that blocks or flags when its ticked list names PERSON or LOCATION — binding suppresses the coverage union but not the 0.6 threshold, so such a detector stops acting on the German name band (for a flagging detector, "not masked" reads as "not flagged"). It never appears for a leg that ran under a PII mandate, where the floor is applied regardless, nor for a bound detector whose ticked list names neither floor type, where the floor would have changed nothing. It describes the leg it ran on: on a local-only gateway of an enforcing tenant the model leg is exempt from the mandate while the web-search query is not, so one row can carry this beside pii_egress_floor_forced_by_mandate — both true. There is deliberately no matching audit-log event: the config-save boundary cannot see whether a mandate is in force, so a config-level "the floor was disabled" would be false on exactly the mandated tenants. The change itself is gated by a confirmation in the Guardrail Builder; an API PATCH and a guardrail-config import are not gated, so for those paths this marker is the whole record. |
pii_masking_skipped_local |
info | A masking detector was deliberately skipped on a leg of this turn that routed to a first-party local model, where the data does not leave the estate — so this is not a gap. Surfaced so that correct behaviour stops looking like a broken guardrail. It does not assert that every leg stayed local: a turn can carry this marker and pii_unmasked_egress_under_mandate, because the two count different detector sets. |
Filter for the gap rows with guardrail_outcome=degraded; they are also included in
guardrail_outcome=any. Use guardrail_outcome=compliance for the incident markers alone.
meta is present only when the endpoint projects it. GET /logs/{id} always returns it, and
so does GET /logs — under metadata_only with the boolean markers only (no guardrail_error). On GET /gateways/{ID}/guardrail-events it is returned only to callers with the
REQUEST_LOGS_VIEW permission — and those callers also get the gap events themselves, while a
caller without it sees the narrower "a detector acted" event set that endpoint has always
returned. A row is never handed back with a gap it cannot explain.
What is deliberately not returned. guardrail_error.message is the verbatim, unbounded response
body of an untrusted guardrail backend and can echo the scanned text, so only name and
error_class cross the wire. The meta object is a curated set, not a passthrough of the
stored column: client-supplied x-aig-meta-* values and the gateway's operational diagnostics
(aig_stream_error, aig_no_dispatch, aig_mcp_policy_dropped, aig_prompt_binary_stripped,
aig_payload_truncated, threat_taxonomy, spend_read_degraded) are stored on the row and reachable by direct SQL
(JSON_EXTRACT(meta, '$.<key>')), but are not part of this response.
Rejected at the write boundary. Two separate guarantees, and it is worth being precise about
which is enforced where. A client cannot forge these keys: an x-aig-meta-* request header that
would land on one of them is dropped at the trust boundary, so every value in this object is
gateway-authored. Within the gateway, all marker writes go through one shared writer that refuses a
key outside the set above — that keeps a new marker from being stored in a form no reader knows how
to return, but it is a single-writer convention enforced by review, not something the language
prevents.
Rejected filter values. guardrail_outcome accepts only the six values listed above (blocked, scrubbed, flagged, degraded, compliance, any). Anything
else is 400; it is never silently ignored, because an ignored filter would answer "how many
requests bypassed protection?" with the unfiltered log.
GET /logs/{id}
Returns a single log entry by its ID. Required role: admin or tenant_admin; non-admin callers may only read entries belonging to their own tenant.
Returns a LogEntry object. The single-log response is narrower than a list row — it omits trace_id, rate_limited, is_streaming, api_variant, user_agent, client_ip, and referer (use the list endpoint when you need those); client_request_id is on both. Returns 404 if not found.
GET /request-logs/{id}/legs
Returns the per-leg billing ledger for a single request, ordered by sequence ascending. One request can produce several legs — the primary provider call, any fallback attempt, each round of a server-side tool loop or length-cap auto-continuation (every model round the gateway makes in one turn is its own dispatch the provider bills separately), and side services (guardrails, web search) — each recorded as its own row. Use this endpoint to break a single LogEntry down into its individual provider/service calls and their costs. The parent LogEntry input_tokens / output_tokens / cost_usd are the sum across every leg of the request. If the client disconnects mid-stream, the output the provider reported as generated before the disconnect is billed as a partial leg (status 499, error_class cancelled) — you are billed for what the provider reported up to the disconnect, never for output that was never produced.
Required role: admin or tenant_admin. Non-admin callers may only read legs for a request belonging to their own tenant.
Response
Array of leg rows. Returns 404 { "error": "not found" } if the request log id does not exist, 403 { "error": "forbidden" } if the caller is not an admin and the request belongs to another tenant, and 500 { "error": ... } on a storage failure.
| Field | Type | Description |
|---|---|---|
request_log_id |
string | Parent request log id this leg belongs to. |
sequence |
integer | Leg order within the request (0-based; the first leg is 0). |
tenant_id |
string | Tenant UUID. |
gateway_id |
string | Gateway UUID. |
conversation_id |
string | null | Conversation the request belongs to, if any. |
user_id |
string | null | User attributed to the request, if any. |
token_id |
string | null | Auth token used, if any. |
trace_id |
string | null | Linked execution trace, if tracing is enabled. |
turn_id |
string | null | Conversation turn id, if any. |
kind |
string | Leg kind (e.g. primary call, fallback, side service). |
provider |
string | Provider that served the leg. |
model |
string | null | Model used for the leg. |
service |
string | null | Service name for non-inference legs (e.g. web_search). |
region |
string | null | Provider region, if recorded. |
residency_zone |
string | null | Frozen dispatch-time model-residency zone: eu (positively EU-vouched), non_eu (positively non-EU — US or China host/region), or unknown (unprovable at dispatch — variable routing, a Bedrock cross-region profile, a base-URL override, an Azure route with no region, or a region-configurable/self-hosted provider). null for non-dispatch legs (guardrail, PII, search). Immutable evidence, computed at dispatch and never recomputed from current config. Covers the model-inference axis only. See EU data residency — Residency evidence. |
tier |
string | null | Pricing tier. |
byok |
integer | 1 if a bring-your-own-key credential was used. |
is_billable |
integer | 1 if the leg is billable. |
status |
integer | HTTP status for the leg — the upstream's response status, or a gateway-synthesized status for a locally-terminated leg (a client disconnect mid-response records a synthetic 499, "client closed request"). |
error_class |
string | null | Classified error, if the leg failed or was interrupted — e.g. cancelled (client disconnected mid-stream), stream_error (mid-stream read error), provider_error (the provider's stream emitted a mid-stream error event, e.g. Anthropic overloaded_error; the leg records the event's HTTP status — default 500 — instead of a misleading 200), rate_limited, or an HTTP class. null on a clean leg. |
partial |
integer | 1 if the leg was interrupted / incomplete (e.g. a client-aborted stream — the pre-abort output is still billed). |
upstream_request_id |
string | null | Provider-side request id, if returned. |
input_tokens |
integer | Prompt tokens for the leg. |
output_tokens |
integer | Completion tokens for the leg. |
cache_creation_tokens |
integer | Prompt-cache write tokens — the total of both TTL tiers (5-minute and 1-hour). |
cache_creation_1h_tokens |
integer | The part of cache_creation_tokens written with a 1-hour TTL. Priced once at the 1-hour cache-write rate; the remainder (cache_creation_tokens - cache_creation_1h_tokens) at the 5-minute rate. |
cache_read_tokens |
integer | Prompt-cache read tokens. |
cache_deletion_tokens |
integer | Prompt-cache deletion tokens. |
units |
number | null | Metered units for service legs. |
unit_kind |
string | null | Unit the leg is metered in. |
price_input_per_1k |
number | null | Snapshot input price per 1,000 tokens. |
price_output_per_1k |
number | null | Snapshot output price per 1,000 tokens. |
price_cache_write_5m_per_1k |
number | null | Snapshot 5-minute cache-write price per 1,000 tokens. |
price_cache_write_1h_per_1k |
number | null | Snapshot 1-hour cache-write price per 1,000 tokens. |
price_cache_read_per_1k |
number | null | Snapshot cache-read price per 1,000 tokens. |
pricing_source |
string | null | Where the price snapshot came from. |
cost_usd |
number | Cost in USD for the leg. |
saved_cost_usd |
number | Cost saved by caching on this leg. |
currency |
string | Currency of the cost figures (e.g. USD). |
started_at |
integer | Leg start time. |
ended_at |
integer | Leg end time. |
latency_ms |
integer | Leg latency in milliseconds. |
The endpoint returns the full ledger row, so additional identity and bookkeeping columns may also be present.
Examples
⭐ Example: The following examples show common query patterns for the Logs API.
Fetching the last 100 requests
Fetching logs for a specific gateway
Fetching only blocked requests for a tenant
Fetching every request the guardrail layer touched — or failed to
# The guardrail acted (blocked / scrubbed / flagged) OR there was a protection gap.
curl "https://<your-gateway-host>/admin/v1/logs?gateway_id=gw_xyz789&guardrail_outcome=any&limit=50"
Fetching only the requests that were NOT fully protected
# "How many requests bypassed protection?" — a fail-open detector error, an uncommittable
# scrub, an unscannable body, or unmasked PII egressing under a mandate.
curl "https://<your-gateway-host>/admin/v1/logs?gateway_id=gw_xyz789&guardrail_outcome=degraded&limit=50"
Fetching only guardrail-blocked requests
curl "https://<your-gateway-host>/admin/v1/logs?gateway_id=gw_xyz789&guardrail_outcome=blocked&limit=50"
Fetching logs since a Unix timestamp (milliseconds)
Fetching the last hour of OpenAI requests
SINCE=$(date -d "1 hour ago" +%s)000 # convert seconds to ms
curl "https://<your-gateway-host>/admin/v1/logs?provider=openai&since=${SINCE}&limit=500"
Paginating through a large result set
# Page 1
curl "https://<your-gateway-host>/admin/v1/logs?gateway_id=gw_xyz789&limit=100&offset=0"
# Page 2
curl "https://<your-gateway-host>/admin/v1/logs?gateway_id=gw_xyz789&limit=100&offset=100"
Example log entry
{
"id": "log_abc789",
"ts": 1742551232,
"tenant_id": "ten_abc123",
"gateway_id": "gw_xyz789",
"provider": "openai",
"model": "gpt-4o",
"status": 200,
"cached": 0,
"blocked": 0,
"rate_limited": 0,
"blocked_by": null,
"block_reason": null,
"meta": { "guardrail_gap": false },
"guardrail_verdict": null,
"detectors_fired": [],
"scrub_applied": 0,
"input_tokens": 512,
"output_tokens": 128,
"cache_creation_tokens": 0,
"cache_read_tokens": 0,
"cache_deletion_tokens": 0,
"cost_usd": 0.00448,
"latency_ms": 842,
"upstream_latency_ms": 780,
"guardrail_latency_ms": null,
"upstream_attempts": 1,
"fallback_provider": null,
"fallback_model": null,
"saved_cost_usd": 0,
"request_size_bytes": 1024,
"trace_id": null,
"client_request_id": "order-4711-retry-2",
"is_streaming": 1,
"api_variant": null,
"user_agent": "curl/8.0.1",
"client_ip": "203.0.113.7",
"referer": null,
"response_raw": null,
"prompt": null
}