Распределённый трейсинг с OpenTelemetry
Один запрос разветвляется по N сервисам; логи по сервису не скажут, какой хоп тормозит и где сломалось. Трейс сшивает запрос в одно дерево span через общий traceId — если ты пробрасываешь контекст через HTTP и async-границы и сэмплируешь разумно.
Checkout затормозил. Не сломался — затормозил: p99 подполз к 3 секундам, а очередь поддержки заполнилась «спиннер просто висит». Поток пересекал четыре сервиса: api принимал запрос, звал orders, тот звал payments, тот звал inventory. Мы открыли все дашборды, что у нас были. p99 у api: норм. p99 у orders: норм. p99 у payments: норм. p99 у inventory: норм. Каждый сервис божился, что здоров, а запрос всё равно занимал три секунды. Логи тоже не помогли — четыре отдельных потока логов, у каждого свой request id, ни один не знает о других. Никто не мог ответить на единственный важный вопрос: какой хоп съел время? На этот вопрос нельзя ответить по логам отдельных сервисов. Нужен единственный объект, который следует за запросом через все четыре сервиса и записывает, куда ушла каждая миллисекунда. Этот объект — трейс, и этот урок о том, как OpenTelemetry его строит, как теряется контекст, который держит его вместе, и как за это платить, не разорившись.
Что такое трейс: span, traceId и цепочка родителей
Трейс — это всё путешествие одного запроса, представленное как дерево. Каждый узел — это span: одна операция со временем — начало, конец и набор атрибутов (HTTP-маршрут, SQL-выражение, статус). Span связаны двумя id. traceId общий для каждого span запроса — это ключ соединения для всего дерева. spanId идентифицирует один span, и каждый span записывает свой родительский spanId, что и придаёт дереву форму: span у api — корень, span у orders — его ребёнок, span у payments — ребёнок того, и так далее.
// Один span = одна операция. Трейсер создаёт его; ты ставишь атрибуты и заканчиваешь.
const tracer = trace.getTracer('orders');
await tracer.startActiveSpan('chargeCustomer', async (span) => {
span.setAttribute('order.id', orderId);
span.setAttribute('payment.amount', amount);
try {
return await this.payments.charge(orderId, amount);
} catch (err) {
span.recordException(err);
span.setStatus({ code: SpanStatusCode.ERROR });
throw err;
} finally {
span.end(); // start→end is THIS span's duration on the trace
}
});С этим деревом инцидент checkout отвечает на себя сам в один экран: открываешь трейс, видишь четыре span, разложенные на таймлайне, и span у payments видимо шириной 2.6с, а остальные три — тонкие щепки. Углубляешься в него — и находишь, что payments зовёт inventory синхронно в цикле, один round trip на позицию заказа. Ни один дашборд не мог это показать, потому что каждый отдельный вызов inventory был быстрым — медленным был последовательный fan-out, сумма, а сумма существует только в трейсе.
Проброс контекста: механизм, делающий один трейс из четырёх сервисов
Сложная часть — не создавать span, это делает авто-инструментация. Сложная часть — держать каждый span в одном и том же трейсе, пока запрос пересекает границы. Для этого trace context — traceId, текущий spanId и флаг сэмплинга — должен путешествовать через два разных вида границ.
Между сервисами, по сети, контекст едет в W3C HTTP-заголовке traceparent (стандартизированный заголовок трассировки: версия-traceId-spanId-флаги). Вызывающая сторона инжектит его в исходящие заголовки запроса; вызываемая сторона извлекает его и продолжает трейс, а не начинает новый.
// traceparent: version-traceId-parentSpanId-flags
// 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
// └─ 32 hex traceId ──────────────┘ └─ 16 hex spanId ┘ └ sampled
// Вызывающая сторона инжектит этот заголовок; вызываемая извлекает его, и span orders
// становится ДОЧЕРНИМ к span api — тот же traceId, новый spanId, parent = api.Внутри процесса, через await и колбэки, нет заголовка, который понесёт контекст, — поэтому Node-менеджер контекста OTel хранит «текущий активный span» в AsyncLocalStorage. ALS — это механизм V8, который держит значение прикреплённым к одной логической async-цепочке вызовов: span, начатый в guard, всё ещё текущий span внутри handler и внутри вызова pg, который делает handler, потому что все они выполняются внутри одной отслеживаемой ALS-цепочки. Вот почему запрос к БД авто-вкладывается под span запроса, а ты ничего не передаёшь руками.
Режим отказа симметричен: потеряй контекст на любой из границ — и трейс фрагментируется. Пропусти traceparent на исходящем вызове — и вызываемая сторона начнёт свежий корневой трейс. Вырвись из ALS-цепочки — fire-and-forget неотавайченный промис, голый setTimeout, хоп через очередь без проброса — и продолжение выполнится без активного span, так что OTel не видит родителя и открывает новый корень. В любом случае запрос, который должен быть одним деревом, становится несколькими несвязанными фрагментами, и ты снова таращишься на несвязанные трейсы, не в силах проследить запрос из конца в конец.
▸Почему это работает
Почему fire-and-forget или неотавайченный async-вызов ломает трейс? Потому что «текущий span» живёт не в переменной, которую ты передаёшь, — он живёт в AsyncLocalStorage, привязанный к отавайченной цепочке вызовов, которая начала span. Когда ты делаешь await, V8 держит тебя внутри этого хранилища, так что продолжение всё ещё видит span как текущий. Когда ты не делаешь await — зовёшь функцию и роняешь промис, или планируешь работу голым setTimeout, или пушишь джобу в очередь, а её подхватывает другой процесс — это продолжение выполняется вне исходного хранилища. OTel ищет текущий span, не находит ничего и начинает свежий корневой span с совершенно новым traceId. Дочерняя работа теперь — свой собственный трейс. Исходное дерево никогда не узнаёт, что работа случилась, а у нового корня нет родителя, так что в просмотре трейсов один запрос выглядит как несколько несвязанных трейсов, которые ты не можешь сшить обратно.
Подключение OTel к Nest: bootstrap до приложения
Единственное правило, которое кусает всех: инструментация работает, monkey-patch’ая модули, которые трейсит (http, express, pg, ядро Nest), так что SDK должен стартовать до того, как твоё приложение импортирует эти модули. На практике ты кладёшь NodeSDK в отдельный файл и грузишь его первым — node --require ./tracing.js dist/main.js или import './tracing' самой первой строкой main.ts.
// tracing.ts — MUST be loaded before the Nest app so it patches http/pg/nest first
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { TraceIdRatioBasedSampler } from '@opentelemetry/sdk-trace-base';
const sdk = new NodeSDK({
// auto-instruments http, express, @nestjs/core, pg — spans appear with no app code
instrumentations: [getNodeAutoInstrumentations()],
// head sampling: keep 10% of traces (see the sampling section)
sampler: new TraceIdRatioBasedSampler(0.1),
});
sdk.start();Как только это загружено, каждый HTTP-запрос и каждый запрос pg производят span автоматически. Ты добавляешь ручные span (tracer.startActiveSpan) только вокруг бизнес-операций, которые авто-инструментация не может назвать, — «зарезервировать остаток», «оценить риск». И ты замыкаешь круг структурным логированием из L01: инжектишь активный traceId в каждую строку лога. Это единственное поле — ключ корреляции: найди медленный трейс, скопируй его traceId и подними каждый лог из каждого сервиса для ровно этого запроса. Логи отвечают что случилось; трейс отвечает куда ушло время; traceId — это соединение между ними.
// L01's logger, now trace-aware: every line carries the active traceId
const span = trace.getActiveSpan();
const ctx = span?.spanContext();
this.logger.log({ msg: 'charge failed', orderId, traceId: ctx?.traceId, spanId: ctx?.spanId });
// now one traceId pulls every log from every service for this exact requestСэмплинг: ты не можешь позволить себе хранить каждый трейс
Прежде чем добавлять трейсинг в продакшен-сервис, спроси себя: при 5 000 RPS сколько данных span ты реально готов хранить и оплачивать? Ответ определяет, к какой из двух стратегий ниже ты потянешься. Трейсить каждый запрос дорого — хранение span и пропускная способность экспорта доминируют, а неограниченные span или атрибуты на запрос добавляют реальную цену. Поэтому ты сэмплируешь: часть трейсов оставляешь, остальное роняешь. Есть две стратегии, и компромисс между ними — это сеньорское решение.
Head sampling решает на корне, до выполнения запроса, обычно по доле: TraceIdRatioBasedSampler(0.1) оставляет 10%. Дёшево и не требует доп. инфраструктуры — но решение слепое. Оно не может знать, что запрос, который ты уронил, был тем самым, что занял 3 секунды или бросил 500, так что при низкой доле ты систематически пропускаешь редкие медленные и падающие запросы — ровно те, что тебе и нужны.
Tail sampling решает после того, как запрос завершился, когда исход известен: оставь все ошибки и все медленные трейсы, сэмплируй малую долю скучных быстрых. Оно ловит редкий плохой запрос, который head sampling роняет. Цена в том, что его нельзя решить в процессе — каждый span трейса должен где-то буферизоваться, пока трейс не завершится и не вынесется вердикт оставить/уронить, а значит ты должен гонять OpenTelemetry Collector как буферизующий ярус между твоими сервисами и бэкендом.
Ты должен трейсить высоконагруженный checkout-поток через четыре сервиса. Объём делает 100%-трейсинг слишком дорогим, но вся причина добавлять трейсинг — поймать редкие медленные и падающие checkout. Какой подход к сэмплингу подходит?
Сервис api зовёт сервис orders по HTTP, и ты хочешь, чтобы span у orders был ребёнком span у api в одном трейсе. Что несёт trace context через эту границу сервиса?
Запрос ставит в очередь BullMQ-джобу. В твоём UI трейсинга span джобы показываются как отдельные корневые трейсы, несвязанные с запросом, который её поставил. Почему и каков фикс?
- 01Из чего состоит трейс и как проброс контекста держит один запрос в одном трейсе, когда он пересекает границы сервисов и async?
- 02Как подключить OTel к Nest-приложению и как выбирать между head- и tail-сэмплингом?
Распределённый трейс превращает один запрос, который разветвляется по N сервисам, в единое дерево, которое — единственный артефакт, способный ответить «какой хоп съел время»: логи по сервисам и дашборды видят каждый по одному срезу и упускают последовательный fan-out, который сделал весь запрос медленным. Трейс — это дерево span (каждый — операция со временем и атрибутами); каждый span делит один traceId (ключ соединения), у каждого есть spanId, и каждый записывает родительский spanId, чтобы придать дереву форму. Механизм, который держит все эти span в одном трейсе, — проброс trace context (traceId + текущий spanId + флаг сэмплинга): МЕЖДУ сервисами он едет в W3C HTTP-заголовке traceparent — вызывающая инжектит, вызываемая извлекает — а ВНУТРИ процесса живёт в AsyncLocalStorage, менеджере контекста OTel, так что span, начатый в guard, всё ещё текущий внутри handler и его вызова БД. Потеряй контекст — неотавайченный fire-and-forget, голый setTimeout или хоп очереди без проброса — и продолжение выполнится без активного span, так что OTel открывает свежий корень и запрос фрагментируется на несвязанные трейсы; фикс для BullMQ — инжектить traceparent в payload джобы и восстанавливать его в processor. Подключай OTel, загружая NodeSDK до приложения, чтобы авто-инструментация смогла пропатчить http/pg/nest первой, добавляй ручные span для бизнес-операций и инжекти активный traceId в каждую строку лога как ключ корреляции, связывающий логи с трейсами. Поскольку хранить каждый трейс слишком дорого, ты сэмплируешь: head sampling (например TraceIdRatioBasedSampler(0.1), 1-10%) дёшев, но слеп к редкому медленному/падающему запросу; tail sampling оставляет все ошибки и медленные трейсы, но требует OpenTelemetry Collector (промежуточный буферизующий сервис), чтобы накапливать span, пока исход не станет известен. Теперь, когда видишь медленный запрос, а дашборды каждого сервиса говорят «всё в порядке», ты знаешь, куда смотреть: открываешь трейс, находишь широкий span и читаешь последовательный fan-out, который ни одна отдельная метрика показать не могла.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.