Когда сервис ведёт себя странно под нагрузкой — медленно отвечает, падает с ошибками, — без метрик приходится гадать. Метрики — это числа, которые сервис сам о себе сообщает: сколько запросов в секунду, какая доля заканчивается ошибкой, сколько времени уходит на ответ. 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-метрикам.