ClickHouse usage
The ClickHouse tables that hold the usage ledger, their columns, request types, retention and the write-ahead log.
8 min readUpdated for v2.4.0Edit on GitHub
The gateway inserts usage rows and never updates them. Each replica buffers
rows and trace spans and inserts them every
OBLETH_TELEMETRY_FLUSH_INTERVAL_MS (default 5000, from 100 to 60000), or at
once when 500 are waiting, so a request reaches Request logs up to that long
after it ends. It creates its tables at startup, and adds missing columns and
indexes, in the database named by OBLETH_CLICKHOUSE_DB (default obleth).
| Table | Engine | Holds |
|---|---|---|
usage | MergeTree | One row per request, pruned to the retention window |
usage_daily | SummingMergeTree | Daily rollup of usage, never pruned |
usage_daily_mv | Materialized view | Feeds usage_daily from new usage rows |
usage_meta | ReplacingMergeTree(updated_at) | Internal markers for one-time schema work |
spans | MergeTree | Per-request timing spans, kept 14 days |
usage table
PARTITION BY toYYYYMMDD(ts), ORDER BY (tenant_id, ts_ms), with a minmax
skip index idx_ts_ms on ts_ms for cross-tenant time-range reads.
| Column | Type | Meaning |
|---|---|---|
request_id | UUID | The x-obleth-request-id of the request |
tenant_id | UUID | Tenant; nil for health probes |
key_id | UUID | API key; nil for health probes |
model | String | Canonical model name (aliases and auto resolved) |
admission | LowCardinality(String) | fast, queued, rejected, boon or health_probe |
weight | Int64 | Tenant weight at request time |
input_tokens | UInt32 | Input tokens, cached ones included |
cached_input_tokens | UInt32 | Input tokens the backend served from its prefix cache, as it reported them; at most input_tokens (default 0) |
output_tokens | UInt32 | Output tokens |
estimated_tokens | UInt32 | Pre-request estimate used for budgeting |
queue_wait_ms | UInt32 | Time waiting for a fairshare slot |
ttft_ms | UInt32 | Time to first token |
total_ms | UInt32 | Total duration |
status_code | UInt16 | HTTP status returned |
cache_status | LowCardinality(String) | hit, miss or off (default off) |
cost_usd | Float64 | USD cost, fixed at completion (default 0) |
energy_wh | Float64 | Attributed watt-hours (default 0) |
energy_cost_usd | Float64 | Energy at the configured $/kWh (default 0) |
co2_g | Float64 | Grams CO₂ at the configured grid intensity (default 0) |
ts_ms | Int64 | Unix time in milliseconds |
session_id | String | Conversation id (default empty) |
session_id_source | LowCardinality(String) | client, derived or none |
request_type | LowCardinality(String) | See Request types |
device_id | String | Device claim from an identity token; empty for API keys |
end_user | String | The end user the request named, for a key with per-user fairshare on; else empty |
parent_request_id | UUID | On a helper row, the client request it served; nil otherwise |
model_variant | String | The variant the client named; model stays its model's name. Empty otherwise, including calls through a model family |
model_family | String | The model family the client named; model is the member that answered. Empty otherwise |
reasoning_effort | LowCardinality(String) | The level the request was served at, unsupported when the model has no effort control and the caller's was removed, or empty when it went through as sent |
reasoning_effort_requested | LowCardinality(String) | The level the caller asked for, folded to none/low/medium/high when known; recorded for pass-through models too |
ts | DateTime64(3) | MATERIALIZED from ts_ms; the partition key |
- Cost and energy are fixed at completion from the prices and rates in
effect then, and never recomputed, so a price change affects only later
requests. Energy is
0unless energy accounting is on for the model. A non-finite value is stored as0. session_idcomes fromx-obleth-session-id,x-session-id, or the body'ssession_idormetadata.session_id; otherwise it is derived from the conversation unlessOBLETH_SESSION_ID_DERIVATIONis off. See Conversations and sessions.device_idis kept only when it is at most 64 letters, digits,_or-. See Identity tokens.cached_input_tokenscomes from the backend'susage.prompt_tokens_details.cached_tokens, read from the same usage object asprompt_tokens. Estimates, cancelled requests, answers the gateway writes itself and rows written before v2.1.0 record 0.usage_dailyhas no sum of it.
Request types
request_type drives the Type column and the type: filter in Request logs.
| Value | Kind | Written for |
|---|---|---|
chat | Client | /v1/chat/completions |
completion | Client | /v1/completions |
responses | Client | /v1/responses |
messages | Client | /v1/messages |
verdict | Client | /v1/verdicts, one row for all questions |
embedding | Client | /v1/embeddings |
audio | Client | Any path under /v1/audio/ |
image | Client | Any path under /v1/images/ |
video | Client | POST /v1/videos; follow-ups write no row |
rerank | Client | /v1/rerank, /v1/reranking |
moderation | Client | /v1/moderations |
search | Client | /v1/search; no tokens, no cost |
other | Client | Any other forwarded path |
vision_boon | Helper | Vision boon's describer model |
structured_output_boon | Helper | Structured-output repair calls |
tool_loop | Helper | Each round trip of the gateway tool loop |
guardrails_boon | Helper | Guardrails harm classifications |
speculation_draft | Helper | Speculation boon's draft calls |
speculation_verify | Helper | Speculation boon's verification calls |
image_generation_boon | Helper | Image-generation boon calls |
web_search_boon | Helper | Each search the web-search boon runs; no tokens, no cost |
health_probe | Internal | Health checks, under the nil tenant |
benchmark | Internal | Every row of a synthetic tenant, helper rows included |
Helper rows have admission boon, carry the tokens and cost of that one
call, and name the request they served in parent_request_id. Compression and knowledge retrieval write no row of their own.
POST /v1/messages/count_tokens writes none either.
Internal rows are left out of Management API usage, token and cost reads unless
you pass include_internal=true. benchmark rows never enter usage_daily;
health_probe rows do. Mark a tenant synthetic with
PUT /api/v1/tenants/{id}/synthetic; the
benchmark harness does this for its own
tenants.
usage_daily rollup
One row per day × tenant × key × model, never pruned.
PARTITION BY toYYYYMM(day), ORDER BY (day, tenant_id, key_id, model).
| Column | Type | Meaning |
|---|---|---|
day | Date | toDate(ts) |
tenant_id | UUID | Tenant |
key_id | UUID | API key |
model | String | Model |
requests | UInt64 | All requests |
success_requests | UInt64 | Status 200 to 399 |
error_requests | UInt64 | Status 400 and above |
input_tokens | UInt64 | Summed input tokens |
output_tokens | UInt64 | Summed output tokens |
estimated_tokens | UInt64 | Summed estimates |
cache_hits | UInt64 | Rows with cache_status = 'hit' |
cache_misses | UInt64 | Rows with cache_status = 'miss' |
ttft_ms_sum | UInt64 | Time to first token, successful requests only |
total_ms_sum | UInt64 | Duration, successful requests only |
cost_usd_sum | Float64 | Summed cost_usd |
energy_wh_sum | Float64 | Summed energy_wh |
energy_cost_usd_sum | Float64 | Summed energy_cost_usd |
co2_g_sum | Float64 | Summed co2_g |
Read it through GET /api/v1/usage/daily,
which divides the latency sums by success_requests.
How the rollup is kept
usage_daily_mvadds newusagerows as they land, excludingbenchmark.- A one-time backfill seeds the rollup from older history, only when the
rollup is empty; booting replicas elect one backfiller through
usage_meta. - At startup the gateway recreates the view only when its stored definition
differs. While it is recreated, rows other replicas insert do not reach
usage_daily. SummingMergeTreecollapses rows sharing the sort key on merge, so query withsum()rather than reading rows as final.
spans table
PARTITION BY toYYYYMMDD(fromUnixTimestamp64Milli(start_ms)),
ORDER BY (request_id, start_ms), expired by a ClickHouse TTL 14 days after
start_ms. Spans are written only for keys or tenants with tracing on
(PUT /api/v1/keys/{id}/tracing, PUT /api/v1/tenants/{id}/tracing) and are
read by the request spans
endpoint. They are dropped, not spilled, while ClickHouse is unavailable.
| Column | Type | Meaning |
|---|---|---|
request_id | UUID | Request the span belongs to |
span_name | LowCardinality(String) | Step, such as auth_resolve or verdict:q:<id> |
parent_span | String | Parent step (default empty) |
start_ms | Int64 | Unix start time, ms |
duration_ms | UInt32 | Duration |
status | LowCardinality(String) | Outcome (default ok) |
attributes | String | JSON attributes (default empty) |
session_id | String | Same as the usage row |
session_id_source | LowCardinality(String) | Same as the usage row |
usage_meta table
Columns key String, value String, updated_at DateTime64(3), ordered by
(key, value). It holds the backfilled_v1 marker and the
backfill_claim_v1 election rows, and no usage data.
Data retention
A background worker runs at startup and then hourly, dropping whole usage
day partitions older than the retention window. The window defaults to
OBLETH_USAGE_RETENTION_DAYS (180) and is set live with
PUT /api/v1/settings/usage-retention; stored values are clamped to 1 to 36500
days. POST /api/v1/usage/compact prunes now. usage_daily is never pruned,
and you need no ClickHouse TTL on usage.
WAL and replay
When ClickHouse is unavailable, including at boot, usage rows spill to a local
write-ahead log whatever OBLETH_FAIL_OPEN says. Spill segments are JSON-lines
files in <OBLETH_WAL_PATH>.segments/ (default path
./obleth-telemetry.wal), rotated at 1 MiB or 60 seconds. When ClickHouse
answers, the gateway applies the schema and replays segments in order, one
checkpointed batch at a time with backoff.
- Spill stops at 256 MiB or 1,024 segments; past that, rows are dropped and
counted in
obleth_telemetry_dropped. The in-memory buffer holds 10,000 rows; a full buffer also drops. - A corrupt line moves to
<file>.corruptand the rest of the file replays. - Each batch carries an
insert_deduplication_tokenfrom its request ids, andusageandusage_dailysetnon_replicated_deduplication_window = 10000, so a replayed batch that had already committed is discarded, not counted twice. - The Helm chart sets
OBLETH_WAL_PATHto/var/lib/obleth/telemetry.walon a per-podemptyDir: spilled rows survive a container restart but not pod deletion.
Useful queries
Add AND request_type NOT IN ('health_probe', 'benchmark') to match what the
dashboard and Management API report.
Tokens by tenant, last 24 hours:
SELECT tenant_id,
sum(input_tokens) AS input_tokens,
sum(output_tokens) AS output_tokens,
count() AS requests
FROM obleth.usage
WHERE ts_ms >= toUnixTimestamp(now() - INTERVAL 24 HOUR) * 1000
AND request_type NOT IN ('health_probe', 'benchmark')
GROUP BY tenant_id
ORDER BY output_tokens DESC;
Cache hit rate by model:
SELECT model,
countIf(cache_status = 'hit') AS hits,
round(countIf(cache_status = 'hit') / count() * 100, 1) AS hit_pct
FROM obleth.usage
WHERE ts_ms >= toUnixTimestamp(now() - INTERVAL 24 HOUR) * 1000
GROUP BY model
ORDER BY hits DESC;
Latency percentiles, last hour:
SELECT quantile(0.50)(ttft_ms) AS p50_ttft_ms,
quantile(0.95)(ttft_ms) AS p95_ttft_ms,
quantile(0.95)(total_ms) AS p95_total_ms
FROM obleth.usage
WHERE ts_ms >= toUnixTimestamp(now() - INTERVAL 1 HOUR) * 1000
AND status_code < 400;
Last use per key:
SELECT key_id,
max(ts_ms) AS last_used_ms,
argMax(model, ts_ms) AS last_model,
argMax(status_code, ts_ms) AS last_status_code,
count() AS requests
FROM obleth.usage
WHERE ts_ms >= toUnixTimestamp(now() - INTERVAL 30 DAY) * 1000
GROUP BY key_id
ORDER BY requests DESC;
Spend by tenant and month, from the rollup:
SELECT tenant_id,
toStartOfMonth(day) AS month,
sum(cost_usd_sum) AS cost_usd
FROM obleth.usage_daily
GROUP BY tenant_id, month
ORDER BY month, cost_usd DESC;
