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.
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:
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.
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:
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.
When a request triggers calls to multiple services (or microservices), correlation IDs let you trace the full journey in logs:
// src/middleware/requestId.ts import { randomUUID } from 'crypto';
export function requestId(req, res, next) { // Accept correlation ID from upstream (API gateway, frontend) or generate one req.requestId = req.headers['x-request-id'] as string ?? randomUUID(); res.setHeader('x-request-id', req.requestId); next(); }
Pass the correlation ID when making outbound service calls:
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.
// src/utils/context.ts import { AsyncLocalStorage } from 'async_hooks';
export const requestContext = new AsyncLocalStorage<RequestContext>();
export function getRequestId(): string | undefined { return requestContext.getStore()?.requestId; }
// src/middleware/requestId.ts import { randomUUID } from 'crypto'; import { requestContext } from '../utils/context.js';
export function requestId(req, res, next) { const id = (req.headers['x-request-id'] as string) ?? randomUUID(); req.requestId = id; res.setHeader('x-request-id', id);
// Everything downstream runs inside this callback and can read the // store without requestId being threaded through every signature requestContext.run({ requestId: id, userId: req.user?.id }, () => next()); }
// src/services/payments.service.ts — no requestId parameter anymore import { getRequestId } from '../utils/context.js';
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.
A naive Docker build copies node_modules including all dev dependencies, making the image hundreds of megabytes unnecessarily. Multi-stage builds fix this.
# Dockerfile
# ─── Stage 1: Build ────────────────────────────────────────────────────────── FROM node:22-alpine AS builder
WORKDIR /app
# Install ALL dependencies (including devDependencies for TypeScript) COPY package*.json ./ RUN npm ci
# Copy source and compile COPY tsconfig.json ./ COPY src ./src RUN npm run build # outputs to /app/dist
# ─── Stage 2: Production image ─────────────────────────────────────────────── FROM node:22-alpine AS production
WORKDIR /app
# Create non-root user — never run Node.js as root RUN addgroup -g 1001 nodejs && \ adduser -S -u 1001 -G nodejs nodeuser
# Install only production dependencies COPY package*.json ./ RUN npm ci --omit=dev && npm cache clean --force
# Copy compiled output from builder stage — no TypeScript source COPY --from=builder /app/dist ./dist
# Set ownership RUN chown -R nodeuser:nodejs /app USER nodeuser
# Document the port (doesn't actually publish it) EXPOSE 3000
# Use exec form — PID 1 gets SIGTERM, enables graceful shutdown CMD ["node", "dist/index.js"]
// src/index.ts import app from './app.js'; import prisma from './db/prisma.js'; import redis from './db/redis.js'; import logger from './utils/logger.js'; import { env } from './config/env.js'; // Same NodeSDK instance instrumentation.js started at process boot — imported // here (not re-created) so this file can sequence its shutdown, see the // OpenTelemetry section below for why the SDK doesn't manage its own SIGTERM. import { sdk } from '../instrumentation.js';
async function shutdown(signal: string) { logger.info({ signal }, 'Shutdown signal received');
// Stop accepting new connections server.close(async () => { try { // Close database connections cleanly await prisma.$disconnect(); redis.disconnect(); // Flush any in-flight spans before exiting. This is one step of the // same sequence as the rest of shutdown — not a second, independent // SIGTERM handler racing this one to decide who exits the process first. await sdk.shutdown(); logger.info('Graceful shutdown complete'); process.exit(0); } catch (err) { logger.error({ err }, 'Error during shutdown'); process.exit(1); } });
// Force exit if graceful shutdown hangs setTimeout(() => { logger.error('Forced shutdown after timeout'); process.exit(1); }, 10_000); }
docker-compose up -d # start in background docker-compose logs -f app # tail logs docker-compose down # stop docker-compose down -v # stop and wipe data
pm2 start ecosystem.config.js --env production pm2 save # persist config — restart on system reboot pm2 startup # generate startup script for your OS pm2 status # check running processes pm2 logs myapp # tail logs pm2 reload myapp # zero-downtime reload (cluster mode) pm2 monit # real-time CPU/memory dashboard
Cluster mode vs worker threads: PM2 cluster mode forks multiple Node.js processes, each with their own event loop. This saturates all CPU cores. For CPU-bound work within a single request, worker threads (covered in the Architect phase) are the right tool. For most APIs (I/O bound), PM2 cluster mode is sufficient.
The ecosystem.config.js above writes straight to out_file/error_file with no rotation — left alone, those files grow forever until they fill the disk. pm2-logrotate is a PM2 module that rotates and compresses them automatically:
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M # rotate once a file hits 50MB pm2 set pm2-logrotate:retain 14 # keep 14 rotated files pm2 set pm2-logrotate:compress true # gzip rotated files pm2 set pm2-logrotate:rotateInterval '0 0 * * *' # also rotate daily at midnight
Confirm it's active with pm2 module:list. This should be configured before the first production deploy, not discovered after a disk-full incident takes the app down.
steps: - name: Deploy to production uses: appleboy/ssh-action@v1 with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} script: | set -e docker pull ghcr.io/${{ github.repository }}:latest
# Start the new version on a staging port — the old container # keeps serving live traffic on :3000, untouched, while this boots docker run -d \ --name myapp-next \ --restart unless-stopped \ -p 3001:3000 \ --env-file /etc/myapp/.env \ ghcr.io/${{ github.repository }}:latest
# Require the new container to pass its own health check before # it's allowed anywhere near the old one. If the new image is # broken, this fails here — the old container is never stopped, # so a bad deploy produces no outage at all, only a failed job. healthy=false for i in $(seq 1 15); do if curl -sf http://localhost:3001/health > /dev/null 2>&1; then healthy=true break fi sleep 2 done
if [ "$healthy" != "true" ]; then echo "myapp-next failed its health check — aborting deploy, old container untouched" docker rm -f myapp-next exit 1 fi
# The new image is now proven healthy. Retire the old container, # free :3000, and rebind the same already-pulled, already-verified # image to it. Docker cannot remap a running container's published # port, so this last step is still a brief restart — but it is # restarting a known-good image instead of gambling on an # unverified one, which was the actual danger in the original # stop -> rm -> run script (a broken image took the whole app # down with no way back). docker stop myapp || true docker rm myapp || true docker rm -f myapp-next docker run -d \ --name myapp \ --restart unless-stopped \ -p 3000:3000 \ --env-file /etc/myapp/.env \ ghcr.io/${{ github.repository }}:latest
A second caveat, on --env-file /etc/myapp/.env: this is fine for local development, but P-6 (Configuration, Security Hardening) is explicit that production secrets shouldn't live in a flat file on the deploy target — fetch them from a dedicated secrets manager (Vault, AWS Secrets Manager, etc.) at container startup instead, and reserve --env-file for genuinely non-sensitive configuration. The script above is written for clarity about the blue/green mechanics, not as a template for how secrets should actually reach the container.
Honest caveat: this script minimizes risk — the old container is never touched until the new image proves it can pass its own health check, so a broken build fails the deploy job instead of taking production down. It does not achieve true zero-downtime: the final docker run still has to boot a fresh Node process bound to :3000, and the graceful-shutdown code above only shortens that gap, it doesn't close it. Closing it fully requires a reverse proxy (nginx/Caddy) in front of both containers, with its upstream switched from :3000 to :3001before the old container is stopped — real blue/green, with no shared port that has to be handed off. That's the fix once even a few hundred milliseconds of downtime per deploy is unacceptable; this script is the safe middle ground for a bare-VM setup without one.
Pino produces JSON logs with near-zero overhead. Never use console.log in production code. redact only does something if you actually log an object shaped like its configured paths.
Child loggers (logger.child({ requestId })) attach context to every log line in a scope without repetition.
Correlation IDs, propagated via AsyncLocalStorage rather than threaded through every function signature, make distributed debugging possible — but ALS context doesn't cross process/queue boundaries on its own; a job payload still needs the ID written in explicitly.
Metrics (prom-client, RED/USE, /metrics) are the third observability pillar alongside logs and traces — aggregate health that logs and traces alone can't show.
Multi-stage Docker builds separate the TypeScript compile step from the production image. Result: ~150MB images instead of ~800MB.
Graceful shutdown — one coordinated SIGTERM handler that closes the server, drains connections, disconnects databases, and flushes OTel spans in sequence, not two competing handlers racing each other.
docker-compose — one command starts Postgres, Redis, and your app for local development with hot reload.
PM2 cluster mode — saturates all CPU cores with independent Node.js processes. Zero-downtime reload with pm2 reload; pair it with pm2-logrotate so logs don't fill the disk.
GitHub Actions — test → build Docker image → push to registry → SSH deploy on every push to main, health-checking the new container before retiring the old one. Note this still isn't true zero-downtime without a reverse proxy in front — see the deploy step's caveat.
Next: WebSockets and real-time communication — the upgrade from HTTP to WebSocket, broadcasting events to connected clients, presence tracking, and scaling WebSockets across multiple servers with Redis Pub/Sub.
Pino logs with correlation IDs are necessary. They're not sufficient. When a request to your Express API takes 800ms and you don't know if the time was spent in a database query, an external HTTP call, or application logic, you're debugging blind. Distributed traces tell you exactly where the time went.
OpenTelemetry (OTel) is the industry-standard observability framework — vendor-neutral, supported by every major APM tool (Datadog, New Relic, Jaeger, Grafana Tempo, Honeycomb). Set it up once, export to any backend.
// instrumentation.js — must be loaded BEFORE anything else import { NodeSDK } from '@opentelemetry/sdk-node' import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node' import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
// Exported (not just created and started) so src/index.ts can await its // shutdown as one step of the app's own coordinated shutdown sequence. export const sdk = new NodeSDK({ serviceName: process.env.SERVICE_NAME ?? 'api-server', traceExporter: new OTLPTraceExporter({ url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? 'http://localhost:4318/v1/traces', }), instrumentations: [ getNodeAutoInstrumentations({ '@opentelemetry/instrumentation-fs': { enabled: false }, // too noisy '@opentelemetry/instrumentation-pg': { enhancedDatabaseReporting: true, // includes SQL in span attributes }, }), ], })
sdk.start()
// Deliberately no `process.on('SIGTERM', ...)` here. A second, independent // SIGTERM handler in this file would race the application's own shutdown // handler in src/index.ts — both firing on the same signal, with no // guarantee spans are flushed before the other handler calls // `process.exit()`. Instead, `sdk` is exported so the shutdown sequence // shown earlier in this module (`src/index.ts`) can call `await sdk.shutdown()` // itself, after the HTTP server has already stopped accepting new work — // one handler, one ordered sequence, instead of two racing each other.
Start your application with the instrumentation file loaded:
node --import ./instrumentation.js src/index.js # or in package.json scripts: "start": "node --import ./instrumentation.js src/index.js"
After this single change, every Express request gets a trace with child spans for every database query, Redis operation, and outbound HTTP call. No application code changes.
The span appears in your trace waterfall between the database query that loaded the order and the Redis operation that caches the result. You can see exactly how long payment processing takes, independently of everything around it.
Logs answer "what happened" for one event. Traces answer "where did the time go" for one request. Neither answers "how is the system doing right now, in aggregate" — that's what metrics are for: numeric time series, cheap to store and query, the natural fit for dashboards and alerts. "Observability basics" isn't complete without this third pillar alongside logs and traces.
Two frameworks for deciding what to expose:
RED (request-driven services): Rate — requests/sec, Errors — failed requests/sec, Duration — latency distribution. Answers "is the API healthy right now?"
USE (resources): Utilization — percent time busy, Saturation — queue depth or work waiting, Errors — error count. Answers "is the CPU, connection pool, or event loop the bottleneck?"
prom-client is the standard library for exposing Prometheus-format metrics from Node.js:
npm install prom-client
// src/utils/metrics.ts import client from 'prom-client';
Point Prometheus (or a managed equivalent — Grafana Cloud, Datadog's Prometheus scraper) at /metrics and you get RED dashboards and alerting (rate(http_requests_total{status_code=~"5.."}[5m]) > 0.01) with no further code changes. Gate /metrics behind network-level access control (internal-only ingress) — it's an operational endpoint, not a public one.
At around 2K requests per second, logging every request at info level is fine: a few thousand JSON lines a second is well within what Pino, a log shipper, and an aggregator absorb without issue. Near 50K requests per second, logging every single request becomes expensive in its own right: network egress, ingestion cost, and query latency all scale with line count.
Two separate levers — don't conflate them:
Sampling (for logs) — log every warn/error, but only a fraction of info-level successes (1-in-100, or 100% for the first few minutes after a deploy). Make the decision deterministic per request (hash the request ID) rather than random per line — otherwise a specific request's logs end up half-present, referencing lines that were dropped.
Cardinality (for metrics) — the number of distinct values a label can take. route is a fixed, small set of path patterns and is safe on a metric label. userId or requestId is unbounded — put those in logs and traces, where a value only needs to be searchable, never in a metric label, where every distinct value becomes a permanent new time series.
Knowledge Check
Why is structured logging (e.g., using Pino to output JSON) strongly preferred over standard console.log in production environments?
What is the primary benefit of using a multi-stage Docker build for a Node.js TypeScript application?
What does OpenTelemetry (OTel) auto-instrumentation provide for a Node.js application without requiring any changes to your business logic code?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.