Skip to content

Logging Patterns

Your API throws intermittent 500s in production. The “logs” are unstructured console.log strings, you can’t correlate a single request across three services, and your on-call engineer is grepping plaintext at 3 AM. The fix isn’t writing a logging framework by hand — it’s getting your AI tool to convert what you already have into structured, correlated, redacted logs in one pass.

This recipe shows the prompts and the review loop. The hand-written logging class is the thing the AI deletes, not the thing you ship.

  • A prompt that converts every console.log in a repo to structured winston (or pino) logging with a JSON formatter, defaultMeta, and a redaction layer
  • A prompt that adds request-correlation middleware so one requestId follows a request through every downstream call via AsyncLocalStorage
  • A prompt that audits your codebase for log statements leaking secrets and PII before they reach disk
  • The failure modes that make AI-generated logging look fine in review and break in production

The mistake is asking the AI to “add logging.” You get scattered logger.info calls with no schema. Instead, drive three discrete passes: pick a library, make logs structured and redacted, then make them correlated. Evaluate the diff after each pass.

Step 1: Pick a maintained library, not a bespoke class

Section titled “Step 1: Pick a maintained library, not a bespoke class”

Do not let the AI hand-roll a SamplingLogger or MultiTransportLogger. Both winston and pino already solve transports, levels, formatting, and redaction, and they’re maintained. pino is the faster default (it serializes off the hot path); winston is more flexible if you need many transports. State the choice in your prompt so the AI doesn’t reinvent it.

Step 2: Convert console.log to structured, redacted logging

Section titled “Step 2: Convert console.log to structured, redacted logging”

This is a mechanical multi-file refactor — exactly what agent mode and headless runs are good at. The prompt is essentially identical across all three tools; only the invocation differs.

Open agent mode (Cmd/Ctrl+I), point it at src/, and paste the structured-logging prompt below. Cursor edits every call site across files in one pass; review each change in the diff view and use a checkpoint before accepting so you can roll the whole refactor back if the schema is wrong. Keep src/lib/logger.ts open as the anchor file so the agent centralizes config there instead of redefining a logger per module.

Here is the prompt itself. It is opinionated on purpose — a vague “add logging” prompt is what produced the unqueryable mess you started with.

After the diff lands, evaluate it against three questions before you accept:

  1. Is the message a stable, searchable constant? logger.info('User login') is greppable; logger.info(`User ${id} logged in`) is not. Reject interpolated messages — the dynamic parts belong in the metadata object.
  2. Did redaction land on a shared formatter, not per-call delete statements? If the AI sprinkled manual delete obj.password lines, send it back: redaction must be a format applied to every log or it will be forgotten on the next log line someone adds.
  3. Are levels meaningful? A handled validation error is warn, an unhandled 500 is error. If everything became info, your alerting is now useless.

Step 3: Add request correlation that survives async boundaries

Section titled “Step 3: Add request correlation that survives async boundaries”

A requestId that only exists on req is worthless the moment you call a service function that doesn’t take req. The robust pattern is AsyncLocalStorage: store the request context once in middleware, then any logger anywhere in the call stack reads it without threading req through every signature. Make the AI use it explicitly — left to its own devices it will pass req.logger around and lose the id at the first setTimeout or detached promise.

The “prove it” clause matters: it forces the AI to demonstrate propagation across an async boundary instead of asserting it works. If it can’t show the id surfacing in a function that never received req, the implementation is the fragile kind.

Redaction by key name is necessary but not sufficient — emails and IPs are PII under GDPR and won’t be caught by a password/token matcher. Run a dedicated audit pass as your last step.

These are the failure modes that pass code review and surface at 3 AM.

  • The AI logs full request bodies “for debugging.” A single logger.info('request', { body: req.body }) will dump passwords, tokens, and card numbers into your log store the first time someone hits a sign-up route. Key-based redaction won’t save you if the whole body is nested under one safe-looking key. Forbid logging raw bodies in the prompt, and verify the audit table from Step 4 is empty before you ship.
  • logger.child() context is lost across async boundaries. A child logger bound in middleware does not automatically follow into a setTimeout, a detached .then(), or a queue worker. This is exactly why Step 3 uses AsyncLocalStorage instead of req.logger — if you see a correlation id present on the request log but missing on the downstream service log, you’ve hit this, and the fix is to read context from the store, not from a passed-in child.
  • Everything became info, so alerting is noise. If the refactor flattened all levels, your “log on errors” alert either never fires or fires on every request. Re-run Step 2’s level-review question and have the AI reclassify: warn for handled/4xx, error for unhandled/5xx, debug for high-frequency diagnostics.
  • Synchronous logging on the hot path adds latency. Writing to a slow transport (a file on a network mount, a remote HTTP sink) inline with the request blocks it. Use pino (async by design) or a buffered/async transport for winston, and never await a log write in a request handler.
  • A maintained-library MCP would have answered the config question for you. If the AI guesses at a winston or pino API and gets it subtly wrong, attach the Context7 MCP (@upstash/context7-mcp) and add “use Context7 for the winston/pino docs” to the prompt — it pulls current library docs into context so the generated config matches the installed version instead of a hallucinated one.