Перейти к содержимому
6 мин чтения

Завершение телеметрии нельзя оставлять на последний сигнал

Завершение телеметрии без потерь: разберите flush, буферы, тайм-ауты shutdown и доставку трасс из коротких фоновых задач.

Завершение телеметрии нельзя оставлять на последний сигнал

Потерянный спан при остановке процесса редко указывает на ошибку в самом спане. Чаще приложение честно вызвало end(), SDK положил результат в очередь, а оркестратор через секунду убил процесс вместе с очередью. В backend это выглядит как случайная дырка в трассировке. На деле это предсказуемая гонка между жизненным циклом приложения и жизненным циклом exporter.

Завершение телеметрии нужно проектировать как часть штатного shutdown, а не добавлять в обработчик SIGTERM в последний вечер перед релизом. Это относится к HTTP-сервисам, consumer-процессам, cron-задачам, миграциям и коротким LLM-задачам. У них разная форма остановки, но одна неприятная общая черта: процесс может исчезнуть раньше, чем телеметрия покинет память.

end() завершает измерение, а не доставку

Вызов span.end() фиксирует время окончания и передаёт спан в цепочку процессоров. Он не означает, что backend уже получил данные. При пакетной обработке между этими двумя событиями лежат очередь в памяти, таймер отправки, сериализация, сетевой запрос, ответ приёмника и иногда ещё очередь коллектора.

Это различие часто размывают даже опытные команды. Они говорят: «Мы закрываем спаны», когда имеют в виду «Мы надеемся, что агент когда-нибудь их отправит». Для долгоживущего процесса такая надежда иногда срабатывает: через несколько секунд приходит очередной batch. Для команды, которая отработала 400 миллисекунд и завершилась, ждать некому.

Спецификация OpenTelemetry разделяет эти этапы прямо. OnEnd у процессора вызывается синхронно во время Span.End, но BatchSpanProcessor затем накапливает готовые спаны. Стандартные значения, описанные в спецификации и переменных окружения SDK, легко создают ловушку: задержка между выгрузками по умолчанию составляет 5 секунд, максимальный размер очереди 2048, размер пакета 512, а тайм-аут экспорта 30 секунд.

Если ваш контейнер получил SIGTERM и имеет 10 секунд grace period, спан, завершённый в начале остановки, может ждать планового flush до 5 секунд. Затем exporter может ждать сеть дольше, чем живёт процесс. У вас не «иногда пропадает телеметрия». У вас заложены конфликтующие таймеры.

Отдельно проверьте незавершённые спаны. Если код получает сигнал и немедленно выходит, активные HTTP-запросы, операции базы данных и вызовы модели могут не дойти до своих end(). Flush не спасёт то, чего процессор никогда не получил. Сначала нужно остановить поступление новой работы и дать текущей работе закончиться или отмениться осознанно.

BatchSpanProcessor удобен, пока у него есть время

Пакетный процессор нужен почти всегда в production. Он снижает число сетевых запросов и защищает прикладные потоки от задержек exporter. Переключить весь сервис на синхронный SimpleSpanProcessor, чтобы «ничего не потерять», кажется простым выходом, но обычно переносит проблему в latency запросов и создаёт лавину ошибок при медленном приёмнике.

BatchSpanProcessor работает при одном важном условии: процесс остаётся жив достаточно долго, чтобы очередь опустела. Он отправляет пакет, когда сработал периодический таймер, очередь достигла размера пакета или вызывающий код запросил flush. Если очередь заполнена, он начинает отбрасывать новые завершённые спаны. Если shutdown уже начался, поздние спаны тоже могут быть отброшены.

В документации семантических соглашений OpenTelemetry для self-observability есть полезная деталь, которую стоит использовать в эксплуатации. Для переполненной очереди рекомендовано значение queue_full, а для спанов после остановки процессора - already_shutdown в error.type метрики обработки спанов. Поддержка этой метрики зависит от версии и языка SDK, но сама проверка должна войти в ваш список наблюдаемых сигналов.

Не путайте две разные потери:

  • queue_full означает, что приложение создало завершённые спаны быстрее, чем pipeline успел их обработать.
  • Тайм-аут или ошибка exporter означает, что processor взял данные, но не завершил выгрузку в отведённое время.
  • already_shutdown означает, что ваш код продолжил работать и завершать спаны после того, как вы уже закрыли SDK.
  • Отсутствие span.end() означает, что прикладная операция не дошла до нормального завершения.

У этих сбоев разные владельцы. Увеличивать очередь при already_shutdown бессмысленно. Поднимать тайм-аут exporter при переполнении очереди тоже бессмысленно. Сначала назовите тип потери, потом меняйте настройку.

Shutdown должен идти после остановки работы

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

Для HTTP-сервиса порядок обычно такой:

  1. Получите SIGTERM или иной сигнал остановки и пометьте процесс как draining.
  2. Снимите readiness и перестаньте принимать новые соединения или задания от балансировщика.
  3. Дождитесь активных запросов до заранее выбранного дедлайна, затем отмените оставшиеся контексты.
  4. Закройте consumers, пулы и фоновые исполнители, которые ещё могут создавать спаны.
  5. Вызовите shutdown провайдера телеметрии с отдельным тайм-аутом и проверьте результат.

Третий пункт нельзя заменить на «подождём немного». Держите счётчик активных единиц работы. В HTTP это запросы, в очереди это сообщения, в batch-пайплайне это элементы, которые уже взял worker. Shutdown-сигнал переводит этот счётчик в режим убывания. Когда он стал нулём либо истёк дедлайн, телеметрия получает право завершаться.

В Go особенно вреден шаблон, где defer tp.Shutdown(ctx) стоит в main, а main вызывает os.Exit(1) из ветки ошибки. os.Exit не запускает deferred-функции. В Node.js аналогичная ошибка выглядит как process.exit(1) сразу после обработки исключения. В Java встречается Runtime.getRuntime().halt(), который обходит shutdown hooks. Все три варианта могут быть оправданы при повреждённом процессе, но не должны быть обычной веткой управления.

Вот каркас для Go, в котором порядок виден явно. Он не привязан к конкретному exporter, но показывает, где должен жить дедлайн:

rootCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()

<-rootCtx.Done()

server.SetReady(false)
server.StopAccepting()

workCtx, cancelWork := context.WithTimeout(context.Background(), 20*time.Second)
err := workers.Drain(workCtx)
cancelWork()
if err != nil {
    logger.Error("work drain failed", "error", err)
}

telemetryCtx, cancelTelemetry := context.WithTimeout(context.Background(), 5*time.Second)
err = tracerProvider.Shutdown(telemetryCtx)
cancelTelemetry()
if err != nil {
    logger.Error("telemetry shutdown failed", "error", err)
}

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

Тайм-ауты должны помещаться в grace period

Тайм-аут shutdown нельзя выбирать из настроения. Он входит в общий бюджет времени, который выдаёт платформа. Kubernetes, systemd, контейнерный рантайм, supervisor и serverless-среда имеют разные правила, но всем безразлично, успели ли вы выгрузить последний trace. Когда внешний дедлайн истекает, процесс прекращает существование.

Соберите бюджет в обратном порядке. Пусть оркестратор даёт процессу 30 секунд. Вам могут понадобиться 2 секунды, чтобы балансировщик перестал слать трафик, 18 секунд на активные запросы, 6 секунд на consumers и 4 секунды на телеметрию. Это не универсальные числа, а пример сложения. Если активная операция может законно длиться минуту, 30-секундный grace period уже противоречит вашему контракту независимо от OpenTelemetry.

У exporter должно быть меньше времени, чем у полного shutdown. В противном случае внешний дедлайн оборвёт его посередине сетевого вызова. Не ставьте OTEL_BSP_EXPORT_TIMEOUT равным terminationGracePeriodSeconds или тайм-ауту systemd. Оставьте запас на переход между стадиями, выполнение кода и планирование потоков.

В конфигурации это обычно выглядит так:

OTEL_BSP_SCHEDULE_DELAY=1000
OTEL_BSP_EXPORT_TIMEOUT=4000
OTEL_BSP_MAX_QUEUE_SIZE=4096
OTEL_BSP_MAX_EXPORT_BATCH_SIZE=512
APP_DRAIN_TIMEOUT=20s
APP_TELEMETRY_SHUTDOWN_TIMEOUT=5s

Уменьшение OTEL_BSP_SCHEDULE_DELAY сокращает среднее время ожидания в обычной работе, но увеличивает частоту отправок. Это настройка стоимости и нагрузки, а не замена правильному shutdown. Большая очередь даёт запас на короткий burst, но расходует память и не создаёт пропускную способность сети. Если exporter стабильно отстаёт, очередь лишь дольше скрывает отставание.

Проверьте ещё одно ограничение: тайм-аут контекста, который вы передаёте в shutdown, не всегда автоматически отменяет все внутренние фоновые потоки одинаково во всех SDK. Прочитайте документацию выбранной реализации и протестируйте её на реальном exporter. Спецификация требует, чтобы Shutdown и ForceFlush могли сообщать об успехе, ошибке или тайм-ауте, а конкретный API различается по языкам.

ForceFlush нужен коротким процессам и опасным средам

Единая точка LLM-вызовов
AI Router направляет вызовы к моделям через один OpenAI-совместимый endpoint.

OpenTelemetry прямо оговаривает, что ForceFlush следует вызывать только там, где это действительно необходимо, и приводит FaaS как пример: среда может приостановить процесс после invocation до плановой отправки batch. Это хороший ориентир, но не повод расставить flush после каждого запроса.

В долгоживущем API-сервисе flush на каждом HTTP-ответе превращает пакетную телеметрию в дорогую синхронную доставку. Вы получите больше сетевой работы, больше хвостовой задержки и всё равно не получите стопроцентную гарантию при отказе приёмника. Нормальный сервис опирается на batch во время работы и на shutdown во время остановки.

В короткой задаче картина другая. Скрипт, который за 200 миллисекунд прочитал CSV, вызвал внешний API и завершился, никогда не дождётся пятиминутного или пятисекундного планировщика. Он обязан владеть жизненным циклом SDK. После завершения всей работы он должен вызвать Shutdown, дождаться результата и только затем вернуть код завершения.

Разделяйте ForceFlush и Shutdown по смыслу:

  • ForceFlush просит отправить уже переданные processor-у данные, не уничтожая provider.
  • Shutdown прекращает pipeline, включает эффект flush и освобождает ресурсы.
  • Повторный запуск работы после Shutdown не должен рассчитывать на старый provider.
  • Ошибка flush говорит о том, что попытка не завершилась, а не о том, что данные безопасно лежат где-то вне процесса.

В serverless-функции иногда допустимо вызвать ForceFlush перед возвратом из обработчика, если экземпляр может обслужить следующий invocation и provider нельзя закрывать после каждого вызова. Но сначала измерьте цену. При высоком потоке это добавляет сетевую работу на каждый invocation. Если среда создаёт новый процесс на задачу, завершение provider обычно яснее и безопаснее.

Фоновые задачи чаще всего завершают SDK слишком рано

Очередь задач и HTTP-сервер имеют разные ловушки. Сервер обычно удерживает процесс за счёт event loop и активных соединений. Worker может получить сообщение, породить несколько goroutine или promises, подтвердить сообщение брокеру и посчитать задачу законченной, хотя фоновые операции ещё работают.

Самый неприятный вариант выглядит так. Worker создаёт родительский span для задания, запускает три параллельных обращения к моделям, вызывает span.end() в основном потоке и переходит к shutdown. Две дочерние операции ещё выполняются. Их спаны либо завершаются после закрытия processor, либо не завершаются вовсе, когда контекст отменяется. В интерфейсе наблюдаемости вы видите короткое задание без наиболее дорогих вызовов и делаете неверный вывод о latency и стоимости.

Не закрывайте telemetry provider по факту подтверждения одного сообщения, если worker должен жить дальше. Завершайте provider при остановке всего worker-процесса. Для самой задачи используйте счётчик или структурированную конкуррентность: родитель завершает span только после того, как дождался дочерних операций или записал, что отменил их.

Для Node.js особенно опасен ручной process.exit(). Event loop мог бы дождаться незакрытого сетевого запроса exporter, но принудительный выход не даёт ему шанса. Лучше установить process.exitCode, завершить контролируемую остановку через await, записать ошибку и позволить процессу закончиться естественно. Если библиотека или runtime держит процесс живым слишком долго, найдите этот дескриптор отдельно, а не лечите его немедленным выходом.

Для scheduled jobs добавьте в контракт задачи явный этап финализации. Его результат должен попасть в обычный лог процесса: идентификатор запуска, количество обработанных элементов, результат shutdown телеметрии, длительность. Когда следующий инцидент покажет дыру в trace, этот журнал позволит отличить потерю экспортера от аварийного убийства процесса.

Проверьте всю цепочку, а не сообщение «flush completed»

Добавьте метки контента
Метки контента помогают учитывать требования AI-законодательства Казахстана в LLM-потоке.

Лог «flush completed» полезен, но он не доказывает, что событие доступно в конечном хранилище. Exporter мог успешно передать batch коллектору, а тот мог поставить данные в свою очередь, отбросить их по лимиту или не доставить дальше. Трассировка проходит несколько независимых границ отказа.

Тестируйте ровно ту цепочку, которую запускаете в production: приложение, локальный SDK, сеть, collector или gateway, backend. Не ограничивайтесь unit-тестом, который подменяет exporter объектом в памяти. Такой тест проверяет порядок вызовов, но скрывает DNS, TLS, прокси, лимиты очереди и время остановки контейнера.

Ниже сценарий, который стоит положить в CI для каждого типа исполняемого процесса.

  1. Сгенерируйте run_id, например UUID, и добавьте его как атрибут корневого спана тестового запуска.
  2. Создайте несколько дочерних спанов, завершите часть сразу, а один после небольшой задержки.
  3. Запустите тот же shutdown-путь, который использует приложение при SIGTERM, а не отдельную тестовую функцию.
  4. Дождитесь появления run_id в конечном backend в пределах заранее выбранного времени.
  5. Повторите запуск с искусственной задержкой приёмника и с таймером, который близок к внешнему grace period.

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

Добавьте отдельный тест на переполнение. Временно замедлите exporter, ограничьте очередь небольшим числом и завершите больше спанов, чем она вмещает. Команда должна увидеть контролируемую потерю в метрике или логе. Если вы не можете заметить искусственно созданный queue_full, в реальном инциденте вы узнаете о нём по жалобе аналитика.

Наблюдайте за самим pipeline

Данные остаются в стране
Размещённые в Казахстане open-weight модели подходят командам с требованиями data residency.

Телеметрия, за которой никто не наблюдает, ломается тихо. Приёмник может быть доступен, но exporter использует неверный endpoint. Коллектор может принять запрос, но упереться в собственную очередь. Процесс может закрываться вовремя, но очередь SDK уже переполнена до начала shutdown.

Соберите для каждого сервиса небольшой набор сигналов: количество завершённых прикладных запросов, количество экспортированных спанов, ошибки export, дропы из очереди, длительность shutdown и число активных задач на момент получения SIGTERM. Не все SDK публикуют одинаковые метрики, поэтому часть можно снять из логов или обёртки exporter. Важна не конкретная панель, а возможность сопоставить поток работы с потоком телеметрии.

Не делайте алерт только на абсолютное число ошибок exporter. Ночной сервис с тремя запросами и одна ошибка нуждается в реакции, а потоковый consumer с миллионами спанов может пережить единичный временный сбой. Смотрите на долю неуспешной обработки, на серии тайм-аутов и на рост очереди. Самое полезное сравнение - количество бизнес-операций против количества корневых спанов с тем же service name и типом операции.

Логи shutdown должны быть структурированными. В них полезны причина остановки, число активных задач в начале draining, время на drain, результат SDK shutdown и ошибка exporter. Не записывайте туда payload запросов или prompt целиком. Телеметрия часто проходит через большее число систем, чем прикладной журнал, и лишние данные там становятся отдельной проблемой доступа.

Для LLM-сервисов это особенно заметно: короткий запрос к модели может породить длинную цепочку ретраев, tool calls и фоновых проверок. Если процесс заканчивается сразу после выдачи ответа клиенту, вы можете потерять именно спаны, которые объясняют дорогой запрос. AI Router позволяет направлять LLM-вызовы через один OpenAI-совместимый endpoint, но корректное завершение телеметрии остаётся обязанностью приложения и его runtime.

Чеклист перед включением в production

Считайте этот список проверкой контракта остановки, а не списком настроек OpenTelemetry. Если на любой пункт нет точного ответа, следующий перезапуск может оставить пробелы в данных.

  • У процесса есть один владелец shutdown, и он не вызывает принудительный выход до завершения cleanup.
  • При SIGTERM сервис прекращает получать новую работу до закрытия telemetry provider.
  • Активные HTTP-запросы, сообщения и фоновые операции имеют собственный дедлайн на drain.
  • Тайм-аут exporter меньше времени, которое остаётся до внешнего kill после всех прикладных стадий.
  • Логи и метрики показывают переполнение очереди, ошибки отправки, тайм-ауты и поздние спаны после shutdown.

Проверьте также конфигурацию deployment. preStop-хук не заменяет обработчик SIGTERM, потому что его порядок и доступное время зависят от платформы. Readiness probe не помогает, если ваш consumer продолжает забирать сообщения. Увеличенный grace period не помогает, если код вызывает os.Exit или process.exit через 20 миллисекунд после сигнала.

Хорошая финальная проверка проста и неприятна: отправьте процессу SIGTERM под нагрузкой, повторите это десятки раз и сравните число начатых, завершённых и доставленных корневых операций. Делайте это в среде, где есть настоящий collector и те же сетевые правила, что в production. Если после такого теста вы не можете объяснить каждый пропавший span, конфигурация ещё не готова к следующему deploy.

Часто задаваемые вопросы

Достаточно ли вызвать span.end(), чтобы спан попал в backend?

Нет. span.end() завершает измерение и передаёт спан процессору SDK, но пакетный процессор может держать его в очереди до следующей выгрузки. Экспорт произойдёт только после срабатывания таймера, заполнения пакета, явного ForceFlush или корректного Shutdown.

Нужно ли вызывать ForceFlush перед Shutdown?

Обычно нет. Спецификация OpenTelemetry считает Shutdown операцией, которая уже включает эффекты ForceFlush, поэтому отдельный flush перед ним часто лишь съедает ваш бюджет времени. Отдельный ForceFlush нужен там, где процесс не завершается штатно, но среда может заморозить его сразу после работы, например в части FaaS-сценариев.

Какой таймаут дать OpenTelemetry при остановке процесса?

Начните с конца: отведите время на завершение HTTP-сервера, остановку consumers, выгрузку телеметрии и запас на планировщик. Затем задайте таймаут экспорта меньше общего grace period. Если процесс получает 30 секунд на остановку, а exporter ждёт 30 секунд сам по себе, вы не оставили времени ни на что другое.

Почему короткая фоновая задача теряет трассы?

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

В каком порядке останавливать приложение и телеметрию?

Сначала перестаньте принимать новую работу, затем дождитесь уже начатой работы, закройте consumers и только после этого завершайте телеметрию. Если вызвать shutdown SDK раньше, поздние спаны от активных запросов будут отброшены или останутся незавершёнными. Порядок важнее наличия самого обработчика сигнала.

Можно ли гарантировать доставку всех спанов?

Нельзя надёжно гарантировать доставку, если среда может жёстко оборвать процесс или сеть. Можно сделать потерю измеримой: считать переполнения очереди, ошибки экспорта, тайм-ауты flush и долю задач с подтверждённой выгрузкой. Для аудита и бизнес-событий используйте отдельный журнал событий с собственной моделью надёжности, а не трейсы.

Что означает переполненная очередь BatchSpanProcessor?

Очередь заполняется, когда приложение завершает спаны быстрее, чем exporter и приёмник могут их принять. BatchSpanProcessor ограничивает её размер и начинает выбрасывать данные при заполнении. Увеличение лимита помогает только при коротком всплеске, а при постоянной перегрузке скрывает проблему и увеличивает память процесса.

Как проверить, что shutdown действительно доставляет телеметрию?

Логи exporter полезны, но они говорят лишь о попытке отправки. Нужен управляемый тест: создать уникальный trace ID, завершить процесс через тот же путь, что в production, и дождаться этого ID в приёмнике. Повторите тест при нормальной сети, задержке приёмника и почти исчерпанном grace period.

Решает ли автоматическая инструментация проблему потери спанов?

Автоматическая инструментация может создать SDK и exporter, но она не всегда знает жизненный цикл вашей прикладной работы. Особенно это заметно в Node.js-скриптах, очередях задач, serverless-функциях и коде, который вызывает принудительный выход. Проверьте, кто владеет провайдером и где именно вызывается его завершение.

Что нельзя делать в обработчике SIGTERM?

Не делайте telemetry shutdown первым обработчиком SIGTERM. Сначала снимите readiness, остановите приём запросов и дождитесь активной работы. Телеметрию завершайте последней из внутренних подсистем, пока процесс ещё жив и сеть для exporter доступна.