Перейти к содержимому

Observability — Implementation Plan

Дизайн и обоснование решений: 2026-07-20-observability-design.md.

Этапы 1–3 ничем не заблокированы. Этапы 4–5 трогают общий код и требуют ревью Игоря.

ЭтапСостояние
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 пока не подключён (конфиг лежит в репозитории готовым).


Создать репозиторий в организации, структура:

alloy/config.alloy
prometheus/prometheus.yml
prometheus/rules/
loki/loki-config.yml
grafana/provisioning/{datasources,dashboards}/
grafana/dashboards/
dokploy/docker-compose.observability.yml

README.md — как поднять, куда ходить, где что лежит. Ссылка на этот план.

Один compose со всеми четырьмя сервисами:

  • Prometheus — retention 15d + лимит по размеру, volume под TSDB
  • Loki — файловое хранилище, retention 30d + лимит, volume
  • Grafana — volume под БД, admin-пароль из env
  • Alloy — монтируется docker.sock (только чтение) и хост-пути для метрик; портов наружу не публикует

Наружу через Traefik торчит только Grafana. Остальные — внутри сети.

Проверка: все четыре контейнера в статусе Up, ни один не в рестарт-петле.

Создать compose-сервис observability в проекте infra, источник — GitHub, ветка main, автодеплой по пушу. Env: GRAFANA_ADMIN_PASSWORD, ретеншны.

Проверка: деплой доходит до done, контейнеры поднялись.

Привязать grafana.crewsforge.com (A-запись уже есть), Let’s Encrypt, порт 3000.

Проверка: https://grafana.crewsforge.com отдаёт форму входа с валидным сертификатом; пароль по умолчанию admin/admin не проходит.


discovery.docker + loki.source.docker. Лейблы: имя контейнера, имя compose-сервиса, проект Dokploy — чтобы фильтровать по сервису, а не по хешу в имени контейнера.

Проверка: в Grafana → Explore → Loki запрос {service="auth-api"} показывает свежие строки; намеренный рестарт контейнера виден в логах с корректным временем.

prometheus.exporter.unix (хост) и prometheus.exporter.cadvisor (контейнеры), отдаются Prometheus.

Проверка: есть метрики node_* и container_*; счётчик рестартов реагирует на ручной рестарт контейнера.

Включить экспортер Prometheus в Traefik и добавить его в scrape-конфиг.

Даёт RED-картину по всем маршрутам без правок в приложениях: количество запросов, коды ответов, латентность по роутерам.

⚠️ Конфиг Traefik принадлежит Dokploy — менять через API/UI, не по SSH. Изменение статического конфига требует рестарта Traefik: короткий разрыв входящего трафика. Делать осознанно.

Проверка: метрика traefik_router_requests_total растёт при обращении к любому домену.

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 показывает даты истечения сертификатов.


Prometheus и Loki подключаются автоматически из файлов — не руками через UI. Иначе при пересоздании Grafana всё придётся настраивать заново.

Проверка: после docker compose down && up datasources на месте без ручных действий.

Минимум три, все в git как JSON:

  1. Обзор платформы — доступность всех доменов, 5xx по сервисам, рестарты, ресурсы хоста. Экран «всё ли в порядке» на одном взгляде.
  2. Сервис (шаблонный, переключение по имени) — RPS, ошибки, латентность, память, рестарты, логи этого сервиса рядом.
  3. Инфраструктура — Postgres, Redis, RabbitMQ: соединения, память, глубина очередей.

Проверка: дашборды заводятся из provisioning на чистой Grafana и показывают данные.

Набор из design-документа: сервис недоступен, крэшлуп, сертификат < 14 дней, диск > 85%, всплеск 5xx, OOM-kill.

Латентность не алертим до накопления базовой линии.

Проверка (обязательно, иначе смысла нет): остановить неважный контейнер → алерт срабатывает; вернуть → гаснет. Алерт, который никогда не проверяли, не считается работающим.

Решить, куда шлём: Telegram, почта или пока только UI Grafana. SMTP уже настроен и проверен — почта доступна сразу.


Этап 4. Структурное логирование (нужен PR и ревью)

Заголовок раздела «Этап 4. Структурное логирование (нужен PR и ревью)»

Идёт до метрик: даёт результат сам по себе и создаёт каркас, на который потом лягут метрики. Конвенция переносится из desktop-app, где она уже обкатана в бою.

nestjs-pino. Живёт в utils/, как prisma-client и redis-client: это инфраструктурный модуль с жизненным циклом, а не набор хелперов. В shared не кладём — он должен оставаться листом (см. PR #20 про циклы).

Конфиг:

LoggerModule.forRoot({
pinoHttp: {
autoLogging: false, // как в desktop-app: автологирование запросов выключено
redact: [ /* заголовки, куки, пароли, коды */ ],
},
})

Три отличия от desktop-app, каждое обосновано:

  1. redact обязателен. В desktop-app его нет, но там и autoLogging выключен, поэтому заголовки в лог не попадали. Здесь уже логируется email, а логи живут 30 дней и доступны любому со входом в Grafana. Маскируем: authorization, cookie, set-cookie, password, code, token. Email — частично (pro***@crewsforge.com).
  2. requestId из CLS. В каждом app.module.ts уже настроен ClsModule с генерацией id из X-Request-Id — и он никуда не идёт. Подцепляем к pino: тогда один запрос прослеживается через все строки (| json | requestId="..."). Именно этого не хватало при разборе падений auth-api.
  3. Правило «только скаляры» в data. В desktop-app встречается data: { webhook, event } — целые объекты в логе. Это раздувает Loki и тянет туда что попало, включая потенциальные секреты.

Проверка: после подключения вывод сервиса — валидный JSON, одно событие = одна строка; redact подтверждён запросом с заголовком Authorization (в логе должно быть [Redacted]).

По одной строке в каждый app.module.ts + app.useLogger() в bootstrap.

Ключевое: nestjs-pino подменяет логгер приложения, поэтому существующие new Logger(X) продолжают работать и начинают писать JSON. Переписывать 30 вызовов ради включения не нужно.

Проверка: логи всех пяти сервисов в Loki парсятся через | json; многострочные объекты NestJS больше не рассыпаются на отдельные записи.

✅ Зафиксировано в 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.

Через @nestjs/terminus: Postgres, Redis, RabbitMQ (последний только для ai-agent-service).

Разделить liveness (процесс жив) и readiness (зависимости доступны) — иначе кратковременная недоступность БД вызовет бесконечный перезапуск контейнера вместо ожидания.

Подключить health-чек в compose и в Traefik.

Проверка: /health отдаёт 200 со статусами зависимостей; при остановленном Redis readiness становится нездоровым, а liveness остаётся живым.

Либа 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_INTERCEPTORHttpMetricsInterceptor — глобальный, ловит все маршруты без аннотаций на эндпоинтах
  • collectDefaultMetrics() — heap, event loop lag, GC
  • OnModuleInit поднимает /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 цифры не занижены.

То, чего прокси не видит:

  • регистрации: успешные / неуспешные
  • отправки писем: успех / ошибка (SMTP молча висел — метрика поймала бы сразу)
  • публикации в RabbitMQ и ответы агента
  • длительность запросов к БД

Проверка: прогнать сквозной сценарий регистрации → счётчики выросли.


Этап 1 (каркас) → Этап 2 (сбор) → Этап 3 (дашборды, алерты)
Этап 4 (логирование, PR + ревью)
Этап 5 (метрики в коде, PR + ревью)

Этапы 1–3 не блокированы ничем. Этапы 4 и 5 трогают общий код и требуют ревью Игоря.

Логирование идёт перед метриками: оно ценно само по себе, а метрики потом ложатся на готовый каркас.

  • Канал алертов (Task 12) — Telegram или почта.
  • Технический долг «инфра вне git» — описан в design-документе, осознанно отложен.
  • S3 для Loki — не нужен, файловой системы достаточно. Вернуться, если ретеншн логов потребуется увеличить кратно.

Не входит в задачу метрик, но напрямую с ней связано и напрашивается.

@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 по всему монорепо, разгребая накопившиеся нарушения.