Sentry в Angular-монорепозитории: от «пользователи не жалуются» до 6889 событий в одной группе
На этой странице
Зачем это нужно
console.error в браузере пользователя не видит никто. Тестировщик проверяет один браузер и одни данные. Реальные пользователи — это сотни комбинаций: старый Chrome на рабочем компьютере, вкладка, открытая со вчерашнего дня, медленная сеть, права, которых нет ни у кого из команды.
Sentry ставит в приложение маленький SDK. Он ловит необработанные ошибки и отправляет их на сервер вместе с контекстом: страница, браузер, версия приложения, шаги до ошибки. Одинаковые ошибки объединяются в группу (issue), и ты видишь не «10 000 событий», а «7 проблем, отсортированных по количеству пользователей».
Один проект на весь монорепозиторий
У нас 17 приложений с общим кодом. Варианты:
- 17 проектов в Sentry — 17 настроек, 17 мест, где искать. Одна ошибка в общей библиотеке разбросана по 17 спискам.
- Один проект + тег
app— общая ошибка видна одной группой, а фильтр по тегу показывает, в каком приложении она встречается.
Выбрали второе. Каждое событие помечается тегом приложения, а версия называется <приложение>@<коммит>.
Подключение одной функцией
Повторять настройку в каждом main.ts — значит, через полгода получить 17 разных настроек. Поэтому вся конфигурация живёт в одной библиотеке, а приложение вызывает одну функцию. Учебная версия:
// 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,
],
});DSN — публичный идентификатор проекта, его не нужно прятать: он и так окажется в JavaScript-бандле у каждого пользователя. С его помощью можно только отправить события, прочитать их нельзя.
Source maps: без них Sentry бесполезен
В продакшене код минифицирован. Без source maps стек ошибки выглядит так:
main-SII6D5SQ.js:4210:239874 (r.handleError)
main-SII6D5SQ.js:3931:19729 (yr$2)Строка 4210, колонка 239 874, функция yr$2. Понять, что сломалось, невозможно.
Source map — файл .map, который сопоставляет каждое место минифицированного кода с исходным файлом и строкой. Sentry использует его, чтобы показать стек в терминах твоего кода: assignments.page.ts:142, onSave().
Схема в CI:
- Собрать с source maps (
"sourceMap": trueв конфигурации сборки). - Встроить ID в файлы (
sentry-cli sourcemaps inject): каждый.jsи его.mapполучают общий идентификатор, по которому Sentry потом их сопоставит. - Загрузить в Sentry с тем же именем release, что и в рантайме.
- Удалить
.mapперед деплоем: пользователям они не нужны, а исходники на публичном сервере — лишняя утечка.
# учебный пример, для одного приложения
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На чём я споткнулся (три фикса CI подряд):
- Release должен совпадать символ в символ. В рантайме версия берётся из файла, который генерируется при сборке из того же короткого SHA коммита. Разные SHA в рантайме и при загрузке — и Sentry не найдёт карты.
- Старая команда загрузки устарела.
releases files … upload-sourcemapsзаменена наsourcemaps upload. - Новый сборщик Angular кладёт файлы в
browser/. Карты загружались из папки на уровень выше, и пути не совпадали.
Проверка простая: открыть свежую ошибку и посмотреть, видно ли имя твоего файла в стеке.
Сэмплирование: не отправлять всё
Каждое событие стоит денег (лимиты тарифа) и трафика пользователя. Ошибки отправляем все, а всё остальное — долей.
| Что | Prod | Staging |
|---|---|---|
| Трейсы производительности | 2% | 10% |
| Replay обычной сессии | 0% | 0% |
| Replay сессии с ошибкой | 50% | 100% |
Сэмплирование — это вероятность. 2% значит: каждый переход по странице с вероятностью 0,02 будет записан. При 10 000 переходов в день это около 200 трейсов — достаточно, чтобы увидеть медленные страницы, и почти бесплатно.
Replay — запись того, что пользователь делал на странице перед ошибкой (не видео, а восстановленный DOM). Записываем только сессии, где случилась ошибка: обычные сессии нам не нужны.
Шум: что выбрасывать сразу
Часть «ошибок» — не твои баги. Браузерные расширения, ResizeObserver loop (безобидное предупреждение браузера), скрипты, которые кто-то вставил в страницу. Их отсекаем в beforeSend до отправки:
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 = не отправлять
},Список держи коротким и пополняй по факту, а не заранее. Каждая строка в нём — это ошибки, которые ты больше никогда не увидишь.
История одной группы: 6889 событий, 157 пользователей
Через пару месяцев после подключения на первом месте висела одна группа с заголовком:
Object captured as exception with keys: error, headers, message, name, ok, ...6889 событий, 157 пользователей. Выглядит как одна страшная ошибка. На деле — все необработанные HTTP-ошибки всех приложений, слипшиеся в одну.
Почему так. Angular при ошибке запроса выбрасывает HttpErrorResponse. Это не Error: у него нет стека. Sentry получает просто объект, не может построить по нему стек и группирует все такие объекты вместе. 500 на одном эндпоинте, 404 на другом, 504 на третьем — одна группа.
Как Sentry группирует: для каждого события он вычисляет fingerprint — «отпечаток». Обычно это хеш стека и типа ошибки. Одинаковый отпечаток — одна группа. Нет стека — у всех одинаковый отпечаток.
Решение — превратить HTTP-ошибку в нормальный Error и задать отпечаток самим:
// учебный пример
@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);
}
}И нормализация пути, чтобы /users/15 и /users/16 были одной группой, а не тысячей:
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('/');
}Теперь вместо одной гигантской группы — понятные: HTTP 504 /api/users/me/notifications/count. Видно, какой эндпоинт падает, с каким кодом и как часто. Заодно в этой куче нашёлся частый запрос настройки, которой просто не существовало на части серверов. Фронт падал вместо того, чтобы считать её выключенной. Это починили отдельно.
Урок: если одна группа подозрительно большая — проверь, не склеены ли в ней разные проблемы. Обычно это значит, что у событий нет стека.
Что Sentry не должен знать
Sentry — сторонний сервис. Всё, что ты отправляешь, хранится у него. В системе с персональными данными это главный вопрос, а не настройка «на потом».
Что не отправляем:
- Query-параметры URL. В них бывают ИИН, телефоны, поисковые запросы. Отсюда
pathnameв нормализации пути. - Свободный текст ошибок бэкенда. Сообщение вида «Пользователь Иванов И. И. не найден» — это персональные данные. Пропускаем только коды вида
SETTING_DOES_NOT_EXIST: проверка регуляркой^[A-Z0-9_]+$. - Имя и должность пользователя. Для поиска проблем достаточно внутреннего ID: по нему можно найти человека в своей системе, если очень нужно. Сам Sentry этого знать не должен. Общая роль («оператор», «администратор») — можно, она помогает фильтровать. Конкретная должность — нет: по ней человека легко опознать.
- Содержимое страниц в Replay. По умолчанию Replay маскирует весь текст и поля ввода и не записывает картинки. Эти умолчания не отключаем.
Как я это проверил: открыл несколько реальных событий в Sentry и прочитал каждое поле, как будто я посторонний. Это стоит делать после подключения, а потом — раз в несколько месяцев. Код меняется, и данные начинают утекать через места, о которых не думал.
Что Sentry показал в первую же неделю
Пара находок, которых мы бы не увидели без него:
- «Failed to fetch dynamically imported module». Пользователь держит вкладку открытой, мы деплоим новую версию, старые файлы чанков удаляются с сервера. Пользователь переходит на ленивую страницу — браузер просит чанк, которого уже нет. Лечится так: поймать именно эту ошибку и один раз перезагрузить страницу, либо не удалять чанки предыдущей версии сразу.
- Одна ошибка в двух группах. Одинаковый
TypeErrorна одной и той же странице, но две группы. Повод проверить, что source maps загружены для каждой сборки: без них стеки разных сборок отличаются, и отпечатки тоже.
Чек-лист
- Один проект на монорепо, тег приложения, release
app@sha. - Вся настройка в одной библиотеке, в приложении — одна функция.
- Source maps: собрать → inject → upload с тем же release → удалить
.mapперед деплоем. - Проверить на свежей ошибке, что в стеке видно имена твоих файлов.
- Ошибки — все, трейсы и replay — долей.
HttpErrorResponse→Error+ свой fingerprint.- Никаких query-параметров, свободного текста, имён и должностей.
- Раз в квартал читать реальные события глазами постороннего.
Связанные посты: nx-monorepo-from-copies, ci-nx-affected, angular-6-to-22.