Skip to content

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.

curl "https://<your-gateway-host>/admin/v1/logs"

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: true is 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 the x-aig-collect-log-payload: false header.

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 model echoed in the stream: Anthropic's message_start.message.model, or the top-level model on an OpenAI-family chunk (every OpenAI-compatible provider surfaces it through the shared chunk parser). On a buffered turn it is the parsed body's model (every provider). Only a non-empty string is accepted; it is clamped to 128 characters. A missing value, a JSON null, a number, or an empty string is rejected and the column is left NULL (no distinct served revision observed). On a multi-leg turn (tool loop / failover) the last leg that carried a served revision wins, so model_version matches the model that produced the delivered answer (consistent with model / fallback_model).
  • Coverage. Populated for Anthropic turns of every shape (streaming, compat, native buffered); for OpenAI-family compatible streaming turns (the served model echoed on every chunk, surfaced through the shared chunk parser); and for any native buffered (stream:false) turn whose provider echoes model. It is left NULL only when the provider echoes no usable model (e.g. a buffered provider that omits it). For a pinned model the served id already equals the requested model, so model_version simply 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 by GET /logs or GET /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.

curl "https://<your-gateway-host>/admin/v1/logs/log_abc789"

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.

curl "https://<your-gateway-host>/admin/v1/request-logs/log_abc789/legs"

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

curl "https://<your-gateway-host>/admin/v1/logs?limit=100"

Fetching logs for a specific gateway

curl "https://<your-gateway-host>/admin/v1/logs?gateway_id=gw_xyz789&limit=50"

Fetching only blocked requests for a tenant

curl "https://<your-gateway-host>/admin/v1/logs?tenant_id=ten_abc123&blocked=1&limit=50"

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)

curl "https://<your-gateway-host>/admin/v1/logs?since=1742544000000&limit=200"

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
}

See also