Skip to content

Logging 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.

  • 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 with AsyncLocalStorage, 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.log from coming back the week after the refactor
  • The ways agent-written logging passes review and fails in production, with a recovery for each

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.

PassWhat the agent changesCheck that proves it
1. Log contractsrc/lib/logger.ts and one test fileContract test green, and red when one redact path is removed
2. ConvertEvery console.* call siteContract test green, no-console lint at zero
3. CorrelateMiddleware plus the logger’s context hookA test logs from a function that never sees req and finds the requestId
4. AuditNothing at first: a findings tableCanary 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.

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

Needpinowinston
JSON output by defaultYesWith format.json()
RedactionBuilt in: redact: { paths, censor }, path-basedA custom format you write
Per-request context on every linemixin() option, called for each log callA custom format that reads the context
Many transports (files, HTTP, syslog)pino.transport(), which runs transports in a worker threadWide transport ecosystem, configured in one place
Call signaturelogger.info({ userId }, 'User login'): object firstlogger.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.

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 window
# 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.

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 window
# Terminal, repository root
git grep -n "console\." src/ # leftover console calls
git grep -nE "logger\.[a-z]+\([^)]*\`" src/ # messages built with template literals

Add 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.

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.

Terminal window
# Plan mode keeps the run read-only
claude -p --permission-mode plan "$(cat prompts/audit-logging.txt)" > log-audit.md

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:

  1. Contract test: green, and shown to go red when a redact path is removed. It covers JSON shape, constant messages, redaction canaries and requestId propagation across await and setTimeout.
  2. Lint gate: no-console is error in the linter config and runs in CI, so the rule outlives this pull request.
  3. Searches with empty output: the two git grep commands from the conversion pass.
  4. 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.
  5. 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, so userId is 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 asserts user.id survives, 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: *.password matches user.password but not body.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 on debug in production during an incident. Recovery: the conversion prompt forbids whole bodies; the review prompt checks for them.
  • The requestId vanishes in queue workers and timers. Context stored in AsyncLocalStorage does not cross a process boundary. A job picked up by a worker has no store. Recovery: put the requestId in the job payload and call requestContext.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. Never await a 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.

Did this guide help you?

One tap, no sign-in.

not at all a lot