diff --git a/README.md b/README.md index 2960168c5..7d086f241 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ take up to 60 seconds once the docker build finishes. - [i18n](docs/i18n.md). - [NGXS Conventions](docs/ngxs.md). - [Testing Strategy](docs/testing.md). +- [Sentry error filtering](docs/sentry.md). ### Optional diff --git a/docs/sentry.md b/docs/sentry.md new file mode 100644 index 000000000..1fc232429 --- /dev/null +++ b/docs/sentry.md @@ -0,0 +1,69 @@ +# Sentry error filtering + +Sentry collects JavaScript errors from the OSF Angular app in the browser. Many of those events are not application bugs: flaky networks, cancelled requests, missing/deleted API resources, browser extensions, and stale tabs after a deploy. + +Filtering happens on the client when Sentry starts. Change the lists in `src/app/core/helpers/sentry-filter.helper.ts`. That file is passed into `Sentry.init` from `src/app/core/provider/application.initialization.provider.ts`. + +[Sentry filtering docs](https://docs.sentry.io/platforms/javascript/configuration/filtering/) + +## How to read Sentry after this + +If an issue disappears from Sentry, it was probably filtered here. It does not mean the user stopped hitting the error. + +Server failures (HTTP 500–599) and real JavaScript exceptions are still sent. + +## What we drop + +Three independent checks. An event is dropped if **any** of them match. + +### 1. Error message (`ignoreErrors`) + +Sentry treats each string as a **substring**. `Failed to fetch` also matches `Failed to fetch dynamically imported module`. + +| You will stop seeing | Typical cause | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| Handled unknown error | Sentry could not extract a real Error from Angular | +| Non-Error promise rejection captured… | A promise rejected with `undefined` / `null` / a plain object | +| no elements in sequence | RxJS `EmptyError` (empty observable used with `first()` / `single()`) | +| ResizeObserver loop… | Browser layout warning | +| Failed to fetch | Chrome/Edge: offline, CORS, blocked request, including `api.osf.io` / `addons.osf.io` | +| Load failed | Safari equivalent of failed fetch (including `files.osf.io`) | +| NetworkError when attempting to fetch resource | Firefox equivalent of failed fetch | +| Failed to fetch dynamically imported module / error loading dynamically imported module / Importing a module script failed / ChunkLoadError / Loading chunk … failed | User has an old tab open after a frontend deploy | +| AbortError / The operation was aborted / The user aborted a request | Request cancelled (navigation, timeout, user abort) | + +### 2. Script URL (`denyUrls`) + +Errors whose stack frames come from a **browser extension**, not from OSF code (`chrome-extension://`, `moz-extension://`, `safari-extension://`, and similar). + +### 3. HTTP status below 500 (`beforeSend`) + +If the event is an HTTP response (Angular `HttpErrorResponse`, the message `Http failure response for …: 410`, or `Object captured as exception` with HTTP fields) and the status is **0–499**, it is dropped. + +| Status | Meaning | Dropped? | +| ------------- | -------------------------------------- | ------------------- | +| 0 | No response (offline, CORS, cancelled) | Yes | +| 401, 403 | Not signed in / not allowed | Yes | +| 404, 410 | Missing or deleted resource | Yes | +| 409, 422, 429 | Conflict, validation, rate limit | Yes | +| Other 4xx | Client/request errors | Yes | +| 500–599 | Server error | **No — still sent** | + +This includes noisy issues such as `Http failure response for https://api.osf.io/v2/users/…: 410` and `Object captured as exception with keys: error, headers, … status … url` when the status is below 500. + +**Side effect:** a 4xx that is actually a frontend bug is also dropped (for example a request URL that contains `undefined`). + +## What still goes to Sentry + +- HTTP 500–599 +- TypeError / ReferenceError / other exceptions that are not in the ignore list and have no HTTP status +- HTTP-looking events where a status cannot be read + +## Changing the filters + +1. Open `src/app/core/helpers/sentry-filter.helper.ts`. +2. Add a **string** to `SENTRY_IGNORE_ERRORS` for a stable message substring, or a **RegExp** for a pattern. +3. Add to `SENTRY_DENY_URLS` only for third-party script origins. +4. Change `sentryBeforeSend` only if the HTTP status rule should change (for example keep 404s that contain `undefined` in the URL). + +After a release, confirm in the Sentry project that volume dropped and that 5xx / real exceptions still appear. diff --git a/src/app/core/helpers/sentry-filter.helper.ts b/src/app/core/helpers/sentry-filter.helper.ts new file mode 100644 index 000000000..9bd53031f --- /dev/null +++ b/src/app/core/helpers/sentry-filter.helper.ts @@ -0,0 +1,157 @@ +import { HttpErrorResponse } from '@angular/common/http'; + +import type { ErrorEvent, EventHint } from '@sentry/angular'; + +export const SENTRY_IGNORE_ERRORS: (string | RegExp)[] = [ + 'Handled unknown error', + 'Non-Error promise rejection captured', + 'no elements in sequence', + /ResizeObserver loop/, + 'error loading dynamically imported module', + 'Importing a module script failed', + 'Failed to fetch', + 'Load failed', + 'NetworkError when attempting to fetch resource', + 'AbortError', + 'The operation was aborted', + 'The user aborted a request', + 'ChunkLoadError', + /Loading chunk [\w.-]+ failed/, +]; + +export const SENTRY_DENY_URLS: (string | RegExp)[] = [ + /extensions\//i, + /^chrome:\/\//i, + /^chrome-extension:\/\//i, + /^moz-extension:\/\//i, + /^safari-extension:\/\//i, + /^safari-web-extension:\/\//i, + /^ms-browser-extension:\/\//i, +]; + +const MIN_REPORTED_STATUS = 500; +const MAX_UNWRAP_DEPTH = 4; + +const STATUS_MESSAGE_PATTERNS = [ + /Http failure response for .*: (\d{1,3})(?:\s|$)/, + /Server returned code (\d{1,3})(?:\s|$)/, +]; + +const CAPTURED_OBJECT_KEYS = /(?:Object captured as exception|Non-Error exception captured) with keys: (.+)/; + +const WRAPPER_KEYS = ['ngOriginalError', 'rejection', 'cause'] as const; +const HTTP_RESPONSE_KEYS = ['url', 'statusText', 'headers', 'ok'] as const; + +function isHttpResponseLike(value: object): value is { status: number } { + const hasNumericStatus = 'status' in value && typeof (value as { status: unknown }).status === 'number'; + + return hasNumericStatus && HTTP_RESPONSE_KEYS.some((key) => key in value); +} + +function describesHttpResponse(message: string | undefined): boolean { + const keys = message + ?.match(CAPTURED_OBJECT_KEYS)?.[1] + .split(',') + .map((key) => key.trim()); + + if (!keys?.includes('status')) { + return false; + } + + return HTTP_RESPONSE_KEYS.some((key) => keys.includes(key)); +} + +function getStatusFromMessage(message: string | undefined): number | null { + if (!message) { + return null; + } + + for (const pattern of STATUS_MESSAGE_PATTERNS) { + const match = message.match(pattern); + + if (match) { + return Number(match[1]); + } + } + + return null; +} + +function getStatusFromError(error: unknown, depth = 0): number | null { + if (error instanceof HttpErrorResponse) { + return error.status; + } + + if (typeof error === 'string') { + return getStatusFromMessage(error); + } + + if (!error || typeof error !== 'object') { + return null; + } + + if (isHttpResponseLike(error)) { + return error.status; + } + + if (depth >= MAX_UNWRAP_DEPTH) { + return null; + } + + for (const key of WRAPPER_KEYS) { + const status = getStatusFromError((error as Record)[key], depth + 1); + + if (status !== null) { + return status; + } + } + + return null; +} + +function getErrorMessage(error: unknown, event: ErrorEvent): string | undefined { + if (typeof error === 'string') { + return error; + } + + if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') { + return error.message; + } + + const values = event.exception?.values; + + return values?.[values.length - 1]?.value; +} + +function getStatusFromSerialized(event: ErrorEvent, message: string | undefined): number | null { + const serialized = event.extra?.['__serialized__']; + + if (!serialized || typeof serialized !== 'object') { + return null; + } + + const status = 'status' in serialized ? serialized.status : null; + + if (typeof status === 'number' && (isHttpResponseLike(serialized) || describesHttpResponse(message))) { + return status; + } + + if ('message' in serialized && typeof serialized.message === 'string') { + return getStatusFromMessage(serialized.message); + } + + return null; +} + +function resolveHttpStatus(error: unknown, event: ErrorEvent): number | null { + const message = getErrorMessage(error, event); + + return getStatusFromError(error) ?? getStatusFromSerialized(event, message) ?? getStatusFromMessage(message); +} + +export function sentryBeforeSend(event: ErrorEvent, hint: EventHint): ErrorEvent | null { + const status = resolveHttpStatus(hint.originalException, event); + const isReportable = status === null || status >= MIN_REPORTED_STATUS; + + return isReportable ? event : null; +} diff --git a/src/app/core/provider/application.initialization.provider.ts b/src/app/core/provider/application.initialization.provider.ts index 10e11a5d9..ed08425c7 100644 --- a/src/app/core/provider/application.initialization.provider.ts +++ b/src/app/core/provider/application.initialization.provider.ts @@ -1,6 +1,7 @@ import { isPlatformBrowser } from '@angular/common'; import { inject, PLATFORM_ID, provideAppInitializer } from '@angular/core'; +import { SENTRY_DENY_URLS, SENTRY_IGNORE_ERRORS, sentryBeforeSend } from '@core/helpers/sentry-filter.helper'; import { OSFConfigService } from '@core/services/osf-config.service'; import { ENVIRONMENT } from './environment.provider'; @@ -43,7 +44,9 @@ export function initializeApplication() { environment: environment.production ? 'production' : 'development', maxBreadcrumbs: 50, sampleRate: 1.0, - integrations: [], + ignoreErrors: SENTRY_IGNORE_ERRORS, + denyUrls: SENTRY_DENY_URLS, + beforeSend: sentryBeforeSend, }); } }