On this page
When something breaks in production, the only thing standing between you and a fix is what you logged before it happened. Most projects log nothing useful: a wall of console.log("here"), free-text strings you cannot search, and occasionally a customer's email or password printed straight into the logs. Good observability is not a heavy platform; it is a small discipline applied consistently. One structured event per meaningful action, machine-readable, scrubbed of personal data, with errors forwarded somewhere you will actually see them.
This page is a lightweight logging layer that does that: a single log helper that emits one JSON line per event, redacts sensitive fields by name, and forwards warnings and errors to Sentry without changing any call site. It pairs with Error Monitoring with Sentry, which covers the Sentry setup itself.
One event, one line, machine-readable
A log you cannot query is a log you will not read. Emit structured JSON, one object per line, with a stable event name and typed fields. A platform's log search can then filter by msg, level, or any field, instead of you grepping prose.
function emit(level: Level, msg: string, fields?: Record<string, unknown>) {
const line = JSON.stringify({
ts: new Date().toISOString(),
level,
msg, // stable event name, e.g. "payment.webhook.received"
...(fields ? redact(fields) : {}),
});
(level === "error" || level === "warn" ? console.error : console.log)(line);
}The rules
Whenyou log anything in production code
Doemit one structured JSON line with a stable event name and typed fields, not a free-text string. Name events like namespaced keys (domain.action.outcome) so they group and search.
log.info("auth.signup.ok", { method: "email", durationMs });
log.warn("payment.webhook.fetch_fail", { paymentId });
// not: console.log("signup worked for " + email);Whena log's fields might contain personal or sensitive data
Doredact by key in the logger itself, so no call site can leak. Keep a deny-list (password, email, answers, tokens) and replace matched values with [redacted].
const REDACT = new Set(["password", "email", "token", "answers"]);
function redact(o) {
if (o && typeof o === "object")
return Object.fromEntries(Object.entries(o).map(
([k, v]) => [k, REDACT.has(k) ? "[redacted]" : redact(v)]));
return o;
}Whenyou want errors and warnings in an aggregator (Sentry)
Doforward them from inside the log helper, gated on the DSN being set, so every existing log.error call reports automatically with zero changes at the call sites.
function emit(level, msg, fields) {
/* ...write the line... */
if ((level === "error" || level === "warn") && process.env.SENTRY_DSN) {
void sendToSentry(level, msg, fields); // lazy-imported, never throws
}
}Whenyou log inside a serverless function (route handler, server action)
Doawait a flush before returning so the log/Sentry transport completes. A serverless function can freeze the instant it returns, dropping in-flight async sends.
export async function POST() {
try { /* work */ }
catch (e) { log.error("job.failed", e); }
finally { await log.flush(); } // ensure Sentry POST finishes
return Response.json({ ok: true });
}Whenlogging itself could fail (transport down, Sentry not initialized)
Domake every logging path swallow its own errors. Logging is a side effect on the hot path; it must never throw and take down the request it was only meant to observe.
async function sendToSentry(/* ... */) {
try { /* lazy import + capture */ } catch { /* never rethrow */ }
}Whenyou need errors caught before any request handler runs
Douse Next.js instrumentation hooks (register / onRequestError in instrumentation.ts) to initialize Sentry and capture server errors globally, rather than wrapping every route by hand.
export async function register() { /* init Sentry for the runtime */ }
export const onRequestError = Sentry.captureRequestError;