Evaluation Queue Architecture#
Langfuse runs evaluations through two parallel BullMQ pipelines, both backed by Redis and hosted in the worker service:
| Pipeline | Queue Name | Target |
|---|---|---|
| Trace-based | evaluation-execution-queue | TRACE / DATASET evaluators |
| Observation-based | llm-as-a-judge-execution-queue | EVENT / EXPERIMENT evaluators |
Both pipelines ultimately call the same executeLLMAsJudgeEvaluation() core but differ in trigger path, data transport, and variable extraction. A third queue — secondary-evaluation-execution-queue — isolates high-throughput projects from the primary trace-based queue .
TraceUpsertQueue / DatasetRunItemUpsert / ui-create-eval
└─► createEvalJobs() ──────────────────────────────► evaluation-execution-queue
secondary-evaluation-execution-queue (high-throughput)
OTel ingestion path only
└─► scheduleObservationEvals() ──────────────────► llm-as-a-judge-execution-queue
Queue Names, Event Schemas & Key Files#
Queue names and Zod event schemas are centralized in packages/shared/src/server/queues.ts:
QueueName enum value | Redis key | Payload schema |
|---|---|---|
EvaluationExecution | evaluation-execution-queue | EvalExecutionEvent: { projectId, jobExecutionId, delay? } |
EvaluationExecutionSecondaryQueue | secondary-evaluation-execution-queue | Same as above |
LLMAsJudgeExecution | llm-as-a-judge-execution-queue | LLMAsJudgeExecutionEventSchema: { projectId, jobExecutionId, observationS3Path } |
The observationS3Path in the LLMAsJudgeExecution payload is the key architectural difference: observation evals upload the full observation object to S3 at scheduling time and pass the path through the queue, while trace evals carry only metadata and fetch data on-demand during execution.
Key source files:
| File | Role |
|---|---|
packages/shared/src/server/queues.ts | Queue name enum, event schemas, job type map |
packages/shared/src/server/redis/evalExecutionQueue.ts | Primary + secondary shard singletons |
packages/shared/src/server/redis/llmAsJudgeExecutionQueue.ts | LLMAsJudge shard singletons |
packages/shared/src/server/redis/sharding.ts | SHA-256 shard index computation |
worker/src/features/evaluation/evalService.ts | createEvalJobs, evaluate, extractVariablesFromTracingData |
worker/src/features/evaluation/observationEval/scheduleObservationEvals.ts | Observation eval scheduling + S3 upload |
worker/src/queues/evalQueue.ts | Secondary queue routing processor |
worker/src/queues/workerManager.ts | Per-shard worker registration |
Sharding#
All three eval queues support horizontal sharding. Shard count is set independently via environment variables (default: 1 each) :
| Queue | Env var |
|---|---|
EvaluationExecution | LANGFUSE_EVAL_EXECUTION_QUEUE_SHARD_COUNT |
EvaluationExecutionSecondaryQueue | LANGFUSE_EVAL_EXECUTION_SECONDARY_QUEUE_SHARD_COUNT |
LLMAsJudgeExecution | LANGFUSE_LLM_AS_JUDGE_EXECUTION_QUEUE_SHARD_COUNT |
Shard naming appends the index suffix starting at shard 1: evaluation-execution-queue (shard 0), evaluation-execution-queue-1 (shard 1), etc.
Shard selection at enqueue time :
- When Redis cluster is enabled (
REDIS_CLUSTER_ENABLED=true) and ashardingKeyis provided,getShardIndex()computesSHA-256(shardingKey).slice(0, 8)→ integer →% shardCount. - Otherwise, defaults to shard 0.
The sharding key used when enqueuing trace eval jobs is {projectId}-{jobExecutionId} . The same format is used when redirecting to the secondary queue .
Worker registration: WorkerManager calls Queue.getShardNames() and registers one BullMQ worker per shard . Sharded queues (including eval queues) use extended stall settings — lockDuration: 60 000 ms, stalledInterval: 120 000 ms, maxStalledCount: 3 — to tolerate slow LLM calls .
Secondary Queue for High-Throughput Projects#
The secondary-evaluation-execution-queue isolates specific projects from the primary trace-based queue to prevent high-throughput projects from blocking others .
Routing is done at execution time, not at job creation. The primary queue processor (evalQueue.ts) checks:
- Whether
LANGFUSE_SECONDARY_EVAL_EXECUTION_QUEUE_ENABLED_PROJECT_IDSis set (comma-separated project IDs). - Whether the incoming job's
projectIdmatches an entry in that list. - If matched, the job is re-enqueued on
SecondaryEvalExecutionQueuewith the same{projectId}-{jobExecutionId}sharding key and the primary job is discarded.
The secondary queue has its own independent shard count and worker pool, allowing it to be scaled separately.
Trace-Based vs Observation-Based Pipelines#
| Aspect | Trace-based (EvaluationExecution) | Observation-based (LLMAsJudgeExecution) |
|---|---|---|
| Trigger | TraceUpsertQueue, DatasetRunItemUpsert, ui-create-eval | OTel ingestion path only |
| Scheduling function | createEvalJobs() | scheduleObservationEvals() |
| Target object types | TRACE, DATASET | EVENT, EXPERIMENT |
| Data transport | Metadata only; data fetched on-demand from ClickHouse/Postgres at execution | Full observation uploaded to S3 at scheduling; S3 path passed through queue |
| Legacy SDK support | ✅ Yes | ❌ No — requires OTel SDK (Python v4+, JS/TS v5+) |
The OTel path's otelIngestionQueueProcessor calls fetchObservationEvalConfigs per batch and then scheduleObservationEvals() per observation. The legacy IngestionService only publishes to TraceUpsertQueue and never calls observation eval scheduling — this is confirmed intentional behavior.
scheduleObservationEvals() steps :
- Filter configs by in-memory filter evaluation + sampling.
- Upload the full observation payload to S3 once (not once per config).
- Per matching config: generate deterministic
jobExecutionIdfromconfigId:observationId, upsertJobExecutionrow, enqueue onLLMAsJudgeExecutionQueuewithdelay: 0.
Experiment configs (EXPERIMENT target) additionally require the observation to be the experiment root span before firing .
Variable Extraction & Multi-Source Mapping#
For trace-based evals, extractVariablesFromTracingData() resolves template variables from three source types:
langfuseObject | Data source | Lookup key |
|---|---|---|
trace | ClickHouse | jobInputTraceId + jobInputTraceTimestamp |
dataset_item | Postgres | jobInputDatasetItemId + jobInputDatasetItemValidFrom (versioned) |
Observation types (span, generation, event, agent, tool, etc.) | ClickHouse | traceId + mapping.objectName (observation name within trace) |
mapping.objectName is required for any observation-type variable and identifies which named observation to extract from within a trace. It forms the per-function cache key ({projectId}:{traceId}:{objectName}) to avoid redundant ClickHouse hits within a single execution .
selectedColumnId picks which field to return (input, output, metadata, expected_output). An optional jsonSelector applies a JSONPath expression to the selected value for nested extraction .
Available object types and their supported columns are defined in packages/shared/src/features/evals/types.ts (availableTraceEvalVariables, availableDatasetEvalVariables).
Observation evals use a simpler path: the observation payload is already available via the S3 download at execution time, so observationVariableMapping requires no langfuseObject or objectName — the executor reads directly from the pre-fetched object .