Observation Eval Scheduling#
Observation-level evaluators (job configs with targetObject = EVENT or EXPERIMENT) are scheduled through a separate pipeline from trace-level evaluators, and are only triggered by the OTel ingestion path. Understanding this split is critical for debugging missing eval scores.
Core architectural split:
| Evaluator target | Triggered by | Scheduling function |
|---|---|---|
TRACE / DATASET | TraceUpsertQueue → worker | createEvalJobs() in evalService.ts |
EVENT / EXPERIMENT | OTel ingestion → worker | scheduleObservationEvals() in observationEval/ |
Both paths ultimately call the same executeLLMAsJudgeEvaluation() core , but differ in trigger mechanism, config fetch, and variable extraction source.
Ingestion Paths#
OTel Ingestion — the only path that auto-triggers observation-level evals#
The otelIngestionQueueProcessor handles this end-to-end:
- Downloads raw ResourceSpans from S3, converts to events, and concurrently writes traces (via
processEventBatch) and observations (viaIngestionService.mergeAndWrite) to ClickHouse . - After writes complete, calls
processor.processToEvent(parsedSpans)to produce enriched event records carrying trace-level attributes (userId, sessionId, tags, release) . - Calls
fetchObservationEvalConfigsfor the project. If configs exist, runsscheduleObservationEvals()per observation.
This enrichment step — provided by OtelIngestionProcessor.processToEvent() — is what makes observation-level scheduling possible and is absent from the legacy ingestion path.
Legacy SDK Ingestion — trace-level evals only#
The IngestionService (used by langfuse SDK v3 client.generation() / client.trace() calls) writes observations to ClickHouse and publishes to TraceUpsertQueue . The queue consumer calls createEvalJobs() with sourceEventType: "trace-upsert", which only processes TRACE/DATASET-targeted configs. scheduleObservationEvals() is never called.
This is confirmed intentional behavior by Langfuse maintainers — the legacy flow will not be extended . Observation-level evaluators require migrating to OTel-based SDKs:
- Python SDK v4+
- JS/TS SDK v5+
- Or OTel-native ingestion version 4 (
x-langfuse-ingestion-version: 4header)
The log message "No active evaluation jobs found" / "Cached no traceBased eval configs" appearing after legacy-SDK ingestion is expected — it is emitted by the trace-level path, which is unaware of observation eval configs .
Batch Actions (ui-create-eval)#
Uses createEvalJobs() with sourceEventType: "ui-create-eval" — the trace-level path. Observation-level EVENT/EXPERIMENT evaluators are not triggered via this path .
Experiments (dataset-run-item-upsert)#
When a dataset run item is ingested, createEvalJobs() is called with sourceEventType: "dataset-run-item-upsert". Critical guard: if datasetItem.observationId is set, the function explicitly skips creating a trace-level eval job — doing so would prematurely attach a score at the trace level. Observation-level scoring for experiment items is expected to flow through the OTel path's EXPERIMENT target object handling in scheduleObservationEvals.
scheduleObservationEvals Logic#
scheduleObservationEvals receives a pre-fetched list of observation eval configs and a single observation record:
- Filter + sample: for each config, evaluates filter conditions via
InMemoryFilterServiceand applies the config's sampling rate . Skips S3 upload if nothing matches. - S3 upload (once per observation, not per config): uploads the full observation payload for later variable extraction by the executor .
- Per-config job creation :
- Generates a deterministic
jobExecutionIdfromconfigId:observationId - Upserts a
JobExecutionrow withjobInputTraceId+jobInputObservationId - Enqueues on
LLMAsJudgeExecutionQueuewithdelay: 0
- Generates a deterministic
Experiment configs additionally require the observation to be the experiment root span (observation.span_id === observation.experiment_item_root_span_id) to fire .
Key Files#
| File | Role |
|---|---|
otelIngestionQueue.ts | Only ingestion path that triggers observation evals |
observationEval/scheduleObservationEvals.ts | Core scheduling — filter, sample, S3 upload, enqueue |
observationEval/fetchObservationEvalConfigs.ts | Fetches active configs with targetObject IN [EVENT, EXPERIMENT] |
observationEval/observationEvalProcessor.ts | Executes scheduled jobs (S3 download → variable extraction → LLM judge) |
evalService.ts | Trace-level eval scheduling; guards against observation-linked dataset items |
IngestionService/index.ts | Legacy ingestion — only publishes to TraceUpsertQueue |
Debugging#
- Evals not triggering at all: check whether ingestion goes through the legacy SDK (will never trigger observation evals) vs. OTel SDK.
- Check if jobs are being created: filter traces in the UI by
environment = langfuse-llm-as-a-judge. - Observation-not-found retries:
retryObservationNotFounduses exponential backoff (30s → 1m → 2m → 4m, max 5 retries) when an eval job requires a specific observation that hasn't arrived yet . "No active evaluation jobs found"log: this is emitted by the trace-level path and is expected when using observation-targeted evaluators with legacy ingestion .