Cristhian Villegas
Backend15 min read3 views

Improve Your Logs and Traceability with MDC in Spring Boot, Python and Node.js

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.

Servers in a data center representing microservices infrastructure

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.

ConceptDescription
Thread-Local StorageEach thread has its own copy of the MDC, preventing collisions between concurrent requests
Key-ValueStores pairs like traceId=abc123, userId=42, sessionId=xyz
TransparentOnce configured, every log.info() automatically includes MDC values without changing code
PropagableCan be propagated between microservices via HTTP headers
📌 Analogy: MDC is like a hospital wristband. Once you put it on the patient (request), every doctor, nurse, or lab (service, method, layer) can read the information without asking "who is this patient?" every time.

Equivalents in Each Language

LanguageMechanismLibrary / API
JavaThreadLocal (native MDC)SLF4J + Logback / Log4j2
Pythoncontextvars (PEP 567)logging + custom Filter
Node.jsAsyncLocalStoragenode:async_hooks (built-in)

Why Is It Important for Traceability?

Without MDC, your logs look like this:

bash
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:

bash
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

Monitoring dashboard with charts and metrics representing observability

Source: Luke Chesser — Unsplash

Implementation in Spring Boot (Java / SLF4J + Logback)

Spring Framework logo

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

java
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

xml
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)

java
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}
🔐 Critical: Always call 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)

Python logo

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

python
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

python
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)

python
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

python
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)
💡 Tip: In Python, 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)

JavaScript code representing the Node.js ecosystem

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

typescript
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

typescript
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)

typescript
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

typescript
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});
💡 Tip: 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:

  1. The API Gateway generates the initial traceId and puts it in the X-Trace-Id header
  2. Each service reads the header, puts it in its MDC, and propagates it in outgoing calls
  3. All logs from all services share the same traceId

Network of connections representing communication between microservices

Source: NASA — Unsplash

With the propagation implemented in the previous sections, a purchase flow generates logs like this across all three services:

bash
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.

java
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():

python
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:

typescript
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:

ToolJava (Spring Boot)PythonNode.js
ELK StackLogstashEncoderpython-json-loggerwinston + JSON format
Datadogdd-trace-java (auto)ddtrace (auto)dd-trace-js (auto)
OpenTelemetryotel-java-agentopentelemetry-python@opentelemetry/node
Grafana LokiLabels from MDCLabels from contextvarsLabels from store
AWS CloudWatchJSON structured logsaws-lambda-powertools@aws-lambda-powertools

Example: JSON logging for ELK in each language

java
1// Java: logback with LogstashEncoder
2// pom.xml: net.logstash.logback:logstash-logback-encoder
3// Output: {"timestamp":"...","level":"INFO","traceId":"a1b2c3","message":"..."}
python
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":"..."}
typescript
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 use traceId
  • 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.

Share:
CV

Cristhian Villegas

Software Engineer specializing in Java, Spring Boot, Angular & AWS. Building scalable distributed systems with clean architecture.

Comments

Sign in to leave a comment

No comments yet. Be the first!

Related Articles