Logging Patterns
PLLogging patterns for AI agents are four recipes that have Claude Code, Codex or Cursor turn scattered console.log calls into structured JSON logs from one maintained library, with a request ID on every line and secrets redacted by configuration. Each recipe ends in a machine check (a log-contract test, a secret canary and a lint rule), so nobody reviews every call site by eye.
This page is for developers who own a Node.js service and for tech leads who decide what a logging pull request must prove. Your API throws intermittent 500s, the logs are interpolated strings, and you cannot follow one request through three services. Asked to “add logging”, an agent sprinkles logger.info calls with no schema. The recipes below give it a contract to meet instead.
What you get from these logging recipes
Section titled “What you get from these logging recipes”- A log contract written as a test before the agent touches a call site, so “structured” and “redacted” are pass or fail
- Five copy-paste prompts: write the log contract, convert
console.*, add request correlation withAsyncLocalStorage, audit for secrets and personal data, and review the finished pull request - Exact commands to run each pass with Claude Code and Codex, and the Cursor equivalent
- A lint rule that stops
console.logfrom coming back the week after the refactor - The ways agent-written logging passes review and fails in production, with a recovery for each
Which logging pass comes first?
Section titled “Which logging pass comes first?”Run the work as four small passes, not one “add logging” request. Each pass has its own check, and the next one starts only when the check is green.
| Pass | What the agent changes | Check that proves it |
|---|---|---|
| 1. Log contract | src/lib/logger.ts and one test file | Contract test green, and red when one redact path is removed |
| 2. Convert | Every console.* call site | Contract test green, no-console lint at zero |
| 3. Correlate | Middleware plus the logger’s context hook | A test logs from a function that never sees req and finds the requestId |
| 4. Audit | Nothing at first: a findings table | Canary test green, findings table empty or triaged |
The same passes work in Python (structlog with contextvars) and Go (log/slog with context.Context). The examples use Node.js and TypeScript.
Should the agent use pino or winston?
Section titled “Should the agent use pino or winston?”Pick the library in the prompt. Left to choose, an agent often writes its own Logger class with transports and levels, which you then maintain. Both libraries below are maintained (npm latest on 2026-09-26: pino 10.3.1, winston 3.19.0).
| Need | pino | winston |
|---|---|---|
| JSON output by default | Yes | With format.json() |
| Redaction | Built in: redact: { paths, censor }, path-based | A custom format you write |
| Per-request context on every line | mixin() option, called for each log call | A custom format that reads the context |
| Many transports (files, HTTP, syslog) | pino.transport(), which runs transports in a worker thread | Wide transport ecosystem, configured in one place |
| Call signature | logger.info({ userId }, 'User login'): object first | logger.info('User login', { userId }): message first |
The default in this page is pino, because redaction and per-line context are configuration rather than code. Choose winston when the service already uses it or needs several transports configured together. The call-signature row matters more than it looks: see the failure modes below.
Write the log contract as a test first
Section titled “Write the log contract as a test first”The contract is what makes the rest provable. It pins the fields every line must carry and plants canary secrets that must never reach the output. Ask the agent to write it before anything else. Then prove the test can fail: remove one redact path, watch it go red, and restore the path. A contract that cannot fail proves nothing.
The core of src/lib/logger.ts that the contract drives toward:
import { AsyncLocalStorage } from 'node:async_hooks';import pino, { type DestinationStream } from 'pino';
export const requestContext = new AsyncLocalStorage<{ requestId: string; userId?: string }>();
export function createLogger(destination?: DestinationStream) { return pino( { level: process.env.LOG_LEVEL ?? 'info', base: { service: 'api-gateway', version: process.env.APP_VERSION }, timestamp: pino.stdTimeFunctions.isoTime, redact: { paths: ['*.password', '*.token', '*.apiKey', '*.headers.authorization', '*.headers.cookie'], censor: '[REDACTED]', }, mixin: () => requestContext.getStore() ?? {}, }, destination, );}
export const logger = createLogger();And the contract test, in the project’s existing runner (Vitest shown):
import { Writable } from 'node:stream';import { expect, it } from 'vitest';import { createLogger, requestContext } from '../src/lib/logger';
function capture() { const lines: string[] = []; const sink = new Writable({ write(chunk, _enc, done) { lines.push(chunk.toString()); done(); }, }); return { log: createLogger(sink), lines };}
it('emits JSON with a constant message and no canary secrets', () => { const { log, lines } = capture(); log.info({ user: { id: 'u1', password: 'CANARY-pw' }, req: { headers: { authorization: 'Bearer CANARY-tok' } } }, 'User login'); expect(lines.join('')).not.toContain('CANARY'); expect(JSON.parse(lines[0])).toMatchObject({ msg: 'User login', service: 'api-gateway' }); expect(JSON.parse(lines[0]).user.id).toBe('u1');});
it('carries the requestId across an async boundary', async () => { const { log, lines } = capture(); await requestContext.run({ requestId: 'req-test-1' }, async () => { await new Promise((resolve) => setTimeout(resolve, 5)); log.info('Downstream call'); }); expect(JSON.parse(lines[0]).requestId).toBe('req-test-1');});The CANARY- prefix is the trick: one not.toContain covers every field, however deeply the agent nests it. Add a canary for each kind of secret the service handles. The redact paths above cover one level of nesting; the two-level canary in the prompt below forces the agent to add deeper paths such as *.*.password, or to stop logging the parent object. Both the logger and the test ran as shown against pino 10.3.1 and Node.js AsyncLocalStorage.
Install the library yourself first (npm install pino), so no pass needs network access. Then run the contract prompt.
Read the test, not the logger. If the assertions match the requirements above, the logger either passes them or does not.
Convert console.log to structured logs across the repository
Section titled “Convert console.log to structured logs across the repository”With the contract green, the conversion is a mechanical multi-file refactor. The prompt is the same for every tool; how you run it differs.
# Terminal, repository root. Flags checked against Claude Code 2.1.283; the# v2.1.285 change is from the Claude Code changelog (checked 2026-10-12).# claude -p starts in Manual mode (auto mode on third-party providers or with# telemetry off, from v2.1.285); --permission-mode dontAsk denies anything that# would prompt, on every provider. Reads and read-only commands still run.claude -p "$(cat prompts/convert-logging.txt)" \ --permission-mode dontAsk \ --allowedTools "Read" "Edit" "Write" "Bash(npm test:*)" "Bash(npx eslint:*)"Save the conversion prompt below as prompts/convert-logging.txt. The allowlist lets the agent edit files and run the tests and the linter, and nothing else: no git push, no package installs.
# Terminal, repository root. Checked against codex-cli 0.157.1.# Permission profiles are beta (Codex CLI 0.138.0 or later).codex exec -c default_permissions=":workspace" "$(cat prompts/convert-logging.txt)"The :workspace profile lets Codex write inside the repository only. On a Codex version without permission profiles, use --sandbox workspace-write instead; do not combine the two. For a large repository, run the same prompt as a Codex cloud task and review the result as a pull request.
Add a project Rule first: “Never use console.* in src/. Import logger from src/lib/logger.ts. pino signature: object first, message second.” The rule keeps later agent sessions from reintroducing console.log. Then paste the conversion prompt into the Agent, review the multi-file diff before accepting, and run npm test in the terminal. For a large repository, hand the same prompt to a Cloud Agent and review its pull request.
Three things to check in the summary, not in every file: the tests and the linter are green, the list of uncertain fields is short enough to decide on in a few minutes, and both searches below print nothing:
# Terminal, repository rootgit grep -n "console\." src/ # leftover console callsgit grep -nE "logger\.[a-z]+\([^)]*\`" src/ # messages built with template literalsAdd request correlation that survives async boundaries
Section titled “Add request correlation that survives async boundaries”A requestId stored on req disappears the moment a service function does not take req. AsyncLocalStorage fixes that: middleware stores the context once, and the logger’s mixin reads it from anywhere in the call stack. Agents default to threading req.logger through function arguments, so name the mechanism in the prompt.
Install supertest as a devDependency yourself before this pass if the project lacks it. The supertest case is the proof. An implementation that only works on the synchronous path fails it, which is the version that loses the ID in production. Log the route pattern (/users/:id) rather than the raw URL: raw URLs carry IDs, emails and tokens in query strings.
If the service already exports OpenTelemetry traces, @opentelemetry/instrumentation-pino (npm latest 0.68.0 on 2026-09-26) adds trace_id and span_id to each log line, so logs and traces join in your backend. Carrying those fields through the log pipeline is covered in monitoring and logging patterns.
Audit logs for secrets and personal data
Section titled “Audit logs for secrets and personal data”Path-based redaction catches the keys you listed. It does not catch an email in a message string, an IP address, or a token under a key nobody thought of. Emails and IP addresses are personal data under the GDPR, which turns a harmless-looking info line on every login into a retention and erasure problem. Run the audit read-only, so the agent reports before it edits.
# Plan mode keeps the run read-onlyclaude -p --permission-mode plan "$(cat prompts/audit-logging.txt)" > log-audit.mdcodex exec -c default_permissions=":read-only" "$(cat prompts/audit-logging.txt)" > log-audit.mdSwitch to Plan Mode, paste the audit prompt, and save the table it returns. Plan Mode produces a plan instead of edits.
Triage the table, then send the fixes back as a normal edit pass with the new canaries added to the contract test. The audit is done when the canary test is green and every row in the table is fixed or accepted by name. For the policy side (which data may be logged at all and for how long), see privacy and data handling.
How do you prove the logging refactor without reading every call site?
Section titled “How do you prove the logging refactor without reading every call site?”A logging pull request touches dozens of files. Review the evidence instead of the diff:
- Contract test: green, and shown to go red when a redact path is removed. It covers JSON shape, constant messages, redaction canaries and
requestIdpropagation acrossawaitandsetTimeout. - Lint gate:
no-consoleiserrorin the linter config and runs in CI, so the rule outlives this pull request. - Searches with empty output: the two
git grepcommands from the conversion pass. - One real line: start the service locally, send one request, and paste one log line into the pull request description. A reviewer checks the fields in seconds.
- Audit table: attached, with each row fixed or accepted.
The author signs off on steps 1–4. Whoever owns data protection for the service accepts or rejects the remaining audit rows. For a reusable review prompt, ask the agent to check the finished diff against this list:
What breaks when an agent rewrites your logging?
Section titled “What breaks when an agent rewrites your logging?”- The metadata silently disappears. An agent used to winston writes
logger.info('User login', { userId })against pino. pino reads the second argument as a format parameter, souserIdis not recorded as a structured field. Recovery:git grep -nE "logger\.[a-z]+\('[^']*', *\{" src/lists every call in winston order, and the review prompt checks argument order in the diff. The contract test only guards the logger configuration: it assertsuser.idsurvives, so a redact path that is too broad goes red, but it calls the logger directly and cannot see a call site. - Redaction misses nested secrets. pino redact paths are exact:
*.passwordmatchesuser.passwordbut notbody.user.password. Recovery: canaries at two nesting levels in the contract test, and add deeper paths (or stop logging the parent object) when one leaks. - A whole body sits under a safe-looking key.
logger.debug({ payload: req.body })bypasses every key-based rule, and someone turns ondebugin production during an incident. Recovery: the conversion prompt forbids whole bodies; the review prompt checks for them. - The
requestIdvanishes in queue workers and timers. Context stored inAsyncLocalStoragedoes not cross a process boundary. A job picked up by a worker has no store. Recovery: put therequestIdin the job payload and callrequestContext.run()at the top of the worker handler. - Every line became
info. The refactor flattened levels, so an error-rate alert either never fires or fires on every request. Recovery: rerun the conversion prompt’s level rules on the affected files only, then check that the error rate from logs matches the 5xx rate from your metrics. - Logging adds latency on the request path. A synchronous write to a slow destination blocks the request. Recovery: write JSON to stdout and let the platform ship it, or use
pino.transport(), which runs the transport in a worker thread. Neverawaita log write in a handler. - The agent guesses the library’s API. It writes an option from another major version, or a winston idiom into pino config, and the result looks plausible in review. Recovery: connect the Context7 MCP server so the agent reads the docs for the installed version (setup for all three tools on Context7), and keep the contract test as the arbiter.
Where to go next with logging
Section titled “Where to go next with logging”- Debugging patterns: use these structured logs to correlate failing and passing requests
- Monitoring and logging patterns: carry
trace_idthrough the log pipeline and alert on what the logs now emit - Recovery patterns: what the service does when the errors you now log actually fire
- Observability MCP servers: let the agent query production logs and errors read-only
- Error handling recipes: the rest of this section
Did this guide help you?
One tap, no sign-in.
What will you do with it?
How did it read?
What got in the way? Pick any that apply
Your role?
Thanks. I read every answer. Michał Jaskólski
Did this guide help?
One tap each. Close whenever you like.
no sign-in