I was on call when a “poison‑pill” order‐event blasted through our Kafka topic, stalled the consumer thread, and left the whole order‑processing pipeline dead for ten minutes. The alert sounded, the ops dashboard was red, and the root cause was a single bad Avro payload that never left the inbound queue. The kicker? The trace UI showed a clean end‑to‑end flow—no hint that the message had been corrupted. What went wrong was that we weren’t propagating the OpenTelemetry trace context into the Kafka headers, so the “bad” span was detached from the rest of the request chain. The fix was surprisingly cheap: inject W3C TraceContext into every outgoing record and extract it on the consumer side. Once we did that, the offending message lit up as a red node in Jaeger, and we could immediately route it to a dead‑letter topic without guessing.
- Kafka + OpenTelemetry gives you true end‑to‑end visibility across async boundaries.
- Inject W3C TraceContext into Kafka headers on the producer; extract it on every consumer.
- Use the OpenTelemetry Collector to sample, enrich, and ship traces to Jaeger, Tempo, or Zipkin.
- Handle poisoned messages with DLQs that preserve trace IDs for rapid root‑cause analysis.
- Sample intelligently—head‑based 10 % sampling adds < 3 % latency even at 200 kmsg/s.
Before you start: Apache Kafka 3.6+, OpenTelemetry 1.0+ SDKs, Spring Boot 3.x (or Go 1.24 with Sarama), Confluent Schema Registry, OTel Collector, Jaeger 1.63+, Grafana Tempo 2.5+, Kubernetes 1.31 with Strimzi, and a basic grasp of W3C TraceContext.
Build an event‑driven microservices mesh with Kafka and OpenTelemetry
An event‑driven microservices mesh with Kafka and OpenTelemetry involves instrumenting services to propagate W3C TraceContext in Kafka message headers. This enables end‑to‑end distributed tracing across producers, consumers, and processors. Configure OpenTelemetry Collector to ingest traces and export them to backends like Jaeger for full observability of your asynchronous event flows.
Foundations: Event‑Driven Architecture & Traces
Why Kafka and Microservices Mesh Pair Well
Kafka is the de‑facto backbone for high‑throughput, durable event streams. Its log‑structured storage, partitioned scalability, and exactly‑once semantics (when using idempotent producers) make it a natural glue for micro‑services that don’t call each other directly. In a mesh, each service merely publishes or subscribes—no hard‑coded URLs, no synchronous retries. This decoupling reduces cascade failures, but it also shatters the traditional request‑response trace. Without extra effort, you lose the ability to see why a downstream failure happened.
The Critical Role of OpenTelemetry for Observability
OpenTelemetry (OTel) is the vendor‑neutral telemetry umbrella that standardizes traces, metrics, and logs. Its W3C TraceContext format is tiny (≈ 55 bytes) and fits comfortably into a Kafka record’s header map. By stitching spans together across producer → broker → consumer, you restore the causal chain that would otherwise be invisible. The spec reached 1.0 stability in 2024, so every major language SDK now offers first‑class support.
My take: Most teams treat tracing as an after‑thought, sprinkling log statements instead. In my experience, that habit costs you 2–3 hours of debugging per incident. The moment you make trace propagation a first‑class citizen, the debugging time shrinks dramatically.
Step 1: Setting Up Your Kafka Infrastructure
Choosing a Kafka Distribution & Configuration
You can run Apache Kafka yourself, use Confluent Platform, or spin up Strimzi on Kubernetes. For production‑grade reliability, I prefer Strimzi because it gives you the Kubernetes native experience—auto‑reconciliation, rolling upgrades, and integrated Kafka Connect. A minimal Kafka CR might look like:
# version: v1beta2 (Strimzi 0.39)
apiVersion: kafka.strimzi.io/v1beta2
kind: Kafka
metadata:
name: event-mesh
spec:
kafka:
version: 3.6.0
replicas: 3
listeners:
- name: plain
port: 9092
type: internal
tls: false
config:
# Turn on idempotent producer support
transaction.state.log.replication.factor: 3
transaction.state.log.min.isr: 2
# Reduce latency for low‑batch workloads
linger.ms: 5
Keep the replication factor ≥ 3 for fault tolerance, and set min.insync.replicas to 2 to avoid data loss during a broker outage.
Essential Topic Naming and Schema Registry Strategy
A clean naming convention prevents accidental cross‑writes:
| Prefix | Meaning |
|---|---|
cmd- | Commands (intent) |
evt- | Events (state changes) |
qry- | Queries (read‑only) |
dlq- | Dead‑letter queues |
All messages should go through the Confluent Schema Registry to enforce Avro compatibility. Register a TraceContext schema that stores traceparent and tracestate as strings; this lets you validate that the headers are present before processing.
{
"type": "record",
"name": "TraceContext",
"fields": [
{"name": "traceparent", "type": "string"},
{"name": "tracestate", "type": ["null","string"], "default": null}
]
}
Step 2: Instrumenting Services with OpenTelemetry
Adding OTel SDKs & Auto‑Instrumentation
In Spring Boot 3.x, add the starter:
<!-- pom.xml -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-spring-boot-starter</artifactId>
<version>1.33.0</version>
</dependency>
Spring auto‑detects @RestController, @KafkaListener, and WebClient calls, creating spans automatically. For Go services using Sarama, import the OTel instrumented wrapper:
// go.mod
require go.opentelemetry.io/otel v1.14.0
require github.com/Shopify/sarama v1.40.0
// producer.go
package main
import (
"context"
"log"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
"github.com/Shopify/sarama"
)
func main() {
cfg := sarama.NewConfig()
cfg.Version = sarama.V3_6_0_0
cfg.Producer.Return.Successes = true
// Enable idempotence
cfg.Producer.Idempotent = true
prod, err := sarama.NewSyncProducer([]string{"kafka:9092"}, cfg)
if err != nil {
log.Fatalf("producer init: %v", err)
}
defer prod.Close()
tracer := otel.Tracer("order-service")
ctx, span := tracer.Start(context.Background(), "publish-order")
defer span.End()
// Build the record with trace headers
msg := &sarama.ProducerMessage{
Topic: "evt-order-created",
Value: sarama.StringEncoder(`{ "orderId": "12345" }`),
Headers: []sarama.RecordHeader{
{
Key: []byte("traceparent"),
Value: []byte(trace.SpanContextFromContext(ctx).TraceID().String()),
},
},
}
_, _, err = prod.SendMessage(msg)
if err != nil {
log.Fatalf("send failed: %v", err)
}
}
Creating W3C Trace Context Propagation Across Services
The traceparent header follows the format 00-{trace-id}-{parent-id}-01. On the consumer side, extract it and start a child span:
@KafkaListener(topics = "evt-order-created", groupId = "order-processor")
public void handle(EventMessage msg, @Header(KafkaHeaders.RECEIVED_MESSAGE_KEY) String key,
@Header("traceparent") String traceparent) {
Context parentCtx = W3CTraceContextPropagator.getInstance()
.extract(Context.current(),
Collections.singletonMap("traceparent", traceparent),
Map::get);
Span span = tracer.spanBuilder("process-order")
.setParent(parentCtx)
.startSpan();
try (Scope ignored = span.makeCurrent()) {
// business logic …
} finally {
span.end();
}
}
Tip: If you’re using batch consumption, see the FAQ below for a pattern that avoids exploding the number of parent spans.
Internal link: For deeper sampling tweaks, see our advanced OTel sampling strategies guide.
Step 3: Bridging Kafka Events with Distributed Traces
Injecting & Extracting Trace Context in Kafka Headers
Kafka’s Headers API (since 3.6) supports arbitrary key‑value pairs. The OpenTelemetry Java SDK provides a KafkaPropagation helper:
Tracer tracer = GlobalOpenTelemetry.getTracer("order-service");
ProducerRecord<String, String> record = new ProducerRecord<>("evt-order-created", "12345", payload);
TextMapSetter<ProducerRecord<String, String>> setter = (r, key, value) -> r.headers().add(key, value.getBytes(StandardCharsets.UTF_8));
tracer.propagate().inject(Context.current(), record, setter);
On the consumer side, use the matching TextMapGetter to recover the context.
Correlating Producer, Consumer, and Processor Spans
Once the parent context is attached, the trace graph looks like this:
graph LR
P[Producer Span] --> B[Kafka Broker]
B --> C[Consumer Span]
C --> D[Processor Span]
Because each span shares the same trace-id, tools like Jaeger render a single tree rather than three islands. The parent‑child relationship is preserved via the parent-id field in traceparent.
Step 4: Building the Observability Mesh
Routing Traces to Jaeger/Tempo/Zipkin
Deploy an OpenTelemetry Collector as a sidecar or daemonset. A typical pipeline config:
# collector-config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
tail_sampling:
policies:
- name: high_rate
type: always_on
rate_limit: 1000
exporters:
jaeger:
endpoint: jaeger-collector:14250
tls:
insecure: true
logging:
loglevel: debug
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch, tail_sampling]
exporters: [jaeger, logging]
The tail_sampling processor lets you keep a high‑rate of error traces while throttling the happy path.
Configuring Logs & Metrics for a Unified View
OpenTelemetry’s Logs Bridge can forward structured logs (JSON) to Loki or Elasticsearch; combine them with trace IDs for a true trace‑centric log view. In Spring Boot you can enable the bridge via:
management.tracing.sampling.probability=0.1
logging.pattern.level=%X{trace_id:-} %p
This ensures every log line carries the current trace_id, letting you search logs by trace in Grafana Loki.
Production Considerations & Architectural Trade‑offs
Evaluating Delivery Semantics & Idempotency
| Semantics | Kafka Setting | Trace Fidelity | Idempotency Requirement |
|---|---|---|---|
| At‑least‑once | acks=all + enable retries | Every retry gets a fresh span (duplicate trace) | Must de‑duplicate in business logic |
| Exactly‑once | Transactional producer (transactional.id) | One trace per logical transaction | Guarantees exactly‑once write, but higher latency |
I’ve found transactional producers to be a good compromise for order‑critical flows (e.g., payment). The extra round‑trip adds ~2‑3 ms per record, which is negligible compared to downstream processing time.
Scaling, Performance Costs, and Fault Tolerance
- Sampling impact: Head‑based 10 % sampling adds ~1 ms latency per request. Tail‑based sampling (on the collector) adds almost nothing because the data is already in‑flight.
- Header size: Adding two 55‑byte headers (
traceparent,tracestate) inflates each Kafka record by ~110 bytes. On a 1 GB/s topic that’s < 1 % overhead—acceptable, but keep an eye on max.message.bytes (default 1 MiB) if you embed large custom attributes. - Multi‑tenant groups: If several teams share a consumer group, each must respect the incoming trace ID. Otherwise, one team’s trace can leak into another’s UI, confusing root‑cause analysis. Use separate consumer groups per domain to avoid this.
Production Gotchas
| Symptom | Root Cause | Fix |
|---|---|---|
| Trace IDs missing in Jaeger | Producer omitted header because of custom KafkaTemplate bypassing OTel | Wrap all producer calls with otel.propagate().inject or use Spring’s KafkaTemplate auto‑instrumented bean |
| Consumer crashes on malformed header | Header value not UTF‑8 or exceeds 256 bytes (Kafka limit) | Validate header length and fallback to a new span if extraction fails |
| DLQ messages have no trace ID | Dead‑letter process re‑creates a ProducerRecord without copying headers | Ensure you clone the original record’s headers before re‑publishing to dlq-… |
Common Errors & Fixes
Error: org.apache.kafka.common.errors.RecordTooLargeException
Why it happens: Adding TraceContext headers pushes the record size over max.request.size.
Fix: Increase the broker config:
config:
max.request.size: 2097152 # 2 MiB
message.max.bytes: 2097152
Also, prune any unused custom headers.
Error: invalid traceparent header format
Why it happens: Some old producers still use the deprecated ot-tracer-span-id header.
Fix: Normalize in a HeaderAdapter that checks both keys and rewrites to traceparent before extraction.
if (headers.lastHeader("ot-tracer-span-id") != null) {
// translate old format to traceparent
}
Error: Consumer thread stuck on poll() after a poison pill
Why it happens: The consumer never acknowledges the offset because the processing exception is swallowed.
Fix: Use Spring’s ErrorHandler to route the failing record to a dead‑letter topic while preserving headers:
@Bean
public DefaultErrorHandler errorHandler(KafkaTemplate<String, String> template) {
return new DefaultErrorHandler((record, exception) -> {
template.send("dlq-" + record.topic(),
null,
record.key(),
record.value(),
record.headers());
}, new FixedBackOff(0L, 0));
}
The preserved traceparent means the DLQ entry appears as a child of the original trace.
Error: “Missing instrumentation for batch consumer”
Why it happens: When consuming 100 messages per poll(), each message spawns its own child span, flooding the collector.
Fix: Create a parent span per batch and then start lightweight child spans for each record.
Span batchSpan = tracer.spanBuilder("process-order-batch")
.setParent(Context.current())
.startSpan();
try (Scope ignored = batchSpan.makeCurrent()) {
for (ConsumerRecord<String, String> rec : records) {
// child span per record
Span child = tracer.spanBuilder("process-order")
.setParent(Context.current())
.startSpan();
// …process…
child.end();
}
} finally {
batchSpan.end();
}
Real‑World Example & Code Walkthrough
A Complete Order Processing Flow with Common Pitfalls
- Order Service (producer) – receives an HTTP POST, validates, emits
cmd-create-order. - Payment Service (consumer → producer) – consumes
cmd-create-order, calls external payment gateway, emitsevt-payment-completedorevt-payment-failed. - Inventory Service (consumer) – consumes payment events, reserves stock, publishes
evt-order-fulfilled. - Shipping Service (consumer) – consumes fulfillment, creates shipment, writes to
dlq‑shipping-failureon errors.
All services share the same trace ID. The trace graph contains a branching pattern where each microservice adds a child span. If the payment gateway times out, the corresponding span includes an error status and the downstream services still receive the payment-failed event, preserving the causal chain.
Minimal Spring Boot Producer
@RestController
@RequestMapping("/orders")
@RequiredArgsConstructor
public class OrderController {
private final KafkaTemplate<String, String> kafka;
private final Tracer tracer;
@PostMapping
public ResponseEntity<Void> create(@RequestBody OrderDto dto) {
Span span = tracer.spanBuilder("create-order")
.setSpanKind(SpanKind.SERVER)
.startSpan();
try (Scope ignored = span.makeCurrent()) {
String payload = new ObjectMapper().writeValueAsString(dto);
kafka.executeInTransaction(ops -> {
ops.send(MessageBuilder.withPayload(payload)
.setHeader(KafkaHeaders.TOPIC, "cmd-create-order")
.setHeader("traceparent", extractTraceParent(Context.current()))
.build());
});
return ResponseEntity.accepted().build();
} catch (Exception e) {
span.recordException(e);
span.setStatus(StatusCode.ERROR);
throw e;
} finally {
span.end();
}
}
private String extractTraceParent(Context ctx) {
// Extract the W3C traceparent string
return W3CTraceContextPropagator.getInstance()
.inject(ctx, new StringBuilder(), (c, k, v) -> c.append(v));
}
}
Go Consumer with DLQ Handling
// consumer.go
package main
import (
"context"
"log"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
"github.com/Shopify/sarama"
)
type handler struct {
producer sarama.SyncProducer
tracer otel.Tracer
}
func (h *handler) Setup(_ sarama.ConsumerGroupSession) error { return nil }
func (h *handler) Cleanup(_ sarama.ConsumerGroupSession) error { return nil }
func (h *handler) ConsumeClaim(sess sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error {
for msg := range claim.Messages() {
ctx := extractTraceContext(msg)
spanCtx, span := h.tracer.Start(ctx, "process-payment", otel.WithSpanKind(oteltrace.SpanKindConsumer))
func() {
defer span.End()
// Simulated processing
if err := process(msg.Value); err != nil {
// send to DLQ preserving trace headers
dlqMsg := &sarama.ProducerMessage{
Topic: "dlq-payment",
Value: sarama.ByteEncoder(msg.Value),
Headers: msg.Headers,
}
_, _, err2 := h.producer.SendMessage(dlqMsg)
if err2 != nil {
log.Printf("failed DLQ send: %v", err2)
}
span.RecordError(err)
span.SetStatus(oteltrace.StatusCodeError, err.Error())
// commit offset to avoid reprocessing
sess.MarkMessage(msg, "")
return
}
// success path
sess.MarkMessage(msg, "")
}()
}
return nil
}
func extractTraceContext(msg *sarama.ConsumerMessage) context.Context {
carrier := propagation.MapCarrier{}
for _, hdr := range msg.Headers {
carrier[string(hdr.Key)] = string(hdr.Value)
}
return otel.GetTextMapPropagator().Extract(context.Background(), carrier)
}
Testing & Validating End‑to‑End Traces Locally
- Spin up a local Kafka cluster with docker‑compose (include schema registry).
- Run the OTel Collector in
docker run otel/opentelemetry-collector:0.103.0with the config above. - Fire a
curl -X POST localhost:8080/orders -d '{"orderId":"123"}'. - Open Jaeger UI (
http://localhost:16686) and search fororderId=123. You should see a tree with Producer → Payment → Inventory → Shipping spans, each bearing the same trace ID.
If a span is missing, inspect the message headers with kafka-console-consumer --property print.headers=true. The absence of traceparent indicates a missing injection point.
Common Errors & Fixes (continued)
Error: “Trace ID truncation when using Avro schema”
Why it happens: Avro strings are encoded with a 2‑byte length prefix, and the default schema limited the field to 100 characters, cutting off the 55‑byte traceparent.
Fix: Set the Avro schema field maxLength to at least 200 or use a bytes type instead of string for the trace fields.
Error: “Consumer group lag spikes after enabling tracing”
Why it happens: The collector’s batch processor introduced a flush interval of 5 seconds, causing back‑pressure.
Fix: Reduce batch.timeout to 100ms and enable remote‑write directly to Jaeger via gRPC to bypass the collector buffer for low‑latency pipelines.
Frequently asked questions
How do you handle trace context propagation with Kafka batch consumers?
Inject the parent trace ID into the Kafka message headers during production. In the batch consumer, extract and create a single parent span that links to individual processing spans for each message in the batch, maintaining the causal relationship.
What is the performance overhead of enabling OpenTelemetry tracing in a high‑throughput Kafka pipeline?
With head‑based sampling (e.g., sampling only 10% of traces) configured in the OTel Collector, overhead is typically 1‑3% latency. Avoid recording every single event; sample intelligently based on traffic and error rates.
Can I use the same trace ID across multiple partitions?
Yes. The trace ID lives in the message header, not in the Kafka key. As long as you copy the header when you produce to any partition, the trace stays intact.
—
If you’ve run into a quirky edge case or have an alternative way to ship trace IDs, drop a comment below. I love hearing how other teams stitch together their event‑driven meshes. Happy tracing!