31. API observability

Observability в сетевом API на Go: логи, метрики RED и стандарт OpenTelemetry

«Отладка кода вдвое сложнее его написания. Поэтому, если вы пишете код максимально заумно, вы по определению недостаточно умны, чтобы его отладить».
— Брайан Керниган

Когда вы разрабатываете монолитную утилиту на локальной машине, арсенал отладки предельно прост: достаточно вызвать fmt.Println() или запустить log.Printf(). Если алгоритм сбоит, вы открываете терминал (STDOUT) и беглым взглядом находите строчку ошибки.

Однако в распределенной микросервисной среде с балансировщиками нагрузки, пограничными API-шлюзами, брокерами сообщений и динамическими подами (Pods) в Kubernetes этот подход терпит крах. Когда клиент из другой страны жалуется в поддержку: «Нажатие кнопки оплаты выдает ошибку», а 50 реплик ваших сервисов синхронно выплевывают 10 гигабайт неструктурированного текста в минуту, попытка локализовать причину сбоя через консольный grep превращается в безнадежный поиск иголки в пылающем стоге сена.

Observability (Наблюдаемость) — это фундаментальное свойство системы, определяющее, насколько точно вы можете восстановить внутреннее скрытое состояние сервисов, анализируя исключительно их внешние выходные артефакты. Это не просто установка панели в Grafana, а инженерная культура проектирования кода.


1. Управление вслепую: Почему логов недостаточно

Представьте монитор жизненных показателей в отделении интенсивной терапии. Врачу недостаточно раз в смену читать текстовую запись в журнале: «Пациент пожаловался на боль».

В реанимации используется триада приборов:

  1. Метрики (Metrics) — «Что-то сломалось?»: Кардиомонитор непрерывно фиксирует пульс, артериальное давление и сатурацию кислорода. Если пульс падает ниже 40 — сирена тревоги звенит мгновенно, до того как пациент потеряет сознание.
  2. Трассировка (Tracing) — «Где именно сломалось?»: Введение контрастного вещества в кровеносную систему показывает точный путь крови через клапаны сердца, мозг и почки, мгновенно подсвечивая место закупорки сосуда.
  3. Логи (Logs) — «Почему это произошло?»: Запись хирурга с точным описанием конкретного надреза и параметров ткани в операционной палате.
flowchart TD
    subgraph Triad ["Триада Observability"]
        M["1. Метрики (Prometheus)<br>Числа во времени • Агрегаты<br>Симптом: 'Latency p99 взлетела до 3с'"]
        T["2. Трассировка (OpenTelemetry / Jaeger)<br>Сквозной граф вызовов по сети<br>Локализация: 'Запрос завис в billing-db'"]
        L["3. Логи (log/slog / Loki)<br>Детальный контекст события<br>Причина: 'connection pool exhausted'"]
    end

    M -. "Алерт на дашборде" .-> T
    T -. "Связь через Trace ID" .-> L

    style Triad fill:#0f172a,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    style M fill:#1e293b,stroke:#10b981,stroke-width:2px,color:#f8fafc
    style T fill:#1e293b,stroke:#6366f1,stroke-width:2px,color:#f8fafc
    style L fill:#1e293b,stroke:#f59e0b,stroke-width:2px,color:#f8fafc
  

2. Логирование: Переход к структурированным данным (Structured Logs)

Исторически программы на Go писали лог как сырую неструктурированную строку:

Text
2023-10-25 15:00:00 ERROR: user 123 failed to pay order 456 because of timeout

Парсить миллионы таких строк регулярными выражениями в ElasticSearch или Loki — архитектурная катастрофа. Стоит разработчику в следующем коммите заменить слово timeout на deadline exceeded, как все настроенные алерты и аналитические дашборды компании рухнут.

Идиоматичный подход Senior-инженера — Структурированное логирование (Structured Logging). Лог обязан быть машиночитаемым JSON-документом, в котором служебные бизнес-атрибуты строго отделены от текста сообщения.

Go 1.21+ и пакет log/slog

Долгое время стандартом высокой производительности в Go оставались сторонние библиотеки uber-go/zap и rs/zerolog. Начиная с Go 1.21, экосистема получила нативное решение промышленного уровня прямо в стандартной библиотеке — пакет log/slog.

Go
package main

import (
    "log/slog"
    "os"
)

func main() {
    // Настраиваем структурированный вывод в формате JSON в STDOUT
    logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelInfo,
    }))
    slog.SetDefault(logger)

    // Идиоматичная запись ошибки в slog
    slog.Error("payment processing failed",
        slog.String("user_id", "user-123"),
        slog.Int("order_id", 456),
        slog.String("reason", "timeout"),
    )
}

В потоке вывода это формирует безупречный компактный JSON:

JSON
{"time":"2026-09-14T07:45:00Z","level":"ERROR","msg":"payment processing failed","user_id":"user-123","order_id":456,"reason":"timeout"}

3. Метрики: Метод RED и Prometheus

Хранение логов стоит дорого. Сохранять лог-запись на каждый из каждого успешного 200 OK запроса (или 100 000 RPS) невозможно — терабайты дисков переполнятся за считанные часы.

Для непрерывной оценки здоровья и пропускной способности системы используются Метрики (Metrics).
Индустриальным стандартом в Kubernetes и облачных средах является Prometheus. Он работает по Pull-модели: Go-сервис хранит в оперативной памяти легковесные атомарные счетчики, а агент Prometheus раз в 15 секунд опрашивает HTTP-эндпоинт /metrics, мгновенно скрапя текущие значения.

Для сетевых HTTP/gRPC API сбор метрик обязан следовать классической методологии RED (разработанной Томом Уилки):

  1. Rate (Частота): Количество запросов в секунду (RPS).
  2. Errors (Ошибки): Количество запросов, завершившихся неудачей (HTTP-статусы 5xx, см. 6. Статусы HTTP).
  3. Duration (Длительность): Время обработки запросов (гистограмма задержек, расчет перцентилей p50, p95, p99).

4. Идиоматичный Prometheus Middleware

Для сбора RED-метрик мы оборачиваем стандартный роутер Go в прозрачный Middleware с использованием официального пакета client_golang:

Go
package observability

import (
    "net/http"
    "strconv"
    "time"

    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
)

var (
    httpRequestsTotal = promauto.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_requests_total",
            Help: "Общее количество входящих HTTP-запросов",
        },
        []string{"method", "path", "status"}, // Labels (Ярлыки)
    )

    httpRequestDuration = promauto.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_request_duration_seconds",
            Help:    "Гистограмма времени выполнения запросов в секундах",
            Buckets: prometheus.DefBuckets, // Дефолтные корзины задержек
        },
        []string{"method", "path"},
    )
)

// statusResponseWriter перехватывает HTTP-статус ответа
type statusResponseWriter struct {
    http.ResponseWriter
    statusCode int
}

func (rw *statusResponseWriter) WriteHeader(code int) {
    rw.statusCode = code
    rw.ResponseWriter.WriteHeader(code)
}

// MetricsMiddleware собирает RED метрики на каждом HTTP вызове
func MetricsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()

        rw := &statusResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
        next.ServeHTTP(rw, r)

        duration := time.Since(start).Seconds()
        statusStr := strconv.Itoa(rw.statusCode)

        // Идиоматичный Go 1.22: r.Pattern возвращает нормализованный роут (/users/{id})!
        routePattern := r.Pattern
        if routePattern == "" {
            routePattern = "unmatched"
        }

        // Атомарный инкремент счетчиков Prometheus (наносекундная операция)
        httpRequestsTotal.WithLabelValues(r.Method, routePattern, statusStr).Inc()
        httpRequestDuration.WithLabelValues(r.Method, routePattern).Observe(duration)
    })
}

5. Ловушка кардинальности: High Cardinality Catastrophe

Взгляните на аргумент path в вызове WithLabelValues. Что произойдет, если передать туда сырой r.URL.Path из запроса в стиле 4. Resource oriented design, например /users/123, /users/456?

flowchart LR
    subgraph Bad ["Антипаттерн: Сырой URL (High Cardinality)"]
        direction TB
        U1["/users/1"] --> S1["Time Series 1"]
        U2["/users/2"] --> S2["Time Series 2"]
        UN["/users/1000000"] --> SN["Time Series 1 000 000!"]
        SN --> Crash["💥 OOM Kill Go Сервера и Падение Prometheus"]
    end

    subgraph Good ["Нормализация: Route Pattern"]
        direction TB
        G1["/users/1"] --> Norm["/users/{id}"]
        G2["/users/2"] --> Norm
        Norm --> S_OK["Всего 1 Time Series!"]
    end

    style Bad fill:#1e293b,stroke:#f43f5e,stroke-width:1px,color:#f8fafc
    style Good fill:#1e293b,stroke:#10b981,stroke-width:2px,color:#f8fafc
  

6. Observability как единое целое (OpenTelemetry)

Метрики показывают, что эндпоинт /orders стал отвечать за 5 секунд вместо 100 миллисекунд (Duration выросла). Логи показывают ошибку timeout in DB.
Но как связать конкретную строчку в логах с конкретной аномальной точкой на графике задержек?

Эту проблему решает сквозной идентификатор Trace ID.

В современной индустрии разрозненные логи, метрики и трейсы объединяются под зонтичным стандартом OpenTelemetry (OTel) (спецификация W3C Trace Context).
Когда внешний запрос поступает на пограничный шлюз (25. API Gateway), шлюз генерирует глобально уникальный Trace ID (например, 5b8aa5a2d2c872e8321cf373fac1e818) и передает его во внутреннюю сеть через стандартизированный заголовок traceparent:

HTTP
traceparent: 00-5b8aa5a2d2c872e8321cf373fac1e818-00f067aa0ba902b7-01

Ваш Go-бэкенд извлекает Trace ID, помещает его в контекст запроса и обязан обогатить им каждую запись структурированного лога:

flowchart TD
    Req["Входящий HTTP-запрос<br>(Заголовок traceparent)"] --> GW["API Gateway / Middleware"]

    GW -->|"Запись trace_id в context.Context"| Ctx["ctx = WithTraceID(ctx, id)"]
    GW -->|"Инкремент RED счетчика"| Prom["Prometheus Metric Store"]

    Ctx --> Business["Бизнес-логика обработчика"]

    Business -->|"slog.Error(..., slog.String('trace_id', id))"| Logs["JSON Логи (Loki / Elastic)"]
    Business -->|"Создание Span (Start/End)"| OTel["OpenTelemetry Collector (Jaeger)"]

    Logs -.->|"Мгновенный переход по trace_id"| OTel

    style Req fill:#0f172a,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    style Prom fill:#1e293b,stroke:#10b981,stroke-width:1px,color:#f8fafc
    style Logs fill:#1e293b,stroke:#f59e0b,stroke-width:1px,color:#f8fafc
    style OTel fill:#1e293b,stroke:#a855f7,stroke-width:1px,color:#f8fafc
  

7. Итог

  1. Observability — фундаментальное свойство системы, позволяющее восстановить внутреннее состояние по ее внешним артефактам без деплоя отладочных принтов.
  2. Используйте структурированное логирование (JSON) через стандартный пакет log/slog (Go 1.21+). Типизированные атрибуты slog.Attr сводят к нулю аллокации в куче, защищая Garbage Collector.
  3. Никогда не логируйте чувствительные данные (пароли, JWT, PII).
  4. Собирайте RED-метрики (Rate, Errors, Duration) через Prometheus и категорически защищайтесь от катастрофы High Cardinality, подставляя в лейблы нормализованные Route Patterns (r.Pattern), а не сырые URL с динамическими ID.
  5. Связывайте метрики, логи и распределенные спаны сквозным идентификатором Trace ID по стандарту W3C Trace Context.

Упомянув Trace ID, мы лишь слегка прикоснулись к сложнейшей механике распределенной диагностики. Как наглядно увидеть, сколько времени один клиентский вызов провел в очереди Kafka, сколько в PostgreSQL, а сколько на вычислениях в Go? Как сквозные спаны передаются сквозь границы десятков микросервисов? Эту захватывающую тему мы детально разберем в следующей статье: 32. Tracing запросов.