Improve Your Logs and Traceability with MDC in Spring Boot, Python and Node.js
Introduction: Finding a Needle in a Haystack of Logs
Imagine a production system with 50 microservices processing thousands of requests per second. A user reports an error. You open the logs and find millions of interleaved lines from different threads, services, and users. How do you find exactly the lines related to that specific request?
The answer is MDC (Mapped Diagnostic Context): a mechanism that lets you add contextual information to every log line automatically, without modifying business code. In this article, we'll explore what MDC is, why it's fundamental for traceability, and how to implement it with real examples in Spring Boot, Python, and Node.js.
![]()
Source: Taylor Vick — Unsplash
What is MDC (Mapped Diagnostic Context)?
MDC is a key-value map associated with the current execution thread (thread-local) that logging frameworks can automatically inject into every log line. It works like an invisible dictionary that travels with each request through the entire execution chain.
| Concept | Description |
|---|---|
| Thread-Local Storage | Each thread has its own copy of the MDC, preventing collisions between concurrent requests |
| Key-Value | Stores pairs like traceId=abc123, userId=42, sessionId=xyz |
| Transparent | Once configured, every log.info() automatically includes MDC values without changing code |
| Propagable | Can be propagated between microservices via HTTP headers |
Equivalents in Each Language
| Language | Mechanism | Library / API |
|---|---|---|
| Java | ThreadLocal (native MDC) | SLF4J + Logback / Log4j2 |
| Python | contextvars (PEP 567) | logging + custom Filter |
| Node.js | AsyncLocalStorage | node:async_hooks (built-in) |
Why Is It Important for Traceability?
Without MDC, your logs look like this:
1INFO OrderService - Processing order
2INFO PaymentService - Payment received
3ERROR OrderService - Failed to update inventory
4INFO PaymentService - Sending confirmation email
5INFO OrderService - Processing order
6ERROR PaymentService - Payment declined
Which line belongs to which request? Impossible to tell. With MDC:
1INFO [traceId=a1b2c3 userId=42] OrderService - Processing order
2INFO [traceId=a1b2c3 userId=42] PaymentService - Payment received
3ERROR [traceId=a1b2c3 userId=42] OrderService - Failed to update inventory
4INFO [traceId=d4e5f6 userId=99] PaymentService - Sending confirmation email
5INFO [traceId=d4e5f6 userId=99] OrderService - Processing order
6ERROR [traceId=d4e5f6 userId=99] PaymentService - Payment declined
Now you can filter by traceId=a1b2c3 and see exactly the complete flow of that request.
- Production debugging: Filter logs by traceId to reconstruct the complete request flow
- Cross-service correlation: Follow a request across multiple microservices
- Auditing: Know which user executed which operation and when
- Metrics: Measure latency per request, not just global averages
- Smart alerts: Group errors by traceId to avoid duplicate alerts

Source: Luke Chesser — Unsplash
Implementation in Spring Boot (Java / SLF4J + Logback)

Source: Wikimedia Commons
Spring Boot uses SLF4J as the logging facade and Logback as the default implementation. MDC is natively integrated.
1. Filter that injects MDC
1import jakarta.servlet.Filter;
2import jakarta.servlet.FilterChain;
3import jakarta.servlet.ServletRequest;
4import jakarta.servlet.ServletResponse;
5import jakarta.servlet.http.HttpServletRequest;
6import org.slf4j.MDC;
7import org.springframework.stereotype.Component;
8import java.util.UUID;
9
10@Component
11public class MdcFilter implements Filter {
12
13 @Override
14 public void doFilter(ServletRequest request, ServletResponse response,
15 FilterChain chain) throws Exception {
16 HttpServletRequest httpReq = (HttpServletRequest) request;
17 try {
18 String traceId = httpReq.getHeader("X-Trace-Id");
19 if (traceId == null || traceId.isBlank()) {
20 traceId = UUID.randomUUID().toString().substring(0, 8);
21 }
22 MDC.put("traceId", traceId);
23 MDC.put("userId", httpReq.getHeader("X-User-Id"));
24
25 chain.doFilter(request, response);
26 } finally {
27 MDC.clear(); // CRITICAL: prevent leaks in thread pools
28 }
29 }
30}
2. Logback pattern
1<!-- logback-spring.xml -->
2<configuration>
3 <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
4 <encoder>
5 <pattern>%d{HH:mm:ss.SSS} %-5level [%X{traceId:-no-trace}] [%X{userId:-anon}] %logger{36} - %msg%n</pattern>
6 </encoder>
7 </appender>
8 <root level="INFO">
9 <appender-ref ref="CONSOLE" />
10 </root>
11</configuration>
3. Propagation to other services (RestTemplate)
1public class MdcPropagationInterceptor implements ClientHttpRequestInterceptor {
2 @Override
3 public ClientHttpResponse intercept(HttpRequest request, byte[] body,
4 ClientHttpRequestExecution execution) throws IOException {
5 String traceId = MDC.get("traceId");
6 if (traceId != null) {
7 request.getHeaders().set("X-Trace-Id", traceId);
8 }
9 return execution.execute(request, body);
10 }
11}
12
13// Register it
14@Bean
15public RestTemplate restTemplate() {
16 RestTemplate rt = new RestTemplate();
17 rt.setInterceptors(List.of(new MdcPropagationInterceptor()));
18 return rt;
19}
MDC.clear() in a finally block. Servers reuse threads, and without cleanup a request may "inherit" data from the previous one.
Implementation in Python (logging + contextvars)

Source: Python Software Foundation
Python doesn't have native MDC, but contextvars (Python 3.7+) offers exactly the same thing: context variables that propagate automatically in async code.
1. Reusable MDC module
1import logging
2import uuid
3from contextvars import ContextVar
4
5# Context variables (equivalent to Java's ThreadLocal)
6trace_id_var: ContextVar[str] = ContextVar('trace_id', default='no-trace')
7user_id_var: ContextVar[str] = ContextVar('user_id', default='anon')
8
9
10class MDCFilter(logging.Filter):
11 """Injects context variables into every log record."""
12 def filter(self, record):
13 record.trace_id = trace_id_var.get()
14 record.user_id = user_id_var.get()
15 return True
16
17
18def setup_logging(name: str = 'app') -> logging.Logger:
19 """Configure a logger with integrated MDC."""
20 handler = logging.StreamHandler()
21 handler.setFormatter(logging.Formatter(
22 '%(asctime)s %(levelname)-5s [%(trace_id)s] [%(user_id)s] %(name)s - %(message)s'
23 ))
24 handler.addFilter(MDCFilter())
25
26 logger = logging.getLogger(name)
27 logger.addHandler(handler)
28 logger.setLevel(logging.INFO)
29 return logger
30
31
32logger = setup_logging()
2. FastAPI middleware
1from fastapi import FastAPI, Request
2from starlette.middleware.base import BaseHTTPMiddleware
3
4app = FastAPI()
5
6
7class MDCMiddleware(BaseHTTPMiddleware):
8 async def dispatch(self, request: Request, call_next):
9 trace_id = request.headers.get('X-Trace-Id', uuid.uuid4().hex[:8])
10 user_id = request.headers.get('X-User-Id', 'anon')
11
12 token_trace = trace_id_var.set(trace_id)
13 token_user = user_id_var.set(user_id)
14
15 try:
16 response = await call_next(request)
17 response.headers['X-Trace-Id'] = trace_id
18 return response
19 finally:
20 trace_id_var.reset(token_trace)
21 user_id_var.reset(token_user)
22
23
24app.add_middleware(MDCMiddleware)
25
26
27@app.get("/orders/{order_id}")
28async def get_order(order_id: int):
29 logger.info(f"Fetching order {order_id}")
30 return {"order_id": order_id}
3. Propagation to other services (httpx)
1import httpx
2
3async def call_payment_service(order_id: int):
4 """Propagate traceId to the next microservice."""
5 trace_id = trace_id_var.get()
6 user_id = user_id_var.get()
7
8 async with httpx.AsyncClient() as client:
9 response = await client.post(
10 "http://payment-service/api/pay",
11 json={"order_id": order_id},
12 headers={
13 "X-Trace-Id": trace_id,
14 "X-User-Id": user_id,
15 },
16 )
17 logger.info(f"Payment service responded: {response.status_code}")
18 return response.json()
4. Also works with Flask and Django
1# Flask middleware
2from flask import Flask, request, g
3
4app = Flask(__name__)
5
6@app.before_request
7def set_mdc():
8 trace_id = request.headers.get('X-Trace-Id', uuid.uuid4().hex[:8])
9 token = trace_id_var.set(trace_id)
10 g.mdc_token = token
11
12@app.teardown_request
13def clear_mdc(exc=None):
14 token = getattr(g, 'mdc_token', None)
15 if token:
16 trace_id_var.reset(token)
contextvars propagates automatically across async functions, asyncio.create_task(), and ThreadPoolExecutor (since Python 3.12 with copy_context().run()). No extra decorators needed like in Java.
Implementation in Node.js (AsyncLocalStorage)

Source: Gabriel Heinzer — Unsplash
Node.js offers AsyncLocalStorage (stable since Node 16), which works like ThreadLocal for async contexts — perfect for MDC.
1. Reusable MDC module
1// src/mdc.ts
2import { AsyncLocalStorage } from 'node:async_hooks';
3
4interface MDCContext {
5 traceId: string;
6 userId: string;
7 [key: string]: string; // extensible for more fields
8}
9
10export const mdcStorage = new AsyncLocalStorage<MDCContext>();
11
12export function getMDC(): MDCContext {
13 return mdcStorage.getStore() ?? { traceId: 'no-trace', userId: 'anon' };
14}
15
16export function createLogger(name: string) {
17 const format = (level: string, msg: string, extra?: object) => {
18 const { traceId, userId } = getMDC();
19 return JSON.stringify({
20 timestamp: new Date().toISOString(),
21 level, traceId, userId, logger: name, message: msg, ...extra,
22 });
23 };
24
25 return {
26 info: (msg: string) => console.log(format('INFO', msg)),
27 warn: (msg: string) => console.warn(format('WARN', msg)),
28 error: (msg: string, err?: Error) =>
29 console.error(format('ERROR', msg, { stack: err?.stack })),
30 };
31}
2. Express middleware
1import express from 'express';
2import { randomUUID } from 'node:crypto';
3import { mdcStorage, createLogger } from './mdc';
4
5const app = express();
6const log = createLogger('OrderController');
7
8// MDC Middleware
9app.use((req, res, next) => {
10 const traceId = (req.headers['x-trace-id'] as string)
11 || randomUUID().substring(0, 8);
12 const userId = (req.headers['x-user-id'] as string) || 'anon';
13
14 res.setHeader('X-Trace-Id', traceId);
15
16 // Entire request runs within MDC context
17 mdcStorage.run({ traceId, userId }, () => next());
18});
19
20app.get('/orders/:id', (req, res) => {
21 log.info('Fetching order ' + req.params.id);
22 res.json({ orderId: req.params.id });
23});
3. Propagation to other services (fetch / axios)
1import { getMDC } from './mdc';
2
3// With native fetch (Node 18+)
4async function callPaymentService(orderId: string) {
5 const { traceId, userId } = getMDC();
6 const log = createLogger('PaymentClient');
7
8 log.info('Calling payment service for order ' + orderId);
9
10 const response = await fetch('http://payment-service/api/pay', {
11 method: 'POST',
12 headers: {
13 'Content-Type': 'application/json',
14 'X-Trace-Id': traceId,
15 'X-User-Id': userId,
16 },
17 body: JSON.stringify({ orderId }),
18 });
19
20 log.info('Payment responded: ' + response.status);
21 return response.json();
22}
23
24// With axios (global interceptor)
25import axios from 'axios';
26
27axios.interceptors.request.use((config) => {
28 const { traceId, userId } = getMDC();
29 config.headers['X-Trace-Id'] = traceId;
30 config.headers['X-User-Id'] = userId;
31 return config;
32});
4. Also works with NestJS and Fastify
1// NestJS: Middleware
2import { Injectable, NestMiddleware } from '@nestjs/common';
3import { mdcStorage } from './mdc';
4
5@Injectable()
6export class MdcMiddleware implements NestMiddleware {
7 use(req: any, res: any, next: () => void) {
8 const traceId = req.headers['x-trace-id'] || randomUUID().substring(0, 8);
9 res.setHeader('X-Trace-Id', traceId);
10 mdcStorage.run({ traceId, userId: req.headers['x-user-id'] || 'anon' }, next);
11 }
12}
13
14// Fastify: hook
15fastify.addHook('onRequest', (req, reply, done) => {
16 const traceId = req.headers['x-trace-id'] || randomUUID().substring(0, 8);
17 reply.header('X-Trace-Id', traceId);
18 mdcStorage.run({ traceId, userId: req.headers['x-user-id'] || 'anon' }, done);
19});
AsyncLocalStorage propagates automatically through setTimeout, setInterval, Promises, async/await, and EventEmitter. No extra work needed — the context "travels" with the async flow.
Propagation Between Microservices: The Complete Pattern
The real power of MDC shows when you propagate the traceId between microservices:
- The API Gateway generates the initial
traceIdand puts it in theX-Trace-Idheader - Each service reads the header, puts it in its MDC, and propagates it in outgoing calls
- All logs from all services share the same
traceId

Source: NASA — Unsplash
With the propagation implemented in the previous sections, a purchase flow generates logs like this across all three services:
1# API Gateway (Node.js)
2INFO [traceId=a1b2c3] [user-42] Gateway - POST /api/orders received
3
4# Order Service (Spring Boot)
5INFO [traceId=a1b2c3] [user-42] OrderService - Creating order #1001
6INFO [traceId=a1b2c3] [user-42] OrderService - Calling payment service
7
8# Payment Service (Python/FastAPI)
9INFO [traceId=a1b2c3] [user-42] payment - Processing payment for order 1001
10INFO [traceId=a1b2c3] [user-42] payment - Payment approved, charging $99.99
11
12# Order Service (Spring Boot)
13INFO [traceId=a1b2c3] [user-42] OrderService - Order #1001 confirmed
A single grep a1b2c3 shows you the complete flow through Gateway (Node), Order (Java) and Payment (Python).
Async Contexts and Concurrency
ThreadLocal-based MDC has limitations with concurrency. Each language solves it differently:
Java: Virtual Threads (Java 21+)
Virtual Threads don't inherit MDC automatically. Solution: TaskDecorator.
1public class MdcTaskDecorator implements TaskDecorator {
2 @Override
3 public Runnable decorate(Runnable runnable) {
4 Map<String, String> ctx = MDC.getCopyOfContextMap();
5 return () -> {
6 try {
7 if (ctx != null) MDC.setContextMap(ctx);
8 runnable.run();
9 } finally {
10 MDC.clear();
11 }
12 };
13 }
14}
Python: asyncio and ThreadPoolExecutor
contextvars propagates automatically in asyncio. For threads, use copy_context():
1import asyncio
2from contextvars import copy_context
3from concurrent.futures import ThreadPoolExecutor
4
5async def process_orders(order_ids: list[int]):
6 """contextvars propagates automatically to tasks."""
7 tasks = [asyncio.create_task(process_one(oid)) for oid in order_ids]
8 await asyncio.gather(*tasks) # Each task inherits the context
9
10# For threads (CPU-bound work)
11def run_in_thread(fn):
12 ctx = copy_context()
13 with ThreadPoolExecutor() as pool:
14 future = pool.submit(ctx.run, fn)
15 return future.result()
Node.js: Workers and child processes
AsyncLocalStorage propagates across the event loop, but not to Worker threads. Propagate manually:
1import { Worker } from 'node:worker_threads';
2import { getMDC } from './mdc';
3
4function runInWorker(scriptPath: string) {
5 const mdc = getMDC();
6 const worker = new Worker(scriptPath, {
7 workerData: { traceId: mdc.traceId, userId: mdc.userId },
8 });
9 return worker;
10}
11
12// Inside the worker:
13import { workerData } from 'node:worker_threads';
14// workerData.traceId contains the parent's trace
Integration with Observability Tools
MDC integrates with the entire observability ecosystem, regardless of language:
| Tool | Java (Spring Boot) | Python | Node.js |
|---|---|---|---|
| ELK Stack | LogstashEncoder | python-json-logger | winston + JSON format |
| Datadog | dd-trace-java (auto) | ddtrace (auto) | dd-trace-js (auto) |
| OpenTelemetry | otel-java-agent | opentelemetry-python | @opentelemetry/node |
| Grafana Loki | Labels from MDC | Labels from contextvars | Labels from store |
| AWS CloudWatch | JSON structured logs | aws-lambda-powertools | @aws-lambda-powertools |
Example: JSON logging for ELK in each language
1// Java: logback with LogstashEncoder
2// pom.xml: net.logstash.logback:logstash-logback-encoder
3// Output: {"timestamp":"...","level":"INFO","traceId":"a1b2c3","message":"..."}
1# Python: python-json-logger
2from pythonjsonlogger import jsonlogger
3
4handler = logging.StreamHandler()
5handler.setFormatter(jsonlogger.JsonFormatter(
6 '%(asctime)s %(levelname)s %(trace_id)s %(user_id)s %(name)s %(message)s'
7))
8# Output: {"asctime":"...","levelname":"INFO","trace_id":"a1b2c3","message":"..."}
1// Node.js: winston with JSON
2import winston from 'winston';
3import { getMDC } from './mdc';
4
5const logger = winston.createLogger({
6 format: winston.format.combine(
7 winston.format((info) => ({ ...info, ...getMDC() }))(),
8 winston.format.json(),
9 ),
10 transports: [new winston.transports.Console()],
11});
12// Output: {"level":"info","traceId":"a1b2c3","userId":"42","message":"..."}
Best Practices
- Always clean up context:
MDC.clear()in Java,.reset(token)in Python, automatic in Node.js (exits.run()scope) - Don't store sensitive data in MDC (passwords, tokens, unencrypted PII)
- Use consistent key names across services: if one uses
traceId, all should usetraceId - Limit the number of keys: 3-5 are enough (traceId, userId, tenantId, sessionId)
- JSON in production so ELK/Datadog/Loki can parse fields automatically
- Standard headers:
X-Trace-Id,X-Request-Id, or W3C Trace Context - Test cleanup: verify context is empty after each request
Conclusion
MDC transforms your logs from a chaotic stream into a traceable narrative. The pattern is the same in all three languages: intercept the request → inject context → propagate between services → clean up at the end.
Whether with SLF4J + Logback in Spring Boot, contextvars in Python, or AsyncLocalStorage in Node.js, a few lines of configuration automatically enrich every log with traceability. Combined with ELK, Datadog, or Grafana, MDC becomes the backbone of microservices observability.
Implement it today — your future self, debugging a production error at 3 AM, will thank you.
Comments
Sign in to leave a comment
No comments yet. Be the first!