web: Fix Sentry Initialization (#25450)

web: let the error-reporting setting govern Sentry in production

`init({ enabled: process.env.NODE_ENV !== "production" })` disabled the
SDK in exactly the builds that ship: the container's web stage sets
`NODE_ENV=production` (lifecycle/container/Dockerfile), and the bundler
inlines it. An administrator who turned error reporting on in their
config therefore received nothing, while `browserTracingIntegration` was
registered only in production — the opposite polarity — so development
had the SDK on with no tracing integration behind it.

Consolidates the decision into `sentryEnabled(cfg, debug)`:

- The administrator's `errorReporting.enabled` decides, in every
  environment; `CanDebug` still forces it on.
- Development additionally honors `?disable-sentry`, so a noisy local
  session can opt out for one load without a rebuild.
- The tracing integration is registered whenever the SDK runs. It has
  always been configured with the automatic instrumentation off, since
  the router opens the spans itself.

`enabled: true` stays on the `init` call so the decision is readable off
the client, which is how the router outlets gate their spans.

Tidy.
This commit is contained in:
Teffen Ellis
2026-08-25 16:22:56 +01:00
committed by GitHub
parent 63026fa791
commit 3fb3e3e0a3
18 changed files with 329 additions and 121 deletions

View File

@@ -15,7 +15,6 @@ import {
} from "./navigation/sidebar.js";
import { isAPIResultReady } from "#common/api/responses";
import { configureSentry } from "#common/sentry/index";
import { isGuest } from "#common/users";
import { WebsocketClient } from "#common/ws/WebSocketClient";
@@ -131,8 +130,6 @@ export class AdminInterface extends WithCapabilitiesConfig(
//#region Lifecycle
constructor() {
configureSentry();
super();
WebsocketClient.connect();

View File

@@ -1,3 +1,4 @@
import "#common/sentry/apply";
import "#elements/messages/MessageContainer";
import "#admin/ak-interface-admin";

View File

@@ -1,7 +1,7 @@
import { renderForm } from "./SAMLProviderImportFormForm.js";
import { aki } from "#common/api/client";
import { SentryIgnoredError } from "#common/sentry/index";
import { SentryIgnoredError } from "#common/sentry/error";
import { Form } from "#elements/forms/Form";

View File

@@ -3,7 +3,7 @@ import "#elements/forms/DeleteBulkForm";
import { aki } from "#common/api/client";
import { createPaginatedResponse } from "#common/api/responses";
import { deviceTypeName } from "#common/labels";
import { SentryIgnoredError } from "#common/sentry/index";
import { SentryIgnoredError } from "#common/sentry/error";
import { PaginatedResponse, Table, TableColumn, Timestamp } from "#elements/table/Table";
import { SlottedTemplateResult } from "#elements/types";

View File

@@ -1,4 +1,4 @@
import { SentryIgnoredError } from "#common/sentry/index";
import { SentryIgnoredError } from "#common/sentry/error";
export interface PlexPinResponse {
// Only has the fields we care about

View File

@@ -0,0 +1,17 @@
import { getClient } from "@sentry/browser";
import { describe, expect, it } from "vitest";
describe("sentry/apply", () => {
it("boots without a server-injected global", async () => {
// `globalAK` falls back to `ConfigFromJSON({ capabilities: [] })` when the
// server didn't inject `window.authentik`, and `errorReporting` comes back
// undefined despite `Config` typing it as required. Reading through it
// unguarded throws here — in the first import of every entrypoint, taking
// the whole interface down rather than just Sentry.
delete (window as Partial<Window & { authentik: unknown }>).authentik;
await expect(import("#common/sentry/apply")).resolves.toBeDefined();
expect(getClient(), "Sentry stays uninitialized with no configuration").toBeUndefined();
});
});

View File

@@ -0,0 +1,62 @@
/**
* @file Initializes Sentry as an import side effect.
*
* Imported first from each interface entrypoint so reporting is live before the
* element modules evaluate and custom elements register — errors thrown during
* that window used to escape, because initialization ran in an element
* constructor.
*
* The enable/disable policy is {@linkcode isSentryEnabled}, which is a pure
* function so it can be tested without a browser.
*/
import { globalAK } from "#common/global";
import {
DEFAULT_SENTRY_BROWSER_OPTIONS,
isSentryEnabled,
setSentryCapabilities,
setSentryInterface,
} from "#common/sentry/utils";
import { readInterfaceRouteParam } from "#elements/router/utils";
import { ConsoleLogger } from "#logger/browser";
import { CapabilitiesEnum } from "@goauthentik/api";
import { browserTracingIntegration, init, spotlightBrowserIntegration } from "@sentry/browser";
import { type Integration } from "@sentry/core/browser";
const { errorReporting, capabilities } = globalAK().config;
const debug = capabilities.includes(CapabilitiesEnum.CanDebug);
if (isSentryEnabled({ errorReporting, debug, search: window.location.search })) {
const logger = ConsoleLogger.prefix("sentry");
const integrations: Integration[] = [
browserTracingIntegration({
// https://docs.sentry.io/platforms/javascript/tracing/instrumentation/automatic-instrumentation/#custom-routing
instrumentNavigation: false,
instrumentPageLoad: false,
traceFetch: false,
}),
];
if (debug) {
logger.debug("Enabled Spotlight");
integrations.push(spotlightBrowserIntegration());
}
init({
...DEFAULT_SENTRY_BROWSER_OPTIONS,
integrations,
tracePropagationTargets: [window.location.origin],
dsn: errorReporting?.sentryDsn,
tracesSampleRate: debug ? 1.0 : errorReporting?.tracesSampleRate,
environment: errorReporting?.environment,
});
setSentryCapabilities(capabilities);
setSentryInterface(readInterfaceRouteParam());
}

View File

@@ -0,0 +1,16 @@
/**
* @file An error that opts out of Sentry reporting.
*
* Deliberately import-free. This module is pulled in by form and API helpers
* across all three interfaces, none of which want the Sentry SDK or the
* generated API client dragged along with the class.
*/
/**
* A generic error that can be thrown without triggering Sentry's reporting.
*
* @see {@linkcode beforeSend} in `sentry/utils.ts`, which drops these events.
*
* @category Sentry
*/
export class SentryIgnoredError extends Error {}

View File

@@ -1,107 +0,0 @@
import { globalAK } from "#common/global";
import { readInterfaceRouteParam } from "#elements/router/utils";
import { ConsoleLogger } from "#logger/browser";
import { CapabilitiesEnum, ResponseError } from "@goauthentik/api";
import {
browserTracingIntegration,
ErrorEvent,
EventHint,
init,
setTag,
spotlightBrowserIntegration,
} from "@sentry/browser";
import { type Integration } from "@sentry/core";
/**
* A generic error that can be thrown without triggering Sentry's reporting.
*/
export class SentryIgnoredError extends Error {}
export const TAG_SENTRY_COMPONENT = "authentik.component";
export const TAG_SENTRY_CAPABILITIES = "authentik.capabilities";
function beforeSend(
event: ErrorEvent,
hint: EventHint,
): ErrorEvent | PromiseLike<ErrorEvent | null> | null {
if (!hint) {
return event;
}
if (hint.originalException instanceof SentryIgnoredError) {
return null;
}
if (
hint.originalException instanceof ResponseError ||
hint.originalException instanceof DOMException
) {
return null;
}
return event;
}
export function configureSentry(): void {
const cfg = globalAK().config;
const debug = cfg.capabilities.includes(CapabilitiesEnum.CanDebug);
if (!cfg.errorReporting?.enabled && !debug) {
return;
}
const logger = ConsoleLogger.prefix("sentry");
const integrations: Integration[] =
process.env.NODE_ENV === "production"
? [
browserTracingIntegration({
// https://docs.sentry.io/platforms/javascript/tracing/instrumentation/automatic-instrumentation/#custom-routing
instrumentNavigation: false,
instrumentPageLoad: false,
traceFetch: false,
}),
]
: [];
if (debug) {
logger.debug("Enabled Spotlight");
integrations.push(spotlightBrowserIntegration());
}
init({
enabled: process.env.NODE_ENV !== "production",
dsn: cfg.errorReporting.sentryDsn,
ignoreErrors: [
/network/gi,
/fetch/gi,
/module/gi,
// Error on edge on ios,
// https://stackoverflow.com/questions/69261499/what-is-instantsearchsdkjsbridgeclearhighlight
/instantSearchSDKJSBridgeClearHighlight/gi,
// Seems to be an issue in Safari and Firefox
/MutationObserver.observe/gi,
/NS_ERROR_FAILURE/gi,
],
release:
process.env.NODE_ENV === "production"
? `authentik@${import.meta.env.AK_VERSION}`
: undefined,
integrations,
tracePropagationTargets: [window.location.origin],
tracesSampleRate: debug ? 1.0 : cfg.errorReporting.tracesSampleRate,
environment: cfg.errorReporting.environment,
beforeSend,
});
setTag(TAG_SENTRY_CAPABILITIES, cfg.capabilities.join(","));
if (window.location.pathname.includes("if/")) {
setTag(TAG_SENTRY_COMPONENT, `web/${readInterfaceRouteParam()}`);
}
logger.debug("Initialized!");
}

View File

@@ -0,0 +1,138 @@
import { SentryIgnoredError } from "#common/sentry/error";
import { RouteInterfaceName } from "#elements/router/utils";
import { CapabilitiesEnum, type ErrorReportingConfig, ResponseError } from "@goauthentik/api";
import { BrowserOptions, ErrorEvent, EventHint, setTag } from "@sentry/browser";
/**
* Query parameter that turns Sentry off for a single page load.
*/
export const DISABLE_SENTRY_PARAM = "disable-sentry";
/**
* The configuration needed to determine whether Sentry should report for this page load.
*
* @see {@linkcode isSentryEnabled}
*/
export interface SentrySetupOptions {
/**
* The deployment's error-reporting configuration.
*
* Optional because `Config` types it as required while
* `ErrorReportingConfigFromJSON` passes a missing value straight through —
* it is absent whenever the server didn't inject `window.authentik`.
*/
errorReporting?: ErrorReportingConfig;
/**
* Whether the instance reports the `CanDebug` capability.
*/
debug: boolean;
/**
* The current query string, i.e. `window.location.search`.
*/
search: string;
/**
* Whether this is a production build. Defaults to the build-time environment.
*/
production?: boolean;
}
/**
* Whether Sentry should report for this page load.
*
* The administrator's `errorReporting.enabled` setting decides, in every
* environment — a deployment that turns error reporting on expects to receive
* errors. `CanDebug` enables it on its own, which is what activates Spotlight.
*
* Development additionally honors `?disable-sentry`, so a noisy local session
* can opt out for one load without a rebuild.
*
* @category Sentry
*/
export function isSentryEnabled({
errorReporting,
debug,
search,
production = process.env.NODE_ENV === "production",
}: SentrySetupOptions): boolean {
if (!errorReporting?.enabled && !debug) return false;
if (production) return true;
const params = new URLSearchParams(search);
return !params.has(DISABLE_SENTRY_PARAM);
}
/**
* A `beforeSend` callback that ignores certain errors.
*
* @category Sentry
*/
export function beforeSend(
event: ErrorEvent,
hint: EventHint,
): ErrorEvent | PromiseLike<ErrorEvent | null> | null {
if (!hint) {
return event;
}
if (hint.originalException instanceof SentryIgnoredError) {
return null;
}
if (
hint.originalException instanceof ResponseError ||
hint.originalException instanceof DOMException
) {
return null;
}
return event;
}
/**
* Include the given capabilities in Sentry events.
*
* @category Sentry
*/
export function setSentryCapabilities(capabilities: CapabilitiesEnum[]): void {
setTag("authentik.capabilities", capabilities.join(","));
}
/**
* Include the given route interface in Sentry events.
*
* @category Sentry
*/
export function setSentryInterface(interfaceName: RouteInterfaceName) {
setTag("authentik.component", `web/${interfaceName}`);
}
/**
* Default Sentry options for the browser.
*
* Free of browser globals at module scope, so the policy this module also
* exports stays importable outside a document.
*
* @category Sentry
*/
export const DEFAULT_SENTRY_BROWSER_OPTIONS = {
ignoreErrors: [
/network/gi,
/fetch/gi,
/module/gi,
// Error on edge on ios,
// https://stackoverflow.com/questions/69261499/what-is-instantsearchsdkjsbridgeclearhighlight
/instantSearchSDKJSBridgeClearHighlight/gi,
// Seems to be an issue in Safari and Firefox
/MutationObserver.observe/gi,
/NS_ERROR_FAILURE/gi,
],
release:
process.env.NODE_ENV === "production"
? `authentik@${import.meta.env.AK_VERSION}`
: undefined,
beforeSend,
} as const satisfies BrowserOptions;

View File

@@ -0,0 +1,87 @@
import { DISABLE_SENTRY_PARAM, isSentryEnabled } from "./utils.js";
import { type ErrorReportingConfig } from "@goauthentik/api";
import { describe, expect, it } from "vitest";
const reporting = (enabled: boolean): ErrorReportingConfig => ({ enabled }) as ErrorReportingConfig;
describe("isSentryEnabled", () => {
it("is false when the administrator has error reporting off", () => {
expect(
isSentryEnabled({ errorReporting: reporting(false), debug: false, search: "" }),
).toBe(false);
});
it("is true when the administrator has error reporting on", () => {
expect(isSentryEnabled({ errorReporting: reporting(true), debug: false, search: "" })).toBe(
true,
);
});
it("is true for a debug instance even with error reporting off", () => {
// `CanDebug` is what activates Spotlight; it must not depend on the
// administrator's reporting setting.
expect(isSentryEnabled({ errorReporting: reporting(false), debug: true, search: "" })).toBe(
true,
);
});
it("is false when the config is absent entirely", () => {
// `Config` types `errorReporting` as required, but the server may not
// have injected `window.authentik` at all.
expect(isSentryEnabled({ debug: false, search: "" })).toBe(false);
});
describe("in production", () => {
const production = true;
it("ignores the disable parameter", () => {
expect(
isSentryEnabled({
errorReporting: reporting(true),
debug: false,
search: `?${DISABLE_SENTRY_PARAM}`,
production,
}),
).toBe(true);
});
});
describe("in development", () => {
const production = false;
it("honors the disable parameter", () => {
expect(
isSentryEnabled({
errorReporting: reporting(true),
debug: false,
search: `?${DISABLE_SENTRY_PARAM}`,
production,
}),
).toBe(false);
});
it("honors the disable parameter alongside other parameters", () => {
expect(
isSentryEnabled({
errorReporting: reporting(true),
debug: true,
search: `?q=authentik&${DISABLE_SENTRY_PARAM}=1`,
production,
}),
).toBe(false);
});
it("reports when the parameter is absent", () => {
expect(
isSentryEnabled({
errorReporting: reporting(true),
debug: false,
search: "?q=authentik",
production,
}),
).toBe(true);
});
});
});

View File

@@ -1,4 +1,4 @@
import { SentryIgnoredError } from "#common/sentry/index";
import { SentryIgnoredError } from "#common/sentry/error";
export class PreventFormSubmit extends SentryIgnoredError {
// Stub class which can be returned by form elements to prevent the form from submitting

View File

@@ -12,7 +12,6 @@ import Styles from "./FlowExecutor.css" with { type: "bundled-text" };
import { aki } from "#common/api/client";
import { APIError, parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
import { globalAK } from "#common/global";
import { configureSentry } from "#common/sentry/index";
import { applyBackgroundImageProperty } from "#common/theme";
import { Interface } from "#elements/Interface";
@@ -152,7 +151,6 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos
//#region Lifecycle
constructor() {
configureSentry();
super();
this.#api = aki(FlowsApi);
this.addController(this.#flowIframeMessageController);

View File

@@ -1,3 +1,4 @@
import "#common/sentry/apply";
import "#elements/messages/MessageContainer";
import "#elements/ak-drawer/ak-drawer";
import "#flow/FlowExecutor";

View File

@@ -6,7 +6,6 @@ import "#elements/router/RouterOutlet";
import "#components/ak-nav-tabs";
import { globalAK } from "#common/global";
import { configureSentry } from "#common/sentry/index";
import { isGuest } from "#common/users";
import { WebsocketClient } from "#common/ws/WebSocketClient";
@@ -83,8 +82,6 @@ class UserInterface extends WithLicenseSummary(
//#region Lifecycle
constructor() {
configureSentry();
super();
WebsocketClient.connect();

View File

@@ -1,3 +1,4 @@
import "#common/sentry/apply";
import "#elements/messages/MessageContainer";
import "#user/ak-interface-user";

View File

@@ -1,7 +1,7 @@
import "#elements/forms/HorizontalFormElement";
import { aki } from "#common/api/client";
import { SentryIgnoredError } from "#common/sentry/index";
import { SentryIgnoredError } from "#common/sentry/error";
import { ModelForm } from "#elements/forms/ModelForm";

View File

@@ -11,7 +11,7 @@ import { AndNext } from "#common/api/config";
import { createPaginatedResponse } from "#common/api/responses";
import { globalAK } from "#common/global";
import { deviceTypeName } from "#common/labels";
import { SentryIgnoredError } from "#common/sentry/index";
import { SentryIgnoredError } from "#common/sentry/error";
import { PaginatedResponse, Table, TableColumn, Timestamp } from "#elements/table/Table";
import { SlottedTemplateResult } from "#elements/types";