Skip to content
Chapter 92Lesson 3

The 3am rule and PII exclusion

A logging policy for your Pino structured logs, deciding what every 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 the request’s IDs to a file’s logger so your call sites stay lean. The machinery works, but the team has never decided the two things that determine whether running it is worthwhile: what those lines should say, and what they must never say.

Picture the moment this decision is for. It’s 3am, a pager fires, and the on-call engineer opens the log destination filtered by the requestId they copied off a Sentry event. Two questions hang over that screen at once. Can they see enough to diagnose the incident without reproducing it? And is there anything in those lines that a regulator would object to finding six months later, the question nobody asks until it’s expensive? The same log line answers to two audiences who never meet.

That is the whole lesson, and it fits in one sentence worth memorizing: log what an operator at 3am would want, and exclude what a regulator at 9am would object to. Two audiences, two rules, one config. 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 redaction 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 all of this tractable, so let’s name it up front. Back in the error-handling chapter you learned that every error is really two artifacts, a sanitized user string and a rich operator record, and they diverge at the wrapper, never at the UI. The log is the operator side of that split, made queryable. Hold onto that, because it explains a counterintuitive call you’re about to make: it’s the reason an email address is safe to log and a customer’s name is not.

Start with what good looks like, because it motivates why logs exist at all. A denylist with nothing to constrain is abstract; “don’t log that” only means something once you’re already reaching to log things.

Here’s the principle: log the what and the which, not the how. The operator paged at 3am needs to know what operation ran, which entities it touched, and how it turned out. They do not need a play-by-play of your control flow. They are not reading your code line by line; they are reconstructing an incident from the outside, and the log is their only window in.

That principle has a concrete shape that 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.

That’s it. Most operations are one or two lines. The temptation is always to log more; resist it until you’ve felt the cost, which we’ll get to.

To make this concrete, here are four representative log calls, one for each of the seams your app actually has. They use the child-logger idiom from the last lesson, so the requestId and the seam name are already bound, and the call site only has to add what’s specific to this operation. Step through them and watch what each field buys the operator.

// (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 only 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 }. Note that input is the validated shape, not the raw form; the next section shows why that distinction 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 Stripe 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 rather than a session: 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: there’s no log.info('entering handler'), no log.info('about to call Stripe'). That restraint is the rule, not an accident, and it’s worth stating on its own because the opposite is the most common way teams ruin their logs.

The anti-pattern is logging everything. It starts innocently, with a log.info('entering handler') at the top of a function and a log.info('leaving handler') at the bottom, repeated on every function in the call stack. Each pair feels like diligence. In aggregate it’s a disaster: the volume buries the signal, the destination bills you per line, and the entry/exit pairs carry no diagnostic value at all, because the requestId already ties every line of the request together. You don’t need a line that says “I got here”; the presence of the next meaningful line already proves you got there.

So the discipline is to 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 right next to this. A read-heavy endpoint running at high requests-per-second, emitting one info line per hit, can cost more than its diagnostic value the moment it’s under load, and a healthy read returning the same shape a thousand times a second is the least interesting thing in your logs. The move there is to drop those routine reads to debug (which is off in production) and keep info for state changes and error for everything that failed. If a single endpoint’s log volume ever starts dominating the bill, the answer is to sample, keeping 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.

Now the constraint. It comes after the first rule because it only makes sense once you’re already reaching to log things, and the previous section had you reaching.

Meet the second audience. The first was the 3am operator; the second is the regulator at 9am, and standing right behind the regulator is the third-party log vendor, Axiom in the next lesson, that will store every line you ship. That last detail is the one that should change your posture. Once a line leaves your process and lands in a vendor’s index, replicated across their infrastructure and backups, asking them to surgically delete one field from one customer’s records months later runs into hard limits. The framing to internalize is blunt: prevention is cheaper than deletion. The cheapest PII to delete is the PII you never sent.

So here is the exclusion list, grouped into three tiers, because a grouped list is easier to remember than a flat dump.

Secrets — never, in any form

Passwords, plaintext or hashed: neither belongs in a log. 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. The point is to know the category exists and that it’s never operational-log material.

Three of these terms earn a hover definition the first time they appear, because they’re regulatory and non-obvious: PII , GDPR , and special-category data .

Now the part that deserves equal billing, because it’s where careful engineers go wrong: the safe list. After absorbing “GDPR” people overcorrect and start redacting identifiers too. They strip the userId, they mask the email, and in doing so they blind the operator and lose the ability to tie an incident back to a specific customer. So state it plainly: userId, email, orgId, plan, role, and the non-sensitive validated request shape are safe, and they should be logged.

This isn’t a loophole. It’s the operator side of the split you already know. Those identifiers are load-bearing for support (“find me everything that happened to this customer this week”), for fraud investigation, and for incident correlation. Redacting them doesn’t make you more compliant; it makes you blind for no benefit. The contrast is sharp enough to deserve a picture.

The operator/user split decides what the logger emits

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

This is the conceptual crux, so it gets its own heading. You met the split in the error-handling chapter: every error is two artifacts, a sanitized user string and a rich operator record, diverging at the wrapper. The new application is that the same split decides logging. Operator-side fields (internal IDs, email) go in the log; user-side fields (name, address, phone) don’t. Redaction, which is next, is just the backstop that enforces the boundary when a user-side field accidentally rides inside some object you logged.

The figure below draws the boundary. The point it makes unmissable is that email and IDs land on the safe side, not the redacted one.

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
Email and IDs are diagnostic, not decorative — redacting them blinds the operator.

Keep that two-column picture in your head; it’s the one to recall at a call site when you’re about to log an object and you hesitate over a field.

Structural redaction: the denylist set once

Section titled “Structural redaction: the denylist set once”

Now the payoff: the single code artifact of this lesson, the thing that fills the empty redact slot from the last lesson. Before showing the config, it’s worth being clear about why it’s structural, because the structure is the whole point.

There are two ways to keep a sensitive field out of a log line. Compare them.

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

Fragile. The caller hand-prunes the object, remembering to leave user.name and user.phone out. This relies on every developer remembering and every reviewer catching it. One forgotten field leaks forever, and the leak is invisible to a security review that can’t read every call site.

The rule the comparison earns is that redaction is the logger’s job, not the caller’s. The caller’s job is to log honestly; the logger’s job is to know what to censor. Push that responsibility down to the config and it’s enforced everywhere, by construction, including in code that hasn’t been written yet.

Here’s the config that does it: the redact slot from lib/logger.ts, now filled, plus the PII_KEYS constant it pulls in. Step through it.

// 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, pulled out as a named 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. Note the wildcard: bare password matches a top-level key, while *.password matches the key one level deep. The * is a single-level wildcard; a dotted path like req.headers.authorization reaches an exact nested location. Wildcard paths are measurably slower than explicit keys, so fan out by name where you know the shape and reserve * for genuinely unknown nesting, rather than carpet-bombing with *.

// 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. Watch the footgun: these paths are case-sensitive, so authorization will not match a header logged as Authorization. Node lowercases incoming header names for you, so this is usually free, but a hand-built object can reintroduce a capital and silently defeat the redaction. 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]',
};

PII_KEYS fanned out to both a top-level path (name) and a one-level-deep path (*.name) for each key, 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: the string [REDACTED]. The crucial property is when this runs. 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

Putting it all in one object buys auditability. This is a single file a reviewer reads in a pull request. Logging a new field that happens to be sensitive means adding one line here, and that line shows up in the diff, where review catches it. Behind that, as defense in depth, the course wires a small CI check that fails the build on literal sensitive patterns in committed code (a bare password: or secret: followed by a value), so a hardcoded leak never even reaches review. The config is the policy; the CI grep is the seatbelt.

One failure mode is worth naming so you recognize it, because it’s the way this config quietly rots.

Three applications trip people up in the real world. Each follows directly from the two rules, but the consequence isn’t obvious until you’ve been bitten, so let’s walk them.

Sometimes the operator genuinely needs the input. A Zod validation failed, and the shape of the input is the diagnosis: which field was wrong, in what way. The instinct is to log req.body so you can see what came in. Don’t.

Log the validated and redacted output instead, never the raw body. The Zod parse already enforced the shape, so the result has known keys, and your redact config strips the sensitive ones from those known keys. The raw req.body, by contrast, can carry anything an attacker chose to send, including fields you never modeled and therefore never added to PII_KEYS. A denylist can only redact keys it knows about, so an unbounded object is an unbounded leak. Never log req.body before the parse.

The sharpest version of this is logging the validation error itself.

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

Wrong. Attaching input next to issues logs the exact payload that failed validation, the malformed email and the raw password attempt, which is the PII the validation was guarding. The error report carries the very data you were trying to keep out.

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 operator-side and safe to log. It’s file paths and line numbers, your code’s geography, with no user data in it. That one’s easy.

Error.message is the trap. A message can carry concatenated user input: Could not find user with email alice@example.com. The split says the message is operator-side for the log, so logging it is fine in isolation. The problem is that the same message can travel to other surfaces. It might get surfaced in a user-facing toast, or written into the audit log, and on those surfaces it leaks. A field that’s safe in one stream is a leak in another, and a string baked into the message rides along everywhere the error goes.

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

The IP is the genuinely nuanced case, because the answer is conditional. Under GDPR an IP address is personal data. But it’s also genuinely useful for diagnosis: rate-limit context, geographic-anomaly detection during a credential-stuffing wave. So you can’t blanket-ban it, and you can’t freely log it either. The discipline the course adopts splits by purpose:

  • Routine info logging: log the IP with the last octet zeroed, as in 192.168.1.0. That’s 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, and give those logs shorter retention than ordinary operational logs.

One claim to avoid, because it’s a common and dangerous oversimplification: masking the last octet does not make an IP “not personal data” or exempt it from GDPR. Partial masking reduces identifiability and satisfies data-minimization; it lowers risk, and it’s the right default. But if the masking is reversible, or the remaining data still identifies someone in context, the obligations persist. The honest framing is “minimize by default, justify the exception,” not “masking equals anonymous.”

Notice that the mechanics line up with that posture: ip is in PII_KEYS, so the default is redaction. Logging an IP at all requires a deliberate, masked call that names what it’s doing. That’s the disciplined move in miniature: the safe default is exclusion, and the exception is explicit and justified. (Where the shorter retention actually gets enforced is the audit-log retention work in the security chapter; this lesson only flags the IP-specific gotcha.)

Audit the rule: a sign-in flow under review

Section titled “Audit the rule: a sign-in flow under review”

This is a judgment skill, and judgment comes from review, not from reading. So here are two exercises, an easy one and then an applied one.

First, sort the fields. You’re deciding what your logger config should let through versus what it must catch. The whole point is 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. Make each call explicitly.

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 where it actually lives: in code, under review. The pull request below adds logging to a sign-in server action. Read it as a reviewer and leave a comment on every line that violates the two rules. One line is correct and present on purpose; flagging it would be 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);
});

One last distinction, because this is a real conflation and getting it wrong is expensive in both directions. The operational logger you’ve built this chapter is not the audit log from the security chapter. They’re two different streams with overlapping but distinct rules, and treating them as 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
Same incident, two streams: the operational log is built to be cheap, redacted, and short-lived; the audit log is built to be durable and complete. Route one through the other and you break both.

The rule that follows is to never route one stream through the other. Don’t try to reconstruct an audit trail by grepping operational logs; they’re best-effort, redacted, and expired by the time a compliance question arrives. And don’t dump operational diagnostics into the audit table, which has the wrong retention, wrong cost, and wrong audience. Each stream has a job; let it do its own.

That closes the loop this chapter opened. Errors and logs are two surfaces of one incident, joined by requestId, and now the log surface carries exactly what it should: everything the operator at 3am needs, and nothing the regulator at 9am would object to.