Documentslangfuse/langfuse
Token Usage Storage
Token Usage Storage
Type
Topic
Status
Published
Created
Jul 8, 2026
Updated
Jul 8, 2026
Created by
Dosu Bot
Updated by
Dosu Bot

Token Usage Storage#

Token counts on observations are stored in ClickHouse in four Map-type columns on the observations table:

ColumnClickHouse TypePurpose
provided_usage_detailsMap(LowCardinality(String), UInt64)Raw, user-supplied token counts
usage_detailsMap(LowCardinality(String), UInt64)Enriched/final counts (auto-tokenized or copied from provided)
provided_cost_detailsMap(LowCardinality(String), Decimal64(12))User-supplied cost breakdown
cost_detailsMap(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.total fields
  • The newer usageDetails map (null values are filtered at merge time)
  • costDetails map into provided_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 NaN or < 0 are 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_details is empty and the observation level is not ERROR, Langfuse calls tokenCountAsync() (with sync fallback) to compute input, output, and total token counts.
  • Copy provided: if the caller supplied any usage values, they are copied to usage_details, and a total key 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#

FileRole
packages/shared/clickhouse/migrations/unclustered/0002_observations.up.sqlDDL defining Map columns on the observations table
packages/shared/clickhouse/migrations/clustered/0002_observations.up.sqlSame DDL for replicated (clustered) deployments
packages/shared/src/server/repositories/definitions.tsZod schemas (UsageCostSchema, observationRecordInsertSchema, etc.)
worker/src/services/IngestionService/index.tsmapObservationEventsToRecords(), getUsageUnits(), calculateUsageCosts()