Observability
Logging, request ids, error reporting and the health endpoint — the structured logger, why console is a lint error, requestLog/reportError, and the job-run logger. Use when adding log lines, when no-console fires, or when wiring a reporter like Sentry.
Generated from
.claude/skills/observability/SKILL.md, which ships in every project created from this template. Your agent loads it on demand; this page is the same text. Edit the skill, not this page.
console.* is a lint error outside src/lib/log.ts. Not because console is
bad — because a log line you cannot filter by request id is a log line you
will grep for at 2am and not find.
Three functions, three situations
import { log, requestLog, reportError } from '@/lib/log';
// Outside a request (startup, scripts): the bare logger, or a child.
log.info('cache warmed', { entries: 120 });
// Inside a request: the request id comes bound.
const logger = await requestLog({ route: 'health' });
logger.warn('slow query', { ms: 450 });
// Something a human should be ALERTED about:
reportError(error, { source: 'stripe-webhook', eventId: event.id });- Message first, facts as fields — never interpolate ids into the message
(
'user signed in', { userId }, not`user ${id} signed in`). Fields are filterable; prose is not. reportErroris for INTERNAL-class surprises. Expected failures — bad input, missing permission, spent budget — are outcomes, not incidents, and belong atwarnor nowhere.
The output shape is a contract
In production: one JSON object per line with level/time/msg — the shape
every ingester (Vercel included) parses into filterable fields, pinned by
src/lib/log.test.ts. In development: compact human lines. Do not change the
production keys casually; dashboards filter on them.
Request ids
src/proxy.ts mints an x-request-id for every request (honoring one that
arrives from a trusted upstream) and sets it on the RESPONSE — so a user's
bug report can include the exact id, and requestLog() binds the same id to
every server-side line. One filter, one request's whole story.
Jobs
A job handler receives log in its args, bound with the job id and the
Inngest run id — the job-world equivalent of the request id. Use it, not the
global logger, so a retried run's lines group together:
handler: async ({ input, step, log }) => {
log.info('sending welcome email', { to: input.email });
},Wiring a reporter (Sentry or otherwise)
reportError is the seam: one function every alert-worthy site already
calls. Adopting a vendor is an edit there — add the SDK, call
captureException inside it — not a hunt across the codebase. Until then it
emits structured error lines, which every platform can alert on.
The health endpoint
GET /api/health is public (a monitor has no session) and answers exactly
two things: can the process reach its database, and which rate-limit backend
is live. Nothing else — a health endpoint is also reconnaissance surface.
Point your uptime monitor at it; a deploy is not verified until it returns
{ ok: true }.
Audit logging
How the audit trail works — the .audit() stage on the action client, what gets recorded and what must not, immutability via RLS, and the lint rule on destructive actions. Use when adding mutations, when the require-audit-on-destructive rule fires, or when compliance questions come up.
Caching and revalidation
How mutations refresh pages — the typed revalidate registry, why importing next/cache is a lint error, and where the generator registers new features. Use when a list is stale after a mutation, or when the next/cache restriction fires.