DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Structured Logging with Claude Code: pino, Request IDs, and Sensitive Data Redaction

console.log('Order created') in production means no request ID, no log level, no JSON format, and no way to trace what happened when something breaks at 3am.

Claude Code generates pino structured logging from CLAUDE.md rules — so you get observability that actually works.


The CLAUDE.md Rules

## Logging Rules

- Use pino (fast, JSON output, production-ready)
- No console.log or console.error — use logger exclusively
- pino-http for automatic HTTP request/response logging
- X-Request-ID header on all requests (generate if missing)
- All logs must include request_id
- Log levels: trace/debug/info/warn/error/fatal
- NEVER log passwords, tokens, credit card numbers, or PII
- Use pino redact for automatic sensitive field removal
Enter fullscreen mode Exit fullscreen mode

These constraints give Claude Code enough context to generate a logger that's safe by default.


logger.ts

import pino from 'pino';

export const logger = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  redact: {
    paths: [
      'password',
      'token',
      'accessToken',
      'refreshToken',
      'authorization',
      'req.headers.authorization',
      'req.headers.cookie',
      '*.password',
      '*.token',
    ],
    censor: '[REDACTED]',
  },
  ...(process.env.NODE_ENV !== 'production' && {
    transport: {
      target: 'pino-pretty',
      options: { colorize: true },
    },
  }),
});
Enter fullscreen mode Exit fullscreen mode

In production: compact JSON, fast serialization. In development: human-readable colored output. The redact config means even if someone logs { body } containing a password field, it never reaches the log sink.


requestIdMiddleware.ts

import { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'crypto';

export function requestIdMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  const requestId = (req.headers['x-request-id'] as string) ?? randomUUID();
  req.id = requestId;
  res.setHeader('X-Request-ID', requestId);
  next();
}
Enter fullscreen mode Exit fullscreen mode

Clients that generate their own request IDs (e.g., mobile apps) can pass X-Request-ID. Otherwise one is generated. Either way, it propagates back in the response header — so the client can correlate its own logs with server logs.


app.ts

import pinoHttp from 'pino-http';
import { requestIdMiddleware } from './requestIdMiddleware';
import { logger } from './logger';

// requestId middleware must come first
app.use(requestIdMiddleware);

app.use(
  pinoHttp({
    logger,
    genReqId: (req) => req.id,
    autoLogging: {
      ignore: (req) => req.url === '/health',
    },
  })
);
Enter fullscreen mode Exit fullscreen mode

genReqId wires the request ID from the middleware into pino-http's log entries. /health is excluded from auto-logging to avoid log spam from load balancer probes.


getRequestLogger(req)

import { Request } from 'express';
import { logger } from './logger';

export function getRequestLogger(req: Request) {
  return logger.child({
    requestId: req.id,
    userId: (req as any).user?.id,
    tenantId: (req as any).tenant?.id,
  });
}
Enter fullscreen mode Exit fullscreen mode

Child loggers inherit the parent config (redact, level, transport) and add context fields. Every log line from a route handler automatically includes requestId, userId, and tenantId.


Usage in a Route Handler

router.post('/orders', async (req, res) => {
  const log = getRequestLogger(req);

  log.info({ body: req.body }, 'Creating order');

  const order = await orderService.create(req.body, req.user.id);

  log.info({ orderId: order.id }, 'Order created');

  res.status(201).json(order);
});
Enter fullscreen mode Exit fullscreen mode

What the JSON Output Looks Like

{
  "level": 30,
  "time": 1741680000000,
  "pid": 1234,
  "hostname": "app-pod-7f9b",
  "requestId": "550e8400-e29b-41d4-a716-446655440000",
  "userId": "usr_abc123",
  "tenantId": "tenant_xyz",
  "msg": "Order created",
  "orderId": "ord_789"
}
Enter fullscreen mode Exit fullscreen mode

Every log line has: timestamp, level, request ID, user context, and the actual message. This is what you need for distributed tracing and log aggregation (Datadog, CloudWatch, Loki, etc.).


What CLAUDE.md Gives You

The pattern: write the logging rules in CLAUDE.md → Claude Code generates compliant code on the first pass.

  • X-Request-ID on all requests → trace any log back to its HTTP request
  • pino-http auto-logging → HTTP access logs without manual instrumentation
  • redact → passwords and tokens never reach the log aggregator, by default
  • logger.child() → every log line carries user and tenant context automatically

Without CLAUDE.md, you get console.log in route handlers. With it, you get production-grade observability.


Want the full set of Node.js backend CLAUDE.md rules I use — including Docker, error handling, database patterns, and security constraints? It's packaged as a Code Review Pack on PromptWorks (¥980, /code-review).


What's in your logging setup that you wish you had from day one?

Top comments (0)