3. Спаны и трейсы

Анатомия и жизненный цикл единицы распределенной работы

«Распределенная система — это среда, где поломка компьютера, о существовании которого вы даже не знали, способна парализовать вашу собственную рабочую станцию».
— Лесли Лэмпорт

В предыдущей статье мы рассмотрели OpenTelemetry как глобальный индустриальный стандарт и архитектурный мост между кодом приложения и коллекторами данных. Теперь спустимся на уровень рантайма языка Go и препарируем фундаментальные кирпичики, из которых складывается вся картина наблюдаемости: Спаны (Spans) и Трейсы (Traces).

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


Анатомия запроса

Трейс — это не монолитный непрерывный лог. Это направленный ациклический граф (DAG), узлами которого являются отдельные дискретные операции — Спаны.

Если трейс представляет собой всю пьесу целиком (жизненный путь запроса от клика в браузере до фиксации транзакции в реплике СУБД), то спан — это отдельная реплика или действие конкретного актера на сцене.


Структура Спана

Спан представляет собой атомарную единицу работы. В Go (через OpenTelemetry SDK) спан создается, живет в рамках функции и завершается.

Ключевые поля Спана:

  1. Trace ID: Глобальный идентификатор всего запроса. Одинаков для всех спанов в рамках одного трейса (128 бит / 16 байт).
  2. Span ID: Уникальный идентификатор конкретной операции (64 бит / 8 байт).
  3. Parent Span ID: ID родительского спана. Именно это поле строит иерархию (ссылка на родительский узел дерева).
  4. Start/End Timestamp: Время начала и окончания работы.
  5. Attributes: Метаданные (Key-Value), описывающие операцию (например, http.method="GET").
  6. Events: Лог-подобные события, произошедшие внутри спана (например, "получено сообщение из очереди").
  7. Status: Результат выполнения (Ok, Error, Unset).
flowchart TD
    A["Span A Root<br>TraceID: abc<br>SpanID: 1"]
    A --> B["Span B<br>TraceID: abc<br>SpanID: 2<br>ParentID: 1"]
    A --> C["Span C<br>TraceID: abc<br>SpanID: 3<br>ParentID: 1"]
    C --> D["Span D<br>TraceID: abc<br>SpanID: 4<br>ParentID: 3"]

    subgraph "Span D Internals"
        D1["Attribute: db.system=postgres"]
        D2["Event: Acquired connection"]
        D3["Status: Ok"]
    end

    style A fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#fff
    style B fill:#312e81,stroke:#818cf8,color:#fff
    style C fill:#312e81,stroke:#818cf8,color:#fff
    style D fill:#0f172a,stroke:#10b981,color:#fff
  

Under the Hood: Создание Спана в Go

В Go создание спана происходит через объект Tracer. метод tracer.Start возвращает два значения: сам спан и новый контекст.

Go
package main

import (
    "context"

    "go.opentelemetry.io/otel"
)

func myHandler(ctx context.Context) {
    // Создаем дочерний спан от контекста, который пришел извне
    tr := otel.Tracer("my-service")
    ctx, span := tr.Start(ctx, "myHandler-operation")

    // Обязательно завершаем спан в конце функции
    // defer гарантирует вызов даже при панике
    defer span.End()

    // ... бизнес-логика ...

    // Если вызываем другую функцию, передаем обновленный ctx
    processPayment(ctx)
}

func processPayment(ctx context.Context) {
    // Вложенная операция...
}

Контекст в Go иммутабелен (immutable). Вызов tr.Start не мутирует переданный ctx, а создает новую обертку context.WithValue, помещая туда указатель на созданный спан. Если вы передадите в дочернюю функцию старый ctx, новый спан не станет родителем для последующих операций!

Mechanical Sympathy: Цена создания спана

Многие боятся добавлять спаны, думая, что это «убьет» производительность. Давайте разберем, что происходит при вызове tr.Start:

  1. Генерация ID: Требует криптографически стойкого (или псевдослучайного) генератора. Это не просто инкремент счетчика, это вычислительная работа. OpenTelemetry использует оптимизированные быстрые генераторы псевдослучайных чисел без блокировок глобального мьютекса.
  2. Time Now: Вызов time.Now() — это системный вызов или чтение VDSO (в зависимости от ОС и версии Go). Это быстро, но не бесплатно. В современном Linux на x86-64/arm64 это чтение счетчика тактов процессора RDTSC через память VDSO без переключения контекста в ядро.
  3. Сэмплирование (Sampling): Самое важное!
    • Если Sampler решает не записывать этот спан (например, ProbabilitySampler с вероятностью 1%), SDK возвращает NoopSpan (заглушку).
    • Заглушка не записывает атрибуты, не отправляет данные. Она почти бесплатна (пара аллокаций на интерфейс).
    • Вывод: Вы можете instrumentировать весь код, но писать только процент запросов.

Типы Спанов

В OpenTelemetry есть два типа спанов, и их путают чаще всего.

1. Internal Span (Внутренний)

Это спан, который описывает операцию внутри вашего сервиса.

  • Семантика: "Я делаю работу".
  • Пример: Функция валидации заказа, выполнение SQL-запроса через драйвер.

2. Remote Span / Client & Server Spans (Внешний)

Это спаны, описывающие взаимодействие с внешним миром (RPC).

  • Client Span: Создается отправителем перед вызовом внешнего сервиса. Время спана включает в себя сетевые задержки и ожидание ответа.
    • Атрибуты: rpc.system="grpc", rpc.method="Checkout".
  • Server Span: Создается получателем при входе запроса. Он становится дочерним по отношению к Client Span'у отправителя (благодаря propagation).
sequenceDiagram
    participant S1 as Service 1 (Caller)
    participant S2 as Service 2 (Callee)

    Note over S1: Client Span Starts
    S1->>S2: HTTP/gRPC Request (traceparent header)
    Note over S2: Server Span Starts (Child of Client Span)
    Note over S2: Business Logic Execution
    Note over S2: Server Span Ends
    S2-->>S1: HTTP/gRPC Response
    Note over S1: Client Span Ends
  

Атрибуты vs События (Events)

Когда что использовать?

  • Attributes (Атрибуты): Используйте для статических или квазистатических данных, по которым вы будете искать трейсы.
    • Пример: user_id, order_id, http.status_code.
    • Осторожно: Атрибуты с высокой кардинальностью (ID) могут раздуть индексы в бэкенде (Jaeger/Tempo).
  • Events (События): Используйте для точечных логов внутри спана.
    • Пример: "Retry attempt 1", "Cache miss".
    • События имеют timestamp и позволяют увидеть хронологию внутри одной операции без создания новых спанов.

Связь с ошибками

Обработка ошибок в трейсинге специфична.
Просто вернуть ошибку в коде недостаточно, чтобы спан покраснел в интерфейсе Jaeger. Нужно явно записать статус ошибки.

Go
package main

import (
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/codes"
    "go.opentelemetry.io/otel/trace"
)

func handleOperation(span trace.Span, err error) {
    if err != nil {
        // 1. Записываем событие об ошибке (StackTrace добавится автоматически в events)
        span.RecordError(err)

        // 2. Устанавливаем статус спана как Error
        span.SetStatus(codes.Error, err.Error())

        // 3. Можно добавить атрибут, если нужно фильтровать по типу ошибки
        span.SetAttributes(attribute.String("error.type", "db_connection_failed"))
    }
}

Почему это так устроено? В Go ошибка — это значение. Функция может вернуть ошибку, которую вызывающий код успешно обработает (например, возьмет данные из кэша при ошибке БД). Если бы SDK красило спан в красный цвет при любом err != nil, графики трейсинга пестрели бы ложными паниками. Поэтому программист явно решает, является ли ошибка фатальным статусом (codes.Error).


Итог

  1. Trace — это дерево, Span — это узел.
  2. Используйте defer span.End() всегда.
  3. Attributes — для метаданных поиска, Events — для логирования хода выполнения.
  4. При ошибках используйте RecordError и SetStatus(codes.Error).

В следующей статье мы разберем механику того, как Спаны связываются между сервисами — Контекст propagation: 4. Контекст propagation.