OTel Ingestion and Trace Hierarchy#
Overview#
Langfuse's OTel ingestion pipeline converts standard OpenTelemetry ResourceSpan payloads into Langfuse traces and observations. The flow is:
- HTTP → S3: The API handler uploads raw
resourceSpansto blob storage and enqueues a job on theOtelIngestionQueue - Worker →
OtelIngestionProcessor: TheotelIngestionQueueProcessordownloads the file and passes it toOtelIngestionProcessor.processToIngestionEvents(), which produces a flat list ofIngestionEventTypeevents - Split by type: Events are split into traces (
processEventBatch) and observations (IngestionService.mergeAndWrite), processed concurrently - ClickHouse write:
IngestionServicemerges events with any existing ClickHouse record and writes the final record
For each OTel span, the processor emits up to two events:
- A
trace-createevent (full, partial, or shallow — see below) - An observation event (
span-create,generation-create, etc.)
Primary source files:
OtelIngestionProcessor.ts— core conversion logicotelIngestionQueue.ts— worker job handlerIngestionService/index.ts— merge-and-write logicattributes.ts— allLangfuseOtelSpanAttributesenum values
Root Span Detection and trace-create Strategy#
Root Span Logic#
A span is classified as a root span if it has no parentSpanId or if it carries the langfuse.internal.as_root = "true" attribute :
isRootSpan = !parentObservationId || String(attributes[LangfuseOtelSpanAttributes.AS_ROOT]) === "true"
AS_ROOT is defined as "langfuse.internal.as_root" in the attributes enum. Setting it to "true" forces a span to behave as a trace root regardless of OTel parentage — useful when a Langfuse-instrumented service runs inside a broader OTel trace where the real root is not observable.
Three Grades of trace-create Events#
The processor emits one of three trace shapes per unique traceId :
| Grade | Condition | Fields populated |
|---|---|---|
| Full | isRootSpan === true | name, userId, sessionId, tags, input, output, metadata, version, release, environment |
| Partial | hasTraceUpdates && !isRootSpan | name, userId, sessionId, tags, metadata, version, release (no span input/output) |
| Shallow | First time seeing a traceId, no root/updates | Only id, timestamp, environment |
hasTraceUpdates is true when any of the Langfuse trace attributes (e.g. langfuse.trace.name, user.id, session.id, langfuse.trace.tags) or framework-specific equivalents (Vercel AI SDK ai.telemetry.metadata.*, LlamaIndex tag.tags) are present .
Trace Deduplication#
To avoid emitting redundant trace-create events for already-seen traces, the processor checks a Redis key (langfuse:project:{projectId}:trace:{traceId}:seen) with a 10-minute TTL . A trace-create is only emitted when the span is a root, has trace-level updates, or the traceId has not been seen in the last 10 minutes. Within a single batch, shallow trace-create events are suppressed if a full or partial one exists for the same traceId .
Parent-Child Observation Relationships#
Each OTel span maps to a Langfuse observation where:
observation.id= hex-encoded OTelspanIdobservation.parentObservationId= hex-encoded OTelparentSpanId(null for root spans)
Both Python and JS SDKs send IDs in different formats; parseId() handles both hex strings and integer arrays .
The parentObservationId is written directly to the ClickHouse observations table as parent_observation_id . There is no blocking wait for the parent to exist. If a child span arrives before its parent (common in async/concurrent traces), the child is written with an orphaned reference. This is resolved later by the event propagation layer.
Immutable Fields#
Once written to ClickHouse, id, project_id, trace_id, start_time, created_at, and environment are immutable — subsequent events for the same observation ID cannot overwrite these. Mutable fields (name, metadata, input, output, model, usage, etc.) are merged by taking the most recent non-null value from time-sorted events.
Eventual Consistency for Orphaned Parent References#
Langfuse does not retry or block when a child observation references a parent that hasn't been written yet. Instead, it relies on two layers of eventual consistency:
1. Dual-Write to Staging Table#
After ClickHouse write, each observation is also written to ObservationsBatchStaging with a partition-aware timestamp . The getPartitionAwareTimestamp() method ensures events within the last 2 minutes use their original createdAt, while older events get the current timestamp — preventing stale partition updates after the 4-minute lock window .
2. EventPropagation COALESCE Fallback#
The handleEventPropagationJob worker processes batches from ObservationsBatchStaging and writes them to events_full. During this step, the parent reference is resolved:
CASE
WHEN obs.id = concat('t-', obs.trace_id) THEN ''
ELSE coalesce(obs.parent_observation_id, concat('t-', obs.trace_id))
END AS parent_span_id
If parent_observation_id is null (i.e., the parent never arrived), the trace's root pseudo-span (t-{traceId}) becomes the fallback parent. This ensures the events_full tree is always connected.
3. Eval-Specific Retry#
For evaluation jobs that require a specific parent observation to exist, a separate retryObservationNotFound mechanism provides exponential backoff (30s → 1m → 2m → 4m) with a maximum of 5 retries. This is not used in the general ingestion path.
Key Attributes Reference#
All Langfuse-specific OTel span attributes are defined in the LangfuseOtelSpanAttributes enum:
| Attribute | Enum Key | Purpose |
|---|---|---|
langfuse.internal.as_root | AS_ROOT | Force span to be treated as trace root |
langfuse.trace.name | TRACE_NAME | Override trace name |
langfuse.trace.input / .output | TRACE_INPUT / TRACE_OUTPUT | Set trace-level I/O (not observation I/O) |
langfuse.trace.metadata | TRACE_METADATA | Trace metadata (also supports dotted sub-keys) |
user.id / session.id | TRACE_USER_ID / TRACE_SESSION_ID | User and session association |
langfuse.trace.tags | TRACE_TAGS | Trace tags (string, JSON array, or CSV) |
langfuse.observation.type | OBSERVATION_TYPE | Force observation type (span/generation/event) |
langfuse.observation.level | OBSERVATION_LEVEL | Observation level (DEFAULT/DEBUG/WARNING/ERROR) |
langfuse.observation.input / .output | OBSERVATION_INPUT / OBSERVATION_OUTPUT | Observation I/O |
langfuse.observation.model.name | OBSERVATION_MODEL | Model name for generation observations |
langfuse.observation.usage_details | OBSERVATION_USAGE_DETAILS | Token usage (JSON) |
langfuse.environment | ENVIRONMENT | Environment tag (also supports deployment.environment.name) |
The processor supports input/output extraction for many third-party instrumentation libraries (Vercel AI SDK, Genkit, MLFlow, TraceLoop, Pydantic AI, LiveKit, LlamaIndex, etc.) via framework-specific attribute paths .