Модуль: 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и кладёт remoteSpanContextв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 пропагаторов одновременно для постепенной миграции.