RELAY_USAGE_QUEUE_SIZE, 16,384 by default). When the queue is full, new events are dropped and counted in relay_records_lost_total{kind="usage"}; relay_emit_queue_depth{kind="usage"} shows the queue filling before that happens. Events of requests refused before reaching an upstream wait in a smaller queue of their own (RELAY_USAGE_REFUSED_QUEUE_SIZE, 4,096 by default), written after the main one; their drops count as kind="usage_refused", so kind="usage" counts only lost records of requests that reached an upstream. See Metrics.
What an event records
Slugs (
model, host, policy, project, relay_key, …) are copied as of the request, so an event stays readable after the entity is renamed or deleted; the _id fields are the stable keys.
Request tags
A caller attaches its own dimensions withX-WR-Request-Tags, a flat JSON object of string values: at most 16 keys, keys up to 64 characters, values up to 256, and 2,048 bytes in total. A header that breaks any of these rules is dropped whole and never fails the request. The header is not forwarded upstream.
tag=feature:summarize and group with group_by=tags.feature.
Reading usage
The control API serves the events and aggregates over them. All paths are on the control listener under/api.
A caller whose role grants
usage.read (or logs.read) at the global scope reads every event. Under RELAY_AUTHZ=rbac, anyone else reads only the events of projects they can see, plus their own requests. A request id outside that set answers 404.
Filters
Every endpoint above takes these query parameters. List parameters are repeatable and match any of their values.Grouping
summary and timeseries take group_by:
source(thesummarydefault),finish_reason,error_kind- slugs as recorded:
model,host,policy,provider,project,team,principal,relay_key - ids:
model_id,host_id,policy_id,project_id,team_id,principal_id,credential_id,relay_key_hash,host_key_id tags.<key>(a caller tag) orextras.<key>(a Relay-added value such asextras.instance)none: one row over everything the filters match, withgroupset to{}
group_by, timeseries returns a single series. Per-project spend over a week is one call:
group_by, the response carries labels, a map from each group value to a readable name: the latest non-empty slug recorded beside that id in the window, else the entity’s name in the current catalog, else, for a user, the username. A credential_id is named after its relay key; inference tokens have no name. In timeseries the name is the latest across the whole window. Values without a name are left out.
Comparing windows
summary can put each row next to the same group over another window.
The two forms cannot be combined. Each row gains
previous with requests, error_count, errors_4xx, errors_5xx, throttled, tokens, cost_nanos, unpriced, duration_ms and ttft_ms, and the response gains previous_from and previous_to. Rows stay those of the requested window: a group with no traffic in the compared window has no previous, and a group seen only there has no row. A window with no start (since=0s and no from) or a start after its end is rejected with 400, as are the parameter mistakes above.
Distinct counts
summary and timeseries take distinct, one of relay_key_hash, relay_key, principal_id, credential_id, project_id, team_id, model_id or host_id. Each row or point then carries distinct: the number of different non-empty values of that field among its events, so traffic without a relay key is not counted as a key. A value seen in two buckets counts in both, so per-bucket values do not add up to a window total; read that from summary, with group_by=none for a single number. When comparing, previous carries its own distinct.
Filter values
GET /api/usage/facets lists the values a filter can take. dims names up to 16 dimensions from the group_by vocabulary (none excluded), repeated or comma-separated; limit caps the values per dimension (default 50, max 200). Each dimension is counted with every filter applied except its own, so model_id values honour host_id= but ignore model_id=, and a dropdown keeps showing the alternatives to what is selected. Values are ordered by request count, then by value, and the empty value is listed like any other. Requests rejected before reaching a provider are left out, as in summaries.
An id value carries a label, resolved as for labels. from and to echo the requested window.
Failed requests
error_count in summaries and time series counts events with a status of 400 or above, split into errors_4xx and errors_5xx. throttled counts the 429s, which are also in errors_4xx.
A request Relay refused before calling a provider has status 0 and an error_kind naming why: rate_limited, no_keys, keys_exhausted, model_not_found, model_not_allowed, no_host_binding, and so on. It is listed by /api/usage/events and /api/logs and left out of summary, timeseries and facets, since it carries no usage.
When the provider answered with an error status, the event has that status and error_kind upstream_error, whether Relay passed the response to the caller or returned it after every key failed. That includes a 4xx the request itself caused, such as an invalid parameter or a prompt over the context window; status tells them apart. error_message is upstream returned <status>, or, when every key failed, the start of the provider’s error body. Only payload logging keeps the full body. Other provider-side kinds are upstream_unreachable (no connection to the host), timeout and client_canceled. In proxy mode a request that failed before the provider answered (a refused connection, a TLS error) is recorded as upstream_error with status 0.
Token counts and cost
Token counts come from the provider’s response; Relay does not tokenize.tokens uses the same keys for every provider:
input: prompt tokens not read from or written to a prompt cache.cache_readandcache_creationcount those separately.output: every generated token, reasoning included.- Some keys are parts of another key, not additions to it:
reasoning,audio_output,accepted_predictionandrejected_predictionare insideoutput;audio_inputis insideinput;cache_creation_1h(prompt tokens written to a 1-hour cache, which Anthropic bills at a higher rate than its 5-minute writes) is insidecache_creation.
input + cache_read + cache_creation + output.
cost_nanos is computed once, when the event is recorded, from the rate sheet of the host binding that served the request. Changing a rate sheet does not reprice past events. When no rate sheet applies (the binding has none, it is disabled, or no reported token matches a priced meter), the event is unpriced: cost_nanos is absent, which is not a cost of zero. Summaries count such events in unpriced, so a total cost next to a non-zero unpriced is a lower bound.
Each token is charged once. A part whose meter has a rate on the sheet is charged at that rate and left out of its whole’s charge; a part without a rate is charged inside its whole. With an output rate of 1.2 and a reasoning rate of 4 (USD per million), a response with output: 3000 and reasoning: 2500 charges 500 tokens on tokens.output and 2,500 on tokens.reasoning. cost_breakdown lists the charge per meter, which for tokens.input and tokens.output can therefore be less than the rate times the stored count.
The context-length tier is chosen by the prompt length, input + cache_read + cache_creation: a request whose prompt reaches a rate’s aboveTokens pays that tier’s rate on every meter, even when most of the prompt is a cache hit.
When the provider reports the service tier that served the request (OpenAI’s service_tier, such as priority or flex), the event records it as extras.service_tier and is priced with the rate-sheet rows whose serviceTier matches. A meter with no row for the tier is charged at its base rate, so a tier lists only the meters it prices differently. A tier row that applies at the prompt length wins over every base row for that meter, so a tier with context-length pricing needs its own aboveTokens rows.
Cost is a record of what the request would cost at the sheet’s rates. Relay does not cap spend on it.
Where usage is stored
Theusage-logging settings section selects the store. A change applies without a restart; switching backends starts writing to the new store and does not move existing events.
backend, retentionDays, file and clickhouse are required. When the section names no backend, RELAY_EVENTLOG_BACKEND decides, then file. Only an admin may change this section. Connection strings stay in the environment because Relay needs them before it can read settings. For a deployment with more than one pod, use clickhouse or postgres, so every pod reads the same history. See Settings for the section format.
Retention
retentionDays sets how long the clickhouse and postgres backends keep events; 0 (the default) keeps them forever. On ClickHouse the value becomes the table’s TTL and is applied to the existing table whenever the section changes, so shortening it deletes older events in the background, and they cannot be recovered. Postgres deletes expired rows once a day. The file backend keeps everything, and valkey keeps 24 hours.