Command Palette

Search for a command to run...

Hectal
Case 3.3·Tracing Deep DiveSEV3

“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

asynchronous context propagationpropagation.inject / extractmanual spansspan links

00 It starts

Reproduce this incident in your lab, then work the case alongside the timeline below. Try each query yourself before reading its result.

terminal
$ # add the jobs table and the worker below (see the investigation), then
docker compose up -d --build shoplite
── lab output ──
✔ Container shoplite Started

01 The investigation

  1. 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 with for 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());
  2. 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 setInterval callback runs outside any request, so there's no active context to inherit.

    TraceQL· Tempo
    { resource.service.name = "shoplite" && name =~ "pg.query:DELETE.*" }
    result
    Trace 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)
  3. 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.

  4. 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

  1. 01Inject into the job, extract in the worker

    Store the carrier in a trace jsonb 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

  1. 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-receipt also 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.

01

After the fix, write a TraceQL query that returns checkout traces which include a send-receipt span.

02

The worker is changed to process 50 jobs in one batch span. How should context be handled now?

06 Interview questions from this case

01

How do you keep a trace connected across a message queue?

02

What's the difference between a parent-child relationship and a span link?

0/4 · 0%