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.
What You’ll Walk Away With
Section titled “What You’ll Walk Away With”- A prompt that converts every
console.login a repo to structuredwinston(orpino) logging with a JSON formatter,defaultMeta, and a redaction layer - A prompt that adds request-correlation middleware so one
requestIdfollows a request through every downstream call viaAsyncLocalStorage - 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 Workflow
Section titled “The Workflow”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.
Run it headless so the change is scripted and testable in one shot:
claude -p "Refactor every console.log/console.error in src/ to the winston logger in src/lib/logger.ts. After editing, run: npm test" \ --allowedTools "Read" "Edit" "Bash(npm test:*)"-p runs non-interactively; --allowedTools lets it edit and run the test suite without prompting on each file, so the refactor and its verification happen together.
In the Codex CLI, run the prompt as a positional argument and let it land the edits, gating shell commands on failure:
codex --ask-for-approval on-failure \ "Replace all console.* calls in src/ with the structured winston logger in src/lib/logger.ts, then run npm test"For a large repo, push the same prompt to Codex Cloud from the IDE extension or the web app and review the resulting diff as a PR rather than watching it locally.
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:
- 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. - Did redaction land on a shared formatter, not per-call
deletestatements? If the AI sprinkled manualdelete obj.passwordlines, send it back: redaction must be a format applied to every log or it will be forgotten on the next log line someone adds. - Are levels meaningful? A handled validation error is
warn, an unhandled 500 iserror. If everything becameinfo, 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.
Step 4: Audit for leaked secrets and PII
Section titled “Step 4: Audit for leaked secrets and PII”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.
When This Breaks
Section titled “When This Breaks”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 asetTimeout, a detached.then(), or a queue worker. This is exactly why Step 3 usesAsyncLocalStorageinstead ofreq.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:warnfor handled/4xx,errorfor unhandled/5xx,debugfor 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 forwinston, and neverawaita log write in a request handler. - A maintained-library MCP would have answered the config question for you. If the AI guesses at a
winstonorpinoAPI 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.
What’s Next
Section titled “What’s Next”- Debugging Patterns — turn these structured logs into a fast root-cause workflow
- Monitoring Patterns — alert on the signals your logs now emit
- Recovery Patterns — graceful degradation when the errors you’re logging actually fire