You Don't Have a Logging Problem. You Have an Observability Problem.
Structured logging with Pino, metrics with Prometheus, and error tracking with Sentry — the three pillars that turn a 2-hour outage into an 8-minute fix
Senior Developer

Every Node.js app has console.log in it. Most production apps have a lot of console.log in them. And when something breaks at 2 AM, developers find themselves scrolling through thousands of lines of unstructured text, grepping for clues, and hoping the error message is somewhere in there.
This is not a logging problem. It is an observability problem — the difference between knowing your app is running and understanding what it is doing.
Observability has three pillars: logs, metrics, and traces. This guide covers all three, practically, for a Node.js app on a VPS. No Kubernetes required. No $500/month observability platform required.
Pillar 1 — Structured Logging with Pino
console.log outputs strings. Strings are hard to search, filter, or aggregate. Structured logging outputs JSON — machine-readable lines that your logging infrastructure can query, filter, and alert on.
Pino is the fastest Node.js logging library and the one most production teams use.
npm install pino pino-pretty// src/lib/logger.js
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
// In production: output raw JSON
// In development: pretty-print for readability
transport: process.env.NODE_ENV !== 'production'
? {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'HH:MM:ss',
ignore: 'pid,hostname',
},
}
: undefined,
// Base fields added to every log line
base: {
service: 'your-app-name',
version: process.env.APP_VERSION || 'unknown',
env: process.env.NODE_ENV,
},
// Redact sensitive fields — they will show as [Redacted] in logs
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'body.password',
'body.token',
'*.creditCard',
'*.ssn',
],
censor: '[Redacted]',
},
});
export default logger;Usage — structured, not string-concatenated:
import logger from '../lib/logger.js';
// Bad — unstructured, unsearchable
console.log('User ' + userId + ' logged in from ' + ip);
// Good — structured, filterable, correlated
logger.info({ userId, ip, action: 'login' }, 'User logged in');
// With error details
logger.error({
userId,
error: err.message,
stack: err.stack,
context: 'payment-processing',
}, 'Payment failed');Now you can query your logs: "show me all login events from this IP" or "show me all payment failures in the last hour" — in milliseconds.
Pillar 2 — Request Logging and Correlation IDs
Every request should produce a log entry with a unique correlation ID. When a user reports a problem, you ask them for their request ID and pull up everything that happened during that request — across every service.
npm install uuid// src/middleware/requestLogger.js
import { randomUUID } from 'crypto';
import logger from '../lib/logger.js';
export function requestLogger(req, res, next) {
const requestId = req.headers['x-request-id'] || randomUUID();
const startTime = Date.now();
// Attach request ID to request and response
req.id = requestId;
res.setHeader('X-Request-ID', requestId);
// Create a child logger with request context
// Every log from this request will include these fields
req.log = logger.child({
requestId,
method: req.method,
url: req.originalUrl,
ip: req.ip,
userAgent: req.headers['user-agent'],
});
req.log.info('Request started');
// Log on response finish
res.on('finish', () => {
const duration = Date.now() - startTime;
const level = res.statusCode >= 500 ? 'error'
: res.statusCode >= 400 ? 'warn'
: 'info';
req.log[level]({
statusCode: res.statusCode,
duration,
contentLength: res.getHeader('content-length'),
}, 'Request completed');
});
next();
}In your route handlers, use req.log instead of the base logger so the request context is included automatically:
router.post('/orders', authenticate, async (req, res) => {
req.log.info({ userId: req.user.id }, 'Creating order');
try {
const order = await createOrder(req.body, req.user.id);
req.log.info({ orderId: order.id, total: order.total }, 'Order created');
res.status(201).json(order);
} catch (err) {
req.log.error({ error: err.message, stack: err.stack }, 'Order creation failed');
res.status(500).json({ error: 'Order failed', requestId: req.id });
}
});When the user calls support and says "my order failed", you have their requestId. One query finds every log line from that request.
Pillar 3 — Metrics with Prometheus
Metrics answer the question: "How is my system behaving right now and over time?" Logs tell you what happened. Metrics tell you the shape of what is happening.
npm install prom-client// src/lib/metrics.js
import client from 'prom-client';
// Collect default Node.js metrics (memory, CPU, event loop lag, etc.)
client.collectDefaultMetrics({
prefix: 'app_',
labels: { service: 'your-app-name' },
});
// HTTP request duration histogram
export const httpRequestDuration = new client.Histogram({
name: 'app_http_request_duration_seconds',
help: 'HTTP request duration in seconds',
labelNames: ['method', 'route', 'status_code'],
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5],
});
// Active connections gauge
export const activeConnections = new client.Gauge({
name: 'app_active_connections',
help: 'Number of active HTTP connections',
});
// Business metrics — track what matters to your product
export const ordersCreated = new client.Counter({
name: 'app_orders_created_total',
help: 'Total orders created',
labelNames: ['status'],
});
export const queueDepth = new client.Gauge({
name: 'app_queue_depth',
help: 'Number of jobs waiting in queue',
labelNames: ['queue'],
});
export const registry = client.register;// Middleware to record request metrics
export function metricsMiddleware(req, res, next) {
const start = Date.now();
res.on('finish', () => {
const duration = (Date.now() - start) / 1000;
const route = req.route?.path || req.path || 'unknown';
httpRequestDuration
.labels(req.method, route, res.statusCode)
.observe(duration);
});
next();
}// Expose metrics endpoint for Prometheus to scrape
// Protect this endpoint — metrics can reveal system internals
router.get('/metrics',
basicAuth({ users: { prometheus: process.env.METRICS_PASSWORD } }),
async (req, res) => {
res.set('Content-Type', registry.contentType);
res.end(await registry.metrics());
}
);// Record business metrics in your handlers
import { ordersCreated } from '../lib/metrics.js';
router.post('/orders', authenticate, async (req, res) => {
try {
const order = await createOrder(req.body, req.user.id);
ordersCreated.labels('success').inc();
res.status(201).json(order);
} catch (err) {
ordersCreated.labels('failed').inc();
res.status(500).json({ error: 'Order failed' });
}
});The Prometheus + Grafana Stack on a VPS
Add this to your docker-compose.yml:
prometheus:
image: prom/prometheus:latest
container_name: prometheus
restart: unless-stopped
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.retention.time=30d'
networks:
- backend
# Not exposed publicly — only accessible via Grafana
grafana:
image: grafana/grafana:latest
container_name: grafana
restart: unless-stopped
ports:
- "3001:3000" # Accessible at yourdomain.com:3001 (or behind Nginx)
environment:
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD}
GF_USERS_ALLOW_SIGN_UP: "false"
volumes:
- grafana_data:/var/lib/grafana
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning
depends_on:
- prometheus
networks:
- backend
volumes:
prometheus_data:
grafana_data:monitoring/prometheus.yml:
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'your-app'
static_configs:
- targets: ['app:3000']
metrics_path: '/metrics'
basic_auth:
username: 'prometheus'
password: 'your-metrics-password'Pillar 4 — Error Tracking
Logs and metrics tell you things are broken. Error tracking tells you exactly what broke, how many users were affected, and whether it is getting worse.
Sentry is the standard choice. The setup takes five minutes:
npm install @sentry/node// src/lib/sentry.js — initialize before anything else
import * as Sentry from '@sentry/node';
if (process.env.SENTRY_DSN) {
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
release: process.env.APP_VERSION,
// Only send 20% of transactions to stay on the free tier
tracesSampleRate: 0.2,
// Filter out noise
ignoreErrors: [
'TokenExpiredError', // Expected auth errors
'NotFoundError', // Expected 404s
],
});
}
export { Sentry };// In your Express app — Sentry handlers must be in specific positions
import { Sentry } from './lib/sentry.js';
// Must be first middleware
app.use(Sentry.Handlers.requestHandler());
// Your routes
app.use('/api', apiRouter);
// Must be before your error handler
app.use(Sentry.Handlers.errorHandler());
// Your error handler
app.use((err, req, res, next) => {
req.log?.error({ error: err.message, sentry_id: res.sentry }, 'Unhandled error');
res.status(500).json({
error: 'Internal server error',
requestId: req.id,
});
});Add user context so errors are linked to affected users:
// In your authenticate middleware, after verifying the token:
Sentry.setUser({ id: req.user.id });What Good Observability Looks Like in Practice
When something breaks in production, here is the workflow:
Alert fires — Prometheus detects error rate above 1% for 5 minutes and fires a webhook to your Slack
Check the dashboard — Grafana shows the spike started at 14:32, affects the
/ordersendpoint, latency jumped from 50ms to 3sCheck Sentry — A new error appeared at 14:32:
Connection pool exhaustedinorder.service.js:47Pull the logs — Filter by
url: /orders AND level: errorsince 14:30, find 10 request IDsTrace one request — Pull logs for one
requestId, see the full timeline of what happened during that requestRoot cause — A deploy at 14:30 introduced a missing
awaitin a transaction, leaving connections open
Total time from alert to root cause: 8 minutes. Without structured logs, metrics, and error tracking: 2 hours and a lot of grep.
The Quick Start Checklist
// In order of setup priority:
// 1. Structured logging — replaces console.log
import logger from './lib/logger.js';
// 2. Request ID middleware — correlation across services
app.use(requestLogger);
// 3. Metrics middleware
app.use(metricsMiddleware);
// 4. Sentry error tracking
app.use(Sentry.Handlers.requestHandler());
// 5. Your routes
app.use('/api', router);
// 6. Sentry error handler
app.use(Sentry.Handlers.errorHandler());
// 7. Your error handler
app.use(errorHandler);Start with structured logging and request IDs. Add Prometheus and Grafana when you have more than one service or when on-call debugging starts taking more than 30 minutes. Add Sentry from day one — it is free for small volumes and pays for itself the first time it catches a bug before a user reports it.
Comments (0)
Login to post a comment.