Spring Boot
Distributed Logging in Spring Boot: Trace IDs and Micrometer
Distributed logging in Spring Boot: trace IDs, Micrometer Tracing, OpenTelemetry, correlation IDs, Kafka and async propagation, with config for Boot 3 and 4.
Distributed Logging in 30 Seconds
Distributed logging means every log line from every microservice carries the same trace ID for the same user request, so you can search one ID and see the whole story across all services in order. In Spring Boot this is done by Micrometer Tracing (the tracing facade, which replaces Spring Cloud Sleuth) together with a tracer implementation (OpenTelemetry or Brave).
How it works:
1. The first service that receives a request creates a trace ID (or continues the one sent by the caller in the traceparent header).
2. Each unit of work gets a span ID. The trace ID and span ID are put into the SLF4J MDC, so the logging pattern prints them on every line.
3. Instrumented HTTP clients, Feign, Kafka and async executors forward the context to the next service or thread.
4. Logs are shipped to a central store (Loki, Elasticsearch, a cloud service) and spans to a trace backend (Tempo, Jaeger, Zipkin, a vendor). You search by trace ID.
What you need: spring-boot-starter-actuator, a tracing bridge or starter, and a log pattern or structured format that includes the IDs. In current Spring Boot versions the IDs are already included in console logs by default once tracing is on the classpath.
Two facts that avoid most confusion: the trace ID is the same for the whole request, while the span ID changes for each operation inside it; and the trace ID is generated and logged even when the request is not sampled, because sampling only decides whether spans are exported to the backend.
Trace Context and Propagation
Every request entering the system is assigned a trace ID, a unique identifier that follows the request across every service it touches. Each service creates one or more spans (units of work within the trace), each with its own span ID and a reference to its parent span. The trace ID and span ID are placed in the MDC so every log line carries them automatically, and they are propagated to downstream services through HTTP headers (W3C Trace Context or B3).
Trace IDs are 128-bit (32 hexadecimal characters) and span IDs are 64-bit (16 hex characters). Older Sleuth setups sometimes used 64-bit trace IDs, so if you search logs from mixed old and new services, remember that the same trace may appear with a 16-character or a 32-character ID.
The propagation format is controlled with management.tracing.propagation.type (default w3c). All services in a call chain must understand each other's format. If you must mix formats during a migration, configure the services to produce and consume both, rather than letting traces silently break at the boundary.
Java
// ── How trace context flows through the system: ──────────────────────
//
// Client → API Gateway → OrderService → UserService
// → PaymentService → StripeAPI
//
// traceId: 4bf92f3577b34da6a3ce929d0e0e4736 (same for the entire request)
//
// API Gateway spanId: a2fb4a1d1a96d312 parentSpanId: null
// OrderService spanId: 00f067aa0ba902b7 parentSpanId: a2fb4a1d1a96d312
// UserService spanId: b9a37ca3f3a21c7d parentSpanId: 00f067aa0ba902b7
// PaymentService spanId: c4e32f11d8b3901a parentSpanId: 00f067aa0ba902b7
//
// Every log line in every service carries the same traceId.
// In a log aggregation tool (Kibana, Grafana Loki):
// filter by traceId = "4bf92f3577b34da6a3ce929d0e0e4736"
// → see all logs from all services for this one request,
// in chronological order.
// ── HTTP propagation headers: ─────────────────────────────────────────
//
// W3C Trace Context (standard — recommended, the Spring Boot default):
// traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
// ↑ ↑ ↑ ↑
// ver traceId (32 hex) spanId (16 hex) flags (01 = sampled)
// tracestate: vendor-specific state (optional)
// baggage: key=value pairs propagated with the request (optional)
//
// B3 (Zipkin legacy — still common):
// X-B3-TraceId: 4bf92f3577b34da6a3ce929d0e0e4736
// X-B3-SpanId: 00f067aa0ba902b7
// X-B3-ParentSpanId: a2fb4a1d1a96d312
// X-B3-Sampled: 1
//
// Select the format in application.yml:
// management.tracing.propagation.type: w3c # or b3, b3_multiMicrometer Tracing Setup (Spring Boot 3.x and 4.x)
Spring Cloud Sleuth does not support Spring Boot 3 and later; Micrometer Tracing replaces it. Micrometer Tracing is the facade (the API your code calls); a bridge connects it to a concrete tracer, either OpenTelemetry or Brave. Trace and span IDs are placed in the SLF4J MDC automatically, so existing log statements carry the trace context without code changes. Remove old spring-cloud-starter-sleuth dependencies and spring.sleuth.* properties when you migrate; they are ignored.
All setups need spring-boot-starter-actuator. Beyond that, the dependencies depend on your Spring Boot generation, because Spring Boot 4 introduced dedicated starters:
- Spring Boot 4.x with OpenTelemetry and OTLP (Tempo, Jaeger, most vendors): add spring-boot-starter-opentelemetry. It brings the OpenTelemetry API, the Micrometer OTLP metrics registry and the Micrometer tracing bridge for OpenTelemetry.
- Spring Boot 4.x with Zipkin: add spring-boot-starter-zipkin, which uses the Brave tracer. OpenTelemetry's own Zipkin exporter is deprecated, and Spring Boot's auto-configuration for it is scheduled for removal in 4.2, so prefer Brave for Zipkin or send OTLP to a Zipkin version that ingests OTLP.
- Spring Boot 3.x: add the bridge and an exporter yourself, as in the second block below.
Always check the tracing page of the Spring Boot reference documentation for your exact version, because starter and property names have changed between generations.
XML
<!-- ═══ Spring Boot 4.x ═══════════════════════════════════════════════ -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- OpenTelemetry + OTLP (Tempo, Jaeger, vendors): -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-opentelemetry</artifactId>
</dependency>
<!-- OR Brave + Zipkin instead of the OpenTelemetry starter: -->
<!--
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-zipkin</artifactId>
</dependency>
-->
</dependencies>
<!-- ═══ Spring Boot 3.x ═══════════════════════════════════════════════ -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Bridge: Micrometer Tracing → OpenTelemetry: -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<!-- Choose ONE exporter. OTLP (Tempo, Jaeger, vendors): -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
<!-- OR Zipkin through OpenTelemetry (deprecated by OpenTelemetry): -->
<!--
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-zipkin</artifactId>
</dependency>
-->
<!-- OR Brave bridge, for Zipkin-only setups: -->
<!--
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<dependency>
<groupId>io.zipkin.reporter2</groupId>
<artifactId>zipkin-reporter-brave</artifactId>
</dependency>
-->
<!-- Feign: propagates trace headers in OpenFeign calls: -->
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-micrometer</artifactId>
</dependency>
</dependencies>Tracing Configuration
Sampling controls what percentage of requests have their spans exported to the tracing backend. Spring Boot samples 10% by default (probability 0.1). In development and staging, set 1.0 so every request is traced; in high-traffic production, keep a low value such as 0.01 to 0.1, or use tail sampling in an OpenTelemetry Collector. Trace context is always generated and propagated, and trace IDs still appear in logs for requests that are not sampled; sampling only decides whether the spans are exported.
The export endpoint property names depend on the Spring Boot generation. In Spring Boot 3.x, OTLP uses management.otlp.tracing.endpoint and Zipkin uses management.zipkin.tracing.endpoint. In Spring Boot 4.x, OTLP uses management.opentelemetry.tracing.export.otlp.* and Zipkin (Brave) uses management.tracing.export.zipkin.*. Mind the OTLP port: 4318 is the HTTP endpoint (path /v1/traces) and 4317 is gRPC.
Name your service with spring.application.name; it appears as the service name in the trace UI and in the log prefix.
yaml
# application.yml
spring:
application:
name: order-service # service name in the trace UI and in logs
management:
tracing:
sampling:
probability: 1.0 # 1.0 = 100% (dev/staging); default is 0.1 (10%)
propagation:
type: w3c # w3c (default), b3, b3_multi
# ── Spring Boot 3.x: OTLP exporter (Tempo, Jaeger, vendors): ─────────
otlp:
tracing:
endpoint: http://tempo:4318/v1/traces
# ── Spring Boot 3.x: Zipkin exporter: ───────────────────────────────
# zipkin:
# tracing:
# endpoint: http://zipkin:9411/api/v2/spans
# ── Spring Boot 4.x: OTLP exporter: ─────────────────────────────────
# opentelemetry:
# tracing:
# export:
# otlp:
# endpoint: http://tempo:4318/v1/traces
# ── Spring Boot 4.x: Zipkin with Brave (spring-boot-starter-zipkin): ─
# tracing:
# export:
# zipkin:
# endpoint: http://zipkin:9411/api/v2/spans
# ── application-prod.yml override: ───────────────────────────────────
# management:
# tracing:
# sampling:
# probability: 0.05 # 5% sampling in productionTrace IDs in Log Lines
With Micrometer Tracing on the classpath, current Spring Boot versions add a correlation ID to the console log automatically. By default it has the form [traceId-spanId]. You can change it with the logging.pattern.correlation property; the example below reproduces the old Sleuth format, [service,traceId,spanId]. If your project still uses the older approach of putting the IDs into logging.pattern.level, remove it after upgrading, or the IDs will be printed twice.
For production, prefer structured (JSON) logs. Spring Boot 3.4 and later can write structured logs natively with logging.structured.format.console (ecs, logstash or gelf), and the trace ID and span ID from the MDC are included as fields. JSON fields are far easier to filter in Kibana or Loki than text patterns, and they avoid fragile regex parsing.
Also log business identifiers (orderId, userId) in a consistent way. The trace ID tells you which request; the business ID tells you which entity, and you will usually need both.
yaml
# application.yml
# Sleuth-style correlation id: [order-service,4bf92f35...,00f067aa...]
logging:
pattern:
correlation: "[${spring.application.name:},%X{traceId:-},%X{spanId:-}] "
# Structured JSON logs (Spring Boot 3.4+). MDC values such as traceId and spanId are included:
# logging:
# structured:
# format:
# console: ecs # or logstash, gelf
# ── Roughly what a log line looks like with the default format: ──────
# 14:23:01.456 INFO 1 --- [order-service] [nio-8080-exec-1] [4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7] c.e.order.OrderService : Order placed orderId=1001
# 14:23:01.512 INFO 1 --- [order-service] [nio-8080-exec-1] [4bf92f3577b34da6a3ce929d0e0e4736-b9a37ca3f3a21c7d] c.e.order.client.UserClient : GET user-service/api/users/42
# ↑ same traceId on every line (the request),
# spanId differs per operation (the unit of work)
# ── Structured (JSON) example: ───────────────────────────────────────
# {
# "@timestamp": "2026-10-31T14:23:01.456Z",
# "log.level": "INFO",
# "service.name": "order-service",
# "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
# "spanId": "00f067aa0ba902b7",
# "message": "Order placed orderId=1001"
# }Verify That Tracing Works
Check the whole chain with one request. Send a request with a known traceparent header, then search for that trace ID everywhere: the logs of every service involved, and the trace backend.
If the ID shows up in the first service's logs but not in the next one, the break is between them: the HTTP client was not instrumented, the propagation format differs, or a gateway removed the header. If the logs have the ID but the trace backend shows nothing, look at sampling and at the exporter endpoint.
Shell
# 1. Call the first service with a known trace id:
curl -H "traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" \
http://localhost:8080/orders/1001
# 2. Search every service's logs for it:
# Kibana (KQL): traceId : "4bf92f3577b34da6a3ce929d0e0e4736"
# Grafana Loki: {app=~".+"} |= "4bf92f3577b34da6a3ce929d0e0e4736"
# Local / Docker: docker compose logs | grep 4bf92f3577b34da6a3ce929d0e0e4736
# 3. Open the same id in the trace UI (Zipkin, Tempo/Grafana, Jaeger):
# you should see one trace with a span per service and per outgoing call.
# Checklist when it does not work:
# - spring-boot-starter-actuator and a tracing starter/bridge are on the classpath
# - management.tracing.sampling.probability is 1.0 while testing (default is 0.1)
# - outgoing HTTP clients are created from Spring's builders (see below)
# - every service uses the same propagation typeAuto-Instrumentation: What Is Traced and What Is Not
Spring Boot instruments most of the common paths automatically, but only when the objects are created in the way the auto-configuration expects.
Traced automatically: incoming requests in Spring MVC and WebFlux; outgoing calls from RestTemplate, RestClient and WebClient when they are created from the injected RestTemplateBuilder, RestClient.Builder or WebClient.Builder; OpenFeign (with feign-micrometer); and Kafka when observation is enabled on the template and listener containers.
Not traced unless you take extra steps: a client created with new RestTemplate() or WebClient.create() (no tracing customizer is applied, so the trace breaks at that call); work moved to another thread (@Async, executors, CompletableFuture, virtual-thread pools you create yourself); raw Kafka producers and consumers; and JDBC calls (add the Datasource Micrometer project if you want database spans).
The most common reason for "the trace stops at service B" is a client built with new.
Java
// ── WRONG: not instrumented, trace context is NOT propagated ─────────
RestTemplate bad = new RestTemplate();
WebClient alsoBad = WebClient.create();
// ── RIGHT: build from the injected, pre-configured builders ───────────
@Configuration
public class HttpClientConfig {
@Bean
RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder.build(); // tracing interceptor is applied
}
@Bean
WebClient webClient(WebClient.Builder builder) {
return builder.baseUrl("http://user-service").build();
}
@Bean
RestClient restClient(RestClient.Builder builder) {
return builder.baseUrl("http://user-service").build();
}
}Custom Spans and Trace Enrichment
Auto-instrumentation creates spans for HTTP requests and clients. Add custom spans for significant internal operations: a batch job, a call to a library that is not instrumented, or any step whose duration and outcome should be visible separately in the trace timeline.
The recommended way is the Micrometer Observation API. One observation produces both a span and a metric, records errors automatically when the code inside throws, and nests under the current span. Use lowCardinalityKeyValue for values with a small, bounded set (channel, region): they go to metrics and traces. Use highCardinalityKeyValue for unbounded values (userId, orderId): they go to traces only, and keep your metrics backend healthy.
If you want a span without a metric, use the lower-level Tracer API, as in the second example. Remember to end every span, always in a finally block, and to open a scope with tracer.withSpan() so child spans and log lines attach to it.
For simple cases annotate the method with @Observed (or @NewSpan). This needs management.observations.annotations.enabled=true and the AspectJ dependency (spring-boot-starter-aop in Boot 3.x, spring-boot-starter-aspectj in Boot 4.x). Do not annotate code that is already instrumented (controllers, Spring Data repositories), or you get duplicate observations. Self-invocation within the same class bypasses the annotation proxy.
Be careful with personal data: tags, baggage and log fields are sent to third-party backends. Use internal ids, not names, e-mail addresses or payment details.
Java
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderService {
private final ObservationRegistry observationRegistry;
private final Tracer tracer;
private final OrderRepository orderRepository;
private final PaymentClient paymentClient;
private final InventoryClient inventoryClient;
// ── Recommended: Observation API (span + metric, errors recorded automatically) ──
public OrderResponse placeOrder(CreateOrderRequest request) {
return Observation.createNotStarted("order.placement", observationRegistry)
.lowCardinalityKeyValue("channel", request.getChannel()) // metrics + traces
.highCardinalityKeyValue("userId", String.valueOf(request.getUserId())) // traces only
.observe(() -> {
checkInventory(request); // child span below
PaymentResponse payment = processPayment(request);
Order order = orderRepository.save(
Order.from(request, payment.getTransactionId()));
log.info("Order placed successfully orderId={}", order.getId());
return OrderResponse.from(order);
});
}
private void checkInventory(CreateOrderRequest request) {
Observation.createNotStarted("inventory.check", observationRegistry)
.highCardinalityKeyValue("productId", String.valueOf(request.getProductId()))
.observe(() -> inventoryClient.checkStock(request.getProductId()));
}
// ── Low-level Tracer API: a span without a metric ─────────────────
private PaymentResponse processPayment(CreateOrderRequest request) {
return inSpan("payment.process", () -> paymentClient.charge(PaymentRequest.from(request)));
}
// Small helper so every span is started, scoped, error-tagged and ended correctly:
private <T> T inSpan(String name, Supplier<T> work) {
Span span = tracer.nextSpan().name(name).start();
try (Tracer.SpanInScope scope = tracer.withSpan(span)) {
return work.get();
} catch (RuntimeException ex) {
span.error(ex);
throw ex;
} finally {
span.end();
}
}
}
// ── Annotation style (needs management.observations.annotations.enabled=true + AspectJ) ──
@Service
public class ReportService {
@Observed(name = "report.generate", contextualName = "generate-report")
public Report generate(Long id) {
// ...
return new Report();
}
}Baggage and Correlation IDs
Sometimes you want to carry a business value, such as a tenant ID, with the request through every service and print it in every log line. Micrometer Tracing calls this baggage. List the field as a remote field so it is propagated over the network, and as a correlation field so it is copied into the MDC and appears in logs. Remember that baggage travels to every downstream service, including third parties, so never put personal data or secrets in it.
For a basic request ID without any tracing stack (legacy systems, batch jobs, third-party APIs that do not propagate trace context), you can use a custom correlation ID header. It complements trace IDs rather than replacing them. When you do this, validate the incoming value: an attacker-controlled header that ends up in your logs can be used for log injection, so accept only a short, safe character set and otherwise generate a new one.
Note that outgoing clients only forward the correlation ID if you register the interceptor. A RestTemplate component interceptor is not applied automatically; add it through RestTemplateBuilder (which also keeps tracing enabled). A Feign RequestInterceptor bean, on the other hand, is picked up automatically by Spring Cloud OpenFeign.
Java
# ── Option A (preferred): baggage, configured in application.yml ───────
# management:
# tracing:
# baggage:
# enabled: true
# remote-fields: tenantId # propagated to downstream services
# correlation:
# fields: tenantId # copied into the MDC → usable as %X{tenantId}
// ── Set baggage in code: ──────────────────────────────────────────────
try (BaggageInScope tenant = tracer.createBaggageInScope("tenantId", "acme")) {
log.info("Handling request"); // MDC contains tenantId=acme; sent to downstream calls
orderClient.fetch();
}
// ── Option B: custom correlation ID filter (entry point or first service) ─
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CorrelationIdFilter extends OncePerRequestFilter {
public static final String HEADER = "X-Correlation-Id";
public static final String MDC_KEY = "correlationId";
private static final Pattern SAFE = Pattern.compile("^[A-Za-z0-9-]{8,64}$");
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
String incoming = request.getHeader(HEADER);
String correlationId = (incoming != null && SAFE.matcher(incoming).matches())
? incoming // accept only a safe format
: UUID.randomUUID().toString();
MDC.put(MDC_KEY, correlationId);
response.setHeader(HEADER, correlationId); // return it to the client
try {
chain.doFilter(request, response);
} finally {
MDC.remove(MDC_KEY); // remove only our key
}
}
}
// ── Propagate in Feign calls (RequestInterceptor beans are auto-registered): ──
@Component
public class CorrelationIdFeignInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
String id = MDC.get(CorrelationIdFilter.MDC_KEY);
if (StringUtils.hasText(id)) {
template.header(CorrelationIdFilter.HEADER, id);
}
}
}
// ── Propagate in RestTemplate calls — must be registered explicitly: ───
public class CorrelationIdRestTemplateInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution)
throws IOException {
String id = MDC.get(CorrelationIdFilter.MDC_KEY);
if (StringUtils.hasText(id)) {
request.getHeaders().add(CorrelationIdFilter.HEADER, id);
}
return execution.execute(request, body);
}
}
@Configuration
public class RestTemplateConfig {
@Bean
RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder
.additionalInterceptors(new CorrelationIdRestTemplateInterceptor())
.build(); // still instrumented for tracing
}
}
// The MDC key is printed with %X{correlationId:-} or appears automatically in JSON logs.Async, Reactive and Kafka Trace Propagation
Trace context lives in thread-local storage. When work moves to another thread (@Async methods, executors, CompletableFuture) or another process (Kafka, queues), the context is not inherited automatically and the trace breaks. Current Spring Boot versions give you declarative switches for the common cases, so avoid hand-written decorators.
@Async and executors: if you use Spring Boot's auto-configured task executor, enable context propagation with spring.task.execution.propagate-context=true. If you define your own executor, register a ContextPropagatingTaskDecorator on it. This uses the Micrometer context-propagation library and carries the observation (and with it the span and MDC values) to the worker thread. For ExecutorService instances you create yourself, wrap them with ContextExecutorService.
Reactive code: set spring.reactor.context-propagation=auto (or call Hooks.enableAutomaticContextPropagation()), so thread-locals are restored across Reactor operators.
Kafka: enable observation on the template and listener containers. Spring for Apache Kafka then injects the trace context into message headers when producing and restores it when consuming, so the consumer's logs carry the producer's trace ID. Batch listeners are not propagated record by record. For raw KafkaProducer or KafkaConsumer clients, which Spring does not wrap, propagate manually with the Micrometer Propagator bean, shown last.
Java
# application.yml — declarative propagation
spring:
task:
execution:
propagate-context: true # auto-configured @Async executor
reactor:
context-propagation: auto # Reactor / WebFlux
kafka:
template:
observation-enabled: true # producer side: writes trace context into headers
listener:
observation-enabled: true # consumer side: restores trace context from headers
// ── Custom executor: register the decorator yourself ──────────────────
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
ContextPropagatingTaskDecorator contextPropagatingTaskDecorator() {
return new ContextPropagatingTaskDecorator();
}
@Bean
ThreadPoolTaskExecutor taskExecutor(ContextPropagatingTaskDecorator decorator) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
executor.setTaskDecorator(decorator); // trace + MDC follow the task
executor.initialize();
return executor;
}
}
// ── Kafka with observation enabled: no tracing code needed ────────────
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderEventPublisher {
private final KafkaTemplate<String, OrderPlacedEvent> kafkaTemplate;
public void publish(Order order) {
kafkaTemplate.send("order.placed", order.getId().toString(), OrderPlacedEvent.from(order));
log.info("Published order.placed orderId={}", order.getId()); // carries the traceId
}
}
@Service
@Slf4j
public class PaymentEventListener {
@KafkaListener(topics = "order.placed", groupId = "payment-service")
public void handleOrderPlaced(OrderPlacedEvent event) {
log.info("Processing order.placed orderId={}", event.orderId()); // same traceId as the producer
// ...
}
}
// ── Manual propagation for raw Kafka clients (Micrometer Propagator bean) ──
@Service
@RequiredArgsConstructor
public class RawKafkaTracing {
private final Tracer tracer;
private final Propagator propagator;
// Producer: write the current context into the record headers
public void inject(ProducerRecord<String, String> record) {
Span current = tracer.currentSpan();
if (current != null) {
propagator.inject(current.context(), record.headers(),
(headers, key, value) -> headers.add(key, value.getBytes(StandardCharsets.UTF_8)));
}
}
// Consumer: continue the trace from the record headers
public void consume(ConsumerRecord<String, String> record, Runnable work) {
Span span = propagator.extract(record.headers(), (headers, key) -> {
Header h = headers.lastHeader(key);
return h == null ? null : new String(h.value(), StandardCharsets.UTF_8);
})
.name("kafka.consume.order.placed")
.start();
try (Tracer.SpanInScope scope = tracer.withSpan(span)) {
work.run();
} catch (RuntimeException ex) {
span.error(ex);
throw ex;
} finally {
span.end();
}
}
}Common Distributed Logging Problems
1. traceId and spanId are empty in logs. Actuator or the tracing bridge/starter is missing, tracing is disabled, there is no active span (for example code running on your own thread or in a scheduled task that is not observed), or your log pattern does not print the MDC values.
2. The trace breaks between two services. The caller's HTTP client was created with new RestTemplate() or WebClient.create(); the two services use different propagation types (w3c versus b3); or an API gateway, proxy or WAF drops the traceparent header. Fix the client creation, align management.tracing.propagation.type, and let the proxy forward the header.
3. Logs have trace IDs but the trace backend is empty. The default sampling probability is 0.1, so 90% of traces are not exported. Set 1.0 while testing. Also check the endpoint, the port (OTLP HTTP 4318 versus gRPC 4317), network access, and that the exporter dependency or starter is present.
4. IDs are lost in @Async, CompletableFuture or thread pools. Enable context propagation (spring.task.execution.propagate-context=true or a ContextPropagatingTaskDecorator) instead of copying the MDC by hand. Calling MDC.clear() in your own decorators also wipes values that other components set.
5. Kafka messages start a new trace. Observation is not enabled on the KafkaTemplate and listener container, or you are using a batch listener, where the observation is not propagated into the listener method.
6. Reactive code loses the context. Enable spring.reactor.context-propagation=auto.
7. Duplicate spans. @Observed or @NewSpan on code that is already instrumented (controllers, repositories, Feign clients).
8. Metrics explosion. Unbounded values such as userId or orderId were added as low-cardinality key values. Use highCardinalityKeyValue.
9. Old Sleuth settings do nothing. spring.sleuth.* properties and starters are not used with Spring Boot 3 and later.
10. Sensitive data in logs, tags or baggage. Use internal identifiers, mask personal data, and avoid logging request bodies.
11. Tests show no traces. Tracing and metrics export are disabled in @SpringBootTest by default in Spring Boot 3.x; add @AutoConfigureObservability (or the equivalent in your Spring Boot version) when a test needs a Tracer.
12. Searching for a 16-character trace ID finds nothing. Current versions use 128-bit (32 character) trace IDs; search with a prefix or the full ID.
Frequently Asked Questions
What is distributed logging?
Logging in a microservice system in a way that lets you follow one request across all services, by attaching the same trace ID (and a span ID per operation) to every log line and shipping all logs to one searchable place.
What replaced Spring Cloud Sleuth?
Micrometer Tracing, with a bridge to OpenTelemetry or Brave. Sleuth does not work with Spring Boot 3 and later.
What is the difference between a trace ID and a span ID?
The trace ID identifies the whole request across all services. A span ID identifies one operation within that trace, such as one service's handling of the request or one outgoing call. A span knows its parent span.
What is the difference between a trace ID and a correlation ID?
A trace ID is generated and propagated by the tracing library in a standard format (W3C traceparent) and is understood by tracing backends. A correlation ID is a custom header you manage yourself; it is useful for systems without tracing support, but it gives you no span hierarchy or timing.
How do I see the trace ID in my logs?
Add spring-boot-starter-actuator and a tracing bridge or starter. Current Spring Boot versions print the IDs in the console log by default; customise the format with logging.pattern.correlation or use structured logging.
Does a trace ID still appear if the request is not sampled?
Yes. The trace ID is generated and logged regardless; sampling only decides whether spans are exported to the backend.
Which should I choose, OpenTelemetry or Brave?
OpenTelemetry is the industry standard and the Spring team's recommended direction, with OTLP supported by almost every backend. Brave is mature and a good fit for Zipkin-only environments. Both sit behind the same Micrometer Tracing API, so your application code does not change.
How do I trace across Kafka?
Enable spring.kafka.template.observation-enabled and spring.kafka.listener.observation-enabled. The trace context is carried in the Kafka message headers.
How do I propagate the trace to @Async methods?
Set spring.task.execution.propagate-context=true for the auto-configured executor, or add a ContextPropagatingTaskDecorator to your own executor.
How much does tracing cost in production?
Mostly the export of spans and the storage in the backend. Reduce it with a low sampling probability or tail sampling in an OpenTelemetry Collector, and avoid very chatty custom spans in hot loops.
Do I still need centralized logging if I have tracing?
Yes. Traces show timing and structure; logs show detail and messages. The shared trace ID is what links the two.
Key Takeaways
- One trace ID per request, one span ID per operation; both are in the MDC and printed in every log line.
- Micrometer Tracing replaces Sleuth. Use the OpenTelemetry starter (Spring Boot 4) or bridge plus exporter (Spring Boot 3), always with actuator.
- Trace context is always propagated and logged; sampling (default 10%) only controls export to the backend. Use 1.0 while testing.
- Build HTTP clients from RestTemplateBuilder, RestClient.Builder and WebClient.Builder, or the trace breaks.
- Use the Observation API for custom spans, highCardinalityKeyValue for IDs, and keep personal data out of tags, baggage and logs.
- Enable context propagation for @Async and reactive code, and observation for Kafka templates and listeners.
- Prefer structured JSON logs, and verify with one request that carries a known traceparent header.
- Related topics: Centralized Logging (ELK / Loki), Zipkin and Tempo, Spring Boot Actuator, API Gateway, Resilience4j.