Module P-8·25 min read

Structured logging with Pino, correlation IDs, multi-stage Docker builds, docker-compose for local dev, PM2 cluster mode, and GitHub Actions CI/CD.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

Module P-8 — Logging, Observability Basics, and Deployment

What this module covers: console.log doesn't scale. When your API handles thousands of requests per second across multiple servers, you need structured logs you can query, correlation IDs that link a request across every log line, metrics that tell you how the system is doing in aggregate, and a deployment pipeline that gets code to production without manual steps. This module covers structured logging with Pino, AsyncLocalStorage-based correlation IDs, RED/USE application metrics with prom-client, multi-stage Docker builds that produce lean production images, docker-compose for local development, PM2 for running Node.js processes in production (with log rotation), and a GitHub Actions pipeline that tests, builds, and deploys on every push to main.


Why Structured Logging

console.log('User logged in: ' + userId) produces a string. You can't query it, you can't filter it, you can't aggregate it. Structured logging produces JSON:

json

Now you can run WHERE msg = 'User logged in' AND durationMs > 500 to find slow logins. You can group by userId to see all activity for a user. You can set up alerts on level = 'error'.

Structured logs are console.log wearing a lab coat and carrying a barcode scanner — every entry can be scanned, sorted, and cross-referenced instead of read one at a time under a flashlight.


Pino: Fast Structured Logging

Pino is the fastest JSON logger for Node.js. It is designed to minimise work on the hot path — log entries are serialised asynchronously.

bash

Logger singleton

typescript

Using the logger

typescript

Seeing Redaction Actually Work

The redact config on the logger above targets req.headers.authorization, body.password, and body.passwordHash — but that only does anything if a logged object is actually shaped that way. Logging the request and body on a login attempt is a realistic place this matters:

typescript
json

email passes through untouched — only the paths named in redact.paths are censored. Without logging something shaped like { req, body }, the redact config is dead configuration that never runs against a matching field.


HTTP Request Logging Middleware

Log every incoming request with timing:

typescript
typescript

Development output (pino-pretty):

text

Correlation IDs Across Services

When a request triggers calls to multiple services (or microservices), correlation IDs let you trace the full journey in logs:

typescript

Pass the correlation ID when making outbound service calls:

typescript

Now grep requestId=f8a2e9c1 in your log aggregator shows every log line from every service for a single user request.

AsyncLocalStorage: Correlation IDs Without Manual Threading

callPaymentService(orderId, requestId) above works, but every function between the request handler and that call has to accept and forward a parameter that has nothing to do with its actual job. Add a second cross-cutting value (tenant ID, the acting user's ID for audit logs) and business logic starts carrying "plumbing" parameters it shouldn't need to know exist.

AsyncLocalStorage (built into Node's async_hooks module) fixes this: a value is attached to the current async execution context once, and anything called within that context — controllers, services, repositories, outbound fetch calls, arbitrarily deep — can read it back without it ever being passed as an argument.

typescript
typescript
typescript

The logger can read from the same store, so a request-scoped logger no longer needs requestId passed in explicitly either:

typescript

One important limit: AsyncLocalStorage context only survives within a single process's async call chain. It does not automatically cross a network hop or a job queue boundary — a BullMQ job runs in a separate worker process with its own (empty) ALS context, so the correlation ID has to be explicitly written into the job payload when enqueuing, then re-established with requestContext.run(...) at the top of the worker's processor function.

Production story: a UPI settlement flow crossed three hops — the merchant-facing API, a simulated NPCI switch call, and a BullMQ job that reconciled the settlement afterward. The correlation ID was threaded manually as a function parameter through the first two hops, but the code that enqueued the reconciliation job passed only the settlement payload — nobody remembered the request ID needed to travel with it too. When a settlement timed out, there was no single ID to grep for across all three log streams; the on-call engineer reconstructed what happened by eyeballing timestamps across the API logs, the switch simulator's logs, and the queue worker's logs, trying to guess which lines belonged to the same transaction. The fix wasn't AsyncLocalStorage by itself — it was making the correlation ID part of the job payload contract and re-entering requestContext.run() inside the worker, so the same ID that started the request was still attached to every log line the reconciliation job produced.


Multi-Stage Docker Build

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.