Why Structured Logging Matters
Why Structured Logging Matters
Unstructured log lines like console.log('User logged in') are nearly useless at scale. Structured logging emits machine-readable JSON objects, making logs searchable, filterable, and analyzable by tools like ELK, Datadog, or CloudWatch Insights.
Problems with Unstructured Logs
- Hard to query: Grepping for specific fields is error-prone and slow
- No consistent schema: Different developers log different formats
- Missing context: No request ID, user ID, or timestamp precision
What Structured Logging Looks Like
// Bad - unstructured
console.log(`Order ${orderId} created for user ${userId}`);
// Good - structured
logger.info('Order created', {
orderId: 'ord_abc123',
userId: 'usr_xyz789',
amount: 29.99,
currency: 'USD',
requestId: 'req_001'
});
Benefits
- Searchability: Query logs by any field (e.g.,
userId:usr_xyz789) - Aggregation: Count errors by endpoint, user, or error type
- Alerting: Trigger alerts on specific structured fields
- Correlation: Trace a request across microservices via correlation IDs
Winston: Configuration and Transports
Winston: Configuration and Transports
Winston is the most widely used Node.js logging library. It supports multiple transports (console, file, HTTP, MongoDB), custom formats, and level-based filtering.
Basic Setup
import winston from 'winston';
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: {
service: 'order-service',
environment: process.env.NODE_ENV
},
transports: [
new winston.transports.Console(),
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
maxsize: 10 * 1024 * 1024, // 10MB
maxFiles: 5
}),
new winston.transports.File({
filename: 'logs/combined.log',
maxsize: 50 * 1024 * 1024,
maxFiles: 10
})
]
});
Custom Transports
import { Transport, TransportStreamOptions } from 'winston';
class ElasticsearchTransport extends Transport {
constructor(opts: TransportStreamOptions) {
super(opts);
}
async log(info: any, callback: () => void) {
await fetch('http://elasticsearch:9200/logs/_doc', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(info)
});
callback();
}
}
Environment-Specific Configuration
function createLogger(env: string) {
const baseConfig = {
level: env === 'production' ? 'warn' : 'debug',
format: env === 'production'
? winston.format.combine(
winston.format.timestamp(),
winston.format.json()
)
: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
};
return winston.createLogger(baseConfig);
}
Pino: High-Performance Logging
Pino: High-Performance Logging
Pino is significantly faster than Winston (up to 5x) because it serializes JSON synchronously without buffering. It outputs newline-delimited JSON, which is ideal for container environments where stdout is aggregated by the platform.
Basic Setup
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
formatters: {
level(label) {
return { level: label };
}
},
timestamp: pino.stdTimeFunctions.isoTime,
redact: ['req.headers.authorization', 'req.body.password'],
base: {
service: 'payment-service',
version: '1.2.0'
}
});
logger.info({ userId: 'usr_123', amount: 99.99 }, 'Payment processed');
// Output: {"level":"info","time":"2024-01-15T10:30:00.000Z","service":"payment-service","version":"1.2.0","userId":"usr_123","amount":99.99,"msg":"Payment processed"}
Child Loggers for Context
// Create a child logger with request context
const reqLogger = logger.child({
requestId: 'req_abc123',
userId: 'usr_xyz789',
endpoint: 'POST /api/orders'
});
reqLogger.info('Processing order');
// All subsequent logs from this child include requestId, userId, and endpoint
reqLogger.warn({ inventory: 3 }, 'Low inventory');
reqLogger.error({ error: err }, 'Order failed');
Pino Transports (pino-transport)
import pino from 'pino';
const transport = pino.transport({
targets: [
{
target: 'pino/file',
options: { destination: '/var/log/app.log', mkdir: true }
},
{
target: 'pino-elasticsearch',
options: {
node: 'http://elasticsearch:9200',
index: 'app-logs'
}
}
]
});
const logger = pino({ level: 'info' }, transport);
Winston vs Pino
| Feature | Winston | Pino |
|---|---|---|
| Performance | ~10k ops/sec | ~50k+ ops/sec |
| Transport ecosystem | Large | Growing |
| Browser support | Yes | Limited |
| Child loggers | Yes | Yes (more efficient) |
| TypeScript | Good | Excellent |
Request Context and Correlation IDs
Request Context and Correlation IDs
In microservices and even monoliths, a single user request may touch multiple services, databases, and queues. Correlation IDs let you trace the entire journey of a request across all components.
Middleware for Request IDs
import { v4 as uuidv4 } from 'uuid';
import { Request, Response, NextFunction } from 'express';
import pino from 'pino';
const logger = pino({ level: 'info' });
function requestLogger(req: Request, res: Response, next: NextFunction) {
const requestId = req.headers['x-request-id'] as string || uuidv4();
const startTime = Date.now();
req.log = logger.child({
requestId,
method: req.method,
path: req.path,
userAgent: req.headers['user-agent']
});
res.setHeader('X-Request-Id', requestId);
req.log.info('Request received');
res.on('finish', () => {
req.log.info({
statusCode: res.statusCode,
duration: Date.now() - startTime
}, 'Request completed');
});
next();
}
Propagating Context Across Services
// When calling another service, forward the correlation ID
async function callPaymentService(order: Order, requestId: string) {
const response = await fetch('http://payment-service/charge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Request-Id': requestId // Propagate correlation
},
body: JSON.stringify(order)
});
return response.json();
}
AsyncLocalStorage for Automatic Context
import { AsyncLocalStorage } from 'async_hooks';
const asyncLocalStorage = new AsyncLocalStorage<Map<string, any>>();
// Every async call within this context automatically has access to the store
asyncLocalStorage.run(new Map([['requestId', 'req_abc']]), async () => {
await processOrder(); // requestId available without passing explicitly
await updateInventory();
await sendConfirmation();
});
// Inside any function in the chain
function log(message: string) {
const store = asyncLocalStorage.getStore();
const requestId = store?.get('requestId');
console.log(JSON.stringify({ requestId, message }));
}
Best Practices
- Generate a unique request ID at the edge (API gateway or first service)
- Propagate the ID in
X-Request-Idheader across all service calls - Store the ID in AsyncLocalStorage for automatic context propagation
- Include the ID in every log line for easy correlation
Log Levels and Severity Strategy
Log Levels and Severity Strategy
Choosing the right log level is critical. Misuse creates noise (everything logged as info) or hides critical issues (errors logged as warn).
Standard Levels
| Level | When to Use | Example |
|---|---|---|
error |
Unrecoverable failure requiring immediate attention | Database connection lost, payment processing failed |
warn |
Unexpected but handled condition | Rate limit approaching, deprecated API called |
info |
Important business events | User signed up, order placed, job completed |
debug |
Diagnostic info for developers | SQL query executed, cache hit/miss, request payload |
trace |
Extremely verbose, frame-by-frame details | Variable state at each step, every function entry/exit |
Configuration by Environment
const logLevels: Record<string, string> = {
development: 'debug',
staging: 'info',
production: 'warn',
test: 'silent'
};
const logger = pino({
level: logLevels[process.env.NODE_ENV] || 'info'
});
Structured Error Logging
// Never log errors as strings - preserve the stack trace
try {
await processPayment(order);
} catch (err) {
// Bad: loses stack trace
logger.error(`Payment failed: ${err.message}`);
// Good: preserves full context
logger.error({
err: err instanceof Error ? err : new Error(String(err)),
orderId: order.id,
amount: order.total,
paymentMethod: order.paymentMethod
}, 'Payment processing failed');
}
Dynamic Level Adjustment
// Runtime level adjustment for debugging production issues
app.post('/admin/log-level', (req, res) => {
const { level } = req.body;
if (['fatal', 'error', 'warn', 'info', 'debug', 'trace'].includes(level)) {
logger.level = level;
logger.info({ newLevel: level }, 'Log level changed');
res.json({ success: true });
} else {
res.status(400).json({ error: 'Invalid log level' });
}
});
Security and Privacy in Logging
Security and Privacy in Logging
Logs often contain sensitive data. A single misconfigured log line can leak PII, credentials, or tokens, leading to compliance violations (GDPR, HIPAA) or security breaches.
Data You Should Never Log
- Passwords, API keys, secrets, or tokens
- Credit card numbers or SSNs
- Full request/response bodies without sanitization
- Personal health information (PHI)
Redaction with Pino
import pino from 'pino';
const logger = pino({
redact: [
'req.headers.authorization',
'req.headers.cookie',
'req.body.password',
'req.body.creditCard',
'user.ssn',
'*.token', // Redact any nested 'token' field
'*.secret' // Redact any nested 'secret' field
],
redact: {
censor: '[REDACTED]',
remove: true // Completely removes the field instead of censoring
}
});
Winston Sanitization
const sanitizeFormat = winston.format((info) => {
const sensitiveFields = ['password', 'token', 'ssn', 'creditCard'];
for (const field of sensitiveFields) {
if (info[field]) {
info[field] = '[REDACTED]';
}
}
return info;
});
const logger = winston.createLogger({
format: winston.format.combine(
sanitizeFormat(),
winston.format.json()
)
});
Audit Logging
// Separate audit log for compliance (append-only, immutable)
const auditLogger = pino({
level: 'info'
}, pino.destination({
dest: '/var/log/audit.log',
sync: true // Synchronous for audit trail integrity
}));
function auditLog(action: string, userId: string, details: Record<string, any>) {
auditLogger.info({
audit: true,
action,
userId,
timestamp: new Date().toISOString(),
ip: details.ip,
resource: details.resource
});
}
// Usage
auditLog('user.login', 'usr_123', { ip: '192.168.1.1', resource: '/api/auth' });
auditLog('data.export', 'usr_123', { ip: '192.168.1.1', resource: '/api/users' });
Quiz
1. Why is structured logging preferred over console.log() in production applications?
2. What is the primary performance advantage of Pino over Winston?
3. When logging errors in a try/catch block, which approach preserves the most diagnostic information?
Flashcards
Question
What are the standard log levels and when should each be used?
Click to reveal answer
Answer
error: unrecoverable failures requiring immediate attention. warn: unexpected but handled conditions. info: important business events (order placed, user signed up). debug: diagnostic info for developers. trace: extremely verbose, frame-by-frame details.
Question
How do you propagate a correlation ID across microservice calls?
Click to reveal answer
Answer
Generate a unique request ID at the edge (API gateway or first service), pass it in the X-Request-Id header with every inter-service HTTP call, and use AsyncLocalStorage to make it available to all async functions without explicit parameter passing.
Question
What sensitive data should be redacted from logs and how do you implement redaction in Pino?
Click to reveal answer
Answer
Never log passwords, API keys, tokens, credit card numbers, SSNs, or PII. In Pino, use the 'redact' option with field paths (e.g., 'req.headers.authorization', '*.token') to censor or remove sensitive fields before serialization.
Revision Notes
Key Takeaways
- 1. Structured logging emits JSON objects with named fields, enabling machine-queryable logs for debugging, alerting, and analytics
- 2. Pino is faster than Winston due to synchronous JSON serialization without buffering, making it ideal for high-throughput production systems
- 3. Always propagate correlation IDs (X-Request-Id) across services and use AsyncLocalStorage for automatic context propagation in async code
- 4. Use appropriate log levels: error for unrecoverable failures, warn for handled anomalies, info for business events, debug for developer diagnostics
- 5. Never log sensitive data - use redaction patterns for passwords, tokens, PII, and credentials in all log output
Interview Tips
- • Explain why you would choose Pino over Winston for a high-traffic service (performance, synchronous serialization, lower memory usage)
- • Describe how you would implement request tracing across 3+ microservices (correlation ID generation, header propagation, AsyncLocalStorage)
- • Walk through how you would debug a production issue using structured logs (filter by requestId, aggregate by error type, trace timeline)
- • Explain your strategy for log levels in development vs production and how to dynamically adjust levels for debugging
Cheat Sheet
Winston: createLogger with transports (Console, File), formats (timestamp, json, errors), custom transports. Pino: pino() with redact, formatters, child loggers, transport targets. Request context: generate UUID at edge, pass via X-Request-Id header, use AsyncLocalStorage for implicit propagation. Error logging: always pass error object (not string), include business context fields. Security: redact passwords, tokens, PII using field path patterns. Log rotation: use maxsize/maxFiles on File transports or rely on platform log aggregation.