mirror of
https://github.com/goauthentik/authentik.git
synced 2026-08-30 18:51:39 -07:00
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:
@@ -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();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "#common/sentry/apply";
|
||||
import "#elements/messages/MessageContainer";
|
||||
import "#admin/ak-interface-admin";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
17
web/src/common/sentry/apply.browser.test.ts
Normal file
17
web/src/common/sentry/apply.browser.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
62
web/src/common/sentry/apply.ts
Normal file
62
web/src/common/sentry/apply.ts
Normal 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());
|
||||
}
|
||||
16
web/src/common/sentry/error.ts
Normal file
16
web/src/common/sentry/error.ts
Normal 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 {}
|
||||
@@ -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!");
|
||||
}
|
||||
138
web/src/common/sentry/utils.ts
Normal file
138
web/src/common/sentry/utils.ts
Normal 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;
|
||||
87
web/src/common/sentry/utils.unit.test.ts
Normal file
87
web/src/common/sentry/utils.unit.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "#common/sentry/apply";
|
||||
import "#elements/messages/MessageContainer";
|
||||
import "#elements/ak-drawer/ak-drawer";
|
||||
import "#flow/FlowExecutor";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "#common/sentry/apply";
|
||||
import "#elements/messages/MessageContainer";
|
||||
import "#user/ak-interface-user";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user