Как потерять трейсинг в микросервисах (и не заметить этого)
- вторник, 3 ноября 2026 г. в 00:00:10

Всем привет, меня зовут Евгений, я ведущий Go-разработчик в Cloud.ru. За время работы над разными проектами в разных компаниях я замечал одну похожую историю: все собирают трейсы, но пользы от них мало. Их просто никто не смотрит, потому что они неполные. В них буквально не видно весь путь запроса от клиента до последнего микросервиса в цепочке интеграций разных продуктов и команд в компании. В итоге на них все забивают, ищут проблемы по логам, это входит в привычку, так дальше и живут.
В этой статье я хочу рассказать, где же теряются трейсы в микросервисах и как перестать жрать кактус.
Настроить трейсинг в обычном синхронном API сейчас уже ни для кого не проблема. В сети огромное количество статей и мануалов по этой теме, готовые библиотеки с middleware/interceptors для любого языка и протокола, да даже любая нейронка вам напишет готовый код специально под ваш проект.
Допустим, ваша команда пишет на Go, использует gRPC, у вас подключены интерсепторы, которые запускают процесс трассировки для каждого входящего запроса, context.Context плавно скользит по коду, бережно перенося информацию о спанах. И вот вам нужно интегрироваться с другим продуктом в компании, а там команда пишет на Python, и их API на обычном HTTP. На этом стыке разнородных технологий вы наверняка забудете, что клиент для HTTP-запросов не работает с контекстом, и ваш трейсинг оборвется.
У кого так было? У нас вот было. На стыке разнородных технологий, особенно в переходе с более привычной на менее привычную, нужно быть очень внимательным.
client := &http.Client{ Timeout: 10 * time.Second, } ctx, span := tracer.Start(ctx, "new-span") // <-- Span есть defer span.End() req, err := http.NewRequest(http.MethodGet, "https://api.example.com", nil) // <-- А в запросе его нет if err != nil { return err } // И в заголовки не добавили resp, err := client.Do(req) // <-- И при вызове тоже нет
Исправляется проблема довольно просто:
client := &http.Client{ Transport: otelhttp.NewTransport(http.DefaultTransport), // <-- Готовая обёртка над транспортом Timeout: 10 * time.Second, } ctx, span := tracer.Start(ctx, "new-span") defer span.End() req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com", nil) // <-- Контекст с трейсом связывается с запросом if err != nil { return err } resp, err := client.Do(req) // <-- otelhttp сам допишет заголовки
Этот пункт похож на предыдущий — тоже переход через технологию. Все популярные современные брокеры поддерживают передачу заголовков или метаданных вместе с сообщением: NATS, Kafka, RabbitMQ и другие. Но в отличие от синхронных API, здесь нет стандартного механизма middleware, чтобы работать с метаданными сообщений. Приходится обрабатывать их самостоятельно, причем как на стороне отправителя, так и на стороне получателя. Об этом очень легко забыть, упустить из виду, и на этом очень часто теряются трейсы.
Алгоритм решения таков.
На примере Go + Kafka:
type CustomProducerCarrier struct { Headers *[]sarama.RecordHeader } // todo: В CustomProducerCarrier необходимо реализовать интерфейс // propagation.TextMapCarrier (https://pkg.go.dev/go.opentelemetry.io/otel/propagation#TextMapCarrier) ctx, span := tracer.Start(ctx, "kafka.produce", trace.WithSpanKind(trace.SpanKindProducer)) defer span.End() msg := &sarama.ProducerMessage{ Topic: topic, Value: sarama.ByteEncoder(value), Headers: []sarama.RecordHeader{}, } carrier := CustomProducerCarrier{Headers: &msg.Headers} otel.GetTextMapPropagator().Inject(ctx, carrier) _, _, err := producer.SendMessage(msg) return err
Так данные отправятся получателю, но на его стороне тоже нужно кое-что сделать.
carrier := CustomProducerCarrier{Headers: (*[]sarama.RecordHeader)(&msg.Headers)} remoteCtx := otel.GetTextMapPropagator().Extract(context.Background(), carrier) // <-- Тут уже будет информация о спане, созданном на стороне продюсера ctx, span := tracer.Start(ctx, "kafka.consume", trace.WithSpanKind(trace.SpanKindConsumer), trace.WithLinks(trace.LinkFromContext(remoteCtx)), ) // <-- Тут уже новый спан консюмера, локальный
Здесь мы реконструировали родительский спан в продюсере и создали новый корневой спан в консюмере, связав его с родительским через SpanLink. Альтернативно можем создать прямой дочерний спан от внешнего:
remoteSpanContext := trace.NewSpanContext(trace.SpanContextConfig{ TraceID: traceID, SpanID: spanID, TraceFlags: trace.FlagsSampled, // Если внешний спан действительно был сэмплирован Remote: true, // Обязательно при восстановлении внешнего спана }) remoteCtx := trace.ContextWithSpanContext(context.Background(), remoteSpanContext) // <-- Тут уже будет информация о спане, созданном на стороне продюсера ctx, span := tracer.Start(remoteCtx, "kafka.consume", trace.WithSpanKind(trace.SpanKindConsumer), ) // <-- Тут уже новый спан консюмера, локальный
Подробнее о том, какой подход выбрать, поговорим в разделе Использовать SpanLink или нет. В любом случае вся цепочка взаимодействий будет сохранена, мы ничего не потеряем.
Брокеры сообщений в целом могут использоваться как очереди, но нередко в качестве очереди используется база данных, структура в памяти или что-то еще. Также в микросервисной архитектуре популярен паттерн Transactional inbox/outbox, когда данные сохраняются в базу, а обрабатываются или отправляются отдельным потоком после чтения из базы. Для таких вещей в принципе нет готовой библиотеки для внедрения трейсинга, а вся работа с ними находится в коде вашего приложения, и очень легко понадеяться на «магию контекста». Тут-то трейсы и теряются.
Решение простое: сохранить данные трейса вместе с полезными данными задачи, а при извлечении восстановить трейс.
type Task struct { ID int64 Payload []byte TraceCarrier map[string]string } /* Сохранение */ carrier := propagation.MapCarrier{} otel.GetTextMapPropagator().Inject(ctx, carrier) task := Task{ Payload: data, TraceCarrier: carrier } err = saveTask(ctx, task) /* Извлечение */ task, err := getTask(ctx, id) if err != nil { return err } parentCtx = otel.GetTextMapPropagator().Extract(ctx, propagation.MapCarrier(task.TraceCarrier)) taskCtx, span := tracer.Start(parentCtx, "task.process")
Так как у нас уже есть контекст с трейсом, мы можем использовать стандартный MapCarrier из библиотеки go.opentelemetry.io/otel/propagation в отличие от кастомного для Kafka. Новый спан создали напрямую для примера, но можно и через SpanLink, об этом будет далее.
В современных микросервисных архитектурах находят применение различные оркестраторы задач, такие как Temporal, Restate, Conductor. Они позволяют описывать процессы в виде кода, отслеживать их выполнение, вмешиваться руками в процесс в случае необходимости, автоматически перезапускать в случае ошибок — в общем, разработчики могут сосредоточиться на бизнес-логике и не тратить время на инфраструктурный код. И конечно, потерять трейсы. Иначе мы бы сейчас об этом не говорили.
Далее разберем на примере Temporal, как же можно потерять трейсинг.
В Temporal критически важна детерминированность кода, поэтому там используется свой собственный workflow.Context вместо обычного, а также свои инструменты управления конкурентностью, синхронизацией и т.д. Использовать привычные инструменты для трейсинга здесь не получается, так что многие обычно забивают на это и просто стартуют новый трейс в Activity, где уже есть классический гошный контекст и можно писать код как обычно привыкли. Однако Temporal поддерживает механизм интерсепторов, подобно gRPC, и в его библиотеках есть реализация интерсептора для трейсинга.
package main import ( "go.temporal.io/sdk/client" "go.temporal.io/sdk/contrib/opentelemetry" "go.temporal.io/sdk/interceptor" ) func main() { /* ... */ // Создаём интерсептор для трейсинга tracingInterceptor, err := opentelemetry.NewTracingInterceptor( opentelemetry.TracerOptions{ Tracer: otel.Tracer("temporal-workflow"), }, ) if err != nil { log.Fatal(err) } // Подключаемся к серверу Temporal c, err := client.Dial(client.Options{ HostPort: "localhost:7233", Interceptors: []interceptor.ClientInterceptor{tracingInterceptor}, }) if err != nil { log.Fatal(err) } defer c.Close() // Создаём воркер w := worker.New(c, "temporal-worker", worker.Options{ Interceptors: []interceptor.WorkerInterceptor{tracingInterceptor}, }) }
Теперь, когда мы будем запускать Workflow с помощью созданного клиента, данные трейсинга из контекста будут обрабатываться интерсептором, и он будет создавать новые спаны для каждого запускаемого Workflow и Activity. Внутри Activity можно создавать свои спаны для операций, если это необходимо.
Далее рассмотрим еще один кейс потери трейсинга. Он не связан с забытым инструментарием в коде, но часто встречается в крупных компаниях и достоен упоминания в статье.
Самый очевидный и самый непростой случай, особо актуальный для больших корпораций. Продукты и команды формируются в разное время, технологии меняются, а правило «работает — не трогай» еще никто не отменял. Одни пишут на Go, используют OpenTelemetry и собирают данные в Tempo. Другие пишут на Java и в Zipkin через B3-Propagation. И в какой-то момент развития продукта первой команды им требуется интегрироваться со второй. А может быть и с третьей, у которой бэкенд на Perl, а трассировка полностью самописная. При таком раскладе отследить цепочку запросов становится весьма непросто. Но это можно немного облегчить.
Grafana с 12 версии поддерживает механизм Trace Correlation. Прямо в интерфейсе можно настроить переход от трейса к логам по trace_id, но также можно и сделать переход к другому источнику данных, например, если у команд разные инстансы Tempo, но общая Grafana. А еще есть возможность построить URL с подстановкой параметров, так можно добавить ссылку на чужую систему, на страницу поиска и сразу передать параметры. В Jaeger есть подобный механизм, в конфиг-файле можно определить linkPatterns. Не так гибко, как в Grafana, но хоть что-то. Возможно в вашем инструменте тоже есть что-то подобное.

Если нет возможности использовать корреляции прямо в вашем инструменте для просмотра трейсов, но вы используете OtelCollector для сбора, можно добавить трансформер, который сформирует ссылку и добавит в атрибуты.
processors: transform: trace_statements: - context: span statements: - set( attributes["trace.url.jaeger"], Concat([ "https://jaeger.local/trace/", span.trace_id.string ], "") )
В крайнем случае можно сформировать ссылку прямо в коде приложения и добавить как атрибут в спан. Звучит как костыль, но при интеграции с legacy-системами без них все равно не обойтись.
Если уже совсем никак не выходит добавить прямые ссылки в интерфейс, остается только добавлять атрибуты, которые помогут искать трейсы между системами. Например, вот так:
ctx, span := tracer.Start(ctx, "legacy.svc/Create", trace.WithAttributes( attribute.String("destination", "leagcy-service-1"), attribute.Int("task_id", task.ID), ))
Здесь добавлены два атрибута:
destination позволяет понять, в какую систему ушел запрос. Вы, как разработчик, и так наверняка помните, куда в этом месте идут запросы, но L2-инженер поддержки скорее всего не знает. Этот атрибут поможет понять, где смотреть продолжение.
task_id как уникальное бизнес-значимое значение позволит найти трейс в другой системе. Хорошо, если она поддерживает OpenTelemetry стандарт, и тогда бы вы могли продолжить поиск по уже знакомому trace_id, но если там другой стандарт или самописное решение, то нужно что-то общее для обеих систем.
Важно, чтобы поисковый атрибут имел уникальное значение для каждого запроса. Идентификатор задачи, операции, реквеста — главные кандидаты на выбор. Останется только договориться с командой на той стороне, чтобы они его записывали в свои трейсы.
Прямая связь спанов удобна, когда они в коротком интервале. Когда разрывы между ними измеряются десятками минут или часами, тут уже сложнее. Ломается поиск, индексация, сэмплирование, даже интерфейс плывет. С другой стороны, линки не отображаются в одном экране, в итоге поиск проблемы по трейсам усложняется. В общем, как говорится...

В документации OpenTelemetry SpanLink предлагается как рекомендуемое решение для связи спанов в асинхронном взаимодействии. Но если у вас в нормальном режиме работы паузы между отправкой и получением (или постановкой в очередь и извлечением) невелики и консюмер не обрабатывает пачками события, например не заливает их в Clickhouse оптом, то вы можете попробовать связывать спаны напрямую без использования SpanLink.
Так вам не придется прыгать по ссылкам, держать множество вкладок, только чтобы найти проблемное место — все будет у вас на виду в одной странице. С другой стороны, когда цепочка взаимодействий слишком глубока и ваши спаны не влезают в экран, разделение даже синхронных запросов на разные трейсы с линком может улучшить опыт ваших коллег из техподдержки в поиске проблем. При этом вы также можете использовать Trace Correlation в Grafana или аналогичный механизм в других платформах для упрощения перехода между сегментами.

Хорошо выстроенный трейсинг — это отличный инструмент для поиска и решения проблем в современных приложениях. Чтобы разработчики и девопсы могли сосредоточиться на таких задачах, как построение трейсинга, а не тратить время на дебаг сетевых проблем в кластере, мы в Cloud.ru разрабатываем Managed Kubernetes. Готовый облачный managed-кластер берет эти проблемы на себя.
Расскажите, сталкивались ли вы в работе с разорванными или потерянными трейсами? Или может у вас есть опыт построения трейсинга для сложного взаимодействия сервисов?