Трекинг ошибок, корреляция и усталость от алертов
Логи, метрики и трейсы — три стога сена, пока их не сошьёт общий traceId. Трекер ошибок группирует по fingerprint и дедуплицирует; вычищай PII перед отправкой; алертить надо по СКОРОСТИ ошибок, а не по счётчику, иначе канал замьютят и реальный сбой утонет в шуме.
Сбой платежей длился час, прежде чем кто-то заглянул. Не потому что не сработал алерт — алерты срабатывали постоянно. Этот канал #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 был замьючен неделями безопасных-но-реальных ошибок, и сбой платежей оставался незамеченным час. Какое изменение алертинга наиболее прямо это чинит?
- 01Объясни, как коррелировать три столпа наблюдаемости во время инцидента и почему группировка трекера по fingerprint лучше строки лога на ошибку.
- 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.