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
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.
Log Rotation with pm2-logrotate
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:
bash
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.
GitHub Actions CI/CD Pipeline
A pipeline that runs on every push: type-check → test → build Docker image → push to registry → deploy:
yaml
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.
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. 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.
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.
Application Metrics: The Third Pillar
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:
bash
typescript
The same res.on('finish', ...) hook that already drives HTTP request logging can feed metrics too:
typescript
typescript
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.
Log Volume and Cardinality at Scale
Near the low end of this course's advertised range (2K TPS), 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 the high end (50K TPS), 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.
// src/utils/logger.tsimport pino from'pino';import{ env }from'../config/env.js';const logger =pino({ level: env.LOG_LEVEL??'info',// In development: pretty-print for readability// In production: raw JSON for log aggregators (Datadog, Loki, CloudWatch) transport: env.NODE_ENV==='development'?{ target:'pino-pretty', options:{ colorize:true, translateTime:'HH:MM:ss.l', ignore:'pid,hostname',},}:undefined,// Redact sensitive fields from all log entries redact:{ paths:['req.headers.authorization','body.password','body.passwordHash'], censor:'[REDACTED]',},// Base fields added to every log entry base:{ env: env.NODE_ENV, version: process.env.npm_package_version,},// Serialisers — control how objects are formatted serializers:{ err: pino.stdSerializers.err,// properly serialise Error objects req: pino.stdSerializers.req, res: pino.stdSerializers.res,},});exportdefault logger;
import logger from'../utils/logger.js';// Info — normal operationslogger.info({ userId:42, orderId:7},'Order created');// Warn — unexpected but recoverablelogger.warn({ attemptCount:3, ip: req.ip },'Failed login attempt');// Error — errors that need attentionlogger.error({ err, orderId:7},'Failed to send order confirmation email');// Debug — verbose, off in productionlogger.debug({ query: sql, params },'Executing query');// Child loggers — add context for a specific scopeconst requestLogger = logger.child({ requestId: req.requestId });requestLogger.info('Processing payment');requestLogger.info({ chargeId:'ch_xxx'},'Payment succeeded');// Both entries have requestId set automatically
// src/middleware/requestId.tsimport{ randomUUID }from'crypto';import{ requestContext }from'../utils/context.js';exportfunctionrequestId(req, res, next){const id =(req.headers['x-request-id']asstring)??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());}
# Dockerfile# ─── Stage 1: Build ──────────────────────────────────────────────────────────FROM node:22-alpine AS builderWORKDIR /app# Install ALL dependencies (including devDependencies for TypeScript)COPY package*.json ./RUN npm ci# Copy source and compileCOPY tsconfig.json ./COPY src ./srcRUN npm run build # outputs to /app/dist# ─── Stage 2: Production image ───────────────────────────────────────────────FROM node:22-alpine AS productionWORKDIR /app# Create non-root user — never run Node.js as rootRUN addgroup -g 1001 nodejs && \ adduser -S -u 1001 -G nodejs nodeuser# Install only production dependenciesCOPY package*.json ./RUN npm ci --omit=dev && npm cache clean --force# Copy compiled output from builder stage — no TypeScript sourceCOPY--from=builder /app/dist ./dist# Set ownershipRUN chown -R nodeuser:nodejs /appUSER nodeuser# Document the port (doesn't actually publish it)EXPOSE 3000# Use exec form — PID 1 gets SIGTERM, enables graceful shutdownCMD ["node", "dist/index.js"]
docker build -t myapp:latest .docker run -p3000:3000 --env-file .env myapp:latest
// src/index.tsimport 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';const server = app.listen(env.PORT,()=>{ logger.info({ port: env.PORT},'Server started');});asyncfunctionshutdown(signal:string){ logger.info({ signal },'Shutdown signal received');// Stop accepting new connections server.close(async()=>{try{// Close database connections cleanlyawait 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 hangssetTimeout(()=>{ logger.error('Forced shutdown after timeout'); process.exit(1);},10_000);}process.on('SIGTERM',()=>shutdown('SIGTERM'));process.on('SIGINT',()=>shutdown('SIGINT'));process.on('uncaughtException',(err)=>{ logger.fatal({ err },'Uncaught exception'); process.exit(1);});process.on('unhandledRejection',(reason)=>{ logger.fatal({ reason },'Unhandled promise rejection'); process.exit(1);});
# docker-compose.ymlversion:'3.9'services:app:build:context: .
target: builder # use the builder stage — includes ts-node for devports:-'3000:3000'environment:NODE_ENV: development
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/myapp_dev
REDIS_URL: redis://redis:6379JWT_ACCESS_SECRET: local-dev-secret-32-characters-minimum
JWT_REFRESH_SECRET: local-dev-refresh-32-characters-min
volumes:- ./src:/app/src # hot reload — changes reflected instantlycommand: npm run dev
depends_on:postgres:condition: service_healthy
redis:condition: service_healthy
postgres:image: postgres:16-alpine
environment:POSTGRES_DB: myapp_dev
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:-'5432:5432'volumes:- postgres_data:/var/lib/postgresql/data
healthcheck:test:['CMD-SHELL','pg_isready -U postgres']interval: 5s
timeout: 5s
retries:5redis:image: redis:7-alpine
ports:-'6379:6379'healthcheck:test:['CMD','redis-cli','ping']interval: 5s
timeout: 5s
retries:5volumes: postgres_data:
docker-compose up -d# start in backgrounddocker-compose logs -f app # tail logsdocker-compose down # stopdocker-compose down -v# stop and wipe data
npminstall-g pm2
// ecosystem.config.jsmodule.exports={apps:[{name:'myapp',script:'dist/index.js',instances:'max',// one process per CPU coreexec_mode:'cluster',// cluster mode — all processes share port 3000max_memory_restart:'500M',env_production:{NODE_ENV:'production',PORT:3000,},// Loggingout_file:'/var/log/myapp/out.log',error_file:'/var/log/myapp/error.log',merge_logs:true,log_date_format:'YYYY-MM-DD HH:mm:ss',},],};
pm2 start ecosystem.config.js --env production
pm2 save # persist config — restart on system rebootpm2 startup # generate startup script for your OSpm2 status # check running processespm2 logs myapp # tail logspm2 reload myapp # zero-downtime reload (cluster mode)pm2 monit # real-time CPU/memory dashboard
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M # rotate once a file hits 50MBpm2 set pm2-logrotate:retain 14# keep 14 rotated filespm2 set pm2-logrotate:compress true# gzip rotated filespm2 set pm2-logrotate:rotateInterval '0 0 * * *'# also rotate daily at midnight
# .github/workflows/deploy.ymlname: Test, Build, Deploy
on:push:branches:[main]pull_request:branches:[main]env:REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}jobs:test:runs-on: ubuntu-latest
services:postgres:image: postgres:16env:POSTGRES_DB: myapp_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:['5432:5432']options:--health-cmd pg_isready --health-interval 10s
redis:image: redis:7ports:['6379:6379']options:--health-cmd "redis-cli ping" --health-interval 10s
steps:-uses: actions/checkout@v4
-uses: actions/setup-node@v4
with:node-version:'22'cache:'npm'-run: npm ci
-name: Type check
run: npx tsc --noEmit
-name: Run tests
run: npm run test:ci
env:DATABASE_URL: postgresql://postgres:postgres@localhost:5432/myapp_test
REDIS_URL: redis://localhost:6379JWT_ACCESS_SECRET: ci-test-secret-at-least-32-characters
JWT_REFRESH_SECRET: ci-refresh-secret-at-least-32-characters
NODE_ENV: test
build-and-push:needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' # only on main branch pushespermissions:contents: read
packages: write
outputs:image-tag: ${{ steps.meta.outputs.tags }}steps:-uses: actions/checkout@v4
-name: Log in to container registry
uses: docker/login-action@v3
with:registry: ${{ env.REGISTRY }}username: ${{ github.actor }}password: ${{ secrets.GITHUB_TOKEN }}-name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}tags:| type=sha,prefix=sha-
type=raw,value=latest,enable=true-name: Build and push Docker image
uses: docker/build-push-action@v5
with:context: .
target: production
push:truetags: ${{ steps.meta.outputs.tags }}cache-from: type=gha
cache-to: type=gha,mode=max
deploy:needs: build-and-push
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
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
// instrumentation.js — must be loaded BEFORE anything elseimport{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.exportconst sdk =newNodeSDK({serviceName: process.env.SERVICE_NAME??'api-server',traceExporter:newOTLPTraceExporter({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.
node--import ./instrumentation.js src/index.js
# or in package.json scripts:"start":"node --import ./instrumentation.js src/index.js"
import{ trace, context }from'@opentelemetry/api'importpinofrom'pino'const baseLogger =pino({level:'info'})// Middleware that creates a request-scoped logger with trace contextapp.use((req, res, next)=>{const span = trace.getActiveSpan()const spanContext = span?.spanContext() req.log= baseLogger.child({traceId: spanContext?.traceId,spanId: spanContext?.spanId,requestId: req.headers['x-request-id'],})next()})// Use req.log instead of console.log in route handlersapp.get('/api/orders',async(req, res)=>{ req.log.info('Fetching orders')const orders =awaitgetOrders() req.log.info({count: orders.length},'Orders fetched') res.json(orders)})