Skip to content
Closed
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
86 changes: 86 additions & 0 deletions docs/sentry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# 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 | 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:

- `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 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? |
| ------------- | -------------------------------------- | ------------------- |
| 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.
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export class TosConsentBannerComponent {
* if user is authenticated we check whether is accepted terms of service to hide banner or show if not
* otherwise user is not authenticated we hide banner always
*/
return user ? user.acceptedTermsOfService : true;
return user?.id ? user.acceptedTermsOfService : true;
});

/**
Expand Down
158 changes: 158 additions & 0 deletions src/app/core/helpers/sentry-filter.helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
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/,
'Beacon is not defined',
];

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
4 changes: 3 additions & 1 deletion src/app/features/home/home.component.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<div class="home-container flex flex-column">
<section class="flex flex-column pt-7 px-3 md:mt-7 md:px-5 xl:flex-row xl:mt-4 xl:px-4">
<osf-scheduled-banner class="xl:mt-8" />

<section class="flex flex-column mt-7 px-3 md:px-5 xl:flex-row xl:mt-4 xl:px-4">
<div
class="flex flex-column align-items-center text-center xl:justify-content-center xl:text-left xl:align-items-start"
>
Expand Down
3 changes: 2 additions & 1 deletion src/app/features/home/home.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { MockComponents, MockProvider } from 'ng-mocks';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';

import { ScheduledBannerComponent } from '@osf/core/components/osf-banners/scheduled-banner/scheduled-banner.component';
import { IconComponent } from '@osf/shared/components/icon/icon.component';
import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component';

Expand All @@ -23,7 +24,7 @@ describe('HomeComponent', () => {
activatedRouteMock = ActivatedRouteMockBuilder.create().build();

TestBed.configureTestingModule({
imports: [HomeComponent, ...MockComponents(SearchInputComponent, IconComponent)],
imports: [HomeComponent, ...MockComponents(SearchInputComponent, IconComponent, ScheduledBannerComponent)],
providers: [provideOSFCore(), MockProvider(Router, routerMock), MockProvider(ActivatedRoute, activatedRouteMock)],
});

Expand Down
2 changes: 2 additions & 0 deletions src/app/features/home/home.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Component, inject } from '@angular/core';
import { FormControl } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';

import { ScheduledBannerComponent } from '@osf/core/components/osf-banners/scheduled-banner/scheduled-banner.component';
import { IconComponent } from '@osf/shared/components/icon/icon.component';
import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component';

Expand All @@ -19,6 +20,7 @@ import { INTEGRATION_ICONS, SLIDES } from './constants/data';
Carousel,
Button,
SearchInputComponent,
ScheduledBannerComponent,
IconComponent,
NgOptimizedImage,
NgTemplateOutlet,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
[icon]="'custom-icon-institutions'"
/>

<osf-scheduled-banner />

<div class="flex-column flex flex-1 w-full bg-white p-5">
<osf-search-input [control]="searchControl" [placeholder]="'institutions.searchInstitutions' | translate" />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormControl } from '@angular/forms';
import { provideRouter } from '@angular/router';

import { ScheduledBannerComponent } from '@core/components/osf-banners/scheduled-banner/scheduled-banner.component';
import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component';
import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component';
import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component';
Expand All @@ -31,7 +30,7 @@ describe('InstitutionsListComponent', () => {
TestBed.configureTestingModule({
imports: [
InstitutionsListComponent,
...MockComponents(SubHeaderComponent, SearchInputComponent, LoadingSpinnerComponent, ScheduledBannerComponent),
...MockComponents(SubHeaderComponent, SearchInputComponent, LoadingSpinnerComponent),
],
providers: [
provideOSFCore(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl } from '@angular/forms';
import { RouterLink } from '@angular/router';

import { ScheduledBannerComponent } from '@core/components/osf-banners/scheduled-banner/scheduled-banner.component';
import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component';
import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component';
import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component';
Expand All @@ -25,7 +24,6 @@ import { FetchInstitutions, InstitutionsSelectors } from '@osf/shared/stores/ins
SubHeaderComponent,
SearchInputComponent,
LoadingSpinnerComponent,
ScheduledBannerComponent,
],
templateUrl: './institutions-list.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,18 +143,6 @@ <h3>{{ 'common.labels.affiliatedInstitutions' | translate }}</h3>
[cedarTemplates]="cedarTemplates()"
></osf-overview-collections>

<div class="flex flex-column gap-2">
<h3>{{ 'common.labels.subjects' | translate }}</h3>

<osf-subjects-list [subjects]="subjects()" [isLoading]="areSelectedSubjectsLoading()"></osf-subjects-list>
</div>

<div class="flex flex-column gap-2">
<h3>{{ 'shared.tags.title' | translate }}</h3>

<osf-tags-list [tags]="resource.tags" (tagClick)="tagClicked($event)"></osf-tags-list>
</div>

@if (!isAnonymous()) {
<osf-resource-citations
[resourceId]="resource.id"
Expand Down
6 changes: 1 addition & 5 deletions src/app/features/project/project.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,7 @@ export class ProjectComponent implements OnDestroy {
});

effect(() => {
const licenseId = this.currentProject()?.licenseId;

if (licenseId) {
this.actions.getLicense(licenseId);
}
this.actions.getLicense(this.currentProject()?.licenseId);
});

effect(() => {
Expand Down
Loading
Loading