Events Table Architecture#
Langfuse is migrating its ClickHouse storage layer from a separate observations + traces model to a unified events-based model. In this new architecture, every span, observation, and trace root is a row in a single events table, with trace-level fields denormalized onto each row. The migration uses a dual-write pattern so the old and new pipelines run in parallel, allowing a gradual cutover.
ClickHouse Tables#
Three tables form the core of the new architecture (DDL in dev-tables.sh):
| Table | Purpose | Engine |
|---|---|---|
observations_batch_staging | Temporary staging table for in-flight ingestion events | ReplacingMergeTree(event_ts, is_deleted) — 3-min partitions, 12-hr TTL |
events_full | Canonical events table with full-length I/O and metadata | ReplacingMergeTree(event_ts, is_deleted) — monthly partitions |
events_core | Lightweight read table with truncated I/O (200 chars) | ReplacingMergeTree — populated via MV from events_full |
observations_batch_staging uses 3-minute partitions keyed on s3_first_seen_timestamp and has a 12-hour TTL (ttl_only_drop_parts=1) — complete partitions are dropped automatically, not individual rows .
events_full is the write target. Its primary key is (project_id, toStartOfMinute(start_time), xxHash32(trace_id)), enabling efficient range scans and trace-ID lookups . All trace-level fields (trace_name, user_id, session_id, tags, etc.) are denormalized directly onto each span row.
events_core is populated by a materialized view (events_core_mv) that truncates input/output/metadata_values to 200 characters . Most read queries use events_core for speed; detail views fetch from events_full.
Dual-Write During Ingestion#
When processing incoming events, IngestionService writes to both the legacy observations table and the new observations_batch_staging table. The dual-write is gated behind the writeToStagingTables / LANGFUSE_EXPERIMENT_INSERT_INTO_EVENTS_TABLE feature flags .
Two record types are staged:
- Observation records — written verbatim to staging with a partition-aware timestamp .
- Trace records — converted to observation-shaped records via
convertTraceToStagingObservationso the trace root becomes a pseudo-span withspan_id = trace_id.
Partition-Aware Timestamp#
The getPartitionAwareTimestamp() method controls which partition a staged record lands in. If the original createdAtTimestamp is within the last 2 minutes, it is used as-is (so the record lands in its natural 3-minute partition). Older events are stamped with Date.now(), forwarding them to the current partition. This implements a "partition lock" that prevents churning old partitions after they have been processed .
EventPropagation Job: Staging → events_full#
The handleEventPropagationJob worker runs every minute via BullMQ and propagates completed partitions from observations_batch_staging into events_full.
Processing Flow#
Redis cursor → next partition → JOIN with traces → INSERT INTO events_full → update cursor
-
Cursor read — The job reads
langfuse:event-propagation:last-processed-partitionfrom Redis to find the last completed partition . -
Partition selection — It queries
system.partsfor the oldest active partition inobservations_batch_stagingthat is (a) older than a configurable delay (default 10 minutes viaLANGFUSE_EXPERIMENT_EVENT_PROPAGATION_PARTITION_DELAY_MINUTES) and (b) after the cursor . -
JOIN + INSERT — The partition's rows are joined against
traces(within a ±1-day time window, capped at 7 days max) and inserted intoevents_full. Trace-level fields (name,user_id,session_id,tags,release,metadata) are merged onto each observation row at this step . -
Parent span resolution —
parent_span_idis resolved using COALESCE: ifparent_observation_idis null the trace's root pseudo-span (concat('t-', trace_id)) is used as fallback, keeping the tree connected . -
Cursor update — After a successful insert, the processed partition timestamp is written back to Redis .
Concurrency and Ordering#
The EventPropagationQueue enforces global concurrency = 1, ensuring partitions are always processed sequentially in timestamp order . The queue runs on a * * * * * cron (every minute) . The worker is only started when both QUEUE_CONSUMER_EVENT_PROPAGATION_QUEUE_IS_ENABLED=true and LANGFUSE_EXPERIMENT_INSERT_INTO_EVENTS_TABLE=true.
Query Layer#
The EventsQueryBuilder and EventsAggregationQueryBuilder provide fluent query building for the new tables. Key behaviors:
- Table selection: defaults to
events_core; switches toevents_fullautomatically when full I/O or metadata expansion is requested . - ORDER BY optimization: automatically prepends
project_idandtoStartOfMinute(start_time)to match the table's primary key . - xxHash32 optimization: adds
xxHash32(trace_id) = xxHash32(...)for trace-ID equality filters to enable primary key pruning . - Split queries:
buildEventsFullTableSplitQuery()filters onevents_core, then fetches full I/O only for matched rows fromevents_full— avoiding large I/O reads on the full table.
For trace-level aggregations (traces list UI), EventsAggregationQueryBuilder groups by (trace_id, project_id) and uses argMaxIf on event_ts to pick the latest value for mutable fields .
Key Source Files#
| File | Description |
|---|---|
dev-tables.sh | DDL for observations_batch_staging, events_full, events_core, events_core_mv |
handleEventPropagationJob.ts | Partition processing job (cursor, JOIN, INSERT) |
eventPropagationQueue.ts | BullMQ queue definition — cron schedule and global concurrency |
IngestionService/index.ts | Dual-write to staging; getPartitionAwareTimestamp |
event-query-builder.ts | Fluent query builders for events_core/events_full |