Skip to content
Chapter 92Lesson 3

Logging policy and PII redaction

What every Pino log line should carry for an on-call operator, and what it must never leak to a regulator.

The logger is wired. The last lesson gave you the pino singleton in lib/logger.ts, a requestId threaded through every line by AsyncLocalStorage, and the child-logger idiom that binds a request’s IDs to a file’s logger. The machinery runs, but the team has never decided the two things that make running it worthwhile: what those lines should say, and what they must never say.

The rule fits in one sentence: log what an operator at 3am, paging through logs filtered to one requestId, needs to diagnose the incident, and exclude what a regulator at 9am would object to finding six months later. You’ll build both halves. The first is a per-seam logging policy, the 3am rule, that decides the content of every log call. The second fills the empty redact slot the last lesson left in lib/logger.ts: a denylist that keeps secrets and personal data out of the stream by key name, set once and never re-litigated at the call site.

One piece of known ground makes this tractable. In the error-handling chapter you learned that every error is two artifacts, a sanitized user string and a rich operator record, that diverge at the wrapper. The log is the operator side of that split, made queryable, which is why an email address is safe to log and a customer’s name is not.

Log the what and the which, not the how: what operation ran, which entities it touched, and how it turned out, not a play-by-play of your control flow. The operator paged at 3am is reconstructing an incident from the outside, and the log is their only window in.

That shape recurs at every server-side seam:

  • One structured info line per successful meaningful operation.
  • One structured error line per failure, carrying the cause chain through the { err } serializer from the last lesson.
  • Each line names the operation, the entities it touched (by ID), the outcome, and the duration.

Here are four representative calls, one per seam. They use the child-logger idiom from the last lesson, so the requestId and seam name are already bound and each call site adds only what is specific to its operation.

// (a) server action — authedAction wrapper
const log = logger.child({ seam: 'action.createInvoice', userId, orgId });
log.info({ input, durationMs }, 'invoice created');
log.error({ err, code, durationMs }, 'create invoice failed');
// (b) webhook handler
const log = logger.child({ seam: 'webhook.stripe', stripeEventType, eventId });
log.info({ signatureVerified, idempotencyHit, durationMs }, 'webhook processed');
// (c) background job — Trigger.dev task
const log = logger.child({ seam: 'job.exportInvoices', jobId });
log.info({ invoiceId, userId, resultCode, attempt }, 'export complete');
// (d) external API call
const log = logger.child({ seam: 'stripe.charges.create' });
log.info({ endpoint: 'POST /v1/charges', status, durationMs, attempt }, 'ok');

Every seam opens with a child logger. The requestId is already on it from the ALS; here we add the seam, which names the file, and the IDs specific to this operation. Every line below inherits them, so the call sites carry only the fields that change.

// (a) server action — authedAction wrapper
const log = logger.child({ seam: 'action.createInvoice', userId, orgId });
log.info({ input, durationMs }, 'invoice created');
log.error({ err, code, durationMs }, 'create invoice failed');
// (b) webhook handler
const log = logger.child({ seam: 'webhook.stripe', stripeEventType, eventId });
log.info({ signatureVerified, idempotencyHit, durationMs }, 'webhook processed');
// (c) background job — Trigger.dev task
const log = logger.child({ seam: 'job.exportInvoices', jobId });
log.info({ invoiceId, userId, resultCode, attempt }, 'export complete');
// (d) external API call
const log = logger.child({ seam: 'stripe.charges.create' });
log.info({ endpoint: 'POST /v1/charges', status, durationMs, attempt }, 'ok');

On success the action logs its name, the validated input shape, userId, orgId, and durationMs. On failure it swaps to error and carries the code plus the cause chain via { err }. input is the validated shape, not the raw form; the next section shows why that matters.

// (a) server action — authedAction wrapper
const log = logger.child({ seam: 'action.createInvoice', userId, orgId });
log.info({ input, durationMs }, 'invoice created');
log.error({ err, code, durationMs }, 'create invoice failed');
// (b) webhook handler
const log = logger.child({ seam: 'webhook.stripe', stripeEventType, eventId });
log.info({ signatureVerified, idempotencyHit, durationMs }, 'webhook processed');
// (c) background job — Trigger.dev task
const log = logger.child({ seam: 'job.exportInvoices', jobId });
log.info({ invoiceId, userId, resultCode, attempt }, 'export complete');
// (d) external API call
const log = logger.child({ seam: 'stripe.charges.create' });
log.info({ endpoint: 'POST /v1/charges', status, durationMs, attempt }, 'ok');

This line answers which org, which event, and whether we already processed it. stripeEventType and eventId are on the child; the call adds whether the signature verified, whether the idempotency ledger already held this event, and how long it took. When the webhook returns a 500 for one org, this is what tells the operator the scope.

// (a) server action — authedAction wrapper
const log = logger.child({ seam: 'action.createInvoice', userId, orgId });
log.info({ input, durationMs }, 'invoice created');
log.error({ err, code, durationMs }, 'create invoice failed');
// (b) webhook handler
const log = logger.child({ seam: 'webhook.stripe', stripeEventType, eventId });
log.info({ signatureVerified, idempotencyHit, durationMs }, 'webhook processed');
// (c) background job — Trigger.dev task
const log = logger.child({ seam: 'job.exportInvoices', jobId });
log.info({ invoiceId, userId, resultCode, attempt }, 'export complete');
// (d) external API call
const log = logger.child({ seam: 'stripe.charges.create' });
log.info({ endpoint: 'POST /v1/charges', status, durationMs, attempt }, 'ok');

A Trigger.dev task inherits no auth context, so the IDs come from the payload: invoiceId and userId are passed in. Add the resultCode and the retry attempt so a flaky job’s pattern is visible across runs.

// (a) server action — authedAction wrapper
const log = logger.child({ seam: 'action.createInvoice', userId, orgId });
log.info({ input, durationMs }, 'invoice created');
log.error({ err, code, durationMs }, 'create invoice failed');
// (b) webhook handler
const log = logger.child({ seam: 'webhook.stripe', stripeEventType, eventId });
log.info({ signatureVerified, idempotencyHit, durationMs }, 'webhook processed');
// (c) background job — Trigger.dev task
const log = logger.child({ seam: 'job.exportInvoices', jobId });
log.info({ invoiceId, userId, resultCode, attempt }, 'export complete');
// (d) external API call
const log = logger.child({ seam: 'stripe.charges.create' });
log.info({ endpoint: 'POST /v1/charges', status, durationMs, attempt }, 'ok');

For an outbound call the operator wants the endpoint, the status code, the duration, and which retry attempt this was. When an upstream is slow or flapping, these four fields are the whole story.

1 / 1

Notice what these lines leave out: no log.info('entering handler'), no log.info('about to call Stripe'). That restraint is the rule, and its opposite is the anti-pattern: an entering/leaving pair on every function in the call stack. The volume buries the signal, the destination bills you per line, and the pairs carry no diagnostic value, because the requestId already ties every line of the request together and the next meaningful line already proves you got there.

So log the request entry once, log the significant decisions inside it (a cache miss when you expected a hit, a rate-limit headroom check, a branch taken, a fallback fired), and log the outcome. Decisions earn a line; transitions don’t.

A volume threshold sits next to this. Drop routine reads to debug (off in production) and keep info for state changes and error for failures. If a single endpoint’s volume ever dominates the bill, sample: keep 1-in-N info lines, but never sample errors. You keep 100% of errors, always, because an error you didn’t log is an incident you can’t reconstruct.

The second audience is the 9am regulator, and behind the regulator the third-party log vendor (Axiom, next lesson) that stores every line you ship. Once a line lands in a vendor’s index, replicated across their backups, asking them to delete one field from one customer’s records months later runs into hard limits. So the rule is blunt: the cheapest PII to delete is the PII you never sent.

The exclusion list has three tiers.

Secrets — never, in any form

Passwords, plaintext or hashed. API keys and tokens (Stripe keys, OAuth tokens, JWTs, session cookies, signed URLs). Full Authorization and Cookie headers. Full request bodies, which can carry any of the above.

Personal data (PII) under GDPR

Full name, postal address, phone number. IP address (special-cased below). Full card or bank numbers, government IDs, date of birth, precise geolocation.

Special-category data — the brightest line

Health, religion, ethnicity, political opinion, sexual orientation, biometric data. GDPR Article 9 treats these as a sharper prohibition than ordinary PII. Know the category exists and that it’s never operational-log material.

The regulatory terms: PII is data that can identify a person, GDPR is the EU regulation governing it, and special-category data is the Article 9 subset under a stricter prohibition.

The safe list is where careful engineers overcorrect: after absorbing “GDPR” they strip the userId and mask the email, blinding the operator from tying an incident back to a customer. State it plainly: userId, email, orgId, plan, role, and the non-sensitive validated request shape are safe, and they should be logged. They’re load-bearing for support (“find me everything that happened to this customer this week”), fraud investigation, and incident correlation. Redacting them doesn’t make you more compliant; it makes you blind for no benefit.

The operator/user split decides what the logger emits

Section titled “The operator/user split decides what the logger emits”

The split from the error-handling chapter decides logging too: operator-side fields (internal IDs, email) go in the log; user-side fields (name, address, phone) don’t. Redaction, next, is the backstop when a user-side field accidentally rides inside some object you logged.

Operator side — LOG IT diagnostic, not decorative
userId email orgId plan role requestId validated request shape Error.stack error code
User side — REDACT secrets & user-side PII
password token Authorization header full name postal address phone IP (full) card number special-category data
The operator/user split applied to logging: email and IDs land on the safe side, not the redacted one.

Structural redaction: the denylist set once

Section titled “Structural redaction: the denylist set once”

The empty redact slot from the last lesson gets filled here. There are two ways to keep a sensitive field out of a log line.

log.info(
{ user: { id: user.id, email: user.email } },
'profile updated',
);

Fragile. The caller hand-prunes the object, and every developer has to remember to leave user.name and user.phone out while every reviewer has to catch it. One forgotten field leaks forever, invisible to a security review that can’t read every call site.

Redaction is the logger’s job: push it down to the config and it’s enforced everywhere by construction, including in code not yet written.

Here is the redact slot from lib/logger.ts, now filled, plus the PII_KEYS constant it pulls in.

// lib/logger.ts — the redact slot, now filled
export const PII_KEYS = [
'fullName', 'name', 'phone', 'address', 'dateOfBirth', 'ip',
];
export const redactionConfig = {
paths: [
'password', '*.password',
'token', '*.token', '*.apiKey', '*.secret',
'req.headers.authorization',
'req.headers.cookie',
'res.headers["set-cookie"]',
...PII_KEYS.flatMap((key) => [key, `*.${key}`]),
],
censor: '[REDACTED]',
};

The application’s own personal-data fields, named as a constant so they’re reviewed as a unit. This same constant feeds Sentry’s beforeSend redaction from the first lesson, so both enforcement points share one source of truth: add a field here and it’s stripped from logs and error events.

// lib/logger.ts — the redact slot, now filled
export const PII_KEYS = [
'fullName', 'name', 'phone', 'address', 'dateOfBirth', 'ip',
];
export const redactionConfig = {
paths: [
'password', '*.password',
'token', '*.token', '*.apiKey', '*.secret',
'req.headers.authorization',
'req.headers.cookie',
'res.headers["set-cookie"]',
...PII_KEYS.flatMap((key) => [key, `*.${key}`]),
],
censor: '[REDACTED]',
};

The secret keys. Bare password matches a top-level key; * is a single-level wildcard, so *.password matches one level deep, while a dotted path like req.headers.authorization reaches an exact nested location. Wildcards are measurably slower than explicit keys, so fan out by name where you know the shape and reserve * for genuinely unknown nesting.

// lib/logger.ts — the redact slot, now filled
export const PII_KEYS = [
'fullName', 'name', 'phone', 'address', 'dateOfBirth', 'ip',
];
export const redactionConfig = {
paths: [
'password', '*.password',
'token', '*.token', '*.apiKey', '*.secret',
'req.headers.authorization',
'req.headers.cookie',
'res.headers["set-cookie"]',
...PII_KEYS.flatMap((key) => [key, `*.${key}`]),
],
censor: '[REDACTED]',
};

Full Authorization, Cookie, and Set-Cookie headers. These paths are case-sensitive, so authorization won’t match a header logged as Authorization. Node lowercases incoming header names for you, but a hand-built object can reintroduce a capital and silently defeat the redaction, so lowercase header keys before logging.

// lib/logger.ts — the redact slot, now filled
export const PII_KEYS = [
'fullName', 'name', 'phone', 'address', 'dateOfBirth', 'ip',
];
export const redactionConfig = {
paths: [
'password', '*.password',
'token', '*.token', '*.apiKey', '*.secret',
'req.headers.authorization',
'req.headers.cookie',
'res.headers["set-cookie"]',
...PII_KEYS.flatMap((key) => [key, `*.${key}`]),
],
censor: '[REDACTED]',
};

Each PII_KEYS entry fanned out to a top-level path (name) and a one-level-deep path (*.name), so a personal field is caught whether it sits at the root of the logged object or nested inside user.

// lib/logger.ts — the redact slot, now filled
export const PII_KEYS = [
'fullName', 'name', 'phone', 'address', 'dateOfBirth', 'ip',
];
export const redactionConfig = {
paths: [
'password', '*.password',
'token', '*.token', '*.apiKey', '*.secret',
'req.headers.authorization',
'req.headers.cookie',
'res.headers["set-cookie"]',
...PII_KEYS.flatMap((key) => [key, `*.${key}`]),
],
censor: '[REDACTED]',
};

What a matched field becomes, and when matters most: redaction happens at serialize time, before the line leaves the process, so a redacted field never touches stdout, never reaches the drain, and never lands in the vendor’s index. It’s gone before it’s anywhere.

1 / 1

One object buys auditability: logging a new sensitive field means one line here, visible in the diff where review catches it. As defense in depth, a CI check fails the build on hardcoded secret patterns in committed code.

Three applications of the two rules that bite people anyway.

When a Zod parse fails, the shape of the bad input is the diagnosis: which field was wrong, and how. The instinct is to log req.body. Don’t.

Log the validated, redacted output instead. After the parse the result has known keys, and your redact config strips the sensitive ones. Raw req.body can carry anything an attacker sent, including fields you never modeled and so never added to PII_KEYS. A denylist redacts only the keys it knows, so an unbounded object is an unbounded leak. The same trap hides in the validation error.

const parsed = signInSchema.safeParse(input);
if (!parsed.success) {
log.warn({ issues: parsed.error.issues, input }, 'sign-in validation failed');
}

Wrong. Logging input next to issues records the exact payload that failed: the malformed email, the raw password attempt, the PII the validation was guarding.

Errors and stack traces: what’s safe and the message trap

Section titled “Errors and stack traces: what’s safe and the message trap”

Error.stack is safe to log: file paths and line numbers, your code’s geography, no user data.

Error.message is the trap. A message can carry concatenated user input, like Could not find user with email alice@example.com. It’s harmless in the log itself, but the same message travels to other surfaces, a user-facing toast or the audit log, where it leaks. A string baked into the message rides along everywhere the error goes.

The fix is the error-class convention from the error-handling chapter: constant messages, structured fields for context. Write new NotFoundError('user', { email }), not new Error('Could not find user with email ' + email). The { email } field rides through your redact config like any other; the message stays a constant that’s safe on every surface.

The IP is the conditional case. Under GDPR it’s personal data, but it’s also useful for diagnosis: rate-limit context, geographic-anomaly detection during a credential-stuffing wave. You can’t blanket-ban it, and you can’t freely log it either, so split the decision by purpose:

  • Routine info logging: log the IP with the last octet zeroed, as in 192.168.1.0. Enough for a geographic or subnet signal, not enough to single out one person.
  • Security events (sign-in failures, rate-limit breaches): log the full IP under the legitimate-interest basis tied to security, with shorter retention than ordinary operational logs.

Avoid one dangerous oversimplification: masking the last octet does not make an IP “not personal data.” Partial masking reduces identifiability and satisfies data-minimization, so it’s the right default, but if it’s reversible or the remaining data still identifies someone in context, the GDPR obligations persist. Minimize by default, justify the exception.

The mechanics enforce that: ip is in PII_KEYS, so the default is redaction, and logging one takes a deliberate, masked call that names what it’s doing.

First, sort the fields into what your logger config should let through versus what it must catch. Watch the counterintuitive calls: email and userId are safe, a full name and phone are not, a zeroed IP is fine while a full IP is not.

You're deciding what your logger config lets through. Sort each field into where it belongs. Drag each item into the bucket it belongs to, then press Check.

Safe to log Operator-side — diagnostic
Redact / never log Secrets or user-side PII
userId
email
orgId
plan
requestId
durationMs
error.issues
Error.stack
IP, last octet zeroed
password
Authorization header
stripe.token
customer full name
postal address
phone number
full client IP
raw req.body

Now apply it in code. The pull request below adds logging to a sign-in server action. Review it and comment on every line that violates the two rules. One line is correct and present on purpose: flagging it is the over-redaction trap, so think before you comment.

Review this PR adding logging to the sign-in action. Comment on every line that breaks the 3am rule or the exclusion rule — and only those lines. Click any line to leave a review comment, then press Submit review.

lib/actions/sign-in.ts
'use server';
export const signIn = action(async (input) => {
const log = logger.child({ seam: 'action.signIn' });
log.info({ body: input }, 'sign-in attempt');
const parsed = signInSchema.safeParse(input);
if (!parsed.success) {
log.warn({ issues: parsed.error.issues, input }, 'validation failed');
return err('validation', 'Check your email and password.');
}
const result = await verifyCredentials(parsed.data);
if (!result.ok) {
logger.error({ error: JSON.stringify(result.cause) }, 'sign-in failed');
return err('unauthorized', 'Invalid email or password.');
}
const { user } = result;
log.info({ userId: user.id, ip: clientIp }, 'signed in');
log.info({ userId: user.id, email: user.email }, 'session created');
return ok(user);
});

The operational logger you built this chapter is not the audit log from the security chapter. They share some rules but not all, and collapsing them into one stream breaks both.

Operational log this chapter
Audit log the security chapter
Durability
Ephemeral, best-effort stdout → drain
Durable, transactional a DB write inside the transaction
Retention
~30 days rolls off on a timer
Per legal basis longer, kept as required
PII
Redact aggressively secrets & user-side PII stripped
Preserves payloads keeps what the op log strips
Audience
On-call operator diagnose the incident
Compliance / security answer the regulator
The same incident on two streams: the operational log is cheap, redacted, and short-lived; the audit log is durable and complete.

Never route one stream through the other. Don’t reconstruct an audit trail by grepping operational logs; they’re best-effort, redacted, and expired by the time a compliance question arrives. Don’t dump operational diagnostics into the audit table; its retention, cost, and audience are all wrong for them.

Errors and logs are two surfaces of one incident, joined by requestId — carrying what the 3am operator needs and nothing the 9am regulator would object to.