Backend Development 12 min read

Using MDC for TraceId Propagation in Java Backend Applications

This article explains what MDC (Mapped Diagnostic Context) is, outlines its API, demonstrates how to configure logging and interceptors, and provides concrete solutions—including a custom thread‑pool wrapper and HTTP client interceptors—to ensure TraceId is correctly propagated across threads and remote calls in Java backend systems.

Java Architect Essentials
Java Architect Essentials
Java Architect Essentials
Using MDC for TraceId Propagation in Java Backend Applications

MDC (Mapped Diagnostic Context) is a feature provided by logging frameworks such as log4j, logback, and log4j2 that allows a hash map bound to the current thread to store key‑value pairs, making contextual information like a TraceId available to all log statements executed in that thread.

API

clear() – removes all entries from MDC

get(String key) – retrieves the value for a given key

getContext() – obtains the entire MDC map

put(String key, Object value) – stores a key‑value pair

remove(String key) – deletes a specific entry

Typical usage involves adding an interceptor that extracts a TraceId from the incoming request and puts it into MDC at the beginning of request processing.

LOGGER.info("traceId:{} ", traceId)

However, TraceId can be lost in two common scenarios: child threads and HTTP calls to downstream services.

Solution for Child‑Thread TraceId Loss

Wrap the thread pool so that the MDC context is transferred to worker threads. The custom ThreadPoolExecutorMdcWrapper class overrides execution methods and uses ThreadMdcUtil.wrap to propagate the context.

public class ThreadPoolExecutorMdcWrapper extends ThreadPoolExecutor {
    public ThreadPoolExecutorMdcWrapper(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue
workQueue) {
        super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
    }
    @Override
    public void execute(Runnable task) {
        super.execute(ThreadMdcUtil.wrap(task, MDC.getCopyOfContextMap()));
    }
    @Override
    public
Future
submit(Runnable task, T result) {
        return super.submit(ThreadMdcUtil.wrap(task, MDC.getCopyOfContextMap()), result);
    }
    @Override
    public
Future
submit(Callable
task) {
        return super.submit(ThreadMdcUtil.wrap(task, MDC.getCopyOfContextMap()));
    }
    @Override
    public Future
submit(Runnable task) {
        return super.submit(ThreadMdcUtil.wrap(task, MDC.getCopyOfContextMap()));
    }
}

The utility class ThreadMdcUtil handles the actual wrapping of Runnable and Callable objects, setting the MDC map before execution and clearing it afterwards.

public class ThreadMdcUtil {
    public static void setTraceIdIfAbsent() {
        if (MDC.get(Constants.TRACE_ID) == null) {
            MDC.put(Constants.TRACE_ID, TraceIdUtil.getTraceId());
        }
    }
    public static
Callable
wrap(final Callable
callable, final Map
context) {
        return () -> {
            if (context == null) {
                MDC.clear();
            } else {
                MDC.setContextMap(context);
            }
            setTraceIdIfAbsent();
            try {
                return callable.call();
            } finally {
                MDC.clear();
            }
        };
    }
    public static Runnable wrap(final Runnable runnable, final Map
context) {
        return () -> {
            if (context == null) {
                MDC.clear();
            } else {
                MDC.setContextMap(context);
            }
            setTraceIdIfAbsent();
            try {
                runnable.run();
            } finally {
                MDC.clear();
            }
        };
    }
}

Solution for HTTP Call TraceId Loss

For outbound HTTP calls, add an interceptor that copies the TraceId from MDC into the request header. Below are examples for three common HTTP clients.

HttpClient

public class HttpClientTraceIdInterceptor implements HttpRequestInterceptor {
    @Override
    public void process(HttpRequest httpRequest, HttpContext httpContext) throws HttpException, IOException {
        String traceId = MDC.get(Constants.TRACE_ID);
        if (traceId != null) {
            httpRequest.addHeader(Constants.TRACE_ID, traceId);
        }
    }
}

private static CloseableHttpClient httpClient = HttpClientBuilder.create()
        .addInterceptorFirst(new HttpClientTraceIdInterceptor())
        .build();

OkHttp

public class OkHttpTraceIdInterceptor implements Interceptor {
    @Override
    public Response intercept(Chain chain) throws IOException {
        String traceId = MDC.get(Constants.TRACE_ID);
        Request request = chain.request();
        if (traceId != null) {
            request = request.newBuilder().addHeader(Constants.TRACE_ID, traceId).build();
        }
        return chain.proceed(request);
    }
}

private static OkHttpClient client = new OkHttpClient.Builder()
        .addNetworkInterceptor(new OkHttpTraceIdInterceptor())
        .build();

RestTemplate

public class RestTemplateTraceIdInterceptor implements ClientHttpRequestInterceptor {
    @Override
    public ClientHttpResponse intercept(HttpRequest httpRequest, byte[] body, ClientHttpRequestExecution execution) throws IOException {
        String traceId = MDC.get(Constants.TRACE_ID);
        if (traceId != null) {
            httpRequest.getHeaders().add(Constants.TRACE_ID, traceId);
        }
        return execution.execute(httpRequest, body);
    }
}

restTemplate.setInterceptors(Arrays.asList(new RestTemplateTraceIdInterceptor()));

For services that receive the request, a server‑side interceptor extracts the TraceId from the header and puts it back into MDC, creating a new one if absent.

public class LogInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String traceId = request.getHeader(Constants.TRACE_ID);
        if (traceId == null) {
            traceId = TraceIdUtils.getTraceId();
        }
        MDC.put(Constants.TRACE_ID, traceId);
        return true;
    }
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        MDC.remove(Constants.TRACE_ID);
    }
}

Finally, modify the logging pattern to include the TraceId:

<property name="pattern">[TRACEID:%X{traceId}] %d{HH:mm:ss.SSS} %-5level %class{-1}.%M()/%L - %msg%xEx%n</property>

With these configurations, TraceId is consistently propagated across threads and HTTP calls, enabling reliable end‑to‑end tracing in Java backend services.

backendJavaThreadPoolloggingInterceptorTraceIdMDC
Java Architect Essentials
Written by

Java Architect Essentials

Committed to sharing quality articles and tutorials to help Java programmers progress from junior to mid-level to senior architect. We curate high-quality learning resources, interview questions, videos, and projects from across the internet to help you systematically improve your Java architecture skills. Follow and reply '1024' to get Java programming resources. Learn together, grow together.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.