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

Как добавить метрику и алерт

Пошаговый рецепт: объявить Prometheus-метрику в сервисе, инкрементировать её в коде, проверить что она экспонирована, и подключить alert на её поведение. Полный reference — в ../conventions/observability.md. Здесь — сжатый практический сценарий для PR-автора.

Содержание

1. Где живут метрики

В каждом сервис-репо метрики объявляются в одном пакете internal/metrics/metrics.go (определения) + register.go (MustRegister). Это owned-пакет сервиса, другие сервисы не лезут.

Один registry на сервис. Регистрация — один раз на старте в cmd/server/main.go через metrics.MustRegister().

2. Выбор типа

Тип Когда использовать Пример
Counter Монотонно растущий счётчик фактов review_created_total
Gauge Текущее значение, может расти и падать outbox_unpublished_rows
Histogram Распределение длительностей / размеров http_request_duration_seconds
Summary Почти не используем

Summary считает quantile'ы в клиенте и нельзя агрегировать между pod'ами. Histogram + histogram_quantile(...) в PromQL гибче — для новых метрик выбирай histogram.

3. Объявление метрики

// internal/metrics/metrics.go
package metrics

import (
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
)

var ReviewCreatedTotal = promauto.NewCounterVec(
    prometheus.CounterOpts{
        Name: "review_created_total",
        Help: "Total reviews created, partitioned by outcome.",
    },
    []string{"result"},
)

var ReviewCreateDuration = promauto.NewHistogramVec(
    prometheus.HistogramOpts{
        Name:    "review_create_duration_seconds",
        Help:    "Latency of CreateReview service method.",
        Buckets: prometheus.DefBuckets,
    },
    []string{"result"},
)

promauto.New* регистрирует метрику в глобальном registry автоматически — отдельного MustRegister не нужно. Если используешь кастомный registry (для теста) — бери prometheus.NewCounterVec + reg.MustRegister.

4. Naming

Reference — в ../conventions/observability.md §Naming. Кратко:

  • snake_case, всё в нижнем регистре.
  • Unit-суффикс: _seconds, _bytes, _total, _ratio.
  • Counter заканчивается на _total, кроме _duration_seconds.
  • Формат: <domain>_<action>_<unit> (например, user_registrations_total, kafka_publish_duration_seconds).

Плохо: numReviews, reviewTime, errors_count.

5. Labels

Labels — низкой cardinality. Каждая уникальная комбинация label'ов создаёт отдельную серию в TSDB; высокая cardinality = OOMKill у Prometheus.

  • Хорошо: result, endpoint, status_code, topic, event_type.
  • Плохо: user_id, request_id, correlation_id, email, error_message (произвольная строка).

Для каждой label документируй допустимые значения:

// result ∈ {"ok", "validation", "conflict", "db_error", "internal"}

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

6. Инкремент из кода

Типовой паттерн: метрика увеличивается в defer после успешной/сбойной операции, с маппингом ошибки в label.

// internal/service/review_service.go
func (s *ReviewService) Create(ctx context.Context, cmd CreateReviewCommand) (r *domain.Review, err error) {
    start := time.Now()
    defer func() {
        result := resultFromError(err)
        metrics.ReviewCreatedTotal.WithLabelValues(result).Inc()
        metrics.ReviewCreateDuration.WithLabelValues(result).Observe(time.Since(start).Seconds())
    }()

    // ... бизнес-логика
    return r, err
}

resultFromError — один helper на сервис:

// internal/metrics/result.go
func resultFromError(err error) string {
    switch {
    case err == nil:
        return "ok"
    case errors.Is(err, service.ErrValidation):
        return "validation"
    case errors.Is(err, service.ErrConflict):
        return "conflict"
    case errors.Is(err, pkgdb.ErrNotFound):
        return "not_found"
    default:
        return "internal"
    }
}

Такой helper гарантирует согласованный набор значений label'а result по всему сервису. Ревьюер может быстро проверить: «сколько значений я ожидаю у result? Столько в switch».

7. /metrics endpoint

Endpoint монтируется один раз в роутере:

r.Handle("/metrics", promhttp.Handler())

Правила (см. ../conventions/observability.md §Prometheus endpoint):

  • Не за auth-middleware — scrape идёт из Prometheus внутри кластера.
  • Не роутится Gateway'ем наружу.
  • Не вешай access-log middleware — забьёт логи scrape-запросами.

8. Тест, что метрика exposed

// internal/handler/metrics_test.go
func TestMetricsEndpoint_ExposesReviewCreated(t *testing.T) {
    handler := promhttp.Handler()

    req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
    rec := httptest.NewRecorder()
    handler.ServeHTTP(rec, req)

    require.Equal(t, http.StatusOK, rec.Code)
    body := rec.Body.String()
    require.Contains(t, body, "review_created_total")
    require.Contains(t, body, "review_create_duration_seconds")
}

Тест ловит регрессии вида «переименовал метрику, забыл обновить dashboards». Запуск — в обычном make test.

Полезный tip: testutil.ToFloat64(metric) (из prometheus/client_golang/prometheus/testutil) — прочитать текущее значение counter'а/gauge'а в тесте.

9. Добавление alert rule

Alert-правила живут в infra-репо (не в сервисе, не в handbook). Handbook описывает формат. Типовое правило:

# infra-repo: alerts/review.yml
groups:
  - name: review
    rules:
      - alert: ReviewCreateErrorRateHigh
        expr: |
          sum(rate(review_created_total{result!="ok"}[5m]))
            /
          sum(rate(review_created_total[5m]))
          > 0.05
        for: 10m
        labels:
          severity: warn
          service: review
        annotations:
          summary: "review: create error rate > 5% for 10m"
          description: |
            Over the last 5 minutes, {{ $value | humanizePercentage }}
            of review.create calls failed.
          runbook: "<runbook-url-placeholder>"

Обязательные поля alert'а:

  • expr — PromQL, без magic numbers в правой части (всегда human-readable: 0.05 для 5%, не .05).
  • for — минимум 5 минут для warn-alert'ов, 10 минут для page-alert'ов. Без for любой transient spike даст шум.
  • labels.severitypage / warn / info. Определяет, куда идёт нотификация.
  • annotations.summary — одна строка, видна в pager.
  • annotations.runbook — ссылка на runbook-страницу, которую on-call откроет в 3 часа ночи. Если runbook'а ещё нет — placeholder плюс TODO на PR в infra-репозиторий.

10. SLO-based alerts

Если метрика описывает SLI (например, доля успешных HTTP-запросов на endpoint), правильный подход — multi-burn-rate alert от SLO, а не сырой порог:

  • Fast burn (5 минут на 1 час сжечь): немедленный page.
  • Slow burn (30 минут на 6 часов сжечь): тикет на утро.

SLO-определения per-сервис живут в infra-репо, не в handbook. Здесь — ссылка: когда метрика — SLI, заводи SLO рядом с alert'ом, не только threshold.

11. Dashboards

Grafana dashboards — JSON в infra-репо. Минимум для новой метрики:

  • Timeseries panel с rate/avg новой метрики.
  • Разбивка по основным labels (обычно result).
  • Range последние 24 часа, step 1m.

Коллаба с infra-командой через PR в infra-репо. Не блокируй merge кода из-за отсутствия dashboard — создай follow-up task.

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

Часто нужно: «каждый факт = метрика + info-лог» (регистрация пользователя, upload файла). Для этого helper (см. ../conventions/observability.md):

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

Helper инкрементирует counter business_event_total{name="user.registered"} и пишет INFO-лог с теми же attrs. Используй для операций, которые хочется и в логах видеть, и в дашборде считать.

Anti-patterns

  • Метрика без Help-текста. В Grafana вместо описания — пустая строка, никто не помнит, что это за число.
  • High cardinality labels. Один user_id как label = OOMKill Prometheus'у за пару часов под нагрузкой.
  • Summary вместо Histogram. Потом в Grafana нельзя построить «p99 по всему кластеру» — summary не агрегируется.
  • Alert без for. Transient spike (один 500-й ответ в минуту) будит on-call.
  • Регистрация метрики на каждый запрос. Если promauto.New* в handler'е — при втором запросе panic duplicate metrics collector. Регистрируй один раз в init/main/package-level var.
  • Runbook не указан. Без runbook alert бесполезен — on-call не знает, что делать. Ссылка на troubleshooting/*.md достаточна.
  • Label error_message = текст ошибки. Любая вариация строки — новая серия. Используй error_class с коротким enum'ом.

Чеклист перед merge

  • Имя метрики соответствует convention (snake_case, unit-суффикс).
  • Help заполнен, на английском, короче 70 символов.
  • Labels документированы (enum возможных значений в комменте у определения метрики).
  • Cardinality каждого label — проверена (< 100 уникальных значений).
  • Есть test, что метрика exposed на /metrics.
  • Метрика инкрементируется из нужного места (handler / service / middleware — в зависимости от того, что она измеряет).
  • Если метрика требует alert — open PR в infra-репо с alert-rule.
  • Runbook-ссылка в alert'е указывает на существующую страницу (или placeholder + follow-up task).

Связанные разделы