Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
69 changes: 69 additions & 0 deletions docs/sentry.md
Original file line number Diff line number Diff line change
@@ -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.
157 changes: 157 additions & 0 deletions src/app/core/helpers/sentry-filter.helper.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)[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;
}
5 changes: 4 additions & 1 deletion src/app/core/provider/application.initialization.provider.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
});
}
}
Expand Down
Loading