OTEL Tracing
Overview
Section titled “Overview”This document describes Queuert’s OpenTelemetry tracing implementation. Tracing provides end-to-end visibility into job chain execution, including job dependencies, retry attempts, and blocker relationships.
Span Hierarchy
Section titled “Span Hierarchy”Queuert uses a five-level span hierarchy:
PRODUCER: create chain.{type} ← Chain published (ends immediately)│├── PRODUCER: create job.{type} ← Job published (ends immediately)│ ││ ├── PRODUCER: await chain.{type} ← Blocker dependency│ │ links: [blocker chain]│ │ └── CONSUMER: resolve chain.{type} ← Blocker resolved│ ││ ├── CONSUMER: start job-attempt.{type} ← Worker processes attempt (has duration)│ │ ├── INTERNAL: prepare│ │ └── INTERNAL: complete│ ││ └── CONSUMER: start job-attempt.{type} ← Retry attempt│ ├── INTERNAL: prepare│ └── INTERNAL: complete│├── PRODUCER: create job.{type} ← Continuation job│ ││ └── CONSUMER: start job-attempt.{type} (final)│ ├── INTERNAL: prepare│ ├── INTERNAL: complete│ └── CONSUMER: complete chain.{type} ← Chain completionSpan kinds use OpenTelemetry’s PRODUCER/CONSUMER/INTERNAL semantics. The chain has both a PRODUCER (creation) and CONSUMER (completion) span for symmetry.
| Span | Kind | Created | Ended | Duration |
|---|---|---|---|---|
| create chain.{type} | PRODUCER | startJobChain() | Immediately | ~0ms |
| create job.{type} | PRODUCER | startJobChain(), continueWith() | Immediately | ~0ms |
| await chain.{type} | PRODUCER | startJobChain() with blockers | Immediately | ~0ms |
| resolve chain.{type} | CONSUMER | Blocker chain completes | Immediately | ~0ms |
| start job-attempt.{type} | CONSUMER | Worker claims job | Attempt completes/fails | Processing time |
| prepare | INTERNAL | prepare() called | prepare() returns | Transaction time |
| complete | INTERNAL | complete() called | complete() returns | Transaction time |
| complete job.{type} | CONSUMER | Workerless completion | Immediately | ~0ms |
| complete chain.{type} | CONSUMER | Final job completes | Immediately | ~0ms |
Trace Context Propagation
Section titled “Trace Context Propagation”Each job stores two trace contexts: chainTraceContext (chain-level, for chain completion and blocker linking) and traceContext (job-level, for attempt spans and continuation linking). Blocker dependencies store a single trace context in the job_blocker table (the blocker PRODUCER span context). All context values are string | null at the core level—the OTEL adapter uses W3C traceparent strings.
Context flows through the system:
- Chain start: Creates chain and job spans, stores
chainTraceContext(chain span) andtraceContext(job span) with the job - Blockers: Creates blocker PRODUCER spans as children of the job span, stores blocker span context in
job_blockertable. ReturnsblockerChainTraceContexts(thechainTraceContextfrom each blocker chain’s root job) for linking - Continuation: Inherits
chainTraceContextfrom origin, creates new job span with its owntraceContext, links to origin job - Worker processing: Creates attempt span as child of job using
traceContext, chain completion useschainTraceContext - Blocker completion: Ends blocker span using context from
job_blockertable - Chain completion: Creates CONSUMER chain span linked to PRODUCER chain using
chainTraceContext
Deduplication
Section titled “Deduplication”When startJobChain is called with deduplication options and a matching chain already exists, no new chain is created. The span must reflect this outcome correctly.
Deduplication is not an error—it’s expected behavior that successfully returned an existing chain. Per OpenTelemetry status conventions, the span status should remain UNSET (not ERROR), with an attribute indicating deduplication occurred.
Span Behavior
Section titled “Span Behavior”When deduplication occurs:
- Adds attribute
queuert.chain.deduplicated: true - References the existing chain’s IDs
- Optionally links to the existing chain’s trace context
Visualization
Section titled “Visualization”Caller requests startJobChain with deduplication key "user-123":
First call (creates new chain):PRODUCER create chain.process-user [0ms] ──────────────│ queuert.chain.id: "abc-123"│ queuert.chain.deduplicated: false│└── ... (normal processing)
Second call (deduplicated):PRODUCER create chain.process-user [0ms] ────────────── queuert.chain.id: "abc-123" ← same as existing queuert.chain.deduplicated: true links: [chain abc-123] ← link to existing chainBlocker Relationships
Section titled “Blocker Relationships”When a job has blockers (dependencies on other chains), each blocker gets a PRODUCER/CONSUMER span pair as a child of the blocked job’s PRODUCER span. The PRODUCER (await chain.{type}) is created at startJobChain time with a link to the blocker chain. The CONSUMER (resolve chain.{type}) is created when the blocker chain completes, so the time between them represents the blocking duration.
The blocker PRODUCER span’s trace context is persisted in the job_blocker table so the CONSUMER can be created later by a different process (the one completing the blocker chain).
EXTERNAL span (e.g., HTTP request)│├── PRODUCER: create chain.process-order ──────────────│ ││ └── PRODUCER: create job.process-order│ ││ ├── PRODUCER: await chain.fetch-user ──link──→ chain fetch-user│ │ └── CONSUMER: resolve chain.fetch-user│ ││ ├── PRODUCER: await chain.fetch-inventory ──link──→ chain fetch-inventory│ │ └── CONSUMER: resolve chain.fetch-inventory│ ││ └── CONSUMER: start job-attempt.process-order│ │ job.blockers contains resolved blocker outputs│ ├── INTERNAL: prepare│ ├── INTERNAL: complete ✓│ └── CONSUMER: complete chain.process-order│├── PRODUCER: create chain.fetch-user ─────────────────│ ││ └── PRODUCER: create job.fetch-user│ ││ └── CONSUMER: start job-attempt.fetch-user ✓│ ├── INTERNAL: prepare│ ├── INTERNAL: complete│ └── CONSUMER: complete chain.fetch-user│└── PRODUCER: create chain.fetch-inventory ──────────── │ └── PRODUCER: create job.fetch-inventory │ └── CONSUMER: start job-attempt.fetch-inventory ✓ ├── INTERNAL: prepare ├── INTERNAL: complete └── CONSUMER: complete chain.fetch-inventoryBlocker Span Lifecycle
Section titled “Blocker Span Lifecycle”- PRODUCER created and ended in
startJobChainwhen the job has blockers — one PRODUCER span per blocker, as a child of the job’s PRODUCER span, with a link to the blocker chain’s trace context - Persisted — the PRODUCER span context is stored in the
job_blockertable (trace_contextcolumn) so the CONSUMER can be created by another process - CONSUMER created when
unblockJobsdetects the blocker chain has completed — the PRODUCER span context is read fromjob_blockerand a CONSUMER span is created as its child
Continuation Relationships
Section titled “Continuation Relationships”When a job continues to another job via continueWith, the continuation links to its origin:
PRODUCER: create chain.multi-step ────────────────────────│├── PRODUCER: create job.step-one│ └── CONSUMER: start job-attempt.step-one #1│ ├── INTERNAL: prepare│ └── INTERNAL: complete (calls continueWith)│└── PRODUCER: create job.step-two │ links: [job step-one] ← origin link │ └── CONSUMER: start job-attempt.step-two #1 (final) ├── INTERNAL: prepare ├── INTERNAL: complete └── CONSUMER: complete chain.multi-stepThe origin link shows the causal flow: “step-two was created by step-one’s completion”.
Workerless Completion
Section titled “Workerless Completion”When a job is completed via completeJobChain (without a worker), there is no job-attempt. Instead, a CONSUMER job span marks the completion, and if the chain is fully completed, a CONSUMER chain span closes the trace:
PRODUCER: create chain.approve-order ─────────────────────│└── PRODUCER: create job.approve-order │ └── CONSUMER: complete job.approve-order ← Workerless completion │ └── CONSUMER: complete chain.approve-orderThe CONSUMER job span is a child of the PRODUCER job span and carries the same chain/job attributes. When continueWith is called during workerless completion, the CONSUMER chain span is omitted (the chain continues):
PRODUCER: create chain.multi-step ────────────────────────│├── PRODUCER: create job.step-one│ ││ └── CONSUMER: complete job.step-one ← Workerless completion (continueWith)│└── PRODUCER: create job.step-two │ links: [job step-one] │ └── ...This uses the completeJobSpan adapter method rather than startAttemptSpan, reflecting that no attempt processing occurred.
Chain Duration Measurement
Section titled “Chain Duration Measurement”With create chain at start and complete chain at end, total chain duration is calculated as:
Chain Duration = complete chain.startTime - create chain.startTimeThis provides end-to-end visibility even though individual PRODUCER/CONSUMER spans are instantaneous markers.
Summary
Section titled “Summary”Queuert’s tracing design provides:
- Symmetric chain spans: PRODUCER at creation, CONSUMER at completion
- Hierarchical job spans: Chain → Job → Attempt → prepare/complete
- Workerless completion: CONSUMER job span closes the trace without an attempt
- Blocker visibility: Dedicated blocker spans with links to blocker chains, duration = blocking time
- Continuation tracking: Span links connect jobs in a chain
- Retry visibility: Multiple attempt spans under each job
- Deduplication tracking: Attribute marks deduplicated chains, links to existing trace
- Cross-worker correlation: Trace context stored in job state
- Optional integration: Returns
undefinedwhen tracing disabled
See Also
Section titled “See Also”- OTEL Metrics — OpenTelemetry metrics implementation
- Job Chain Model — Chain identity and continuation model
- Job Processing — Prepare/complete pattern
- Adapters — Overall adapter design philosophy
- In-Process Worker — Worker lifecycle and attempt handling