Перейти к содержимому
Поиск
Разработка

OpenTelemetry в Node.js: трассировка запросов — мастерская и 10 опытов

Как увидеть путь запроса через два Node.js-сервиса и worker: трассы, ошибки, контекст, sampling, Collector и Jaeger. Десять настоящих опытов, интерактивный просмотр и полный проект Windows/Linux/VPS.

Александр Некрасов
OpenTelemetry — трасса запроса между сервисами Node.js

OpenTelemetry в Node.js: traceparent, два сервиса, ошибки, sampling и Jaeger. Мастерская реальных трасс, исходники и запуск Windows/Linux/VPS.

OpenTelemetry: увидеть весь путь запроса

Пользователь нажал «Рассчитать стоимость», но ответ появился через несколько секунд. В журнале веб-сервера виден медленный запрос, процессор не перегружен, база выглядит здоровой. Что происходило внутри? Приложение могло ждать сервис цен, повторять сетевой вызов, формировать документ или выполнять независимые операции последовательно. OpenTelemetry помогает связать эти действия в одну трассу и увидеть их длительность, порядок, ошибки и границы сервисов.

В этой статье мы соберём практический стенд на Node.js: Checkout принимает HTTP-запрос, Pricing рассчитывает цену, отдельный worker выполняет вычисление. SDK создаёт настоящие операции, Collector принимает их по OTLP, Jaeger показывает результат. Рядом работает собственная мини-мастерская: можно выбрать опыт, раскрыть дерево, отфильтровать сервис, найти ошибку и скачать JSON.

После разбора у вас останется готовая основа для диагностики своего API: именованные бизнес-операции, передача traceparent, связь с логами, выборка трасс и аккуратное завершение процесса. Мы отдельно пройдём Windows, Linux, контейнерный запуск и практику VPS. Каждый пример сопровождается объяснением, что именно он показывает и какие ограничения необходимо учитывать.

Где трассировка действительно помогает

Первый сценарий — несколько сервисов за одним пользовательским действием. Интернет-магазин запрашивает каталог, остатки и доставку. CRM формирует отчёт, обращаясь к аналитике и хранилищу. Платёжный процесс проверяет условия, вызывает провайдера и сохраняет результат. Общее время ответа уже известно, но именно трасса показывает, какая ветвь заняла большую часть времени и где возникло ожидание.

Второй сценарий — сложный монолит. Для пользы от OpenTelemetry не обязательно иметь десятки микросервисов. В одном Node-процессе можно разделить проверку входа, чтение данных, преобразование и подготовку ответа. Сравнивая одинаковые операции до и после изменения, проще проверить гипотезу: например, действительно ли кеш убрал обращение к внешнему сервису.

Третий сценарий — редкие ошибки, которые трудно повторить локально. Идентификатор трассы из ответа или журнала помогает открыть конкретное выполнение, увидеть успешные шаги перед ошибкой и сообщения соседнего сервиса. Трассировка не заменяет обработку ошибок и тесты: она показывает фактическое выполнение. Начните с одного важного пользовательского сценария, а не с бессистемного оборачивания каждой функции.

Метрики, логи и трассы: разные вопросы

Метрики отвечают на вопрос «что происходит со всей системой»: сколько запросов пришло, какая доля завершилась ошибкой, как изменился p95. Логи описывают отдельные события: операция отклонена, файл обработан, соединение восстановлено. Трасса связывает вложенные операции конкретного выполнения и показывает их временную структуру. Вместе они помогают перейти от общего симптома к конкретной причине.

Представьте, что длительность API выросла после релиза. Метрика обнаруживает изменение, трасса показывает дополнительный вызов Pricing, а лог с тем же traceId объясняет причину повтора. Не стоит сохранять весь текст журналов в атрибутах span: размер, стоимость и поиск станут неудобными. Обычно достаточно ограниченных полей и связи через идентификаторы.

На сайте есть материалы про Prometheus и метрики и структурированные логи Pino. Здесь мы сосредоточимся на трассах. Показанные JSON-строки пишет само приложение в stdout; отдельный экспорт OTel Logs в стенде не настроен. Такое разделение помогает понять, какой компонент отвечает за каждый результат.

Что делают API, SDK, Collector и Jaeger

API позволяет приложению начать операцию, добавить поля и получить активный контекст. SDK решает, записывать ли операцию, хранит состояние и передаёт завершённые spans экспортерам. Exporter преобразует данные в нужный формат и отправляет их по сети либо сохраняет другим способом. Сам по себе вызов API без настроенного SDK может работать как пустая операция.

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

Jaeger хранит и показывает трассы. Учебный запуск использует память, поэтому после пересоздания контейнера история исчезает. Сырые JSONL и JSON из архива сохраняются отдельно. Общая цепочка: Node SDK → OTLP/HTTP → Collector → Jaeger. Браузерная мастерская читает сохранённые результаты этой цепочки, а не подключается к вашему серверу наблюдаемости.

Checkout / Pricing / worker
          │ OTLP/HTTP
          ▼
Collector: память → очистка полей → batch
          │ OTLP/HTTP
          ▼
Jaeger: поиск, дерево, временная шкала

Параллельно SDK сохраняет JSONL для воспроизведения.

Мини-мастерская: десять настоящих опытов

Начните с первого опыта: выберите корневой HTTP span, затем бизнес-операцию и локальный расчёт. После этого откройте два сервиса и сравните идентификаторы родителя. Для последовательного и параллельного вариантов обратите внимание на перекрытие полос. В опыте с ошибкой включите фильтр ERROR и раскройте событие exception. В sampling переключитесь между двумя запросами: один имеет трассу, другой выполнен, но не экспортирован.

Это интерактивный просмотр записи реального Node.js, а не запуск серверного SDK в браузере. Фильтры и раскрытие деталей работают прямо на странице; изменить серверный код и получить новую трассу можно из скачанного проекта. В нём находятся два HTTP-сервиса, рабочий поток, Collector, Jaeger, тесты, исходные JSONL и команды записи. Никакие spans для красивой схемы не добавлялись.

В опубликованной записи 51 операция. Все span ID сопоставлены с ответами API Jaeger после фактической доставки через Collector. Учебные ожидания 45 и 80 миллисекунд задаются кодом Pricing, а ширина полос строится по реальному времени SDK. Один запуск объясняет структуру работы, но не обещает такую же скорость на другом компьютере.

Версии и важное отличие SDK 3

Проект проверен на Node.js 24.19.0. Зафиксированы @opentelemetry/api 1.10.0, sdk-node 0.301.1, sdk-trace 3.1.1 и OTLP/HTTP exporter 0.301.1. Collector Contrib имеет версию 0.165.0, Jaeger — 2.23.0. Номера разных пакетов OpenTelemetry не обязаны совпадать: API, стабильные SDK и интеграционные пакеты развиваются по своим линиям. Рабочую комбинацию фиксирует package-lock.json.

В используемом SDK 3 пакет называется @opentelemetry/sdk-trace, а BatchSpanProcessor принимает объект с полем exporter. Старый вызов с двумя аргументами здесь не подходит. Проверяйте документацию и типы установленной версии, затем выполняйте реальный запрос, а не ограничивайтесь успешным npm install.

В стенде HTTP-инструментация ручная: в исходниках видны SERVER и CLIENT spans, inject и extract. Если позже подключить автоматическую HTTP-инструментацию, потребуется убрать дублирующую обвязку и запустить SDK до загрузки инструментируемых модулей. Официальная отправная точка — Node.js Getting Started. Фрагменты ниже объясняют отдельные приёмы; полный связанный проект находится в архиве.

import { BatchSpanProcessor } from '@opentelemetry/sdk-trace';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';

const processor = new BatchSpanProcessor({
  exporter: new OTLPTraceExporter({
    url: 'http://127.0.0.1:14318/v1/traces',
    timeoutMillis: 2500,
  }),
  maxQueueSize: 512,
  maxExportBatchSize: 64,
  scheduledDelayMillis: 5000,
  exportTimeoutMillis: 3000,
});

Windows: первый запуск без Docker

Распакуйте архив и откройте PowerShell там, где лежит package.json. Установите Node.js поддерживаемой линии 24 и проверьте версию. Npm ci ставит точный набор зависимостей из lock. Тесты запускают свои сервисы на свободных случайных loopback-портах. Capture использует фиксированные 8413 и 8414, проходит опыты и завершает только собственные процессы.

После capture выполните build: он копирует новую запись и исходники в public, собирает app.js и помещает Bootstrap рядом с ним. Serve открывает мастерскую на 127.0.0.1:8403. Открытие HTML двойным щелчком не подходит, поскольку браузер должен получить recording.json через HTTP. Внешний CDN для интерфейса не нужен.

Последняя команда работает до Ctrl+C. Для следующих экспериментов откройте второй терминал. Если порт занят, проект сообщает об этом и не завершает чужое приложение. Не запускайте одновременно capture и постоянно работающий npm start: они используют одну пару стандартных портов. Для первой записи Collector и Docker не требуются.

node --version
npm ci --ignore-scripts
npm test
npm run capture
npm run build
npm run serve
# Открыть http://127.0.0.1:8403

Linux: запись и работающий HTTP API

На Linux порядок установки зависимостей и сборки тот же. Проверьте путь и версию Node, особенно если на сервере уже установлен системный пакет: старый runtime может не поддерживать актуальные зависимости. Используйте обычного пользователя для работы с проектом, оставив административные права для установки службы. Процессам нужны права записи в records.

Npm start оставляет Checkout и Pricing работающими. Можно послать собственный запрос curl, посмотреть JSON-ответ, повторить ошибку и сравнить идентификаторы. Ctrl+C передаёт завершение обоим дочерним процессам. Capture организует весь набор запросов сам, собирает логи, дожидается flush и сохраняет единый JSON.

Сервисы по умолчанию слушают 127.0.0.1. Для удалённого знакомства достаточно SSH-туннеля; открывать учебный API интернету не требуется. Если задача состоит в диагностике существующего приложения, переносите в него instrumentation и настройки SDK, а не публикуйте /demo как бизнес-маршрут. На сервере отдельно проверьте права каталогов, занятие портов и управляемое завершение процесса.

node --version
npm ci --ignore-scripts
npm test
npm start
# Другой терминал:
curl 'http://127.0.0.1:8413/demo?scenario=distributed'
curl 'http://127.0.0.1:8413/demo?scenario=error'

Ресурс SDK и понятное имя сервиса

Ресурс описывает источник телеметрии: имя приложения, версию релиза, среду запуска. Это отличается от атрибутов конкретного запроса. Service.name помогает найти сервис в Jaeger, service.version — сравнить релизы. Если всем процессам дать одно имя, дерево сохранится, но разобраться станет сложнее. Если включить в имя идентификатор каждого запроса, группировка окажется непрактичной.

В стенде названы workshop-checkout, workshop-pricing и workshop-cpu-worker. Автоматическое обнаружение ресурсов отключено, чтобы публичная запись не собирала лишние сведения о компьютере. В production можно включить нужные detectors, сначала посмотрев, какие поля они создают. Массивы метрик и логов здесь явно пустые: дополнительные сигналы не экспортируются.

Настройку выполняют один раз на процесс, до первых бизнес-операций. Worker имеет собственную среду JavaScript, поэтому его SDK настраивается отдельно. Минимальный пример ниже подключает exporter через NodeSDK. Полный проект дополнительно пишет JSONL и ограничивает размеры атрибутов. Не создавайте SDK заново для каждого HTTP-запроса: это нарушает ожидаемый жизненный цикл компонентов.

import { NodeSDK } from '@opentelemetry/sdk-node';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';

const sdk = new NodeSDK({
  resource: resourceFromAttributes({
    'service.name': 'checkout',
    'service.version': '1.0.0',
  }),
  autoDetectResources: false,
  traceExporter: new OTLPTraceExporter({
    url: 'http://127.0.0.1:14318/v1/traces',
  }),
  metricReaders: [],
  logRecordProcessors: [],
});
sdk.start();

Опыт 1. Родительский span и бизнес-операция

Трасса имеет общий traceId, каждая операция — собственный spanId. Поле parentSpanId связывает её с непосредственным родителем. Получается дерево: входной HTTP SERVER содержит checkout.business, а тот — quote.local. Имя span описывает тип действия, например «расчёт цены», а допустимые параметры передаются отдельными атрибутами.

StartActiveSpan делает созданную операцию текущей на время callback. Вложенный вызов автоматически получает родителя. Сам span необходимо завершить: Promise, возвращённый callback, не отменяет эту обязанность. В архиве helper inSpan использует try/catch/finally, фиксирует исключение и вызывает end даже при ошибке. Это предотвращает потерянные незавершённые операции в обычных ветках.

Откройте первый опыт и проверьте три идентификатора. Выберите quote.local и найдите его родителя. Такой шаг полезен перед подключением нескольких сервисов: если локальная вложенность неверная, распределённая трасса увеличит путаницу. Подробности API разобраны в официальном руководстве instrumentation. Не подменяйте родительскую связь строковым атрибутом с чужим traceId: визуально похожее поле не меняет контекст операции.

import { trace } from '@opentelemetry/api';
const tracer = trace.getTracer('pricing-module', '1.0.0');
await tracer.startActiveSpan('quote.calculate', async span => {
  try {
    span.setAttribute('quote.items', 2);
    span.addEvent('validation.completed');
    return { total: 1200 };
  } finally {
    span.end();
  }
});

Опыт 2. HTTP traceparent связывает сервисы

Контекст не пересекает сеть сам по себе. Checkout создаёт CLIENT span, inject записывает контекст в HTTP-заголовки, Pricing выполняет extract и создаёт SERVER span с полученным родителем. Оба сервиса имеют общий traceId, но свои spanId. Содержимое ответа не участвует в связывании: идентификатор и заголовок возвращаются только для наглядной проверки.

Traceparent содержит версию формата, trace ID, parent ID и флаги, включая sampled. Используйте propagator вместо собственного разбора заголовка: он обрабатывает формат и некорректные значения согласно контракту. На входе нового запроса extraction начинается от ROOT_CONTEXT, чтобы случайный контекст соседней работы не стал родителем.

Выберите CLIENT «GET /price», затем SERVER того же маршрута. У SERVER parentSpanId совпадает со spanId CLIENT. В сыром HTTP-результате отправленный и полученный traceparent одинаковы. Это проверка цепочки, а не просто похожих названий. Смысл передачи описан в документации context propagation. Для публичного входа отдельно определяют, насколько можно доверять пришедшему контексту и sampled-решению.

import { context, propagation, ROOT_CONTEXT } from '@opentelemetry/api';
const headers = {};
propagation.inject(context.active(), headers);
// Передать headers с HTTP-запросом.

const incomingHeaders = { traceparent: headers.traceparent };
const remoteParent = propagation.extract(ROOT_CONTEXT, incomingHeaders);
// Передать remoteParent в startActiveSpan у получателя.

Опыт 3. Последовательные вызовы

Медленный запрос может состоять из нескольких быстрых операций, выполненных друг за другом. Checkout сначала получает цену alpha, дожидается результата, затем запрашивает beta. Pricing намеренно ждёт 45 и 80 миллисекунд. Это управляемая учебная задержка, чтобы порядок был хорошо виден; она не называется временем реальной базы или внешнего провайдера.

На шкале две CLIENT-операции образуют последовательность. Родительская checkout.business охватывает обе, а SERVER включает обработку запроса и подготовку ответа. Из-за служебной работы итог не обязан совпадать с суммой заданных ожиданий. Начало span и длительность имеют конечную точность: микроскопическое перекрытие на уровне округления не доказывает параллельность.

Такой опыт помогает найти последовательный обход коллекции, когда каждый элемент независимо обращается к сервису. Следующий вопрос инженерный: допускает ли контракт параллельность, какие ограничения у upstream и можно ли отправить один пакетный запрос? Трасса показывает порядок, но допустимость оптимизации определяется требованиями. Если второй вызов зависит от первого, простая замена await на Promise.all будет ошибкой.

const first = await getPrice('slow', 'alpha');
const second = await getPrice('slow', 'beta');
const total = first.price + second.price;

Опыт 4. Параллельные ветви и критический путь

В параллельном варианте два независимых обращения запускаются через Promise.all. CLIENT spans получают одного бизнес-родителя, но существуют одновременно. На шкале видно перекрытие, а родитель заканчивается после завершения более долгой ветви и обработки результатов. Сумма длительностей всех spans больше времени запроса, потому что часть работы параллельная, а интервалы родителей уже включают дочерние.

Не превращайте этот пример в совет запускать неограниченное количество обещаний. Тысячи одновременных обращений способны перегрузить сервис, исчерпать соединения и увеличить ошибки. Нужны ограничения конкуренции, тайм-ауты и понятное поведение при частичной неудаче. Promise.all отклоняется при первой ошибке, но сам не отменяет остальные начатые операции.

Задержки здесь одинаковы с предыдущим опытом, поэтому сравнение показывает влияние порядка. Для оценки реальной нагрузки нужны отдельный воспроизводимый тест и метрики. Продолжение — k6: нагрузочное тестирование API. В production полезно сравнивать не только среднюю длительность, но и хвост распределения, долю ошибок и нагрузку на зависимые сервисы.

const prices = await Promise.all([
  getPrice('slow', 'alpha'),
  getPrice('slow', 'beta'),
]);
const total = prices.reduce((sum, item) => sum + item.price, 0);

Опыт 5. Ошибка, exception и HTTP-статус

Ошибка приложения и статус span — разные вещи. RecordException добавляет событие с информацией об исключении, setStatus помечает операцию как ERROR. HTTP-код сохраняется отдельным атрибутом. Если только перехватить ошибку и вернуть JSON, span может выглядеть обычным завершённым вызовом, хотя клиент получил 500. Обновляйте телеметрию в ветке обработки ошибки.

Pricing намеренно выбрасывает фиксированную учебную ошибку. Его сервер возвращает 500, клиентская операция Checkout получает неуспешный ответ, бизнес-операция завершается ошибкой, а входной обработчик отдаёт JSON. В дереве видно, где возникло первичное событие и как ошибка прошла через вызовы. Связанные логи имеют тот же traceId.

Для реальных исключений учитывайте приватность сообщения и stack trace: внутри могут оказаться SQL, пути, токены или пользовательский ввод. Здесь записываются контролируемые имя и сообщение без произвольного тела запроса. Не помечайте любую ожидаемую бизнес-ветку аварийной автоматически: политика статусов должна соответствовать смыслу операции и принятой HTTP-инструментации. Событие exception само по себе не заменяет setStatus.

import { SpanStatusCode } from '@opentelemetry/api';
function markFailure(span, error) {
  span.recordException({
    name: error.name,
    message: 'Pricing request failed',
  });
  span.setStatus({
    code: SpanStatusCode.ERROR,
    message: 'Pricing request failed',
  });
}

Trace ID в логах: найти конкретное выполнение

Журнал становится полезнее, если строка содержит traceId и spanId текущего контекста. Поддержка получает идентификатор из ответа, разработчик открывает трассу, затем фильтрует журнал обоих сервисов. Идентификатор берут из активного span в момент записи, а не хранят в общей переменной процесса.

Параллельные операции Node выполняются в одном процессе и чередуются. Общая переменная currentTraceId рано или поздно свяжет сообщение с чужим запросом. При корректной async-цепочке контекст OpenTelemetry относится к своему выполнению. Вне трассируемой операции идентификатор может отсутствовать: это нормально для стартового сообщения или обслуживания процесса.

В архиве используется простой JSON в stdout, чтобы не добавлять библиотеку к объяснению. В рабочем проекте объект можно передать Pino или другому logger. Наличие traceId не гарантирует, что трасса сохранена: sampled=0 оставляет контекст и позволяет написать лог, но экспортированных spans не будет. Поэтому поиск должен учитывать политику выборки и срок хранения, а не воспринимать любую отсутствующую трассу как поломку.

import { trace, context } from '@opentelemetry/api';
function logEvent(event) {
  const sc = trace.getSpan(context.active())?.spanContext();
  console.log(JSON.stringify({
    event,
    traceId: sc?.traceId ?? null,
    spanId: sc?.spanId ?? null,
    sampled: Boolean(sc && (sc.traceFlags & 1)),
  }));
}

Опыт 6. Контекст после await

Обработчик делает await, возвращается в event loop, продолжает выполнение позже и запускает callback. NodeSDK настраивает менеджер контекста, связывающий эту асинхронную работу. В обычной цепочке Promise/await активный span сохраняется, поэтому вложенные операции получают родителя без ручной передачи идентификаторов через каждый аргумент функции.

В шестом опыте одновременно отправляются два HTTP-запроса. Каждый создаёт context.after-await, ждёт таймер, затем setImmediate и сверяет активный span с собственным. Реальные ответы и логи показывают, что продолжения остались в своих трассах. Это проверка изоляции двух запросов, а не одинаковые объекты, созданные браузерной схемой.

Проблемы возможны в самодельных callback-механизмах, нестандартных пулах и интеграциях, теряющих async-связь. Если появилась неожиданная корневая операция, проверьте создание и активацию контекста. Для отдельного callback может понадобиться context.bind или явный context.with. Не маскируйте потерю контекста присваиванием traceId в атрибут: это не создаёт parent-child связь. Сначала воспроизведите сбой маленьким тестом с двумя одновременными запросами.

import { context, trace } from '@opentelemetry/api';
import { setTimeout as delay } from 'node:timers/promises';
const before = trace.getSpan(context.active())?.spanContext();
await delay(25);
const after = trace.getSpan(context.active())?.spanContext();
console.log({ sameSpan: before?.spanId === after?.spanId });

Опыт 7. Контекст в worker_threads

Worker_threads — отдельная среда JavaScript. Активный контекст основного потока туда автоматически не переносится. Родительская операция report.dispatch-worker записывает контекст в carrier и передаёт его через workerData. Worker извлекает контекст, создаёт report.cpu-worker, действительно вычисляет checksum, завершает SDK и возвращает результат.

Дерево сохраняет общий traceId, а ресурс worker имеет отдельное имя. Его parentSpanId указывает на dispatch-операцию. Видна и длительность вычисления, и общий путь запроса. Если используется пул, контекст передают с каждой задачей, а не один раз при создании потока: задачи принадлежат разным запросам.

Carrier должен содержать только поля propagation. Не переносите туда пароль или полный объект запроса. Для очередей и отложенной обработки иногда уместнее span links: задача может объединять несколько источников или жить независимо от HTTP-ответа. Управляемый пул разобран в статье Piscina: потоки Node.js. В этой мастерской создаётся один worker ради ясного опыта; запуск нового потока на каждый производственный запрос обычно требует отдельного обоснования. Холодная загрузка SDK учитывается отдельно: на старт до сообщения ready отведено 15 секунд, затем на вычисление и отправку span — 5 секунд. Это важно для медленных файловых систем и Docker bind mount; ограничение самой задачи при этом сохраняется.

import { Worker } from 'node:worker_threads';
import { context, propagation } from '@opentelemetry/api';
const carrier = {};
propagation.inject(context.active(), carrier);
const worker = new Worker(new URL('./worker.mjs', import.meta.url), {
  workerData: { carrier, rounds: 2000000 },
});
// В worker: propagation.extract(ROOT_CONTEXT, workerData.carrier)
// и собственный NodeSDK перед созданием span.

Опыт 8. Sampling: запрос есть, трассы нет

При высокой нагрузке сохранять все операции дорого. ParentBasedSampler уважает sampled-решение родителя, а для нового корня применяет TraceIdRatioBasedSampler. В демонстрации вероятность корневой записи равна единице, чтобы обычные опыты были полностью видны. Выборка относится к записи телеметрии, а не к выполнению самой бизнес-операции.

Capture посылает два реальных HTTP-запроса с входными traceparent. Один имеет sampled=1, второй sampled=0. Оба получают HTTP200 и проходят логику, но второй не создаёт экспортируемых spans. Мастерская показывает явное пустое состояние и сохранённый ответ. Внешний родитель первой трассы отмечен как внешний; отсутствующий span не дорисовывается.

Head sampling принимает решение в начале: случайные 10% не гарантируют сохранение каждой будущей ошибки. Tail sampling выбирает после накопления данных, требует отдельной конфигурации Collector, памяти и маршрутизации частей трассы. Здесь он не реализован. На публичном входе продумайте доверие к sampled-флагу клиента: чужой заголовок не должен бесконтрольно увеличивать стоимость вашей телеметрии.

import { ParentBasedSampler, TraceIdRatioBasedSampler }
  from '@opentelemetry/sdk-trace';
const sampler = new ParentBasedSampler({
  root: new TraceIdRatioBasedSampler(0.1),
});
// 10% новых корней; решение имеющегося родителя сохраняется.
// В опубликованной записи root ratio=1.

Опыт 9. Полезные атрибуты без секретов

Хороший атрибут помогает объяснить действие и имеет ограниченный набор значений: тариф, канал обращения, число элементов, результат проверки. Плохой кандидат — произвольный текст, email, cookie, Authorization или URL с query string. Данные проходят через SDK, сеть, Collector и хранилище, поэтому лишнее поле расширяет поверхность утечки.

Исходный учебный объект содержит вымышленные email и token. AllowedAttributes берёт только plan и channel из разрешённых списков. В фактических spans остаются два безопасных поля; тест проверяет отсутствие исходного токена и email. Маршрут /health обслуживается без span, чтобы частые проверки готовности не заполняли запись шумом.

Collector дополнительно удаляет несколько известных чувствительных атрибутов. Это второй рубеж, а не универсальная очистка. Он не догадается, что секрет попал в сообщение исключения, и не удалит уже записанное в локальный JSONL. Минимизируйте данные до отправки. Для реального сервиса определите доступ, срок хранения и допустимые идентификаторы. Ограничение длины поля полезно для объёма, но само по себе не защищает секрет.

function allowedAttributes(input) {
  const result = {};
  if (['basic', 'team'].includes(input.plan))
    result['workshop.plan'] = input.plan;
  if (['web', 'mobile'].includes(input.channel))
    result['workshop.channel'] = input.channel;
  return result;
}

Опыт 10. Завершение и отправка буфера

BatchSpanProcessor накапливает завершённые операции и отправляет пачками. Если немедленно вызвать process.exit после ответа, последние spans могут остаться в памяти. Штатное завершение: прекратить приём, дождаться активных обработчиков, завершить их spans, вызвать sdk.shutdown и дождаться результата. Затем процессу можно закончить работу.

Последний запрос десятого опыта проходит два сервиса. Capture отправляет собственным процессам команду завершения, получает подтверждение с количеством экспортированных spans и читает JSONL. Последняя трасса проверяется по идентификатору; те же операции отдельно ищутся в Jaeger. Это подтверждает flush конкретного успешного запуска, но не обещает сохранность при отключении питания или SIGKILL.

Тайм-аут остановки службы или контейнера должен оставлять время и обработчикам, и экспорту. При недоступном получателе shutdown может завершиться ошибкой, а ограниченная очередь — потерять данные. Телеметрия не должна бесконечно удерживать процесс. В рабочем приложении ошибки экспорта фиксируют отдельно, а политику завершения согласуют с требованиями сервиса и механизмом обновления релиза.

let closing;
function stop(server, sdk) {
  if (closing) return closing;
  closing = (async () => {
    await new Promise(resolve => server.close(resolve));
    await sdk.shutdown();
  })();
  return closing;
}
// Подключить к SIGTERM/SIGINT и обработать отклонение Promise.

Collector: pipeline приёма и отправки

Collector собирается из receivers, processors, exporters и pipelines. Объявить компонент недостаточно: его нужно включить в pipeline. Receiver принимает OTLP/HTTP на 4318 внутри контейнера. Процессоры ограничивают память, удаляют известные чувствительные поля и группируют отправку. Exporter передаёт трассы Jaeger по внутреннему DNS-имени.

В текущем Collector используется имя otlp_http. В старых публикациях встречается другое написание, поэтому проверяйте конфигурацию на закреплённом образе. Endpoint экспортера — базовый URL, а SDK-переменная OTEL_EXPORTER_OTLP_TRACES_ENDPOINT указывает полный путь /v1/traces. Нельзя подставить порт веб-интерфейса Jaeger вместо OTLP-порта.

Очередь и лимит памяти помогают контролировать ресурс, но не гарантируют доставку при любом отказе. После исчерпания очереди или повторов возможна потеря. Для устойчивой схемы проектируют хранение очереди, доступность gateway и наблюдение за Collector. Полный файл содержит лимиты и очистку; сокращённый пример ниже объясняет связи. Порядок processors имеет значение: обработка выполняется в указанной последовательности.

receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318
processors:
  batch:
    timeout: 200ms
exporters:
  otlp_http/jaeger:
    endpoint: http://jaeger:4318
service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlp_http/jaeger]

Windows: подключить Collector и Jaeger

Compose запускает инфраструктуру, а Node остаётся процессом Windows. Так удобно редактировать исходники и подключать debugger, сохраняя Jaeger в контейнере. Проект использует отдельное имя Compose и сеть. Команды остановки относятся только к этому стенду, а опубликованные порты привязаны к 127.0.0.1.

Проверьте логи Collector и дождитесь готовности. Переменная PowerShell включает OTLP для дочерних процессов следующего capture. Verify:collector запрашивает API Jaeger и сверяет каждый экспортированный span ID. Если SDK создал локальную запись, но доставка не состоялась, эта проверка не должна пройти. Build затем обновляет браузерные данные.

Переменная действует в текущем терминале. После опыта удалите её для возврата к локальной записи. При конфликте сети выберите свободный OTEL_DEMO_SUBNET до создания контейнеров; не удаляйте неизвестную сеть ради примера. Подробнее организация стендов разобрана в мастерской Docker Compose. Открытый интерфейс Jaeger сам по себе не доказывает, что конкретные операции дошли через pipeline.

docker compose config --quiet
docker compose up -d collector jaeger
docker compose logs --tail 20 collector
$env:OTEL_EXPORTER_OTLP_TRACES_ENDPOINT='http://127.0.0.1:14318/v1/traces'
npm run capture
npm run verify:collector
npm run build
Remove-Item Env:OTEL_EXPORTER_OTLP_TRACES_ENDPOINT

Linux и полностью контейнерный запуск

В Bash переменную можно задать для одной команды. Следующий capture без префикса снова будет локальным. После записи выполните verify:collector и сохраните результат вместе с кодом. Версии и digest образов помогают повторить окружение, хотя длительности зависят от машины и нагрузки.

Профиль full переносит Checkout и Pricing в контейнеры. Checkout обращается к http://pricing:8414, оба SDK — к http://collector:4318/v1/traces. Localhost внутри контейнера обозначает сам контейнер, поэтому хостовый 127.0.0.1:14318 туда копировать нельзя. Наружу опубликованы loopback-порты.

Depends_on задаёт порядок создания, а не гарантированную готовность отвечать. Проверьте /health и логи. Native capture и full профиль одновременно не запускайте: порты одинаковы. JSONL контейнерного варианта лежит в /tmp/otel; перед удалением скопируйте нужные файлы или настройте volume с корректными правами. Jaeger здесь хранит историю в памяти. Этот стенд воспроизводит реальные Linux-процессы, но не является кластером с отказоустойчивым хранилищем.

docker compose up -d collector jaeger
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://127.0.0.1:14318/v1/traces npm run capture
npm run verify:collector

# Альтернатива: приложения тоже в контейнерах
docker compose --profile full up -d --build
curl 'http://127.0.0.1:8413/health'
curl 'http://127.0.0.1:8413/demo?scenario=parallel'
docker compose --profile full down

Как читать трассу в Jaeger

Откройте http://127.0.0.1:16687, выберите workshop-checkout и найдите GET /demo за период записи. Внутри видны Checkout, Pricing и worker соответствующего опыта. Можно скопировать trace ID из мастерской или ответа и открыть конкретное выполнение, вместо поиска похожего запроса по времени.

Сначала ищите ветвь, занимающую значительную часть интервала. Затем смотрите её атрибуты, события и дочерние операции. Долгий CLIENT span показывает ожидание внешнего вызова, но не доказывает, что медленным был CPU удалённого приложения: внутри могли быть сеть, очередь и передача ответа. SERVER другой стороны помогает сузить причину, если обе части сохранены.

Статус UNSET означает отсутствие явно установленного OK или ERROR, а не автоматическую поломку. Учитывайте HTTP-атрибуты и контракт инструментации. Для разных машин проверяйте синхронизацию часов: сдвиг начала дочерней операции может быть связан с часами. Длительности родителей и детей нельзя складывать, потому что интервалы вложены, а параллельные ветви перекрываются.

curl 'http://127.0.0.1:16687/api/services'
# Подставьте trace ID из своей записи:
curl 'http://127.0.0.1:16687/api/traces/TRACE_ID'

Исходники и доказательства выполнения

JSONL содержит отдельные завершённые spans, фактически полученные собственным exporter SDK. Recording.json объединяет их по запросам, добавляет HTTP-ответы, логи, версии среды, результат остановки и SHA256 исходников. Explorer читает этот файл, поэтому запись исследуется независимо от работающего сервера.

Verify:collector сохраняет ответы API Jaeger отдельно и сравнивает множество span ID с локальной записью. Это сильнее проверки доступности страницы: интерфейс может работать, когда телеметрия не поступает. В архив включён успешный результат опубликованного прогона. При новой записи проверку выполняют заново: старые доказательства относятся к старым идентификаторам.

Серверные .mjs опубликованы как текст с расширением .txt, браузерный модуль собран в app.js. Это исключает зависимость страницы от поддержки MIME для .mjs на хостинге. ZIP содержит полноценную структуру и lock. После изменения src повторите test, capture, verify:collector и build. Иначе опубликованный viewer может показать прежнюю запись, которая уже не соответствует текущему коду.

src/telemetry.mjs         # SDK, exporter, span helper
src/service.mjs           # Checkout и Pricing
src/worker.mjs            # настоящий worker_threads
ui/                      # интерфейс просмотра
records/                 # JSONL, HTTP, Jaeger JSON
deploy/collector.yaml     # pipeline
tools/capture.mjs
tools/verify-collector.mjs
public/                  # готовая мастерская

Какие проверки защищают пример

Тесты запускают два настоящих Node-сервиса на свободных случайных loopback-портах. Проверяются родительские связи, traceparent, HTTP500 и exception, контекст одновременных запросов, worker, sampled=0 и последняя трасса после shutdown. Маршруты и параметры ограничены: неизвестный сценарий не превращается в произвольное сетевое обращение.

Для последовательного и параллельного опыта проверяются порядок и заметное перекрытие, а не обещание конкретного ускорения. Учитывается миллисекундная точность начала span, чтобы округление не давало ложный сбой. Тесты модели проверяют дерево с внешним родителем и пустую трассу. DOM-проверки переключают опыт, фильтры, детали и команды Windows/Linux.

Автотесты не заменяют браузерную приёмку. На узкой ширине отдельно проверяют перенос ID, селекты, полосы и пустые состояния. Эмуляция ширины не равна физическому телефону. Verify:collector требует работающих контейнеров и подтверждает реальный сетевой путь. При переносе на сервер дополнительно проверяют настройки окружения, права, доступность получателя и поведение при его временном отказе.

npm test
npm run build
npm audit
# Отдельная интеграционная проверка:
npm run verify:collector

VPS: пользователь, systemd и завершение

Для постоянного Linux-запуска используйте отдельного системного пользователя и менеджер служб. Код размещают в /opt/otel-workshop, изменяемые данные — в /var/lib/otel-workshop. Файлы окружения лежат вне public и доступны нужным пользователям. В архиве есть шаблон systemd для checkout/pricing и команды установки каталогов.

Проверьте путь Node через command -v node и настройте Collector endpoint. Запустите Pricing, проверьте /health, включите Checkout. При остановке systemd посылает SIGTERM; приложение прекращает приём и ждёт shutdown SDK. TimeoutStopSec должен превышать время активных запросов и экспорта. Слишком короткий лимит оборвёт процесс до отправки последних spans.

Шаблон ограничивает память и CPU, разрешает запись в каталог данных. Это основа адаптации, а не доказательство готовности любого VPS: сам systemd в контейнерном тесте не запускался. На реальном сервере проверяют права, перезапуск, обновление релиза, отказ Collector и заполнение диска. JSONL exporter требует ротации или отключения в долгоживущем приложении, иначе файл будет расти.

[Service]
User=otel-demo
WorkingDirectory=/opt/otel-workshop
EnvironmentFile=/etc/otel-workshop/%i.env
ExecStart=/usr/bin/node /opt/otel-workshop/src/service.mjs
Restart=on-failure
TimeoutStopSec=20
KillSignal=SIGTERM
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/lib/otel-workshop/%i
MemoryMax=256M
CPUQuota=100%

Доступ к Jaeger и публичная мастерская

Учебные Collector и Jaeger не стоит открывать без ограничений интернету. На VPS удобно оставить loopback и подключиться SSH-туннелем. Браузер вашего компьютера обращается к localhost, соединение идёт через защищённый канал к серверу. Можно изучать стенд без отдельного публичного интерфейса наблюдаемости.

Рабочей инфраструктуре нужна собственная схема доступа: аутентификация, TLS, ограничение сети, роли, срок хранения и подходящее хранилище. Collector принимает телеметрию, а не пользовательский API-трафик; открытый endpoint позволяет создавать лишнюю нагрузку и подмешивать данные. Explorer этой статьи показывает только подготовленную синтетическую запись, поэтому посетитель не получает доступ к реальному Collector.

Статическую мастерскую отдаёт обычный веб-сервер. Для своего Node API используйте reverse proxy с понятными тайм-аутами и лимитами, затем проверяйте propagation через всю цепочку. На сайте есть материал Caddy, HTTPS и reverse proxy. Настраивать прокси только ради локального опыта не обязательно: сначала добейтесь понятного результата на loopback.

ssh -L 16687:127.0.0.1:16687 user@server
# Открыть на своём компьютере:
# http://127.0.0.1:16687

Память, очереди и стоимость подробностей

Каждый span занимает память и требует работы: создание, атрибуты, сериализация, отправка, хранение. Полезная инструментация выбирает значимые границы, а не оборачивает каждую итерацию цикла. Для большого массива обычно достаточно span задачи, количества элементов и событий этапов. Подробности добавляют там, где они помогают принять решение.

BatchSpanProcessor ограничивает очередь, пачку и время экспорта. Collector имеет собственную очередь и память. Это разные уровни: изменение одного не увеличивает другой. При долгой недоступности получателя ограниченная система либо теряет телеметрию, либо использует отдельно спроектированное постоянное хранение очереди. Бесконечного безопасного буфера нет.

Наш JSONL exporter удобен для обучения, но в постоянном сервисе требует ротации или замены. Следите за нагрузкой самой телеметрии и проверяйте накладные расходы на своём профиле запросов. Результат мастерской не обещает нулевую стоимость OpenTelemetry. Sampling, ограниченные атрибуты и разумные границы spans помогают управлять объёмом, сохраняя диагностическую ценность.

const spanLimits = {
  attributeCountLimit: 32,
  attributeValueLengthLimit: 160,
  eventCountLimit: 16,
};
// Передать spanLimits в NodeSDK.
// Лимиты не очищают секреты: allowlist нужен отдельно.

Частые ошибки первого подключения

Если Jaeger пуст, двигайтесь по цепочке. Убедитесь, что SDK запущен и span завершён. Проверьте sampling, имя сервиса, период поиска, endpoint. Посмотрите логи Collector: компонент мог быть объявлен, но не включён в pipeline, порт мог отличаться, контейнер — завершиться из-за конфигурации. Только затем ищите проблему интерфейса.

Если вместо одной трассы появились две, проверьте inject/extract и создание SERVER span. Дубликаты HTTP-операций часто означают сочетание ручной и автоматической инструментации. Исчезновение последних запросов при перезапуске требует проверки end, shutdown и тайм-аута службы. Старые данные мастерской после capture обычно означают пропущенный build.

При импортах не смешивайте SDK 2 и SDK 3. При сетевых проблемах различайте localhost хоста и контейнера. При лишнем объёме проверьте произвольные поля, полный URL, подробные циклы и доверие внешнему sampled-флагу. Воспроизводите проблему одним запросом, сохраняйте trace ID и конфигурацию. Наличие локального JSONL не доказывает доставки в Jaeger — для этого существует отдельная интеграционная проверка.

docker compose ps
docker compose logs --tail 50 collector jaeger
curl 'http://127.0.0.1:8413/health'
npm ls @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/sdk-trace
npm run build

Как применить подход в своём проекте

Выберите сценарий, который сложно диагностировать: заказ, отчёт, импорт файла, внешние запросы. Назовите сервис и основные операции, подключите SDK, добавьте контекст к логам. Начните с небольшого allowlist атрибутов и одной понятной трассы. Это полезнее десятков неразличимых spans без бизнес-смысла.

После успешного запроса проверьте ошибку, параллельность и остановку. Добавьте второй сервис, убедитесь в parent-child связи и настройте sampling под свой объём. Сохраните команды воспроизведения и проверяйте их при обновлении зависимостей. Для постоянного окружения отдельно спроектируйте Collector, хранилище, доступ и наблюдение за потерей телеметрии.

Архив позволяет пройти весь путь на синтетических данных: реальные Node-процессы, контейнерная конфигурация, интерфейс и сырые записи. Официальные источники: JavaScript instrumentation, sampling, Collector configuration, актуальный SDK и Jaeger 2.23. Переносите настройки осмысленно и подтверждайте результат реальными запросами своего приложения.