Модуль: Observability · Уровень: Senior

TL;DR#

OpenTelemetry (OTel) — это вендор-нейтральный стандарт и набор SDK для сбора телеметрии: traces, metrics, logs (три сигнала + появляющиеся profiles). Ключевая идея — разделить API (стабильный контракт, который вызывает код приложения и библиотеки) и SDK (реализация, которая семплирует, батчит и экспортирует). Данные текут через pipeline: Instrumentation → Provider → Processor → Exporter → Collector → Backend. Collector — отдельный процесс/демон, который принимает (receivers), обрабатывает (processors) и отправляет (exporters) телеметрию, отвязывая приложение от конкретного бэкенда. Context propagation через context.Context + W3C traceparent связывает спаны между сервисами. В Go главные грабли: не забыть Shutdown() (иначе теряются батчи), не плодить cardinality в метриках, прокидывать ctx сквозь весь стек.

Теория#

Три сигнала#

СигналЧто отвечаетМодель данныхСтоимость хранения
Traces“Где время потерялось в одном запросе?”Дерево спанов с causalityВысокая (per-request), нужен sampling
Metrics“Сколько / как быстро / насколько плохо в агрегате?”Числовые временные ряды с labelsНизкая (агрегаты), но взрыв от cardinality
Logs“Что конкретно произошло в этой точке?”Структурированные записи с timestamp/severityСредняя-высокая

Сила OTel — в корреляции: один TraceID связывает спаны, метрики (через exemplars) и логи (через trace_id в атрибутах). На senior-собеседовании ждут, что вы понимаете: сигналы дополняют друг друга, а не дублируют. Метрики → “что-то сломалось и насколько” (дёшево, всегда включены). Трейсы → “почему именно этот запрос медленный” (sampled). Логи → “детали конкретного события”.

API vs SDK#

Это центральное архитектурное различие OTel и любимый вопрос.

  • API (go.opentelemetry.io/otel, .../trace, .../metric) — интерфейсы и no-op реализации. Библиотеки (например, HTTP-клиент, драйвер БД) инструментируются только через API. Если SDK не подключён — вызовы превращаются в no-op (нулевой оверхед), приложение работает.
  • SDK (go.opentelemetry.io/otel/sdk/...) — конкретная реализация: семплеры, процессоры, экспортёры, resource detection. Подключается только в main() приложения, никогда в библиотеке.

Это позволяет автору библиотеки инструментировать код, не навязывая пользователю зависимость от тяжёлого SDK или конкретного вендора.

// Библиотека: использует ТОЛЬКО API
import "go.opentelemetry.io/otel"

var tracer = otel.Tracer("github.com/me/mylib")  // именованный по имени модуля

func DoWork(ctx context.Context) error {
    ctx, span := tracer.Start(ctx, "DoWork")
    defer span.End()
    // ...
    return nil
}

Глобальный otel.Tracer(...) берёт TracerProvider из глобального реестра. Пока приложение не вызвало otel.SetTracerProvider(sdkProvider), это no-op провайдер.

Полная инициализация SDK для Go-сервиса#

import (
    "context"
    "time"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
    "go.opentelemetry.io/otel/propagation"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)

func initTracer(ctx context.Context) (func(context.Context) error, error) {
    // Resource — описывает сам сервис (service.name ОБЯЗАТЕЛЕН для бэкендов)
    res, err := resource.New(ctx,
        resource.WithAttributes(
            semconv.ServiceName("payments"),
            semconv.ServiceVersion("1.4.2"),
            semconv.DeploymentEnvironment("production"),
        ),
        resource.WithFromEnv(),    // OTEL_RESOURCE_ATTRIBUTES
        resource.WithHost(),
        resource.WithProcess(),
    )
    if err != nil {
        return nil, err
    }

    // Exporter: OTLP/gRPC в Collector
    exp, err := otlptracegrpc.New(ctx,
        otlptracegrpc.WithEndpoint("otel-collector:4317"),
        otlptracegrpc.WithInsecure(),
    )
    if err != nil {
        return nil, err
    }

    tp := sdktrace.NewTracerProvider(
        // BatchSpanProcessor — буферизует и шлёт батчами (НЕ Simple в проде!)
        sdktrace.WithBatcher(exp,
            sdktrace.WithMaxQueueSize(2048),
            sdktrace.WithBatchTimeout(5*time.Second),
        ),
        sdktrace.WithResource(res),
        // ParentBased + TraceIDRatio: уважаем решение родителя, иначе семплируем 10%
        sdktrace.WithSampler(
            sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1)),
        ),
    )

    otel.SetTracerProvider(tp)
    otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
        propagation.TraceContext{},  // W3C traceparent/tracestate
        propagation.Baggage{},       // W3C baggage
    ))

    return tp.Shutdown, nil  // ВАЖНО: вызвать при graceful shutdown
}
func main() {
    ctx := context.Background()
    shutdown, err := initTracer(ctx)
    if err != nil { log.Fatal(err) }
    defer func() {
        // даём время дослать батчи
        sdctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()
        _ = shutdown(sdctx)
    }()
    // ... запуск сервера
}

Инструментирование HTTP/gRPC#

Не пишите ручные спаны для транспорта — используйте contrib-инструментацию:

import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"

// Сервер: автоматически создаёт server-span, извлекает traceparent из заголовков
handler := otelhttp.NewHandler(mux, "http.server",
    otelhttp.WithSpanNameFormatter(func(_ string, r *http.Request) string {
        return r.Method + " " + r.Pattern  // route, НЕ raw URL (cardinality!)
    }),
)

// Клиент: создаёт client-span, инжектит traceparent в исходящий запрос
client := http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)}

Для gRPC — otelgrpc.NewServerHandler() / otelgrpc.NewClientHandler() через grpc.StatsHandler(...) (старые interceptor-API deprecated).

Context propagation: как трейс «прошивает» сервисы#

Service A                          Service B
─────────                          ─────────
ctx, span := tracer.Start(ctx)
  └─ TraceID, SpanID в ctx
http req ──[inject]──> заголовок:   ──[extract]──> ctx с теми же TraceID,
  traceparent: 00-<trace>-<span>-01                parent = SpanID из A
                                    ctx, span := tracer.Start(ctx)
                                      └─ тот же TraceID, новый SpanID, parent = A
  • Inject — пропагатор кладёт traceparent из SpanContext в исходящие заголовки (делает otelhttp.Transport автоматически).
  • Extract — на входе достаёт traceparent и кладёт remote SpanContext в ctx (делает otelhttp.Handler).
  • Baggage — отдельный механизм: произвольные key=value, путешествующие по всему трейсу (tenant_id, feature_flag). Осторожно: baggage едет в КАЖДОМ заголовке каждого hop’а — не суйте туда много/секретное.

W3C формат: traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 = version-traceid(16б)-spanid(8б)-flags. Флаг 01 = sampled.

Collector — почему он нужен#

Приложение шлёт OTLP в Collector, а не в бэкенд напрямую. Зачем:

  • Развязка: сменить вендора (Jaeger → Tempo → Datadog) = поменять конфиг Collector, не передеплоивать сервисы.
  • Буферизация/ретраи/батчинг вне процесса приложения.
  • Обработка: добавить/удалить атрибуты, redact PII, переименовать, агрегировать.
  • Tail-based sampling (см. tracing.md) — невозможен на стороне приложения, нужен Collector, видящий весь трейс.
# collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc: { endpoint: 0.0.0.0:4317 }
      http: { endpoint: 0.0.0.0:4318 }

processors:
  batch: { timeout: 5s, send_batch_size: 1024 }
  memory_limiter: { check_interval: 1s, limit_mib: 512 }
  attributes:
    actions:
      - { key: http.request.header.authorization, action: delete }  # redact PII

exporters:
  otlp/tempo:  { endpoint: tempo:4317, tls: { insecure: true } }
  prometheus:  { endpoint: 0.0.0.0:8889 }

service:
  pipelines:
    traces:  { receivers: [otlp], processors: [memory_limiter, attributes, batch], exporters: [otlp/tempo] }
    metrics: { receivers: [otlp], processors: [memory_limiter, batch], exporters: [prometheus] }

Deployment-паттерны: agent (DaemonSet/sidecar рядом с приложением — быстрый offload) + gateway (centralized — tail sampling, агрегация). Часто оба слоя сразу.

Metrics в OTel-стиле (отличия от Prometheus client)#

OTel-метрики используют instruments: Counter, UpDownCounter, Histogram, и асинхронные (observable) Gauge/Counter с callback. Модель — push (OTLP) или pull (Prometheus exporter). Важная фича — Views: переименование, фильтрация атрибутов, кастомные бакеты гистограмм, drop ненужных инструментов — настраивается централизованно в SDK без правки кода инструментирования.

meter := otel.Meter("payments")
reqDur, _ := meter.Float64Histogram("http.server.duration",
    metric.WithUnit("s"),
    metric.WithExplicitBucketBoundaries(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5),
)
reqDur.Record(ctx, elapsed.Seconds(),
    metric.WithAttributes(attribute.String("route", "/checkout"), attribute.Int("status", 200)))

Logs#

OTel logs в Go (go.opentelemetry.io/contrib/bridges/otelslog) — мост к slog: записи получают trace_id/span_id автоматически из контекста и экспортируются по OTLP. Часто на практике логи всё ещё собирают агентом из stdout, а через OTLP идут traces+metrics — но bridge даёт нативную корреляцию.

Подводные камни / gotchas#

  • Забытый Shutdown() → последний батч спанов/метрик теряется при остановке. Всегда defer shutdown(ctx) с таймаутом.
  • SimpleSpanProcessor в проде — синхронный экспорт на каждый span.End(), блокирует hot path. Только BatchSpanProcessor.
  • Не прокинут ctx (новый context.Background() где-то в середине) → разрыв трейса, спаны без родителя, “осиротевшие” trace_id в логах.
  • tracer.Start без defer span.End() → утечка незакрытых спанов, они не экспортируются.
  • Высокая cardinality в атрибутах (user_id, raw URL с ID, timestamp в label метрики) → взрыв временных рядов / стоимости. Кладите ID в спан-атрибуты (там ок), но НЕ в метрики.
  • record.Error() ≠ статус спана. span.RecordError(err) добавляет event, но НЕ помечает спан как failed. Нужно ещё span.SetStatus(codes.Error, msg).
  • Пропагатор не настроен → каждый сервис стартует новый трейс, межсервисная связь рвётся. Дефолтный propagator в Go SDK — no-op, его НУЖНО задать явно.
  • Семплинг без ParentBased → один трейс семплируется частично (часть сервисов записала, часть нет) = битые трейсы. Всегда оборачивайте в ParentBased.
  • service.name не задан → бэкенд показывает unknown_service. Это самый частый «почему ничего не видно».
  • Batch loss под нагрузкой: при переполнении очереди (MaxQueueSize) спаны дропаются молча. Мониторьте otel_sdk_* метрики самого SDK.

Вопросы на собеседовании#

В: В чём принципиальное различие между OTel API и SDK и почему оно так устроено? О: API — стабильный контракт с no-op реализацией, его используют библиотеки и код приложения. SDK — тяжёлая реализация (семплинг, батчинг, экспорт), подключается только в main(). Это позволяет инструментировать библиотеки без навязывания зависимости от вендора/SDK: если SDK не подключён — нулевой оверхед. Меняя SDK/экспортёр, не трогаешь инструментированный код.

В: Зачем нужен Collector, если можно слать прямо в бэкенд? О: Развязка от вендора (смена бэкенда = конфиг, не редеплой), вынос батчинга/ретраев/буферизации из приложения, обработка (redact PII, нормализация атрибутов), и главное — tail-based sampling, который требует видеть весь трейс целиком и невозможен на стороне отдельного сервиса.

В: Как трейс связывается между двумя сервисами? О: Через context propagation. На исходящем запросе пропагатор инжектит W3C traceparent (trace_id + span_id + flags) в заголовки. На входящем — извлекает в context.Context как remote parent. Дочерний спан наследует тот же trace_id, ссылается на родительский span_id. В Go это делают otelhttp/otelgrpc автоматически, при условии что ctx прокинут и пропагатор настроен.

В: Разница между RecordError и SetStatus? О: RecordError(err) добавляет в спан событие типа exception (stacktrace, тип, сообщение) — но спан остаётся «успешным». SetStatus(codes.Error, msg) помечает сам спан как failed, что влияет на error-rate в UI и tail-sampling. Для корректного отображения ошибки нужны оба.

В: Почему BatchSpanProcessor, а не Simple? О: Simple экспортирует синхронно на каждый End() — блокирует горячий путь и убивает латенси под нагрузкой. Batch складывает в очередь и шлёт батчами в фоне по таймеру/размеру, с back-pressure. Simple оправдан только в тестах/CLI.

В: Что такое Baggage и в чём опасность? О: Baggage — произвольные key=value, пропагируемые по всему трейсу (tenant_id, feature flag) для прокидывания контекста между сервисами без явной передачи. Опасность: едет в заголовках каждого hop’а (overhead), может попасть в downstream-логи/трейсы (утечка PII), и легко раздуть размер. Не класть секреты, держать минимальным.

В: Как сделать так, чтобы трейс был либо полностью записан, либо полностью пропущен, при вероятностном семплинге? О: ParentBased(TraceIDRatioBased(p)). Корневой спан принимает решение по детерминированному хешу trace_id с вероятностью p; все потомки через ParentBased уважают флаг sampled родителя (передаётся в traceparent). Так весь трейс консистентен. Без ParentBased каждый сервис решал бы независимо → битые частичные трейсы.

На что копают на senior+#

  • Архитектура pipeline под нагрузкой: где теряются данные (queue overflow, memory_limiter дропает), как мониторить сам OTel SDK/Collector, back-pressure и его эффект на приложение.
  • Tail-based vs head-based sampling: trade-offs, почему tail требует gateway-collector с буфером всех спанов трейса до решения, проблема с долгими трейсами и памятью.
  • Semantic conventions: знание стандартных атрибутов (http.request.method, db.system, service.name) и почему их стабильность важна для дашбордов/алертов; миграция версий semconv.
  • Cost engineering: контроль cardinality, sampling-стратегии, exemplars для связи метрик и трейсов без полного хранения трейсов.
  • Корреляция трёх сигналов: один trace_id в логах (через slog bridge), exemplars в гистограммах метрик, и переход metric → trace → log в UI (Grafana Tempo/Loki/Mimir).
  • Миграция/совместимость: OpenCensus → OpenTelemetry bridge, переход с vendor-специфичных SDK, поддержка W3C + B3 пропагаторов одновременно для постепенной миграции.