“Some receipt emails arrive 20 minutes late. Every checkout trace ends at 'insert into jobs' — and then nothing.”
case name: The trace that stops at the queue
- Service
- shoplite-api · receipt worker
- Impact
- Late receipts; support tickets; no way to follow a single order end to end
- Detected by
- Support tickets
- Time to resolve
- 2 h
Skills you'll use on this case
propagation.inject / extractmanual spansspan links00 It starts
Reproduce this incident in your lab, then work the case alongside the timeline below. Try each query yourself before reading its result.
01 The investigation
- 09:00
FINDING
How receipts work: checkout inserts a job row; a worker loop picks jobs up
It's a Postgres-backed job queue: checkout writes
insert into jobs (kind, payload), and a worker polls withfor update skip locked. It's a common, solid pattern, and completely invisible to tracing unless you carry the context yourself.obs-lab/shoplite/server.js (as originally written)add to filejs // in /checkout, after the order insert: await db.query("insert into jobs (kind, payload) values ('receipt', $1)", [{ productId, qty }]); // worker, started once at boot: setInterval(async () => { const { rows } = await db.query( "delete from jobs where id = (select id from jobs order by id limit 1 for update skip locked) returning *"); if (rows[0]) await sendReceipt(rows[0].payload); }, 500); // db/init.sql: create table jobs (id bigserial primary key, kind text, payload jsonb, // created_at timestamptz default now()); - 09:20
QUERY
Search for the receipt work in Tempo
The worker's database queries show up, but each one is the ROOT of its own tiny trace: no parent, no link to the checkout that created the job. The
setIntervalcallback runs outside any request, so there's no active context to inherit.TraceQL· Tempo{ resource.service.name = "shoplite" && name =~ "pg.query:DELETE.*" }resultTrace ID Root span Spans Duration 91ab… pg.query:DELETE shoplite 1 2ms 77c0… pg.query:DELETE shoplite 1 2ms ... (thousands of 1-span orphan traces) - 09:40
HYPOTHESIS
Late receipts = time waiting in the queue, but we can't measure it per order
The interesting latency lives BETWEEN the two halves: from insert to pickup. With the trace broken at the queue, there's no way to see it for any specific order.
- 11:00
RESOLVED
Context carried through the job row; one trace per order, queue time visible
Root cause
Trace context propagates automatically across HTTP calls (headers) and within a request (async context), but NOT through data at rest. The job row carried the payload but not the trace context, so the worker's spans started new, disconnected traces.
02 The concepts behind it
Propagation is a carrier problem
Context crosses boundaries by being written into a CARRIER on one side and read back on the other. HTTP headers are the carrier that instrumentation handles for you. Message queues (Kafka headers, SQS message attributes, RabbitMQ properties) often need instrumentation or manual code. Database job tables, files, and cron-scheduled work always need you to store and restore the context yourself.
OpenTelemetry's API makes this two calls: propagation.inject(context, carrier) writes traceparent (and friends) into any object, and propagation.extract(context, carrier) rebuilds the context from it.
Parent or link?
If one job is caused by one request, making the job's span a CHILD of the request (extract, then start the span within that context) gives one continuous trace, including the time spent waiting in the queue. If one piece of work processes MANY messages (a batch), use span LINKS: the batch span has its own trace and links to each originating trace, because a span can have only one parent.
Manual spans for your own logic
Auto-instrumentation doesn't know what a 'receipt' is. tracer.startActiveSpan("send-receipt", ...) creates a named span with your attributes (order.id, queue.wait_ms), which is searchable in TraceQL and meaningful to anyone reading the trace.
03 The fix
01Inject into the job, extract in the worker
Store the carrier in a
tracejsonb column. The worker restores the context and runs its work inside a new span whose PARENT is the checkout. It also records how long the job waited, the number that actually explains late receipts.obs-lab/shoplite/server.jsadd to filejs const { context, propagation, trace } = require("@opentelemetry/api"); const tracer = trace.getTracer("shoplite"); // producer — in /checkout const carrier = {}; propagation.inject(context.active(), carrier); // { traceparent: "00-…-…-01" } await db.query("insert into jobs (kind, payload, trace) values ('receipt', $1, $2)", [{ productId, qty }, carrier]); // consumer — the worker loop const job = rows[0]; const parent = propagation.extract(context.active(), job.trace ?? {}); await context.with(parent, () => tracer.startActiveSpan("send-receipt", async (span) => { span.setAttribute("queue.wait_ms", Date.now() - new Date(job.created_at).getTime()); try { await sendReceipt(job.payload); } finally { span.end(); } }), ); // db/init.sql: alter table jobs add column trace jsonb;
04 Make sure it never surprises you again
01Query queue wait across all orders
With the attribute on the span, TraceQL finds slow-queued receipts directly, each one linked to its full checkout trace. With span metrics on,
send-receiptalso gets its own latency histogram.TraceQL· Tempo{ name = "send-receipt" && span.queue.wait_ms > 60000 }
05 Your turn: write the query
Run each one against your lab before revealing the answer. Share your own version in the comments; there's usually more than one correct query.
After the fix, write a TraceQL query that returns checkout traces which include a send-receipt span.
The worker is changed to process 50 jobs in one batch span. How should context be handled now?
06 Interview questions from this case
How do you keep a trace connected across a message queue?
What's the difference between a parent-child relationship and a span link?