Logging
Lumio's frontend apps share a single env-aware structured logger from the @lumio/logger workspace package. It wraps console.* so log lines land in the SSR terminal and the browser DevTools console alike, and forwards error / warn events to Sentry when an integration is wired up. The same package also exposes a Next.js proxy.ts helper that prints one access line per incoming request — including the real client IP (IPv4 / IPv6) resolved from the upstream proxy headers.
The Rust API and bots stay on tracing + lo-telemetry; the new logger is frontend-only.
Package layout
| File | Purpose |
|---|---|
shared/logger/src/index.ts | Default logger (logger), factory (createLogger), Sentry binding (bindSentry). |
shared/logger/src/middleware.ts | createRequestLoggerMiddleware — drop-in for Next.js 16 proxy.ts. |
The package exports both entry points:
import { logger, createLogger, bindSentry } from "@lumio/logger";
import { createRequestLoggerMiddleware } from "@lumio/logger/middleware";
Environment
The logger reads two env vars to decide what to print:
| Variable | Server | Browser | Allowed values | Default |
|---|---|---|---|---|
LUMIO_ENV / NEXT_PUBLIC_LUMIO_ENV | yes | yes | development | staging | production | development |
LUMIO_LOG_LEVEL / NEXT_PUBLIC_LUMIO_LOG_LEVEL | yes | yes | debug | info | warn | error | per env (see below) |
Default thresholds when *_LOG_LEVEL is absent:
development→debug(everything)staging→info(nodebug)production→warn(only warnings + errors)
LUMIO_ENV also tags Sentry events so staging and production stay on separate dashboards.
Levels
logger.debug("...", context);
logger.info("...", context);
logger.warn("...", context); // → Sentry capture-message
logger.error("...", error, context); // → Sentry capture-exception
error always logs regardless of the configured threshold — errors signal real user-visible problems and must not be silenced. The other three respect the threshold.
context is an arbitrary Record<string, unknown> that gets JSON-serialised and appended to the line. It also flows through to Sentry as the extra payload.
Named loggers
Use createLogger({ name }) or logger.child(scope) to prefix every line with a [scope] tag. Useful for tracing which component produced which log.
const log = logger.child("chat-page");
log.info("Loading chat", { userId });
// → [chat-page] Loading chat {"userId":"…"}
const sender = log.child("sender");
sender.error("Failed to send", err, { platform: "twitch" });
// → [chat-page:sender] Failed to send {"platform":"twitch"} <err>
Per-request access logging (proxy.ts)
Every Next.js app has a proxy.ts (Next 16 file rename of middleware.ts) that emits one structured line per request:
[web:request] GET /dashboard/chat {"ip":"91.99.42.7","ip_family":"ipv4","user_agent":"Mozilla/5.0…","elapsed_ms":3}
[web:request] GET /api/chat/history {"ip":"2001:db8::1","ip_family":"ipv6","user_agent":"…","elapsed_ms":12}
Resolution order for the client IP:
cf-connecting-ip(Cloudflare Pages / proxy)x-real-ip(Caddy / nginx / Hetzner LB)- First entry of
x-forwarded-for request.ip(some Next.js runtimes expose it natively)"unknown"
ip_family is derived from the resolved address: ipv4, ipv6, or unknown. IPv4-mapped IPv6 (::ffff:1.2.3.4) is treated as ipv4 for log clarity.
The matcher config skips static-asset paths so hot-reload chatter doesn't drown the log:
export const config = {
matcher: [
"/((?!_next/static|_next/image|_next/data|favicon.ico|robots.txt|sitemap.xml).*)",
],
};
Note — Next.js 16 expects the export to be named
proxy, notmiddleware. The factory function returns a regularNextMiddleware-shaped function; only the export name changed.
Sentry binding
bindSentry() wires the logger to a Sentry-compatible binding. Each Next.js app does this once at boot — server-side via instrumentation.ts, browser-side via sentry.client.config.ts. After binding, logger.error and logger.warn automatically forward to Sentry.captureException / Sentry.captureMessage (translating warn → Sentry's warning severity along the way).
The binding is optional — without a DSN, the logger still works as a plain console logger. See Error Tracking for the full Sentry setup.
Best practices
- Never log secrets. Tokens, passwords, OAuth secrets, session cookies — none of them should land in
context. Sentry'sbeforeSendstripsAuthorization/Cookieheaders, but thecontextobject is up to the caller. - One scope per concern. Pick a stable
nameper component / route so log filtering downstream (Loki, Grafana, Sentry) groups cleanly. Avoid stamp-of-the-day dynamic prefixes. - Errors as
Errorobjects. Preferthrow new Error("msg")andlogger.error("Failed", err, ctx)over stringifying — Sentry's stack-frame symbolication relies on theErrorinstance. debugis debug-only. Production threshold defaults towarn; anything you put indebugwill silently disappear in prod, which is the point. Don't put critical state-tracking there.
Verifying it works
Local dev:
just dev-web
# in another terminal, hit a page:
curl http://localhost:4000/
# → terminal shows: [web:request] GET / {"ip":"127.0.0.1","ip_family":"ipv4",...}
Throw a test error inside a Server Action / route handler:
import { logger } from "@lumio/logger";
export async function POST() {
try {
throw new Error("test");
} catch (err) {
logger.error("test handler failed", err, { hint: "expected" });
return new Response("ok", { status: 200 });
}
}
Terminal shows the error + stack; if NEXT_PUBLIC_SENTRY_DSN is configured, the event also lands in Sentry under the matching environment tag.