Sentry in an Angular monorepo: from “users don't complain” to 6889 events in one group
On this page
Why you need it
Nobody sees console.error in a user’s browser. A tester checks one browser and one set of data. Real users are hundreds of combinations: an old Chrome on a work computer, a tab left open since yesterday, a slow network, permissions nobody on the team has.
Sentry puts a small SDK into the app. It catches unhandled errors and sends them to a server along with context: the page, the browser, the app version, the steps before the error. Identical errors are merged into a group (issue), and instead of “10,000 events” you see “7 problems, sorted by number of users”.
One project for the whole monorepo
We have 17 apps with shared code. The options:
- 17 projects in Sentry — 17 configurations, 17 places to look. One error in a shared library is scattered across 17 lists.
- One project + an
apptag — a shared error shows up as one group, and filtering by tag shows which app it occurs in.
We chose the second. Every event is tagged with its app, and the version is named <приложение>@<коммит>.
Setup with one function
Repeating the setup in every main.ts means ending up with 17 different setups six months later. So all the configuration lives in one library, and an app calls one function. A teaching version:
// observability/provide-observability.ts (учебный пример)
import { ErrorHandler, inject, makeEnvironmentProviders, provideAppInitializer } from '@angular/core';
import { Router } from '@angular/router';
import * as Sentry from '@sentry/angular';
export function provideObservability(cfg: { app: string; env: string; release: string }) {
return makeEnvironmentProviders([
{ provide: ErrorHandler, useClass: AppErrorHandler }, // наш обработчик, см. ниже
{ provide: Sentry.TraceService, deps: [Router] },
provideAppInitializer(() => {
if (cfg.env === 'development') return; // локально не шумим
Sentry.init({
dsn: SENTRY_DSN,
environment: cfg.env,
release: `${cfg.app}@${cfg.release}`,
integrations: [Sentry.browserTracingIntegration(), Sentry.replayIntegration()],
});
Sentry.setTag('app', cfg.app);
inject(Sentry.TraceService); // создание сервиса включает трейсинг переходов
}),
]);
}// main.ts любого приложения
bootstrapApplication(AppComponent, {
providers: [
provideObservability({ app: 'region-a', env: environment.envName, release: environment.release }),
...appConfig.providers,
],
});The DSN is the project’s public identifier and doesn’t need hiding: it ends up in every user’s JavaScript bundle anyway. It can only be used to send events, not to read them.
Source maps: without them Sentry is useless
In production the code is minified. Without source maps an error’s stack looks like this:
main-SII6D5SQ.js:4210:239874 (r.handleError)
main-SII6D5SQ.js:3931:19729 (yr$2)Line 4210, column 239,874, function yr$2. There’s no way to tell what broke.
A source map is a .map file that maps every place in the minified code back to the original file and line. Sentry uses it to show the stack in terms of your code: assignments.page.ts:142, onSave().
The setup in CI:
- Build with source maps (
"sourceMap": truein the build configuration). - Inject an ID into the files (
sentry-cli sourcemaps inject): each.jsand its.mapget a shared identifier that Sentry later uses to match them. - Upload to Sentry with the same release name as at runtime.
- Delete
.mapbefore deploying: users don’t need them, and source code on a public server is an unnecessary leak.
# учебный пример, для одного приложения
RELEASE="region-a@${CI_COMMIT_SHORT_SHA}"
npx sentry-cli releases new "$RELEASE"
npx sentry-cli sourcemaps inject dist/apps/region-a/browser
npx sentry-cli sourcemaps upload --release "$RELEASE" --url-prefix "~/" dist/apps/region-a/browser
npx sentry-cli releases finalize "$RELEASE"
find dist -name '*.map' -deleteWhere I tripped up (three CI fixes in a row):
- The release must match character for character. At runtime the version comes from a file generated during the build from the same short commit SHA. Different SHAs at runtime and at upload, and Sentry won’t find the maps.
- The old upload command is deprecated.
releases files … upload-sourcemapswas replaced bysourcemaps upload. - The new Angular builder puts files in
browser/. The maps were uploaded from the folder one level up, and the paths didn’t match.
The check is simple: open a fresh error and see whether your file name shows up in the stack.
Sampling: don’t send everything
Every event costs money (plan limits) and the user’s traffic. We send all errors, and everything else as a share.
| What | Prod | Staging |
|---|---|---|
| Performance traces | 2% | 10% |
| Replay of a normal session | 0% | 0% |
| Replay of a session with an error | 50% | 100% |
Sampling is a probability. 2% means each page navigation is recorded with probability 0.02. At 10,000 navigations a day that’s about 200 traces — enough to spot slow pages, and almost free.
Replay is a recording of what the user did on the page before the error (not video, but a reconstructed DOM). We record only sessions where an error happened: we don’t need normal sessions.
Noise: what to throw away right away
Some “errors” aren’t your bugs. Browser extensions, ResizeObserver loop (a harmless browser warning), scripts someone injected into the page. We cut them off in beforeSend before sending:
beforeSend(event) {
const ignore = ['ResizeObserver loop', 'chrome-extension://'];
const noisy = event.exception?.values?.some(v => ignore.some(i => v.value?.includes(i)));
return noisy ? null : event; // null = не отправлять
},Keep the list short and add to it as cases come up, not in advance. Every line in it is errors you will never see again.
The story of one group: 6889 events, 157 users
A couple of months after setup, one group sat at the top with this title:
Object captured as exception with keys: error, headers, message, name, ok, ...6889 events, 157 users. It looks like one scary error. In fact it was every unhandled HTTP error from every app, stuck together into one.
Why. When a request fails, Angular throws HttpErrorResponse. That’s not an Error: it has no stack. Sentry receives a plain object, can’t build a stack from it, and groups all such objects together. A 500 on one endpoint, a 404 on another, a 504 on a third — one group.
How Sentry groups: for each event it computes a fingerprint. Usually it’s a hash of the stack and the error type. Same fingerprint, same group. No stack, and everyone has the same fingerprint.
The fix is to turn the HTTP error into a proper Error and set the fingerprint ourselves:
// учебный пример
@Injectable()
export class AppErrorHandler implements ErrorHandler {
handleError(error: unknown): void {
if (error instanceof HttpErrorResponse) {
if (error.status === 401) return; // истёкшая сессия — штатный выход, не баг
const path = normalizePath(error.url);
const e = new Error(`HTTP ${error.status} ${path}`);
e.name = 'HttpErrorResponse';
Sentry.captureException(e, {
fingerprint: ['http-error', String(error.status), path], // группа = статус + путь
level: 'warning', // пользователь уже видел тост с ошибкой
});
} else {
Sentry.captureException(error);
}
console.error(error);
}
}And normalise the path, so that /users/15 and /users/16 are one group rather than a thousand:
function normalizePath(url: string | null): string {
if (!url) return 'unknown';
const pathname = new URL(url, location.origin).pathname; // без query-параметров
return pathname
.split('/')
.map(s => /^\d+$/.test(s) || /^[0-9a-f-]{36}$/i.test(s) ? ':id' : s)
.join('/');
}Now, instead of one giant group, there are clear ones: HTTP 504 /api/users/me/notifications/count. You can see which endpoint fails, with which code and how often. Along the way this pile revealed a frequent request for a setting that simply didn’t exist on some of the servers. The frontend crashed instead of treating it as turned off. That was fixed separately.
Lesson: if one group is suspiciously large, check whether different problems are stuck together in it. Usually it means the events have no stack.
What Sentry shouldn’t know
Sentry is a third-party service. Everything you send is stored with them. In a system with personal data this is the main question, not a setting “for later”.
What we don’t send:
- URL query parameters. They can contain IINs, phone numbers, search queries. That’s where
pathnamein the path normalisation comes from. - Free text of backend errors. A message like “User Ivanov I. I. not found” is personal data. We only let through codes like
SETTING_DOES_NOT_EXIST: checked with the regex^[A-Z0-9_]+$. - The user’s name and job title. An internal ID is enough to investigate problems: it can be used to find the person in your own system if really needed. Sentry itself shouldn’t know this. A general role (“operator”, “administrator”) is fine, it helps filtering. A specific job title is not: it makes a person easy to identify.
- Page content in Replay. By default Replay masks all text and input fields and doesn’t record images. We don’t turn those defaults off.
How I checked this: I opened several real events in Sentry and read every field as if I were a stranger. It’s worth doing right after setup, and then every few months. Code changes, and data starts leaking through places you didn’t think of.
What Sentry showed in the very first week
A couple of findings we wouldn’t have seen without it:
- “Failed to fetch dynamically imported module”. A user keeps a tab open, we deploy a new version, the old chunk files are removed from the server. The user navigates to a lazy page, and the browser asks for a chunk that no longer exists. The fix: catch exactly this error and reload the page once, or don’t delete the previous version’s chunks right away.
- One error in two groups. The same
TypeErroron the same page, but two groups. A reason to check that source maps are uploaded for every build: without them, stacks from different builds differ, and so do the fingerprints.
Checklist
- One project per monorepo, an app tag, release
app@sha. - All setup in one library, one function in the app.
- Source maps: build → inject → upload with the same release → delete
.mapbefore deploying. - Check on a fresh error that your file names show up in the stack.
- Errors — all of them; traces and replay — a share.
HttpErrorResponse→Error+ your own fingerprint.- No query parameters, free text, names or job titles.
- Once a quarter, read real events through a stranger’s eyes.
Related posts: nx-monorepo-from-copies, ci-nx-affected, angular-6-to-22.