Skip to Content
ConventionsObservability

Observability

Как инструментовать новый код. Каждый endpoint, каждый worker, каждое публикуемое событие должны быть видны в трёх сигналах — logs, metrics, traces — и связаны общим correlation_id / trace_id. Без этого отладка prod-инцидента превращается в гадание.

Правило приёмки на PR-ревью: если ты добавил новое поведение (endpoint / worker / event handler) без инструментации — PR не мержится.

Содержание

Три сигнала

СигналИнструментБэкенд
Logslog/slog (JSON → stdout)Loki
Metricsprometheus/client_golangPrometheus
TracesOpenTelemetry SDK + OTLPTempo / Jaeger

Все три настраиваются в cmd/server/main.go и/или в internal/{log,metrics,otel}/, передаются в сервисы/handler’ы через DI.

Logs

Логирование описано в logging — здесь не дублируем. Ключевые правила для observability:

  • log/slog JSON-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_secondsHistogramЛатентность HTTP-запросов per route/method/status
http_requests_totalCounterЧисло HTTP-запросов per route/method/status
go_goroutines, go_memstats_*GaugeРантайм Go
process_cpu_seconds_total, process_resident_memory_bytesCounter/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_coderequest_id (уникальный на запрос)
result (success/error)error_message (строка)
topiccorrelation_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-ошибки никогда не попадает в spanMessage, Detail, Hint, Where, InternalQuery могут содержать значения полей.
  • Не пытайся парсить DETAIL: regex’ом. Формат меняется между версиями Postgres (9.x vs 15+), не-ASCII значения ломают простые regex, multi-line INTERNALQUERY может содержать SQL с литералами.
  • Для логов — та же логика: клади pg_code, table, constraint как отдельные поля. Исходный err можно пробрасывать вверх по стеку и использовать при errors.Is/As; в логируемое сообщение попадают только безопасные атрибуты.
  • Исключение — локальная разработка (LOG_FORMAT=text, staging-only флаг). Там err.Error() разрешён для удобства отладки. Никогда в prod.

Запрещённые span attributes

  • user.email, user.phone, user.fullname
  • auth.token, auth.refresh_token, Authorization header
  • request.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_idOpenTelemetry 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»:

  1. Grafana Dashboard per-сервис → смотришь аномалию на http_request_duration_seconds или http_requests_total{status="500"}.
  2. Click-through в Tempo из панели по одному trace’у в проблемном окне → открывается waterfall со всеми span’ами запроса.
  3. Tempo span показывает, где задержка: медленный SQL, retry на Kafka publish, внешний HTTP.
  4. Tempo → Loki (через общий trace_id в labels логов; либо Correlation-Id, если цепочка пересекает сервисы) → открываются логи того же запроса. Видишь ERROR, текст ошибки, идентификаторы.
  5. Логи → БД (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 workerCounter на tick-событие, gauge на backlog (если применимо), лог старта/остановки. Span на итерацию.
Новый внешний HTTP-клиентotelhttp.NewTransport — обязательно. Histogram на latency — обязательно.
Новое событие в outboxGauge 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.

См. также

Last updated on