Диагностика: каналы, async-контекст и профили
diagnostics_channel — это in-process pub/sub, который ничего не стоит без подписчиков, AsyncLocalStorage несёт request id сквозь await без проброса аргументов, а --cpu-prof пишет профиль на выходе — инструментируй без monkey-patching.
У сервиса оплаты были чистые структурные логи, но каждая строка была островом: level=error msg="charge failed" без всякой возможности связать её с запросом, который её вызвал. Команда пробросила аргумент traceId через HTTP-обработчик и сервис заказов, но двумя слоями ниже колбэк библиотеки его потерял, и оттуда каждая строка лога стала анонимной. Во время частичного сбоя у них были тысячи строк об ошибках и никакого способа восстановить путь одного пользователя. Фикс был не в ещё одном поле лога вручную — это AsyncLocalStorage, который несёт id сквозь каждый await и колбэк автоматически, так что глубокий логгер читает его, и никто его не передаёт.
diagnostics_channel: инструментировать без monkey-patching
Старый способ добавить трейсинг или метрики в библиотеку — это monkey-patch её прототипа: обернуть http.request, подменить метод драйвера, — что молча ломается на следующем апгрейде и привязывает твой код наблюдаемости к внутренностям. node:diagnostics_channel — встроенная in-process шина pub/sub, появившаяся в Node 15, — заменяет этот подход. Продюсер публикует именованные сообщения; подписчики получают их синхронно. Core, undici и http уже выставляют каналы, так что ты инструментируешь их, не трогая их код.
Свойство производительности, которое делает безопасным оставлять это в горячих путях, — hasSubscribers. publish без слушателей по сути бесплатен, так что библиотека может оградить стоимость сборки сообщения и не платить ничего, когда никто не смотрит.
import diagnostics_channel from "node:diagnostics_channel";
const channel = diagnostics_channel.channel("app:db:query");
// продюсер (в обёртке БД) — платим за сборку payload только если наблюдают
function runQuery(sql, params) {
if (channel.hasSubscribers) {
channel.publish({ sql, params, startedAt: performance.now() });
}
return driver.query(sql, params);
}
// подписчик (в твоей настройке трейсинга) — подключаемся с нулевой связностью с обёрткой
channel.subscribe((message) => {
metrics.increment("db.query", { sql: message.sql });
});Компромисс против event emitter или обёртки: каналы синхронны и не упорядочены между продюсерами, так что подписчик обязан быть быстрым и не должен бросать в вызов продюсера. Выигрыш — расцепление: ты можешь подписаться на встроенные каналы запросов undici для распределённого трейсинга без единой строки патчинга, и инструментация переживает апгрейды библиотеки.
AsyncLocalStorage: контекст запроса, переживающий await
Баг из хука — traceId, потерянный двумя слоями ниже, — это канонический случай для AsyncLocalStorage (из node:async_hooks — хранилище, автоматически привязанное к текущему async-контексту, без передачи аргументов). Он даёт тебе хранилище, заскоупленное на асинхронное дерево вызовов: ты вызываешь als.run(store, fn) один раз на границе запроса, и любой код, достижимый из fn — сквозь await, setTimeout, цепочки промисов, колбэки библиотек, — может вызвать als.getStore() и прочитать тот же store. Ты больше никогда не передаёшь id аргументом.
import { AsyncLocalStorage } from "node:async_hooks";
const als = new AsyncLocalStorage();
// на границе: открыть контекст для этого запроса
app.use((req, res, next) => {
const store = { traceId: req.headers["x-trace-id"] ?? crypto.randomUUID() };
als.run(store, () => next());
});
// где угодно ниже по потоку, как угодно глубоко, без всякой проводки:
function log(msg) {
const { traceId } = als.getStore() ?? {};
logger.info({ traceId, msg });
}Сравни это с переменной уровня модуля: глобал разделяется между всеми конкурентными запросами, так что под нагрузкой запрос B перезаписывает id запроса A, и твои трассы перемешиваются в бессмыслицу. AsyncLocalStorage держит отдельный store на каждый async-контекст, что ровно та изоляция, которая нужна серверу. Стоимость реальна, но ограничена — он едет на async_hooks, так что есть накладной расход на каждую async-операцию; держи store маленьким (id и флаги, а не большие объекты) — и ты платишь однозначные проценты в типичных обработчиках запросов.
| Инструмент | Что даёт | Стоимость / когда брать |
|---|---|---|
diagnostics_channel | In-process pub/sub: инструментировать core/undici/http без патчинга | ~бесплатно без подписчиков (hasSubscribers); всегда включённые хуки трейсинга/метрик |
AsyncLocalStorage | Store, заскоупленный на запрос, читаемый сквозь await/колбэки | Ограниченный расход на операцию; неси trace/request id и флаги тенанта |
async_hooks | Сырой жизненный цикл init/before/after/destroy async-ресурсов | Реальный CPU на ресурс; используй экономно, для контекста предпочитай ALS |
—cpu-prof / —heap-prof | .cpuprofile/.heapprofile, записанный на выходе, открывается в DevTools | Расход профилирования, пока включено; CPU/heap-разбор по запросу |
| сигналы (SIGTERM/SIGINT/SIGUSR1) | Хуки жизненного цикла: graceful shutdown, Ctrl-C, открыть инспектор | Бесплатно; обрабатывай SIGTERM, чтобы слить до того, как оркестратор тебя убьёт |
▸Почему это работает
AsyncLocalStorage построен на async_hooks, но это не один и тот же инструмент. async_hooks выставляет сырой жизненный цикл каждого async-ресурса — init, before, after, destroy — и занятый хук срабатывает на каждом таймере, сокете и промисе, поэтому наивное использование может стоить двузначные проценты CPU. AsyncLocalStorage — это один хорошо оптимизированный потребитель этой машинерии, который тебе на самом деле нужен для контекста. Правило: бери AsyncLocalStorage для данных, заскоупленных на запрос, и опускайся к сырому async_hooks только ради глубокого отслеживания ресурсов, которое иначе не получить, — и измеряй, когда делаешь это.
Сигналы и профилирование по запросу
Когда контейнер попадает под rolling-обновление или процесс теряет память на staging, нужно действовать на живом процессе без рестарта — именно для этого и существуют сигналы и профилирование по запросу. Долгоживущий процесс общается со своим оператором через сигналы. Оркестраторы шлют SIGTERM, чтобы попросить graceful shutdown — ты получаешь окно, чтобы перестать принимать новую работу, слить запросы в полёте, закрыть пулы БД и выйти; если ты его игнорируешь, Kubernetes следом шлёт SIGKILL после grace-периода (30с по умолчанию) и обрубает тебя посреди запроса. SIGINT — это Ctrl-C в терминале. SIGUSR1 особый: Node по умолчанию открывает инспектор при его получении, так что ты можешь подключить дебаггер к живому процессу без рестарта.
// graceful shutdown: перестать принимать → слить → закрыть зависимости → выйти
process.on("SIGTERM", async () => {
server.close(); // перестать принимать новые соединения
await drainInFlight(); // дать активным запросам завершиться
await db.end(); // закрыть пулы / сбросить
process.exit(0);
});Когда нужно увидеть, почему процесс медленный или течёт, профилируй его по запросу. Запусти с --cpu-prof (или --heap-prof), и Node запишет .cpuprofile (или .heapprofile) на выходе, который ты открываешь в панели Performance/Memory Chrome DevTools; --diagnostic-dir управляет тем, куда падают файлы. Для жёсткого краша включи core dumps (ulimit -c unlimited и --abort-on-uncaught-exception, чтобы дампить на непойманном throw) и разбирай дамп посмертно с llnode/lldb. Сквозная идея: ничто из этого не требует править код приложения, так что это безопасно добавить к плохо ведущему себя production-процессу.
Веб-сервис должен прицепить trace id на границе запроса и заставить логгер пятью слоями ниже его эмитить, включая внутри async-колбэков сторонней библиотеки. Как пробрасывать id?
Почему переменная-глобал уровня модуля неверна для хранения trace id на запрос, а AsyncLocalStorage верен?
Библиотека публикует в diagnostics_channel на каждый запрос к БД, но команда не замечает расхода в проде, где никто не подписан. Почему publish почти бесплатен?
Расставь корректный graceful shutdown по SIGTERM — от получения сигнала до чистого выхода:
- 1 Получить SIGTERM от оркестратора (просьбу завершиться)
- 2 Перестать принимать новые соединения / работу (server.close, выйти из балансировщика)
- 3 Слить запросы в полёте — дать активной работе завершиться в окне grace
- 4 Закрыть зависимости: завершить пулы БД, сбросить буферы, закрыть очереди
- 5 process.exit(0) до дедлайна SIGKILL оркестратора
- 01Что делает diagnostics_channel достаточно дешёвым, чтобы оставлять его в горячем пути, и в чём его компромисс против monkey-patching библиотеки?
- 02Почему глобал уровня модуля проваливается в хранении trace id на запрос под конкуренцией и как AsyncLocalStorage чинит это без смены каждой сигнатуры функции?
Node поставляет первоклассную диагностику, не требующую правки твоего приложения. diagnostics_channel — это in-process шина pub/sub: продюсеры publish-ят именованные сообщения, а подписчики получают их синхронно, и поскольку продюсер ограждает через hasSubscribers, ненаблюдаемый publish по сути бесплатен — так что ты инструментируешь core, undici и http для трейсинга или метрик без monkey-patching прототипов, которые ломаются на апгрейде. Для контекста, заскоупленного на запрос, AsyncLocalStorage (построенный на async_hooks) держит отдельный store на каждое async-дерево вызовов: als.run(store, fn) на границе, als.getStore() где угодно ниже по потоку сквозь каждый await и колбэк, чего общий глобал уровня модуля не может, потому что конкурентные запросы перезаписали бы id друг друга. Сырой async_hooks используй экономно — его жизненный цикл init/before/after/destroy срабатывает на каждом async-ресурсе и стоит реального CPU — и держи store ALS маленьким. Общайся с оператором через сигналы: обрабатывай SIGTERM, чтобы перестать принимать, слить работу в полёте, закрыть зависимости и сделать process.exit(0) до дедлайна SIGKILL оркестратора; SIGINT — это Ctrl-C, а SIGUSR1 открывает инспектор. И разбирайся по запросу: --cpu-prof/--heap-prof пишут профиль на выходе для DevTools, --diagnostic-dir размещает файлы, а core dumps с llnode/lldb покрывают жёсткие краши — всё без касания кода приложения. Теперь, когда встретишь анонимные строки лога без трассы, процесс, убитый SIGKILL посреди запроса, или необъяснимый всплеск CPU, — ты знаешь, к какому инструменту тянуться.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.