Observability — метрики, логи, трейсы

Перед началом: Модули 1-10. Инструменты: Yandex Monitoring, Cloud Logging, tracing. Время: ~5 ч.

В этом модуле добавляем наблюдаемость в бэкенд чата: метрики (Prometheus/OpenTelemetry), структурированные логи (JSON с trace_id) и трейсы. Цель: видеть, где «тормозит» и что упало — до того, как об этом напишет пользователь.

🔥 Зачем это вообще нужно

В 3 часа ночи — алерт: «Error Rate 15%». Вы открете логи — 10 000 строк в минуту. Где проблема? YDB? Function? Gateway? WebSocket? Без метрик и трейсов — поиск иголки в стоге сена. Нужна Observability: метрики (Prometheus), логи (Loki), трейсы (Jaeger) — всё с trace_id.

📖 Теория

Три стула наблюдаемости

СтолпИнструментЧто даёт
МетрикиOpenTelemetry → Prometheus endpointагрегаты: RPS, latency p95/p99, error rate
ЛогиJSON в stdout → Yandex Cloud Loggingдетальный контекст: что произошло, trace_id
ТрейсыOpenTelemetry → Yandex Tracingсквозной путь запроса: handler → UseCase → YDB

Структура пакета observability

internal/observability/
├── metrics/
│   ├── http.go       # latency, коды ответов
│   ├── usecase.go    # длительность сценариев
│   ├── ydb.go        # latency, throttle, ошибки
│   └── ws.go         # активные соединения, ping
├── tracing/
│   └── provider.go   # init OTel tracer
└── logging/
    └── structured.go  # JSON-логи с trace_id

Шаг 1. Инициализация OpenTelemetry

go
// internal/observability/metrics/init.go
func Init(ctx context.Context) error {
    exporter, err := prometheus.New()
    if err != nil {
        return err
    }
    provider := metric.NewMeterProvider(metric.WithReader(exporter))
    otel.SetMeterProvider(provider)
    // Prometheus endpoint запускается автоматически
    return nil
}

В main.go: observability.Init(ctx) до запуска сервера. Это выполняется один раз на «холодный старт» и переиспользуется для всех запросов.

Шаг 2. Метрики HTTP

go
var httpDuration = otel.Meter("chat").MustFloat64Histogram(
    "http.request.duration",
    metric.WithUnit("ms"),
)

func RecordHTTP(ctx context.Context, method, path string, status int, dur time.Duration) {
    httpDuration.Record(ctx, float64(dur.Milliseconds()),
        attribute.String("method", method),
        attribute.String("path", path),
        attribute.Int("status", status),
    )
}

Оборачиваем mux middleware:

go
func MetricsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        rec := &statusRecorder{ResponseWriter: w, status: 200}
        next.ServeHTTP(rec, r)
        RecordHTTP(r.Context(), r.Method, r.URL.Path, rec.status, time.Since(start))
    })
}

Шаг 3. Метрики UseCase

go
var usecaseDuration = otel.Meter("chat").MustFloat64Histogram(
    "usecase.duration",
    metric.WithUnit("ms"),
)

func UseCaseTimer(ctx context.Context, name string) func(err error) {
    start := time.Now()
    return func(err error) {
        usecaseDuration.Record(ctx, float64(time.Since(start).Milliseconds()),
            attribute.String("usecase", name),
            attribute.Bool("error", err != nil),
        )
    }
}

Использование в любом UseCase:

go
func (uc *SendMessageUseCase) Execute(ctx context.Context, msg domain.Message) error {
    defer observability.UseCaseTimer(ctx, "send_message")(nil) // err обновится
    return uc.service.ProcessMessage(ctx, msg)
}

Шаг 4. Метрики YDB

go
var (
    ydbDuration  = otel.Meter("ydb").MustFloat64Histogram("ydb.query.duration", metric.WithUnit("ms"))
    ydbThrottled = otel.Meter("ydb").MustInt64Counter("ydb.throttled")
)

func RecordYDB(ctx context.Context, query string, dur time.Duration, err error) {
    ydbDuration.Record(ctx, float64(dur.Milliseconds()), attribute.String("query", query))
    if err != nil && isThrottlingError(err) {
        ydbThrottled.Add(ctx, 1, attribute.String("query", query))
    }
}

Рост ydb.throttled — первый сигнал, что нужны индексы или оптимизация запросов (см. модуль 6).

Шаг 5. Метрики WebSocket

go
var wsActive = otel.Meter("ws").MustInt64ObservableGauge("ws.connections.active")

type WSMonitor struct{ count int64 }

func (m *WSMonitor) Inc() { atomic.AddInt64(&m.count, 1) }
func (m *WSMonitor) Dec() { atomic.AddInt64(&m.count, -1) }
// Регистрация gauge → экспортирует count при scrape

Шаг 6. Структурированные логи (JSON)

go
type LogEntry struct {
    Time    time.Time `json:"time"`
    Level   string    `json:"level"`
    Msg     string    `json:"msg"`
    TraceID string    `json:"trace_id,omitempty"`
    UserID  string    `json:"user_id,omitempty"`
}

func Log(ctx context.Context, level, msg string, fields map[string]any) {
    entry := LogEntry{Time: time.Now(), Level: level, Msg: msg}
    if span := trace.SpanFromContext(ctx); span.SpanContext().IsValid() {
        entry.TraceID = span.SpanContext().TraceID().String()
    }
    // merge fields → JSON → stdout
    json.NewEncoder(os.Stdout).Encode(entry)
}

Yandex Cloud Logging автоматически парсит JSON и строит индексы по trace_id, level, user_id.

Интеграция с Yandex Cloud

СервисЧто забираетКак
Yandex Monitoringметрики из Prometheus endpointавтоматический scrape (или ручная настройка)
Yandex LoggingJSON-логи из stdoutингестия из Container/Function
Yandex Tracingтрейсы OTelOTLP exporter → tracing.cloud.yandex.net

Все три — serverless, без управления инфраструктурой.

SLO и алерты

Минимальный набор алертов для чата:

АлертУсловиеДействие
HTTP 5xx всплескrate(http_errors[5m]) > 1%дежурный → расследование
Latency деградацияhistogram_quantile(0.99, http_duration) > 500msпроверка YDB/функций
YDB throttlingrate(ydb_throttled[5m]) > 0нужны индексы или RU
WS reconnects всплескrate(ws_reconnects[5m]) > Nпроверка WS-контейнера

SLO для MVP: p99 latency отправки < 200 мс; p99 доставки (через WS) < 3 с; uptime > 99.5%.

💱 Компромиссы

  • Метрики vs логи vs трейсы: разная степень агрегации и стоимость; метрики — дёшево и агрегированно, трейсы — детально, но дорого.
  • Trace sample rate: 100% → полная картина, но дорого; низкий rate → слепые пятна при редких багах.
  • Жёсткий vs мягкий SLO / Error Budget: жёсткий → много алертов, отвлекают; мягкий → пропускаем деградацию.
  • Структурированные JSON-логи: индексируемы и парсятся — но больше объёма и затрат на хранение.

Практика

  • Добавить http.request.duration и usecase.duration в существующий код; открыть /metrics локально.
  • Настроить Yandex Monitoring дашборд: RPS, p95/p99 latency, error rate, WS connections.
  • Сэмулировать рост ydb.throttled и проверить, что алерт срабатывает.
  • Отправить сообщение с trace_id и найти его путь: HTTP → UseCase → YDB — в Yandex Tracing.

🎓 Финал курса: Вы построили бэкенд чата: Go + YDB + Serverless + WebSocket + YMQ + Terraform. Система работает, масштабируется и наблюдаема. Теперь — фронтенд.


🧩 Проверь себя

1. Three Pillars of Observability — это...
2. Что OpenTelemetry делает в serverless backend?
3. OpenTelemetry trace_id в serverless (Functions → YDB → YMQ) — как передаётся?
4. Alerting при SLO breach — что делать?
5. SLO и Error Budget помогают...

🎓 Финал курса

Вы построили бэкенд чата: Go + YDB + Serverless + WebSocket + YMQ + Terraform. Система работает, масштабируется и наблюдаема. Теперь — фронтенд.