Skip to main content

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.

Browser Sentry in apps/web is consent-gated

In 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:

FileLoaded byPurpose
next.config.tsbuildWraps withSentryConfig (source-map upload, build-time injection)
sentry.client.config.tsbrowserBrowser-side Sentry init + bindSentry for @lumio/logger. In apps/web this is a one-line delegate to syncSentryConsent()
sentry.server.config.tsNode runtimeSSR / route-handler / Server-Action capture
sentry.edge.config.tsEdge runtimeEdge route handlers
instrumentation.tsserverregister() 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

VariableWherePurpose
NEXT_PUBLIC_SENTRY_DSNBrowser + serverPublic DSN. Required to enable client-side capture.
SENTRY_DSNServer onlyOptional — 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_ORGBuild (CI)Sentry org slug — required for source-map upload. Leave blank locally.
SENTRY_PROJECTBuild (CI)Sentry project slug — same.
SENTRY_AUTH_TOKENBuild (CI)Auth token for source-map upload. Generate one in Sentry → Settings → Auth Tokens.
LUMIO_ENV / NEXT_PUBLIC_LUMIO_ENVBothTags 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 onRequestError export in instrumentation.ts (Sentry's recommended hook).
  • Anything emitted via logger.error(...) or logger.warn(...) — the logger calls Sentry.captureException / Sentry.captureMessage when 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

AppReplay
apps/webNot 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/idreplayIntegration({ 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.