Token Usage Storage#
Token counts on observations are stored in ClickHouse in four Map-type columns on the observations table:
| Column | ClickHouse Type | Purpose |
|---|---|---|
provided_usage_details | Map(LowCardinality(String), UInt64) | Raw, user-supplied token counts |
usage_details | Map(LowCardinality(String), UInt64) | Enriched/final counts (auto-tokenized or copied from provided) |
provided_cost_details | Map(LowCardinality(String), Decimal64(12)) | User-supplied cost breakdown |
cost_details | Map(LowCardinality(String), Decimal64(12)) | Final computed cost breakdown |
The separation between provided_* and non-prefixed columns lets the system distinguish what the caller sent from what Langfuse computed—useful for auditing, debugging, and re-tokenizing when model definitions change.
The same four fields appear in the Zod schemas used for both inserts and reads: observationRecordInsertSchema / observationRecordReadSchema, and in the events table schema (eventRecordBaseSchema).
Data Flow: from API call to ClickHouse#
API request (usage / usageDetails / costDetails)
│
▼
mapObservationEventsToRecords() ← builds provided_usage_details
│
▼
mergeObservationRecords() ← merges with prior ClickHouse record
│
▼
getGenerationUsage()
├─ getUsageUnits() ← validates + enriches → usage_details
└─ calculateUsageCosts() ← computes cost_details
│
▼
ClickhouseWriter.addToQueue(observations, …)
Step 1 — Mapping user input to provided_usage_details
mapObservationEventsToRecords() extracts three sources from the event body and merges them into provided_usage_details:
- Legacy
usage.input,usage.output,usage.totalfields - The newer
usageDetailsmap (null values are filtered at merge time) costDetailsmap intoprovided_cost_details
Step 2 — Validation in getUsageUnits()
Before enrichment, all provided_usage_details entries are re-validated :
- Values are coerced via
Number(value) - Entries where the result is
NaNor< 0are silently dropped - Null/undefined values are skipped
This guards against strings, negative counts, or ClickHouse returning UInt64 as strings (which would cause string-concatenation bugs without the coercion).
Step 3 — Enrichment: auto-tokenization vs. copy
getUsageUnits() then decides how to populate usage_details:
- Auto-tokenize: if the validated
provided_usage_detailsis empty and the observation level is notERROR, Langfuse callstokenCountAsync()(with sync fallback) to computeinput,output, andtotaltoken counts. - Copy provided: if the caller supplied any usage values, they are copied to
usage_details, and atotalkey is synthesized if absent:sum(all values).
Step 4 — Cost calculation
calculateUsageCosts() populates cost_details. If provided_cost_details has any entries, they are used as-is (no model price look-up). Otherwise, unit counts in usage_details are multiplied by per-token prices resolved from the matched model/pricing tier.
Schema helpers and read-path coercion#
ClickHouse returns UInt64 map values as strings over the wire. UsageCostSchema (a Zod transformer) coerces each map entry to Number, drops nulls, and raises a Zod issue on non-numeric values. This schema is applied on every read in both observationRecordReadSchema and eventRecordReadSchema .
The convertObservationReadToInsert() helper passes all four detail maps through unchanged when round-tripping from read → insert (e.g., during record merges on update events).
Key source files#
| File | Role |
|---|---|
packages/shared/clickhouse/migrations/unclustered/0002_observations.up.sql | DDL defining Map columns on the observations table |
packages/shared/clickhouse/migrations/clustered/0002_observations.up.sql | Same DDL for replicated (clustered) deployments |
packages/shared/src/server/repositories/definitions.ts | Zod schemas (UsageCostSchema, observationRecordInsertSchema, etc.) |
worker/src/services/IngestionService/index.ts | mapObservationEventsToRecords(), getUsageUnits(), calculateUsageCosts() |