Перейти к содержанию

Observability

Как инструментовать новый код. Каждый endpoint, каждый worker, каждое публикуемое событие должны быть видны в трёх сигналах — logs, metrics, traces — и связаны общим correlation_id / trace_id. Без этого отладка prod-инцидента превращается в гадание.

Правило приёмки на PR-ревью: если ты добавил новое поведение (endpoint / worker / event handler) без инструментации — PR не мержится.

Содержание

Три сигнала

Сигнал Инструмент Бэкенд
Logs log/slog (JSON → stdout) Loki
Metrics prometheus/client_golang Prometheus
Traces OpenTelemetry SDK + OTLP Tempo / Jaeger

Все три настраиваются в cmd/server/main.go и/или в internal/{log,metrics,otel}/, передаются в сервисы/handler'ы через DI.

Logs

Логирование описано в logging.md — здесь не дублируем. Ключевые правила для observability:

  • log/slog JSON-handler в stdout.
  • Структурные поля: service, request_id, correlation_id, trace_id (при активном трейсинге), user_id (если authenticated), route.
  • PII маскируй — см. logging.md.
  • Один лог на одну ошибку — логируй на границе сервиса, не в каждом слое (см. error-handling.md).

Metrics

Prometheus endpoint

Каждый сервис выставляет /metrics на том же HTTP-сервере:

r.Handle("/metrics", promhttp.Handler())
  • Без auth (scrape идёт из Prometheus внутри кластера).
  • Не роутится gateway'ем наружу.
  • Middleware на /metrics не вешаем — access-log забьётся scrape- запросами. См. http-api.md.

Стандартные метрики

Автоматически экспортируются без твоего участия:

Метрика Тип Что измеряет
http_request_duration_seconds Histogram Латентность HTTP-запросов per route/method/status
http_requests_total Counter Число HTTP-запросов per route/method/status
go_goroutines, go_memstats_* Gauge Рантайм Go
process_cpu_seconds_total, process_resident_memory_bytes Counter/Gauge Процесс

HTTP-метрики навешиваются middleware'ом на chi-роутер, рантайм и process-метрики — через promhttp.Handler() по умолчанию.

Custom метрики

Регистрируй через prometheus/client_golang. Один registry per сервис — живёт в internal/metrics/metrics.go:

package metrics

import "github.com/prometheus/client_golang/prometheus"

var (
    UserRegistrations = prometheus.NewCounter(prometheus.CounterOpts{
        Name: "user_registrations_total",
        Help: "Number of successful user registrations.",
    })

    OutboxUnpublished = prometheus.NewGauge(prometheus.GaugeOpts{
        Name: "outbox_unpublished_rows",
        Help: "Rows in outbox table waiting to be published.",
    })

    KafkaPublishDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "kafka_publish_duration_seconds",
            Help:    "Latency of Kafka publish calls.",
            Buckets: prometheus.DefBuckets,
        },
        []string{"topic", "result"},
    )
)

func MustRegister() {
    prometheus.MustRegister(
        UserRegistrations,
        OutboxUnpublished,
        KafkaPublishDuration,
    )
}

MustRegister вызывается один раз в main.go после создания конфига.

Типы метрик

Тип Когда использовать Пример
Counter Счётчик событий, монотонно растёт user_registrations_total
Gauge Текущее значение, может падать outbox_unpublished_rows
Histogram Распределение длительностей/размеров kafka_publish_duration_seconds
Summary Почти не используем

Summary даёт клиентские quantile'ы, но в современном стеке histogram + histogram_quantile(...) в PromQL гибче. Новые метрики делай histogram'ами, не summary.

Naming

Формат: <domain>_<action>_<unit>. Требования:

  • Snake_case, всё в нижнем регистре.
  • Unit в конце имени:
  • _seconds — длительности.
  • _bytes — размеры.
  • _total — counter'ы (кроме _duration_seconds).
  • _ratio — доли от 0 до 1.
  • _count — не используем, всё _total.

Примеры:

user_registrations_total
review_create_duration_seconds
media_upload_bytes
kafka_publish_duration_seconds
outbox_unpublished_rows

Плохие имена: numReviews, reviewCreateTime, errors.

Labels

Labels — низкой cardinality. Прометей держит серию per-комбинацию label'ов; высокая cardinality = взрыв памяти.

Хорошо Плохо
endpoint (паттерн /v1/reviews/{id}, не ?id=42) user_id (миллионы значений)
status_code request_id (уникальный на запрос)
result (success/error) error_message (строка)
topic correlation_id

Правило большого пальца: если label может иметь > 100 уникальных значений — он не для метрики. Клади его в лог или span.

Helper для бизнес-событий

Частая нужда — «каждый раз, когда X, инкремент метрики + info-лог». Для этого helper:

package metrics

func BusinessEvent(ctx context.Context, name string, attrs ...slog.Attr) {
    businessEventCounter.WithLabelValues(name).Inc()
    log.FromCtx(ctx).LogAttrs(ctx, slog.LevelInfo, name, attrs...)
}

Использование:

metrics.BusinessEvent(ctx, "user.registered",
    slog.Int64("user_id", user.ID))

Traces

OpenTelemetry setup

internal/otel/setup.go:

package otel

import (
    "context"

    "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 Setup(ctx context.Context, serviceName, endpoint string) (func(context.Context) error, error) {
    exp, err := otlptracegrpc.New(ctx,
        otlptracegrpc.WithEndpoint(endpoint),
        otlptracegrpc.WithInsecure(),
    )
    if err != nil {
        return nil, fmt.Errorf("otlp exporter: %w", err)
    }

    res, err := resource.New(ctx,
        resource.WithAttributes(semconv.ServiceName(serviceName)),
    )
    if err != nil {
        return nil, fmt.Errorf("resource: %w", err)
    }

    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exp),
        sdktrace.WithResource(res),
    )
    otel.SetTracerProvider(tp)
    otel.SetTextMapPropagator(propagation.TraceContext{})

    return tp.Shutdown, nil
}

Shutdown вызывается на shutdown (см. shutdown.md) — дренирует оставшиеся span'ы в экспортёр.

Конкретный адрес OTLP-collector'а — в env (OTEL_EXPORTER_OTLP_ENDPOINT). Конкретный backend (Tempo/Jaeger) прозрачен для сервиса.

Автоматическая инструментация

Ручные span'ы ставить почти не нужно — большая часть трассировки идёт из библиотечных middleware'ов:

HTTP (сервер):

r.Use(otelhttp.NewMiddleware("service-name"))

otelhttp создаёт span на каждый request, читает traceparent header из входящего и пробрасывает в ctx.

HTTP (клиент):

client := &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)}

Исходящий запрос инжектит traceparent в headers — следующий сервис продолжит тот же trace.

pgx:

poolCfg, _ := pgxpool.ParseConfig(dsn)
poolCfg.ConnConfig.Tracer = otelpgx.NewTracer()

Каждый SQL-запрос — span.

Kafka/Watermill:

router.AddMiddleware(
    otelMiddleware.Trace(tracer),
    // ...остальные middleware
)

При publish traceparent пишется в envelope metadata (см. events.md). При consume — читается обратно в ctx.

Ручные span'ы

Добавляй только когда бизнес-логика проходит через несколько сервис-слоёв и нужен детальный tracing:

ctx, span := tracer.Start(ctx, "service.ProcessReview")
defer span.End()

span.SetAttributes(
    attribute.Int64("review.id", reviewID),
    attribute.Int64("place.id", placeID),
)

tracer берётся из глобального provider'а один раз в пакете. Имя tracer'а — логический идентификатор пакета в формате kazmaps.<service>.<layer>:

var tracer = otel.Tracer("kazmaps.review.service")

Атрибуты span'а

  • ID-поля (review.id, user.id) — можно.
  • topic, event_type, status_codeможно.
  • Email, phone, токен — нельзя. Те же правила, что для логов.
  • Длинные строки (тело запроса, payload) — нельзя, Tempo порежет или откажет.

Context propagation

Правило: context всегда первый параметр функции. Отклонения ломают трассировку, потому что ручные span'ы и otel-middleware цепляются за ctx.

// плохо
func (s *Service) Register(req Request, ctx context.Context) error

// хорошо
func (s *Service) Register(ctx context.Context, req Request) error

Correlation

Три ID, которые связывают сигналы в один запрос:

ID Где живёт
Request-Id (X-Request-Id) HTTP-header, ставит chimw.RequestID
Correlation-Id (X-Correlation-Id) HTTP-header + Kafka metadata — ULID, переживает цепочку сервисов
trace_id OpenTelemetry span context, пробрасывается через traceparent

В middleware:

r.Use(chimw.RequestID)
r.Use(correlationID)       // читает X-Correlation-Id, если нет — генерирует
r.Use(otelhttp.NewMiddleware("service-name"))

Kafka-consumer извлекает traceparent из metadata через otelMiddleware.Trace — context restores полностью. Publisher делает Inject в metadata (см. events.md).

Health endpoints

/healthz (liveness) и /readyz (readiness) — обязательны. Детали в http-api.md.

Правило: /readyz переключается в 503 при shutdown — см. shutdown.md.

Alerting

SLO-based alerting через Prometheus + Alertmanager. Типовой шаблон — multi-burn-rate alert на error rate:

  • Fast burn (за 5м / 1ч) — страница ответственного сразу.
  • Slow burn (за 30м / 6ч) — тикет на следующий рабочий день.

Конкретные SLO-дефиниции per-сервис живут в infra-репо, не в handbook. Handbook описывает как инструментовать, а не какие конкретно SLO у каждого сервиса. Про формат SLO/SLI и выбор таргетов — slo-and-budget.md.

Fast-burn шаблон

Prometheus alert rule для SLO 99.9% availability (budget 0.001):

- alert: ReviewAvailabilityFastBurn
  expr: |
    (
      sum(rate(http_requests_total{service="review",route="/v1/reviews",code=~"5.."}[5m]))
      /
      sum(rate(http_requests_total{service="review",route="/v1/reviews"}[5m]))
    ) > (14.4 * 0.001)
    and
    (
      sum(rate(http_requests_total{service="review",route="/v1/reviews",code=~"5.."}[1h]))
      /
      sum(rate(http_requests_total{service="review",route="/v1/reviews"}[1h]))
    ) > (14.4 * 0.001)
  for: 2m
  labels:
    severity: page
    service: review
  annotations:
    summary: "Review availability SLO fast burn"
    description: "Error rate {{ $value | humanizePercentage }}, burning 14.4x normal."

Условие из двух окон в AND: короткое (5m) отсеивает медленный инцидент, длинное (1h) — одноразовые спайки. 14.4 × budget = порог, при котором бюджет 30 дней сгорит за ~2 часа.

Аналогичный slow-burn — с окнами 30m/6h и порогом 6× — см. slo-and-budget.md.

Latency alert

Альтернативная формула — доля запросов медленнее таргета:

- alert: ReviewLatencyFastBurn
  expr: |
    (
      1 -
      sum(rate(http_request_duration_seconds_bucket{service="review",route="/v1/reviews",le="0.5"}[5m]))
      /
      sum(rate(http_request_duration_seconds_count{service="review",route="/v1/reviews"}[5m]))
    ) > (14.4 * 0.01)
  for: 2m
  labels:
    severity: page

Читается как: «доля запросов медленнее 500ms превысила 14.4% (для SLO 99%)».

Простые технические alert'ы

Не все alerts — SLO-based. Некоторые — про техническое состояние:

- alert: OutboxLagHigh
  expr: outbox_forwarder_lag_seconds{service=~".+"} > 60
  for: 5m
  labels: { severity: ticket }

- alert: KafkaConsumerLagHigh
  expr: kafka_consumer_lag > 10000
  for: 10m
  labels: { severity: ticket }

- alert: CircuitBreakerOpen
  expr: circuit_breaker_state > 1
  for: 5m
  labels: { severity: page }

- alert: DLQGrowing
  expr: rate(messages_poisoned_total[5m]) > 0
  for: 5m
  labels: { severity: ticket }

Такие alert'ы не требуют SLO-окна — они сигнализируют о конкретной технической проблеме, которую нужно починить вручную.

Добавил новую метрику → добавь алерт на неё. Шаблон alertrule — ../how-to/add-metric-and-alert.md.

Debugging production issue

Типовой flow, когда прилетел тикет «пользователь жалуется на 500 на /v1/reviews»:

  1. Grafana Dashboard per-сервис → смотришь аномалию на http_request_duration_seconds или http_requests_total{status="500"}.
  2. Click-through в Tempo из панели по одному trace'у в проблемном окне → открывается waterfall со всеми span'ами запроса.
  3. Tempo span показывает, где задержка: медленный SQL, retry на Kafka publish, внешний HTTP.
  4. Tempo → Loki (через общий trace_id в labels логов; либо Correlation-Id, если цепочка пересекает сервисы) → открываются логи того же запроса. Видишь ERROR, текст ошибки, идентификаторы.
  5. Логи → БД (psql с user_id из лога) → смотришь состояние сущности в момент инцидента.

Это работает только если:

  • Все три сигнала включены.
  • Correlation/trace id пробрасывается везде.
  • Логи содержат structured trace_id.
  • PII в логи не попадает (иначе их нельзя шарить в тикете).

Когда добавлять observability

Минимальный набор на любое изменение:

Изменение Что добавить
Новый HTTP endpoint Стандартные HTTP-метрики подхватятся middleware'ом. Бизнес-лог (Info на успех) — руками. Если есть значимые побочные эффекты — counter (X_total).
Новый Kafka handler Метрика events_consumed_total{topic, result} подхватится publisher/consumer middleware. Логи на WARN/ERROR. Ручной span — если handler делает > 2 внешних вызова.
Новый background worker Counter на tick-событие, gauge на backlog (если применимо), лог старта/остановки. Span на итерацию.
Новый внешний HTTP-клиент otelhttp.NewTransport — обязательно. Histogram на latency — обязательно.
Новое событие в outbox Gauge outbox_unpublished_rows уже существует. Счётчик events_published_total{topic} подхватится forwarder'ом.

Чеклист на PR — ../checklists/pr-author.md. Ревьюер проверяет, что новый код виден в трёх сигналах.

Что не делать

  • Не вешай высокоcardinality labels (user_id, request_id) на метрики.
  • Не пиши собственный trace-propagation поверх OpenTelemetry. Используй propagation.TraceContext.
  • Не логируй то, что уже собирается метриками (каждый HTTP-запрос на INFO — шум). Логи — для контекста, метрики — для трендов.
  • Не включай DEBUG-уровень в prod. См. logging.md.
  • Не делай ручной span на каждую функцию. Ставь только на бизнес- значимые операции.
  • Не клади PII в span attributes. Те же правила, что для логов.
  • Не открывай /metrics наружу gateway'ем. Внутренний endpoint.

См. также