Structured Logging
Best practices for production-ready logging in Node.js applications using Pino and structured JSON output.
Philosophy
Logs are data, not text. Structured logging treats every log entry as a queryable data point, enabling powerful analysis, alerting, and debugging in production.
Three core principles:
- Machine-readable first: JSON structure enables programmatic querying
- Context-rich: Include all relevant metadata (correlation IDs, user IDs, request info)
- Security-conscious: Never log sensitive data (passwords, tokens, PII)
Why Pino
Pino is the recommended logging library for Node.js (2025):
- 5x faster than Winston: Minimal CPU overhead, async by default
- Structured JSON: Every log is a JSON object, no string templates
- Low latency: Critical for high-throughput applications
- Async transports: Heavy operations (file writes, network calls) happen in worker threads
- Child loggers: Easy context propagation
- Redaction built-in: Automatic sensitive data removal
Performance Comparison
| Library | Logs/Second | CPU Usage | Memory | |---------|-------------|-----------|---------| | Pino | 50,000+ | 2-4% | ~45MB | | Winston | ~10,000 | 10-15% | ~180MB | | Bunyan | ~15,000 | 8-12% | ~150MB |
Installation & Setup
# Install Pino and pretty-printing for development
pnpm add pino
pnpm add -D pino-pretty
Basic Configuration
// lib/logger.ts
import pino from 'pino'
const isDevelopment = process.env.NODE_ENV === 'development'
export const logger = pino({
level: process.env.LOG_LEVEL || 'info',
// Use pretty printing in development, JSON in production
transport: isDevelopment
? {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname',
},
}
: undefined,
// Base context included in every log
base: {
env: process.env.NODE_ENV || 'development',
revision: process.env.VERCEL_GIT_COMMIT_SHA || 'local',
},
// Format timestamps as ISO 8601
timestamp: pino.stdTimeFunctions.isoTime,
// Redact sensitive fields automatically
redact: {
paths: [
'password',
'passwordHash',
'secret',
'apiKey',
'token',
'accessToken',
'refreshToken',
'authorization',
'cookie',
'req.headers.authorization',
'req.headers.cookie',
'*.password',
'*.passwordHash',
'*.secret',
'*.apiKey',
'*.token',
],
censor: '[REDACTED]',
},
})
// Export type for use in application
export type Logger = typeof logger
Log Levels
Pino supports six log levels (from lowest to highest):
logger.trace('Extremely detailed debugging') // Level 10
logger.debug('Detailed debugging') // Level 20
logger.info('General information') // Level 30
logger.warn('Warning, non-critical issue') // Level 40
logger.error('Error, requires attention') // Level 50
logger.fatal('Fatal error, app cannot continue') // Level 60
When to use each level:
- trace: Function entry/exit, loop iterations (extremely verbose)
- debug: Variable values, conditional branches, algorithm steps
- info: HTTP requests, user actions, state changes, startup/shutdown
- warn: Deprecated API usage, retry attempts, degraded performance
- error: Exceptions caught, failed operations, data validation errors
- fatal: Database connection lost, critical service unavailable, unrecoverable errors
Production recommendation: Set LOG_LEVEL=info by default, use debug or trace only when debugging specific issues.
Child Loggers (Context Propagation)
Create child loggers to add context that persists across multiple log statements:
// Without child logger (repetitive)
logger.info({ userId: '123', requestId: 'abc' }, 'User logged in')
logger.info({ userId: '123', requestId: 'abc' }, 'Profile fetched')
logger.info({ userId: '123', requestId: 'abc' }, 'Settings updated')
// With child logger (clean)
const requestLogger = logger.child({ userId: '123', requestId: 'abc' })
requestLogger.info('User logged in')
requestLogger.info('Profile fetched')
requestLogger.info('Settings updated')
Middleware Pattern (Express/Next.js)
// middleware/logging.ts
import { v4 as uuidv4 } from 'uuid'
import { logger } from '@/lib/logger'
import type { NextRequest } from 'next/server'
export function createRequestLogger(req: NextRequest) {
// Generate correlation ID for request tracing
const correlationId = req.headers.get('x-correlation-id') || uuidv4()
// Create child logger with request context
return logger.child({
correlationId,
method: req.method,
path: req.nextUrl.pathname,
userAgent: req.headers.get('user-agent'),
ip: req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip'),
})
}
// Usage in API route
export async function GET(req: NextRequest) {
const log = createRequestLogger(req)
log.info('Processing request')
try {
const data = await fetchData()
log.info({ dataCount: data.length }, 'Data fetched successfully')
return Response.json(data)
} catch (error) {
log.error({ error }, 'Failed to fetch data')
return Response.json({ error: 'Internal error' }, { status: 500 })
}
}
Structured Logging Patterns
✅ Good: Structured Fields
// Queryable, analyzable
logger.info({
event: 'user_login',
userId: user.id,
email: user.email,
provider: 'google',
duration: 150,
}, 'User authenticated')
// Easy queries:
// - All Google logins: event='user_login' AND provider='google'
// - Slow logins: event='user_login' AND duration > 1000
// - Specific user: event='user_login' AND userId='123'
❌ Bad: String Templates
// Not queryable, hard to parse
logger.info(`User ${user.email} logged in via ${provider} in ${duration}ms`)
// Cannot easily query by provider or filter by duration
Error Logging
// ✅ Good: Include error object with structured context
try {
await riskyOperation()
} catch (error) {
logger.error({
error,
operation: 'riskyOperation',
userId: user.id,
retryCount: 3,
}, 'Operation failed after retries')
}
// ❌ Bad: Lose stack trace and context
try {
await riskyOperation()
} catch (error) {
logger.error(`Operation failed: ${error.message}`)
}
Performance Logging
// Track operation duration
const startTime = Date.now()
try {
const result = await fetchFromDatabase(query)
const duration = Date.now() - startTime
logger.info({
event: 'database_query',
query: query.type,
duration,
resultCount: result.length,
}, 'Query completed')
// Alert if slow
if (duration > 1000) {
logger.warn({
event: 'slow_query',
query: query.type,
duration,
}, 'Database query exceeded threshold')
}
return result
} catch (error) {
logger.error({
error,
event: 'database_error',
query: query.type,
duration: Date.now() - startTime,
}, 'Query failed')
throw error
}
Correlation IDs (Request Tracing)
Correlation IDs enable tracing a single request through multiple services and log statements.
// middleware/correlation.ts
import { v4 as uuidv4 } from 'uuid'
export function correlationMiddleware(req: Request, res: Response, next: NextFunction) {
// Extract or generate correlation ID
const correlationId = req.headers['x-correlation-id'] || uuidv4()
// Add to response headers for client
res.setHeader('x-correlation-id', correlationId)
// Attach logger with correlation ID to request
req.log = logger.child({ correlationId })
next()
}
// Usage in route
app.get('/api/users', async (req, res) => {
req.log.info('Fetching users')
const users = await fetchUsers()
req.log.info({ count: users.length }, 'Users fetched')
res.json(users)
})
// All logs will include the same correlationId:
// {"level":"info","correlationId":"abc-123","msg":"Fetching users"}
// {"level":"info","correlationId":"abc-123","count":42,"msg":"Users fetched"}
Sensitive Data Redaction
Critical security practice: Never log sensitive information.
Automatic Redaction (configured in setup)
// Pino automatically redacts these fields (from setup above)
logger.info({
user: {
email: 'user@example.com',
password: 'secret123', // Will be [REDACTED]
},
apiKey: 'sk_live_123', // Will be [REDACTED]
}, 'User data processed')
// Output:
// {
// "user": {
// "email": "user@example.com",
// "password": "[REDACTED]"
// },
// "apiKey": "[REDACTED]",
// "msg": "User data processed"
// }
Manual Redaction for Dynamic Fields
// Utility function for safe logging
function sanitizeForLogging<T extends Record<string, any>>(obj: T): T {
const sensitivePatterns = [
/password/i,
/secret/i,
/token/i,
/key/i,
/authorization/i,
]
const sanitized = { ...obj }
for (const key in sanitized) {
if (sensitivePatterns.some(pattern => pattern.test(key))) {
sanitized[key] = '[REDACTED]'
}
}
return sanitized
}
// Usage
logger.info(sanitizeForLogging(userData), 'User updated')
Convex-Specific Logging
Convex functions run in a managed environment with built-in logging, but structured logging still applies:
// convex/users.ts
import { query } from './_generated/server'
import { v } from 'convex/values'
export const getUser = query({
args: { userId: v.id('users') },
handler: async (ctx, args) => {
// Use console with structured data
console.info({
operation: 'getUser',
userId: args.userId,
timestamp: Date.now(),
})
try {
const user = await ctx.db.get(args.userId)
if (!user) {
console.warn({
operation: 'getUser',
userId: args.userId,
result: 'not_found',
})
return null
}
console.info({
operation: 'getUser',
userId: args.userId,
result: 'success',
})
return user
} catch (error) {
console.error({
operation: 'getUser',
userId: args.userId,
error: error.message,
})
throw error
}
},
})
Note: Convex console.log/info/error are automatically structured in the dashboard. Use objects instead of strings for better filtering.
Centralization & Observability
In production, centralize logs to a log aggregation service:
Option 1: Datadog (Recommended for Enterprise)
// lib/logger.ts
import pino from 'pino'
const logger = pino({
// ... base config
transport: {
target: 'pino-datadog-transport',
options: {
apiKey: process.env.DATADOG_API_KEY,
service: 'my-app',
env: process.env.NODE_ENV,
tags: ['team:engineering', 'project:webapp'],
},
},
})
Option 2: Graylog (Self-Hosted)
import pino from 'pino'
const logger = pino({
transport: {
target: 'pino-socket',
options: {
address: process.env.GRAYLOG_HOST,
port: 12201,
mode: 'udp',
},
},
})
Option 3: Vercel Log Drains (for Next.js on Vercel)
Vercel automatically collects logs and can forward to:
- Datadog
- LogDNA
- Logtail
- New Relic
- Sentry
- Custom HTTPS endpoints
Configure in Vercel Dashboard → Project → Settings → Log Drains
Querying Logs
With centralized structured logs, you can query efficiently:
# Datadog query
service:my-app AND env:production AND level:error AND @userId:123
# Find slow database queries
service:my-app AND event:database_query AND duration > 1000
# Track user journey
service:my-app AND @correlationId:abc-123-def
Testing & Development
Development: Pretty Printing
# Pretty output for development
NODE_ENV=development pnpm dev
# Raw JSON for testing centralization
NODE_ENV=production pnpm dev
Testing: Log Capture
// test/logger.test.ts
import { describe, it, expect, vi } from 'vitest'
import { logger } from '@/lib/logger'
describe('Logger', () => {
it('redacts sensitive fields', () => {
const logSpy = vi.spyOn(logger, 'info')
logger.info({
email: 'user@example.com',
password: 'secret',
}, 'User data')
expect(logSpy).toHaveBeenCalledWith(
expect.objectContaining({
email: 'user@example.com',
password: '[REDACTED]',
}),
'User data'
)
})
})
Best Practices Summary
Do ✅
- Use structured JSON:
{ userId: '123', action: 'login' }not"User 123 logged in" - Include context: Add all relevant fields (IDs, timestamps, metadata)
- Use correlation IDs: Track requests across services
- Redact sensitive data: Passwords, tokens, PII automatically filtered
- Log at appropriate levels: info for normal flow, error for failures
- Use child loggers: Add context once, reuse across log statements
- Centralize in production: Send logs to Datadog/Graylog/ELK
- Query your logs: Use structured fields for powerful analysis
Don't ❌
- Don't use string templates: Breaks queryability
- Don't log sensitive data: Passwords, tokens, credit cards, SSNs
- Don't log in tight loops: Excessive logs hurt performance
- Don't ignore log levels: Trace/debug should be off in production
- Don't concatenate error messages: Log full error object with stack
- Don't use console.log in production: Use proper logging library
- Don't skip correlation IDs: Makes debugging multi-service flows impossible
Quick Setup Checklist
For a new Node.js/Next.js project:
- [ ] Install Pino:
pnpm add pino pino-pretty - [ ] Create logger singleton in
lib/logger.ts - [ ] Configure redaction for sensitive fields
- [ ] Set up correlation ID middleware
- [ ] Create child logger pattern for requests
- [ ] Configure pretty printing for development
- [ ] Set up log transport for production (Datadog/Graylog)
- [ ] Add environment variable:
LOG_LEVEL - [ ] Test redaction with unit tests
- [ ] Document logging patterns in project README
Philosophy
"Logs are the voice of your production application."
Structured logging transforms logs from debug statements into queryable data. In production, logs enable:
- Debugging: Trace requests, find errors, understand behavior
- Monitoring: Track metrics, detect anomalies, set alerts
- Analytics: Understand user behavior, measure performance
- Security: Detect attacks, audit access, investigate incidents
Invest in logging infrastructure early. The cost is minimal; the value is immense.
When agents implement logging, they should:
- Default to Pino for Node.js applications (5x faster than Winston)
- Use structured JSON fields, not string templates
- Include correlation IDs for request tracing
- Redact sensitive fields automatically
- Use child loggers for context propagation
- Log at appropriate levels (info for normal, error for failures)
- Centralize logs in production (Datadog, Graylog, or Vercel Log Drains)
- Never log passwords, tokens, API keys, or PII