← назад к разделу

Когда сервис ведёт себя странно под нагрузкой — медленно отвечает, падает с ошибками, — без метрик приходится гадать. Метрики — это числа, которые сервис сам о себе сообщает: сколько запросов в секунду, какая доля заканчивается ошибкой, сколько времени уходит на ответ. Prometheus собирает эти числа, Grafana рисует графики. В Go-стеке для этого есть prometheus/client_golang и вспомогательная библиотека promauto.

Два порта: бизнес и management

Главная ошибка при первом подключении — повесить /metrics на тот же порт, что и бизнес-API. Это опасно: любой, кто знает адрес, увидит внутреннюю статистику сервиса. Правильный подход — отдельный management-сервер на другом порту (обычно :9090), который видит только инфраструктура.

// internal/platform/metrics/server.go
package metrics

import (
    "net/http"

    "github.com/prometheus/client_golang/prometheus/promhttp"
)

func StartManagement(addr string) *http.Server {
    mux := http.NewServeMux()
    mux.Handle("/metrics", promhttp.Handler())
    mux.HandleFunc("/health/live", liveHandler)
    mux.HandleFunc("/health/ready", readyHandler)
    return &http.Server{Addr: addr, Handler: mux}
}

В main.go запускаем оба сервера одновременно через errgroup:

businessSrv := &http.Server{Addr: cfg.Addr, Handler: router}
managementSrv := metrics.StartManagement(cfg.ManagementAddr) // :9090

g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return businessSrv.ListenAndServe() })
g.Go(func() error { return managementSrv.ListenAndServe() })
if err := g.Wait(); err != nil {
    log.ErrorContext(ctx, "server_stopped", slog.String("error", err.Error()))
}

Prometheus scraper каждые 15 секунд обращается к /metrics на management-порту и складывает данные в свою базу. Бизнес-порт он не видит.

Стандартные labels один раз

У каждого сервиса есть стандартные атрибуты: имя (service), окружение (env), версия (version). Их нужно добавить ко всем метрикам — но не копировать в каждый With-вызов. Создаём их один раз при старте через prometheus.Labels и применяем через MustCurryWith:

// internal/platform/metrics/common.go
var commonLabels = prometheus.Labels{
    "service": env.ServiceName,
    "env":     env.AppEnv,
    "version": env.Version,
}

var ordersCreatedTotal = promauto.NewCounterVec(prometheus.CounterOpts{
    Name: "orders_created_total",
    Help: "Orders successfully created",
}, []string{"service", "env", "version", "payment_method"})

// фиксируем общие labels один раз:
var ordersCreated = ordersCreatedTotal.MustCurryWith(commonLabels)

// в хендлере — только бизнес-label:
ordersCreated.With(prometheus.Labels{"payment_method": "CARD"}).Inc()

Так в каждом вызове нет повторения service/env/version — только то, что меняется по смыслу.

RED-метрики через middleware

RED — три вопроса о здоровье HTTP-сервиса:

  • Rate — сколько запросов в секунду?
  • Errors — какой процент заканчивается ошибкой?
  • Duration — как долго отвечает?

Лучший способ собрать их — один middleware, который оборачивает все маршруты. Важная деталь: path нужно брать из chi route pattern (/orders/{id}), а не из сырого URL (/orders/abc123). Иначе каждый уникальный ID создаст отдельный time series, и через неделю их будут миллионы.

// internal/platform/middleware/metrics.go
var (
    httpRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
        Name: "http_requests_total",
        Help: "Total HTTP requests by method, path and status class",
    }, []string{"method", "path", "status_class"})

    httpRequestDurationSeconds = promauto.NewHistogramVec(prometheus.HistogramOpts{
        Name:    "http_request_duration_seconds",
        Help:    "HTTP request latency",
        Buckets: prometheus.DefBuckets,
    }, []string{"method", "path", "status_class"})
)

func Metrics(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ww := chimiddleware.NewWrapResponseWriter(w, r.ProtoMajor)
        start := time.Now()
        next.ServeHTTP(ww, r)

        path := chi.RouteContext(r.Context()).RoutePattern()
        status := statusClass(ww.Status())
        httpRequestsTotal.WithLabelValues(r.Method, path, status).Inc()
        httpRequestDurationSeconds.WithLabelValues(r.Method, path, status).Observe(time.Since(start).Seconds())
    })
}

func statusClass(code int) string {
    switch {
    case code < 400:
        return "success"
    case code < 500:
        return "client_error"
    default:
        return "server_error"
    }
}

Middleware подключается в роутер после того, как chi успел разобрать path:

r := chi.NewRouter()
r.Use(RequestID)
r.Use(otelhttp.Middleware("order-service"))
r.Use(Metrics) // после otelhttp — span уже есть
r.Use(chimiddleware.Logger)

PromQL-запросы для дашборда:

# RPS по маршруту
sum(rate(http_requests_total[5m])) by (path, method)

# доля ошибок 5xx
sum(rate(http_requests_total{status_class="server_error"}[5m])) by (path)
  / sum(rate(http_requests_total[5m])) by (path)

# p95 латентность
histogram_quantile(0.95,
  sum by (le, path) (rate(http_request_duration_seconds_bucket[5m]))
)

USE-метрики — runtime и пул соединений

USE — три вопроса о ресурсах:

  • Utilization — насколько ресурс занят?
  • Saturation — есть ли очередь ожидающих?
  • Errors — есть ли сбои?

Для Go-рантайма (горутины, GC, heap) и процесса (CPU, файловые дескрипторы) всё уже есть в стандартных коллекторах:

// internal/platform/metrics/setup.go
func RegisterCollectors() {
    prometheus.MustRegister(
        collectors.NewGoCollector(),       // горутины, GC паузы, heap
        collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), // CPU, FD
    )
}

Для пула соединений к базе данных (pgx) коллектора из коробки нет, но написать его просто:

type pgxPoolCollector struct {
    pool       *pgxpool.Pool
    acquired   *prometheus.Desc
    idle       *prometheus.Desc
    totalConns *prometheus.Desc
}

func NewPgxPoolCollector(pool *pgxpool.Pool, service string) *pgxPoolCollector {
    labels := prometheus.Labels{"service": service}
    return &pgxPoolCollector{
        pool:       pool,
        acquired:   prometheus.NewDesc("pgx_pool_acquired_conns", "Acquired connections", nil, labels),
        idle:       prometheus.NewDesc("pgx_pool_idle_conns", "Idle connections", nil, labels),
        totalConns: prometheus.NewDesc("pgx_pool_total_conns", "Total connections", nil, labels),
    }
}

func (c *pgxPoolCollector) Collect(ch chan<- prometheus.Metric) {
    stat := c.pool.Stat()
    ch <- prometheus.MustNewConstMetric(c.acquired, prometheus.GaugeValue, float64(stat.AcquiredConns()))
    ch <- prometheus.MustNewConstMetric(c.idle, prometheus.GaugeValue, float64(stat.IdleConns()))
    ch <- prometheus.MustNewConstMetric(c.totalConns, prometheus.GaugeValue, float64(stat.TotalConns()))
}

Ключевые метрики из коллекторов:

МетрикаЧто показывает
go_goroutinesнасыщенность горутинами
go_gc_duration_secondsпаузы сборщика мусора
go_memstats_heap_inuse_bytesиспользование heap
process_open_fdsоткрытые файловые дескрипторы
pgx_pool_acquired_connsактивные соединения к БД
pgx_pool_total_connsразмер пула (насыщенность)

Бизнес-метрики рядом с хендлером

Системные метрики отвечают на вопрос «как работает сервис», но не «что происходит в бизнесе». Сколько заказов создано? Какой процент подтверждений провалился? Для этого добавляют бизнес-метрики — счётчики и гистограммы, которые живут рядом с кодом конкретного модуля:

// internal/order/metrics.go
var (
    ordersCreatedTotal = promauto.NewCounterVec(prometheus.CounterOpts{
        Name: "orders_created_total",
        Help: "Orders successfully created",
    }, []string{"payment_method"})

    orderAmountRub = promauto.NewHistogram(prometheus.HistogramOpts{
        Name:    "order_amount_rub",
        Help:    "Order amount in rubles",
        Buckets: []float64{100, 500, 1000, 5000, 10000, 50000},
    })

    orderConfirmFailedTotal = promauto.NewCounterVec(prometheus.CounterOpts{
        Name: "order_confirm_failed_total",
        Help: "Order confirmation failures by reason",
    }, []string{"reason"})
)

Применение в хендлере:

func (h *CreateOrderHandler) Handle(ctx context.Context, cmd CreateOrderCommand) (*Order, error) {
    order, err := domain.NewOrder(cmd)
    if err != nil {
        return nil, fmt.Errorf("create order: %w", err)
    }
    if err := h.orders.Save(ctx, order); err != nil {
        return nil, fmt.Errorf("save order: %w", err)
    }

    ordersCreatedTotal.WithLabelValues(string(cmd.PaymentMethod)).Inc()
    orderAmountRub.Observe(float64(order.AmountMinor) / 100)
    return order, nil
}

Четыре типа метрик в Prometheus:

  • Counter — только растёт: orders_created_total, payment_failed_total.
  • Gauge — текущее значение: размер очереди, активные сессии.
  • Histogram — распределение значений: суммы заказов, время обработки.
  • CounterVec / HistogramVec — то же, но с label-разрезами.

Имена метрик: snake_case и единица измерения

Prometheus ожидает имена в snake_case, а единицу измерения — в суффиксе. Это нужно для совместимости с готовыми дашбордами и алертами:

orders_created_total          — Counter (суффикс _total обязателен)
payment_duration_seconds      — Histogram (время в секундах)
order_amount_rub              — Histogram (единица валюты)
pgx_pool_acquired_conns       — Gauge (не Counter — нет _total)

Частые ошибки:

orderCreatedCount    — нарушение: camelCase, нет _total
paymentTime          — нарушение: нет единицы измерения
orderAmount          — нарушение: нет единицы измерения

Низкая cardinality в labels

Prometheus хранит отдельный time series на каждую уникальную комбинацию label-значений. Если в label попадает UUID пользователя или ID заказа — через неделю миллионы time series, сервер Prometheus падает по памяти.

Правило простое: label — это категория, а не уникальный идентификатор.

Правильно:

ordersCreatedTotal.WithLabelValues("CARD").Inc()          // payment_method: CARD/SBP/CRYPTO
httpRequestsTotal.WithLabelValues("GET", "/orders", "success").Inc() // chi route pattern
orderConfirmFailedTotal.WithLabelValues("insufficient_stock").Inc()  // reason: фиксированный набор

Неправильно:

ordersCreatedTotal.WithLabelValues(cmd.OrderID).Inc()      // уникальный UUID — OOM
httpRequestsTotal.WithLabelValues("GET", r.RequestURI, "success").Inc() // /orders/SKU-123456

Если нужно проследить за конкретным заказом или пользователем — для этого есть трассировка (distributed tracing через OTel spans). Span хранится один раз, а не как отдельный time series в базе метрик.

Коротко

  • /metrics — только на отдельном management-порту, не на бизнес-порту.
  • Стандартные labels service/env/version — один раз через MustCurryWith, не копировать в каждый With.
  • RED для HTTP — через chi-middleware; path берётся из route pattern, не из сырого URL.
  • USE для рантайма — collectors.NewGoCollector() + collectors.NewProcessCollector(...).
  • Бизнес-метрики живут рядом с хендлером модуля, не в центральном файле.
  • promauto.NewCounterVec регистрирует метрику автоматически — не нужен явный Register.
  • Имена: snake_case, суффикс с единицей (_total, _seconds, _rub).
  • Label — категория с десятками значений максимум; уникальные ID → OOM.

Что почитать дальше

  • Конфигурация observability в Go — management-порт, уровень логирования в runtime.
  • Health checks в Go — liveness/readiness на management-порту.
  • Tracing в Go — OTel spans для данных с высокой cardinality.
  • SLO и алерты в Go — multi-window burn-rate alerts по RED-метрикам.