Когда Go-сервис падает в 3 ночи, у вас есть одна минута, чтобы понять что именно сломалось. Без observability вы слепой - видите только симптом, но не причину. С правильно настроенными логами, метриками и трейсами диагностика укладывается в минуты, а не в 20–40 минут поиска иголки в стоге. В этой статье разберём полный стек: OpenTelemetry SDK для Go + OTel Collector + Prometheus + Grafana Tempo + Loki - с рабочим кодом и docker-compose.
Что такое observability и зачем она нужна Go-сервисам
Мониторинг vs observability: в чём разница
Мониторинг - это дашборд с зелёными и красными лампочками. Он говорит «что-то сломалось». Observability - способность понять внутреннее состояние системы по внешним сигналам. Она отвечает на вопрос «почему именно это сломалось и в каком сервисе».
Для Go-сервисов разница особенно ощутима в микросервисной архитектуре: один HTTP-запрос может пройти через 5 сервисов, и без трейсинга найти узкое место - задача нетривиальная.
Три кита: logs, metrics, tracing
Observability строится на трёх столпах, каждый из которых закрывает свою задачу:
- Логи - события: «пользователь X авторизовался», «запрос упал с ошибкой». Контекстные, но дорогие при поиске.
- Метрики - числа: RPS, latency p95, количество ошибок. Дёшевы для хранения и алертинга.
- Трейсы - граф вызовов: кто кого вызвал, сколько занял каждый шаг. Незаменимы для диагностики задержек.
Вместе они дают полную картину. По отдельности - лишь фрагменты.
OpenTelemetry: стандарт для Go-observability
OpenTelemetry (OTel) - вендор-нейтральный стандарт для сбора телеметрии, лицензированный под Apache 2.0. Для Go он предоставляет единый SDK, который закрывает все три столпа через один pipeline.
Архитектура: SDK → Collector → Backend
Данные движутся так:
Go-приложение (OTel SDK)
|
v
OTel Collector
|
+--------> Prometheus (метрики)
|
+--------> Grafana Tempo (трейсы)
|
+--------> Grafana Loki (логи)
|
v
Grafana (визуализация)
OTel Collector - центральная точка входа. Он принимает данные по OTLP (gRPC на порту 4317, HTTP на 4318), обрабатывает и отправляет в целевые системы. Менять бэкенд (скажем, Tempo → Jaeger) достаточно в конфиге коллектора - код приложения не трогаете.
Инициализация: Resource и провайдеры
Resource - метаданные сервиса, которые прикрепляются ко всем телеметрическим данным:
res, _ := resource.Merge(
resource.Default(),
resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName("my-service"),
semconv.ServiceVersion("1.0.0"),
),
)
Один Resource передаётся в три провайдера: sdktrace.NewTracerProvider, sdkmetric.NewMeterProvider, log.NewLoggerProvider. Без ServiceName данные в Grafana окажутся в кучу без разметки.
Ремарка автора. Первая ошибка, которую я вижу в чужом коде - провайдеры создаются, но
defer provider.Shutdown()не вызывается. Итог: при graceful shutdown Go-процесса последние батчи телеметрии не уходят. Всегда добавляйтеdeferдля каждого провайдера.
Какие go get нужны для старта
go get go.opentelemetry.io/otel \
go.opentelemetry.io/otel/sdk \
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc \
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc \
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc \
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp \
go.opentelemetry.io/contrib/bridges/otelslog
Актуальные версии пакетов - на pkg.go.dev и в официальной документации OTel.
Метрики в Go: Counter, Histogram, Gauge
Метрики - самый дешёвый способ понять состояние системы в реальном времени. OTel Go SDK поддерживает семь типов инструментов.
Какой тип метрики выбрать
Простое правило из практики:
| Тип | Когда использовать | Пример вопроса |
|---|---|---|
| Counter | Монотонно растущие события | «Сколько запросов обработано?» |
| Gauge | Текущее состояние | «Сколько памяти сейчас?» |
| Histogram | Распределение значений | «Каков p95 времени ответа?» |
| UpDownCounter | Значение растёт и падает | «Сколько активных соединений?» |
Пример инициализации Counter и Histogram в обработчике:
meter := otel.Meter("my-service")
reqCounter, _ := meter.Int64Counter(
"http_requests_total",
metric.WithDescription("Total HTTP requests"),
)
latency, _ := meter.Float64Histogram(
"http_request_duration_ms",
metric.WithUnit("ms"),
metric.WithDescription("HTTP request latency"),
)
В обработчике:
func handler(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// ... бизнес-логика ...
latency.Record(r.Context(), float64(time.Since(start).Milliseconds()),
metric.WithAttributes(semconv.HTTPResponseStatusCode(200)))
reqCounter.Add(r.Context(), 1)
}
UUID в labels - почему нельзя
UUID в label создаёт неограниченную кардинальность: каждый уникальный label порождает отдельную time series в Prometheus. Тысячи UUID → миллионы time series → OOM в Prometheus. Правило: в labels пишите только предсказуемые значения (endpoint, status, method). UUID пишите в трейс (span.SetAttributes) или в лог.
Алерты в Prometheus Rules
Правило оповещения в yaml - это одна конфигурация, которая экономит ночной сон:
- alert: HighLatency
expr: histogram_quantile(0.95, sum by (le) (
rate(http_request_duration_ms_bucket[5m]))) > 2000
for: 3m
labels:
severity: warning
annotations:
summary: "p95 latency > 2000ms"
Prometheus проверяет правило каждые evaluation_interval секунд и отправляет алерт в Alertmanager при срабатывании.
Трейсинг в Go: spans, context, sampling
Трейсинг - самый информативный и самый дорогой инструмент. В production нельзя писать все трейсы - нужна выборка.
Как работает span и вложенность
Спан - единица работы: у него есть имя, время старта и окончания, атрибуты и статус. Вложенность строится через context.Context - каждый дочерний спан «знает» о родителе:
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx, span := tracer.Start(r.Context(), "http_handler")
defer span.End()
h.service.Process(ctx) // передаём ctx вниз
}
func (s *service) Process(ctx context.Context) {
ctx, span := tracer.Start(ctx, "service_process")
defer span.End()
// ctx содержит дочерний спан
}
Нарушение цепочки - типичная ошибка новичков: context.Background() вместо ctx в вызове дочернего метода. Результат - два несвязанных трейса вместо одного.
Сэмплинг: как не утопить бэкенд трейсами
В production включайте TraceIDRatioBased с коэффициентом 0.1 (10% трейсов):
tracerProvider := sdktrace.NewTracerProvider(
sdktrace.WithSampler(
sdktrace.ParentBased(
sdktrace.TraceIDRatioBased(0.1),
),
),
sdktrace.WithBatcher(traceExp),
sdktrace.WithResource(res),
)
ParentBased позволяет вышестоящему сервису управлять решением: если клиент сказал «сэмплировать» - все дочерние сервисы тоже сэмплируют этот трейс.
Propagation: как трейс переходит между сервисами
Без propagator каждый HTTP-запрос начинает новый изолированный трейс:
otel.SetTextMapPropagator(propagation.TraceContext{})
otelhttp.NewTransport автоматически добавляет W3C TraceContext-заголовок к исходящим запросам. На входе otelhttp.NewHandler читает его и «присоединяется» к существующему трейсу. Эта пара - обязательный минимум для распределённого трейсинга.
Ремарка автора. Если вы работаете со старым кодом, который использует Zipkin, OTel поддерживает B3-формат через
go.opentelemetry.io/contrib/propagators/b3. Это позволяет мигрировать постепенно, не ломая существующую цепочку.
Как добавить атрибуты и записать ошибку:
span.SetAttributes(attribute.String("user.id", userID))
result, err := doWork(ctx)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
⚠️ RecordError сам по себе не ставит статус Error - нужно явно вызвать SetStatus.
Логи в Go: slog + OTel Log Bridge
Почему OTel не имеет собственного log API
У OTel нет своего Logger - это осознанное решение. В Go уже есть slog (добавлен в Go 1.21), zap, logrus. Вместо конкуренции с ними OTel предлагает Log Bridge - адаптер, который перехватывает записи существующего логгера и направляет их в OTel pipeline. Код приложения не меняется.
Корреляция логов и трейсов
otelslog.NewHandler автоматически добавляет trace_id и span_id в каждую запись, если лог пишется внутри активного спана:
logProvider, shutdown := initLogProvider(ctx)
defer shutdown(ctx)
logger := slog.New(otelslog.NewHandler(
"my-service",
otelslog.WithLoggerProvider(logProvider),
))
// В контексте span:
ctx, span := tracer.Start(ctx, "my_operation")
logger.InfoContext(ctx, "processing item", slog.String("item_id", id))
// → в Loki запись придёт с trace_id и span_id
В Grafana Loki это настраивается через derivedFields в datasource: кликаете на trace_id в логе → открывается соответствующий трейс в Tempo. Связка работает в обе стороны: из трейса в Tempo нажимаете иконку лога → переходите в связанные логи Loki.
Полный стек: docker-compose и Grafana
Порядок запуска сервисов
Правильный порядок зависимостей:
В docker-compose.yml это выражается через depends_on с condition: service_healthy. Без healthcheck OTel Collector стартует раньше Tempo и теряет первые трейсы.
Минимальный docker-compose для полного стека (сокращённая версия):
services:
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
ports: ["4317:4317", "4318:4318", "8889:8889"]
depends_on:
tempo: { condition: service_healthy }
prometheus: { condition: service_healthy }
prometheus:
image: prom/prometheus:latest
ports: ["9090:9090"]
healthcheck:
test: ["CMD", "wget", "--spider", "http://localhost:9090/-/healthy"]
tempo:
image: grafana/tempo:2.7.0
ports: ["3200:3200"]
healthcheck:
test: ["CMD", "wget", "--spider", "http://localhost:3200/ready"]
loki:
image: grafana/loki:3.3.2
ports: ["3100:3100"]
grafana:
image: grafana/grafana:latest
ports: ["3000:3000"]
depends_on: [prometheus, tempo]
Grafana datasource: связки Metrics→Traces→Logs
Три datasource связываются так:
- Prometheus → Tempo: через
exemplarTraceIdDestinations- в метрике кликаете на точку с exemplar и попадаете в трейс. - Loki → Tempo: через
derivedFieldsс regextraceID=(\w+)- кликаете на trace_id в логе. - Tempo → Loki: через
tracesToLogsV2.datasourceUid- из трейса видите связанные логи.
Это замыкает треугольник «метрики - трейсы - логи» в единый интерфейс расследования.
Альтернативный взгляд
Часть команд выбирает managed-решения (Datadog, New Relic) вместо self-hosted стека. Аргумент: настройка Prometheus + Tempo + Loki занимает несколько дней, а Datadog работает «из коробки». Контраргумент: Datadog берёт деньги за каждый хост и каждый миллион log events. При 50+ сервисах счёт быстро превышает стоимость DevOps-инженера в месяц. Open-source стек сложнее, но при правильной настройке полностью контролируется и не зависит от вендора.
Нетривиальный факт
OTel Go поддерживает Zero-code instrumentation через eBPF (проект OBI) - без единой строчки кода в приложении. Инструментация внедряется на уровне ядра Linux (≥ 4.19). Единственное ограничение: если вы используете eBPF-инструментацию совместно с ручной, не устанавливайте global TracerProvider - это ломает совместную работу. Технология пока экспериментальная, но уже работает в production на x64 и ARM.
FAQ
Что такое observability в Go?
Observability - способность понять внутреннее состояние сервиса по внешним сигналам. Для Go это три потока данных: логи (события), метрики (числа), трейсы (граф вызовов). Вместе они дают полную картину инцидента.
Чем OpenTelemetry отличается от Prometheus?
Prometheus - TSDB для метрик с pull-моделью сбора. OpenTelemetry - вендор-нейтральный стандарт для всех трёх сигналов с push через OTLP. OTel Collector умеет экспортировать метрики в Prometheus.
Когда использовать Histogram, а не Gauge?
Histogram - для распределений (latency, размер запроса, p95/p99). Gauge - для текущего мгновенного состояния (память, активные соединения). Counter - для монотонно растущих событий (запросы, ошибки).
Можно ли UUID как label в Prometheus?
Нет - UUID создаёт неограниченную кардинальность и приводит к OOM. UUID пишите только в span attributes или в лог-поля.
Как связать логи и трейсы в Grafana?
otelslog добавляет trace_id в каждый лог внутри активного спана. В Grafana Loki datasource настройте derivedFields с regex для trace_id - клик на него откроет трейс в Tempo.
Какой сэмплинг для трейсов в production?
TraceIDRatioBased(0.1) - 10% трейсов. С ParentBased вышестоящий сервис управляет решением для всей цепочки.
RecordError автоматически ставит статус Error?
Нет - нужно явно вызвать span.SetStatus(codes.Error, msg). RecordError только добавляет событие.



.svg.webp)





