Метрики Prometheus в Go
Лог отвечает на вопрос "что произошло с конкретным запросом". Метрика отвечает на вопрос "как система ведет себя во времени". Именно метрики обычно первыми показывают, что началась деградация: выросли 5xx, p95 latency, Kafka lag, DB pool wait или возраст последнего курса.
Prometheus хорош тем, что модель простая: time series = имя метрики + labels + samples во времени. Но эта простота обманчива: неправильные labels, buckets и alert rules быстро превращают мониторинг в дорогой шум.
Data model
Пример time series:
http_requests_total{service="ratedesk-api", method="POST", route="/convert", code="200"}
Имя говорит, что измеряем. Labels говорят, в каком разрезе. Каждая новая комбинация labels - новая серия.
Нельзя класть в labels:
request_id;trace_id;user_id;- email, телефон;
- raw URL path с id;
- полный текст ошибки;
- произвольные значения из внешних систем.
Можно:
service;env;method;- нормализованный
route; codeилиstatus_class;dependency;operation;error_kindиз ограниченного enum.
Имена и единицы
Prometheus-конвенции:
- seconds, не milliseconds:
http_request_duration_seconds; - bytes:
process_resident_memory_bytes; - counters с
_total:http_requests_total; - ratio как
0..1, а не проценты:0.995, не99.5; - base unit в имени, если единица важна.
Плохие имена:
latency
requests
db_time
convert_failed
Лучше:
http_requests_total
http_request_duration_seconds
db_operation_duration_seconds
db_operation_errors_total
ratedesk_rates_freshness_seconds
Типы метрик
| Тип | Для чего | Пример |
|---|---|---|
| Counter | Только растет, сбрасывается при рестарте. | Количество запросов, ошибок, processed events. |
| Gauge | Может расти и падать. | Active connections, queue size, goroutines. |
| Histogram | Распределение значений по buckets. | HTTP latency, DB query duration. |
| Summary | Quantiles на клиенте. | Редко нужен для multi-instance SLO. |
Для latency почти всегда берите Histogram. Summary считает quantiles на клиенте и плохо агрегируется между instance. Histogram можно суммировать по pods/instances и считать histogram_quantile.
RED, USE и Golden Signals
Для API начинайте с RED:
- Rate: сколько запросов приходит;
- Errors: сколько запросов завершается ошибкой;
- Duration: сколько времени занимает обработка.
Для ресурсов и зависимостей - USE:
- Utilization: насколько занят ресурс;
- Saturation: есть ли очередь/ожидание;
- Errors: сколько ошибок дает ресурс.
Google SRE часто формулирует похожую рамку как Four Golden Signals: latency, traffic, errors, saturation.
Для RateDesk:
| Область | Метрики |
|---|---|
| API | RPS, 4xx/5xx, p50/p95/p99, request size. |
| PostgreSQL | operation duration, errors, pool in-use, acquire wait, locks/slow queries через exporter. |
| Redis | hit/miss ratio, operation duration, unavailable, memory. |
| Kafka | consumer lag, handler duration, retry/DLQ, rebalance/errors. |
| Provider API | request duration, timeout, circuit breaker state. |
| Domain freshness | age последнего успешно обновленного курса. |
client_golang
package observability
import (
"net/http"
"strconv"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
requestsTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total HTTP requests.",
},
[]string{"method", "route", "code"},
)
requestDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request duration in seconds.",
Buckets: []float64{
0.025, 0.05, 0.1, 0.2, 0.3,
0.5, 1, 2.5, 5,
},
},
[]string{"method", "route", "code"},
)
)
func RegisterMetrics(reg *prometheus.Registry) {
reg.MustRegister(requestsTotal, requestDuration)
}
func Handler(reg *prometheus.Registry) http.Handler {
return promhttp.HandlerFor(reg, promhttp.HandlerOpts{})
}
func MetricsMiddleware(route string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
code := strconv.Itoa(rec.status)
requestsTotal.WithLabelValues(r.Method, route, code).Inc()
requestDuration.WithLabelValues(r.Method, route, code).
Observe(time.Since(start).Seconds())
})
}
В большом приложении лучше использовать свой prometheus.Registry, чтобы контролировать, какие метрики регистрируются, и легче тестировать.
При custom registry явно подключайте стандартные collectors, если хотите видеть Go/runtime/process/build info:
reg := prometheus.NewRegistry()
reg.MustRegister(
collectors.NewGoCollector(),
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
collectors.NewBuildInfoCollector(),
requestsTotal,
requestDuration,
)
http.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{
Registry: reg,
EnableOpenMetrics: true,
}))
EnableOpenMetrics нужен, если вы хотите показывать exemplars и переходить из latency bucket к конкретному trace.
Echo middleware и имена метрик
В одном проекте не должно быть одновременно http_requests_total, http_server_requests_total и api_requests_total для одного и того же события. Выберите контракт и держите его в уроках, dashboards и alerts.
Для RateDesk используем:
http_requests_total
http_request_duration_seconds
http_requests_in_flight
Echo middleware:
func EchoMetrics() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
start := time.Now()
err := next(c)
if err != nil {
c.Error(err)
}
code := strconv.Itoa(c.Response().Status)
route := c.Path()
if route == "" {
route = "unmatched"
}
method := c.Request().Method
requestsTotal.WithLabelValues(method, route, code).Inc()
requestDuration.WithLabelValues(method, route, code).
Observe(time.Since(start).Seconds())
return nil
}
}
}
Не используйте c.Request().URL.Path, иначе /users/1, /users/2, /users/3 станут разными series.
Метрики на границах Clean Architecture
Метрики не должны загрязнять domain entities. Обычно их ставят на инфраструктурных границах:
| Граница | Что мерить | Где ставить |
|---|---|---|
| Transport middleware | RED metrics HTTP/gRPC. | Echo/gRPC middleware. |
| Repository | Query duration, errors, pool wait. | Postgres adapter. |
| Cache adapter | Hit/miss, operation duration, unavailable. | Redis adapter. |
| Provider client | Timeout, status class, circuit breaker state. | HTTP client wrapper. |
| Kafka consumer | Lag, handler duration, retries, DLQ. | Consumer loop/handler wrapper. |
| Usecase | Domain outcome: stale response, fallback used. | Application service boundary. |
Domain layer возвращает бизнес-смысл, но не обязан знать Prometheus API. Удобный компромисс - маленький порт вроде MetricsRecorder, который реализуется в infrastructure и передается в usecase только для доменных событий.
Domain metrics
RED/USE недостаточно для продукта с курсами валют. Нужны доменные SLI:
ratedesk_rate_freshness_seconds{source="cbr", pair="USD_RUB"}
ratedesk_provider_requests_total{provider="cbr", outcome="success|timeout|error"}
ratedesk_provider_fallbacks_total{from="cbr", to="stale_cache"}
ratedesk_stale_responses_total{route="/convert", reason="provider_timeout"}
ratedesk_conversions_total{source="fresh|stale|fallback"}
Осторожно с labels: pair должен быть ограниченным справочником, а не произвольным пользовательским вводом. Если валютных пар сотни тысяч, такую детализацию надо пересмотреть.
Тестирование метрик
client_golang позволяет тестировать registry без поднятого Prometheus.
func TestHTTPMetrics(t *testing.T) {
reg := prometheus.NewRegistry()
requests := prometheus.NewCounterVec(
prometheus.CounterOpts{Name: "http_requests_total", Help: "Total HTTP requests."},
[]string{"method", "route", "code"},
)
reg.MustRegister(requests)
requests.WithLabelValues("POST", "/convert", "200").Inc()
if err := testutil.GatherAndCompare(reg, strings.NewReader(`
# HELP http_requests_total Total HTTP requests.
# TYPE http_requests_total counter
http_requests_total{code="200",method="POST",route="/convert"} 1
`), "http_requests_total"); err != nil {
t.Fatal(err)
}
}
Что проверять:
- route нормализован (
/users/:id, не/users/123); - нет labels
request_id,trace_id,user_id,error; - buckets содержат границу SLO, например
0.3; - counter растет на expected outcome;
- dependency metrics пишутся в adapter, а не в domain entity.
Exemplars и native histograms
Exemplar связывает конкретное наблюдение метрики с trace. Это не label series, поэтому trace_id не взрывает кардинальность.
if observer, ok := requestDuration.WithLabelValues(method, route, code).(prometheus.ExemplarObserver); ok {
observer.ObserveWithExemplar(seconds, prometheus.Labels{
"trace_id": traceIDFromContext(ctx),
})
} else {
requestDuration.WithLabelValues(method, route, code).Observe(seconds)
}
Native histograms в Prometheus 3.x уже можно рассматривать для продакшена, но для учебного SLO на 300ms explicit buckets понятнее: студент видит, почему bucket 0.3 нужен для latency SLO. Если включаете native histograms, явно документируйте настройку scrape и совместимость Grafana/Prometheus.
Buckets проектируются вокруг SLO
Если SLO говорит: "95% /convert быстрее 300ms", в buckets должна быть граница около 0.3.
Buckets: []float64{0.05, 0.1, 0.2, 0.3, 0.5, 1, 2.5}
Если все buckets 0.1, 1, 10, вы не сможете нормально понять, как часто запрос уложился в 300ms. Buckets - это не декор, а часть измерительной модели.
Latency можно смотреть как percentile:
histogram_quantile(
0.95,
sum by (le, route) (
rate(http_request_duration_seconds_bucket[5m])
)
)
Но для SLO часто лучше считать долю good events:
sum(rate(http_request_duration_seconds_bucket{route="/convert",le="0.3"}[5m]))
/
sum(rate(http_request_duration_seconds_count{route="/convert"}[5m]))
Так вы прямо измеряете: какая доля запросов уложилась в целевой порог.
PromQL минимум
Counter читается через rate() или increase(), потому что он растет и сбрасывается при рестарте.
sum(rate(http_requests_total[5m])) by (route)
increase(http_requests_total{code=~"5.."}[1h])
Error ratio:
sum(rate(http_requests_total{code=~"5.."}[5m]))
/
sum(rate(http_requests_total[5m]))
p95 latency:
histogram_quantile(
0.95,
sum by (le, route) (
rate(http_request_duration_seconds_bucket[5m])
)
)
Cache hit ratio:
sum(rate(redis_cache_hits_total[5m]))
/
(
sum(rate(redis_cache_hits_total[5m]))
+
sum(rate(redis_cache_misses_total[5m]))
)
Freshness:
time() - ratedesk_last_successful_rate_update_timestamp_seconds
Пропавший target:
absent(up{job="ratedesk"} == 1)
В Grafana используйте $__rate_interval, чтобы окно rate() подстраивалось под range и scrape interval:
sum(rate(http_requests_total[$__rate_interval])) by (route)
Recording rules
Дорогие и часто используемые запросы лучше считать заранее. Это ускоряет dashboards и делает alert rules читабельнее.
groups:
- name: ratedesk:sli
interval: 30s
rules:
- record: job:http_requests:rate5m
expr: |
sum by (job) (rate(http_requests_total[5m]))
- record: job:http_errors:ratio_rate5m
expr: |
sum by (job) (rate(http_requests_total{code=~"5.."}[5m]))
/
sum by (job) (rate(http_requests_total[5m]))
- record: job:http_errors:ratio_rate30m
expr: |
sum by (job) (rate(http_requests_total{code=~"5.."}[30m]))
/
sum by (job) (rate(http_requests_total[30m]))
- record: job:http_errors:ratio_rate1h
expr: |
sum by (job) (rate(http_requests_total{code=~"5.."}[1h]))
/
sum by (job) (rate(http_requests_total[1h]))
- record: job:http_errors:ratio_rate6h
expr: |
sum by (job) (rate(http_requests_total{code=~"5.."}[6h]))
/
sum by (job) (rate(http_requests_total[6h]))
- record: route:http_request_duration:p95_5m
expr: |
histogram_quantile(
0.95,
sum by (route, le) (
rate(http_request_duration_seconds_bucket[5m])
)
)
Alerts и burn rate
Плохой alert:
CPU > 85%
Он может быть полезным на dashboard, но сам по себе не говорит, что пользователь страдает.
Лучше alertить по симптомам:
5xx rate слишком долго выше нормы
p95 latency нарушает SLO
freshness курса вышла за допустимое окно
SLO: 99.9% availability за 30 дней. Error budget = 0.1% = 0.001.
Burn rate:
error_rate / error_budget
Если burn rate = 10, вы тратите бюджет в 10 раз быстрее нормы.
Multi-window multi-burn-rate alert:
groups:
- name: ratedesk:slo-alerts
rules:
- alert: RatedeskFastBurn
expr: |
(
job:http_errors:ratio_rate5m{job="ratedesk"} > (14.4 * 0.001)
)
and
(
job:http_errors:ratio_rate1h{job="ratedesk"} > (14.4 * 0.001)
)
for: 2m
labels:
severity: page
slo: availability
annotations:
summary: "RateDesk fast SLO burn"
runbook_url: "https://gitlab.example.com/ratedesk/docs/runbooks/high-5xx"
- alert: RatedeskSlowBurn
expr: |
(
job:http_errors:ratio_rate30m{job="ratedesk"} > (6 * 0.001)
)
and
(
job:http_errors:ratio_rate6h{job="ratedesk"} > (6 * 0.001)
)
for: 15m
labels:
severity: ticket
slo: availability
annotations:
summary: "RateDesk slow SLO burn"
runbook_url: "https://gitlab.example.com/ratedesk/docs/runbooks/high-5xx"
Dashboards
Хороший dashboard строится не "по технологиям", а по пути диагностики.
Верх:
- SLO state;
- error budget remaining;
- current burn rate;
- deploy marker.
API:
- RPS;
- 4xx/5xx split;
- p50/p95/p99;
- latency heatmap;
- top routes by traffic/errors.
Dependencies:
- DB operation duration/errors;
- DB pool in-use/acquire wait;
- Redis hit ratio and operation duration;
- Kafka lag and handler duration;
- provider timeout/error rate;
- freshness.
Drilldown:
- from p95 spike to traces via exemplars;
- from error route to logs by
request_id; - from dependency error to runbook.
Scrape vs Pushgateway
По умолчанию Prometheus работает pull-моделью:
scrape_configs:
- job_name: ratedesk
metrics_path: /metrics
static_configs:
- targets: ["ratedesk-api:8080"]
Pushgateway нужен только для service-level batch jobs, которые живут слишком мало для scrape. Не используйте его как универсальный push-вход для всех сервисов: получите stale metrics, потерю up, сложный lifecycle и дополнительную точку отказа.
Security
Не выставляйте наружу:
/metrics;- Prometheus UI;
- Alertmanager;
- Pushgateway;
- exporters с admin-информацией.
Доступ должен быть ограничен сетью, reverse proxy, VPN, mTLS или auth. И никогда не кладите secrets/PII в labels: labels индексируются, размножаются и часто живут дольше, чем кажется.
Практика
В RateDesk добавьте:
/metrics.- RED metrics для HTTP.
- DB/Redis/Kafka/provider metrics на границах adapters.
- Freshness metric для последнего курса.
- Dashboard as code.
- 2-3 recording rules.
- 2 alert rules с
runbook_url. - README-раздел про допустимые labels.
- Тесты на registry, labels и базовые counters/histograms.
Acceptance:
- Prometheus видит target
ratedesk; - p95
/convertсчитается через histogram; - имена метрик совпадают в коде, dashboards, recording rules и финальном проекте;
- labels не содержат ids, raw URL и произвольный error text;
- есть PromQL для RPS, 5xx ratio, p95, cache hit ratio, freshness;
- есть хотя бы один SLO/burn-rate alert;
- dashboard позволяет перейти от симптома к logs/traces/runbook.
Интерактивная практика
Почему нельзя добавлять user_id или raw URL в Prometheus label?
Что выведет этот код?
package main
import "fmt"
func MetricType(signal string) string {
if signal == "duration" {
return "histogram"
}
return "counter"
}
func main() {
fmt.Println(MetricType("requests_total"))
fmt.Println(MetricType("duration"))
}
Реализуй MetricKind: общее число запросов - counter, длительность запроса - histogram, текущие in-flight запросы - gauge.