open atlas
↑ К треку
NestJS с нуля до senior NEST · 07 · 05

Трекинг ошибок, корреляция и усталость от алертов

Логи, метрики и трейсы — три стога сена, пока их не сошьёт общий traceId. Трекер ошибок группирует по fingerprint и дедуплицирует; вычищай PII перед отправкой; алертить надо по СКОРОСТИ ошибок, а не по счётчику, иначе канал замьютят и реальный сбой утонет в шуме.

NEST Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Сбой платежей длился час, прежде чем кто-то заглянул. Не потому что не сработал алерт — алерты срабатывали постоянно. Этот канал #alerts орал неделями: флапающий таймаут стороннего сервиса, который ретраился и проходил, обработанный 404 от сканера, долбящего мёртвые роуты, порог по счётчику, который кто-то выставил во время инцидента и так и не подкрутил. Сотни сообщений в день, каждое технически реальная ошибка, каждое безопасно проигнорировать. Так что команда сделала рациональное: замьютила канал. Потом чекаут начал реально падать, алерт прилетел в тот же замьюченный канал и выглядел ровно как шум, который все уже приучили себя не замечать. У тебя крутилось три системы наблюдаемости — логи, метрики, трейсы, всё из предыдущих четырёх уроков — и трекер ошибок поверх. Ничего не помогло, потому что сигнал был погребён под шумом на основе счётчика, и ничто не связывало куски воедино. Этот урок — про сшивку: как коррелировать три столпа по id, трекать ошибки так, чтобы они группировались, а не тонули, и алертить по скорости, которая будит человека только когда его стоит будить.

Три столпа, один инцидент: корреляция по id

Логи говорят, что случилось (строка на событие), метрики говорят, сколько / как быстро (скорости и перцентили из L04), трейсы говорят, где в дереве вызовов живут время и сбои (спаны из L03). Каждое необходимо; ни одно само по себе не достаточно. То, что превращает три несвязанных стога сена в один поисковый инцидент, — это общий ключ соединения, проставленный на всех них: traceId, протянутый через запрос, плюс requestId, записанный в каждую строку лога, прикреплённый к каждому отчёту об ошибке и несомый трейсом.

// Корреляционный middleware/interceptor проставляет ключ соединения один раз, рано.
// (Конверт лога — L01; пропагация трейса — L03 — здесь мы лишь протягиваем ID.)
@Injectable()
export class CorrelationInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler) {
    const req = ctx.switchToHttp().getRequest();
    // переиспользуй входящий трейс, если трейсер его задал, иначе сминть requestId
    const traceId = trace.getActiveSpan()?.spanContext().traceId;
    const requestId = req.headers['x-request-id'] ?? randomUUID();
    // сделай оба доступными логгеру, репортеру ошибок и нижестоящим вызовам
    return runWithContext({ traceId, requestId }, () => next.handle());
  }
}

С ключом на месте алерт становится пивотом, а не расследованием: ошибка в трекере → читаешь её traceId → открываешь ровно этот трейс → фильтруешь логи по этому одному requestId. Ты переходишь от «что-то не так» к «этот запрос, этот спан, эти строки лога» в три клика. Без ключа соединения у тебя поиск по логам, поиск по трейсам и список ошибок, которые не делят общего словаря, — три инструмента, три догадки про корреляцию по времени и куда более длинный час.

Трекинг ошибок: группируй по fingerprint, не тони

Строка лога на ошибку топит. Сотое появление того же NullPointerException — это сотая идентичная строка, мимо которой ты проскролливаешь. Трекер ошибок (в стиле Sentry) переворачивает это: он вычисляет fingerprint из типа ошибки и кадров стека, затем группирует каждое появление с тем же fingerprint в одну issue, считает появления и захватывает контекст — release, пользователь, запрос, breadcrumbs — один раз на группу. Десять тысяч идентичных сбоев становятся одной issue со счётчиком 10 000, а не 10 000 строк.

В Nest глобальный exception filter, который ты построил в L01, — естественная точка репортинга: он уже ловит каждый throw внутри пайплайна запроса, так что это место, где ты отдаёшь ошибку трекеру с прикреплённым корреляционным контекстом.

@Catch()
export class SentryExceptionFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const { traceId, requestId } = getContext();
    Sentry.withScope((scope) => {
      scope.setTag('traceId', traceId);       // <- связывает эту issue с её трейсом
      scope.setTag('requestId', requestId);   // <- и с её точными строками лога
      scope.setUser({ id: getUserId() });     // кто наткнулся (только id, не PII)
      Sentry.captureException(exception);      // fingerprint + группировка Sentry
    });
    // ... всё ещё транслируй в HTTP-ответ, как в L01
  }
}

Два прикрепления отрабатывают своё место. release — SHA деплоя, заданный один раз на старте (Sentry.init({ release })) — делает так, что всплеск маппится на конкретный деплой: новая issue, появившаяся в ту минуту, когда уехал a3f9c1, — это регрессия, которую можно откатить. traceId/requestId делают каждую issue прыжком в один клик в трейс и логи — это корреляция из предыдущего раздела, сделанная конкретной.

Ловушка необработанного rejection

У глобального filter жёсткая граница: он ловит только throw’ы внутри пайплайна запроса. Ошибка, брошенная out-of-band — необработанный promise rejection в setInterval-джобе, throw внутри обработчика EventEmitter, отклонённый promise, который никто не awaitнул — никогда не входит в путь обработки исключений Nest, так что никогда не доходит до filter и никогда не доходит до трекера. Она исчезает. На современном Node unhandledRejection может ещё и завершить процесс напрочь.

Боевая история: ночная джоба сверки делала void doReconcile() — fire-and-forget — и одной ночью doReconcile отклонилась на кривой строке. Глобальный filter её не увидел (нет запроса), Sentry её не увидел (нет filter), и на Node 18 необработанный rejection положил worker-под. Под перезапустился, переретраил ту же плохую строку, снова отклонился, снова упал — тихий crash-loop, который крутился часами с нулём отчётов об ошибках, потому что единственное место, где собирались ошибки, было местом, до которого эта ошибка не могла дойти.

// Out-of-band ошибкам нужны обработчики уровня ПРОЦЕССА — filter их не видит.
process.on('unhandledRejection', (reason) => {
  Sentry.captureException(reason);           // зарепорти, а не потеряй
});
process.on('uncaughtException', (err) => {
  Sentry.captureException(err);
  Sentry.flush(2000).finally(() => process.exit(1)); // зарепорти, слей, потом выйди
});

Правило: глобальный filter покрывает пайплайн запроса; process-обработчики покрывают всё остальное. Тебе нужны оба, иначе целые классы сбоев невидимы.

Почему это работает

Почему алертинг по сырому счётчику ошибок заставляет людей пропускать реальные сбои? Потому что счётчик смешивает ожидаемый, обработанный шум с настоящим сбоем, и у него нет базовой линии. Ровный ручеёк ретраенных таймаутов и сканерных 404 пейджит постоянно — канал наполняется ошибками, которые все «реальны» и все безопасны. Люди делают единственное, что можно с пожарным шлангом безопасных алертов: мьютят его. Потом реальный сбой приходит в тот же замьюченный канал, выглядя идентично шуму, который все уже приучили себя игнорировать. Алертинг по скорости относительно SLO/бюджета ошибок (из L04) чинит рамку: он отделяет «нормальный фоновый ручеёк» от «мы жжём бюджет быстрее, чем позволяет SLO», а это единственное, что реально стоит того, чтобы будить человека. Счётчик всегда был высоким; скорость, пересекающая бюджет — это новая информация.

Усталость от алертов: алертить по сигналу, не по шуму

Режим отказа — алертить на каждую ошибку или на сырой счётчик ошибок. Оба генерируют шум: кратковременный всплеск, обработанный 404, ретраенный-затем-прошедший таймаут — каждый пейджит человека. Когда пейджит всё, люди мьютят, и мьют навсегда. У дисциплины четыре хода:

  • Алертить по скорости / burn SLO, не по счётчику (L04). Burn-rate алерт срабатывает, когда ты потребляешь бюджет ошибок быстрее, чем терпит SLO, — он игнорирует ровный безопасный ручеёк и ловит реальный обрыв.
  • Отделяй «залогируй» от «дёрни человека». Обработанные, ожидаемые условия (валидационный 400, известный ретраебельный таймаут) логируются и считаются, но никогда не пейджат. Пейджат только неожиданные, бьющие по пользователю сбои.
  • Задай severity. Пейджи на SEV1/SEV2; всё остальное роуть на дашборд или в дайджест, а не на телефон человека в 3 ночи.
  • Используй группировку трекера. Один новый fingerprint — это одно уведомление, а не 10 000. «Появилась новая issue» — сигнал; «существующая issue тикнула на единицу» обычно нет.

Вместе эти четыре хода превращают орущий канал в заслуживающий доверия: burn-rate отсекает безопасный ручеёк, дедуп убирает повторяющиеся краши — но без разделения severity первые три всё равно будут генерировать шум, который замьютят.

PII и секреты: вычищай перед отправкой

Прежде чем подключать Sentry или любой другой трекер ошибок, спроси себя: что обычно лежит в теле запроса или заголовке Authorization в твоём приложении? Если там есть токены, email или любые пользовательские данные — а они почти всегда там есть — то отправить сырой запрос третьей стороне значит самому залогировать утечку. Контекст ошибки — это утечка, ждущая случиться. Breadcrumbs и снапшоты запроса могут захватить тела запросов, заголовки (включая Authorization!), email и токены — а трекер ошибок это третья сторона. Отправить ему сырой запрос может означать отправить живой bearer-токен или PII клиента твоему вендору ошибок. Это breach, залогированный тобой, нарочно. Ты обязан вычистить перед тем, как что-либо покинет процесс: хук beforeSend, который редактирует известно-чувствительные поля, deny-list для заголовков и срезание паттернов токенов/секретов.

Sentry.init({
  release: process.env.GIT_SHA,
  beforeSend(event) {
    // deny-list чувствительных заголовков ДО того, как событие покинет процесс
    const headers = event.request?.headers;
    if (headers) { delete headers['authorization']; delete headers['cookie']; }
    // отредактируй очевидные PII / секреты из тела
    if (event.request?.data) event.request.data = redactSecrets(event.request.data);
    return event;
  },
});
Выбери лучший вариант

Твой канал #alerts настолько шумный, что команда его мьютит, и реальный сбой пропустили. Ты должен снизить шум, НЕ пропустив следующий реальный инцидент. Что меняешь?

Викторина

Ночная джоба делает `void doReconcile()`, и одной ночью promise отклоняется. У приложения есть глобальный exception filter, репортящий в Sentry. Почему этот rejection никогда не доходит до Sentry?

Викторина

Канал #alerts был замьючен неделями безопасных-но-реальных ошибок, и сбой платежей оставался незамеченным час. Какое изменение алертинга наиболее прямо это чинит?

Вспомните перед уходом
  1. 01
    Объясни, как коррелировать три столпа наблюдаемости во время инцидента и почему группировка трекера по fingerprint лучше строки лога на ошибку.
  2. 02
    Почему out-of-band ошибки обходят глобальный filter Nest и почему алертинг по сырому счётчику заставляет команды пропускать реальные сбои? Дай фикс для каждого.
Итог

Логи (что случилось), метрики (сколько/как быстро, L04) и трейсы (где в дереве вызовов, L03) — три несвязанных стога сена, пока общий ключ соединения — traceId плюс requestId, проставленный в каждую строку лога, каждый отчёт об ошибке и трейс — не превратит алерт в пивот: ошибка → её traceId → этот трейс → эти точные строки лога. Трекер ошибок лучше строки лога на ошибку тем, что вычисляет fingerprint из типа ошибки и стека и группирует каждое появление в одну issue со счётчиком, захватывая release/пользователя/запрос/breadcrumbs один раз; прикрепляй SHA релиза, чтобы всплеск маппился на деплой, и traceId, чтобы каждая issue линковалась к своему трейсу и логам. Глобальный exception filter из L01 ловит только throw’ы внутри пайплайна запроса, так что out-of-band ошибки — необработанный rejection в таймере или обработчике события, fire-and-forget отклонённый promise — обходят его целиком и исчезают (а на современном Node могут crash-loop’нуть worker), поэтому нужны ещё и обработчики process.on({ unhandledRejection, uncaughtException }), которые репортят. Поскольку трекер — третья сторона, ты обязан вычистить PII и секреты — deny-листить заголовки Authorization и Cookie, отредактировать токены — в хуке beforeSend перед тем, как что-либо покинет процесс, иначе ты сам логируешь breach. Наконец, алертить по сигналу, а не по шуму: алертинг на каждую ошибку или сырой счётчик пейджит на безопасный ручеёк, пока команда не замьютит канал и реальный сбой в нём не спрячется, так что алертить по скорости ошибок / burn rate SLO, группировать по fingerprint, чтобы одна новая issue была одним пейджем, и разделять «залогируй» от «дёрни человека» по severity. Теперь, когда канал #alerts долго молчит и потом присылает один пейдж — ты знаешь, что нужно отреагировать: тишина плюс одно уведомление — это ровно то, как должна выглядеть заслуживающая доверия система observability.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.