Architecture

7 Logging Patterns for Microservices That Actually Help in Production

Microservices turn one log stream into dozens. These seven patterns — correlation IDs, structured context, canonical logs, and more — make distributed debugging possible.

LogFlow TeamAugust 16, 202610 min read

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

1. Correlation IDs Across Every Service

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

2. Structured Context, Not String Templates

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.

3. Entry and Exit Logs

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.

4. Canonical Log Lines

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.

5. Error Context That Includes the "Why"

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.

6. Health and Readiness Logging (Separated)

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.

7. Circuit Breaker State Transitions

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.

Putting It All Together

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.

Start monitoring your logs today

Free plan available. No credit card required. Up and running in 2 minutes.

Get started free