Модуль 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.
goimport "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
goimport ( "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 формат для локальной разработки (читается человеком):
texttime=2026-01-15T10:30:00Z level=INFO msg="request completed" method=GET path=/api/v1/repos status=200
Правило: local/dev — text, staging/prod — json.
Что логировать
| Событие | Уровень | Поля |
|---|---|---|
| Старт сервера | INFO | addr, version |
| Запрос завершён | INFO | method, path, status, duration, request_id |
| Ошибка запроса | ERROR | error, method, path, request_id |
| Panic recovered | ERROR | panic, stack, request_id |
| Graceful shutdown | INFO | reason |
| Подключение к БД | INFO | host (без пароля!) |
Что НЕ логировать
- Тело запроса (может содержать PII)
- Заголовки с токенами (Authorization, Cookie)
- Пароли, секреты
- Email, телефоны в открытом виде
7.3 Request correlation
Зачем нужна корреляция
Один запрос проходит через:
- Middleware
- Handler
- Service
- Repository
- Внешние сервисы
Нужно связать все логи одного запроса. Для этого используется request ID или trace ID.
Request ID middleware
gotype 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
gofunc 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 в сервисы
gofunc (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
Установка:
bashgo 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 метрики
gopackage 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 для метрик
gofunc 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) }) }
Экспорт метрик
goimport "github.com/prometheus/client_golang/prometheus/promhttp" // Endpoint для Prometheus mux.Handle("GET /metrics", promhttp.Handler())
Prometheus периодически скрейпит /metrics и сохраняет метрики.
Бизнес-метрики
Кроме HTTP-метрик, собирайте бизнес-метрики:
textvar ( 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)
- Низкая кардинальность: method, status, path (нормализованный)
- НЕ использовать: user_id, request_id, email (высокая кардинальность)
- Ограниченное множество значений
Высокая кардинальность взрывает storage метрик.
7.5 Tracing (OpenTelemetry)
Что такое distributed tracing
Tracing отслеживает запрос через несколько сервисов. Каждый сервис добавляет span (отрезок работы) к общему trace.
textTrace: abc-123 ├── Span: HTTP GET /api/v1/repos (50ms) │ ├── Span: RepoService.GetRepo (45ms) │ │ └── Span: PostgreSQL SELECT (40ms) │ └── Span: JSON encode (2ms)
OpenTelemetry в Go
Установка:
bashgo get go.opentelemetry.io/otel go get go.opentelemetry.io/otel/sdk/trace go get go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp
Инициализация tracer
gopackage 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
goimport "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" // Оборачиваем handler handler := otelhttp.NewHandler(mux, "repo-service")
Создание spans в коде
goimport "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):
httptraceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
OpenTelemetry автоматически инжектит и извлекает traceparent при использовании otelhttp.
Когда использовать tracing
- Микросервисная архитектура (запрос через несколько сервисов)
- Поиск узких мест (какой сервис/запрос медленный)
- Анализ ошибок в распределённых системах
Для монолита tracing менее критичен, но полезен для анализа latency.
7.6 PII masking
Что такое PII
PII (Personally Identifiable Information) — персональные данные:
- Телефон
- ФИО
- Адрес
- Паспортные данные
- IP-адрес (в некоторых юрисдикциях)
Зачем маскировать
Логи могут быть доступны широкому кругу лиц. Утечка логов = утечка PII. Требования GDPR, 152-ФЗ, HIPAA обязывают защищать персональные данные.
Маскирование через regex
gopackage 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 }
Использование в логировании
gologger.Info("user registered", "email", MaskPII(user.Email), "user_id", user.ID, ) // Результат: email=***@***.***
Структурное маскирование
Для сложных случаев создайте тип MaskedString:
gotype 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
- Не логируйте PII если это не необходимо
- Если необходимо — маскируйте
- Не передавайте PII в query parameters (попадают в access logs)
- Удаляйте PII из error messages перед логированием
- Настройте retention policy для логов с PII
7.7 Health checks
Зачем нужны health checks
Health checks используются:
- Kubernetes liveness probe
- Kubernetes readiness probe
- Load balancer checks
- Мониторинг доступности
/healthz — liveness
Проверяет что процесс жив. Не проверяет зависимости.
jsonfunc (h *Handler) Healthz(w http.ResponseWriter, r *http.Request) { h.writeJSON(w, http.StatusOK, map[string]string{ "status": "ok", }) }
Если /healthz не отвечает — Kubernetes перезапустит под.
/readyz — readiness
Проверяет что сервис готов принимать трафик.
gofunc (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
- Все ошибки логируются с контекстом
gologger.Error("operation failed", "error", err, "operation", "create_repo", "repo_id", repoID, "request_id", requestID, )
- Ошибки имеют метрики
govar errorsTotal = promauto.NewCounterVec( prometheus.CounterOpts{ Name: "errors_total", Help: "Total number of errors", }, []string{"type", "operation"}, ) // Инкремент при ошибке errorsTotal.WithLabelValues("database", "create_repo").Inc()
- Ошибки классифицируются
gotype ErrorKind string const ( ErrorKindDatabase ErrorKind = "database" ErrorKindValidation ErrorKind = "validation" ErrorKindExternal ErrorKind = "external" ErrorKindInternal ErrorKind = "internal" )
-
Алерты на критичные ошибки
// 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):
goimport "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 в шаблоне
textinternal/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
Что изучаем на примере шаблона
- Настройка slog с уровнями и форматами
- Request ID middleware для корреляции
- Prometheus-метрики для HTTP
- OpenTelemetry для tracing
- PII masking в логах
- Health checks
Задание: изучите internal/observability/
- Какие метрики собираются?
- Как настроен logger?
- Есть ли PII masking?
- Как реализованы health checks?
💱 Компромиссы
- Логи vs метрики vs трейсы: логи дают детали конкретного события и
контекст ошибки, но дороги в хранении; метрики (
http_requests_total) дёшевы и агрегируемы, но без деталей; трейсы связывают span'ы по сервисам и ловят задержку, но требуют инструментирования каждого вызова. slogJSON vs Text:NewJSONHandlerпарсится системами логирования в staging/prod, но нечитаем человеком;NewTextHandlerудобен локально, но платим поддержкой двух форматов и конфигомformat.- Histogram vs Summary: histogram с фиксированными
Bucketsагрегируется по инстансам, но точность квантилей ограничена бакетами; summary даёт точные p95 на клиенте, однако ряды разных инстансов не сложишь. - Детальные метки vs кардинальность:
user_idилиrequest_idв labels дают разрез по клиенту, но взрывают storage Prometheus временными сериями; берём низкокардинальныеmethodи нормализованныйpath, а детали уводим в логи. /healthzvs/readyz: healthz всегда 200 и не зовётdb.Ping(), чтобы падение БД не рестартовало живые поды; readyz честно проверяет зависимости, но нестабильная БД постоянно выводит под из балансировки.
Практическое задание
Задание 1: Настройка slog
Реализуйте setupLogger с поддержкой уровней и форматов:
gofunc setupLogger(level, format string) *slog.Logger
Протестируйте:
- DEBUG, INFO, WARN, ERROR уровни
- JSON и Text форматы
Задание 2: Logging middleware
Реализуйте логирование запросов:
gofunc 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:
textmux.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:
govar errorsTotal = promauto.NewCounterVec( prometheus.CounterOpts{ Name: "errors_total", }, []string{"type", "operation"}, )
Инкрементируйте при ошибках в сервисах.
Quiz "Проверь себя"
user_id в Prometheus-метрике — проблема?logger.Info("user registered", "email", "ivan@example.com")log.Printf("user %s did %s", userID, action)✅ Итоги модуля
Студент должен:
- Настраивать 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/ шаблона.