Метрики Prometheus в Go

Лог отвечает на вопрос "что произошло с конкретным запросом". Метрика отвечает на вопрос "как система ведет себя во времени". Именно метрики обычно первыми показывают, что началась деградация: выросли 5xx, p95 latency, Kafka lag, DB pool wait или возраст последнего курса.

Prometheus хорош тем, что модель простая: time series = имя метрики + labels + samples во времени. Но эта простота обманчива: неправильные labels, buckets и alert rules быстро превращают мониторинг в дорогой шум.

Data model

Пример time series:

text
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 в имени, если единица важна.

Плохие имена:

text
latency requests db_time convert_failed

Лучше:

text
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.
SummaryQuantiles на клиенте.Редко нужен для 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:

ОбластьМетрики
APIRPS, 4xx/5xx, p50/p95/p99, request size.
PostgreSQLoperation duration, errors, pool in-use, acquire wait, locks/slow queries через exporter.
Redishit/miss ratio, operation duration, unavailable, memory.
Kafkaconsumer lag, handler duration, retry/DLQ, rebalance/errors.
Provider APIrequest duration, timeout, circuit breaker state.
Domain freshnessage последнего успешно обновленного курса.

client_golang

go
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:

go
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 используем:

text
http_requests_total http_request_duration_seconds http_requests_in_flight

Echo middleware:

go
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 middlewareRED metrics HTTP/gRPC.Echo/gRPC middleware.
RepositoryQuery duration, errors, pool wait.Postgres adapter.
Cache adapterHit/miss, operation duration, unavailable.Redis adapter.
Provider clientTimeout, status class, circuit breaker state.HTTP client wrapper.
Kafka consumerLag, handler duration, retries, DLQ.Consumer loop/handler wrapper.
UsecaseDomain outcome: stale response, fallback used.Application service boundary.

Domain layer возвращает бизнес-смысл, но не обязан знать Prometheus API. Удобный компромисс - маленький порт вроде MetricsRecorder, который реализуется в infrastructure и передается в usecase только для доменных событий.

Domain metrics

RED/USE недостаточно для продукта с курсами валют. Нужны доменные SLI:

text
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.

go
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 не взрывает кардинальность.

go
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.

go
Buckets: []float64{0.05, 0.1, 0.2, 0.3, 0.5, 1, 2.5}

Если все buckets 0.1, 1, 10, вы не сможете нормально понять, как часто запрос уложился в 300ms. Buckets - это не декор, а часть измерительной модели.

Latency можно смотреть как percentile:

promql
histogram_quantile( 0.95, sum by (le, route) ( rate(http_request_duration_seconds_bucket[5m]) ) )

Но для SLO часто лучше считать долю good events:

promql
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(), потому что он растет и сбрасывается при рестарте.

promql
sum(rate(http_requests_total[5m])) by (route) increase(http_requests_total{code=~"5.."}[1h])

Error ratio:

promql
sum(rate(http_requests_total{code=~"5.."}[5m])) / sum(rate(http_requests_total[5m]))

p95 latency:

promql
histogram_quantile( 0.95, sum by (le, route) ( rate(http_request_duration_seconds_bucket[5m]) ) )

Cache hit ratio:

promql
sum(rate(redis_cache_hits_total[5m])) / ( sum(rate(redis_cache_hits_total[5m])) + sum(rate(redis_cache_misses_total[5m])) )

Freshness:

promql
time() - ratedesk_last_successful_rate_update_timestamp_seconds

Пропавший target:

promql
absent(up{job="ratedesk"} == 1)

В Grafana используйте $__rate_interval, чтобы окно rate() подстраивалось под range и scrape interval:

promql
sum(rate(http_requests_total[$__rate_interval])) by (route)

Recording rules

Дорогие и часто используемые запросы лучше считать заранее. Это ускоряет dashboards и делает alert rules читабельнее.

yaml
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:

text
CPU > 85%

Он может быть полезным на dashboard, но сам по себе не говорит, что пользователь страдает.

Лучше alertить по симптомам:

text
5xx rate слишком долго выше нормы p95 latency нарушает SLO freshness курса вышла за допустимое окно

SLO: 99.9% availability за 30 дней. Error budget = 0.1% = 0.001.

Burn rate:

promql
error_rate / error_budget

Если burn rate = 10, вы тратите бюджет в 10 раз быстрее нормы.

Multi-window multi-burn-rate alert:

yaml
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-моделью:

yaml
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 добавьте:

  1. /metrics.
  2. RED metrics для HTTP.
  3. DB/Redis/Kafka/provider metrics на границах adapters.
  4. Freshness metric для последнего курса.
  5. Dashboard as code.
  6. 2-3 recording rules.
  7. 2 alert rules с runbook_url.
  8. README-раздел про допустимые labels.
  9. Тесты на 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.

Интерактивная практика

Quiz+10 XP

Почему нельзя добавлять user_id или raw URL в Prometheus label?

  • Prometheus не поддерживает строки в labels
  • Это создаёт высокую кардинальность и раздувает series/index
  • Labels видны только в Grafana, а не в Prometheus
  • Так нельзя построить histogram
Predict+15 XP

Что выведет этот код?

go
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")) }
Задача+20 XP

Реализуй MetricKind: общее число запросов - counter, длительность запроса - histogram, текущие in-flight запросы - gauge.