Модуль 7. Observability на минимальном стеке: OpenTelemetry, Jaeger, Cloud Monitoring
Цель модуля: Сделать Serverless Git-хостинг полностью прозрачным для эксплуатации без Prometheus и Grafana. Научить студента связывать логи, метрики и трейсы через OpenTelemetry. Использовать Cloud Monitoring SDK (не Prometheus-синтаксис). Настроить cost-алерты на free-tier лимиты. Освоить yc logging read как единственный способ отладки в Cloud Functions. Внедрить правило кардинальности метрик как архитектурный запрет и PII-маскирование через middleware. Показать путь миграции на Prometheus при переезде на VPS.
Стадия роста проекта: Продакшен 24/7. Система работает. Но пришло время доказать, что система действительно надёжна — и первое, что ломается — видимость.
Перед началом: Модули 1–6
Инструменты: OpenTelemetry, Jaeger, Cloud Monitoring SDK, Cloud Logging, Yandex Cloud CLI (yc), Terraform
Трудоёмкость: ~6 ч (теория 1.5 ч · практика 3.5 ч · cost-алерты + middleware 1 ч)
🔥 Инцидент: тишина, которая страшнее ошибок
Saga работает (Модуль 6), PR merge'ятся. Но в понедельник — письмо от дежурного: «Три PR зависли в статусе MERGE_FAILED за выходные. В логах тишина. А количество saga_merge_failed_total превысило 15, и никто не понимает, на каком шаге и почему».
Ты начинаешь копать. Логи есть, но без связи. Метрики: error rate почти нулевой — потому что MERGE_FAILED это не HTTP 500, а бизнес-решение. Трейсы: в Jaeger видны обрывки — трейс есть до Message Queue, а дальше пустота.
И ещё: в Cloud Monitoring — метка repo_id для каждого push. Количество временных рядов взлетело до 10 000. А в логах кто-то логирует email пользователя — утечка PII.
Три вопроса:
- «Что пошло не так?» — сквозные трейсы с
trace_idчерез Message Queue. - «Насколько всё плохо?» — правильные метрики в Cloud Monitoring.
- «Где причина в логах?» — структурированные JSON-логи через
yc logging read.
Четвёртый вопрос: «Почему мониторинг тормозит?» — взрыв кардинальности из-за ID в labels метрик.
Невидимая система неуправляема. В Serverless это критично: нет SSH на сервер. Логи — единственный спасательный круг.
Сквозной User Story: «Вася нажал Merge PR #42. Saga прошла все шаги. В этом модуле OpenTelemetry связывает все переходы в один трейс, Cloud Monitoring алертит при MERGE_FAILED > 5 за 10 минут, cost-алерт срабатывает при превышении 10 RU/с в YDB.»
📖 Теория: Три кита Observability в Serverless
Глоссарий модуля:
Span / Trace — Span: одна операция; Trace: дерево Span-ов, склеенное общим trace_id.
Cloud Monitoring — управляемый мониторинг Yandex Cloud. Замена Prometheus для Serverless.
Cloud Monitoring SDK — yandex-cloud/go-sdk/monitoring для экспорта кастомных метрик. НЕ Prometheus-синтаксис.
Cloud Logging — управляемые логи Yandex Cloud.
yc logging read — CLI-команда для чтения логов. Единственный способ отладки в Serverless.
Cost-алерт — алерт на free-tier лимиты.
Cardinality (кардинальность) — число различных значений метки. repo_id = взрыв рядов. Архитектурный запрет: никаких ID в labels.
PII (Personally Identifiable Information) — персональные данные. Логировать нельзя. Маскирование через middleware.
OpenTelemetry (OTel) — единый стандарт трейсинга/метрик/логов.
traceparent — W3C Trace Context заголовок: 00-{trace_id}-{span_id}-{flags}.
7.1. Три кита Observability
7.2. Сквозной trace_id через Message Queue
go// Outbox Relay: trace_id → SQS message attributes func publishToMQ(ctx context.Context, ev OutboxEvent) error { msg := &sqs.SendMessageInput{ QueueUrl: queueURL, MessageBody: aws.String(ev.Payload), MessageAttributes: map[string]types.MessageAttributeValue{ "traceparent": { DataType: aws.String("String"), StringValue: aws.String(ev.TraceID), }, "event_id": { DataType: aws.String("String"), StringValue: aws.String(ev.EventID), }, }, } _, err := sqsClient.SendMessage(ctx, msg) return err } // Консьюмер: восстанавливаем контекст из message attributes func (c *Consumer) handle(ctx context.Context, msg SQSMessage) error { traceparent := msg.MessageAttributes["traceparent"] ctx = otel.GetTextMapPropagator().Extract(ctx, propagation.MapCarrier{ "traceparent": traceparent, }) ctx, span := otel.Tracer("saga-orchestrator").Start(ctx, "ProcessMerge") defer span.End() // ... бизнес-логика }
Пример трейса для PR merge:
[API] POST /api/pr/42/merge 350ms ← корневой span
├── [git-gateway] git merge 180ms
│ ├── [NFS] write packfile 120ms
│ └── [YDB] UPDATE refs 15ms
├── [YDB] INSERT pr_saga 10ms
└── [Outbox] INSERT outbox_events 8ms
[Timer] saga-orchestrator tick 50ms ← новый трейс (Timer Trigger)
├── [YDB] SELECT stuck PRs 12ms
├── [YDB] UPDATE state=NOTIFYING 8ms
└── [MQ] Publish notify command 5ms
[Timer] saga-orchestrator tick 45ms ← следующий tick
├── [YDB] UPDATE state=INDEXING 8ms
└── [Search] Update index 30ms ← здесь тормозит!
Критично: Timer Trigger создаёт новый трейс. Связь между трейсами — через pr_id в логах и trace_id в SQS message attributes.
7.3. Cloud Monitoring SDK: метрики и алерты
В Serverless нет Prometheus. Используем Cloud Monitoring SDK:
go// ❌ АНТИПАТТЕРН: Prometheus-синтаксис (не работает в Serverless) var sagaTransitionsTotal = promauto.NewCounterVec(...) // ✅ ПРАВИЛЬНО: yandex-cloud/go-sdk/monitoring import ( "github.com/yandex-cloud/go-genproto/yandex/cloud/monitoring/v1" monitoring "github.com/yandex-cloud/go-sdk/monitoring" ) type MetricsExporter struct { sdk *yc.SDK } func (m *MetricsExporter) ExportSagaTransition(fromState, toState string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, err := m.sdk.Monitoring().Metrics().Publish(ctx, &monitoring.PublishMetricsRequest{ FolderId: os.Getenv("YC_FOLDER_ID"), Metrics: []*monitoring.Metric{ { Name: "saga_transitions_total", Labels: map[string]string{ "from_state": fromState, "to_state": toState, }, Type: monitoring.Metric_COUNTER, Value: 1, Metadata: &monitoring.MetricMetadata{}, }, }, }) return err } func (m *MetricsExporter) ExportMergeFailed(failedStep string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, err := m.sdk.Monitoring().Metrics().Publish(ctx, &monitoring.PublishMetricsRequest{ FolderId: os.Getenv("YC_FOLDER_ID"), Metrics: []*monitoring.Metric{ { Name: "saga_merge_failed_total", Labels: map[string]string{ "failed_step": failedStep, }, Type: monitoring.Metric_COUNTER, Value: 1, Metadata: &monitoring.MetricMetadata{}, }, }, }) return err } func (m *MetricsExporter) ExportDLQCount(count int) error { // ... аналогично для dlq_messages_count }
Алерты в Terraform:
hcl# Cost-алерт: YDB RU/с resource "yandex_monitoring_alert" "ydb_ru" { name = "ydb-ru-exceeds-free-tier" conditions { metric { name = "ydb.serverless.ru_per_second" threshold = 8 duration = 300 } } notification_channels = [yandex_monitoring_notification_channel.telegram.id] } # Бизнес-алерт: MERGE_FAILED > 5 за 10 минут resource "yandex_monitoring_alert" "saga_merge_failed" { name = "saga-merge-failed-spike" conditions { metric { name = "saga_merge_failed_total" threshold = 5 duration = 600 } } notification_channels = [yandex_monitoring_notification_channel.telegram.id] } # Cost-алерт: Cloud Functions invocations resource "yandex_monitoring_alert" "functions_invocations" { name = "functions-invocations-exceed-free-tier" conditions { metric { name = "functions.invocations.count" threshold = 800000 duration = 3600 } } notification_channels = [yandex_monitoring_notification_channel.telegram.id] } # Outbox-алерт: pending-события накапливаются resource "yandex_monitoring_alert" "outbox_pending" { name = "outbox-pending-accumulating" conditions { metric { name = "outbox_pending_count" threshold = 50 duration = 300 } } notification_channels = [yandex_monitoring_notification_channel.telegram.id] } # DLQ-алерт: сообщения в DLQ resource "yandex_monitoring_alert" "dlq_messages" { name = "dlq-messages-accumulating" conditions { metric { name = "dlq_messages_count" threshold = 5 duration = 300 } } notification_channels = [yandex_monitoring_notification_channel.telegram.id] } # Алерт на взрыв кардинальности resource "yandex_monitoring_alert" "cardinality_explosion" { name = "cardinality-explosion" conditions { metric { name = "monitoring.series.count" threshold = 5000 # > 5000 рядов — тревога duration = 300 } } notification_channels = [yandex_monitoring_notification_channel.telegram.id] }
Дашборд в Terraform:
hclresource "yandex_monitoring_dashboard" "git_hosting" { name = "git-hosting-dashboard" widgets { title = "Saga Transitions" chart { targets { query = "saga_transitions_total" } } } widgets { title = "Merge Failed" chart { targets { query = "saga_merge_failed_total" } } } widgets { title = "Outbox Pending" chart { targets { query = "outbox_pending_count" } } } widgets { title = "YDB RU/s" chart { targets { query = "ydb.serverless.ru_per_second" } } } }
7.4. yc logging read: единственный спасательный круг
bash# Прочитать логи git-gateway за последний час yc logging read \ --folder-id=b1g... \ --log-group-id=e9b... \ --filter='function_name="git-hosting-git-gateway"' \ --since="1h" \ --limit=100 # Прочитать все переходы Saga для PR #42 yc logging read \ --folder-id=b1g... \ --filter='pr_id="42" AND message="saga_transition"' \ --since="3d"
7.5. Корреляция: Jaeger → логи
- В Jaeger находишь трейс PR #42, копируешь
trace_id - Читаешь логи через CLI:
bashyc logging read \ --folder-id=b1g... \ --filter='trace_id="abc123..."' \ --since="1h"
Результат:
json{ "timestamp": "2024-01-15T10:00:15Z", "level": "INFO", "trace_id": "abc123...", "span_id": "789xyz", "pr_id": "42", "repo_id": "repo-1", "saga_state": "INDEXING", "from_state": "NOTIFYING", "to_state": "INDEXING", "reason": "notify_ok", "attempts": 0, "duration_ms": 45, "message": "saga_transition", "service": "saga-orchestrator", "function_id": "git-hosting-saga-orchestrator" }
7.6. Кардинальность метрик (архитектурный запрет) и PII-маскирование
Правило кардинальности метрик
Правило: Никаких ID (repo_id, user_id, pr_id, event_id, commit_sha) в labels метрик. Только категории и статусы.
Почему: Каждый уникальный label = новый временной ряд. 10 000 репозиториев × метрика с repo_id = 10 000 рядов = взрыв стоимости Cloud Monitoring и деградация производительности.
Чек-лист кардинальности:
markdown## Чек-лист кардинальности метрик - [ ] Все labels — категории (state, status, error_type, from_state, to_state) - [ ] Нет ID (repo_id, user_id, pr_id, event_id, commit_sha) в labels - [ ] Максимум 5 labels на метрику - [ ] Количество уникальных значений на label < 100 - [ ] Протестировано: при 10 000 сущностей рядов < 1000
Примеры:
go// ❌ АНТИПАТТЕРН: repo_id в labels metricsExporter.ExportPush(repoID, userID) // 10 000 рядов! // ✅ ПРАВИЛЬНО: только категории metricsExporter.ExportSagaTransition("MERGING", "NOTIFYING") // ~20 рядов metricsExporter.ExportMergeFailed("INDEXING") // ~5 рядов metricsExporter.ExportConflictRetries("refs", 3) // ~10 рядов
В ADR-SV7 добавить: «Кардинальность как архитектурный запрет: никаких ID в labels. При > 5000 рядов — ревью всех метрик».
PII-маскирование как middleware
Вынести PII-маскирование в общий middleware, который используется во всех Cloud Functions:
go// internal/observability/pii_middleware.go package observability import ( "context" "log/slog" ) // piiKeys — список ключей, которые нужно маскировать var piiKeys = map[string]bool{ "email": true, "phone": true, "password": true, "card_number": true, "cvv": true, "ssn": true, "passport": true, "access_token": true, } // NewPIISafeLogger создаёт logger с автоматическим PII-маскированием func NewPIISafeLogger(base *slog.Logger) *slog.Logger { return slog.New(&piiHandler{Handler: base.Handler()}) } type piiHandler struct { slog.Handler } func (h *piiHandler) Handle(ctx context.Context, r slog.Record) error { // Перебираем атрибуты и маскируем PII newAttrs := make([]slog.Attr, 0, r.NumAttrs()) r.Attrs(func(a slog.Attr) bool { if piiKeys[a.Key] { a.Value = slog.StringValue("***") } newAttrs = append(newAttrs, a) return true }) // Создаём новую запись с маскированными атрибутами newRecord := slog.NewRecord(r.Time, r.Level, r.Message, r.PC) newRecord.Add(newAttrs...) return h.Handler.Handle(ctx, newRecord) }
Использование во всех Cloud Functions:
go// cmd/api/main.go func main() { logger := observability.NewPIISafeLogger(slog.Default()) slog.SetDefault(logger) // Теперь все логи автоматически маскируют PII slog.Info("user login", "email", user.Email, "user_id", user.ID) // Вывод: user login email=*** user_id=abc123 }
Тест PII-маскирования:
gofunc TestPIIMasking(t *testing.T) { var buf bytes.Buffer base := slog.New(slog.NewJSONHandler(&buf, nil)) logger := observability.NewPIISafeLogger(base) logger.Info("test", "email", "user@example.com", "user_id", "123") output := buf.String() if strings.Contains(output, "user@example.com") { t.Error("PII not masked: email leaked") } if !strings.Contains(output, "***") { t.Error("PII masking not applied") } if !strings.Contains(output, "123") { t.Error("non-PII field incorrectly masked") } }
Валидация traceparent (W3C Trace Context)
go// internal/observability/trace_validation.go package observability import ( "fmt" "regexp" ) var traceparentRegex = regexp.MustCompile(`^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$`) // ValidateTraceparent проверяет формат W3C Trace Context func ValidateTraceparent(traceparent string) error { if traceparent == "" { return fmt.Errorf("empty traceparent") } if !traceparentRegex.MatchString(traceparent) { return fmt.Errorf("invalid traceparent format: %s", traceparent) } return nil } // Middleware для валидации trace контекста func ValidateTraceContext(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { traceparent := r.Header.Get("traceparent") if traceparent != "" { if err := ValidateTraceparent(traceparent); err != nil { slog.Warn("invalid traceparent", "error", err, "traceparent", traceparent, ) // Не блокируем запрос, но логируем warning } } next.ServeHTTP(w, r) }) }
Использование в Cloud Functions:
gofunc main() { mux := http.NewServeMux() mux.HandleFunc("/api/pr", handlePR) // Middleware применяется ко всем запросам handler := observability.ValidateTraceContext(mux) http.ListenAndServe(":8080", handler) }
7.7. Миграция на Prometheus при переезде на VPS
| Сервис | Serverless (MVP) | VPS / K8s (рост) | Что меняется |
|---|---|---|---|
| Метрики | Cloud Monitoring SDK | Prometheus + Grafana | OTLP-экспортер → Prometheus scraper |
| Логи | Cloud Logging | Loki / ELK | Формат JSON тот же |
| Трейсы | Jaeger (OTLP) | Jaeger (OTLP) | Не меняется |
| Алерты | Cloud Monitoring Alerts | Alertmanager | Правила те же |
Ключевой инсайт: OpenTelemetry абстрагирует экспорт. При переезде меняется только OTLP-экспортер. Код метрик НЕ меняется.
7.8. Интеграционный тест на trace_id
go//go:build integration func TestTracePropagation_EndToEnd(t *testing.T) { req, _ := http.NewRequest("POST", "/api/pr/42/merge", nil) req.Header.Set("traceparent", "00-abc123def456789012345678abcdef90-1234567890abcdef-01") resp, _ := http.DefaultClient.Do(req) defer resp.Body.Close() time.Sleep(10 * time.Second) msg, err := sqsClient.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{ QueueUrl: queueURL, MessageAttributeNames: []string{"traceparent"}, }) if err != nil { t.Fatalf("receive message: %v", err) } traceAttr := msg.Messages[0].MessageAttributes["traceparent"] if traceAttr == nil || *traceAttr.StringValue != "00-abc123def456789012345678abcdef90-1234567890abcdef-01" { t.Errorf("trace_id not propagated through MQ") } }
7.9. Этот паттерн в архетипах
| Архетип | Ключевой трейс | Бизнес-метрика | Почему критично |
|---|---|---|---|
| Финтех | Перевод: API → Saga → KYC → БД | saga_compensation_total | Компенсации — бизнес-решения |
| Git-хостинг | Push: Smart HTTP → Outbox → Webhook | outbox_pending_count, saga_merge_failed_total | Вебхуки теряются тихо |
| Мессенджер | Сообщение: Send → Deliver → Read | delivery_latency_p99 | Недоставлено |
| Лента | Запрос: API → Кэш → БД → CDN | cache_hit_ratio | Лавина на БД |
7.10. Компромиссы (Trade-offs)
| Решение | Плюс | Минус | Когда выбирать |
|---|---|---|---|
| Cloud Monitoring SDK (Serverless) | Управляемый; интеграция с YC | Нет PromQL; привязка к YC | Serverless MVP |
| Prometheus + Grafana (VPS) | Полный контроль | Нужно обслуживать | Рост > 1k RPS |
| Cloud Logging + yc CLI | Управляемый | Только через CLI | Serverless |
| OpenTelemetry (OTLP) | Единый стандарт; миграция без изменения кода | Overhead | Всегда |
| Cost-алерты на free-tier | Защита от неожиданного счёта | Ложные срабатывания | Всегда для Serverless |
| Кардинальность как запрет | Нет взрыва рядов | Ограничение детализации | Всегда |
| PII-middleware по умолчанию | Защита данных | Доп. overhead на логирование | Всегда |
Вывод: для Serverless MVP — Cloud Monitoring SDK + Cloud Logging + OTel + кардинальность + PII-middleware. При переезде на VPS — замена OTLP-экспортера. См. ADR-SV7.
🛠 Практические задания
Задача 1: OpenTelemetry + Jaeger
✅ Готово, когда:
[ ] OTel SDK инициализирован во всех 4 Cloud Functions
[ ] trace_id сохраняется в SQS message attributes
[ ] Консьюмер восстанавливает контекст
[ ] В Jaeger видно единое дерево трейса
Задача 2: Cloud Monitoring метрики + алерты
✅ Готово, когда:
[ ] Метрики через Cloud Monitoring SDK (не Prometheus)
[ ] Низкая кардинальность: нет repo_id, user_id
[ ] Алерты в Terraform: YDB RU/с > 8, MERGE_FAILED > 5, invocations > 800k, outbox_pending > 50, DLQ > 5, cardinality > 5000
[ ] Дашборд в Terraform
Задача 3: yc logging read + PII masking
✅ Готово, когда:
[ ] yc logging read --filter='pr_id="42"' показывает полную историю
[ ] PII маскируются на *** через middleware
[ ] Логи содержат trace_id, pr_id, saga_state
Задача 4: Правило кардинальности + чек-лист
✅ Готово, когда:
[ ] Все метрики проверены: нет ID в labels
[ ] Чек-лист кардинальности пройден
[ ] Алерт cardinality_explosion в Terraform
Задача 5: Валидация traceparent
✅ Готово, когда:
[ ] Middleware ValidateTraceContext применяется ко всем запросам
[ ] Невалидный traceparent логируется как warning, но не блокирует запрос
[ ] Тест на валидный и невалидный traceparent
Задача 6: План миграции на Prometheus ✅ Готово, когда: [ ] ADR-SV7: «Cloud Monitoring vs Prometheus» [ ] Описано, что меняется: OTLP-экспортер [ ] Описано, что НЕ меняется: код метрик, трейсы, алерты
⚠️ Антипаттерн для разбора
gofunc (h *Handler) MergePR(w http.ResponseWriter, r *http.Request) { slog.Info("merge started") // ❌ Нет trace_id и pr_id mergeTotal.WithLabelValues(repoID).Inc() // ❌ Взрыв кардинальности slog.Info("user merge", "email", user.Email) // ❌ PII без маскирования // ❌ Нет cost-алертов // ❌ Нет валидации traceparent }
🧩 Проверь себя
🤖 Инструкция для LLM — Режим Модуля 7
Привет! Я начинаю Модуль 7 курса «Git Hosting на Go: Serverless MVP на Yandex Cloud».
Дай короткий ответ (2–3 предложения): почему
trace_idобрывается при прохождении через Message Queue, и как его передать через SQS message attributes.Выдай задание:
- Внедрить OpenTelemetry во все 4 Cloud Functions.
- Настроить Cloud Monitoring метрики через SDK и алерты в Terraform.
- Написать скрипт
yc logging readдля отладки PR #42.- Внедрить правило кардинальности метрик как архитектурный запрет.
- Вынести PII-маскирование в middleware для всех функций.
- Добавить валидацию traceparent.
- Написать ADR-SV7: миграция Cloud Monitoring → Prometheus.
Покажи код Антипаттерна и попроси объяснить.
НЕ ПИШИ за меня. Дождись моего ответа.
📝 Чек-лист ментора
- Логи: структурированы (JSON), содержат
trace_id,pr_id,saga_state. - Trace Propagation:
trace_idпередаётся через SQS message attributes. - Timer Trigger: новый трейс, связь через
pr_id. - Jaeger: видно дерево вызовов от API до saga-orchestrator.
- Метрики: Cloud Monitoring SDK (не Prometheus), низкая кардинальность.
- Кардинальность: чек-лист пройден, нет ID в labels, алерт
cardinality_explosion. - PII Masking: middleware во всех функциях, email/phone/password →
***. - Валидация traceparent: middleware, warning при невалидном формате.
- Cost-алерты: YDB RU/с > 8, invocations > 800k, outbox_pending > 50, MERGE_FAILED > 5, DLQ > 5.
- Дашборд в Terraform.
-
yc logging read: студент умеет читать логи. - Корреляция: Jaeger → trace_id → yc logging read.
- План миграции: ADR-SV7.
- Интеграционный тест на trace_id.
- Mermaid-диаграмма: передача trace_id через HTTP и MQ.
🔗 Мостик к Модулю 8 (Defense)
«Отлично, теперь мы видим всю систему как на ладони. Но как это всё задеплоить в продакшене? Как защитить секреты и проверить зависимости на уязвимости? Что будет, если NFS упадёт? В Модуле 8 мы соберём всё вместе: Terraform, GitHub Actions, Lockbox, govulncheck, DR-план с git fsck, авто-ротация HMAC, Cost-отчёт. А в финале — защита перед комитетом.»
Сквозной User Story: «Вася нажал Merge PR #42. Saga прошла все шаги, Observability алертит. В Модуле 8 мы задеплоим всё через Terraform + GitHub Actions, защитим секреты через Lockbox, и Вася сделает свой финальный push в production.»