Tracing & Performance

Correlation IDs in Microservices Logging: A Practical Guide

How to generate, propagate, and log correlation IDs across services — middleware patterns, header conventions, and how they relate to trace IDs.

An error lands in your error tracker. Service D failed. But the stack trace only shows what happened inside D — it doesn't tell you what services A, B, and C were doing when they handled the same request. You need to find every log line from that one request across all four services, but your logs are spread across multiple systems, and a million other requests happened at roughly the same time. Without a correlation ID, that's nearly impossible. With one, it takes seconds.

A correlation ID is a unique identifier attached to a request as it flows through your microservices architecture, threading through logs from every service that touches it. It's the practical glue that lets you answer "what happened to this one request?" when debugging production issues.

For a deep dive into how correlation IDs fit into the bigger picture of tracing, see trace IDs vs span IDs. This guide focuses on the implementation — how to generate, propagate, and log correlation IDs in a way that actually works in production.

Generate or Adopt at the Edge

Every request needs a correlation ID before it touches your first service. You have two options: the client generates one and sends it, or your first backend service generates one. In practice, accepting an inbound ID (if present) and falling back to generating a new one gives you the best of both worlds.

The critical detail most implementations miss: validate and sanitize the inbound ID. An untrusted client ID can introduce log injection attacks or break your logging pipeline.

// Node.js: Express middleware that generates or adopts a correlation ID
import { v4 as uuidv4 } from 'uuid';

const CORRELATION_ID_HEADER = 'x-correlation-id';
const MAX_ID_LENGTH = 64;

app.use((req, res, next) => {
  // Accept an inbound correlation ID (e.g., from a browser or upstream service)
  let correlationId = req.headers[CORRELATION_ID_HEADER];

  if (correlationId) {
    // Validate: must be alphanumeric + hyphens only, under 64 chars
    // This prevents log injection and prevents unbounded storage
    if (!/^[a-z0-9\-]{1,64}$/i.test(correlationId)) {
      correlationId = null; // Invalid; generate a new one
    }
  }

  // If no valid inbound ID, generate a new one
  if (!correlationId) {
    correlationId = uuidv4();
  }

  req.correlationId = correlationId;
  res.setHeader(CORRELATION_ID_HEADER, correlationId);
  next();
});
// Java: Spring Boot filter that does the same
import org.springframework.stereotype.Component;
import jakarta.servlet.Filter;
import java.util.UUID;
import java.util.regex.Pattern;

@Component
public class CorrelationIdFilter implements Filter {
  private static final String HEADER = "x-correlation-id";
  private static final Pattern VALID_ID = Pattern.compile("^[a-z0-9\\-]{1,64}$", Pattern.CASE_INSENSITIVE);

  @Override
  public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) 
      throws IOException, ServletException {
    HttpServletRequest http = (HttpServletRequest) req;
    HttpServletResponse httpRes = (HttpServletResponse) res;
    
    String correlationId = http.getHeader(HEADER);
    
    if (correlationId == null || !VALID_ID.matcher(correlationId).matches()) {
      correlationId = UUID.randomUUID().toString();
    }
    
    httpRes.setHeader(HEADER, correlationId);
    // Store in thread-local context (see next section)
    MDC.put("correlationId", correlationId);
    
    try {
      chain.doFilter(req, res);
    } finally {
      MDC.remove("correlationId");
    }
  }
}

The validation regex is strict: alphanumeric plus hyphens, under 64 characters. This prevents log injection (where an attacker embeds newlines or control characters in the ID) and protects against unbounded string allocation.

Store It in Ambient Context

Once you have a correlation ID, every component in your service needs access to it — but you can't pass it as a parameter to every function. That's where ambient context (thread-local or async-local storage) comes in. This is a core pattern in context propagation for tracing, and it's equally essential for logging.

Node.js: AsyncLocalStorage

In Node.js, AsyncLocalStorage from the async_hooks module propagates context across async boundaries. A logger or middleware can later read it without needing the ID passed as an argument.

import { AsyncLocalStorage } from 'async_hooks';

const correlationIdStorage = new AsyncLocalStorage();

// Set it in middleware
app.use((req, res, next) => {
  let correlationId = req.headers['x-correlation-id'] || uuidv4();
  correlationIdStorage.run(correlationId, () => next());
});

// Any code inside the request can read it
function getCorrelationId() {
  return correlationIdStorage.getStore();
}

// Example: a database query function that shouldn't need to accept the ID
async function queryUser(userId) {
  const correlationId = getCorrelationId();
  logger.info(`Querying user ${userId}`, { correlationId });
  return db.query('SELECT * FROM users WHERE id = ?', [userId]);
}

Java: SLF4J MDC (Mapped Diagnostic Context)

SLF4J's MDC is the standard way to attach context in Java. It stores data in a thread-local map that loggers automatically include in every log line (if configured to do so).

import org.slf4j.MDC;

public class UserService {
  private static final Logger logger = LoggerFactory.getLogger(UserService.class);

  public User getUser(String userId) {
    String correlationId = MDC.get("correlationId");
    logger.info("Fetching user: {}", userId);
    // The correlationId is automatically included in the log output
    return repository.findById(userId);
  }
}

MDC and async boundaries: SLF4J's MDC is thread-local and does NOT propagate across thread pool boundaries or reactive pipelines (like Project Reactor or Spring WebFlux). If you spawn a new thread or use an executor, you must manually copy the MDC into the new context. For reactive code, use context propagation libraries or copy the context before switching threads.

Python: contextvars

Python's contextvars module provides a similar mechanism for async code.

from contextvars import ContextVar
import logging

correlation_id_var = ContextVar('correlation_id', default=None)

class CorrelationIdFilter(logging.Filter):
  def filter(self, record):
    record.correlation_id = correlation_id_var.get()
    return True

# In a middleware or request handler
correlation_id_var.set(request_correlation_id)

# Loggers configured with %(correlation_id)s will pick it up automatically
logger.info("Processing request")  # Will include correlation_id in output

Inject It Into Every Log Line

Storing the correlation ID in ambient context is only half the solution. Your logger must be configured to automatically include it in every line it writes. This is a cornerstone of structured logging best practices — don't pass it manually to every log call, configure it once at the logger level.

// Node.js Winston logger with correlation ID in every line
import winston from 'winston';
import { AsyncLocalStorage } from 'async_hooks';

const correlationIdStorage = new AsyncLocalStorage();

const logger = winston.createLogger({
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.printf(({ timestamp, level, message }) => {
      const correlationId = correlationIdStorage.getStore() || 'no-id';
      return `${timestamp} [${correlationId}] ${level}: ${message}`;
    })
  ),
  transports: [new winston.transports.Console()]
});

// Output:
// 2026-07-14T10:22:14.123Z [a1b2c3d4] info: User created
// 2026-07-14T10:22:14.234Z [a1b2c3d4] info: Email queued
// Java Logback configuration with MDC
<!-- logback.xml -->
<configuration>
  <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>
        %d{ISO8601} [%mdc{correlationId}] %-5level %logger - %msg%n
      </pattern>
    </encoder>
  </appender>
  <root level="INFO">
    <appender-ref ref="CONSOLE" />
  </root>
</configuration>

// Output:
// 2026-07-14T10:22:14.123Z [a1b2c3d4] INFO  UserService - User created
// 2026-07-14T10:22:14.234Z [a1b2c3d4] INFO  EmailService - Email queued

Now every log line carries the correlation ID automatically. When you tail logs or search a log aggregator, you filter by correlation ID and get the complete story of that one request across all services.

Propagate It Outbound

Ambient context works within a single service, but when your code makes an HTTP call or publishes to a message queue, you must explicitly attach the correlation ID to the outgoing request or message.

HTTP Clients

Set the correlation ID in the request header when calling downstream services.

// Node.js: Axios interceptor
import axios from 'axios';
import { AsyncLocalStorage } from 'async_hooks';

const correlationIdStorage = new AsyncLocalStorage();

axios.interceptors.request.use((config) => {
  const correlationId = correlationIdStorage.getStore();
  if (correlationId) {
    config.headers['x-correlation-id'] = correlationId;
  }
  return config;
});

// Every downstream call automatically includes the header
const response = await axios.get('https://inventory-service/stock/sku-123');
// Java: Spring RestTemplate interceptor
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.slf4j.MDC;

restTemplate.getInterceptors().add((request, body, execution) -> {
  String correlationId = MDC.get("correlationId");
  if (correlationId != null) {
    request.getHeaders().add("x-correlation-id", correlationId);
  }
  return execution.execute(request, body);
});

// Every downstream call includes the header

Message Queues

For asynchronous work, attach the correlation ID to message metadata or headers so async consumers continue the same trace.

// Node.js: Publish a message to RabbitMQ with correlation ID
const correlationId = correlationIdStorage.getStore();

channel.sendToQueue('email-queue', Buffer.from(messageBody), {
  headers: {
    'x-correlation-id': correlationId
  }
});
// Java: Publish to Kafka with correlation ID
String correlationId = MDC.get("correlationId");
ProducerRecord<String, String> record = new ProducerRecord<>(
  "email-topic", 
  message
);
if (correlationId != null) {
  record.headers().add("x-correlation-id", correlationId.getBytes());
}
producer.send(record);

When the consumer picks up the message, it should extract the correlation ID from headers and set it in MDC/AsyncLocalStorage before processing.

Return It to the Caller

Make the correlation ID visible to your users and developers so they can provide it when reporting issues.

// Express: Return correlation ID in response header
app.use((req, res, next) => {
  const correlationId = req.correlationId;
  res.setHeader('x-correlation-id', correlationId);
  res.setHeader('x-request-id', correlationId); // Some clients expect X-Request-ID
  next();
});

// Client receives the header and can store it
const correlationId = response.headers['x-correlation-id'];
sessionStorage.setItem('lastCorrelationId', correlationId);

Include it in error pages and API error responses so users see it when something fails:

{
  "error": "Payment processing failed",
  "message": "Your request could not be processed. Reference ID: a1b2c3d4-e5f6-7g8h-9i0j-k1l2m3n4o5p6",
  "correlationId": "a1b2c3d4-e5f6-7g8h-9i0j-k1l2m3n4o5p6"
}

When a customer or developer reports an issue and includes that ID, your team can immediately pull all related logs and traces.

Getting It Wrong (Common Mistakes)

Regenerating the ID per hop. Each service generates its own ID instead of propagating the inbound one. Result: every service appears to handle a different request, and you can't stitch them together.

Logging it inconsistently. Some services include it, others don't. Result: you have a partial story and blame the service that didn't log it.

Dropping it in async work. A request triggers a background job, but the job doesn't inherit the correlation ID. Result: logs from the job appear unrelated to the original request.

Using a sequential or guessable ID. UUIDs or high-entropy IDs are essential. Predictable IDs leak information and make it trivial for attackers to forge requests and pollute logs.

Not validating inbound IDs. Blindly trust a client-provided ID and you're open to log injection and denial-of-service attacks. See sensitive data scrubbing for related security concerns in logs.

Correlation IDs and Tracing Together

In a distributed tracing system like LightTrace, errors captured via Sentry SDKs already carry trace IDs and span IDs. You can attach a correlation ID as a tag or context field and cross-reference it with cross-project traces for the waterfall view. This approach fits naturally into microservices observability — it gives you both log context (for searching logs across systems) and tracing context (for understanding timing and dependencies).

Note: LightTrace does not manage or search logs — but it provides the trace IDs and tags that let you correlate errors with your logging system.

Start tracking errors in minutes

Implement correlation IDs in your services, then use LightTrace to see the full request lifecycle and error context in one view.

Correlation IDs are simple in concept but profound in practice. A single ID threaded through logs turns a distributed system from opaque into debuggable. Generate it at the edge, store it in ambient context, inject it into every log, propagate it outbound, and return it to the caller. When the next production incident happens, you'll have exactly what you need.

Fix your next production error faster

Point any Sentry SDK at LightTrace — free up to 5,000 events/month.