Skip to main content

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

FilePurpose
shared/logger/src/index.tsDefault logger (logger), factory (createLogger), Sentry binding (bindSentry).
shared/logger/src/middleware.tscreateRequestLoggerMiddleware — 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:

VariableServerBrowserAllowed valuesDefault
LUMIO_ENV / NEXT_PUBLIC_LUMIO_ENVyesyesdevelopment | staging | productiondevelopment
LUMIO_LOG_LEVEL / NEXT_PUBLIC_LUMIO_LOG_LEVELyesyesdebug | info | warn | errorper env (see below)

Default thresholds when *_LOG_LEVEL is absent:

  • developmentdebug (everything)
  • staginginfo (no debug)
  • productionwarn (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:

  1. cf-connecting-ip (Cloudflare Pages / proxy)
  2. x-real-ip (Caddy / nginx / Hetzner LB)
  3. First entry of x-forwarded-for
  4. request.ip (some Next.js runtimes expose it natively)
  5. "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, not middleware. The factory function returns a regular NextMiddleware-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's beforeSend strips Authorization / Cookie headers, but the context object is up to the caller.
  • One scope per concern. Pick a stable name per component / route so log filtering downstream (Loki, Grafana, Sentry) groups cleanly. Avoid stamp-of-the-day dynamic prefixes.
  • Errors as Error objects. Prefer throw new Error("msg") and logger.error("Failed", err, ctx) over stringifying — Sentry's stack-frame symbolication relies on the Error instance.
  • debug is debug-only. Production threshold defaults to warn; anything you put in debug will 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.