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.

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, and a deployment pipeline that gets code to production without manual steps. This module covers structured logging with Pino, correlation IDs, multi-stage Docker builds that produce lean production images, docker-compose for local development, PM2 for running Node.js processes in production, 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'.


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

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.


Multi-Stage Docker Build

A naive Docker build copies node_modules including all dev dependencies, making the image hundreds of megabytes unnecessarily. Multi-stage builds fix this.

dockerfile

Build and run:

bash

Image size comparison:

  • Without multi-stage: ~800MB (with TypeScript, ts-node, test deps)
  • With multi-stage: ~150MB (only compiled JS + production deps)

Graceful shutdown in Node.js

typescript

docker-compose for Local Development

docker-compose runs all your local dependencies (Postgres, Redis) with one command:

yaml
bash

PM2 for Production Process Management

When deploying to a bare VM or VPS (rather than a container orchestrator), PM2 manages your Node.js process:

bash
javascript
bash

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.


GitHub Actions CI/CD Pipeline

A pipeline that runs on every push: type-check → test → build Docker image → push to registry → deploy:

yaml

Repository secrets to configure

In GitHub → Settings → Secrets and variables → Actions:

  • DEPLOY_HOST — your server IP or hostname
  • DEPLOY_USER — SSH username (ubuntu, deploy, etc.)
  • DEPLOY_SSH_KEY — private SSH key (the public key must be in ~/.ssh/authorized_keys on the server)

.dockerignore

Prevent unnecessary files from being copied into the build context — this speeds up builds and prevents secrets from leaking into images:

text

Summary

  • Pino produces JSON logs with near-zero overhead. Never use console.log in production code.
  • Child loggers (logger.child({ requestId })) attach context to every log line in a scope without repetition.
  • Correlation IDs set on every request, propagated to outbound calls, and included in every log — make distributed debugging possible.
  • Multi-stage Docker builds separate the TypeScript compile step from the production image. Result: ~150MB images instead of ~800MB.
  • Graceful shutdown — handle SIGTERM, close the server, drain connections, disconnect from databases. Required for zero-downtime deployments.
  • 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.
  • GitHub Actions — test → build Docker image → push to registry → SSH deploy on every push to main. Gate the deploy step behind test success.

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.


OpenTelemetry — Distributed Traces From Minute One

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.

Auto-Instrumentation — Zero Code Changes

The @opentelemetry/auto-instrumentations-node package automatically instruments:

  • Express (request spans, route attributes)
  • node-postgres / pg (query spans with SQL text)
  • ioredis (command spans)
  • http/https (outbound request spans)
  • dns (resolution spans)
  • fs (file operation spans — optional, noisy)
bash
javascript

Start your application with the instrumentation file loaded:

bash

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.

Correlating Trace IDs with Pino Logs

Logs and traces are separate. Connect them by injecting the active trace ID into every log line:

javascript

Now when you search for a trace ID in your log aggregator, you see every log line from that exact request alongside the spans.

Custom Spans for Business Logic

Auto-instrumentation covers I/O. For business logic timing (payment processing, report generation, complex calculations), add manual spans:

javascript

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.


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.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

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