Revision note (2026-09-15). The earlier version of this page was a Spring Cloud Sleuth tutorial, and so was a second post on this site about Sleuth and Zipkin. Sleuth's own reference states that 3.1 is its last minor version and that it will not work with Spring Boot 3.x; its last release on Maven Central is 3.1.11 from February 2024. Beyond being out of date, the old text had concrete defects: it pinned the Hoxton.SR12 BOM (Sleuth 2.2.8) while its custom-span code used the org.springframework.cloud.sleuth.Tracer API that only exists in Sleuth 3.x, so the snippet could not compile against the BOM it recommended; the snippet also called start() twice on the same span; the "sample log lines" showed a 12-character trace id containing non-hex letters; it described X-B3-TraceId headers as what services exchange, which is no longer the default; and the second post constructed new LazyTraceExecutor(tracer, currentTraceContext, executor), a constructor that does not exist in any Sleuth release (3.1.11 has only BeanFactory-based constructors). Both posts are replaced by this one, which is built on Micrometer Tracing, the library Sleuth's core moved into. The URL keeps the word "sleuth" because it is the address people already have for this topic; the content does not.
What changed, in one paragraph
Spring Boot 3 does not auto-configure Sleuth. Its tracing support is built on Micrometer Tracing: a facade (io.micrometer.tracing.Tracer, Span) with two bridges underneath, Brave or OpenTelemetry, and reporters for Zipkin, OTLP, or Wavefront. The Spring Boot reference lists the supported pairs; this article uses "OpenZipkin Brave with Zipkin", which is micrometer-tracing-bridge-brave plus zipkin-reporter-brave. Brave was also Sleuth's default tracer, so the span model, the Zipkin JSON, and the sampler are the ones a Sleuth user already had; the OpenTelemetry bridge was not run for this article, and where its behavior differs (the sampler implementation, for one) this text does not speak for it.
Tested versions: Spring Boot 3.5.16, Micrometer Tracing 1.5.12, Micrometer Observation 1.15.12, Brave 6.1.0, zipkin-reporter 3.5.3, Spring Web 6.2.19, Logback 1.5.34, Java 17 (Amazon Corretto 17.0.14), Gradle 8.8, Docker 27.4.0 with openzipkin/zipkin:3.6.1.
The migration mapping
Everything in this table was either compiled, run, or read from the artifacts named in the last column. The Sleuth column is what the old posts used.
| Spring Cloud Sleuth (Boot 2.x) | Spring Boot 3.5 with Micrometer Tracing | How it was checked |
|---|---|---|
spring-cloud-starter-sleuth | io.micrometer:micrometer-tracing-bridge-brave | build file; Boot reference |
spring-cloud-sleuth-zipkin (3.x) or spring-cloud-starter-zipkin (2.x, last release 2.2.8.RELEASE) | io.zipkin.reporter2:zipkin-reporter-brave | build file; Maven Central metadata |
spring.sleuth.sampler.probability | management.tracing.sampling.probability, default 0.1 | tests; Boot configuration metadata |
spring.zipkin.base-url=http://localhost:9411/ | management.zipkin.tracing.endpoint=http://localhost:9411/api/v2/spans (full URL; this is also the default) | configuration metadata; session |
spring.zipkin.enabled | management.zipkin.tracing.export.enabled | tests |
spring.sleuth.enabled | management.tracing.enabled (controls export and propagation) | test-autoconfigure jar |
spring.sleuth.propagation.type (default B3) | management.tracing.propagation.produce (default W3C) and consume (default W3C, B3, B3_MULTI) | tests; wire observation |
org.springframework.cloud.sleuth.Tracer, Span, Tracer.SpanInScope | io.micrometer.tracing.Tracer, Span, Tracer.SpanInScope, same method shape | compiled and observed in Zipkin |
MDC keys traceId, spanId | unchanged | log lines below |
Log prefix [app,traceId,spanId] | logging.pattern.correlation=[${spring.application.name:},%X{traceId:-},%X{spanId:-}] with logging.include-application-name=false | log lines below |
RestTemplate from RestTemplateBuilder | RestClient from RestClient.Builder (or RestTemplateBuilder, WebClient.Builder); a client built any other way is not instrumented | tests; session |
LazyTraceExecutor, TraceableExecutorService | Micrometer context propagation wrappers (ContextSnapshot, ContextExecutorService), per the migration guide | not exercised here |
| Sleuth test auto-configuration | @AutoConfigureObservability on @SpringBootTest classes | a failing test run, below |
The Micrometer migration guide's own summary is that "in the vast majority of places you should just change the package from org.springframework.cloud.sleuth to io.micrometer.tracing". That held for the manual span in this lab.
The lab: two services, one request
order-service (port 8096) receives GET /orders/{id}, logs, calls inventory-service (port 8097) over HTTP, opens a manual child span while pricing the order, and returns its own trace and span id together with the inventory response. inventory-service logs, and returns the trace context it observed plus the propagation headers that actually arrived, so the caller can prove what crossed the wire.
Both Gradle files add the same two tracing dependencies to the web and actuator starters:
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-tracing-bridge-brave'
implementation 'io.zipkin.reporter2:zipkin-reporter-brave'
Both application.properties files:
management.tracing.sampling.probability=1.0
management.zipkin.tracing.endpoint=http://localhost:9411/api/v2/spans
logging.pattern.correlation=[${spring.application.name:},%X{traceId:-},%X{spanId:-}]
logging.include-application-name=false
The correlation pattern is optional. Without it Boot 3.5 prints [traceId-spanId]; with it you get the Sleuth-style prefix, and the MDC keys are the same ones Sleuth used, so existing log parsers keep working.
The outgoing call must go through the auto-configured builder. The lab keeps a second, deliberately wrong client next to the right one:
@Component
public class InventoryClient {
private final RestClient traced;
private final RestClient untraced;
public InventoryClient(RestClient.Builder builder, @Value("${inventory.base-url}") String baseUrl) {
this.traced = builder.baseUrl(baseUrl).build(); // instrumented by Spring Boot
this.untraced = RestClient.create(baseUrl); // not instrumented
}
public Map<String, Object> stock(String sku) {
return traced.get().uri("/inventory/{sku}", sku).retrieve().body(MAP);
}
public Map<String, Object> stockWithoutPropagation(String sku) {
return untraced.get().uri("/inventory/{sku}", sku).retrieve().body(MAP);
}
}
The manual span is the Sleuth 3 pattern with the package changed, and start() called once:
@Service
public class PricingService {
private final Tracer tracer; // io.micrometer.tracing.Tracer
public BigDecimal price(String orderId) {
Span span = tracer.nextSpan().name("price-order");
try (Tracer.SpanInScope ignored = tracer.withSpan(span.start())) {
span.tag("order.id", orderId);
log.info("Pricing order {}", orderId);
return new BigDecimal("19.90");
}
finally {
span.end();
}
}
}
Zipkin runs in Docker with a pinned tag:
docker run -d --name dd-zipkin-c -p 9411:9411 openzipkin/zipkin:3.6.1
What one request looks like
Both services were started with java -jar from their boot jars, Zipkin in the container above. The request and the response:
$ curl -s localhost:8096/orders/42
{"orderId":"42","price":19.90,"traceId":"6aa8ceb62c0e7f7428fd1d91627917d4","spanId":"28fd1d91627917d4","inventory":{"sku":"SKU-42","available":7,"traceId":"6aa8ceb62c0e7f7428fd1d91627917d4","spanId":"8c61e0d68fc44570","tracingHeaders":{"traceparent":"00-6aa8ceb62c0e7f7428fd1d91627917d4-c4448946379d3693-01"}}}
The order-service process logged two lines for it, the second one from inside the manual span:
2026-09-15T13:51:02.353+09:00 INFO 88168 --- [nio-8096-exec-4] [order-service,6aa8ceb62c0e7f7428fd1d91627917d4,28fd1d91627917d4] com.devdrunk.order.OrderController : Looking up order 42
2026-09-15T13:51:02.403+09:00 INFO 88168 --- [nio-8096-exec-4] [order-service,6aa8ceb62c0e7f7428fd1d91627917d4,b484fcd8ac4d6877] com.devdrunk.order.PricingService : Pricing order 42
The inventory-service process, a different JVM, logged:
2026-09-15T13:51:02.381+09:00 INFO 88167 --- [nio-8097-exec-2] [inventory-service,6aa8ceb62c0e7f7428fd1d91627917d4,8c61e0d68fc44570] c.d.inventory.InventoryController : Checking stock for sku=SKU-42
One traceId, 6aa8ceb62c0e7f7428fd1d91627917d4, in both processes; three span ids: the order server span 28fd1d91627917d4, the pricing span b484fcd8ac4d6877, the inventory server span 8c61e0d68fc44570. Trace ids are 32 hex characters (128-bit, the Boot 3 default) and span ids 16. The traceparent header the inventory side received names a fourth id, c4448946379d3693: that is the order-service client span, which Zipkin shows as the parent of the inventory server span.
The same trace from Zipkin's API (ipv4 values redacted; everything else verbatim):
$ curl -s localhost:9411/api/v2/trace/6aa8ceb62c0e7f7428fd1d91627917d4 | python3 -m json.tool
[
{
"traceId": "6aa8ceb62c0e7f7428fd1d91627917d4",
"parentId": "28fd1d91627917d4",
"id": "c4448946379d3693",
"kind": "CLIENT",
"name": "http get",
"timestamp": 1789447862359006,
"duration": 44548,
"localEndpoint": {"serviceName": "order-service", "ipv4": "(redacted)"},
"tags": {"client.name": "localhost", "exception": "none", "http.url": "http://localhost:8097/inventory/SKU-42", "method": "GET", "outcome": "SUCCESS", "status": "200", "uri": "/inventory/{sku}"}
},
{
"traceId": "6aa8ceb62c0e7f7428fd1d91627917d4",
"parentId": "28fd1d91627917d4",
"id": "b484fcd8ac4d6877",
"name": "price-order",
"timestamp": 1789447862403659,
"duration": 353,
"localEndpoint": {"serviceName": "order-service", "ipv4": "(redacted)"},
"tags": {"order.id": "42"}
},
{
"traceId": "6aa8ceb62c0e7f7428fd1d91627917d4",
"id": "28fd1d91627917d4",
"kind": "SERVER",
"name": "http get /orders/{id}",
"timestamp": 1789447862350711,
"duration": 60369,
"localEndpoint": {"serviceName": "order-service", "ipv4": "(redacted)"},
"tags": {"exception": "none", "http.url": "/orders/42", "method": "GET", "outcome": "SUCCESS", "status": "200", "uri": "/orders/{id}"}
},
{
"traceId": "6aa8ceb62c0e7f7428fd1d91627917d4",
"parentId": "c4448946379d3693",
"id": "8c61e0d68fc44570",
"kind": "SERVER",
"name": "http get /inventory/{sku}",
"timestamp": 1789447862379397,
"duration": 7462,
"localEndpoint": {"serviceName": "inventory-service", "ipv4": "(redacted)"},
"tags": {"exception": "none", "http.url": "/inventory/SKU-42", "method": "GET", "outcome": "SUCCESS", "status": "200", "uri": "/inventory/{sku}"}
}
]
Four spans: server, client, and the manual span on the order side, then the inventory server span whose parentId is the client span. The inventory span has its own id rather than sharing the client's; Boot 3 does not join spans, which is one of the defaults the migration guide calls out. Test traceIsQueryableFromZipkinApi asserts this exact shape, and oneRequestProducesTheSameTraceIdInBothServicesLogs asserts the log lines.
Propagation: W3C out, B3 still accepted
The inventory side reported exactly one propagation header, traceparent, ending in -01 (sampled). No b3, no X-B3-TraceId. That is Boot's default management.tracing.propagation.produce=W3C. If your older services or a gateway still send B3, they are understood: consume defaults to W3C, B3, B3_MULTI. Sending Sleuth-era headers to the new service continues the trace:
$ curl -s -H "X-B3-TraceId: 4bf92f3577b34da6a3ce929d0e0e4736" -H "X-B3-SpanId: 00f067aa0ba902b7" -H "X-B3-Sampled: 1" localhost:8096/orders/11
{"orderId":"11","price":19.90,"traceId":"4bf92f3577b34da6a3ce929d0e0e4736","spanId":"68a69f2488af47af","inventory":{"sku":"SKU-11","available":7,"traceId":"4bf92f3577b34da6a3ce929d0e0e4736","spanId":"4d297b117d6487f6","tracingHeaders":{"traceparent":"00-4bf92f3577b34da6a3ce929d0e0e4736-310d373baba2728c-01"}}}
The caller's trace id survives, the server span gets a new id, and the next hop receives W3C. So during a mixed rollout, the direction that breaks is a new service calling an old Sleuth service configured for B3 only; set management.tracing.propagation.type=B3 (or produce=W3C,B3) on the new side until the old one is gone. That mixed case was not run here.
And the wrong client, RestClient.create():
$ curl -s localhost:8096/orders/9/unpropagated
{"orderId":"9","price":19.90,"traceId":"6aa8ceb65590bed64a4718ab8c6df9bd","spanId":"4a4718ab8c6df9bd","inventory":{"sku":"SKU-9","available":7,"traceId":"6aa8ceb6955f4bc4cd9a03872e3ddf37","spanId":"cd9a03872e3ddf37","tracingHeaders":{}}}
No headers arrived, and the inventory side started an unrelated trace. Both services still log a trace id, which is what makes this failure mode easy to miss: the ids are there, they just never match. The Boot reference says this plainly; the lab reproduces it.
The test trap
The first test run failed with the downstream seeing no traceparent and Zipkin receiving nothing, although trace ids were being generated. Spring Boot's test support (ObservabilityContextCustomizerFactory in spring-boot-test-autoconfigure) sets management.tracing.enabled=false for every @SpringBootTest, and in Boot 3.5 that property means "export and propagate". Brave still produces ids; nothing leaves the process. Add @AutoConfigureObservability to a test class that needs real propagation. A second trap followed: TestRestTemplate is built from Boot's RestTemplateBuilder, so it is instrumented and became the root of every trace, which put five spans in Zipkin instead of four. The tests call the service with a plain RestClient.create(...), which behaves like curl.
Sampling: what the probability does and does not do
management.tracing.sampling.probability defaults to 0.1, and the reference says why: "By default, Spring Boot samples only 10% of requests to prevent overwhelming the trace backend." What the setting controls is whether a trace is reported, not whether it exists. SamplingTests runs order-service at 0.1 and inventory-service at 0.0, with Zipkin export switched off and a Brave SpanHandler registered in each service to see what would have been reported, and sends 100 requests:
- All 100 responses carry a distinct 32-hex trace id, and the inventory side reports the same id for each. Unsampled requests are still traced in the sense that matters for logs.
- order-service recorded exactly 10 server spans and 30 spans in total (server, client,
price-orderper sampled trace). The count is exact, not approximate: Brave 6.1.0'sSampler.create(float)delegates toCountingSampler, which marks exactly 10 of every 100 root decisions. Boot builds itsSamplerbean from that method. This is a Brave property; the OpenTelemetry bridge uses a ratio sampler keyed on the trace id, which was not measured here. - inventory-service, at probability 0.0, recorded exactly the 10 traces its caller sampled and none of the other 90. The sampled flag in the incoming
traceparentwins over the local sampler. The decision is made once, at the first service, and the rest of the chain follows it. Setting a lower probability on a downstream service does not reduce what it reports for traces that arrive already sampled.
The two-process session confirms the last point from the other side. With order-service restarted at probability 0.0 and inventory-service still at 1.0:
$ curl -s localhost:8096/orders/42
{"orderId":"42","price":19.90,"traceId":"6aa8cee6acf23302df7e5d09bad901cd","spanId":"df7e5d09bad901cd","inventory":{"sku":"SKU-42","available":7,"traceId":"6aa8cee6acf23302df7e5d09bad901cd","spanId":"f09514243ffc8379","tracingHeaders":{"traceparent":"00-6aa8cee6acf23302df7e5d09bad901cd-470ffa86421c2497-00"}}}
$ curl -s -o /dev/null -w '%{http_code}n' localhost:9411/api/v2/trace/6aa8cee6acf23302df7e5d09bad901cd
404
Both logs carry the id, the header ends in -00, the inventory side honored it despite its own 1.0, and Zipkin never saw the trace. Whatever probability you choose for production, choose it at the edge; the earlier post's "typically 0.05 to 0.1" was not measured against anything, and this article does not offer a replacement number because the right one depends on your traffic and your backend.
What this does not cover
- The OpenTelemetry bridge and OTLP export; the sampler and some span details differ.
- Messaging (Kafka, RabbitMQ),
@Async, and executor wrapping. The migration guide points toContextSnapshotandContextExecutorService; nothing asynchronous is traced in this lab, so the old posts' claims about async propagation are neither confirmed nor repeated. - Baggage (
management.tracing.baggage.*), WebFlux, and reactive propagation. - Zipkin storage, retention, authentication, and sizing. The container runs with in-memory storage.
- Tracing overhead. Nothing was benchmarked; the sampling section is about counts, not cost.
Reproduce it
The project is examples/micrometer-tracing-zipkin in the site's repository: a Gradle build with order-service and inventory-service subprojects, 6 tests, and a README with the full pasted session.
docker run -d --name dd-zipkin-c -p 9411:9411 openzipkin/zipkin:3.6.1
gradle --no-daemon test # 6 tests; the Zipkin one is skipped if 9411 is unreachable
gradle --no-daemon bootJar
java -jar inventory-service/build/libs/inventory-service-0.1.0.jar &
java -jar order-service/build/libs/order-service-0.1.0.jar &
curl -s localhost:8096/orders/42
curl -s localhost:9411/api/v2/trace/<traceId from the response>
docker rm -f dd-zipkin-c
Sources
- Spring Cloud Sleuth reference: end of life and move to Micrometer Tracing
- Spring Boot 3.5 reference: Tracing
- Micrometer Tracing: Spring Cloud Sleuth 3.1 migration guide
- Maven Central metadata: spring-cloud-starter-sleuth (last release 3.1.11)
- Maven Central metadata: spring-cloud-starter-zipkin (last release 2.2.8.RELEASE)
- Zipkin API: GET /api/v2/trace/{traceId}
- Docker Hub: openzipkin/zipkin tags
