OBLETHdocs

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).

TableEngineHolds
usageMergeTreeOne row per request, pruned to the retention window
usage_dailySummingMergeTreeDaily rollup of usage, never pruned
usage_daily_mvMaterialized viewFeeds usage_daily from new usage rows
usage_metaReplacingMergeTree(updated_at)Internal markers for one-time schema work
spansMergeTreePer-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.

ColumnTypeMeaning
request_idUUIDThe x-obleth-request-id of the request
tenant_idUUIDTenant; nil for health probes
key_idUUIDAPI key; nil for health probes
modelStringCanonical model name (aliases and auto resolved)
admissionLowCardinality(String)fast, queued, rejected, boon or health_probe
weightInt64Tenant weight at request time
input_tokensUInt32Input tokens, cached ones included
cached_input_tokensUInt32Input tokens the backend served from its prefix cache, as it reported them; at most input_tokens (default 0)
output_tokensUInt32Output tokens
estimated_tokensUInt32Pre-request estimate used for budgeting
queue_wait_msUInt32Time waiting for a fairshare slot
ttft_msUInt32Time to first token
total_msUInt32Total duration
status_codeUInt16HTTP status returned
cache_statusLowCardinality(String)hit, miss or off (default off)
cost_usdFloat64USD cost, fixed at completion (default 0)
energy_whFloat64Attributed watt-hours (default 0)
energy_cost_usdFloat64Energy at the configured $/kWh (default 0)
co2_gFloat64Grams CO₂ at the configured grid intensity (default 0)
ts_msInt64Unix time in milliseconds
session_idStringConversation id (default empty)
session_id_sourceLowCardinality(String)client, derived or none
request_typeLowCardinality(String)See Request types
device_idStringDevice claim from an identity token; empty for API keys
end_userStringThe end user the request named, for a key with per-user fairshare on; else empty
parent_request_idUUIDOn a helper row, the client request it served; nil otherwise
model_variantStringThe variant the client named; model stays its model's name. Empty otherwise, including calls through a model family
model_familyStringThe model family the client named; model is the member that answered. Empty otherwise
reasoning_effortLowCardinality(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_requestedLowCardinality(String)The level the caller asked for, folded to none/low/medium/high when known; recorded for pass-through models too
tsDateTime64(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 0 unless energy accounting is on for the model. A non-finite value is stored as 0.
  • session_id comes from x-obleth-session-id, x-session-id, or the body's session_id or metadata.session_id; otherwise it is derived from the conversation unless OBLETH_SESSION_ID_DERIVATION is off. See Conversations and sessions.
  • device_id is kept only when it is at most 64 letters, digits, _ or -. See Identity tokens.
  • cached_input_tokens comes from the backend's usage.prompt_tokens_details.cached_tokens, read from the same usage object as prompt_tokens. Estimates, cancelled requests, answers the gateway writes itself and rows written before v2.1.0 record 0. usage_daily has no sum of it.

Request types

request_type drives the Type column and the type: filter in Request logs.

ValueKindWritten for
chatClient/v1/chat/completions
completionClient/v1/completions
responsesClient/v1/responses
messagesClient/v1/messages
verdictClient/v1/verdicts, one row for all questions
embeddingClient/v1/embeddings
audioClientAny path under /v1/audio/
imageClientAny path under /v1/images/
videoClientPOST /v1/videos; follow-ups write no row
rerankClient/v1/rerank, /v1/reranking
moderationClient/v1/moderations
searchClient/v1/search; no tokens, no cost
otherClientAny other forwarded path
vision_boonHelperVision boon's describer model
structured_output_boonHelperStructured-output repair calls
tool_loopHelperEach round trip of the gateway tool loop
guardrails_boonHelperGuardrails harm classifications
speculation_draftHelperSpeculation boon's draft calls
speculation_verifyHelperSpeculation boon's verification calls
image_generation_boonHelperImage-generation boon calls
web_search_boonHelperEach search the web-search boon runs; no tokens, no cost
health_probeInternalHealth checks, under the nil tenant
benchmarkInternalEvery 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).

ColumnTypeMeaning
dayDatetoDate(ts)
tenant_idUUIDTenant
key_idUUIDAPI key
modelStringModel
requestsUInt64All requests
success_requestsUInt64Status 200 to 399
error_requestsUInt64Status 400 and above
input_tokensUInt64Summed input tokens
output_tokensUInt64Summed output tokens
estimated_tokensUInt64Summed estimates
cache_hitsUInt64Rows with cache_status = 'hit'
cache_missesUInt64Rows with cache_status = 'miss'
ttft_ms_sumUInt64Time to first token, successful requests only
total_ms_sumUInt64Duration, successful requests only
cost_usd_sumFloat64Summed cost_usd
energy_wh_sumFloat64Summed energy_wh
energy_cost_usd_sumFloat64Summed energy_cost_usd
co2_g_sumFloat64Summed 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_mv adds new usage rows as they land, excluding benchmark.
  • 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.
  • SummingMergeTree collapses rows sharing the sort key on merge, so query with sum() 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.

ColumnTypeMeaning
request_idUUIDRequest the span belongs to
span_nameLowCardinality(String)Step, such as auth_resolve or verdict:q:<id>
parent_spanStringParent step (default empty)
start_msInt64Unix start time, ms
duration_msUInt32Duration
statusLowCardinality(String)Outcome (default ok)
attributesStringJSON attributes (default empty)
session_idStringSame as the usage row
session_id_sourceLowCardinality(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>.corrupt and the rest of the file replays.
  • Each batch carries an insert_deduplication_token from its request ids, and usage and usage_daily set non_replicated_deduplication_window = 10000, so a replayed batch that had already committed is discarded, not counted twice.
  • The Helm chart sets OBLETH_WAL_PATH to /var/lib/obleth/telemetry.wal on a per-pod emptyDir: 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;