Error Tracking (Sentry)
Lumio's three Next.js apps — apps/web, apps/admin, apps/id — each ship with a @sentry/nextjs integration. The Rust backend is on sentry-rust via lo-telemetry; this page is about the frontend setup.
When NEXT_PUBLIC_SENTRY_DSN is unset, the integration is a no-op — apps boot normally, no events are sent. Local dev typically leaves the DSN blank; staging and production supply it via the deployment environment.
apps/web is consent-gatedIn apps/web, a DSN alone is not enough. Sentry is classified as a Functional cookie in the published policy (§ 25 TDDDG / GDPR), so the browser SDK stays fully inert — no Sentry.init, no capture, no storage writes, no network traffic — until the user grants functional consent. All lifecycle logic lives in apps/web/src/lib/sentry-consent.ts; sentry.client.config.ts does nothing but call syncSentryConsent().
<SentryConsentSync /> (mounted inside ConsentProvider in apps/web/src/app/(main)/layout.tsx) re-runs the sync when the preference changes at runtime, and a cross-tab storage listener tears the client down on withdrawal in another tab. Consent is read from the lumio_cookie_consent localStorage key via getStoredConsent() from @lumio/cookies-consent — that stored choice is the single source of truth, even on routes where ConsentProvider runs in exempt mode.
apps/admin and apps/id are not consent-gated: their sentry.client.config.ts initialises directly when NEXT_PUBLIC_SENTRY_DSN is present.
Files per app
Each of apps/{web,admin,id}/ has the same five files:
| File | Loaded by | Purpose |
|---|---|---|
next.config.ts | build | Wraps withSentryConfig (source-map upload, build-time injection) |
sentry.client.config.ts | browser | Browser-side Sentry init + bindSentry for @lumio/logger. In apps/web this is a one-line delegate to syncSentryConsent() |
sentry.server.config.ts | Node runtime | SSR / route-handler / Server-Action capture |
sentry.edge.config.ts | Edge runtime | Edge route handlers |
instrumentation.ts | server | register() imports the right runtime config and binds the logger; also exports onRequestError = Sentry.captureRequestError |
Configuration is duplicated across the three apps on purpose — the apps evolve independently. The browser configs have already diverged: apps/web is consent-gated and carries no Session Replay, while apps/admin and apps/id init unconditionally on a DSN and do enable Replay. The server and edge configs remain identical across all three.
Environment variables
| Variable | Where | Purpose |
|---|---|---|
NEXT_PUBLIC_SENTRY_DSN | Browser + server | Public DSN. Required to enable client-side capture. |
SENTRY_DSN | Server only | Optional — falls back to NEXT_PUBLIC_SENTRY_DSN when unset. Useful when you want the SSR / Edge runtimes on a different project than the browser. |
SENTRY_ORG | Build (CI) | Sentry org slug — required for source-map upload. Leave blank locally. |
SENTRY_PROJECT | Build (CI) | Sentry project slug — same. |
SENTRY_AUTH_TOKEN | Build (CI) | Auth token for source-map upload. Generate one in Sentry → Settings → Auth Tokens. |
LUMIO_ENV / NEXT_PUBLIC_LUMIO_ENV | Both | Tags every event with the environment (development / staging / production). Falls back to NODE_ENV. |
Source-map upload only runs when SENTRY_AUTH_TOKEN + SENTRY_ORG + SENTRY_PROJECT are all set — typically only in CI for staging / production builds.
What gets captured
- Unhandled exceptions in browser code (React error boundaries, Promise rejections, sync throws).
- SSR / route-handler errors through the
onRequestErrorexport ininstrumentation.ts(Sentry's recommended hook). - Anything emitted via
logger.error(...)orlogger.warn(...)— the logger callsSentry.captureException/Sentry.captureMessagewhen bound. See Logging for the binding details.
What gets stripped
beforeSend in every runtime config deletes the authorization and cookie keys from event.request.headers before the payload leaves the process. sendDefaultPii: false keeps Sentry's automatic IP / user-agent collection off, so cookie-issued JWTs and popout tokens never end up in Sentry.
If you want to enrich events with explicit user info (e.g. account ID), set it manually after login:
import * as Sentry from "@sentry/nextjs";
Sentry.setUser({ id: account.id, ip_address: undefined });
ip_address: undefined opts out of Sentry's IP capture even if sendDefaultPii ever flips on later.
Session Replay
| App | Replay |
|---|---|
apps/web | Not enabled. Removed under ZAF-91 — it was the highest-PII component and was undisclosed in the cookie policy. Do not re-add it, even consent-gated, without a disclosed processing entry in the policy first. |
apps/admin, apps/id | replayIntegration({ maskAllText: true, blockAllMedia: true }) with replaysSessionSampleRate: 0 and replaysOnErrorSampleRate: 1.0 — silent until something throws, then a replay with all text masked and media blocked. |
Performance traces
tracesSampleRate is 0.1 when NODE_ENV === "production" (10 % of transactions) and 1.0 everywhere else, in all three apps on both the browser and server sides.
Suppressed noise
denyUrls on the browser side is the same list in all three apps:
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^moz-extension:\/\//i, /^safari-web-extension:\/\//i]
Note the second entry matches the chrome:// internal scheme, not chrome-extension:// — the generic /extensions\//i pattern is what catches Chrome extension frames. Add entries when you see recurring noise from a specific extension or third-party script.
Adding a new captured event manually
Most code should just throw / call logger.error. For business-event tracking (e.g. "user upgraded plan"), use Sentry's addBreadcrumb or captureMessage directly:
import * as Sentry from "@sentry/nextjs";
Sentry.captureMessage("user upgraded plan", {
level: "info",
extra: { account_id: accountId, plan_slug: plan.slug },
});
These don't surface as errors but let you search them in Sentry's "Issues" tab when triaging.
Debugging the integration locally
Set the DSN in .env.local:
NEXT_PUBLIC_SENTRY_DSN=https://<key>@o<org>.ingest.sentry.io/<project>
LUMIO_ENV=development
In apps/web you must also accept the Functional cookie category in the consent banner (or the browser SDK stays inert and nothing is sent). To reset, clear the lumio_cookie_consent localStorage key and reload.
Then trigger an error from any client component:
"use client";
import { useEffect } from "react";
export default function DebugPage() {
useEffect(() => {
throw new Error("sentry smoketest");
}, []);
return null;
}
Reload the page, open Sentry → Issues, the event should land within a few seconds with environment: development. Filter that out before shipping or the dashboard fills with smoketest noise.