4. Контекст propagation

Context Propagation: механика связывания распределенных вызовов

«Главное свойство программы — воплощает ли она подлинное намерение своего создателя и пользователя».
— К.А.Р. Хоар

В предыдущей статье мы препарировали спаны и увидели, как они организуются в иерархическое дерево. Но если ваше приложение состоит из одного процесса, трейсинг мало чем отличается от обычного профилировщика или структурированного лога. Настоящая магия Distributed Tracing начинается в тот момент, когда запрос пересекает границу процесса (Inter-Process Communication, IPC) — вылетает по сети через HTTP-клиент, уходит в gRPC-канал или публикуется в топик Apache Kafka.

Чтобы связать Спан вызывающей стороны (Client Span) со Спаном принимающей стороны (Server Span), необходим надежный протокол передачи метаданных сквозь транспортные границы — Context Propagation (Проброс контекста).


Клей распределенной трассировки

Без Context Propagation распределенная система мгновенно слепнет: каждый сервис при получении запроса будет думать, что он является родоначальником нового независимого трейса. Вместо единого сквозного дерева вызовов от пользователя через десяток микросервисов вы получите разрозненные изолированные обрубки.

Контекст — это невидимый клей, связывающий разнородные компоненты кластера в единый организм.


Механизм работы

Физическая модель Context Propagation напоминает эстафетный бег 4х100 метров. Бегун A (сервис A) подбегает к границе своего этапа и на полной скорости вкладывает эстафетную палочку (Trace Context) в руку бегуна B (сервис B). Если палочка упала на дорожку — эстафета сорвана, результаты аннулированы, расследование инцидента невозможно.

В мире OpenTelemetry и Go этот процесс состоит из двух фаз: Inject (внедрение в сетевой заголовок) и Extract (извлечение из заголовка в рантайм):

flowchart LR
    subgraph A ["Service A (Client)"]
        A1["Tracer.Start"] --> A2["Context<br>TraceID: abc"]
        A2 --> A3["Propagator.Inject<br>Context -> Headers"]
    end

    subgraph B ["Network"]
        B1["HTTP Headers<br>traceparent: abc..."]
    end

    subgraph C ["Service B (Server)"]
        C1["Propagator.Extract<br>Headers -> Context"] --> C2["Tracer.Start<br>ParentID: abc"]
    end

    A3 --> B1
    B1 --> C1

    style A fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#fff
    style B fill:#312e81,stroke:#818cf8,stroke-width:2px,color:#fff
    style C fill:#065f46,stroke:#10b981,stroke-width:2px,color:#fff
    style B1 fill:#78350f,stroke:#f59e0b,color:#fff
  

Under the Hood: W3C Trace Context

Стандартом де-факто сейчас является W3C Trace Context. Это спецификация, описывающая, как кодировать Trace ID в HTTP-заголовках.

Главный заголовок: traceparent.
Формат: version-traceid-parentid-flags

Пример заголовка:

Text
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
  • 00: Версия формата.
  • 4bf92...: Trace ID (уникальный для всего запроса, 32 шестнадцатеричных символа).
  • 00f0...: Parent Span ID (ID спана, который инициировал вызов, 16 шестнадцатеричных символов).
  • 01: Trace Flags (например, sampled — записывать ли этот трейс).

Реализация в Go

В Go контекст передается через интерфейс context.Context. OpenTelemetry использует propagation пакет для работы с транспортным слоем (HTTP, gRPC).

1. Конфигурация Propagator

Правильный подход — глобально установить пропагатор, поддерживающий W3C.

Go
package main

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/propagation"
)

func initTracer() {
    // ... создание tracer provider ...

    // Устанавливаем глобальный пропагатор (W3C Trace Context + Baggage)
    otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
        propagation.TraceContext{}, // Отвечает за traceparent
        propagation.Baggage{},      // Отвечает за передачу пользовательских данных (baggage)
    ))
}

2. Client Side: Inject (Отправка)

Когда вы делаете исходящий HTTP-запрос, вы должны "внедрить" контекст в заголовки.

Go
package main

import (
    "context"
    "net/http"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/propagation"
)

func makeRequest(ctx context.Context, url string) ([]byte, error) {
    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return nil, err
    }

    // ВАЖНО: Внедряем traceparent в заголовки запроса
    // carrier - это обертка над http.Header, реализующая интерфейс TextMapCarrier
    otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    // Чтение ответа...
    return nil, nil
}

Если вы используете инструментированные библиотеки (например, oteldhttp), они делают это автоматически. Но если вы пишете свой клиент или работаете с очередями (Kafka/RabbitMQ), вы должны вызвать Inject вручную.

3. Server Side: Extract (Прием)

На стороне сервера (middleware) вы должны извлечь контекст из входящих заголовков.

Go
package main

import (
    "net/http"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/propagation"
)

func tracingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Извлекаем контекст из заголовков запроса
        ctx := otel.GetTextMapPropagator().Extract(r.Context(), propagation.HeaderCarrier(r.Header))

        // Создаем span, который автоматически станет дочерним к приходящему traceparent
        tr := otel.Tracer("my-service")
        ctx, span := tr.Start(ctx, "handle-request")
        defer span.End()

        // Передаем обновленный ctx дальше в хендлер
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Mechanical Sympathy: Context в Go

Почему Go идеально подходит для трейсинга?
Потому что context.Context — это идиома языка.

  • Immutable: Контекст иммутабелен. Добавление значения (или Span) создает новый контекст, но не меняет старый (безопасно для конкурентности). В памяти рантайма формируется связанный список звеньев valueCtx.
  • Tree Structure: Дерево контекстов в Go естественно отображается на дерево Спанов.
  • Lifetime: Отмена контекста (ctx.Done()) может сигнализировать о таймауте запроса, что трассировщик может записать как событие.

Baggage: Данные, которые путешествуют

Кроме traceparent, существует механизм Baggage. Это способ передать произвольные пары ключ-значение через все сервисы в рамках одного трейса.

Пример: Вы хотите видеть user_id в логах сервиса Z, но запрос пришел через сервисы A -> B -> C.

В сервисе A:

Go
baggage.Set(ctx, "user.id", "12345")

В сервисе C (если настроен Baggage Propagator):

Go
member, _ := baggage.Member("user.id")
fmt.Println(member.Value()) // "12345"

Итог

  1. Propagation — это процесс передачи Trace ID и Parent Span ID между сервисами.
  2. Стандарт — W3C Trace Context (заголовок traceparent).
  3. В Go используйте otel.GetTextMapPropagator().Inject/Extract.
  4. Всегда передавайте ctx как первый аргумент в функции. Это "правило большого пальца" Go, которое делает трейсинг возможным.

В следующей статье мы рассмотрим инструменты визуализации трейсов — Jaeger и его альтернативы: 5. Jaeger и альтернативы.