Tracing Kafka Producers and Consumers in Python
Kafka decouples producers from consumers in time, which is its purpose and the reason tracing across it needs care. Trace context has to travel inside each message, in its headers; the producer's span measures handing the message to a buffer rather than the broker accepting it; and a consumer may process the message seconds or hours later, possibly in a batch alongside messages from many different requests. This page covers each of those. It is a task article under tracing databases and message queues, part of the distributed tracing and OpenTelemetry in Python section.
Prerequisites
pip install "confluent-kafka>=2.3.0,<3.0.0" \
"opentelemetry-instrumentation-confluent-kafka>=0.48b0,<1.0.0" \
"opentelemetry-sdk>=1.27.0,<2.0.0"
Implementation
Step 1 — Instrument both clients. The instrumentation wraps producer and consumer instances. The producer wrapper creates a producer span for each send and injects the current context into the message headers; the consumer wrapper extracts context from each polled message and creates a consumer span. Both sides must be instrumented, and nothing between them may strip headers.
from confluent_kafka import Producer, Consumer
from opentelemetry.instrumentation.confluent_kafka import ConfluentKafkaInstrumentor
inst = ConfluentKafkaInstrumentor()
producer = inst.instrument_producer(Producer({"bootstrap.servers": BROKERS}))
consumer = inst.instrument_consumer(Consumer({
"bootstrap.servers": BROKERS,
"group.id": "fulfilment",
"enable.auto.commit": False,
}))
Step 2 — Record the delivery outcome from the callback. Producing is asynchronous: produce places the message in a local buffer and returns immediately, and the broker's acknowledgement arrives later through a delivery callback. The producer span therefore measures the enqueue — usually a fraction of a millisecond — and says nothing about whether the message was delivered. Recording the partition, offset or error from the callback, as an event or a log record carrying the same trace identifier, completes the picture.
from opentelemetry import trace
import logging
log = logging.getLogger("orders.events")
def publish_order_event(order):
span_ctx = trace.get_current_span().get_span_context()
def on_delivery(err, msg):
if err is not None:
log.error("event delivery failed", extra={
"trace_id": f"{span_ctx.trace_id:032x}", "kafka_error": str(err)})
else:
log.debug("event delivered", extra={
"trace_id": f"{span_ctx.trace_id:032x}",
"partition": msg.partition(), "offset": msg.offset()})
producer.produce("order-events", key=order.id, value=serialise(order),
on_delivery=on_delivery)
producer.poll(0) # serve delivery callbacks
Step 3 — Record the queue wait on the consumer span. The difference between the message's timestamp and the moment processing starts is how long the message waited in the topic. It is frequently the largest component of end-to-end latency and it is invisible in the consumer span's own duration. Adding it as an attribute makes it queryable, and it is the per-message counterpart of consumer group lag.
import time
def handle(msg):
span = trace.get_current_span() # the consumer span from the wrapper
ts_type, ts_ms = msg.timestamp()
if span.is_recording() and ts_ms > 0:
span.set_attribute("app.queue_wait_ms", int(time.time() * 1000) - ts_ms)
span.set_attribute("messaging.kafka.message.offset", msg.offset())
span.set_attribute("messaging.kafka.destination.partition", msg.partition())
fulfil(msg.value())
Step 4 — Choose how consumer spans relate to producers. With the default arrangement, the consumer span is a child of the producer span, so one trace covers request, publish and processing — and that trace's duration includes the queue wait. The alternative is a new root for the consumer with a link to the producer, which keeps each trace's duration meaningful. Either is valid; mixing them across a fleet is what causes confusion. Tracing databases and message queues discusses the trade.
Step 5 — Link batches to every producer. Consumers that poll and process many messages together cannot sensibly be the child of one producer. A span for the batch, with a link to each message's extracted context, relates the batch to every request that contributed to it, as described in adding span links for batch work.
from opentelemetry.propagate import extract
from opentelemetry.trace import Link, SpanKind
tracer = trace.get_tracer("fulfilment")
def process_batch(messages):
links = []
for m in messages[:64]:
headers = {k: v.decode() for k, v in (m.headers() or [])}
ctx = trace.get_current_span(extract(headers)).get_span_context()
if ctx.is_valid:
links.append(Link(ctx, {"messaging.kafka.message.offset": m.offset()}))
with tracer.start_as_current_span("process order-events batch",
kind=SpanKind.CONSUMER, links=links) as span:
span.set_attribute("messaging.batch.message_count", len(messages))
for m in messages:
fulfil(m.value())
Expected Output: a consumer span with its queue wait, related to the producer.
process order-events CONSUMER 204 ms app.queue_wait_ms=40112 partition=3 offset=88120
UPDATE shipments CLIENT 18 ms
Headers, bridges and the places context gets lost
Kafka stores headers with every message, so context survives however long a message waits. It does not survive everything between producer and consumer, and the common failure points are worth checking when consumer traces stop relating to producers.
Stream processors and bridges. A component that consumes from one topic and produces to another — a stream processing job, a connector, a mirroring tool — must copy headers from input to output. Many do not by default. The downstream consumer then sees messages with no trace context and starts fresh traces. Configuring the component to preserve headers, or injecting new context that links to the extracted one, restores the chain.
Serialisation frameworks that rebuild messages. Some producer wrappers construct a new message object from a schema and do not carry arbitrary headers through. Checking a message on the topic with a console consumer that prints headers confirms whether the traceparent is present.
Consumers that read headers wrongly. Header values are bytes, and a consumer that extracts from them without decoding — or a hand-written extraction that expects a different header name — silently fails to find context. The instrumentation handles this correctly; custom consumer loops are where it breaks.
Retries through dead-letter topics. A message moved to a retry or dead-letter topic by an error handler should keep its original headers, so the eventual reprocessing relates to the original request. Handlers that republish only the value lose it.
For each, the check is the same: read a message from the topic in question and look at its headers. A traceparent present on the input topic and absent on the output names the component responsible.
Commit, retry and the consumer's own failures
The consumer span describes processing one message. Two surrounding behaviours determine whether that span tells the whole story.
Offset commits. With auto-commit disabled, as above, the consumer commits offsets after processing. A crash between processing and commit means the message is processed again after restart, producing a second consumer span for the same message — possibly in a different trace if the consumer is a new root per message. Recording the offset as an attribute, as step 3 does, lets duplicates be recognised: two consumer spans with the same partition and offset are the same message processed twice. That is the evidence needed when a downstream effect happens twice and someone asks why.
Retries within the consumer. A consumer that retries a failing message in place produces either one long span covering all attempts or several spans, depending on where the span begins. Starting the span per attempt, with an attempt number attribute, makes a flapping dependency visible; starting it once around the retry loop makes the total delay visible. Either works if applied consistently, and the attempt count belongs on the span in both cases.
Poison messages. A message that fails every time blocks a partition if the consumer keeps retrying it, and the symptom is lag rising on one partition while others are healthy. The failing consumer spans carry the offset and the exception; alerting on repeated failure spans for the same offset catches a poison message far sooner than lag alone, which rises slowly.
Configuration options
| Concern | Setting | Why |
|---|---|---|
| Instrumentation | both producer and consumer | one-sided breaks the chain |
| Producer span | measures the enqueue | produce is asynchronous |
| Delivery outcome | from the delivery callback | the only confirmation |
| Queue wait | attribute on the consumer span | often the real latency |
| Consumer relationship | child or linked root, fleet-wide | predictable traces |
| Batches | one span, a link per message | many producers per batch |
| Headers | preserved through every bridge | context survives hops |
| Lag | consumer group metric | alerting on backlog |
Verification
Produce and consume one message locally and confirm the consumer span relates to the producer.
spans = exporter.get_finished_spans()
prod = next(s for s in spans if s.kind == SpanKind.PRODUCER)
cons = next(s for s in spans if s.kind == SpanKind.CONSUMER)
related = (cons.parent and cons.parent.span_id == prod.context.span_id) or \
any(l.context.span_id == prod.context.span_id for l in cons.links)
print("related:", related, "same trace:", cons.context.trace_id == prod.context.trace_id)
Expected Output: related, either as a child in the same trace or through a link.
related: True same trace: True
Common mistakes
Treating the producer span as delivery. Error signature: messages lost with no error on any span. Root cause: the send returns before the broker confirms. Remediation: record the delivery callback's outcome.
No queue wait attribute. Error signature: consumer traces that look fast for orders customers say were hours late. Root cause: waiting time is outside the consumer span. Remediation: record it from the message timestamp.
Headers dropped by a bridge. Error signature: consumers downstream of a stream processor never relate to producers. Root cause: the bridge republishes without headers. Remediation: preserve headers or inject linked context.
One producer as parent of a batch. Error signature: one request's trace stretched by an entire batch's processing. Root cause: parenting on the first message. Remediation: a batch span with links.
Never polling the producer. Error signature: delivery callbacks that never fire and a growing local buffer. Root cause: callbacks are served only by poll or flush. Remediation: poll regularly and flush on shutdown.
Frequently Asked Questions
How does trace context travel through Kafka?
In message headers. The producer instrumentation injects the traceparent and baggage into the headers of each message it sends; the consumer instrumentation extracts them. Kafka stores headers with the message, so context survives however long the message waits.
Why is the producer span so short?
Because producing is asynchronous. The send call places the message in the client's buffer and returns; the broker acknowledgement arrives later through a callback. The producer span measures the enqueue, and the delivery outcome must be recorded from the callback.
How do I see how long a message waited before being consumed?
Compare the message's timestamp with the time the consumer started processing it, and record the difference on the consumer span. That queue wait is often the latency a user actually experienced.
What about consumer lag?
Lag — how far behind the latest offset a consumer group is — is a property of the group over time and belongs in metrics. The queue wait on individual consumer spans is its per-message counterpart and explains specific slow traces.