Where the chain breaks: queues, pools, batches — context injection and span links across queues

A queue hop has no request to attach a header to, so the context must be written into the message itself — and because one consumer batch can have many causes while a span has exactly one parent, the consumer records a link to each cause instead.

Previously

Copying a header works while one service calls another over the wire. checkout does not call orders-worker — it publishes a message and returns.

Scene 04

Where the chain breaks: queues, pools, batches

  1. Watch
  2. Try it
  3. Predict
  4. Capture
the queue hop: no outbound request to hang a header onTIME TO CULPRIT31 minexampletarget ≤ 5 mintraceparent: 4bf92f35…-00f0-01Qno request, no headergatewaycheckoutorders-workerown traceQUEUE✕ queue hopnothing written into the message1 message waitingcarries contextnoneorder-8471batch size1 per pollworker runs+200ms latertrace 4bf92f35…0e0e4736ends at published — nothing after the queue01.1s2.1s3.2s4.3sPOST /checkout3.0spublish shop.orderspublishedsecond tracetrace 9c2b71e0…5ad31f08a second trace nobody asked for — no parent, no caller01.1s2.1s3.2s4.3sWHAT THE CONSUMER SPAN HANGS OFFthe request that caused it✕⧗ 200ms after the customer leftorders-worker · consume1 message in this batchorphan — no parent, no linkstraceparent, nowhere on this hop: —Two traces, no error, no warning. Neither half knows the other exists.
What to watch for

What happens to the chain when a service never calls the next one at all? checkout does not make a request to orders-worker — it drops a message on a queue and returns to the customer. (How a queue stores and hands out messages is the build-kafka curriculum's job; here it is simply a barrel that holds messages.) There is no outbound request, so there is nothing to put a header on. Watch what the two halves of this checkout end up looking like.

Continue unlocks when the animation finishes.
Implementation

Highlighted lines are the ones running in the diagram right now.

Producer.publish
checkout writes the context into the order it sends
def publish(destination, order):
span = tracer.start_span(
f"publish {destination}", kind = PRODUCER,
)
msg = Message(body = order)
with use_context(span):
propagator.inject(msg.headers) # into the message
broker.send(destination, msg)
span.end()
return # checkout has answered the customer
Consumer.poll_loop
the worker takes a batch and reads each message's context
def poll_loop():
loop forever:
msgs = broker.poll(max_messages)
origins = []
for m in msgs:
# extracted at process time, not at publish time
ctx = propagator.extract(m.headers)
if ctx.is_valid():
origins.append(ctx)
on_batch(msgs, origins)
Consumer.on_batch
building the one span that covers the whole batch
def on_batch(msgs, origins):
parent = current_context()
if len(msgs) == 1 and origins:
# MAY — single message only; a link also works
parent = origins[0]
span = tracer.start_span(
f"process {destination}", kind = CONSUMER,
parent = parent, # exactly one
# span_limits.max_links defaults to 128
links = [Link(c) for c in origins],
)
with use_context(span): handle_all(msgs)

Where this sits in Build a distributed tracing system (Jaeger / Zipkin style)

Scene 04 of 17, in the Carry it act — The header, the queue that drops it, what rides along.. A queue hop has no request to attach a header to, so the context must ride inside the message. And because one batch can have many causes while a span has exactly one parent, the consumer records a link to each.

Up next. The context now reaches every hop, including the one across the queue, so every hop produces a span in the right trace. What else is worth writing onto that span?

All 17 scenes in Build a distributed tracing system (Jaeger / Zipkin style) · Every curriculum

Built with Arqly
Every scene in Build a distributed tracing system (Jaeger / Zipkin style) builds on the one before it.All 17 Build a distributed tracing system (Jaeger / Zipkin style) scenes