Propagate Sleuth
baggage management
parallel streams
distributed tracing
microservices debugging

Propagate Sleuth baggage on parallel streams

System Design practice on Codemia

Work through 120+ system design problems with detailed solutions, from rate limiters to multi-region storage.

Practice system design

Introduction

Spring Cloud Sleuth keeps trace context and baggage in thread-local state. That works well for normal request handling, but it breaks down once work jumps to threads that Sleuth is not instrumenting for you.

Why parallelStream() Loses Baggage

Sleuth documentation is direct about this: parallelStream() is not supported out of the box for context propagation. The reason is simple. Java parallel streams run work on the common fork-join pool, and Sleuth does not automatically wrap those tasks with the current trace context.

So if you start with baggage such as tenant-id or request-id on the request thread, code inside the parallel stream may see no baggage at all. Logging correlation, downstream headers, and custom tracing tags can all disappear.

Configure Baggage First

Before propagation matters, the baggage fields themselves must be registered:

yaml
1spring:
2  sleuth:
3    baggage:
4      remote-fields:
5        - tenant-id
6      correlation-fields:
7        - tenant-id

That makes Sleuth treat tenant-id as propagated baggage and also copy it into logging correlation where supported.

Prefer an Instrumented Executor

Instead of parallelStream(), use CompletableFuture with a Sleuth-aware executor. Spring Cloud Sleuth provides wrappers such as TraceableExecutorService and LazyTraceExecutor for exactly this problem.

Example:

java
1@Service
2public class PriceService {
3
4    private final BeanFactory beanFactory;
5    private final ExecutorService delegate = Executors.newFixedThreadPool(4);
6
7    public PriceService(BeanFactory beanFactory) {
8        this.beanFactory = beanFactory;
9    }
10
11    public List<String> loadPrices(List<String> ids) {
12        ExecutorService traced =
13            new TraceableExecutorService(beanFactory, delegate, "load-prices");
14
15        List<CompletableFuture<String>> futures = ids.stream()
16            .map(id -> CompletableFuture.supplyAsync(() -> lookup(id), traced))
17            .toList();
18
19        return futures.stream()
20            .map(CompletableFuture::join)
21            .toList();
22    }
23
24    private String lookup(String id) {
25        return "price-" + id;
26    }
27}

In this model, each asynchronous task is submitted through an executor that captures and restores the tracing context. That includes baggage.

Reading the Baggage

Inside the worker thread, you can access baggage through the tracer:

java
String tenantId = tracer.getBaggage("tenant-id").get();
log.info("tenantId={}", tenantId);

As long as the work was scheduled through a traced executor, the value is available where you need it.

Why Manual Copying Is Usually the Wrong Fix

You can manually capture a value before the stream starts and pass it into lambdas. That works for a single field:

java
String tenantId = tracer.getBaggage("tenant-id").get();

But it does not solve the full tracing problem. The active span, MDC correlation, and any future baggage fields are still disconnected unless you copy everything yourself. That creates brittle code.

The better design is to treat context propagation as infrastructure, not business logic.

Migrating Forward

For modern Spring Boot applications, Micrometer Tracing and the Micrometer context propagation library are the long-term path. The underlying lesson stays the same: thread switches need explicit propagation support.

If you are still on Sleuth, the practical answer is:

  • avoid parallelStream() for traced work
  • use traced executors or CompletableFuture
  • keep baggage registration explicit

Common Pitfalls

The most common mistake is assuming baggage works everywhere because it works in controller code. Request-thread success says nothing about later fork-join threads.

Another problem is configuring correlation fields but not remote fields, or the reverse. If baggage is not registered properly, even a correct executor setup will look broken.

Teams also forget executor boundaries hidden inside libraries. If a downstream component spins up work on its own unmanaged pool, context can still disappear.

Finally, do not treat parallelStream() as harmless syntactic sugar in tracing-heavy code. It is a concurrency decision with observability consequences.

Summary

  • Sleuth does not propagate baggage through parallelStream() automatically.
  • Baggage relies on thread-local trace context, so unmanaged thread hops lose it.
  • Register baggage fields explicitly in configuration.
  • Use TraceableExecutorService, LazyTraceExecutor, or another traced executor for async work.
  • Prefer executor-based concurrency over parallelStream() when trace context must survive.

Related reading
Course
Beginner
27 lessons
10 hours
System Design Fundamentals

Build a strong foundation in designing scalable, reliable distributed systems.

View the course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

System Design practice on Codemia

Work through 120+ system design problems with detailed solutions, from rate limiters to multi-region storage.

Practice system design

All Rights Reserved.