3. Спаны и трейсы
Анатомия и жизненный цикл единицы распределенной работы
«Распределенная система — это среда, где поломка компьютера, о существовании которого вы даже не знали, способна парализовать вашу собственную рабочую станцию».
— Лесли Лэмпорт
В предыдущей статье мы рассмотрели OpenTelemetry как глобальный индустриальный стандарт и архитектурный мост между кодом приложения и коллекторами данных. Теперь спустимся на уровень рантайма языка Go и препарируем фундаментальные кирпичики, из которых складывается вся картина наблюдаемости: Спаны (Spans) и Трейсы (Traces).
Понимание внутреннего устройства спана, цены его создания в памяти и семантики передачи контекста через функции — это обязательный навык Senior Go-инженера. Без этого легко превратить высокопроизводительный сервис в генератор лишних аллокаций и взаимных блокировок.
Анатомия запроса
Трейс — это не монолитный непрерывный лог. Это направленный ациклический граф (DAG), узлами которого являются отдельные дискретные операции — Спаны.
Если трейс представляет собой всю пьесу целиком (жизненный путь запроса от клика в браузере до фиксации транзакции в реплике СУБД), то спан — это отдельная реплика или действие конкретного актера на сцене.
Структура Спана
Спан представляет собой атомарную единицу работы. В Go (через OpenTelemetry SDK) спан создается, живет в рамках функции и завершается.
Ключевые поля Спана:
- Trace ID: Глобальный идентификатор всего запроса. Одинаков для всех спанов в рамках одного трейса (128 бит / 16 байт).
- Span ID: Уникальный идентификатор конкретной операции (64 бит / 8 байт).
- Parent Span ID: ID родительского спана. Именно это поле строит иерархию (ссылка на родительский узел дерева).
- Start/End Timestamp: Время начала и окончания работы.
- Attributes: Метаданные (Key-Value), описывающие операцию (например,
http.method="GET"). - Events: Лог-подобные события, произошедшие внутри спана (например, "получено сообщение из очереди").
- 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 возвращает два значения: сам спан и новый контекст.
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:
- Генерация ID: Требует криптографически стойкого (или псевдослучайного) генератора. Это не просто инкремент счетчика, это вычислительная работа. OpenTelemetry использует оптимизированные быстрые генераторы псевдослучайных чисел без блокировок глобального мьютекса.
- Time Now: Вызов
time.Now()— это системный вызов или чтение VDSO (в зависимости от ОС и версии Go). Это быстро, но не бесплатно. В современном Linux на x86-64/arm64 это чтение счетчика тактов процессораRDTSCчерез память VDSO без переключения контекста в ядро. - Сэмплирование (Sampling): Самое важное!
- Если Sampler решает не записывать этот спан (например,
ProbabilitySamplerс вероятностью 1%), SDK возвращает NoopSpan (заглушку). - Заглушка не записывает атрибуты, не отправляет данные. Она почти бесплатна (пара аллокаций на интерфейс).
- Вывод: Вы можете instrumentировать весь код, но писать только процент запросов.
- Если Sampler решает не записывать этот спан (например,
Типы Спанов
В 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. Нужно явно записать статус ошибки.
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).
Итог
- Trace — это дерево, Span — это узел.
- Используйте
defer span.End()всегда. - Attributes — для метаданных поиска, Events — для логирования хода выполнения.
- При ошибках используйте
RecordErrorиSetStatus(codes.Error).
В следующей статье мы разберем механику того, как Спаны связываются между сервисами — Контекст propagation: 4. Контекст propagation.