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

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

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

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

// 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) // :8081

g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error { return ignoreClosed(businessSrv.ListenAndServe()) })
g.Go(func() error { return ignoreClosed(managementSrv.ListenAndServe()) })
g.Go(func() error {
    <-gCtx.Done()
    shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    _ = businessSrv.Shutdown(shutCtx)
    return managementSrv.Shutdown(shutCtx)
})
if err := g.Wait(); err != nil {
    log.ErrorContext(ctx, "server_stopped", slog.String("error", err.Error()))
}

// штатная остановка сервера возвращает http.ErrServerClosed — это не ошибка
func ignoreClosed(err error) error {
    if errors.Is(err, http.ErrServerClosed) {
        return nil
    }
    return err
}

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 — только то, что меняется по смыслу. Объявлять orders_created_total второй раз — например, ещё раз в модуле заказов без общих labels — нельзя: promauto при повторном имени паникует duplicate metrics collector registration. Одна метрика — одно объявление.

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.NewMiddleware("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, файловые дескрипторы) всё уже есть в стандартных коллекторах, и в реестре по умолчанию они уже зарегистрированы: promhttp.Handler() отдаёт go_* и process_* без единой строки кода. Регистрировать их повторно нельзя — prometheus.MustRegister(collectors.NewGoCollector()) на реестре по умолчанию паникует с duplicate metrics collector registration. Явная регистрация нужна только со своим реестром:

// internal/platform/metrics/setup.go
func NewRegistry() *prometheus.Registry {
    reg := prometheus.NewRegistry()
    reg.MustRegister(
        collectors.NewGoCollector(),       // горутины, GC паузы, heap
        collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), // CPU, FD
    )
    return reg
}
// тогда /metrics отдаёт promhttp.HandlerFor(reg, promhttp.HandlerOpts{}),
// а свои метрики объявляют через promauto.With(reg)

Для пула соединений к базе данных (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()))
}

func (c *pgxPoolCollector) Describe(ch chan<- *prometheus.Desc) {
    ch <- c.acquired
    ch <- c.idle
    ch <- c.totalConns
}

Без метода Describe тип не реализует prometheus.Collector, и MustRegister его не примет.

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

МетрикаЧто показывает
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 (
    // ordersCreated объявлен выше, в common.go, уже с общими labels

    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)
    }

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

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

  • Counter — только растёт: orders_created_total, payment_failed_total.
  • Gauge — текущее значение: размер очереди, активные сессии.
  • Histogram — распределение по заранее заданным корзинам: суммы заказов, время обработки; процентили считает Prometheus.
  • Summary — процентили считает сам клиент; по инстансам они не складываются, поэтому в сервисах почти не используют.

CounterVec, GaugeVec, HistogramVec — не отдельные типы, а те же метрики с разрезом по label.

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

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

Имя метрикиТипЧто говорит имя
orders_created_totalCounterсуффикс _total обязателен
payment_duration_secondsHistogramвремя — в секундах
order_amount_rubHistogramединица — валюта
pgx_pool_acquired_connsGaugeне Counter, поэтому без _total

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

Имя метрикиЧто не так
orderCreatedCountcamelCase, и нет _total
paymentTimeнет единицы измерения
orderAmountнет единицы измерения

Низкая cardinality в labelsспросят на собеседовании

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

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

Правильно:

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

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

ordersCreated.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 для рантайма — go_* и process_* уже в реестре по умолчанию; регистрировать коллекторы руками только в своём prometheus.NewRegistry().
  • Бизнес-метрики живут рядом с хендлером модуля, не в центральном файле.
  • promauto.NewCounterVec регистрирует метрику автоматически — не нужен явный Register.
  • Имена: snake_case, суффикс с единицей (_total, _seconds, _rub).
  • Label — категория с десятками значений максимум; уникальные ID → OOM.

Что пощупать

Гистограмма времени ответа http_server_request_duration_seconds в стартовом каталоге практикума remodov/marketplace-system-go считается chi-middleware: метки service, method, route и status, причём route берётся из RoutePattern после маршрутизации, а не из сырого URL, чтобы идентификаторы товаров не плодили ряды; /metrics отдаёт promhttp, трассы уходят в OTLP через OpenTelemetry с сэмплером ParentBased(TraceIDRatioBased) по доле из настроек.

Код: observability.go, observability_test.go.

Сделаем сами

Ветка step-15-delivery-and-observability — /metrics не смонтирован, сэмплер роняет все трассы; тест метрик и тест сэмплера красные.

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