feat: read the dashboard's Sentry DSN from the container-injected runtime config instead of a literal DSN in web/src/main.tsx, so a self-hosted install reports its users' browser errors, URLs and IPs nowhere unless the operator sets WARMBLY_SENTRY_DSN, and document the variable in configuration.mdx and the no-reporting-by-default stance in data-control.mdx

This commit is contained in:
Matthew Meszaros
2026-09-07 03:36:27 -07:00
parent 940211902e
commit 20c18bc2f7
8 changed files with 63 additions and 10 deletions
+3
View File
@@ -561,6 +561,9 @@ services:
# captcha is off server-side. Set WARMBLY_TURNSTILE_KEY to your site key
# when you set CAPTCHA_PROVIDER=turnstile.
WARMBLY_TURNSTILE_KEY: ${WARMBLY_TURNSTILE_KEY:-1x00000000000000000000AA}
# Unset means the dashboard initialises no error reporting and contacts
# no Sentry host. Point it at your own project to collect browser errors.
WARMBLY_SENTRY_DSN: ${WARMBLY_SENTRY_DSN:-}
depends_on:
backend: { condition: service_healthy }
@@ -380,8 +380,11 @@ Delayed sends run through the local poller, so the backend must be running for s
| Variable | What it does | Default |
|---|---|---|
| `SENTRY_DSN` | Error reporting. Optional in every environment, including `prod` | unset |
| `WARMBLY_SENTRY_DSN` | Browser error reporting for the dashboard container, read at container start like the other `WARMBLY_*` values. Unset means the dashboard initialises no reporting SDK and contacts no Sentry host | unset |
| `APNS_KEY` or `APNS_KEY_PATH`, `APNS_KEY_ID`, `APNS_TEAM_ID`, `APNS_TOPIC` | Mobile push on backend and consumer. Partial configuration disables push with a warning, never a crash | unset |
Error reporting is off by default everywhere. A DSN is the operator's choice, and each of these is read by one process, so pointing the backend at a project does not make the dashboard report too. Both accept Sentry Cloud, a self-hosted Sentry or any Sentry-compatible server.
## Updates
| Variable | What it does | Default | Restart needed |
@@ -206,6 +206,18 @@ A self-hosted Warmbly makes no outbound call of its own except one, and it is of
Everything else is you: mail through the mailboxes you connect, DNS lookups for the domains you check, and whatever integrations you configure. There is no telemetry, no phone-home, and no license check.
### Error reporting
An instance reports errors nowhere unless you point it somewhere. Every service reads its own DSN and none of them ship with one, so a default install sends no crash, no stack trace and no browser error to anybody, including us.
| Service | Variable |
|---|---|
| Backend, consumer, worker | `SENTRY_DSN` |
| Dashboard container | `WARMBLY_SENTRY_DSN` |
| Realtime | `SENTRY_DSN` |
Set one and that service reports to whatever Sentry Cloud project, self-hosted Sentry or Sentry-compatible server you name. Leave it unset, which is the default the installer writes, and the SDK is never initialised: there is no host to contact and nothing to opt out of.
## See also
- [Install](/development/install/): the wizard that asks all of this up front
+2 -1
View File
@@ -7,7 +7,8 @@ cat > /usr/share/nginx/html/config.js <<EOF
window.__WARMBLY_ENV__ = {
API_URL: "${WARMBLY_API_URL:-}",
APP_URL: "${WARMBLY_APP_URL:-}",
TURNSTILE_KEY: "${WARMBLY_TURNSTILE_KEY:-}"
TURNSTILE_KEY: "${WARMBLY_TURNSTILE_KEY:-}",
SENTRY_DSN: "${WARMBLY_SENTRY_DSN:-}"
};
EOF
+3 -3
View File
@@ -27,7 +27,7 @@ import type Session from "@/lib/api/models/auth/Session";
import beginSSO from "@/lib/api/client/auth/beginSSO";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import * as Sentry from "@sentry/react";
import { captureException } from "@/lib/observability";
import type Token from "@/lib/api/models/auth/Token";
import {
beginPasskeyLogin,
@@ -311,7 +311,7 @@ export default function LoginPage() {
await completeSession(token);
} catch (e) {
// Cancel / no-passkey is expected here; report only real failures.
if (!(e instanceof PasskeyCancelled)) Sentry.captureException(e);
if (!(e instanceof PasskeyCancelled)) captureException(e);
}
}, [completeSession]);
@@ -327,7 +327,7 @@ export default function LoginPage() {
})
.catch((e) => {
setPasskeyStatus("error");
Sentry.captureException(e);
captureException(e);
})
.finally(() => {
explicitPasskeyChallengePendingRef.current = false;
+2
View File
@@ -9,6 +9,8 @@ export const API_URL = runtimeEnv("API_URL", import.meta.env.VITE_API_URL);
// (no path), so this is the single place the /v1 prefix is applied.
export const API_BASE_URL = `${API_URL}/v1`;
export const TURNSTILE_KEY = runtimeEnv("TURNSTILE_KEY", import.meta.env.VITE_TURNSTILE_KEY);
// Empty means browser error reporting is never initialised. See lib/observability.
export const SENTRY_DSN = runtimeEnv("SENTRY_DSN", import.meta.env.VITE_SENTRY_DSN);
export const HUMAN_VERIFICATION_FAIL = "We couldnt verify youre human. Please try the security check again or reload the page.";
export const PASSWORD_FAIL = "The password must be at least 8 characters long and contain both uppercase and lowercase letters, as well as a number."
export const TOKEN_KEY = "auth_token";
+36
View File
@@ -0,0 +1,36 @@
// Browser error reporting.
//
// The DSN is the operator's choice, not a requirement of the software: the
// dashboard image is the same for the hosted service and for a self-host, so a
// literal DSN in the bundle would make every self-hosted install report its
// users' errors, URLs and IPs to somebody else's Sentry. It comes from the
// container-injected runtime config instead, and an unset DSN means the SDK is
// never initialised, so nothing is ever sent anywhere.
//
// The SDK is imported statically rather than lazily so that its global handlers
// are installed before the first render: a broken deploy fails during boot, and
// a chunk still in flight would miss exactly that error. Not initialising it
// costs a self-hoster some dead bundle weight and zero network calls.
import * as Sentry from "@sentry/react";
import { SENTRY_DSN } from "./information";
let reporting = false;
// initErrorReporting is called once, before the app renders.
export function initErrorReporting(): void {
if (!SENTRY_DSN) return;
Sentry.init({
dsn: SENTRY_DSN,
sendDefaultPii: true,
environment: import.meta.env.MODE,
});
reporting = true;
}
// captureException reports an error the app handled itself. A no-op when no DSN
// is configured.
export function captureException(error: unknown): void {
if (!reporting) return;
Sentry.captureException(error);
}
+2 -6
View File
@@ -65,13 +65,9 @@ import NotFound from './app/not-found';
import { Toaster } from '@/components/ui/toaster';
import * as Sentry from "@sentry/react";
import { initErrorReporting } from "@/lib/observability";
Sentry.init({
dsn: "https://412466daced4b1d85ee040eef66efc95@o4510248538472448.ingest.us.sentry.io/4510248563113984",
sendDefaultPii: true,
environment: import.meta.env.MODE
})
initErrorReporting();
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"