Мазмұнға өту

Аз болса да — күн сайын.

Angular-монорепозиторийдегі Sentry: «пайдаланушылар шағымданбайды» дегеннен бір топтағы 6889 оқиғаға дейін

7 мин оқу

Осы бетте
Қысқаша
Пайдаланушылар багтар туралы ешқашан дерлік жазбайды: олар бетті жаңартып, ренжиді де, әрі қарай кетеді. Sentry әр пайдаланушының браузерінен қателерді түсінікті стекпен және контекстпен жинайды. Төменде — оны бір монорепозиторийдің 17 қолданбасына бірден қалай қостық, source maps-сыз ол неге пайдасыз, дұрыс жіберілмеген бір қате 157 адамнан 6889 оқиғаны қалай жинады және Sentry пайдаланушы туралы нені білмеуі керек.

Бұл не үшін керек

Пайдаланушы браузеріндегі console.error-ді ешкім көрмейді. Тестілеуші бір браузерді және бір деректерді тексереді. Нақты пайдаланушылар — жүздеген комбинация: жұмыс компьютеріндегі ескі Chrome, кешеден бері ашық тұрған қойынды, баяу желі, командада ешкімде жоқ құқықтар.

Sentry қолданбаға кішкентай SDK орнатады. Ол өңделмеген қателерді ұстап, оларды контекстпен бірге серверге жібереді: бет, браузер, қолданба нұсқасы, қатеге дейінгі қадамдар. Бірдей қателер топқа (issue) біріктіріледі, және сен «10 000 оқиғаны» емес, «пайдаланушылар саны бойынша сұрыпталған 7 мәселені» көресің.

Бүкіл монорепозиторийге бір жоба

Бізде ортақ коды бар 17 қолданба бар. Нұсқалар:

  • Sentry-де 17 жоба — 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 — бөгде сервис. Сен жіберетіннің бәрі соның қолында сақталады. Дербес деректері бар жүйеде бұл «кейінге» қалдырылатын баптау емес, басты сұрақ.

Нені жібермейміз:

  • URL-дің query-параметрлері. Оларда ЖСН, телефондар, іздеу сұраулары болады. Жолды қалыпқа келтірудегі pathname осыдан.
  • Бэкенд қателерінің еркін мәтіні. «Иванов И. И. пайдаланушысы табылмады» деген хабарлама — дербес деректер. Тек SETTING_DOES_NOT_EXIST түріндегі кодтарды өткіземіз: ^[A-Z0-9_]+$ регулярлық өрнегімен тексеру.
  • Пайдаланушының аты мен лауазымы. Мәселелерді іздеуге ішкі ID жеткілікті: қатты керек болса, ол бойынша адамды өз жүйеңнен табуға болады. Sentry-дің өзі мұны білмеуі керек. Жалпы рөл («оператор», «әкімші») — болады, ол сүзуге көмектеседі. Нақты лауазым — жоқ: ол бойынша адамды тану оңай.
  • Replay-дегі беттердің мазмұны. Әдепкі бойынша Replay бүкіл мәтінді және енгізу өрістерін жасырады, суреттерді жазбайды. Бұл әдепкі баптауларды өшірмейміз.

Мұны қалай тексердім: Sentry-де бірнеше нақты оқиғаны ашып, әр өрісті бөгде адам сияқты оқып шықтым. Мұны қосқаннан кейін, содан соң бірнеше ай сайын бір рет жасаған жөн. Код өзгереді, ал деректер ойламаған жерлер арқылы ағып кете бастайды.

Sentry алғашқы аптада-ақ не көрсетті

Онсыз көрмейтін бір-екі табыс:

  • «Failed to fetch dynamically imported module». Пайдаланушы қойындыны ашық ұстап отыр, біз жаңа нұсқаны деплой жасаймыз, ескі чанк файлдары серверден өшіріледі. Пайдаланушы жалқау бетке өтеді — браузер енді жоқ чанкті сұрайды. Емі: дәл осы қатені ұстап, бетті бір рет қайта жүктеу, немесе алдыңғы нұсқаның чанктерін бірден өшірмеу.
  • Бір қате екі топта. Бір беттегі бірдей TypeError, бірақ екі топ. Әр құрастыру үшін source maps жүктелгенін тексеруге себеп: оларсыз әртүрлі құрастырулардың стектері, демек саусақ іздері де, әртүрлі болады.

Чек-лист

  1. Монорепоға бір жоба, қолданба тегі, app@sha release.
  2. Бүкіл баптау бір кітапханада, қолданбада — бір функция.
  3. Source maps: құрастыру → inject → сол release-пен upload → деплойдың алдында .map-ді жою.
  4. Жаңа қатеде стекте өз файлдарыңның аттары көрінетінін тексеру.
  5. Қателер — бәрі, трейстер мен replay — үлесімен.
  6. HttpErrorResponse → Error + өз fingerprint-ің.
  7. Ешқандай query-параметрлер, еркін мәтін, аттар мен лауазымдар жоқ.
  8. Тоқсанына бір рет нақты оқиғаларды бөгде адамның көзімен оқу.

Байланысты жазбалар: nx-monorepo-from-copies, ci-nx-affected, angular-6-to-22.