launch-quickly
Patterns

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.
  • reportError is for INTERNAL-class surprises. Expected failures — bad input, missing permission, spent budget — are outcomes, not incidents, and belong at warn or 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 }.

On this page