Модуль 7. Observability

Цели модуля

После модуля студент сможет:

  • Настраивать structured logging через slog
  • Коррелировать запросы через request ID и trace ID
  • Собирать Prometheus-метрики для HTTP-сервиса
  • Настраивать distributed tracing через OpenTelemetry
  • Маскировать PII в логах
  • Реализовывать health checks для Kubernetes
  • Обеспечивать видимость ошибок в production

7.1 Три столпа observability

Что такое observability

Observability — способность понимать внутреннее состояние системы по её внешним выходам (логам, метрикам, трейсам).

Три столпа:

СтолпЧто отвечаетИнструменты
LogsЧто произошло?slog, zap, Loki
MetricsКаково состояние?Prometheus, Grafana
TracesГде задержка?OpenTelemetry, Jaeger

Когда что использовать

Logs:

  • Детали конкретных событий
  • Ошибки с контекстом
  • Аудит действий

Metrics:

  • Агрегированные показатели (RPS, latency, error rate)
  • Алерты
  • Дашборды

Traces:

  • Распределённые запросы через несколько сервисов
  • Поиск узких мест
  • Анализ задержек

RED метод для метрик

Для HTTP-сервиса собираем три ключевые метрики:

  • Rate: запросов в секунду
  • Errors: процент ошибок (5xx)
  • Duration: latency (p50, p95, p99)

USE метод для ресурсов

Для инфраструктуры:

  • Utilization: загрузка ресурса
  • Saturation: очередь/перегрузка
  • Errors: ошибки ресурса

7.2 Structured logging

Почему structured logging

Плоские логи (printf) сложно парсить и анализировать:

go
// ПЛОХО: неструктурированный лог
log.Printf("user %s created repo %s at %v", userID, repoID, time.Now())

Structured logging выводит JSON с полями:

go
// ПРАВИЛЬНО: структурированный лог
logger.Info("repo created",
    "user_id", userID,
    "repo_id", repoID,
    "duration", elapsed,
)

Результат в JSON:

json
{
    "time": "2026-01-15T10:30:00Z",
    "level": "INFO",
    "msg": "repo created",
    "user_id": "user-123",
    "repo_id": "repo-456",
    "duration": "45ms"
}

Легко парсить, фильтровать, агрегировать в системах логирования.

slog (Go 1.21+)

slog — стандартная библиотека structured logging в Go.

go
import "log/slog"

logger := slog.Default()

// Info с полями
logger.Info("request completed",
    "method", r.Method,
    "path", r.URL.Path,
    "status", statusCode,
    "duration", elapsed.String(),
)

// Error с ошибкой
logger.Error("database query failed",
    "error", err,
    "query", query,
    "duration", elapsed.String(),
)

// Debug для разработки
logger.Debug("cache miss", "key", cacheKey)

Уровни логирования

УровеньКогда использовать
DEBUGДетали для разработки (не в prod)
INFOНормальные операции (запуск, запросы)
WARNПодозрительные ситуации (retry, fallback)
ERRORОшибки требующие внимания

Правило: в production уровень INFO или WARN. DEBUG только при отладке.

Настройка slog

go
import (
    "log/slog"
    "os"
)

func setupLogger(level string, format string) *slog.Logger {
    var lvl slog.Level
    switch level {
    case "debug":
        lvl = slog.LevelDebug
    case "info":
        lvl = slog.LevelInfo
    case "warn":
        lvl = slog.LevelWarn
    case "error":
        lvl = slog.LevelError
    default:
        lvl = slog.LevelInfo
    }

    opts := &slog.HandlerOptions{
        Level: lvl,
    }

    var handler slog.Handler
    if format == "json" {
        handler = slog.NewJSONHandler(os.Stdout, opts)
    } else {
        handler = slog.NewTextHandler(os.Stdout, opts)
    }

    return slog.New(handler)
}

// Использование
logger := setupLogger(cfg.LogLevel, cfg.LogFormat)
slog.SetDefault(logger)

Форматы: JSON vs Text

JSON формат для production (парсится системами логирования):

text
{"time":"2026-01-15T10:30:00Z","level":"INFO","msg":"request completed","method":"GET","path":"/api/v1/repos","status":200}

Text формат для локальной разработки (читается человеком):

text
time=2026-01-15T10:30:00Z level=INFO msg="request completed" method=GET path=/api/v1/repos status=200

Правило: local/dev — text, staging/prod — json.

Что логировать

СобытиеУровеньПоля
Старт сервераINFOaddr, version
Запрос завершёнINFOmethod, path, status, duration, request_id
Ошибка запросаERRORerror, method, path, request_id
Panic recoveredERRORpanic, stack, request_id
Graceful shutdownINFOreason
Подключение к БДINFOhost (без пароля!)

Что НЕ логировать

  • Тело запроса (может содержать PII)
  • Заголовки с токенами (Authorization, Cookie)
  • Пароли, секреты
  • Email, телефоны в открытом виде

7.3 Request correlation

Зачем нужна корреляция

Один запрос проходит через:

  • Middleware
  • Handler
  • Service
  • Repository
  • Внешние сервисы

Нужно связать все логи одного запроса. Для этого используется request ID или trace ID.

Request ID middleware

go
type contextKey string

const RequestIDKey contextKey = "request_id"

func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := r.Header.Get("X-Request-ID")
        if id == "" {
            id = uuid.NewString()
        }

        ctx := context.WithValue(r.Context(), RequestIDKey, id)
        w.Header().Set("X-Request-ID", id)

        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func GetRequestID(ctx context.Context) string {
    if id, ok := ctx.Value(RequestIDKey).(string); ok {
        return id
    }
    return ""
}

Логирование с request ID

go
func Logging(logger *slog.Logger) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            start := time.Now()
            wrapped := &statusRecorder{ResponseWriter: w, statusCode: 200}

            next.ServeHTTP(wrapped, r)

            logger.Info("request completed",
                "method", r.Method,
                "path", r.URL.Path,
                "status", wrapped.statusCode,
                "duration", time.Since(start).String(),
                "request_id", GetRequestID(r.Context()),
            )
        })
    }
}

Передача request ID в сервисы

go
func (s *RepoService) GetRepo(ctx context.Context, id string) (*models.Repo, error) {
    requestID := middleware.GetRequestID(ctx)

    s.logger.Info("getting repo",
        "repo_id", id,
        "request_id", requestID,
    )

    repo, err := s.repo.GetByID(ctx, id)
    if err != nil {
        s.logger.Error("failed to get repo",
            "repo_id", id,
            "request_id", requestID,
            "error", err,
        )
        return nil, fmt.Errorf("get repo: %w", err)
    }

    return repo, nil
}

Trace ID для distributed tracing

Для распределённых систем request ID расширяется до trace ID. Trace ID проходит через все сервисы в цепочке запроса.

http
// W3C Trace Context заголовок
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

Формат: version-trace_id-parent_id-flags


7.4 Metrics (Prometheus)

Что такое метрики

Метрики — числовые показатели состояния системы. В отличие от логов, метрики агрегированы и дёшевы в хранении.

Prometheus client_golang

Установка:

bash
go get github.com/prometheus/client_golang/prometheus
go get github.com/prometheus/client_golang/prometheus/promhttp

Типы метрик

ТипНазначениеПример
CounterМонотонно растущийКоличество запросов
GaugeМожет расти и падатьТекущее количество соединений
HistogramРаспределение значенийLatency запросов
SummaryКвантили распределенияp50, p95, p99 latency

HTTP метрики

go
package metrics

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

var (
    httpRequestsTotal = promauto.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_requests_total",
            Help: "Total number of HTTP requests",
        },
        []string{"method", "path", "status"},
    )

    httpRequestDuration = promauto.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_request_duration_seconds",
            Help:    "HTTP request duration in seconds",
            Buckets: prometheus.DefBuckets,
        },
        []string{"method", "path"},
    )

    httpRequestsInFlight = promauto.NewGauge(
        prometheus.GaugeOpts{
            Name: "http_requests_in_flight",
            Help: "Number of HTTP requests currently in flight",
        },
    )
)

Middleware для метрик

go
func Prometheus(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        httpRequestsInFlight.Inc()
        defer httpRequestsInFlight.Dec()

        wrapped := &statusRecorder{ResponseWriter: w, statusCode: 200}
        next.ServeHTTP(wrapped, r)

        duration := time.Since(start).Seconds()
        path := normalizePath(r.URL.Path)  // /repos/{id} вместо /repos/123

        httpRequestsTotal.WithLabelValues(r.Method, path, strconv.Itoa(wrapped.statusCode)).Inc()
        httpRequestDuration.WithLabelValues(r.Method, path).Observe(duration)
    })
}

Экспорт метрик

go
import "github.com/prometheus/client_golang/prometheus/promhttp"

// Endpoint для Prometheus
mux.Handle("GET /metrics", promhttp.Handler())

Prometheus периодически скрейпит /metrics и сохраняет метрики.

Бизнес-метрики

Кроме HTTP-метрик, собирайте бизнес-метрики:

text
var (
    reposCreatedTotal = promauto.NewCounter(
        prometheus.CounterOpts{
            Name: "repos_created_total",
            Help: "Total number of repositories created",
        },
    )

    outboxEventsPending = promauto.NewGauge(
        prometheus.GaugeOpts{
            Name: "outbox_events_pending",
            Help: "Number of pending outbox events",
        },
    )
)

Правила для меток (labels)

  1. Низкая кардинальность: method, status, path (нормализованный)
  2. НЕ использовать: user_id, request_id, email (высокая кардинальность)
  3. Ограниченное множество значений

Высокая кардинальность взрывает storage метрик.


7.5 Tracing (OpenTelemetry)

Что такое distributed tracing

Tracing отслеживает запрос через несколько сервисов. Каждый сервис добавляет span (отрезок работы) к общему trace.

text
Trace: abc-123
├── Span: HTTP GET /api/v1/repos (50ms)
│   ├── Span: RepoService.GetRepo (45ms)
│   │   └── Span: PostgreSQL SELECT (40ms)
│   └── Span: JSON encode (2ms)

OpenTelemetry в Go

Установка:

bash
go get go.opentelemetry.io/otel
go get go.opentelemetry.io/otel/sdk/trace
go get go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp

Инициализация tracer

go
package tracing

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
)

func InitTracer(ctx context.Context, serviceName string) (*sdktrace.TracerProvider, error) {
    exporter, err := otlptrace.New(ctx, otlptracegrpc.NewClient(
        otlptracegrpc.WithEndpoint(cfg.OtelEndpoint),
    ))
    if err != nil {
        return nil, fmt.Errorf("create exporter: %w", err)
    }

    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exporter),
        sdktrace.WithResource(resource.NewWithAttributes(
            semconv.SchemaURL,
            semconv.ServiceName(serviceName),
        )),
    )

    otel.SetTracerProvider(tp)
    return tp, nil
}

Middleware для tracing

go
import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"

// Оборачиваем handler
handler := otelhttp.NewHandler(mux, "repo-service")

Создание spans в коде

go
import "go.opentelemetry.io/otel"

func (s *RepoService) GetRepo(ctx context.Context, id string) (*models.Repo, error) {
    tracer := otel.Tracer("repo-service")
    ctx, span := tracer.Start(ctx, "RepoService.GetRepo")
    defer span.End()

    span.SetAttributes(attribute.String("repo.id", id))

    repo, err := s.repo.GetByID(ctx, id)
    if err != nil {
        span.RecordError(err)
        return nil, err
    }

    return repo, nil
}

Propagation через сервисы

Trace context передаётся через HTTP заголовки (W3C Trace Context):

http
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

OpenTelemetry автоматически инжектит и извлекает traceparent при использовании otelhttp.

Когда использовать tracing

  • Микросервисная архитектура (запрос через несколько сервисов)
  • Поиск узких мест (какой сервис/запрос медленный)
  • Анализ ошибок в распределённых системах

Для монолита tracing менее критичен, но полезен для анализа latency.


7.6 PII masking

Что такое PII

PII (Personally Identifiable Information) — персональные данные:

  • Email
  • Телефон
  • ФИО
  • Адрес
  • Паспортные данные
  • IP-адрес (в некоторых юрисдикциях)

Зачем маскировать

Логи могут быть доступны широкому кругу лиц. Утечка логов = утечка PII. Требования GDPR, 152-ФЗ, HIPAA обязывают защищать персональные данные.

Маскирование через regex

go
package observability

import "regexp"

var (
    emailRegex = regexp.MustCompile(`[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`)
    phoneRegex = regexp.MustCompile(`\+?[0-9]{10,15}`)
    cardRegex  = regexp.MustCompile(`\b[0-9]{13,16}\b`)
)

func MaskPII(s string) string {
    s = emailRegex.ReplaceAllString(s, "***@***.***")
    s = phoneRegex.ReplaceAllString(s, "***-***-****")
    s = cardRegex.ReplaceAllString(s, "****-****-****-****")
    return s
}

Использование в логировании

go
logger.Info("user registered",
    "email", MaskPII(user.Email),
    "user_id", user.ID,
)

// Результат: email=***@***.***

Структурное маскирование

Для сложных случаев создайте тип MaskedString:

go
type MaskedString string

func (m MaskedString) String() string {
    return MaskPII(string(m))
}

func (m MaskedString) MarshalJSON() ([]byte, error) {
    return json.Marshal(MaskPII(string(m)))
}

// Использование
logger.Info("user action", "email", MaskedString(user.Email))

Правила работы с PII

  1. Не логируйте PII если это не необходимо
  2. Если необходимо — маскируйте
  3. Не передавайте PII в query parameters (попадают в access logs)
  4. Удаляйте PII из error messages перед логированием
  5. Настройте retention policy для логов с PII

7.7 Health checks

Зачем нужны health checks

Health checks используются:

  • Kubernetes liveness probe
  • Kubernetes readiness probe
  • Load balancer checks
  • Мониторинг доступности

/healthz — liveness

Проверяет что процесс жив. Не проверяет зависимости.

json
func (h *Handler) Healthz(w http.ResponseWriter, r *http.Request) {
    h.writeJSON(w, http.StatusOK, map[string]string{
        "status": "ok",
    })
}

Если /healthz не отвечает — Kubernetes перезапустит под.

/readyz — readiness

Проверяет что сервис готов принимать трафик.

go
func (h *Handler) Readyz(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
    defer cancel()

    // Проверяем БД
    if err := h.db.Ping(ctx); err != nil {
        h.writeJSON(w, http.StatusServiceUnavailable, map[string]interface{}{
            "status": "not ready",
            "checks": map[string]string{
                "database": "unavailable",
            },
        })
        return
    }

    h.writeJSON(w, http.StatusOK, map[string]interface{}{
        "status": "ready",
        "checks": map[string]string{
            "database": "ok",
        },
    })
}

Если /readyz не отвечает — Kubernetes исключит под из балансировки, но не перезапустит.

Разница между healthz и readyz

EndpointНазначениеЧто проверяетДействие Kubernetes
/healthzПроцесс живНичего (всегда 200)Перезапуск пода
/readyzГотов к трафикуБД, кэш, зависимостиИсключение из балансировки

Startup probe

Для медленно стартующих сервисов:

go
// /startupz — проверяет что сервис завершил инициализацию
func (h *Handler) Startupz(w http.ResponseWriter, r *http.Request) {
    if !h.isInitialized() {
        h.writeJSON(w, http.StatusServiceUnavailable, map[string]string{
            "status": "initializing",
        })
        return
    }

    h.writeJSON(w, http.StatusOK, map[string]string{
        "status": "started",
    })
}

7.8 Error visibility

Проблема

Ошибки в production должны быть видны. Если ошибка произошла, нужно знать:

  • Что произошло
  • Где произошло
  • Когда произошло
  • Какой запрос вызвал ошибку

Стратегия error visibility

  1. Все ошибки логируются с контекстом
go
logger.Error("operation failed",
   "error", err,
   "operation", "create_repo",
   "repo_id", repoID,
   "request_id", requestID,
   )
  1. Ошибки имеют метрики
go
var errorsTotal = promauto.NewCounterVec(
       prometheus.CounterOpts{
           Name: "errors_total",
           Help: "Total number of errors",
       },
       []string{"type", "operation"},
   )

   // Инкремент при ошибке
   errorsTotal.WithLabelValues("database", "create_repo").Inc()
  1. Ошибки классифицируются
go
type ErrorKind string

   const (
       ErrorKindDatabase   ErrorKind = "database"
       ErrorKindValidation ErrorKind = "validation"
       ErrorKindExternal   ErrorKind = "external"
       ErrorKindInternal   ErrorKind = "internal"
   )
  1. Алерты на критичные ошибки

    // Alert rule в Prometheus

yaml
- alert: HighErrorRate
  expr: rate(errors_total[5m]) > 0.1
  for: 5m
  labels:
  severity: critical
  annotations:
  summary: "High error rate detected"

Sentry / error tracking

Для критичных ошибок используйте error tracking сервисы (Sentry, Bugsnag):

go
import "github.com/getsentry/sentry-go"

func init() {
    sentry.Init(sentry.ClientOptions{
        Dsn:         cfg.SentryDSN,
        Environment: cfg.Environment,
    })
}

// Отправка ошибки в Sentry
if err != nil {
    sentry.CaptureException(err)
}

Привязка к шаблону проекта

Где observability в шаблоне

text
internal/observability/
├── observability.go       # Инициализация и сборка
├── logging/
│   ├── logger.go          # Настройка slog
│   ├── pii.go             # Маскирование PII
│   └── fields.go          # Стандартные поля
├── tracing/
│   ├── tracer.go          # OpenTelemetry setup
│   ├── propagation.go     # W3C Trace Context
│   └── middleware.go      # Tracing middleware
├── metrics/
│   ├── metrics.go         # Определение метрик
│   ├── prometheus.go      # Prometheus exporter
│   └── labels.go          # Правила кардинальности
└── contextx/
    └── context.go         # Context helpers

Что изучаем на примере шаблона

  1. Настройка slog с уровнями и форматами
  2. Request ID middleware для корреляции
  3. Prometheus-метрики для HTTP
  4. OpenTelemetry для tracing
  5. PII masking в логах
  6. Health checks

Задание: изучите internal/observability/

  • Какие метрики собираются?
  • Как настроен logger?
  • Есть ли PII masking?
  • Как реализованы health checks?

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

  • Логи vs метрики vs трейсы: логи дают детали конкретного события и контекст ошибки, но дороги в хранении; метрики (http_requests_total) дёшевы и агрегируемы, но без деталей; трейсы связывают span'ы по сервисам и ловят задержку, но требуют инструментирования каждого вызова.
  • slog JSON vs Text: NewJSONHandler парсится системами логирования в staging/prod, но нечитаем человеком; NewTextHandler удобен локально, но платим поддержкой двух форматов и конфигом format.
  • Histogram vs Summary: histogram с фиксированными Buckets агрегируется по инстансам, но точность квантилей ограничена бакетами; summary даёт точные p95 на клиенте, однако ряды разных инстансов не сложишь.
  • Детальные метки vs кардинальность: user_id или request_id в labels дают разрез по клиенту, но взрывают storage Prometheus временными сериями; берём низкокардинальные method и нормализованный path, а детали уводим в логи.
  • /healthz vs /readyz: healthz всегда 200 и не зовёт db.Ping(), чтобы падение БД не рестартовало живые поды; readyz честно проверяет зависимости, но нестабильная БД постоянно выводит под из балансировки.

Практическое задание

Задание 1: Настройка slog

Реализуйте setupLogger с поддержкой уровней и форматов:

go
func setupLogger(level, format string) *slog.Logger

Протестируйте:

  • DEBUG, INFO, WARN, ERROR уровни
  • JSON и Text форматы

Задание 2: Logging middleware

Реализуйте логирование запросов:

go
func Logging(logger *slog.Logger) func(http.Handler) http.Handler

Логируйте: method, path, status, duration, request_id.

Задание 3: Prometheus метрики

Добавьте метрики:

  • http_requests_total (counter)
  • http_request_duration_seconds (histogram)
  • http_requests_in_flight (gauge)

Реализуйте middleware для сбора метрик.

Задание 4: Health checks

Реализуйте /healthz и /readyz:

text
mux.HandleFunc("GET /healthz", h.Healthz)
mux.HandleFunc("GET /readyz", h.Readyz)

/readyz должен проверять подключение к БД.

Задание 5: PII masking

Реализуйте MaskPII для:

  • Email: user@example.com -> @.***
  • Телефон: +79991234567 -> --****
  • Карта: 4111111111111111 -> ---

Задание 6: OpenTelemetry

Настройте tracing:

  • Инициализация TracerProvider
  • otelhttp middleware
  • Span в RepoService.GetRepo

Задание 7: Error visibility

Добавьте метрику errors_total:

go
var errorsTotal = promauto.NewCounterVec(
    prometheus.CounterOpts{
        Name: "errors_total",
    },
    []string{"type", "operation"},
)

Инкрементируйте при ошибках в сервисах.


Quiz "Проверь себя"

1. Почему label user_id в Prometheus-метрике — проблема?
2. Что из перечисленного — базовая HTTP-метрика сервиса? — выберите все подходящие
3. Зачем нужен correlation ID (request_id)?
4. Как отреагирует Kubernetes, если /healthz провалился, а если /readyz?
5. Что не так с этим логом и как исправить?
go
logger.Info("user registered", "email", "ivan@example.com")
6. Как устроен distributed tracing?
7. Чем structured logging через slog отличается от этого кода?
go
log.Printf("user %s did %s", userID, action)
8. Какие метрики входят в RED-модель для HTTP-сервиса?
9. Что обеспечивает видимость ошибок в production?
10. Чем /readyz отличается от /healthz?

✅ Итоги модуля

Студент должен:

  • Настраивать structured logging через slog
  • Коррелировать запросы через request ID
  • Собирать Prometheus-метрики
  • Настраивать OpenTelemetry tracing
  • Маскировать PII в логах
  • Реализовывать health checks
  • Обеспечивать видимость ошибок
  • Ответить на все вопросы Quiz

🔗 Мостик к Модулю 8

Следующий модуль: Модуль 8. Background Processing - worker concept, jobs and schedulers (robfig/cron), outbox pattern, retries and DLQ, idempotency keys, очереди сообщений. Привязка к internal/handlers/jobs/ и internal/adapters/scheduler/ шаблона.