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

0010. Observability очередей: Bull Board UI + Prometheus exposition

Копия ADR из репозитория: backend/docs/adr/0010-queue-observability.md. Канонический текст — в репозитории; портал обновляется командой npm run sync:adr.

  • Статус: accepted
  • Дата: 2026-06-09
  • Связанные задачи: OP-43 (F1.3 · Мониторинг очередей и метрики джоб); связан с OP-46 (F6 /metrics).

Контекст и проблема​

ADR-0007 ввёл BullMQ как единый движок очередей и предусмотрел dead-letter через removeOnFail: false. Но операторам/разработчикам нужно увидеть, что происходит с джобами в рантайме:

  1. Текущее состояние очередей: сколько джоб ждёт, активно, упало, отложено. Без панели в инциденте приходится redis-cli ходить по ключам optima:*:* — медленно и опасно.
  2. Просмотр payload упавших джоб (failed-набора) и ручной retry — ADR-0007 явно полагается на это.
  3. Метрики для Prometheus — длины очередей и время обработки — нужны и для алертов (waiting > N слишком долго), и для дашбордов капасити. Слой /metrics (F6 · OP-46) будет общим для приложения, но первая пачка данных приходит именно от очередей.

Рассмотренные варианты​

UI панели​

  1. Bull Board (@bull-board/api + @bull-board/express).
    • Канонический UI BullMQ, ровно один SPA, без зависимости от внешних сервисов.
    • Совместим с нашим Express-адаптером Nest: монтируется в main.ts через app.getHttpAdapter().getInstance().use().
    • Базовая защита через basic-auth (мы уже применяем тот же приём для Swagger UI). − Один и тот же UI для всех очередей, тонкая разграничения прав по очередям нет (для нас сейчас и не нужно).
  2. Grafana + Redis Exporter. Универсально, но не показывает payload джоб и не даёт ручной retry — это не UI очередей, а график.
  3. Свой Nest-контроллер /admin/queues с шаблонами. Дублирование Bull Board «для красоты»; ничего не выигрывает, тратит ресурсы.

Защита панели​

  1. Basic-auth через express-basic-auth (паттерн Swagger).
    • Симметрично существующему applySwaggerAccessControl.
    • Не требует распространять JWT на endpoint-ы UI Bull Board (которые сам Bull Board рендерит как обычные express-роуты, JwtAuthGuard к ним не применяется). − Учётка — общая на всю команду, не персональная. Допустимо для служебной панели стартапного периода.
  2. JWT через JwtAuthGuard для отдельного контроллера-обёртки. Невозможно: Bull Board сам обрабатывает запросы своим router'ом, guards Nest применяются только к Nest-контроллерам.
  3. IP-allowlist на уровне Coolify/обратного прокси. Не отрицает basic-auth, дополняется им; на уровне приложения мы оставляем second layer.

Источник списка очередей​

  1. Auto-discovery через @nestjs/core.DiscoveryService. Сервис QueueDiscoveryService сканирует DI-контейнер на onApplicationBootstrap и собирает все провайдеры, прошедшие duck-test на BullMQ Queue (поля name: string, методы getJobCounts/add).
    • Новые фичевые модули, регистрирующие BullModule.registerQueue, автоматически попадают и в Bull Board UI, и в /metrics — без ручной правки observability-слоя.
  2. Явное перечисление через QueueObservabilityModule.register({ queues }). Требует обновлять каждый раз. Простой ритуал, который легко забыть — а observability-«дыра» молчаливая (метрик нет, а никто не заметит).
  3. Использовать @bull-board/nestjs (BullBoardModule.forFeature). Покрывает только UI, для /metrics всё равно нужен свой реестр — получим дублирование.

Формат и набор метрик​

  1. Prometheus text exposition format v0.0.4 (наш выбор). Простой, поддерживается всеми scraper'ами, ровно то, что ждёт F6. Метрики:
    • optima_queue_jobs{queue, state} gauge — длины наборов waiting/active/delayed/paused/completed/failed.
    • optima_queue_job_duration_ms{queue, quantile} gauge — p50/p95 длительности обработки по последним 50 завершённым джобам.
  2. OpenMetrics формат — почти тот же, но с обязательным # EOF и # UNIT. Все наши предполагаемые scraper'ы (Prometheus + Grafana Agent) равно понимают exposition v0.0.4; не привносим лишнего.
  3. JSON-эндпоинт (/admin/queues/snapshot). Нет интеграции с общими дашбордами; делать всё равно придётся текстовый формат.

Решение​

Дашборд​

@bull-board/api + @bull-board/express под /admin/queues, монтируется в applyQueueBoardAccessControl (src/core/queue-observability/queue-board-access.ts) параллельно applySwaggerAccessControl. Очереди автодискаверятся через QueueDiscoveryService и оборачиваются в BullMQAdapter.

Защита:

  • dev/test — открыт без авторизации.
  • production — basic-auth при наличии обеих переменных QUEUE_BOARD_USER + QUEUE_BOARD_PASSWORD; если хотя бы одна не задана, панель не монтируется вовсе (логируется WARN).

Метрики /metrics остаются доступны без авторизации в любом окружении: их защищает сетевой контур (внутренняя сеть VPC), а scraper не умеет HTTP-Basic.

Метрики​

Эндпоинт GET /metrics (QueueMetricsController в core/queue-observability) анонимный, отдаёт text/plain; version=0.0.4; charset=utf-8. Логика:

  1. QueueMetricsService.collectSnapshots() асинхронно опрашивает все обнаруженные очереди (getJobCounts + getJobs(['completed'], 0, 49)).
  2. Перцентили считаются методом nearest-rank на выборке последних завершённых джоб (processedOn/finishedOn). Выборка 50 — баланс между стабильностью p95 и стоимостью одного getJobs запроса.
  3. renderPrometheus() формирует exposition-текст детерминированно (фиксированный порядок состояний, экранирование лейблов по спеке).

Если getJobCounts упадёт (Redis-таймаут), сервис логирует и отдаёт нули по этой очереди — /metrics не валится из-за одной проблемной очереди.

Префикс семейств метрик — optima_queue_*. Это:

  • даёт scraper'у дешёвый keep-regex, чтобы оставить только наши метрики;
  • защищает от конфликтов имён с другими приложениями на том же Prometheus.

Auto-discovery очередей​

QueueDiscoveryService сканирует DiscoveryService.getProviders() на хуке OnApplicationBootstrap и кэширует. Список не меняется в рантайме — модули фиксированы на старте, поэтому повторное сканирование не нужно.

Duck-test: провайдер считается очередью, если у него есть поле name: string и методы getJobCounts/add. Так мы:

  • не зависим от приватных типов @nestjs/bullmq;
  • легко мокаем в unit-тестах;
  • безопасны к обновлениям bullmq (контракт Queue.add/getJobCounts стабилен).

Последствия​

  • Положительные:
    • Один шаблон onboarding-а фичевого модуля: зарегистрировал BullModule.registerQueue({ name }) — очередь автоматически появилась в Bull Board UI и в /metrics.
    • Базовая защита панели согласована со Swagger (один паттерн, одна команда настройки в Coolify).
    • F6 (/metrics уровня приложения, OP-46) получает первый источник метрик готовым; дальше расширяется http/db-метриками.
  • Отрицательные / компромиссы:
    • QueueMetricsService пересчитывает перцентили на каждый scrape. Для типового scrape-интервала 15–60 с это дёшево (~50 ключей Redis на очередь); если очередей станет много (десятки), стоит кэшировать результат на 5–10 секунд.
    • Bull Board — общая учётка, не персональная. Допустимо для стартапа; при росте команды можно вынести панель за SSO (отдельный subdomain, например, на тот же Cloudflare Access).
    • Метрики failed-набора не различают «упало по ретраю — скоро повторим» и «упало после исчерпания ретраев». Если потребуется отдельный alert по dead-letter, добавим optima_queue_dead_letter gauge (BullMQ позволяет посчитать через getFailed + фильтр attemptsMade >= attempts). Отложено до явной потребности.
  • Дальше (вне scope OP-43):
    • OP-46 — общий /metrics приложения (HTTP/CPU/DB), который дополнит наш контроллер своими метриками (общий путь, разные источники сборки).
    • Алерты Prometheus на optima_queue_jobs{state="failed"} > N и optima_queue_job_duration_ms{quantile="0.95"} > X — задача Operations, не приложения.