Definition
- Correlation ID – a unique identifier generated at the edge and propagated through every service and log line for a request.
- Distributed tracing – a trace composed of spans (one per operation), linked by trace and span IDs, visualized as a waterfall in Jaeger or Zipkin.
- Centralized logging – shipping all services' structured logs to one searchable store like ELK so you query by correlation ID.
- Propagation – the W3C traceparent header carries trace context between services; Micrometer Tracing automates this in Spring.
- Async boundaries – trace context must be put in Kafka message headers so the trace is not broken at the queue.
Imagine a customer's loan application at National Bank of India passes through many departments: reception, verification, credit check, approval, and disbursement. If something goes wrong, you cannot figure out where unless every department stamps the same case number on the file and logs what they did. The correlation ID is that single case number. Distributed tracing is the stopwatch log showing how long the file spent at each desk. Centralized logging is the central records room where every department files a copy of its notes, all searchable by that one case number.
Understanding the Concepts
What is the problem distributed tracing solves?
In a monolith, a single stack trace tells the whole story. In microservices, one user action fans out across many independently running services, each with its own logs. No single log file contains the full picture.
Monolith: one stack trace
TransferService.transfer()
-> AccountRepository.debit()
-> AccountRepository.credit()
-> NotificationService.send()
Full story in one place.
Microservices: scattered across services
Gateway log: "transfer initiated"
Payment Service log: "debit request received"
Account Service log: "balance updated"
Notification Service log: "SMS sent"
How do you connect these? -> Correlation ID + Distributed Tracing
What is a correlation ID?
A correlation ID is a unique value generated once at the API gateway. It is propagated to every service via an HTTP header, and every service includes it in every log line.
package com.bank.tracing;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import java.util.UUID;
@Component
public class CorrelationIdFilter implements Filter {
private static final String HEADER = "X-Correlation-Id";
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws Exception {
HttpServletRequest httpReq = (HttpServletRequest) req;
String correlationId = httpReq.getHeader(HEADER);
if (correlationId == null) {
correlationId = UUID.randomUUID().toString();
}
MDC.put("correlationId", correlationId);
try {
chain.doFilter(req, res);
} finally {
MDC.remove("correlationId");
}
}
}
Expected log output (all services carry the same ID):
Gateway: correlationId=abc-123 Transfer initiated for SAV2001
Payment Svc: correlationId=abc-123 Debit request received
Account Svc: correlationId=abc-123 Balance updated for SAV2001
Notification: correlationId=abc-123 SMS sent to Rajesh
What is distributed tracing with traces and spans?
A trace represents the whole end-to-end request (trace ID). Each unit of work is a span (span ID) with a parent span ID, forming a tree. Spans carry timestamps for latency analysis.
package com.bank.tracing;
import io.micrometer.tracing.Tracer;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.logging.Logger;
@Service
public class TracedTransferService {
private static final Logger LOG = Logger.getLogger(TracedTransferService.class.getName());
private final Tracer tracer;
private final AccountClient accountClient;
public TracedTransferService(Tracer tracer, AccountClient accountClient) {
this.tracer = tracer;
this.accountClient = accountClient;
}
public void transfer(String fromId, String toId, BigDecimal amount) {
// Micrometer Tracing auto-creates spans and propagates context
var span = tracer.nextSpan().name("transfer-saga").start();
try (var scope = tracer.withSpan(span)) {
LOG.info("traceId=" + span.context().traceId() + " Transfer started");
accountClient.debit(fromId, amount); // child span auto-created
accountClient.credit(toId, amount); // child span auto-created
LOG.info("traceId=" + span.context().traceId() + " Transfer completed");
} finally {
span.end();
}
}
}
Zipkin waterfall for traceId=abc-123:
gateway 0ms ────────────────────────── 250ms
payment-service 5ms ─────────────────── 200ms
transfer-saga 10ms ────────────── 190ms
account-debit 15ms ──── 60ms
account-credit 80ms ──── 50ms
notification 210ms ── 240ms
The slow hop is immediately visible.
How is context propagated between services?
Context is propagated via the W3C traceparent header. Micrometer Tracing does this automatically for REST calls and Kafka messages.
W3C Trace Context header:
traceparent: 00-abc123def456-span789-01
│ │ │ │
│ traceId spanId sampling
version
When Payment Service calls Account Service:
HTTP header: traceparent: 00-abc123-paymentSpan-01
Account Service reads it and creates a child span.
Same traceId, new spanId, parent=paymentSpan.
How does centralized logging work?
Each service writes structured JSON logs including the trace ID. A shipper forwards them to Elasticsearch, queried through Kibana.
{
"timestamp": "2024-01-15T10:30:00.000Z",
"level": "INFO",
"service": "payment-service",
"traceId": "abc123",
"spanId": "span456",
"message": "Debit of 50000.00 from SAV2001 completed",
"accountId": "SAV2001",
"amount": 50000.00
}
ELK Stack flow:
Service logs (JSON) -> Filebeat/Fluentd -> Elasticsearch -> Kibana
Search: traceId:abc123 -> all log lines from all services for that request.
How do you propagate context across async boundaries?
When a service publishes to Kafka, it must put the trace context in message headers. Otherwise the trace breaks at the queue.
package com.bank.tracing;
import io.micrometer.tracing.Tracer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.header.Headers;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;
@Service
public class TracedEventPublisher {
private final KafkaTemplate<String, String> kafka;
private final Tracer tracer;
public TracedEventPublisher(KafkaTemplate<String, String> kafka, Tracer tracer) {
this.kafka = kafka;
this.tracer = tracer;
}
public void publishTransferEvent(String paymentId, String eventJson) {
ProducerRecord<String, String> record =
new ProducerRecord<>("payment-events", paymentId, eventJson);
// Inject trace context into Kafka message headers
Headers headers = record.headers();
var span = tracer.currentSpan();
if (span != null) {
headers.add("traceparent",
span.context().traceId().getBytes());
}
kafka.send(record);
}
}
Without header propagation:
Trace breaks at Kafka. Consumer starts a NEW trace.
The two traces are unlinked.
With header propagation:
Consumer reads traceparent from message header.
Continues the SAME trace. Full end-to-end visibility.
What is sampling and why is it needed?
In high-volume systems, tracing every request is too expensive. Sampling traces a fraction, often with tail-based sampling to keep traces that errored or were slow.
# application.yml - sampling configuration
management:
tracing:
sampling:
probability: 0.1 # Sample 10% of requests
Head-based sampling: decide at the start (random 10%)
Pro: simple. Con: might miss the one slow request.
Tail-based sampling: collect all spans, keep only interesting ones
Pro: keeps errors and slow traces. Con: more infrastructure.
What should a senior engineer know?
Logs, traces, and metrics map to the three pillars of observability. Traces show the path and timing. Logs show detailed events. Metrics show aggregate health. All are correlated by the same IDs. Standardize on W3C Trace Context and OpenTelemetry so every service speaks the same language.
Visual Overview
Distributed Tracing Flow:
Client -> API Gateway (generate traceId=abc123)
│
│ traceparent: 00-abc123-gw001-01
▼
Payment Service (span: pay001, parent: gw001)
│
┌────────┴────────┐
│ │
▼ ▼
Account Svc Kafka Message
(span: acc001) (traceparent in header)
parent: pay001 │
▼
Notification Svc
(span: notif001)
parent: pay001
All spans share traceId=abc123
Zipkin renders the full waterfall.
Key Takeaway
Tracing a request across microservices is about following one logical operation end to end, built on three pillars: correlation IDs, distributed tracing, and centralized logging. The correlation ID is generated once at the API gateway and propagated to every service, so every log line across the system carries the same ID. Distributed tracing adds structure with trace IDs and span IDs forming a tree with timestamps, so you see the latency contribution of each hop in a Zipkin or Jaeger waterfall.
Centralized logging ships all structured JSON logs to one searchable store like Elasticsearch. At National Bank of India, when Rajesh's transfer is slow, Pankaj pastes the correlation ID into Kibana and instantly sees every log line from every service in order. The Jaeger waterfall shows the fraud-scoring span as the clear bottleneck.
The critical detail is that context propagation must cross async boundaries. When a service publishes to Kafka, it puts the trace context in message headers so the consumer continues the same trace. Without this, the trace breaks at the queue. Sampling controls cost in high-volume systems, and tail-based sampling keeps the traces that errored or were slow.
Remember one sentence: stamp every request with a correlation ID at the edge, propagate trace and span IDs across every service including async hops, and ship all logs to one place searchable by that ID.
---