Observability — Implementation Plan
Дизайн и обоснование решений: 2026-07-20-observability-design.md.
Этапы 1–3 ничем не заблокированы. Этапы 4–5 трогают общий код и требуют ревью Игоря.
Статус на 20.07.2026
Заголовок раздела «Статус на 20.07.2026»| Этап | Состояние |
|---|---|
| 1. Каркас | ✅ сделано — Loki + Alloy + Grafana подняты, grafana.crewsforge.com с TLS |
| 2. Сбор | 🟡 частично — логи собираются (Task 5); метрики, Traefik и пробы нет (Tasks 6–8) |
| 3. Дашборды и алерты | ❌ не начато — дашбордов ноль, смотреть можно через Explore |
| 4. Логирование | ❌ не начато — конвенция определена, код не тронут |
| 5. Метрики в коде | ❌ не начато |
Проверено живьём: строка, порождённая запросом к auth-api, появляется в Grafana за считаные секунды с корректными лейблами.
Отклонение от изначального плана: MVP сузили до «логи + Grafana», Prometheus в compose пока не подключён (конфиг лежит в репозитории готовым).
Этап 1. Каркас стека
Заголовок раздела «Этап 1. Каркас стека»Task 1: Репозиторий crewsforge-observability
Заголовок раздела «Task 1: Репозиторий crewsforge-observability»Создать репозиторий в организации, структура:
alloy/config.alloyprometheus/prometheus.ymlprometheus/rules/loki/loki-config.ymlgrafana/provisioning/{datasources,dashboards}/grafana/dashboards/dokploy/docker-compose.observability.ymlREADME.md — как поднять, куда ходить, где что лежит. Ссылка на этот план.
Task 2: Compose стека
Заголовок раздела «Task 2: Compose стека»Один compose со всеми четырьмя сервисами:
- Prometheus — retention
15d+ лимит по размеру, volume под TSDB - Loki — файловое хранилище, retention
30d+ лимит, volume - Grafana — volume под БД, admin-пароль из env
- Alloy — монтируется docker.sock (только чтение) и хост-пути для метрик; портов наружу не публикует
Наружу через Traefik торчит только Grafana. Остальные — внутри сети.
Проверка: все четыре контейнера в статусе Up, ни один не в рестарт-петле.
Task 3: Сервис в Dokploy
Заголовок раздела «Task 3: Сервис в Dokploy»Создать compose-сервис observability в проекте infra, источник — GitHub, ветка main, автодеплой по пушу. Env: GRAFANA_ADMIN_PASSWORD, ретеншны.
Проверка: деплой доходит до done, контейнеры поднялись.
Task 4: Домен Grafana
Заголовок раздела «Task 4: Домен Grafana»Привязать grafana.crewsforge.com (A-запись уже есть), Let’s Encrypt, порт 3000.
Проверка: https://grafana.crewsforge.com отдаёт форму входа с валидным сертификатом; пароль по умолчанию admin/admin не проходит.
Этап 2. Сбор сигналов
Заголовок раздела «Этап 2. Сбор сигналов»Task 5: Alloy — логи всех контейнеров
Заголовок раздела «Task 5: Alloy — логи всех контейнеров»discovery.docker + loki.source.docker. Лейблы: имя контейнера, имя compose-сервиса, проект Dokploy — чтобы фильтровать по сервису, а не по хешу в имени контейнера.
Проверка: в Grafana → Explore → Loki запрос {service="auth-api"} показывает свежие строки; намеренный рестарт контейнера виден в логах с корректным временем.
Task 6: Alloy — метрики хоста и контейнеров
Заголовок раздела «Task 6: Alloy — метрики хоста и контейнеров»prometheus.exporter.unix (хост) и prometheus.exporter.cadvisor (контейнеры), отдаются Prometheus.
Проверка: есть метрики node_* и container_*; счётчик рестартов реагирует на ручной рестарт контейнера.
Task 7: Метрики Traefik
Заголовок раздела «Task 7: Метрики Traefik»Включить экспортер Prometheus в Traefik и добавить его в scrape-конфиг.
Даёт RED-картину по всем маршрутам без правок в приложениях: количество запросов, коды ответов, латентность по роутерам.
⚠️ Конфиг Traefik принадлежит Dokploy — менять через API/UI, не по SSH. Изменение статического конфига требует рестарта Traefik: короткий разрыв входящего трафика. Делать осознанно.
Проверка: метрика traefik_router_requests_total растёт при обращении к любому домену.
Task 8: Внешние пробы
Заголовок раздела «Task 8: Внешние пробы»prometheus.exporter.blackbox, цели: crewsforge.com, app., auth-api., user-api., project-api., admin-api., ai-agent., docs.crewsforge.online, grafana.
Проверка: probe_success = 1 по всем целям; probe_ssl_earliest_cert_expiry показывает даты истечения сертификатов.
Этап 3. Дашборды и алерты
Заголовок раздела «Этап 3. Дашборды и алерты»Task 9: Datasources через provisioning
Заголовок раздела «Task 9: Datasources через provisioning»Prometheus и Loki подключаются автоматически из файлов — не руками через UI. Иначе при пересоздании Grafana всё придётся настраивать заново.
Проверка: после docker compose down && up datasources на месте без ручных действий.
Task 10: Дашборды
Заголовок раздела «Task 10: Дашборды»Минимум три, все в git как JSON:
- Обзор платформы — доступность всех доменов, 5xx по сервисам, рестарты, ресурсы хоста. Экран «всё ли в порядке» на одном взгляде.
- Сервис (шаблонный, переключение по имени) — RPS, ошибки, латентность, память, рестарты, логи этого сервиса рядом.
- Инфраструктура — Postgres, Redis, RabbitMQ: соединения, память, глубина очередей.
Проверка: дашборды заводятся из provisioning на чистой Grafana и показывают данные.
Task 11: Алерты
Заголовок раздела «Task 11: Алерты»Набор из design-документа: сервис недоступен, крэшлуп, сертификат < 14 дней, диск > 85%, всплеск 5xx, OOM-kill.
Латентность не алертим до накопления базовой линии.
Проверка (обязательно, иначе смысла нет): остановить неважный контейнер → алерт срабатывает; вернуть → гаснет. Алерт, который никогда не проверяли, не считается работающим.
Task 12: Канал уведомлений
Заголовок раздела «Task 12: Канал уведомлений»Решить, куда шлём: Telegram, почта или пока только UI Grafana. SMTP уже настроен и проверен — почта доступна сразу.
Этап 4. Структурное логирование (нужен PR и ревью)
Заголовок раздела «Этап 4. Структурное логирование (нужен PR и ревью)»Идёт до метрик: даёт результат сам по себе и создаёт каркас, на который потом лягут метрики. Конвенция переносится из desktop-app, где она уже обкатана в бою.
Task 13: Либа libs/apis/utils/logger
Заголовок раздела «Task 13: Либа libs/apis/utils/logger»nestjs-pino. Живёт в utils/, как prisma-client и redis-client: это инфраструктурный модуль с жизненным циклом, а не набор хелперов. В shared не кладём — он должен оставаться листом (см. PR #20 про циклы).
Конфиг:
LoggerModule.forRoot({ pinoHttp: { autoLogging: false, // как в desktop-app: автологирование запросов выключено redact: [ /* заголовки, куки, пароли, коды */ ], },})Три отличия от desktop-app, каждое обосновано:
redactобязателен. В desktop-app его нет, но там иautoLoggingвыключен, поэтому заголовки в лог не попадали. Здесь уже логируется email, а логи живут 30 дней и доступны любому со входом в Grafana. Маскируем:authorization,cookie,set-cookie,password,code,token. Email — частично (pro***@crewsforge.com).requestIdиз CLS. В каждомapp.module.tsуже настроенClsModuleс генерацией id изX-Request-Id— и он никуда не идёт. Подцепляем к pino: тогда один запрос прослеживается через все строки (| json | requestId="..."). Именно этого не хватало при разборе падений auth-api.- Правило «только скаляры» в
data. В desktop-app встречаетсяdata: { webhook, event }— целые объекты в логе. Это раздувает Loki и тянет туда что попало, включая потенциальные секреты.
Проверка: после подключения вывод сервиса — валидный JSON, одно событие = одна строка; redact подтверждён запросом с заголовком Authorization (в логе должно быть [Redacted]).
Task 14: Подключение к пяти сервисам
Заголовок раздела «Task 14: Подключение к пяти сервисам»По одной строке в каждый app.module.ts + app.useLogger() в bootstrap.
Ключевое: nestjs-pino подменяет логгер приложения, поэтому существующие new Logger(X) продолжают работать и начинают писать JSON. Переписывать 30 вызовов ради включения не нужно.
Проверка: логи всех пяти сервисов в Loki парсятся через | json; многострочные объекты NestJS больше не рассыпаются на отдельные записи.
Task 15: Конвенция логирования в docs/
Заголовок раздела «Task 15: Конвенция логирования в docs/»✅ Зафиксировано в
docs/rules/coding-rules.md, раздел 10. Там же уточнено:actionвключает имя библиотеки и разделяется слэшем (admin-contact/AdminContactService/leaveRequest), человекочитаемый текст живёт вmsg.
Зафиксировать форму вызова, перенесённую из desktop-app:
this.logger.error({ action: 'ChatService/createChat', // Класс/метод — где произошло data: { companyId, visitId, clientPhone }, // контекст, ТОЛЬКО СКАЛЯРЫ err: e, // ошибка отдельным полем});Плюс: stage для разметки этапов длинной операции, доменные идентификаторы верхним уровнем — по ним чаще всего ищут.
Отдельно записать: JSON-поля ≠ лейблы Loki. requestId, userId, email остаются полями. Сделать их лейблами — взорвать Loki кардинальностью ровно так же, как Prometheus.
Task 16: Перевод строковых вызовов в структурные
Заголовок раздела «Task 16: Перевод строковых вызовов в структурные»Сейчас в коде 13 структурных вызовов против 17 строковых. Строковые запекают значения в текст:
this.logger.log(`Client ${client.id} connected to project ${projectId}`)projectId внутри строки — фильтровать по нему можно только регуляркой. Переводим в поля.
Косметика, эффекта на работу сервисов нет — можно отдельным PR и позже.
Проверка: grep по строковым вызовам логгера пуст.
Этап 5. Инструментирование метриками (нужен PR и ревью)
Заголовок раздела «Этап 5. Инструментирование метриками (нужен PR и ревью)»Отдельный PR в crewsforge-back. Ценность высокая, но трогает общий код — поэтому после этапов 1–3.
Task 17: /health
Заголовок раздела «Task 17: /health»Через @nestjs/terminus: Postgres, Redis, RabbitMQ (последний только для ai-agent-service).
Разделить liveness (процесс жив) и readiness (зависимости доступны) — иначе кратковременная недоступность БД вызовет бесконечный перезапуск контейнера вместо ожидания.
Подключить health-чек в compose и в Traefik.
Проверка: /health отдаёт 200 со статусами зависимостей; при остановленном Redis readiness становится нездоровым, а liveness остаётся живым.
Task 18: /metrics
Заголовок раздела «Task 18: /metrics»Либа libs/apis/utils/metrics (prom-client). Именно utils/, не shared — это инфраструктурный модуль, а shared должен оставаться листом (PR #20 про циклы). Создаётся генератором nx g @nx/nest:lib, чтобы каркас (project.json, tsconfig, jest, eslint, barrel) лёг по конвенции.
MetricsModule сам поднимает всё, bootstrap не трогаем:
APP_INTERCEPTOR→HttpMetricsInterceptor— глобальный, ловит все маршруты без аннотаций на эндпоинтахcollectDefaultMetrics()— heap, event loop lag, GCOnModuleInitподнимает/metricsна отдельном порту (не публичный домен — иначе имена маршрутов и трафик утекут в интернет)
Шов подключения — вариант А (решено 20.07): MetricsModule в imports каждого из пяти app.module.ts. Одна строка ×5. Общий bootstrap() отвергнут — переписал бы main.ts всех сервисов с их Swagger/версионированием/особым префиксом admin, риск не оправдан.
PM2 — AggregatorRegistry сразу (решено 20.07). Запуск идёт через pm2-runtime -i ${PM2_INSTANCE_NUMBER}. При -i >1 у каждого воркера свой реестр, скрейп попадает в случайного → метрики занижены молча. AggregatorRegistry собирает со всех воркеров. Сложнее, но масштабирование не ломает цифры.
Метрики HTTP по паттерну команды: гистограмма {method, route}, отдельный счётчик; status_code не в лейблах гистограммы (кардинальность).
Лейбл route — шаблон маршрута (/customers/:id), не фактический путь; 404 → unmatched. Иначе каждый uuid и каждый бот-скан создают новый временной ряд и Prometheus раздувается.
Nx-теги отложены (решено 20.07): либа получит тег scope:utils, но ужесточение depConstraints для всего монорепо — отдельная задача в бэклоге, не в этом PR.
Проверка: /metrics отдаёт данные на своём порту; Prometheus их скрейпит; серии появляются в Grafana; при -i 2 цифры не занижены.
Task 19: Бизнес-метрики
Заголовок раздела «Task 19: Бизнес-метрики»То, чего прокси не видит:
- регистрации: успешные / неуспешные
- отправки писем: успех / ошибка (SMTP молча висел — метрика поймала бы сразу)
- публикации в RabbitMQ и ответы агента
- длительность запросов к БД
Проверка: прогнать сквозной сценарий регистрации → счётчики выросли.
Порядок и зависимости
Заголовок раздела «Порядок и зависимости»Этап 1 (каркас) → Этап 2 (сбор) → Этап 3 (дашборды, алерты) ↓ Этап 4 (логирование, PR + ревью) ↓ Этап 5 (метрики в коде, PR + ревью)Этапы 1–3 не блокированы ничем. Этапы 4 и 5 трогают общий код и требуют ревью Игоря.
Логирование идёт перед метриками: оно ценно само по себе, а метрики потом ложатся на готовый каркас.
Отдельно требует решения
Заголовок раздела «Отдельно требует решения»- Канал алертов (Task 12) — Telegram или почта.
- Технический долг «инфра вне git» — описан в design-документе, осознанно отложен.
- S3 для Loki — не нужен, файловой системы достаточно. Вернуться, если ретеншн логов потребуется увеличить кратно.
Бэклог: Nx-границы (отложено 20.07)
Заголовок раздела «Бэклог: Nx-границы (отложено 20.07)»Не входит в задачу метрик, но напрямую с ней связано и напрашивается.
@nx/enforce-module-boundaries в eslint включён, но беззубый: depConstraints открывается правилом sourceTag:'*' → onlyDependOnLibsWithTags:['*'], а теги проставлены лишь у 5 проектов из ~30. Именно поэтому цикл shared → providers (PR #20) не был пойман линтером и всплыл в проде рестарт-петлёй.
Задача: проставить scope:* / type:* всем проектам и ужесточить depConstraints — как минимум запретить type:util/scope:shared зависеть от type:feature/scope:*-api. Тогда этот класс бага ловится в редакторе и CI, а не в проде.
Отдельный PR, потому что затрагивает конфиг всех проектов и требует прогнать nx lint по всему монорепо, разгребая накопившиеся нарушения.