Microservices turn one log stream into dozens. These seven patterns — correlation IDs, structured context, canonical logs, and more — make distributed debugging possible.
Monolith logging is simple: one application, one log file, one grep command. Microservices destroy this simplicity. A single user action triggers requests across 3-10 services, each with its own log stream, timestamp, and format.
Without deliberate logging patterns, debugging a production issue in a microservice architecture means opening 5 terminals, correlating timestamps by eye, and hoping the clocks are synchronized.
These seven patterns convert scattered microservice logs from "noise across 20 streams" into "a coherent story about what happened."
The most important pattern. Every request that enters the system gets a unique ID, and that ID is passed to every downstream service call.
// API Gateway — generate the ID
app.use((req, res, next) => {
req.traceId = req.headers['x-trace-id'] || crypto.randomUUID()
res.setHeader('x-trace-id', req.traceId)
next()
})
// Every log includes it
logger.info({ traceId: req.traceId, userId: 4821 }, 'Order placed')
// Downstream HTTP calls pass it along
await fetch('http://payment-service/charge', {
headers: { 'x-trace-id': req.traceId }
})
Now when a user reports "my order didn't go through," filter logs by traceId across all services and see the entire request flow: API gateway → order service → payment service → notification service.
Without correlation IDs: "Something failed somewhere between 14:23:01 and 14:23:03." With correlation IDs: "Request abc-123 failed at payment-service line 47: Stripe timeout after 5000ms."
Every service should emit structured JSON with consistent field names:
// Bad — impossible to filter or aggregate
logger.info(`User ${userId} placed order ${orderId} for $${amount}`)
// Good — every field is queryable
logger.info({
event: 'order_placed',
userId,
orderId,
amount,
currency: 'USD',
service: 'order-service',
traceId: req.traceId
})
Enforce a shared schema for common fields across all services:
| Field | Type | Required | Description |
|---|---|---|---|
timestamp |
ISO 8601 | Yes | When the event occurred |
level |
string | Yes | debug/info/warn/error/fatal |
service |
string | Yes | Which service emitted the log |
traceId |
string | Yes | Correlation ID from the gateway |
event |
string | Recommended | Machine-readable event name |
message |
string | Yes | Human-readable description |
Put this schema in a shared library. If services disagree on whether it's user_id or userId or uid, aggregation breaks.
For every inter-service call, log at the caller and the callee:
// Order service (caller)
logger.info({ traceId, targetService: 'payment-service', operation: 'charge' }, 'Calling payment service')
// ... HTTP call happens ...
logger.info({ traceId, targetService: 'payment-service', statusCode: 200, durationMs: 342 }, 'Payment service responded')
// Payment service (callee)
logger.info({ traceId, operation: 'charge', orderId }, 'Charge request received')
// ... processing ...
logger.info({ traceId, operation: 'charge', result: 'success', chargeId: 'ch_xxx' }, 'Charge completed')
This creates a complete timeline: when the call was initiated, how long it took, and what happened on both sides. When a call times out, the entry log without a matching exit log immediately reveals where the request got stuck.
Instead of 15 log lines per request, emit one comprehensive "canonical" log line at the end of each request that summarizes everything:
// After request completes:
logger.info({
event: 'request_completed',
traceId,
method: 'POST',
path: '/orders',
statusCode: 201,
durationMs: 456,
userId: 4821,
orderId: 'ORD-9912',
dbQueries: 3,
dbDurationMs: 89,
externalCalls: [
{ service: 'payment', statusCode: 200, durationMs: 342 },
{ service: 'notification', statusCode: 202, durationMs: 23 }
],
cacheHits: 2,
cacheMisses: 1
}, 'Request completed')
This one line contains enough information to answer most questions: Was it slow? Which downstream service caused the latency? How many database queries ran? Did the cache help?
Canonical log lines are not a replacement for detailed logging — they're a summary layer. Keep detailed debug logs for deep investigation, but use canonical lines for dashboards, alerting, and first-pass analysis.
A stack trace tells you where the code failed. It doesn't tell you why it failed in this specific case. Always log the input that caused the failure:
// Bad — stack trace only
logger.error({ err }, 'Payment failed')
// Good — context + stack trace
logger.error({
err,
orderId: 'ORD-9912',
userId: 4821,
amount: 149.99,
paymentProvider: 'stripe',
stripeErrorCode: 'card_declined',
cardLast4: '4242',
retryAttempt: 3,
traceId
}, 'Payment failed after 3 retries')
The second version answers: Which order? Which user? How much? Which provider? What was the specific error? How many times did it retry? This eliminates the "I need to reproduce it to understand it" loop.
Kubernetes health checks (/health, /ready) generate huge volumes of logs — often 30-40% of total log volume. They're almost never useful for debugging.
Options:
Option A — Don't log health checks at all:
app.get('/health', (req, res) => {
// No logging — this endpoint is called every 10 seconds
res.json({ status: 'ok' })
})
Option B — Log at debug level:
app.get('/health', (req, res) => {
logger.debug({ endpoint: 'health' }, 'Health check')
res.json({ status: 'ok' })
})
Option C — Use ingestion rules to drop them:
Configure the log management platform to drop or sample logs matching path:/health or path:/ready. This keeps the application code unchanged while reducing storage volume.
The volume savings are significant. A service with 10-second health check intervals generates 8,640 health check logs per day — per pod. With 5 pods, that's 43,200 logs/day of pure noise.
When a downstream service degrades, circuit breakers prevent cascade failures. Log every state transition — not every check, just the transitions:
circuitBreaker.on('open', () => {
logger.warn({
event: 'circuit_open',
targetService: 'payment-service',
failureCount: 5,
failureThreshold: 5,
resetTimeoutMs: 30000
}, 'Circuit breaker opened — payment-service calls will be rejected')
})
circuitBreaker.on('halfOpen', () => {
logger.info({
event: 'circuit_half_open',
targetService: 'payment-service'
}, 'Circuit breaker half-open — testing payment-service')
})
circuitBreaker.on('close', () => {
logger.info({
event: 'circuit_closed',
targetService: 'payment-service'
}, 'Circuit breaker closed — payment-service recovered')
})
These logs are rare but high-signal. A "circuit_open" event at 14:23 followed by "circuit_closed" at 14:31 tells the exact story: payment service was down for 8 minutes, the circuit breaker protected the system, and it recovered automatically.
A well-instrumented microservice request generates a log timeline like this:
14:23:01.001 [api-gateway] INFO Request received POST /orders traceId=abc-123
14:23:01.005 [order-service] INFO Order creation started traceId=abc-123 userId=4821
14:23:01.012 [order-service] INFO Calling payment-service traceId=abc-123
14:23:01.015 [payment-service] INFO Charge request received traceId=abc-123
14:23:01.342 [payment-service] INFO Charge completed traceId=abc-123 chargeId=ch_xxx
14:23:01.345 [order-service] INFO Payment confirmed traceId=abc-123 durationMs=333
14:23:01.350 [order-service] INFO Calling notification-svc traceId=abc-123
14:23:01.373 [notification-svc] INFO Email queued traceId=abc-123
14:23:01.380 [order-service] INFO Request completed traceId=abc-123 totalMs=375
14:23:01.382 [api-gateway] INFO Response sent traceId=abc-123 status=201
Filter by traceId=abc-123 in any log management tool and the entire request story is readable in seconds.
Distributed debugging is only as good as the logging patterns behind it. Correlation IDs, structured context, and canonical log lines turn 20 disconnected log streams into one coherent narrative. The tooling surfaces it — the patterns create it.
Free plan available. No credit card required. Up and running in 2 minutes.
Get started freeUnderstanding Log Levels: When to Use Debug, Info, Warn, Error, and Fatal
Log levels control what gets recorded and what gets ignored. Here's when to use each level correctly.
How to Keep Sensitive Data Out of Your Logs (PII, API Keys, Passwords)
Logging PII, API keys, or passwords creates compliance violations and security risks. Here's how to build automatic redaction.
ClickHouse vs Elasticsearch for Log Storage: Cost, Speed, and Trade-offs
ClickHouse uses 5-10x less storage and queries faster. Elasticsearch has better full-text search. Here's when to use each.