Skip to content
Chapter 92Lesson 2

Structured logs with correlation IDs

Emit queryable JSON logs with Pino and thread a shared requestId through every line using AsyncLocalStorage, so logs and Sentry events join back to one request.

A Stripe webhook handler 500s, and it only happens for one org. You open the Sentry event from the last lesson, and it tells you a lot: the stack trace, the line that threw, the org tag, the release it shipped in. It tells you what threw. It does not tell you what happened: the last step that succeeded before the throw, the upstream response the handler got back, whether the idempotency ledger already had this event. That narrative isn’t in the stack trace. It lives in the log lines the request emitted on its way to the crash.

That narrative is the second surface this chapter promised. The last lesson built the error surface; this one builds the log surface, and the single value that joins the two. Every server-side log line and every Sentry event for the same request will carry the same requestId, so an on-call engineer reading a Sentry event can copy that ID and pivot straight to the per-request log narrative. The pivot itself, the UI where you paste the ID and read the story, is a couple of lessons out. Today you plant the shared ID and the discipline that makes the logs worth pivoting to.

That requestId is a correlation ID , and threading it through every log line is the central pattern of this lesson. But before you can correlate logs, the logs have to be worth correlating. So you start one step earlier, with the shape of a single line.

Why a log line is a JSON object, not a sentence

Section titled “Why a log line is a JSON object, not a sentence”

Here is the threshold most people cross without noticing. The default tool, console.log, invites you to write a sentence: console.log('user ' + userId + ' processed invoice ' + id). It runs, it shows up in Vercel’s function output, and for about a week it feels fine. Then the webhook 500s for one org, you go looking, and you discover what that sentence actually bought you: substring search over the last hour, and nothing else. You can grep for processed invoice. You cannot ask “show me every error for org_123 where the request took longer than a second,” because that question needs the machine to understand org_123 and a second as values, and all it has is a flat string.

The fix is to stop writing sentences and start emitting objects. Compare the two:

console.log('user ' + userId + ' processed invoice ' + invoiceId);

Grep-only. The whole line is one opaque string. The only question you can ask of it later is “does this substring appear?” IDs, durations, and levels are baked into prose the index can’t see.

Read the structured version closely, because its shape is the contract for everything that follows. The message, 'invoice processed', is a short, stable phrase a human reads. Everything that varies per request is a key on the object: orgId, invoiceId, durationMs. The log destination you wire up later treats every one of those keys as a column you can filter and aggregate on. The string version threw all of that away the moment it concatenated.

So the discipline, stated literally: every server-side log line is one JSON object. After this lesson you stop writing string log lines in server code. The phrase goes in the message; the facts go in keys.

That raises an obvious question: which keys? If every line invented its own, the dashboards would be chaos. So the codebase fixes a base set of keys that every line carries, and lets each call site append its own on top:

  • level: debug / info / warn / error.
  • time: an ISO 8601 timestamp.
  • msg: the short, low-cardinality phrase.
  • requestId: the correlation ID that joins this line to its siblings and to Sentry.
  • userId? and orgId?: who and which tenant, when known.
  • service: which deployable emitted it ('app', 'worker').
  • env: production / preview / development.

Then domain keys append per line: invoiceId, webhookEventType, durationMs, whatever the event is about. A fixed base set is what keeps a dashboard built today readable next quarter and consistent across two services that share it.

Here’s the part worth holding onto, because it makes the next two sections feel like payoffs instead of chores. You don’t hand-write most of those keys at the call site. time and level come from the logger for free. service, env, and release get baked in once when you configure the logger, which is the next section. And requestId, userId, and orgId get stamped on automatically by a per-request child logger, which is the section after that. By the time you’re calling logger.info(...) in a deep helper, the only keys you type are the domain ones. The rest ride along because you wired them once.

Let’s wire that first piece now.

The whole point of a logger module is to make the right thing the only thing you have to type. You configure the base keys, the redaction, and the output once, in one file, and every other file imports a ready-made logger and just calls it. The 2026 default for this in Node is pino : it’s fast, it emits JSON by design, and it has a child-logger API that, as you’ll see shortly, makes correlation nearly free. You’ll hear winston, bunyan, and consola named as alternatives, but for this stack there’s no reason to reach for them.

Here’s the file. Read it once top to bottom, then step through it.

import 'server-only';
import pino from 'pino';
export const logger = pino({
base: {
service: 'app',
env: process.env.VERCEL_ENV ?? 'development',
release: process.env.VERCEL_GIT_COMMIT_SHA,
},
level: process.env.LOG_LEVEL ?? 'info',
serializers: { err: pino.stdSerializers.err },
redact: redactionConfig,
});

The logger reads server environment variables and must never end up in a client bundle. The server-only import makes that a build error rather than a leak, the same rule every adapter in lib/ follows.

import 'server-only';
import pino from 'pino';
export const logger = pino({
base: {
service: 'app',
env: process.env.VERCEL_ENV ?? 'development',
release: process.env.VERCEL_GIT_COMMIT_SHA,
},
level: process.env.LOG_LEVEL ?? 'info',
serializers: { err: pino.stdSerializers.err },
redact: redactionConfig,
});

base is the set of keys pinned onto every single line this logger emits. Three keys, configured once: which service this is, which environment it’s running in, and which release it shipped in. No call site ever types these again.

import 'server-only';
import pino from 'pino';
export const logger = pino({
base: {
service: 'app',
env: process.env.VERCEL_ENV ?? 'development',
release: process.env.VERCEL_GIT_COMMIT_SHA,
},
level: process.env.LOG_LEVEL ?? 'info',
serializers: { err: pino.stdSerializers.err },
redact: redactionConfig,
});

release reads VERCEL_GIT_COMMIT_SHA, deliberately the same value Sentry tags its releases with from the last lesson. So your logs and your Sentry events now join on the release, not just on the requestId: a regression you see in Sentry maps to the exact deploy in your logs too.

import 'server-only';
import pino from 'pino';
export const logger = pino({
base: {
service: 'app',
env: process.env.VERCEL_ENV ?? 'development',
release: process.env.VERCEL_GIT_COMMIT_SHA,
},
level: process.env.LOG_LEVEL ?? 'info',
serializers: { err: pino.stdSerializers.err },
redact: redactionConfig,
});

The minimum level to emit, read from LOG_LEVEL: info in production to skip the chatty debug lines, debug locally where you want them. Levels are the back half of this lesson.

import 'server-only';
import pino from 'pino';
export const logger = pino({
base: {
service: 'app',
env: process.env.VERCEL_ENV ?? 'development',
release: process.env.VERCEL_GIT_COMMIT_SHA,
},
level: process.env.LOG_LEVEL ?? 'info',
serializers: { err: pino.stdSerializers.err },
redact: redactionConfig,
});

A serializer turns an awkward value into clean JSON. pino.stdSerializers.err renders a thrown Error as { type, message, stack, cause }, and because it walks the cause chain, a wrapped-and-rethrown error keeps its full chain in the log, the same discipline you built around Error.cause earlier in the course.

import 'server-only';
import pino from 'pino';
export const logger = pino({
base: {
service: 'app',
env: process.env.VERCEL_ENV ?? 'development',
release: process.env.VERCEL_GIT_COMMIT_SHA,
},
level: process.env.LOG_LEVEL ?? 'info',
serializers: { err: pino.stdSerializers.err },
redact: redactionConfig,
});

Redaction also lives here: a denylist of keys that get stripped before a line is serialized, so PII and secrets never reach the destination. The contents of that list are the next lesson’s job. For now, note that the slot exists, and that redaction is structural, configured once here, rather than something you remember at each call site.

1 / 1

A note on the process.env.VERCEL_* reads, because you’ve been trained to route environment variables through the validated env.ts schema. The logger is intentionally an exception, and it sits in the same tier as the Sentry config files from the last lesson. These files run at the very edge of the process, before and around the app, and they read the platform’s own variables directly. Keep the logger consistent with the Sentry setup: direct process.env, not the schema.

The transport footgun that breaks on Vercel

Section titled “The transport footgun that breaks on Vercel”

Now the trap, because it bites people who learned pino from a blog post. pino has a feature called a transport : a pluggable shipper that takes your log lines and does something with them, such as pretty-printing them or sending them to a service. For performance, a transport runs in a worker thread, off the main thread.

That worker thread is the problem. Serverless functions on Vercel are torn down and spun back up constantly, and when the function tears down between invocations, the worker thread goes with it, sometimes mid-flush. The result is a transport that breaks silently on cold paths: logs vanish and nothing tells you. Pasting a pino.transport(...) block from a tutorial into your production config is how you ship a logger that drops lines in exactly the incident you wrote it for.

The fix is almost anticlimactic: in production, don’t configure a transport at all. With no transport, pino writes JSON straight to synchronous stdout , and Vercel captures stdout line by line. That captured stream is what the destination, a couple of lessons out, drains and ships onward. Synchronous stdout is the serverless-correct default, and it’s what you get by writing nothing.

A transport still earns its place in one spot: local development, where raw JSON is miserable to read and pino-pretty turns it into colored, aligned lines. So you gate it on the environment. The two panes below abbreviate the config from above to spotlight just that gating.

src/lib/logger.ts
export const logger = pino({
base,
level: process.env.LOG_LEVEL ?? 'info',
});

No transport. pino writes JSON to synchronous stdout, and Vercel captures it. This is the serverless-safe default: there’s no worker thread to tear down.

That’s the logger configured: base keys baked in, errors serialized, output serverless-safe. What it’s still missing is the key that makes a line findable, the requestId. That’s next, and it’s the heart of the lesson.

Threading requestId through AsyncLocalStorage

Section titled “Threading requestId through AsyncLocalStorage”

Here’s the problem in its raw form. A requestId is born at the request boundary, the very first moment Next.js hands you the request. The log line that needs it might be emitted six function calls deep, inside a /lib query helper that has no idea a request is even happening. How does the ID get from the boundary down to that helper?

The obvious answer is the bad one: pass it as a parameter. Add a requestId argument to the action, which passes it to the service, which passes it to the query helper, which passes it to the logger. That’s prop-drilling, but on the server: every function in the call stack grows a parameter it only carries so something deeper can use it, and the moment one link forgets, the chain breaks. What you need is a request-scoped store that any code in the call stack can read without anyone passing it down.

Node has a built-in answer, and you’ll build up to it in three moves: what the tool is, where the ID comes from, and how it reaches every call site.

Move 1: the store that follows the call stack

Section titled “Move 1: the store that follows the call stack”

The tool is AsyncLocalStorage , from Node’s node:async_hooks module, ALS for short. The mental model: you open a “scope” with a value, and anything that runs inside that scope, synchronously or through awaits and however deep, can read that value back with no parameter passing. When the scope ends, the value is gone. Crucially, it’s isolated per request: two requests in flight at once each see their own value, never each other’s.

The shape is two methods. als.run(value, callback) opens a scope: it sets value as the store and runs callback inside it. als.getStore() reads the current store from anywhere inside that callback’s call stack, no matter how deep. That getStore() reaching down the stack without a parameter is the whole trick, and it’s what replaces the prop-drilling.

One platform fact makes this available where you need it: in Next.js 16, proxy.ts runs on the Node runtime, so node:async_hooks is there at the request boundary. That matters because the boundary is where you’ll open the scope.

Before you can store the ID, you need one. The rule at the entry seam is read-or-generate:

  • Read the incoming x-request-id header if it’s present. Vercel’s edge and many proxies already stamp one on the request, and if there’s an ID upstream, you adopt it so the whole hop shares one value.
  • Generate one if there isn’t. The course standard is uuidv7(), the same time-ordered ID you use for primary keys. It’s already in your toolbox and sortable by creation time, which is a nice property for a log ID. (crypto.randomUUID() is the zero-dependency fallback if you don’t want to pull in the helper.)
  • Echo it back on the response as x-request-id. Now a client that hits an error can quote the exact ID in a bug report, and the operator searches for that string and lands on the request.

Move 3: the ALS module and the child logger

Section titled “Move 3: the ALS module and the child logger”

Now you put it in a module of its own, because the store has a shape, a contract other code depends on, and that shape deserves a named home and a type. Here’s lib/request-context.ts.

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();

The import. AsyncLocalStorage is built into Node: no dependency, it ships with the runtime.

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();

The store’s shape, written down as a type because it’s a seam contract that two entry points and every reader depend on. requestId is always present; userId and orgId are optional, because at the very first seam, the proxy, authentication hasn’t happened yet.

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();

One ALS instance for the whole app, typed to the store shape. Notice it’s at module scope, but it holds no value on its own: it’s just the mechanism. The value only exists inside a run call. (Putting an actual store value at module scope is the classic ALS bug, named just below.)

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();

A thin wrapper over storage.run. Call it at an entry seam with a fresh context and the work to run; everything inside that work can now read the context.

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();

The reader any code in the stack calls to get the current context, or undefined if it’s running outside a request scope (a startup task, say). This is the call that replaces prop-drilling.

1 / 1

Here’s the footgun promised earlier, because it’s the one beginners hit and it fails silently. The storage instance lives at module scope, which is correct: it’s shared. The value must not. If you ever set the store once at module load (a single shared context object) instead of opening a fresh scope per request inside runWithContext, every request reads and overwrites the same object, and one request’s userId leaks into another’s logs. The whole point of ALS is per-request isolation, and you only get it by calling run per request. Module-scope instance, per-request value, never the other way around.

And now the payoff that ties the module back to the logger. pino loggers have a child method: logger.child(extraKeys) returns a child logger that stamps extraKeys onto every line it emits. Point it at the request context and you get a logger pre-loaded with the request’s IDs:

import { logger } from '@/lib/logger';
logger.info({ invoiceId }, 'invoice processed');
// → { ..., msg: 'invoice processed', invoiceId } — no requestId

Uncorrelated. A line through the base logger carries the base keys and your domain key, but no requestId, userId, or orgId. It can’t be joined back to a request or to its Sentry event. This is the line you don’t want.

Consider what tab B buys you. Once you’ve made that child logger, correlation is structural: every line it emits is joinable, and you never type a requestId again. The alternative, tab A’s world, is correlation as per-call-site discipline: remembering to attach the right ID at every single logger.info in the codebase, and silently losing the join the first time anyone forgets. The child logger turns a discipline you’d inevitably break into a property of the logger itself.

There’s one gap left, and it’s the subtle part. The child reads getRequestContext(), which only returns something if some seam opened a scope with runWithContext. So where do you open it?

Two seams own the context: proxy.ts and authedAction

Section titled “Two seams own the context: proxy.ts and authedAction”

Your instinct will be to open the scope once, in proxy.ts, since every request flows through there. That instinct is wrong in a way that’s worth understanding, because getting it wrong produces logs that look correct in development and lose their requestId in production.

Here’s the fact: Next.js does not propagate an ALS scope set in proxy.ts into your route handlers, server components, or server actions. The proxy and the handler run in execution contexts that don’t share the proxy’s ALS frame. This is a known, confirmed boundary in Next.js, not a configuration you can flip. A scope you open in the proxy covers the proxy’s own work and nothing downstream. So you establish the context at each entry seam independently. Two seams, not one.

The ALS scope doesn’t cross that boundary, but a header does. So the proxy forwards the requestId on the request itself, as an x-request-id request header, and the second seam reads it back to open its own scope with the very same ID. The value survives the hop even though the scope doesn’t. That’s the thread that keeps it one requestId end to end.

An entry seam is a chokepoint every request of a kind passes through. You already own two of them.

src/proxy.ts
export default function proxy(request: NextRequest) {
const requestId = request.headers.get('x-request-id') ?? uuidv7();
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-request-id', requestId);
return runWithContext({ requestId }, () => {
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set('x-request-id', requestId);
return response;
});
}

Bare context, at the edge. The proxy reads-or-generates the requestId, forwards it on the request so the second seam can recover it, opens the scope with just that, and echoes the ID on the response. No userId or orgId yet, because auth hasn’t run.

Notice the difference between the two store objects: the proxy’s is { requestId }, the action wrapper’s is { requestId, userId, orgId }. That’s why two seams isn’t just a workaround for a framework limitation: it’s where the enriched context, the one with the actor and tenant attached, actually gets to exist. Most of your domain logs are emitted inside actions, so most of them want the enriched store.

Now reconnect this to the last lesson, because here is where the chapter’s promise gets paid off in code. The authedAction wrapper’s catch already calls logger.error(...) and Sentry.captureException(...), which you wrote last lesson. This lesson is what makes those logger.error lines carry a requestId, because they’re now emitting inside the scope this wrapper opened. And the join works in both directions only if the same requestId lands on the Sentry event:

src/lib/authed-action.ts
Sentry.getCurrentScope().setContext('request', { requestId });
Sentry.captureException(error, {
tags: { seam: 'authedAction', action: fn.name },
user: { id: ctx.user.id, email: ctx.user.email },
});

Note where the requestId goes: on the Sentry context, not as a tag. That’s the exact rule the last lesson covered. A requestId is high-cardinality and ephemeral, so it rides as context Sentry stores but doesn’t try to index as a filter dimension. The point isn’t to filter Sentry by requestId; it’s that when you’re reading one event, the requestId is sitting right there to copy and paste into your log search. Same ID, two surfaces, one incident.

That’s the entire mechanism. Now watch one request carry it end to end.

proxy.ts edge seam req_a1b2 authedAction after auth req_a1b2 /lib helper child logger req_a1b2 Sentry · response outbound req_a1b2
opens scope runWithContext({ requestId }, …)
context { requestId: 'req_a1b2' }
The request arrives. proxy.ts reads-or-generates the requestId (req_a1b2) and opens the ALS scope with runWithContext({ requestId }, …).
proxy.ts edge seam req_a1b2 authedAction after auth req_a1b2 /lib helper child logger req_a1b2 Sentry · response outbound req_a1b2
re-opens scope runWithContext({ requestId, userId, orgId }, …)
context { requestId: 'req_a1b2', userId, orgId }
Auth resolves inside authedAction. The proxy's scope didn't cross the boundary, so the wrapper recovers the same requestId from the x-request-id header and opens an enriched scope — same ID, now with userId and orgId.
proxy.ts edge seam req_a1b2 authedAction after auth req_a1b2 /lib helper child logger req_a1b2 Sentry · response outbound req_a1b2
reads scope logger.child(getRequestContext())
log line { level: 'info', requestId: 'req_a1b2', userId, orgId, invoiceId, msg }

The helper never received requestId as an argument — it reached up through the ALS scope. No prop-drilling.

Deep in a /lib helper, the child logger emits a line. requestId, userId, and orgId all appear on it — nobody passed them down.
proxy.ts edge seam req_a1b2 authedAction after auth req_a1b2 /lib helper child logger req_a1b2 Sentry · response outbound req_a1b2
Sentry event context.request = { requestId: 'req_a1b2' }
HTTP response x-request-id: req_a1b2
The action throws or completes. Sentry.captureException sets the same requestId on the event's context, and the response echoes x-request-id.
proxy.ts edge seam req_a1b2 authedAction after auth req_a1b2 /lib helper child logger req_a1b2 Sentry · response outbound req_a1b2
Sentry event req_a1b2 · stack trace
Log destination req_a1b2 · request narrative
On-call pivots. Later, an engineer opens that Sentry event, copies req_a1b2, and filters the log destination by it — reading the whole request narrative the stack trace couldn't tell. (That pivot UI is built a couple of lessons from now.)

Step 3 is the one to dwell on: the helper that emits the line never received a requestId, a userId, or an orgId as an argument. It called getRequestContext(), reached up through the ALS scope opened two frames above it, and got all three. Depth doesn’t matter: a helper ten frames deep reads the same store as one frame deep. That’s the prop-drilling you didn’t have to do.

One small habit compounds nicely. Inside a specific handler, narrow the logger to what that handler is doing by making its own child:

src/app/api/webhooks/stripe/route.ts
const log = logger.child({
seam: 'webhook.stripe',
webhookEventId: event.id,
stripeEventType: event.type,
});
log.info('webhook received');

Because child loggers compose, this log carries the seam name and the webhook keys on top of the request’s requestId, userId, and orgId, so every log.info(...) in the handler is now stamped with all of it. The convention is one child logger per seam, with the seam name matching the file (webhook.stripe), so a glance at any line tells you which seam emitted it. It’s cheap to make, and the file’s logger now knows what the file is doing.

Before moving on, make sure the journey is in your fingers, not just your eyes.

Order the journey of a requestId through one request, from arrival to incident pivot. Drag the items into the correct order, then press Check.

proxy.ts reads or generates the requestId
runWithContext opens the request scope
auth resolves and authedAction enriches the scope with userId and orgId
a deep /lib helper’s child logger stamps the requestId onto a line
the same requestId is set on the Sentry event’s context
the requestId is echoed back on the response as x-request-id

Levels, cardinality, and retiring console.log

Section titled “Levels, cardinality, and retiring console.log”

You’ve got a logger that emits correlated JSON. The last thing standing between that and logs you actually trust is daily-use discipline: which level to use, what to put in keys, and not quietly slipping back to console.log. These three are all the same question, how to use this correctly every day, so they live together.

Every line has a level, and the level is a decision, not decoration. The rule for choosing it:

  • debug: high-volume tracing you want while developing and nowhere near production, such as which branch a calculation took or an intermediate value. Off in prod via LOG_LEVEL.
  • info: a significant successful state change. Signed in. Webhook processed. Job completed. The trap here is treating info as a synonym for console.log: a routine read, such as fetching a list to render a page, does not earn an info line. info marks things that changed, not things that happened to run.
  • warn: a recoverable abnormality. The thing you expected to be in cache wasn’t. A retry fired. A fail-open carve-out triggered and you kept going. Nothing’s broken, but someone should know it’s happening.
  • error: a handled or unhandled exception, logged with the err serializer. These overlap with Sentry on purpose: the same incident lands in both stores, the stack trace in Sentry and the surrounding request narrative in your logs.

Try a few. Pick the level each moment deserves:

Pick the level each logging moment deserves. Pick the right option from each dropdown, then press Check.

  • A Stripe webhook was processed successfully and the subscription row updated →
  • The rate limiter’s Redis call failed, so the limiter fell open and let the request through →
  • A server action caught a thrown error and is about to return the user-safe Result.err
  • You’re tracing which branch a pricing calculation took, on your machine only →
  • An invitation was accepted and a new member joined the org →

The destination indexes every key, and indexing isn’t free, which is why what you put in keys matters as much as that you use keys at all. The instinct to build is around cardinality , the same word the last lesson covered for Sentry tags.

Bounded high-cardinality is fine. userId has as many values as you have users, which is high, but it stops growing when signups stop, and you genuinely want to filter by it. requestId is higher still and effectively unbounded, but it’s ephemeral: the destination drops it on a TTL, so it never accumulates. Both are exactly what keys are for.

What wrecks an index is free text masquerading as a key. A field like { note: 'user clicked the green button while the modal was still animating' } has unbounded distinct values that never repeat and that nobody will ever filter on, so it just bloats the index. Narrative belongs in msg, which stays low-cardinality on purpose; structured facts belong in keys.

This is also why the err serializer is shaped the way it is. The long stack string is genuinely high-cardinality free text, but it lives inside the structured { type, message, stack } object the serializer builds, not as a top-level free-text key, so it doesn’t fragment the index. That gives a hard rule for errors: log { err } and let the serializer run. Never JSON.stringify(err), because Error’s message and stack are non-enumerable, so stringify silently drops them and you get {}. And never hand-build an error: err.stack string field, because that’s the free-text key the serializer was designed to avoid. One more in the same family: don’t log whole database rows. The volume and cost are real; log the keys that identify the row, not the row.

The discipline only holds if the easy wrong thing stops being available. So after this lesson, console.log in server code is a lint error. A no-console rule, scoped to your server files, fails the build: server logs go through logger, full stop.

biome.json
"overrides": [
{
"includes": ["src/app/**", "src/lib/**", "src/server/**"],
"linter": { "rules": { "suspicious": { "noConsole": "error" } } }
}
]

That’s a sketch of the shape, not a config to copy wholesale. The point is the scope, and the scope is deliberately not global. Two carve-outs follow from why the rule exists:

  • Client code keeps console.error. It surfaces in the browser’s DevTools where a developer needs it, and the Sentry client SDK’s console integration from the last lesson already captures it. The rule is server-scoped, not a blanket ban.
  • Tests keep console.log. The lint rule excludes them, since a quick log while debugging a test isn’t a production line.

One boundary is worth drawing clearly so you don’t conflate two different streams. Everything in this lesson is the operational log: ephemeral, best-effort, the request narrative an operator reads during an incident. That is not the audit log you built earlier in the course (logAudit(tx, event)). The audit log is durable, transactional, compliance-scoped, and the system of record for who-did-what. Different table, different audience, different rules. When you want “what happened in this request, right now,” reach for the logger; when you want “the permanent record of this domain event,” reach for the audit log. Don’t route one through the other.

So you can now stand up lib/logger.ts and the ALS request-context module, open the scope at both entry seams, and emit a correctly-leveled, correctly-correlated line from anywhere in the server. The two surfaces, Sentry and your logs, now share a requestId, which is the join the whole chapter is built around. The next lesson decides what each seam should log and fills in that redaction denylist so PII and secrets never leave the building. The lesson after ships these lines to a real destination and walks the Sentry-to-logs pivot this requestId finally makes possible.