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.
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:
info line per successful meaningful operation.error line per failure, carrying the cause chain through the { err } serializer from the last lesson.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 wrapperconst log = logger.child({ seam: 'action.createInvoice', userId, orgId });log.info({ input, durationMs }, 'invoice created');log.error({ err, code, durationMs }, 'create invoice failed');
// (b) webhook handlerconst log = logger.child({ seam: 'webhook.stripe', stripeEventType, eventId });log.info({ signatureVerified, idempotencyHit, durationMs }, 'webhook processed');
// (c) background job — Trigger.dev taskconst log = logger.child({ seam: 'job.exportInvoices', jobId });log.info({ invoiceId, userId, resultCode, attempt }, 'export complete');
// (d) external API callconst 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 wrapperconst log = logger.child({ seam: 'action.createInvoice', userId, orgId });log.info({ input, durationMs }, 'invoice created');log.error({ err, code, durationMs }, 'create invoice failed');
// (b) webhook handlerconst log = logger.child({ seam: 'webhook.stripe', stripeEventType, eventId });log.info({ signatureVerified, idempotencyHit, durationMs }, 'webhook processed');
// (c) background job — Trigger.dev taskconst log = logger.child({ seam: 'job.exportInvoices', jobId });log.info({ invoiceId, userId, resultCode, attempt }, 'export complete');
// (d) external API callconst 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 wrapperconst log = logger.child({ seam: 'action.createInvoice', userId, orgId });log.info({ input, durationMs }, 'invoice created');log.error({ err, code, durationMs }, 'create invoice failed');
// (b) webhook handlerconst log = logger.child({ seam: 'webhook.stripe', stripeEventType, eventId });log.info({ signatureVerified, idempotencyHit, durationMs }, 'webhook processed');
// (c) background job — Trigger.dev taskconst log = logger.child({ seam: 'job.exportInvoices', jobId });log.info({ invoiceId, userId, resultCode, attempt }, 'export complete');
// (d) external API callconst 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 wrapperconst log = logger.child({ seam: 'action.createInvoice', userId, orgId });log.info({ input, durationMs }, 'invoice created');log.error({ err, code, durationMs }, 'create invoice failed');
// (b) webhook handlerconst log = logger.child({ seam: 'webhook.stripe', stripeEventType, eventId });log.info({ signatureVerified, idempotencyHit, durationMs }, 'webhook processed');
// (c) background job — Trigger.dev taskconst log = logger.child({ seam: 'job.exportInvoices', jobId });log.info({ invoiceId, userId, resultCode, attempt }, 'export complete');
// (d) external API callconst 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 wrapperconst log = logger.child({ seam: 'action.createInvoice', userId, orgId });log.info({ input, durationMs }, 'invoice created');log.error({ err, code, durationMs }, 'create invoice failed');
// (b) webhook handlerconst log = logger.child({ seam: 'webhook.stripe', stripeEventType, eventId });log.info({ signatureVerified, idempotencyHit, durationMs }, 'webhook processed');
// (c) background job — Trigger.dev taskconst log = logger.child({ seam: 'job.exportInvoices', jobId });log.info({ invoiceId, userId, resultCode, attempt }, 'export complete');
// (d) external API callconst 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.
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 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.
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.
log.info({ user }, 'profile updated');The discipline. The caller logs the whole object; the logger strips sensitive keys by name at serialize time. A new sensitive field is caught by its name without anyone touching this call site, and the whole policy lives in one reviewable file.
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 filledexport 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 filledexport 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 filledexport 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 filledexport 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 filledexport 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.
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.
const parsed = signInSchema.safeParse(input);if (!parsed.success) { log.warn({ issues: parsed.error.issues }, 'sign-in validation failed');}Right. error.issues carries paths and codes. The operator reads email: invalid format at path ['email'] and knows which field failed and why, without the malformed email ever being logged.
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:
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.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.
userIdemailorgIdplanrequestIddurationMserror.issuesError.stackpasswordAuthorization headerstripe.tokenreq.bodyNow 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.
'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);});This logs raw input before the parse.
The body can hold the plaintext password and any unmodeled field an attacker sent, and a denylist only redacts keys it knows, so the config can’t save you here.
Log the validated, redacted shape after safeParse, never input raw.
parsed.error.issues is already the diagnosis: paths and codes, operator-safe.
Attaching input re-logs the payload that failed — the very PII validation was guarding.
Drop input; keep issues.
message and stack are non-enumerable, so JSON.stringify(result.cause) walks past both and logs {} on your most important line.
Pass { err: result.cause } and let the serializer render { type, message, stack, cause }.
(It also uses the base logger, not the child log, losing the seam binding.)
A successful sign-in is routine, and ip is in PII_KEYS, so the default is redaction.
For a geographic signal, log the last-octet-zeroed IP (192.168.1.0).
Reserve the full IP for security events like repeated failures, under shorter retention.
One sentence decides every line: log what an operator at 3am needs, exclude what a regulator at 9am would object to.
The body, attached input, and full IP break the second half; the JSON.stringify line breaks the first by logging nothing useful.
The last line — userId + email on session creation — is the trap: operator-side identifiers, the load-bearing keys for incident correlation.
Flagging it is the over-redaction reflex, and it blinds the operator for no compliance gain.
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.
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.