Observability¶
Как инструментовать новый код. Каждый endpoint, каждый worker, каждое
публикуемое событие должны быть видны в трёх сигналах — logs,
metrics, traces — и связаны общим correlation_id /
trace_id. Без этого отладка prod-инцидента превращается в гадание.
Правило приёмки на PR-ревью: если ты добавил новое поведение (endpoint / worker / event handler) без инструментации — PR не мержится.
Содержание¶
- Три сигнала
- Logs
- Metrics
- Traces
- 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.md — здесь не дублируем.
Ключевые правила для observability:
log/slogJSON-handler в stdout.- Структурные поля:
service,request_id,correlation_id,trace_id(при активном трейсинге),user_id(если authenticated),route. - PII маскируй — см.
logging.md. - Один лог на одну ошибку — логируй на границе сервиса, не в каждом
слое (см.
error-handling.md).
Metrics¶
Prometheus endpoint¶
Каждый сервис выставляет /metrics на том же HTTP-сервере:
- Без auth (scrape идёт из Prometheus внутри кластера).
- Не роутится gateway'ем наружу.
- Middleware на
/metricsне вешаем — access-log забьётся scrape- запросами. См.http-api.md.
Стандартные метрики¶
Автоматически экспортируются без твоего участия:
| Метрика | Тип | Что измеряет |
|---|---|---|
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...)
}
Использование:
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.md) —
дренирует оставшиеся span'ы в экспортёр.
Конкретный адрес OTLP-collector'а — в env (OTEL_EXPORTER_OTLP_ENDPOINT).
Конкретный backend (Tempo/Jaeger) прозрачен для сервиса.
Автоматическая инструментация¶
Ручные span'ы ставить почти не нужно — большая часть трассировки идёт из библиотечных middleware'ов:
HTTP (сервер):
otelhttp создаёт span на каждый request, читает traceparent header
из входящего и пробрасывает в ctx.
HTTP (клиент):
Исходящий запрос инжектит traceparent в headers — следующий сервис
продолжит тот же trace.
pgx:
Каждый SQL-запрос — span.
Kafka/Watermill:
При publish traceparent пишется в envelope metadata (см.
events.md). При 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>:
Атрибуты 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
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.md).
Health endpoints¶
/healthz (liveness) и /readyz (readiness) — обязательны. Детали в
http-api.md.
Правило: /readyz переключается в 503 при shutdown — см.
shutdown.md.
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.md.
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.md.
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.md.
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.md.
Ревьюер проверяет, что новый код виден в трёх сигналах.
Что не делать¶
- Не вешай высокоcardinality labels (
user_id,request_id) на метрики. - Не пиши собственный trace-propagation поверх OpenTelemetry. Используй
propagation.TraceContext. - Не логируй то, что уже собирается метриками (каждый HTTP-запрос на INFO — шум). Логи — для контекста, метрики — для трендов.
- Не включай
DEBUG-уровень в prod. См.logging.md. - Не делай ручной span на каждую функцию. Ставь только на бизнес- значимые операции.
- Не клади PII в span attributes. Те же правила, что для логов.
- Не открывай
/metricsнаружу gateway'ем. Внутренний endpoint.
См. также¶
logging.md— structured-logging, связка с трейсами черезtrace_id.slo-and-budget.md— как выбирать SLI, считать error budget, настраивать multi-window burn-rate alerts.../how-to/add-metric-and-alert.md— как добавить метрику и алерт.../how-to/debug-outbox-lag.md— типовой debugging flow через Prometheus → Tempo → Loki.../onboarding/05-observability-stack.md— как работать с Grafana/Prometheus/Loki/Tempo локально.../troubleshooting/memory-leak.md— pprof, goroutine leak, heap-анализ.../troubleshooting/db-slow-query.md— как по trace/metric локализовать медленный SQL.