From 07f9d105c33459f84800fcc6ece06834fca8d3d9 Mon Sep 17 00:00:00 2001 From: nsemets Date: Mon, 14 Sep 2026 16:36:58 +0300 Subject: [PATCH 1/3] feat(sentry): added filtering --- README.md | 1 + docs/sentry.md | 69 ++++++++ src/app/core/helpers/sentry-filter.helper.ts | 157 ++++++++++++++++++ .../application.initialization.provider.ts | 5 +- 4 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 docs/sentry.md create mode 100644 src/app/core/helpers/sentry-filter.helper.ts 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, }); } } From 73a7399012c9072c01b458015b402a23bb83b96d Mon Sep 17 00:00:00 2001 From: nsemets Date: Wed, 16 Sep 2026 16:12:41 +0300 Subject: [PATCH 2/3] fix(sentry): added beacon error to ignore errors list --- docs/sentry.md | 1 + src/app/core/helpers/sentry-filter.helper.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/sentry.md b/docs/sentry.md index 1fc232429..a4ce874d5 100644 --- a/docs/sentry.md +++ b/docs/sentry.md @@ -31,6 +31,7 @@ Sentry treats each string as a **substring**. `Failed to fetch` also matches `Fa | 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) | +| Beacon is not defined | Extension or third-party script; not OSF (`navigator.sendBeacon` is a different API) | ### 2. Script URL (`denyUrls`) diff --git a/src/app/core/helpers/sentry-filter.helper.ts b/src/app/core/helpers/sentry-filter.helper.ts index 9bd53031f..230c11b9d 100644 --- a/src/app/core/helpers/sentry-filter.helper.ts +++ b/src/app/core/helpers/sentry-filter.helper.ts @@ -17,6 +17,7 @@ export const SENTRY_IGNORE_ERRORS: (string | RegExp)[] = [ 'The user aborted a request', 'ChunkLoadError', /Loading chunk [\w.-]+ failed/, + 'Beacon is not defined', ]; export const SENTRY_DENY_URLS: (string | RegExp)[] = [ From 4857cbf53d1c2f2df04b3b4a692bb2555a636325 Mon Sep 17 00:00:00 2001 From: nsemets Date: Wed, 16 Sep 2026 16:21:03 +0300 Subject: [PATCH 3/3] fix(docs): updated docs for sentry filtering --- docs/sentry.md | 43 ++++++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/docs/sentry.md b/docs/sentry.md index 1fc232429..27a1ae607 100644 --- a/docs/sentry.md +++ b/docs/sentry.md @@ -20,25 +20,42 @@ Three independent checks. An event is dropped if **any** of them match. 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) | +| 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 | Chrome/Edge: lazy chunk failed to load (often an old tab after deploy) | +| error loading dynamically imported module | Firefox: same lazy-chunk failure | +| Importing a module script failed | Safari: same lazy-chunk failure | +| ChunkLoadError / Loading chunk … failed | Webpack/Vite chunk load failure after deploy | +| AbortError / The operation was aborted / The user aborted a request | Request cancelled (navigation, timeout, user abort) | +| Beacon is not defined | Extension or third-party script; not OSF (`navigator.sendBeacon` is a different API) | ### 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). +Errors whose stack frames come from a **browser extension**, not from OSF code: + +- `extensions/` +- `chrome://` +- `chrome-extension://` +- `moz-extension://` +- `safari-extension://` +- `safari-web-extension://` +- `ms-browser-extension://` ### 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. +If the event is an HTTP response and the status is **0–499**, it is dropped. Status is read from: + +- Angular `HttpErrorResponse` (including nested `ngOriginalError` / `rejection` / `cause`) +- `Http failure response for …: 410` +- `Server returned code 404 with body "…"` +- `Object captured as exception with keys: …` or `Non-Error exception captured with keys: …` when the keys look like an HTTP response | Status | Meaning | Dropped? | | ------------- | -------------------------------------- | ------------------- |