V4 Data Pipeline Migration#
Langfuse V4 replaces the separate traces + observations ClickHouse tables with a unified events-based model: every span, observation, and trace root is a row in a single wide table with trace-level fields denormalized onto each row . The migration is additive — new tables are added alongside the old ones — and uses a dual-write pattern so deployments can pace their own cutover .
Old model New model
┌──────────┐ ┌──────────────┐ ┌──────────────────────────────┐
│ traces │ │ observations │ → │ events_full │
└──────────┘ └──────────────┘ │ (every span, denormalized) │
joined at read time └──────────────────────────────┘
↑ MV
┌────────────────┐
│ events_core │
│ (truncated I/O)│
└────────────────┘
New ClickHouse tables (DDL in dev-tables.sh):
| Table | Role |
|---|---|
observations_batch_staging | Short-lived staging for in-flight events; 3-min partitions, 12-hr TTL |
events_full | Write target; primary key (project_id, toStartOfMinute(start_time), xxHash32(trace_id)) |
events_core | Read-optimized projection via materialized view; I/O truncated to 200 chars |
Traces have no separate entity in V4. Each trace is represented as a virtual root span with span_id = t-{traceId}, making the unified table queryable for both spans and trace-level aggregations .
Breaking changes after cutover to events_only:
- Legacy batch ingestion (
POST /api/public/ingestion, traces/spans/generations/events) → OTLP (POST /api/public/otel/v1/traces) - Read APIs (
GET /api/public/traces,/observations,/sessions, etc.) → Observations API v2 - Python SDK ≤ v2 and JS/TS SDK ≤ v3 are rejected
- Trace-level LLM-as-a-Judge evaluators stop running
All of these keep working in legacy or dual write mode, so the cutover is controlled by the operator.
Write Mode Transitions#
The LANGFUSE_MIGRATION_V4_WRITE_MODE env var controls which tables ingestion targets. All migration modes are temporary — legacy and dual will be removed in a future major version .
| Mode | Behavior | When to use |
|---|---|---|
legacy | Full v3 behavior; new tables not written | De-risk server upgrade; schedule data model migration separately |
dual | Writes both old and new tables; enables v4 UI toggle and v2 APIs | Gradual rollout; older SDKs go through staging with ~10 min delay |
events_only (default) | Writes only new tables; legacy endpoints return 404 | Default for new deployments; cutover target for migrations |
Important sequencing rules :
- Upgrade ClickHouse to ≥ 25.12 before the server upgrade (also requires PostgreSQL ≥ 15, Redis ≥ 7.0).
- Keep
LANGFUSE_BACKGROUND_MIGRATION_V4_ENABLE_HISTORIC_BACKFILL=falsewhile onlegacy— the backfill runs once, and data written between its cutoff and the start ofdualwrite would be permanently missing from the new tables. LANGFUSE_MIGRATION_V4_NATIVE_OTEL_BEHAVIOUR: set todual_writeto retain server-side attribute propagation for OTel producers that haven't adopted client-side propagation; defaults todirect(V4 behavior).
Full configuration reference :
| Variable | Values / default | Purpose |
|---|---|---|
LANGFUSE_MIGRATION_V4_WRITE_MODE | legacy / dual / events_only | Ingestion target tables |
LANGFUSE_MIGRATION_V4_NATIVE_OTEL_BEHAVIOUR | dual_write / direct | OTel attribute propagation mode |
LANGFUSE_MIGRATION_V4_ALLOW_PREVIEW_OPT_IN | true / false | Gates v4 UI toggle and v2 APIs |
LANGFUSE_BACKGROUND_MIGRATION_V4_ENABLE_HISTORIC_BACKFILL | true / false | Enable/defer automated backfill |
LANGFUSE_BACKGROUND_MIGRATION_V4_DROP_PID_TID_SORTING_TABLES | true / false | Drop scratch table after backfill |
Dual write mode sample config :
LANGFUSE_MIGRATION_V4_WRITE_MODE=dual
LANGFUSE_MIGRATION_V4_NATIVE_OTEL_BEHAVIOUR=dual_write
LANGFUSE_BACKGROUND_MIGRATION_V4_ENABLE_HISTORIC_BACKFILL=false
LANGFUSE_MIGRATION_V4_ALLOW_PREVIEW_OPT_IN=false
ClickHouse schema migrations (new events tables, materialized views) are applied automatically on server startup via golang-migrate . No manual schema work is required.
Dual-Write Pipeline and EventPropagation Worker#
In dual mode, IngestionService writes to both the legacy observations table and observations_batch_staging. This is gated by the writeToStagingTables flag / LANGFUSE_EXPERIMENT_INSERT_INTO_EVENTS_TABLE env var .
Two staging record types :
- Observation records — written verbatim, stamped with a partition-aware timestamp
- Trace records — converted via
convertTraceToStagingObservationto a pseudo-span withspan_id = trace_id
Partition-aware timestamp — getPartitionAwareTimestamp(): if createdAtTimestamp is within the last 2 minutes, use it as-is; otherwise, use Date.now(). This prevents churning already-processed 3-minute partitions .
Who writes directly vs. through staging :
- Python SDK ≥ 4.7.0, JS/TS SDK ≥ 5.4.0, or OTel with
x-langfuse-ingestion-version: 4→ writesevents_fulldirectly, no delay - Older SDKs → staging pipeline → ~10 minute delay
EventPropagation Job#
handleEventPropagationJob runs every minute via BullMQ and propagates completed staging partitions into events_full :
Redis cursor → select oldest completed partition → JOIN with traces → INSERT INTO events_full → update cursor
- Cursor — reads
langfuse:event-propagation:last-processed-partitionfrom Redis - Partition selection — queries
system.partsfor the oldest partition older thanLANGFUSE_EXPERIMENT_EVENT_PROPAGATION_PARTITION_DELAY_MINUTES(default 10 min) and after the cursor - JOIN + INSERT — joins rows against
traces(±1-day window, 7-day max), denormalizingname,user_id,session_id,tags,release,metadataonto each observation row - Parent span resolution —
parent_span_id = COALESCE(parent_observation_id, concat('t-', trace_id)), ensuring the tree is always connected even if a parent span never arrived - Cursor update — writes the processed partition timestamp back to Redis
Concurrency: global concurrency = 1, ensuring strictly sequential partition processing in timestamp order (eventPropagationQueue.ts) .
Required env vars to enable the worker :
QUEUE_CONSUMER_EVENT_PROPAGATION_QUEUE_IS_ENABLED=true
LANGFUSE_EXPERIMENT_INSERT_INTO_EVENTS_TABLE=true
Health monitoring: GET /api/health?failIfEventPropagationStuck=true on worker port 3030 returns 503 when the job hasn't progressed within LANGFUSE_EVENT_PROPAGATION_STUCK_THRESHOLD_MINUTES (default 15). Safe to keep configured after cutover — it passes when the dual write is not running .
Staging partitions are retained for 48 hours, giving a multi-day grace window for recovery after an incident .
Historic Backfill (M1–M5 Chain)#
Data ingested before the dual write was active exists only in the old tables. Two options exist :
- Automated backfill — rewrites all historic data into
events_full; requires ~3× ClickHouse disk headroom. Enable withLANGFUSE_BACKGROUND_MIGRATION_V4_ENABLE_HISTORIC_BACKFILL=trueonly afterdualwrite is active and confirmed healthy. - Retention-based rollover — skip the backfill; keep
dualwrite active for one full data retention window. Zero extra disk required.
The Five-Step Chain#
The backfill runs as an ordered chain; each step only starts after its predecessor finishes successfully. Built on ChunkedClickhouseBackfillMigration :
| Step | Migration | What it does |
|---|---|---|
| M1 | CreateRootSpansFromTraces | Writes virtual root spans from traces → events_full |
| M2 | RewriteObservationsToPidTidSorting | Copies observations into scratch table re-sorted by (project_id, trace_id, id); issues SYSTEM STOP MERGES + SYSTEM SYNC REPLICA STRICT to freeze part layout |
| M3 | BackfillEventsFullFromObservations | LEFT ANY JOIN scratch table with traces → events_full (child spans) |
| M4 | BackfillEventsFullFromDatasetRunItems | Cursor-paginated enrichment of experiment spans → events_full |
| M5 | DropPidTidSortingTables | Drops scratch table; gated by LANGFUSE_BACKGROUND_MIGRATION_V4_DROP_PID_TID_SORTING_TABLES=true |
Fire-and-poll pattern for long-running ClickHouse INSERT … SELECT queries : dispatch with AbortController, confirm running via system.processes, abort the HTTP connection, then poll system.query_log for completion. Sets max_execution_time: 0 to bypass client-side timeouts.
State persistence: phase (init → loading_chunks → backfill → completed), chunk todos, and active query IDs are serialized to background_migrations.state in Postgres on every transition — making the chain fully resumable after worker restarts .
Cluster awareness: detectTableEngine() branches between SharedMergeTree (ClickHouse Cloud), ReplicatedMergeTree (self-hosted HA), and MergeTree (single-node), adapting DDL and SYSTEM commands accordingly .
Track backfill progress on the background migrations page in the Langfuse UI, or via the background migrations docs.
OTel Ingestion Migration#
V4 replaces the Langfuse-proprietary batch ingestion API with standard OTLP. All spans must be sent to /api/public/otel/v1/traces with x-langfuse-ingestion-version: 4 .
Legacy API → OTLP Mapping#
| Legacy concept | V4 OTLP representation |
|---|---|
| Trace record | OTel trace ID shared across all spans; no separately-ingested trace entity |
Trace name, userId, sessionId, tags, release, version | Corresponding langfuse.* attributes copied to every span where they must be filterable |
Trace input / output | Deprecated → use langfuse.observation.input/output on the root span |
| Span / Generation / Event | OTel span with langfuse.observation.type set to span / generation / event |
| Create + update events | One span assembled in memory, exported once after it ends |
| Score event | Scores API / SDK — not an OTLP span |
Client-side attribute propagation is required in V4. In the old model, trace attributes were joined server-side at read time. Now they must be present on every observation row. Use OTel Baggage with a span processor to copy selected entries to attributes on every span . See Propagating Trace Attributes to All Spans.
LANGFUSE_MIGRATION_V4_NATIVE_OTEL_BEHAVIOUR=dual_write retains server-side propagation for OTel producers that haven't completed client-side propagation yet — spans without x-langfuse-ingestion-version: 4 go through the staging pipeline (~10 min delay) .
SDK Compatibility Matrix#
| SDK version | Write path |
|---|---|
| Python ≥ 4.7.0 / JS/TS ≥ 5.4.0 | Direct to events_full — no delay |
| Python 4.0–4.6.x / JS/TS 5.0–5.3.x | Staging pipeline — ~10 min delay |
| Python ≤ v2 / JS/TS ≤ v3 | Rejected after events_only cutover |
OTel with x-langfuse-ingestion-version: 4 | Direct to events_full — no delay |
Full ingestion checklist and validation steps: Migrate custom ingestion to Langfuse v4 .
Cutover, Cleanup, and Key Source Files#
Cutover#
Remove migration overrides (or explicitly set events_only) once :
- All producers run compatible SDKs, and
- Historic data is covered (backfill complete, or one full retention window dual-written)
After cutover: legacy endpoints return 404, older SDKs are rejected, trace-level evaluators stop running. This is the point of commitment — rolling back to a v3 read path would miss data written since the switch.
Optional Cleanup#
-
Drop scratch table: set
LANGFUSE_BACKGROUND_MIGRATION_V4_DROP_PID_TID_SORTING_TABLES=trueafter confirming backfill health -
Truncate old tables (irreversible — only after cutover is confirmed):
TRUNCATE TABLE traces; TRUNCATE TABLE observations; -- On clustered deployments: TRUNCATE TABLE traces ON CLUSTER default; TRUNCATE TABLE observations ON CLUSTER default;Use
TRUNCATE, notDROP— Langfuse expects the tables to exist .
Rollback#
V4 schema migrations are additive (new tables only). While on legacy or dual, the old tables receive all data — rolling back to the latest v3 release is safe. After events_only cutover, new data lands only in the new tables; rollback is not safe .
Key Source Files#
| File | Role |
|---|---|
worker/src/services/IngestionService/index.ts | Dual-write to staging; getPartitionAwareTimestamp, convertTraceToStagingObservation |
worker/src/features/eventPropagation/handleEventPropagationJob.ts | EventPropagation BullMQ job (cursor, JOIN, INSERT) |
packages/shared/src/server/redis/eventPropagationQueue.ts | Queue definition: cron schedule, global concurrency = 1 |
packages/shared/clickhouse/scripts/dev-tables.sh | DDL for observations_batch_staging, events_full, events_core, events_core_mv |
packages/shared/clickhouse/migrations/ | golang-migrate SQL files (clustered / unclustered) |
worker/src/backgroundMigrations/utils/backfillBase.ts | ChunkedClickhouseBackfillMigration base class, fire-and-poll, state persistence |
packages/shared/src/server/otel/OtelIngestionProcessor.ts | OTel span → Langfuse events conversion |
packages/shared/src/server/queries/clickhouse-sql/event-query-builder.ts | EventsQueryBuilder / EventsAggregationQueryBuilder for events_core/events_full |
Official docs: