Skip to main content

JavaScript/Node.js Observability

This guide covers implementing all four observability pillars in Node.js applications, with examples for Express.

Logging

Use Winston or Pino for structured JSON logging.

Winston

npm install winston
const winston = require('winston');

const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: {
service: 'my-service',
version: process.env.npm_package_version
},
transports: [
new winston.transports.Console()
]
});

// Usage
logger.info('User logged in', { userId: '123', email: 'user@example.com' });
logger.error('Database connection failed', { error: err.message, stack: err.stack });

Pino

npm install pino
const pino = require('pino');

const logger = pino({
level: process.env.LOG_LEVEL || 'info',
formatters: {
level: (label) => ({ level: label })
},
base: {
service: 'my-service',
version: process.env.npm_package_version
}
});

// Usage
logger.info({ userId: '123', action: 'login' }, 'User logged in');
logger.error({ err: error }, 'Operation failed');

Express Middleware

const { randomUUID } = require('crypto');

// Request logging middleware
app.use((req, res, next) => {
const requestId = req.headers['x-request-id'] || randomUUID();
const startTime = Date.now();

// Create request-scoped logger
req.log = logger.child({ requestId, path: req.path, method: req.method });
req.requestId = requestId;

res.setHeader('x-request-id', requestId);

// Log on response finish
res.on('finish', () => {
req.log.info({
statusCode: res.statusCode,
durationMs: Date.now() - startTime
}, 'Request completed');
});

next();
});

// In route handlers
app.get('/api/users/:id', (req, res) => {
req.log.info({ userId: req.params.id }, 'Fetching user');
// ...
});

Metrics

Use prom-client for Prometheus metrics.

Installation

npm install prom-client

Configuration

const promClient = require('prom-client');

// Enable default metrics (memory, CPU, etc.)
promClient.collectDefaultMetrics({
prefix: 'nodejs_'
});

// Custom metrics
const httpRequestsTotal = new promClient.Counter({
name: 'http_requests_total',
help: 'Total HTTP requests',
labelNames: ['method', 'path', 'status']
});

const httpRequestDuration = new promClient.Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP request latency',
labelNames: ['method', 'path'],
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5]
});

const activeConnections = new promClient.Gauge({
name: 'active_connections',
help: 'Number of active connections'
});

Express Middleware

// Metrics middleware
app.use((req, res, next) => {
const start = Date.now();
let connectionClosed = false;
activeConnections.inc();

const recordMetrics = () => {
if (connectionClosed) return;
connectionClosed = true;

const duration = (Date.now() - start) / 1000;
// Use route template (including baseUrl) to avoid high-cardinality metrics
// Fall back to a bounded constant for unmatched requests
const path = req.route?.path
? `${req.baseUrl || ''}${req.route.path}`
: 'unmatched';

httpRequestsTotal.labels(req.method, path, res.statusCode).inc();
httpRequestDuration.labels(req.method, path).observe(duration);
activeConnections.dec();
};

res.on('finish', recordMetrics);
res.on('close', recordMetrics); // Handle early disconnects

next();
});

// Metrics endpoint
app.get('/metrics', async (req, res) => {
res.set('Content-Type', promClient.register.contentType);
res.end(await promClient.register.metrics());
});

Tracing

Use OpenTelemetry for distributed tracing.

Installation

npm install @opentelemetry/sdk-node \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-grpc \
@opentelemetry/resources \
@opentelemetry/semantic-conventions

Configuration

// tracing.js - Must be loaded before other modules
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-grpc');
const { Resource } = require('@opentelemetry/resources');
const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');

const sdk = new NodeSDK({
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'my-service',
[SemanticResourceAttributes.SERVICE_VERSION]: process.env.npm_package_version
}),
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://otel-collector:4317'
}),
instrumentations: [
getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-fs': { enabled: false }
})
]
});

sdk.start()
.then(() => console.log('Tracing initialized'))
.catch((err) => console.error('Failed to initialize tracing', err));

// Graceful shutdown
process.on('SIGTERM', () => {
sdk.shutdown()
.then(() => console.log('Tracing shut down'))
.catch((err) => console.error('Error shutting down tracing', err))
.finally(() => process.exit(0));
});

Load tracing first in your application:

// index.js
require('./tracing'); // Must be first!
const express = require('express');
// ...

Manual Instrumentation

const { trace, SpanStatusCode } = require('@opentelemetry/api');

const tracer = trace.getTracer('my-service');

async function processOrder(orderId) {
return tracer.startActiveSpan('processOrder', async (span) => {
span.setAttribute('order.id', orderId);

try {
await tracer.startActiveSpan('validateOrder', async (validateSpan) => {
await validateOrder(orderId);
validateSpan.end();
});

await tracer.startActiveSpan('processPayment', async (paymentSpan) => {
paymentSpan.setAttribute('payment.method', 'card');
await processPayment(orderId);
paymentSpan.end();
});

span.setStatus({ code: SpanStatusCode.OK });
} catch (error) {
span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
span.recordException(error);
throw error;
} finally {
span.end();
}
});
}

Connecting Logs and Traces

const { trace } = require('@opentelemetry/api');

function getTraceContext() {
const span = trace.getActiveSpan();
if (span) {
const context = span.spanContext();
return {
traceId: context.traceId,
spanId: context.spanId
};
}
return {};
}

// Use with Winston
const loggerWithTrace = {
info: (message, meta = {}) => {
logger.info(message, { ...meta, ...getTraceContext() });
},
error: (message, meta = {}) => {
logger.error(message, { ...meta, ...getTraceContext() });
}
};

Health Checks

Implement health check endpoints for Kubernetes probes.

Basic Implementation

const express = require('express');
const app = express();

const startTime = Date.now();

// Liveness - is the process running?
app.get('/health/live', (req, res) => {
res.json({ status: 'ok' });
});

// Readiness - can we handle traffic?
app.get('/health/ready', async (req, res) => {
const checks = {};

// Check database
try {
await db.query('SELECT 1');
checks.database = { status: 'healthy' };
} catch (err) {
checks.database = { status: 'unhealthy', error: err.message };
return res.status(503).json({ status: 'unhealthy', checks });
}

// Check Redis
try {
await redis.ping();
checks.redis = { status: 'healthy' };
} catch (err) {
checks.redis = { status: 'unhealthy', error: err.message };
return res.status(503).json({ status: 'unhealthy', checks });
}

res.json({ status: 'healthy', checks });
});

// Comprehensive health
app.get('/health', async (req, res) => {
const uptime = Math.floor((Date.now() - startTime) / 1000);

const dbCheck = await checkDatabase();
const redisCheck = await checkRedis();

const allHealthy = dbCheck.status === 'healthy' && redisCheck.status === 'healthy';

res.status(allHealthy ? 200 : 503).json({
status: allHealthy ? 'healthy' : 'unhealthy',
version: process.env.npm_package_version,
uptime_seconds: uptime,
checks: {
database: dbCheck,
redis: redisCheck
}
});
});

async function checkDatabase() {
const start = Date.now();
try {
await db.query('SELECT 1');
return {
status: 'healthy',
latency_ms: Date.now() - start
};
} catch (err) {
return {
status: 'unhealthy',
error: err.message
};
}
}

async function checkRedis() {
const start = Date.now();
try {
await redis.ping();
return {
status: 'healthy',
latency_ms: Date.now() - start
};
} catch (err) {
return {
status: 'unhealthy',
error: err.message
};
}
}

Terminus (Graceful Shutdown)

npm install @godaddy/terminus
const { createTerminus } = require('@godaddy/terminus');
const http = require('http');

const server = http.createServer(app);

createTerminus(server, {
signal: 'SIGTERM',
healthChecks: {
'/health/live': () => Promise.resolve(),
'/health/ready': async () => {
await db.query('SELECT 1');
await redis.ping();
}
},
onSignal: async () => {
console.log('Server is shutting down');
await db.close();
await redis.quit();
},
onShutdown: () => {
console.log('Cleanup complete');
}
});

server.listen(8080);

Complete Example

// index.js - Entry point (load tracing first!)
require('./tracing');
require('./app');
// app.js
const express = require('express');
const pino = require('pino');
const promClient = require('prom-client');
const { trace } = require('@opentelemetry/api');

const app = express();
const logger = pino({ level: 'info' });
const startTime = Date.now();

// Metrics
promClient.collectDefaultMetrics();
const httpRequests = new promClient.Counter({
name: 'http_requests_total',
help: 'Total HTTP requests',
labelNames: ['method', 'path', 'status']
});

// Request middleware
app.use((req, res, next) => {
const start = Date.now();
req.log = logger.child({ requestId: req.headers['x-request-id'] });

res.on('finish', () => {
const duration = Date.now() - start;
const span = trace.getActiveSpan();

req.log.info({
method: req.method,
path: req.path,
status: res.statusCode,
durationMs: duration,
traceId: span?.spanContext()?.traceId
}, 'Request completed');

// Use route template (including baseUrl) to avoid high-cardinality metrics
const route = req.route?.path
? `${req.baseUrl || ''}${req.route.path}`
: 'unmatched';
httpRequests.labels(req.method, route, res.statusCode).inc();
});

next();
});

// Health endpoints
app.get('/health/live', (req, res) => res.json({ status: 'ok' }));
app.get('/health/ready', async (req, res) => {
try {
await db.query('SELECT 1');
res.json({ status: 'healthy' });
} catch (err) {
res.status(503).json({ status: 'unhealthy', error: err.message });
}
});

// Metrics endpoint
app.get('/metrics', async (req, res) => {
res.set('Content-Type', promClient.register.contentType);
res.end(await promClient.register.metrics());
});

// Application routes
app.get('/api/orders', async (req, res) => {
req.log.info('Fetching orders');
const orders = await orderService.list();
res.json(orders);
});

app.listen(8080, () => {
logger.info({ port: 8080 }, 'Server started');
});