Observability
Как инструментовать новый код. Каждый endpoint, каждый worker, каждое
публикуемое событие должны быть видны в трёх сигналах — logs,
metrics, traces — и связаны общим correlation_id /
trace_id. Без этого отладка prod-инцидента превращается в гадание.
Правило приёмки на PR-ревью: если ты добавил новое поведение (endpoint / worker / event handler) без инструментации — PR не мержится.
Содержание
- Три сигнала
- Logs
- Metrics
- Traces
- Контроль cardinality метрик
- PII и secrets в traces (SQL-плейсхолдеры)
- Correlation
- Health endpoints
- Alerting
- Debugging production issue
- Когда добавлять observability
- Что не делать
- См. также
Три сигнала
| Сигнал | Инструмент | Бэкенд |
|---|---|---|
| Logs | log/slog (JSON → stdout) | Loki |
| Metrics | prometheus/client_golang | Prometheus |
| Traces | OpenTelemetry SDK + OTLP | Tempo / Jaeger |
Все три настраиваются в cmd/server/main.go и/или в
internal/{log,metrics,otel}/, передаются в сервисы/handler’ы через DI.
Logs
Логирование описано в logging — здесь не дублируем.
Ключевые правила для observability:
log/slogJSON-handler в stdout.- Структурные поля:
service,request_id,correlation_id,trace_id(при активном трейсинге),user_id(если authenticated),route. - PII маскируй — см.
logging. - Один лог на одну ошибку — логируй на границе сервиса, не в каждом
слое (см.
error-handling).
Metrics
Prometheus endpoint
Каждый сервис выставляет /metrics на том же HTTP-сервере:
r.Handle("/metrics", promhttp.Handler())- Без auth (scrape идёт из Prometheus внутри кластера).
- Не роутится gateway’ем наружу.
- Middleware на
/metricsне вешаем — access-log забьётся scrape- запросами. См.http-api.
Стандартные метрики
Автоматически экспортируются без твоего участия:
| Метрика | Тип | Что измеряет |
|---|---|---|
http_request_duration_seconds | Histogram | Латентность HTTP-запросов per route/method/status |
http_requests_total | Counter | Число HTTP-запросов per route/method/status |
go_goroutines, go_memstats_* | Gauge | Рантайм Go |
process_cpu_seconds_total, process_resident_memory_bytes | Counter/Gauge | Процесс |
HTTP-метрики навешиваются middleware’ом на chi-роутер, рантайм и
process-метрики — через promhttp.Handler() по умолчанию.
Custom метрики
Регистрируй через prometheus/client_golang. Один registry per сервис —
живёт в internal/metrics/metrics.go:
package metrics
import "github.com/prometheus/client_golang/prometheus"
var (
UserRegistrations = prometheus.NewCounter(prometheus.CounterOpts{
Name: "user_registrations_total",
Help: "Number of successful user registrations.",
})
OutboxUnpublished = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "outbox_unpublished_rows",
Help: "Rows in outbox table waiting to be published.",
})
KafkaPublishDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "kafka_publish_duration_seconds",
Help: "Latency of Kafka publish calls.",
Buckets: prometheus.DefBuckets,
},
[]string{"topic", "result"},
)
)
func MustRegister() {
prometheus.MustRegister(
UserRegistrations,
OutboxUnpublished,
KafkaPublishDuration,
)
}MustRegister вызывается один раз в main.go после создания конфига.
Типы метрик
| Тип | Когда использовать | Пример |
|---|---|---|
Counter | Счётчик событий, монотонно растёт | user_registrations_total |
Gauge | Текущее значение, может падать | outbox_unpublished_rows |
Histogram | Распределение длительностей/размеров | kafka_publish_duration_seconds |
Summary | Почти не используем | — |
Summary даёт клиентские quantile’ы, но в современном стеке histogram +
histogram_quantile(...) в PromQL гибче. Новые метрики делай
histogram’ами, не summary.
Naming
Формат: <domain>_<action>_<unit>. Требования:
- Snake_case, всё в нижнем регистре.
- Unit в конце имени:
_seconds— длительности._bytes— размеры._total— counter’ы (кроме_duration_seconds)._ratio— доли от 0 до 1._count— не используем, всё_total.
Примеры:
user_registrations_total
review_create_duration_seconds
media_upload_bytes
kafka_publish_duration_seconds
outbox_unpublished_rowsПлохие имена: numReviews, reviewCreateTime, errors.
Labels
Labels — низкой cardinality. Прометей держит серию per-комбинацию label’ов; высокая cardinality = взрыв памяти.
| Хорошо | Плохо |
|---|---|
endpoint (паттерн /v1/reviews/{id}, не ?id=42) | user_id (миллионы значений) |
status_code | request_id (уникальный на запрос) |
result (success/error) | error_message (строка) |
topic | correlation_id |
Правило большого пальца: если label может иметь > 100 уникальных значений — он не для метрики. Клади его в лог или span.
Helper для бизнес-событий
Частая нужда — «каждый раз, когда X, инкремент метрики + info-лог». Для этого helper:
package metrics
func BusinessEvent(ctx context.Context, name string, attrs ...slog.Attr) {
businessEventCounter.WithLabelValues(name).Inc()
log.FromCtx(ctx).LogAttrs(ctx, slog.LevelInfo, name, attrs...)
}Использование:
metrics.BusinessEvent(ctx, "user.registered",
slog.Int64("user_id", user.ID))Traces
OpenTelemetry setup
internal/otel/setup.go:
package otel
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
func Setup(ctx context.Context, serviceName, endpoint string) (func(context.Context) error, error) {
exp, err := otlptracegrpc.New(ctx,
otlptracegrpc.WithEndpoint(endpoint),
otlptracegrpc.WithInsecure(),
)
if err != nil {
return nil, fmt.Errorf("otlp exporter: %w", err)
}
res, err := resource.New(ctx,
resource.WithAttributes(semconv.ServiceName(serviceName)),
)
if err != nil {
return nil, fmt.Errorf("resource: %w", err)
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp),
sdktrace.WithResource(res),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.TraceContext{})
return tp.Shutdown, nil
}Shutdown вызывается на shutdown (см. shutdown) —
дренирует оставшиеся span’ы в экспортёр.
Конкретный адрес OTLP-collector’а — в env (OTEL_EXPORTER_OTLP_ENDPOINT).
Конкретный backend (Tempo/Jaeger) прозрачен для сервиса.
Автоматическая инструментация
Ручные span’ы ставить почти не нужно — большая часть трассировки идёт из библиотечных middleware’ов:
HTTP (сервер):
r.Use(otelhttp.NewMiddleware("service-name"))otelhttp создаёт span на каждый request, читает traceparent header
из входящего и пробрасывает в ctx.
HTTP (клиент):
client := &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)}Исходящий запрос инжектит traceparent в headers — следующий сервис
продолжит тот же trace.
pgx:
poolCfg, _ := pgxpool.ParseConfig(dsn)
poolCfg.ConnConfig.Tracer = otelpgx.NewTracer()Каждый SQL-запрос — span.
Kafka/Watermill:
router.AddMiddleware(
otelMiddleware.Trace(tracer),
// ...остальные middleware
)При publish traceparent пишется в envelope metadata (см.
events). При consume — читается обратно в ctx.
Ручные span’ы
Добавляй только когда бизнес-логика проходит через несколько сервис-слоёв и нужен детальный tracing:
ctx, span := tracer.Start(ctx, "service.ProcessReview")
defer span.End()
span.SetAttributes(
attribute.Int64("review.id", reviewID),
attribute.Int64("place.id", placeID),
)tracer берётся из глобального provider’а один раз в пакете. Имя tracer’а
— логический идентификатор пакета в формате kazmaps.<service>.<layer>:
var tracer = otel.Tracer("kazmaps.review.service")Атрибуты span’а
- ID-поля (
review.id,user.id) — можно. topic,event_type,status_code— можно.- Email, phone, токен — нельзя. Те же правила, что для логов.
- Длинные строки (тело запроса, payload) — нельзя, Tempo порежет или откажет.
Context propagation
Правило: context всегда первый параметр функции. Отклонения ломают
трассировку, потому что ручные span’ы и otel-middleware цепляются за
ctx.
// плохо
func (s *Service) Register(req Request, ctx context.Context) error
// хорошо
func (s *Service) Register(ctx context.Context, req Request) errorКонтроль cardinality метрик
Проблема. Случайное попадание user_id, email, request_id,
trace_id или свободного текста в Prometheus label → число активных
серий растёт неограниченно. Результат — OOM Prometheus, пропуск
scrape’ов, потеря алертов. Cardinality-инцидент обычно прилетает не с
жалобы пользователя, а с падением самого мониторинга.
Метрики для мониторинга cardinality
prometheus_tsdb_head_series— количество активных timeseries в Prometheus (головная часть TSDB).prometheus_tsdb_head_series{job="..."}— per-сервис разбивка при корректно настроенной federation / scrape-конфигурации.scrape_samples_scraped{job=X}— альтернативный индикатор: сколько samples снимается с одного scrape-таргета.
Обязательные alert-правила
Каждый сервис регистрирует в monitoring/alerts.yaml два alert’а —
по уровню и по скорости роста:
- alert: HighCardinalityService
expr: |
sum by (job) (prometheus_tsdb_head_series{job=~"kazmaps-.+"}) > 50000
for: 15m
labels: { severity: warning, team: backend }
annotations:
summary: "Service {{ $labels.job }} has > 50k active timeseries"
runbook: "https://backend-handbook.kazmaps.dev/troubleshooting/high-cardinality"
- alert: CardinalityGrowthRate
expr: |
deriv(prometheus_tsdb_head_series[1h]) > 100
for: 30m
labels: { severity: warning, team: backend }
annotations:
summary: "Rapid cardinality growth on {{ $labels.job }}"
description: "Likely a high-cardinality label added in a recent release."- Пороги: 50k активных серий на сервис — warn, 100k — critical.
Если нормальный baseline уже выше, поднимай порог до
2x baseline, не больше. «Подкрути alert, чтобы не будил» без анализа — запрещено. - Второй alert — на скорость роста.
deriv(...) > 100за 30 минут ловит ситуацию, когда в релизе кто-то добавил high-cardinality label и серий начало рождаться больше, чем в норме.
Правила при добавлении нового label
- Оцени expected cardinality ПЕРЕД merge. Оценивай верхнюю границу, не средний случай.
- Запрещены в labels:
user_id,email,request_id,trace_id, free-form message, любые идентификаторы с unbounded range. Такие атрибуты — только в логах / traces / exemplars, никогда в label’е метрики. - Допустимые labels: enum (
status,method, route pattern),version,region,pod(уже автоматически навешивается kubernetes-sd). Кардинальность одного label —≤ 100. route— это pattern (/reviews/{id}), а не substituted URL (/reviews/abc-123). Chi и аналогичные роутеры предоставляют pattern черезchi.RouteContext(ctx).RoutePattern().
PII и secrets в traces (SQL-плейсхолдеры)
Проблема. OpenTelemetry-instrumentation для pgx автоматически
добавляет span с атрибутом db.statement — полный SQL. В
parameterized-запросе SELECT ... WHERE email = $1 сам email в span
не попадёт. Но есть три дырки:
- Не-parameterized (interpolated) query — утечёт целиком, включая значения.
- Часть инструментаций логирует значения плейсхолдеров как
db.statement.parameters→ email/phone/token оказываются в trace. - Ошибка БД сама по себе может содержать значения:
duplicate key value violates unique constraint "users_email_key" DETAIL: Key (email)=(user@example.com) already exists.Если этотerror.Error()попадёт вspan.RecordError, PII улетит в Tempo.
Правила
-
Не включай
RecordErrorдля DB-ошибок без предварительного sanitization. SpanProcessor с auto-record exceptions для pgx — антипаттерн. -
Для pgx используй
otelpgxс отключёнными параметрами:tracer := otelpgx.NewTracer( otelpgx.WithAttributes(attribute.Bool("db.statement.with_parameters", false)), ) poolCfg.ConnConfig.Tracer = tracer -
Для ручных span’ов. Никогда не клади в
span.SetAttributesзначения, которые могут содержать PII. Правило: если поле может содержать данные пользователя — либо в хешированном виде (sha256[:8]), либо вообще не в trace.
Sanitizer для DB-ошибок
Принцип: не пытайся «отфильтровать» PII из err.Error()
регулярками — это гонка с форматом сообщений Postgres, которые
меняются между версиями и не всегда укладываются в один шаблон
(DETAIL: Key (email)=(...) / CONTEXT: ... value "..." /
multi-line / nested parens). Безопаснее зафиксировать белый
список того, что разрешено из *pgconn.PgError отправлять
в trace/логи — код, schema, table, constraint. Всё остальное —
только в defensive plain log с маскировкой.
import (
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgconn"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
// recordDBError кладёт в span только безопасные атрибуты из pgconn.PgError.
// Сырой err.Error() никогда не доходит до span'а и до логов на ERROR.
func recordDBError(span trace.Span, err error) {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
span.SetAttributes(
attribute.String("db.postgres.code", pgErr.Code),
attribute.String("db.postgres.schema", pgErr.SchemaName),
attribute.String("db.postgres.table", pgErr.TableName),
attribute.String("db.postgres.constraint", pgErr.ConstraintName),
)
// Sanitized sentinel: never пропускаем Message/Detail/Hint в span.
span.RecordError(fmt.Errorf("pg error %s on %s.%s: %s",
pgErr.Code, pgErr.SchemaName, pgErr.TableName, pgErr.ConstraintName))
return
}
// Не-pg ошибка — пропускаем через SDK; ответственность на уровне
// кода, который её вернул, — она не должна содержать PII.
span.RecordError(err)
}Использование:
if _, err := tx.Exec(ctx, q, args...); err != nil {
recordDBError(span, err)
return fmt.Errorf("insert review: %w", err) // оригинальный err ходит внутри сервиса
}Правила:
err.Error()Postgres-ошибки никогда не попадает в span —Message,Detail,Hint,Where,InternalQueryмогут содержать значения полей.- Не пытайся парсить
DETAIL:regex’ом. Формат меняется между версиями Postgres (9.x vs 15+), не-ASCII значения ломают простые regex, multi-lineINTERNALQUERYможет содержать SQL с литералами. - Для логов — та же логика: клади
pg_code,table,constraintкак отдельные поля. Исходныйerrможно пробрасывать вверх по стеку и использовать приerrors.Is/As; в логируемое сообщение попадают только безопасные атрибуты. - Исключение — локальная разработка (
LOG_FORMAT=text, staging-only флаг). Тамerr.Error()разрешён для удобства отладки. Никогда в prod.
Запрещённые span attributes
user.email,user.phone,user.fullnameauth.token,auth.refresh_token,Authorizationheaderrequest.body,response.body(если body содержит PII)sms.text,email.body(notification-service)
Разрешены хеши и ID: user.id (bigint — публичный идентификатор),
user.id_hash, review.id, place.id, topic, event_type,
status_code, route (pattern).
Code review правило
При ревью проверяй каждый span.SetAttributes(...) /
attribute.String(...) на PII. Это отдельный пункт в
../checklists/pr-reviewer: ревьюер не
принимает PR, в котором появились span-атрибуты с сырыми
пользовательскими данными.
Correlation
Три ID, которые связывают сигналы в один запрос:
| ID | Где живёт |
|---|---|
Request-Id (X-Request-Id) | HTTP-header, ставит chimw.RequestID |
Correlation-Id (X-Correlation-Id) | HTTP-header + Kafka metadata — ULID, переживает цепочку сервисов |
trace_id | OpenTelemetry span context, пробрасывается через traceparent |
В middleware:
r.Use(chimw.RequestID)
r.Use(correlationID) // читает X-Correlation-Id, если нет — генерирует
r.Use(otelhttp.NewMiddleware("service-name"))Kafka-consumer извлекает traceparent из metadata через
otelMiddleware.Trace — context restores полностью. Publisher делает
Inject в metadata (см. events).
Health endpoints
/healthz (liveness) и /readyz (readiness) — обязательны. Детали в
http-api.
Правило: /readyz переключается в 503 при shutdown — см.
shutdown.
Alerting
SLO-based alerting через Prometheus + Alertmanager. Типовой шаблон — multi-burn-rate alert на error rate:
- Fast burn (за 5м / 1ч) — страница ответственного сразу.
- Slow burn (за 30м / 6ч) — тикет на следующий рабочий день.
Конкретные SLO-дефиниции per-сервис живут в infra-репо, не в handbook.
Handbook описывает как инструментовать, а не какие конкретно SLO у
каждого сервиса. Про формат SLO/SLI и выбор таргетов —
slo-and-budget.
Fast-burn шаблон
Prometheus alert rule для SLO 99.9% availability (budget 0.001):
- alert: ReviewAvailabilityFastBurn
expr: |
(
sum(rate(http_requests_total{service="review",route="/v1/reviews",code=~"5.."}[5m]))
/
sum(rate(http_requests_total{service="review",route="/v1/reviews"}[5m]))
) > (14.4 * 0.001)
and
(
sum(rate(http_requests_total{service="review",route="/v1/reviews",code=~"5.."}[1h]))
/
sum(rate(http_requests_total{service="review",route="/v1/reviews"}[1h]))
) > (14.4 * 0.001)
for: 2m
labels:
severity: page
service: review
annotations:
summary: "Review availability SLO fast burn"
description: "Error rate {{ $value | humanizePercentage }}, burning 14.4x normal."Условие из двух окон в AND: короткое (5m) отсеивает медленный
инцидент, длинное (1h) — одноразовые спайки. 14.4 × budget = порог,
при котором бюджет 30 дней сгорит за ~2 часа.
Аналогичный slow-burn — с окнами 30m/6h и порогом 6× — см.
slo-and-budget.
Latency alert
Альтернативная формула — доля запросов медленнее таргета:
- alert: ReviewLatencyFastBurn
expr: |
(
1 -
sum(rate(http_request_duration_seconds_bucket{service="review",route="/v1/reviews",le="0.5"}[5m]))
/
sum(rate(http_request_duration_seconds_count{service="review",route="/v1/reviews"}[5m]))
) > (14.4 * 0.01)
for: 2m
labels:
severity: pageЧитается как: «доля запросов медленнее 500ms превысила 14.4% (для SLO 99%)».
Простые технические alert’ы
Не все alerts — SLO-based. Некоторые — про техническое состояние:
- alert: OutboxLagHigh
expr: outbox_forwarder_lag_seconds{service=~".+"} > 60
for: 5m
labels: { severity: ticket }
- alert: KafkaConsumerLagHigh
expr: kafka_consumer_lag > 10000
for: 10m
labels: { severity: ticket }
- alert: CircuitBreakerOpen
expr: circuit_breaker_state > 1
for: 5m
labels: { severity: page }
- alert: DLQGrowing
expr: rate(messages_poisoned_total[5m]) > 0
for: 5m
labels: { severity: ticket }Такие alert’ы не требуют SLO-окна — они сигнализируют о конкретной технической проблеме, которую нужно починить вручную.
Добавил новую метрику → добавь алерт на неё. Шаблон alertrule —
../how-to/add-metric-and-alert.
Debugging production issue
Типовой flow, когда прилетел тикет «пользователь жалуется на 500 на
/v1/reviews»:
- Grafana Dashboard per-сервис → смотришь аномалию на
http_request_duration_secondsилиhttp_requests_total{status="500"}. - Click-through в Tempo из панели по одному trace’у в проблемном окне → открывается waterfall со всеми span’ами запроса.
- Tempo span показывает, где задержка: медленный SQL, retry на Kafka publish, внешний HTTP.
- Tempo → Loki (через общий
trace_idв labels логов; либоCorrelation-Id, если цепочка пересекает сервисы) → открываются логи того же запроса. Видишь ERROR, текст ошибки, идентификаторы. - Логи → БД (
psqlсuser_idиз лога) → смотришь состояние сущности в момент инцидента.
Это работает только если:
- Все три сигнала включены.
- Correlation/trace id пробрасывается везде.
- Логи содержат structured
trace_id. - PII в логи не попадает (иначе их нельзя шарить в тикете).
Когда добавлять observability
Минимальный набор на любое изменение:
| Изменение | Что добавить |
|---|---|
| Новый HTTP endpoint | Стандартные HTTP-метрики подхватятся middleware’ом. Бизнес-лог (Info на успех) — руками. Если есть значимые побочные эффекты — counter (X_total). |
| Новый Kafka handler | Метрика events_consumed_total{topic, result} подхватится publisher/consumer middleware. Логи на WARN/ERROR. Ручной span — если handler делает > 2 внешних вызова. |
| Новый background worker | Counter на tick-событие, gauge на backlog (если применимо), лог старта/остановки. Span на итерацию. |
| Новый внешний HTTP-клиент | otelhttp.NewTransport — обязательно. Histogram на latency — обязательно. |
| Новое событие в outbox | Gauge outbox_unpublished_rows уже существует. Счётчик events_published_total{topic} подхватится forwarder’ом. |
Чеклист на PR — ../checklists/pr-author.
Ревьюер проверяет, что новый код виден в трёх сигналах.
Что не делать
- Не вешай высокоcardinality labels (
user_id,request_id) на метрики. - Не пиши собственный trace-propagation поверх OpenTelemetry. Используй
propagation.TraceContext. - Не логируй то, что уже собирается метриками (каждый HTTP-запрос на INFO — шум). Логи — для контекста, метрики — для трендов.
- Не включай
DEBUG-уровень в prod. См.logging. - Не делай ручной span на каждую функцию. Ставь только на бизнес- значимые операции.
- Не клади PII в span attributes. Те же правила, что для логов.
- Не открывай
/metricsнаружу gateway’ем. Внутренний endpoint.
См. также
logging— structured-logging, связка с трейсами черезtrace_id.slo-and-budget— как выбирать SLI, считать error budget, настраивать multi-window burn-rate alerts.../how-to/add-metric-and-alert— как добавить метрику и алерт.../how-to/debug-outbox-lag— типовой debugging flow через Prometheus → Tempo → Loki.../onboarding/05-observability-stack— как работать с Grafana/Prometheus/Loki/Tempo локально.../troubleshooting/memory-leak— pprof, goroutine leak, heap-анализ.../troubleshooting/db-slow-query— как по trace/metric локализовать медленный SQL.