Skip to content

A little — every day.

Sentry in an Angular monorepo: from “users don't complain” to 6889 events in one group

9 min read

On this page
In short
Users almost never report bugs: they reload the page, grumble and move on. Sentry collects errors from every user’s browser with a readable stack and context. Below: how we connected it to all 17 apps of one monorepo at once, why it’s useless without source maps, how one wrongly reported error piled up 6889 events from 157 people, and what Sentry should not know about the user.

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 app tag — 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:

  1. Build with source maps ("sourceMap": true in the build configuration).
  2. Inject an ID into the files (sentry-cli sourcemaps inject): each .js and its .map get a shared identifier that Sentry later uses to match them.
  3. Upload to Sentry with the same release name as at runtime.
  4. Delete .map before 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' -delete

Where 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-sourcemaps was replaced by sourcemaps 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.

WhatProdStaging
Performance traces2%10%
Replay of a normal session0%0%
Replay of a session with an error50%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 pathname in 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 TypeError on 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

  1. One project per monorepo, an app tag, release app@sha.
  2. All setup in one library, one function in the app.
  3. Source maps: build → inject → upload with the same release → delete .map before deploying.
  4. Check on a fresh error that your file names show up in the stack.
  5. Errors — all of them; traces and replay — a share.
  6. HttpErrorResponse → Error + your own fingerprint.
  7. No query parameters, free text, names or job titles.
  8. Once a quarter, read real events through a stranger’s eyes.

Related posts: nx-monorepo-from-copies, ci-nx-affected, angular-6-to-22.