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');
});