Перейти к содержимому

Понемногу — но каждый день.

Sentry в Angular-монорепозитории: от «пользователи не жалуются» до 6889 событий в одной группе

8 мин чтения

На этой странице
Коротко
Пользователи почти никогда не пишут о багах: они обновляют страницу, ругаются и идут дальше. Sentry собирает ошибки из браузера каждого пользователя с понятным стеком и контекстом. Ниже — как мы подключили его сразу к 17 приложениям одного монорепозитория, почему без source maps он бесполезен, как одна неправильно отправленная ошибка набрала 6889 событий от 157 человек, и что Sentry не должен знать о пользователе.

Зачем это нужно

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:

  1. Собрать с source maps ("sourceMap": true в конфигурации сборки).
  2. Встроить ID в файлы (sentry-cli sourcemaps inject): каждый .js и его .map получают общий идентификатор, по которому Sentry потом их сопоставит.
  3. Загрузить в Sentry с тем же именем release, что и в рантайме.
  4. Удалить .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/. Карты загружались из папки на уровень выше, и пути не совпадали.

Проверка простая: открыть свежую ошибку и посмотреть, видно ли имя твоего файла в стеке.

Сэмплирование: не отправлять всё

Каждое событие стоит денег (лимиты тарифа) и трафика пользователя. Ошибки отправляем все, а всё остальное — долей.

ЧтоProdStaging
Трейсы производительности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 загружены для каждой сборки: без них стеки разных сборок отличаются, и отпечатки тоже.

Чек-лист

  1. Один проект на монорепо, тег приложения, release app@sha.
  2. Вся настройка в одной библиотеке, в приложении — одна функция.
  3. Source maps: собрать → inject → upload с тем же release → удалить .map перед деплоем.
  4. Проверить на свежей ошибке, что в стеке видно имена твоих файлов.
  5. Ошибки — все, трейсы и replay — долей.
  6. HttpErrorResponse → Error + свой fingerprint.
  7. Никаких query-параметров, свободного текста, имён и должностей.
  8. Раз в квартал читать реальные события глазами постороннего.

Связанные посты: nx-monorepo-from-copies, ci-nx-affected, angular-6-to-22.