structured-logging

Best practices for production-ready logging in Node.js applications using Pino and structured JSON output.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "structured-logging" with this command: npx skills add phrazzld/claude-config/phrazzld-claude-config-structured-logging

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

Note on Error serialization: Pino handles Error objects natively, but JSON.stringify(new Error("msg")) returns {} because message , name , stack are non-enumerable. For custom loggers, manually extract:

function serializeError(err: unknown): Record<string, unknown> { if (err instanceof Error) { return { name: err.name, message: err.message, stack: err.stack }; } return { value: String(err) }; }

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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

pencil-renderer

No summary provided by upstream source.

Repository SourceNeeds Review
General

ui-skills

No summary provided by upstream source.

Repository SourceNeeds Review
General

llm-gateway-routing

No summary provided by upstream source.

Repository SourceNeeds Review