Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
🌐
Клиент-сервер
Опубликовано:
18.06.2026
Обновлено:
09.07.2026

Наблюдаемость Go-сервисов: настраиваем logs, metrics и tracing

Данил Мануйлов

Когда 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 с regex traceID=(\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 только добавляет событие.

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также