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 ships a ready-made Next.js request-logging helper.

The Rust API and bots stay on tracing + lo-telemetry; this logger is frontend-only.

Package layout

FilePurpose
shared/logger/src/index.tsDefault logger (logger), factory (createLogger), Sentry binding (bindSentry).
shared/logger/src/middleware.tscreateRequestLoggerMiddleware — a self-contained request logger for a proxy.ts that has no other work to do.

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)

NEXT_PUBLIC_* wins over the bare name in both cases (it is the only form the browser bundle can see, and Next.js inlines it at build time). If neither env var is set, resolveEnv() falls back to NODE_ENV, and to development if that is not one of the three recognised values. An unrecognised *_LOG_LEVEL value is ignored rather than erroring.

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)

apps/{web,admin,id}/src/proxy.ts (Next 16's file rename of middleware.ts) each own auth, token refresh, and request logging in one proxy() function, so none of them mounts createRequestLoggerMiddleware — they build a named logger with createLogger({ name: "web" }) and emit the access line themselves at the top of proxy():

const log = createLogger({ name: "web" });

export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const ip = resolveClientIp(request);

log.info(`${request.method} ${pathname}`, {
ip,
user_agent: request.headers.get("user-agent") ?? "-",
elapsed_ms: 0,
});
// … auth / refresh / popout-token handling
}

Output:

[web] GET /dashboard/chat {"ip":"91.99.42.7","user_agent":"Mozilla/5.0…","elapsed_ms":0}

The line is emitted before the request is handled, so elapsed_ms is always 0 — treat it as an access log, not a latency metric.

Resolution order for the client IP (identical in the apps and in createRequestLoggerMiddleware):

  1. cf-connecting-ip (Cloudflare proxy)
  2. x-real-ip (Caddy / nginx / Hetzner LB)
  3. First entry of x-forwarded-for
  4. request.ip (only some runtimes expose it)
  5. "unknown"

createRequestLoggerMiddleware additionally classifies the address as ip_family: ipv4, ipv6, or unknown, with IPv4-mapped IPv6 (::ffff:1.2.3.4) counted as ipv4. The apps' own inline logging does not emit that field.

All three apps share the same matcher, which skips Next internals and static image assets so hot-reload chatter doesn't drown the log:

export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)",
],
};

Note — Next.js 16 expects the export to be named proxy, not middleware, and refuses to boot if a middleware.ts sits alongside it. Fold request-interception logic into proxy.ts rather than adding a second file.

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).

In the browser the binding is consent-gated: Sentry only initializes once the user has granted the Functional cookie category, and bindSentry(null) unbinds it when consent is withdrawn. Server-side binding is unconditional.

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] GET / {"ip":"127.0.0.1","user_agent":"curl/…","elapsed_ms":0}

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.