instrumentation.ts и трассировка: один register() на процесс, один trace на запрос и счёт за выборку
register() в instrumentation.ts выполняется один раз на процесс до запросов — единственное безопасное место инициализации OTel, с guard по рантайму для edge. Next издаёт span-ы render и fetch и передаёт traceparent бэкендам. Счёт задают стратегия выборки и кардинальность.
B2B-SaaS-команда включает трассировку самым простым способом: @vercel/otel, дефолтный конфиг, деплой в пятницу. В понедельник утром дашборды великолепны — каждый рендер RSC превратился в водопад, каждый fetch — в span. Во вторник страница потребления у вендора показывает 31 миллион span-ов в день. Их роут дашборда делает 38 вызовов fetch на рендер — большинство из них HIT-ы дата-кеша, которые даже не покидают машину, — и каждый издаёт span, при 600 запросах в минуту, круглые сутки. В четверг финансы пересылают прогноз: счёт за наблюдаемость идёт примерно втрое выше счёта за вычисления. Команда включает head-выборку 10%, чтобы остановить кровотечение, — и инцидент с p95-латентностью на следующей неделе, ровно то, ради чего покупали трассировку, происходит внутри тех 90% трейсов, которые семплер выбросил. Проблемой была не трассировка — проблемой была трассировка без бюджета. Этот урок — версия с приложенным бюджетом.
register() выполняется раз на процесс — всё остальное стреляет в ногу
instrumentation.ts живёт в корне проекта (рядом с app/ или внутри src/) и экспортирует одну функцию с одной гарантией: Next.js вызывает register(), когда новый серверный процесс загружается, до обслуживания первого запроса. Эта гарантия — ровно то, что нужно OTel SDK (OpenTelemetry — вендор-нейтральный стандарт трассировки): tracer provider должен существовать до первой инструментированной операции, и существовать один раз. Любая альтернатива нарушает одну из половин. Инициализация на уровне модуля в каком-нибудь lib/otel.ts, импортируемом из layout, перевыполняется на каждый бандл маршрута (на serverless каждый роут компилируется отдельной точкой входа) и дважды регистрирует провайдеры. Хуже того, сам register() выполняется в обоих рантаймах приложения — а Node SDK импортирует node:async_hooks и компанию, которых в edge-рантайме не существует. Каноническая форма — guard по рантайму с динамическим импортом:
// instrumentation.ts — единственный файл, который Next.js обещает выполнить раз на серверный процесс
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./instrumentation.node'); // NodeSDK тянет node:async_hooks — не должен попасть в edge-бандл
}
}// instrumentation.node.ts — полный контроль: экспортёр, resource, семплер
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
const sdk = new NodeSDK({
serviceName: 'storefront',
traceExporter: new OTLPTraceExporter({ url: process.env.OTLP_ENDPOINT }),
});
sdk.start();Если кастомный конвейер не нужен, @vercel/otel сворачивает всё это в registerOTel({ serviceName: 'storefront' }) внутри register() — пакет знает о рантаймах и сам выбирает безопасные внутренности. Две честные оговорки, на которых спотыкаются и сеньоры. Первая: «раз на процесс» значит раз на холодный старт, а не раз на деплой — на serverless процессов много и они конкурентны, так что любое состояние, построенное в register(), — счётчики, решения о выборке в памяти — существует на инстанс, а не глобально. Вторая: тот же файл экспортирует onRequestError — хук, который Next.js вызывает при серверных ошибках с контекстом запроса; это естественное место отправлять digest-ы ошибок из прошлого урока в трекер, а не выскребать их из stdout.
Команда инициализирует NodeSDK на уровне модуля в lib/telemetry.ts, импортируемом корневым layout. В next dev всё работает. Что сломается в продакшене на платформе с edge-middleware?
Один запрос — один trace: встроенные span-ы и передача контекста
С зарегистрированным трейсером Next.js издаёт span-ы для собственной работы — без изменений кода: span на рендер маршрута (с именем вида render route (app) /dashboard), span на каждый инструментированный fetch, span-ы на выполнение route handler-ов, с атрибутами вроде шаблона маршрута (NEXT_OTEL_VERBOSE=1 расширяет набор). Дисциплина именования важнее, чем выглядит: span называют по шаблону маршрута, никогда — по конкретному URL: /orders/[id], а не /orders/9381, потому что каждое уникальное имя — новая серия в вашем бэкенде, и имя-на-заказ превращает дашборд в таблицу по клиентам.
Передача контекста — то, что превращает три силоса мониторинга в одну картину. Инструментированный fetch вставляет W3C-заголовок traceparent — 00-<trace-id>-<span-id>-<flags> — в исходящие запросы из серверных компонентов, действий и route handler-ов. Любой бэкенд с OTel SDK на любом языке читает его и продолжает тот же trace. Водопад тогда тянется от браузера через рендер RSC к API заказов и его базе, один trace id из конца в конец — и дежурный вопрос «куда ушли 800 мс» становится поиском, а не тредом на три команды в Slack. Если бэкенд вместо этого появляется отдельным корневым span-ом — первый подозреваемый: прокси или API-шлюз, срезающий traceparent по дороге.
Выборка и кардинальность: счёт — это проектное ограничение
Прежде чем включать трассировку, спросите себя: при вашем трафике сколько span-ов будет лететь в минуту — и за какими из них вы когда-нибудь пойдёте в поиск? Числа из пролога обобщаются. Span сериализуется примерно в 300–800 байт с обычными атрибутами; 31 млн span-ов в день — порядка 10–25 ГБ в день на приёме, а вендоры берут за гигабайт или за миллион span-ов — в любом случае span на каждый HIT дата-кеша — это плата за запись того, что ничего не произошло. Рычаги, по убыванию отдачи. Первый: выбрасывайте span-ы, которые никогда не будете запрашивать — фильтруйте span-ы кеш-хитов fetch и шум статики в span-процессоре или коллекторе до того, как они начнут стоить денег. Второй: head-выборка — ParentBasedSampler(TraceIdRatioBasedSampler(0.1)) — решает в корне трейса и почти бесплатна, но слепа: она выбрасывает трейсы с ошибками и медленные трейсы ровно с той же частотой, что и здоровые, — так команда из пролога и потеряла свой инцидент. Третий: tail-выборка в коллекторе: буферизовать полные трейсы и оставлять всё с ошибкой, всё медленнее порога плюс несколько процентов здоровой базы. Это политика, которую вы на самом деле хотите, и её честная цена операционна: коллектор держит каждый незавершённый trace в памяти на окно решения (обычно 10–30 с; при 600 rps это тысячи буферизованных трейсов), и теперь вы эксплуатируете и масштабируете коллектор. Вместе три рычага переводят вас из режима «платить за всё» в режим «платить только за то, что запросишь»; пропустите первые два — и придёте к tail-выборке с датасетом, который уже слишком дорого содержать.
Кардинальность — тихая половина счёта. Атрибуты индексируются; положите user id, id сессии или полный URL в атрибуты span-а — и бэкенд построит запись индекса на каждого пользователя. Дисциплина, переживающая аудиты: шаблон маршрута, метод, статус, регион — идентификаторы живут в логах (с ключом trace id), а не в индексах трейсов. Накладные расходы рантайма, ради честности, — самая дешёвая часть истории: создание span-а стоит единицы микросекунд CPU, а BatchSpanProcessor экспортирует вне горячего пути, так что типичное приложение платит за трассировку 1–3% CPU. Болят счёт и кардинальность — поэтому стратегия выборки относится к дизайн-ревью, а не к панике после первого инвойса.
▸Почему это работает
Почему вообще семплировать, а не хранить всё и просто реже запрашивать? Потому что стоимость трассировки растёт как трафик, умноженный на плотность инструментации, и оба растут быстрее ценности очередного здорового трейса. Десятитысячный одинаковый рендер за 80 мс не учит ничему, чему не научил сотый. Почти вся информация — в ошибках и выбросах; ровно эту асимметрию кодирует tail-выборка: оставить интересный 1%, оставить тонкую здоровую базу для сравнения и отпустить скучную середину.
После скачка счёта команда ставит head-выборку 5%. Через неделю p95-инцидент почти не оставляет трейсов для отладки. Каково структурное решение?
- 01Почему register() из instrumentation.ts — единственное безопасное место инициализации OTel SDK в Next.js и какой guard ему всё равно нужен?
- 02Проследите, как один запрос становится одним trace через Next.js и бэкенд, и где сидят рычаги стоимости.
instrumentation.ts экспортирует register() — единственную функцию, которую Next.js обещает выполнить раз на серверный процесс до первого запроса, — и это ровно тот контракт, который нужен OTel SDK, и ровно тот, который нарушает инициализация на уровне модуля: на serverless каждый роут компилируется своей точкой входа, поэтому lib/telemetry.ts, импортированный из layout, инициализируется раз на бандл, дублирует регистрацию провайдеров и утаскивает node:async_hooks в edge-бандл, где его не существует. Каноническая форма — guard по NEXT_RUNTIME с динамическим импортом Node-only модуля, либо @vercel/otel, знающий о рантаймах из коробки; при этом «раз на процесс» означает раз на холодный старт, так что ничего из построенного в register() не глобально между инстансами, а тот же файл экспортирует onRequestError — правильный хук для отправки digest-ов ошибок в трекер. С установленным трейсером Next.js издаёт span-ы рендера, fetch-а и route handler-ов, именованные по шаблону маршрута — никогда по конкретному URL, потому что каждое уникальное имя — новая серия, — и штампует W3C-заголовок traceparent на исходящие fetch-и, так что бэкенд с любым OTel SDK продолжает тот же trace, и вопрос о латентности становится одним взглядом на водопад; бэкенд, всплывающий отдельным корнем, обычно означает прокси, срезавший заголовок. Бюджет — проектное ограничение: span весит 300–800 байт, span-на-кеш-хит в масштабе — десятки гигабайт записанного «ничего» в день, head-выборка дешева, но выбрасывает ошибочные трейсы с той же частотой, что и здоровые, а tail-выборка хранит ошибки и выбросы по честной цене памяти коллектора на окно решения плюс ещё один компонент в эксплуатации. Держите кардинальность атрибутов низкой — маршрут, метод, статус, регион; идентификаторы живут в логах с ключом trace id, — потому что накладные расходы CPU составляют 1–3% и проблемой никогда не были; проблема — инвойс и индекс. Теперь, настраивая трассировку, первый вопрос дизайна — не какой экспортёр выбрать, а что ваша политика выборки сохранит, когда инцидент случится в 03:00.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.