diff --git a/web/src/admin/ak-interface-admin.ts b/web/src/admin/ak-interface-admin.ts index 0ef0f4d381..da85141096 100644 --- a/web/src/admin/ak-interface-admin.ts +++ b/web/src/admin/ak-interface-admin.ts @@ -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(); diff --git a/web/src/admin/index.entrypoint.ts b/web/src/admin/index.entrypoint.ts index f670e46b84..cdca28317d 100644 --- a/web/src/admin/index.entrypoint.ts +++ b/web/src/admin/index.entrypoint.ts @@ -1,3 +1,4 @@ +import "#common/sentry/apply"; import "#elements/messages/MessageContainer"; import "#admin/ak-interface-admin"; diff --git a/web/src/admin/providers/saml/SAMLProviderImportForm.ts b/web/src/admin/providers/saml/SAMLProviderImportForm.ts index f8eb8bb02a..cd90eed0e7 100644 --- a/web/src/admin/providers/saml/SAMLProviderImportForm.ts +++ b/web/src/admin/providers/saml/SAMLProviderImportForm.ts @@ -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"; diff --git a/web/src/admin/users/UserDevicesTable.ts b/web/src/admin/users/UserDevicesTable.ts index 7cb9200f6e..9623493957 100644 --- a/web/src/admin/users/UserDevicesTable.ts +++ b/web/src/admin/users/UserDevicesTable.ts @@ -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"; diff --git a/web/src/common/helpers/plex.ts b/web/src/common/helpers/plex.ts index 45e79bbf95..a6db353260 100644 --- a/web/src/common/helpers/plex.ts +++ b/web/src/common/helpers/plex.ts @@ -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 diff --git a/web/src/common/sentry/apply.browser.test.ts b/web/src/common/sentry/apply.browser.test.ts new file mode 100644 index 0000000000..48ab97ce6f --- /dev/null +++ b/web/src/common/sentry/apply.browser.test.ts @@ -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).authentik; + + await expect(import("#common/sentry/apply")).resolves.toBeDefined(); + + expect(getClient(), "Sentry stays uninitialized with no configuration").toBeUndefined(); + }); +}); diff --git a/web/src/common/sentry/apply.ts b/web/src/common/sentry/apply.ts new file mode 100644 index 0000000000..ba60bb7d4f --- /dev/null +++ b/web/src/common/sentry/apply.ts @@ -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()); +} diff --git a/web/src/common/sentry/error.ts b/web/src/common/sentry/error.ts new file mode 100644 index 0000000000..adaa543d4a --- /dev/null +++ b/web/src/common/sentry/error.ts @@ -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 {} diff --git a/web/src/common/sentry/index.ts b/web/src/common/sentry/index.ts deleted file mode 100644 index 16b30d3bca..0000000000 --- a/web/src/common/sentry/index.ts +++ /dev/null @@ -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 | 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!"); -} diff --git a/web/src/common/sentry/utils.ts b/web/src/common/sentry/utils.ts new file mode 100644 index 0000000000..6122151c73 --- /dev/null +++ b/web/src/common/sentry/utils.ts @@ -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 | 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; diff --git a/web/src/common/sentry/utils.unit.test.ts b/web/src/common/sentry/utils.unit.test.ts new file mode 100644 index 0000000000..d36f3594af --- /dev/null +++ b/web/src/common/sentry/utils.unit.test.ts @@ -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); + }); + }); +}); diff --git a/web/src/elements/forms/helpers.ts b/web/src/elements/forms/helpers.ts index e16d9fd46e..0e320bd868 100644 --- a/web/src/elements/forms/helpers.ts +++ b/web/src/elements/forms/helpers.ts @@ -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 diff --git a/web/src/flow/FlowExecutor.ts b/web/src/flow/FlowExecutor.ts index 3f3fb393cd..b21dade5d6 100644 --- a/web/src/flow/FlowExecutor.ts +++ b/web/src/flow/FlowExecutor.ts @@ -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); diff --git a/web/src/flow/index.entrypoint.ts b/web/src/flow/index.entrypoint.ts index 1f4ce40284..abc57ee027 100644 --- a/web/src/flow/index.entrypoint.ts +++ b/web/src/flow/index.entrypoint.ts @@ -1,3 +1,4 @@ +import "#common/sentry/apply"; import "#elements/messages/MessageContainer"; import "#elements/ak-drawer/ak-drawer"; import "#flow/FlowExecutor"; diff --git a/web/src/user/ak-interface-user.ts b/web/src/user/ak-interface-user.ts index 2112b410fc..9aaaca5cc9 100644 --- a/web/src/user/ak-interface-user.ts +++ b/web/src/user/ak-interface-user.ts @@ -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(); diff --git a/web/src/user/index.entrypoint.ts b/web/src/user/index.entrypoint.ts index d7d9cc14b3..cb7b58f9ef 100644 --- a/web/src/user/index.entrypoint.ts +++ b/web/src/user/index.entrypoint.ts @@ -1,3 +1,4 @@ +import "#common/sentry/apply"; import "#elements/messages/MessageContainer"; import "#user/ak-interface-user"; diff --git a/web/src/user/user-settings/mfa/MFADeviceForm.ts b/web/src/user/user-settings/mfa/MFADeviceForm.ts index ce9f49e538..c360e8e650 100644 --- a/web/src/user/user-settings/mfa/MFADeviceForm.ts +++ b/web/src/user/user-settings/mfa/MFADeviceForm.ts @@ -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"; diff --git a/web/src/user/user-settings/mfa/MFADevicesPage.ts b/web/src/user/user-settings/mfa/MFADevicesPage.ts index e465e30059..31f6341a81 100644 --- a/web/src/user/user-settings/mfa/MFADevicesPage.ts +++ b/web/src/user/user-settings/mfa/MFADevicesPage.ts @@ -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";