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.logdoesn'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:
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.
Logger singleton
Using the logger
HTTP Request Logging Middleware
Log every incoming request with timing:
Development output (pino-pretty):
Correlation IDs Across Services
When a request triggers calls to multiple services (or microservices), correlation IDs let you trace the full journey in logs:
Pass the correlation ID when making outbound service calls:
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.
Build and run:
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
docker-compose for Local Development
docker-compose runs all your local dependencies (Postgres, Redis) with one command:
PM2 for Production Process Management
When deploying to a bare VM or VPS (rather than a container orchestrator), PM2 manages your Node.js process:
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:
Repository secrets to configure
In GitHub → Settings → Secrets and variables → Actions:
DEPLOY_HOST— your server IP or hostnameDEPLOY_USER— SSH username (ubuntu,deploy, etc.)DEPLOY_SSH_KEY— private SSH key (the public key must be in~/.ssh/authorized_keyson the server)
.dockerignore
Prevent unnecessary files from being copied into the build context — this speeds up builds and prevents secrets from leaking into images:
Summary
- Pino produces JSON logs with near-zero overhead. Never use
console.login 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)
Start your application with the instrumentation file loaded:
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:
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:
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.
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 & RegisterDiscussion
0Join the discussion