\ Distributed tracing usually sounds complicated. Most Spring Boot developers assume they must install OpenTelemetry SDKs, collectors, agents, and a full backend like Jaeger or Tempo before they can trace requests across microservices. But you can build a lightweight, explicit tracing layer with plain Spring Boot and core Java that is good enough for many real‑world systems.
This step‑by‑step guide shows how to implement your own trace IDs, async‑safe context propagation, MDC‑based correlated logs, and simple custom spans in Spring Boot — all without adding any tracing SDKs. By the end, you will have a minimal observability framework you fully control, ready for production and future OpenTelemetry adoption.
You will build a simple tracing layer for a microservice system such as order-service → inventory-service → payment-service, all speaking over HTTP. Every request carries a shared X-Trace-ID header, and every log line includes that same trace ID for instant correlation.
The framework will provide:
@Async, thread pools, and ReactorYou should already be comfortable with:
RestTemplate or WebClientThreadLocal and logging MDCYou do not need:
A trace ID is a single identifier that follows a request across all services. Common formats are:
For logs and searchability, ULID is an excellent choice because it is time‑ordered and easier to read than a UUID. Here is a simple ULID‑based trace ID generator:
public class TraceIdGenerator { public static String generate() { return UlidCreator.getUlid().toString(); } }
You will call this generator at the gateway boundary (API gateway or first Spring Boot service) whenever a request does not already carry a trace ID.
Every incoming HTTP request should either reuse an existing trace ID header or get a fresh one. A Spring Filter is a good place to centralize this logic.
@Component public class TraceFilter implements Filter { @Override public void doFilter( ServletRequest request, ServletResponse response, FilterChain chain ) throws IOException, ServletException { HttpServletRequest http = (HttpServletRequest) request; String traceId = http.getHeader("X-Trace-ID"); if (traceId == null || traceId.isEmpty()) { traceId = TraceIdGenerator.generate(); } // Store in ThreadLocal TraceContext.setTraceId(traceId); // Put into MDC so every log line has it MDC.put("traceId", traceId); try { chain.doFilter(request, response); } finally { MDC.clear(); TraceContext.clear(); } } }
The TraceContext is a simple ThreadLocal holder:
public class TraceContext { private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>(); public static void setTraceId(String traceId) { TRACE_ID.set(traceId); } public static String getTraceId() { return TRACE_ID.get(); } public static void clear() { TRACE_ID.remove(); } }
Now every incoming request gets a trace ID, and every log line in that request can reference it.
If your service calls another service, you must forward the trace ID in headers. Otherwise, each service will generate its own unrelated ID and you lose end‑to‑end visibility.
@Bean public RestTemplate restTemplate() { RestTemplate restTemplate = new RestTemplate(); restTemplate.getInterceptors().add((request, body, execution) -> { String traceId = TraceContext.getTraceId(); if (traceId != null && !traceId.isEmpty()) { request.getHeaders().add("X-Trace-ID", traceId); } return execution.execute(request, body); }); return restTemplate; }
Every outgoing call now carries X-Trace-ID, so the next service in the chain can reuse it in its own TraceFilter.
WebClient uses Project Reactor, which does not automatically see ThreadLocal values. You need to put the trace ID into the Reactor Context and read it back in a filter.
@Bean public WebClient webClient() { return WebClient.builder() .filter((request, next) -> Mono.deferContextual(ctx -> { String traceId = ctx.getOrDefault("traceId", "unknown"); ClientRequest mutated = ClientRequest.from(request) .header("X-Trace-ID", traceId) .build(); return next.exchange(mutated); }) ) .build(); }
When you build a reactive pipeline, inject the current trace ID into the context:
Mono.just("data") .contextWrite(ctx -> ctx.put("traceId", TraceContext.getTraceId()));
This is where many implementations break — Reactor discards ThreadLocal unless you explicitly bridge it.
Mapped Diagnostic Context (MDC) lets you attach key–value pairs (like traceId) to the current thread so your logging framework includes them automatically.
A typical Logback pattern might look like:
%date [%thread] %-5level %logger - traceId=%X{traceId} - %msg%n
Now, every log line contains traceId=..., so in Kibana, Grafana, CloudWatch, or Loki, you can filter by a single trace ID and see the entire cross‑service flow.
ThreadLocal‑based context is lost when work hops to another thread:
@Async methodsCompletableFutureTo fix this, wrap your executors so they re‑establish trace ID and MDC in worker threads.
public class TraceableExecutor implements Executor { private final Executor delegate; private final String traceId; public TraceableExecutor(Executor delegate, String traceId) { this.delegate = delegate; this.traceId = traceId; } @Override public void execute(Runnable command) { delegate.execute(() -> { try { TraceContext.setTraceId(traceId); MDC.put("traceId", traceId); command.run(); } finally { MDC.clear(); TraceContext.clear(); } }); } }
@Configuration @EnableAsync public class AsyncConfig implements AsyncConfigurer { @Override public Executor getAsyncExecutor() { String traceId = TraceContext.getTraceId(); return new TraceableExecutor( Executors.newCachedThreadPool(), traceId ); } }
Now even asynchronous tasks retain the correct trace ID and log correlation.
Cloud services do not automatically propagate your custom trace IDs. You must attach them explicitly as attributes or headers.
SendMessageRequest req = new SendMessageRequest() .withQueueUrl(queueUrl) .withMessageBody(body) .addMessageAttributesEntry( "traceId", new MessageAttributeValue() .withDataType("String") .withStringValue(TraceContext.getTraceId()) );
PublishRequest req = new PublishRequest() .withTopicArn(topicArn) .withMessage(message) .addMessageAttributesEntry( "traceId", new MessageAttributeValue() .withDataType("String") .withStringValue(TraceContext.getTraceId()) );
Use custom headers such as X-Trace-ID or X-Correlation-ID, and ensure the first Lambda or microservice in the chain generates or reuses the trace ID. This keeps your tracing model consistent across HTTP, messaging, and serverless paths.
Trace IDs tell you which request you are seeing. Spans tell you what happened inside that request and for how long.
A minimal CustomSpan helper might look like:
public class CustomSpan { private final String spanId = UUID.randomUUID().toString(); private final String parentSpanId; private final long startTime = System.currentTimeMillis(); public CustomSpan(String parentSpanId) { this.parentSpanId = parentSpanId; } public Map<String, Object> end(String name) { long endTime = System.currentTimeMillis(); long duration = endTime - startTime; Map<String, Object> span = new HashMap<>(); span.put("spanId", spanId); span.put("parentSpanId", parentSpanId); span.put("traceId", TraceContext.getTraceId()); span.put("name", name); span.put("durationMs", duration); return span; } }
Use it around key operations:
CustomSpan span = new CustomSpan(null); try { // perform operation: DB query, external call, business logic... } finally { log.info("span: {}", span.end("dbQuery")); }
This logs lightweight span records you can later aggregate or visualize.
A typical JSON span log might look like:
{ "traceId": "01HF8M3N9X9Q", "spanId": "db-42", "parentSpanId": "controller-1", "service": "payment-service", "operation": "fetchPayment", "durationMs": 38, "timestamp": "2025-12-03T12:10:00Z" }
Key fields:
traceId – unique ID for the whole requestspanId – ID for this specific operationparentSpanId – which span triggered this oneservice – name of the producing microserviceoperation – what the span representsdurationMs – latency of the operationtimestamp – when the span completedTools like ELK, CloudWatch Logs, and Loki can use these fields to build simple trace visualizations and latency dashboards.
Even without OpenTelemetry or Jaeger, your logs can act as a tracing UI.
traceId in Kibana/Grafana/CloudWatch to see all related logs and spans.operation or service to find slow components.durationMs over time to catch regressions and performance hotspots.This gives you practical, low‑overhead observability using tools you probably already run in production.
Distributed tracing does not require heavy SDKs, agents, or a dedicated tracing backend. With custom trace IDs, MDC correlation, async propagation, and JSON spans, you can give your Spring Boot microservices clear, end‑to‑end visibility using only code you understand and control.
If your team later adopts OpenTelemetry, this design still pays off: your X-Trace-ID maps cleanly to OTEL trace fields, your MDC patterns keep log correlation intact, and your services already understand how to propagate context. You can then replace these manual spans with OpenTelemetry instrumentation gradually, one service at a time, without losing the observability you built today.
\n
\


