The production logger seam
Last lesson you wired Sentry, and the deliberate throw now lands in the dashboard with a readable stack and a release tag. But sit with what that event still doesn’t tell you. It says something broke on the server. It does not say on which request, and it does not promise that what it captured is safe to read — a Sentry event carries breadcrumbs and context, and if a secret rode in on a request it can ride straight into the dashboard. Meanwhile the structured logger has its own gap: replay the Stripe webhook flow right now and the dev console prints the stripe-signature header — the live HMAC that proves a delivery came from Stripe — in the clear. This lesson you make the logger production-grade: no secret ever serializes, and every log line and Sentry event for one request share a requestId you can pivot on.
When it’s done, replaying the webhook renders that signature as [REDACTED] with a top-level requestId on every line, and the Sentry event for that same request carries the matching requestId in its request context — not a tag — with no leaked secret anywhere in it. There’s no UI to screenshot here; the result is two console lines that change shape. The webhook handler goes from dumping the whole header set:
log.info( { headers: Object.fromEntries(request.headers) }, 'request_received',);…to one clean, intentional line that the request scope stamps with an id:
{"level":30,"time":...,"seam":"webhook.stripe","requestId":"0190f...","msg":"request_received"}Your mission
Section titled “Your mission”The seeded Pino logger in src/lib/logger.ts has no scrubbing seam, so the webhook flow logs the Stripe stripe-signature — and any other dropped key — in the clear, a textbook violation of the 3am rule you met in The 3am rule and PII exclusion: a line you wouldn’t paste into a public incident channel at 3am does not ship. On top of that, nothing stamps a request with an id, so a log line and the Sentry event for the same request have no value in common to join on. You close both gaps through one logger seam, and the reason they belong together is structural: the scrubber and the correlation id both have to reach Sentry’s beforeSend (which you wired last lesson) as well as Pino, and splitting them would mean wiring that boundary twice. The load-bearing discipline is one redactor, two callers — declare the redaction routine once and reuse it in both Pino’s output formatter and Sentry’s beforeSend. Duplicating the scrub logic between the two sinks is the failure mode this design exists to prevent: the day someone adds a key to the drop-list, they edit one copy, the other sink keeps leaking, and nobody notices until it’s in the logs. So refactor the redactor into a single exported function before you wire the second caller, not after.
For correlation, reach for AsyncLocalStorage and nothing else. It is the one primitive here you must not substitute: module-level or globalThis state is shared across every concurrent request the process is serving, so one request’s id would bleed into another’s log lines under any real load — the bug that doesn’t show up until production and is nearly impossible to reproduce. The mechanics are the ones from Structured logs with correlation IDs, with one Next.js 16 wrinkle worth naming up front: a scope opened in the proxy does not propagate into route handlers. So the proxy mints the id and threads it across the boundary on a header, and each downstream handler — here, the webhook route — recovers that id from the header and opens its own scope. The header is the carrier; the scope is per-boundary. And because the id is high-cardinality — one distinct value per request — it rides on the Sentry event as context, never as a tag, exactly as the correlation-ID lesson called out; a per-request value in a tag would blow out Sentry’s tag-cardinality index. Out of scope: the Vercel Log Drain that actually reads these logs in production is a deploy-time follow-up from Shipping logs with Vercel Drains, wired when the app ships, not locally. This lesson covers findings 2 and 3; their Fix sections in findings/002-log-secret-leak.md and findings/003-missing-correlation-id.md are filled here, each naming the seam installed and the call sites it now governs.
authorization, cookie, stripe-signature, password, token, apikey, the PII keys email / phone / ip / ssn, and any key ending in _key or _secret, all matched case-insensitively — and is the only redaction logic in the codebase.stripe-signature as [REDACTED] in the log lines rather than printing the signing material in the clear.x-request-id and echoes it on both the request and the response headers; the webhook handler recovers the same id and opens its own scope.requestId field sourced from the request-scoped context.requestId in its request context (not a tag), so a log line and its Sentry event join on one value.findings/002-log-secret-leak.md and findings/003-missing-correlation-id.md Fix sections name the installed seam and the call sites it governs.Coding time
Section titled “Coding time”Implement against the brief and the lesson’s tests, then read the reference solution below. The tests for this lesson can’t import the seam — redact, the logger, and the request context all sit behind import 'server-only', which throws the instant a Node test environment touches them — so they read your source and prove it carries the structure that produces the safe, correlated behavior. That makes attempting it yourself worth more than usual: a source-shape gate is satisfied by the shape of the seam, but only a live replay tells you the secret is actually gone.
Reference solution and walkthrough
Five files move, and the order they move in is the lesson. Start with the context store, because both the logger’s mixin and Sentry’s beforeSend read from it.
src/lib/request-context.ts is new — the single store every seam reads.
import 'server-only';
import { AsyncLocalStorage } from 'node:async_hooks';
export type RequestContext = { requestId: string; userId?: string; orgId?: string;};
const storage = new AsyncLocalStorage<RequestContext>();
export const runWithContext = <T>(context: RequestContext, fn: () => T): T => storage.run(context, fn);
export const getRequestContext = (): RequestContext | undefined => storage.getStore();runWithContext opens a scope and runs fn inside it; getRequestContext reads whatever scope is currently live, or undefined when there is none. requestId is the join key the whole lesson turns on; userId and orgId are optional passengers a seam can attach once it knows them, so a log line, a Sentry event, and a downstream service can all point at the same request. The import 'server-only' is what keeps this off the client bundle — AsyncLocalStorage is a Node primitive and has no business shipping to a browser.
src/lib/logger.ts gains the redactor and the mixin. The redaction routine is worth stepping through, because its three branches are the whole correctness argument.
const DROP_KEYS = new Set([ 'authorization', 'cookie', 'stripe-signature', 'password', 'token', 'apikey',]);
const PII_KEYS = new Set(['email', 'phone', 'ip', 'ssn']);
const REDACTED = '[REDACTED]';
const shouldDrop = (key: string): boolean => { const lower = key.toLowerCase(); return ( DROP_KEYS.has(lower) || PII_KEYS.has(lower) || lower.endsWith('_key') || lower.endsWith('_secret') );};
export const redact = <T>(payload: T): T => { if (Array.isArray(payload)) { return payload.map((item) => redact(item)) as T; } if (payload !== null && typeof payload === 'object') { const entries = Object.entries(payload as Record<string, unknown>).map( ([key, value]) => shouldDrop(key) ? [key, REDACTED] : [key, redact(value)], ); return Object.fromEntries(entries) as T; } return payload;};The drop-list and PII set are the canonical secret/PII keys; shouldDrop lowercases the key first, so Stripe-Signature and stripe-signature are the same secret, then matches the exact list OR a _key/_secret suffix — a future stripe_api_key or webhook_secret is caught by pattern, without anyone touching this list.
const DROP_KEYS = new Set([ 'authorization', 'cookie', 'stripe-signature', 'password', 'token', 'apikey',]);
const PII_KEYS = new Set(['email', 'phone', 'ip', 'ssn']);
const REDACTED = '[REDACTED]';
const shouldDrop = (key: string): boolean => { const lower = key.toLowerCase(); return ( DROP_KEYS.has(lower) || PII_KEYS.has(lower) || lower.endsWith('_key') || lower.endsWith('_secret') );};
export const redact = <T>(payload: T): T => { if (Array.isArray(payload)) { return payload.map((item) => redact(item)) as T; } if (payload !== null && typeof payload === 'object') { const entries = Object.entries(payload as Record<string, unknown>).map( ([key, value]) => shouldDrop(key) ? [key, REDACTED] : [key, redact(value)], ); return Object.fromEntries(entries) as T; } return payload;};Arrays recurse element-by-element, so a secret nested in a list is still found.
const DROP_KEYS = new Set([ 'authorization', 'cookie', 'stripe-signature', 'password', 'token', 'apikey',]);
const PII_KEYS = new Set(['email', 'phone', 'ip', 'ssn']);
const REDACTED = '[REDACTED]';
const shouldDrop = (key: string): boolean => { const lower = key.toLowerCase(); return ( DROP_KEYS.has(lower) || PII_KEYS.has(lower) || lower.endsWith('_key') || lower.endsWith('_secret') );};
export const redact = <T>(payload: T): T => { if (Array.isArray(payload)) { return payload.map((item) => redact(item)) as T; } if (payload !== null && typeof payload === 'object') { const entries = Object.entries(payload as Record<string, unknown>).map( ([key, value]) => shouldDrop(key) ? [key, REDACTED] : [key, redact(value)], ); return Object.fromEntries(entries) as T; } return payload;};Objects map each entry: a dropped key’s value becomes [REDACTED]; every other value recurses. Replacing rather than deleting preserves the surrounding structure, so the line stays readable while the secret never serializes.
const DROP_KEYS = new Set([ 'authorization', 'cookie', 'stripe-signature', 'password', 'token', 'apikey',]);
const PII_KEYS = new Set(['email', 'phone', 'ip', 'ssn']);
const REDACTED = '[REDACTED]';
const shouldDrop = (key: string): boolean => { const lower = key.toLowerCase(); return ( DROP_KEYS.has(lower) || PII_KEYS.has(lower) || lower.endsWith('_key') || lower.endsWith('_secret') );};
export const redact = <T>(payload: T): T => { if (Array.isArray(payload)) { return payload.map((item) => redact(item)) as T; } if (payload !== null && typeof payload === 'object') { const entries = Object.entries(payload as Record<string, unknown>).map( ([key, value]) => shouldDrop(key) ? [key, REDACTED] : [key, redact(value)], ); return Object.fromEntries(entries) as T; } return payload;};Scalars pass through untouched — the recursion’s base case.
Then the Pino instance itself, where the redactor becomes caller one and the mixin reads the context:
export const logger = pino({ level: process.env.LOG_LEVEL ?? 'info', base: undefined, formatters: { log: (object) => redact(object), }, mixin: () => getRequestContext() ?? {},});formatters.log runs redact over every log object on the way out, so no call site has to remember to scrub — the seam catches it. mixin runs per line and merges in whatever the live request scope holds, so requestId lands on every line automatically; when there’s no scope it merges {} and the line is simply un-correlated rather than crashing.
src/proxy.ts is where the id is born. The existing logic — the cookie redirects and the CSP nonce, both pre-fixed in earlier work — moves into a handle function, and proxy becomes a thin shell that mints the id and opens the scope around it.
export async function proxy(request: NextRequest) { // cookiePrefix is mandatory — the better-auth default silently misses the // __Host- cookie. This is presence-only; no authz decision lives here. const cookie = getSessionCookie(request, { cookiePrefix: SESSION_COOKIE_PREFIX, }); // ...}No correlation scope. The proxy threads the CSP nonce onto the request header and returns, but mints no request id and opens no scope — so nothing downstream can join a log line to its request.
export async function proxy(request: NextRequest) { const requestId = request.headers.get('x-request-id') ?? uuidv7(); return runWithContext({ requestId }, () => handle(request, requestId));}
async function handle(request: NextRequest, requestId: string) { // ...}Mint-or-recover, then open the scope. proxy reads an inbound x-request-id (an upstream proxy may have already minted one) or mints a fresh uuidv7(), then runs the whole request inside one runWithContext scope so any line the proxy emits carries the id. The real work lives in handle.
Inside handle, the id is echoed three ways — onto the request headers (so the route handler can recover it), and onto every response path, including the two redirects and the final NextResponse.next:
if (isProtected && !cookie) { const next = encodeURIComponent(path + request.nextUrl.search); const redirect = NextResponse.redirect( new URL(`/sign-in?next=${next}`, request.url), ); redirect.headers.set('x-request-id', requestId); return redirect; }
// ...
const requestHeaders = new Headers(request.headers); requestHeaders.set('x-nonce', nonce); requestHeaders.set('x-request-id', requestId);
const response = NextResponse.next({ request: { headers: requestHeaders } }); response.headers.set('Content-Security-Policy', csp); response.headers.set('x-request-id', requestId); return response;Echoing it on the response is easy to skip and worth keeping: a downstream service — a CDN log, a browser network panel, the next hop in a chain — can read the response header and join on the same request. Echoing it on the request headers is what lets the route handler recover it, which is the next file.
src/app/api/webhooks/stripe/route.ts is the boundary the proxy scope can’t cross. The handler recovers the id and opens its own scope, and the leaked header dump becomes one intentional line.
export const POST = async (request: Request): Promise<Response> => { const body = await request.text(); const signature = request.headers.get('stripe-signature');
log.info( { headers: Object.fromEntries(request.headers) }, 'request_received', ); // ...};Object.fromEntries(request.headers) is the leak. It serializes the entire header set — stripe-signature, cookie, authorization — verbatim into the log line. And POST runs the body with no scope of its own, so even once the logger has a mixin there is no requestId for it to read here.
export const POST = async (request: Request): Promise<Response> => { const requestId = request.headers.get('x-request-id') ?? uuidv7(); return runWithContext({ requestId }, () => handle(request));};
const handle = async (request: Request): Promise<Response> => { const body = await request.text(); const signature = request.headers.get('stripe-signature');
log.info('request_received'); // ...};Recover the id, open a scope, log only what you mean to. POST recovers x-request-id (or mints its own, so the handler is correlated even when hit directly) and wraps the work in runWithContext. The log call now carries no payload at all — and even if a future field crept in, the redact seam in formatters.log would catch it. Two layers, reinforcing each other: the call site never dumps headers, and the seam scrubs anything that slips.
sentry.server.config.ts is the second caller. Last lesson’s beforeSend was a placeholder; now it does two distinct jobs in one hook.
import * as Sentry from '@sentry/nextjs';
import { redact } from '@/lib/logger';import { getRequestContext } from '@/lib/request-context';
const release = process.env.VERCEL_GIT_COMMIT_SHA ?? 'dev';
Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, release, tracesSampleRate: 1.0, beforeSend: (event) => { const scrubbed = redact(event); const requestId = getRequestContext()?.requestId; if (requestId !== undefined) { scrubbed.contexts = { ...scrubbed.contexts, request: { ...scrubbed.contexts?.request, requestId }, }; } return scrubbed; },});Caller two. The same redact from lib/logger.ts runs over the whole event, so a secret captured in event context is scrubbed before it leaves the process. One definition, both sinks — duplicating it here is exactly the drift the design forbids.
import * as Sentry from '@sentry/nextjs';
import { redact } from '@/lib/logger';import { getRequestContext } from '@/lib/request-context';
const release = process.env.VERCEL_GIT_COMMIT_SHA ?? 'dev';
Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, release, tracesSampleRate: 1.0, beforeSend: (event) => { const scrubbed = redact(event); const requestId = getRequestContext()?.requestId; if (requestId !== undefined) { scrubbed.contexts = { ...scrubbed.contexts, request: { ...scrubbed.contexts?.request, requestId }, }; } return scrubbed; },});The correlation join — and it lives inside beforeSend on purpose. beforeSend runs per event with the request scope live; reading getRequestContext() at module scope would run once at boot, with no request, and attach nothing.
import * as Sentry from '@sentry/nextjs';
import { redact } from '@/lib/logger';import { getRequestContext } from '@/lib/request-context';
const release = process.env.VERCEL_GIT_COMMIT_SHA ?? 'dev';
Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, release, tracesSampleRate: 1.0, beforeSend: (event) => { const scrubbed = redact(event); const requestId = getRequestContext()?.requestId; if (requestId !== undefined) { scrubbed.contexts = { ...scrubbed.contexts, request: { ...scrubbed.contexts?.request, requestId }, }; } return scrubbed; },});The id rides as request context, never a tag. It is one distinct value per request — high-cardinality — and a tag index can’t absorb that. Context is the right home, and it’s what lets a log line and this event join on one value.
A few decisions are worth saying out loud.
Why refactor redact into one exported function before wiring Sentry. The redactor has to feed both Pino’s formatters.log and Sentry’s beforeSend. Declaring it once and importing it in both places is the “one redactor, two callers” discipline; the alternative — a copy in each sink — means a future drop-list edit lands in one and not the other, and the gap ships silently. Refactor first, wire the second caller second.
Why requestId is context and not a tag. It’s high-cardinality — a fresh value every request — and Sentry’s tag index is built for low-cardinality facets you filter and group by (release, environment, plan). A per-request id as a tag would bloat that index for no queryable benefit; context is the right shelf, and it still joins to the log line.
Why the proxy scope doesn’t reach the route handler. Next.js 16 does not propagate a proxy-opened AsyncLocalStorage scope into route handlers — they run in a different async context. So the header is the cross-boundary carrier: the proxy writes x-request-id, the handler reads it back and opens a fresh scope. Forgetting the handler’s own scope is the named trap — its lines would carry no id while the proxy’s do, and the join you built would be half-there.
Two more things close out the findings half of the work. Fill the Fix section of findings/002-log-secret-leak.md to name the single redact seam in lib/logger.ts and its two callers — Pino’s formatters.log and Sentry’s beforeSend — plus the webhook handler that stopped dumping headers. Fill the Fix section of findings/003-missing-correlation-id.md to name the AsyncLocalStorage store in request-context.ts, the proxy.ts mint-and-echo, the Pino mixin, and the beforeSend context join, calling out that route handlers open their own scope. The 3am rule itself and the correlation-ID concept are taught in The 3am rule and PII exclusion and Structured logs with correlation IDs — point the Fix sections at the seams you installed, not at a re-explanation.
The AsyncLocalStorage reference — run() and getStore(), the primitive your request-context store wraps.
How Pino formatters and the censor string work — context for the redact seam you hang off formatters.log.
Using beforeSend to strip secrets before an event leaves the process — the second caller of your redactor.
The Next.js 16 proxy reference, including setting request headers via NextResponse.next — how you thread x-request-id across the boundary.
Moment of truth
Section titled “Moment of truth”Run the lesson’s gate:
pnpm test:lesson 4A clean pass looks like this — both finding blocks green:
✓ Req 1/2 — one redaction seam carrying the canonical drop-list (6) ✓ Req 4 — a per-request correlation scope joined on x-request-id (3)
Test Files 1 passed (1) Tests 9 passed (9)The gate reads your source and confirms the shape — that redact is exported once and reused in beforeSend, that the context lives in AsyncLocalStorage, that the proxy and the webhook handler each mint-or-recover and open a scope, and that the Object.fromEntries(request.headers) leak is gone. What it can’t do is watch the bytes move. For that, the surfaces are the dev console (your log lines) and the Sentry dashboard (the event for a thrown request). Tick these off by hand:
stripe trigger payment_intent.succeeded or stripe listen --forward-to localhost:3000/api/webhooks/stripe) shows stripe-signature as [REDACTED] in the console — never the raw t=...,v1=<hex> signing material.requestId field.requestId — in its request context, not a tag — as the request’s log line, so you can pivot from one to the other.findings/002-log-secret-leak.md and findings/003-missing-correlation-id.md Fix sections each name their seam and the call sites it governs.