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. Но операторам/разработчикам нужно увидеть, что
происходит с джобами в рантайме:
- Текущее состояние очередей: сколько джоб ждёт, активно, упало,
отложено. Без панели в инциденте приходится
redis-cliходить по ключамoptima:*:*— медленно и опасно. - Просмотр payload упавших джоб (
failed-набора) и ручной retry — ADR-0007 явно полагается на это. - Метрики для Prometheus — длины очередей и время обработки —
нужны и для алертов (
waiting > Nслишком долго), и для дашбордов капасити. Слой/metrics(F6 · OP-46) будет общим для приложения, но первая пачка данных приходит именно от очередей.
Рассмотренные варианты
UI панели
- Bull Board (
@bull-board/api+@bull-board/express).- Канонический UI BullMQ, ровно один SPA, без зависимости от внешних сервисов.
- Совместим с нашим Express-адаптером Nest: монтируется в
main.tsчерезapp.getHttpAdapter().getInstance().use(). - Базовая защита через
basic-auth(мы уже применяем тот же приём для Swagger UI). − Один и тот же UI для всех очередей, тонкая разграничения прав по очередям нет (для нас сейчас и не нужно).
- Grafana + Redis Exporter. Универсально, но не показывает payload джоб и не даёт ручной retry — это не UI очередей, а график.
- Свой Nest-контроллер
/admin/queuesс шаблонами. Дублирование Bull Board «для красоты»; ничего не выигрывает, тратит ресурсы.
Защита панели
- Basic-auth через
express-basic-auth(паттерн Swagger).- Симметрично существующему
applySwaggerAccessControl. - Не требует распространять JWT на endpoint-ы UI Bull Board
(которые сам Bull Board рендерит как обычные express-роуты,
JwtAuthGuardк ним не применяется). − Учётка — общая на всю команду, не персональная. Допустимо для служебной панели стартапного периода.
- Симметрично существующему
- JWT через
JwtAuthGuardдля отдельного контроллера-обёртки. Невозможно: Bull Board сам обрабатывает запросы своим router'ом, guards Nest применяются только к Nest-контроллерам. - IP-allowlist на уровне Coolify/обратного прокси. Не отрицает basic-auth, дополняется им; на уровне приложения мы оставляем second layer.
Источник списка очередей
- Auto-discovery через
@nestjs/core.DiscoveryService. СервисQueueDiscoveryServiceсканирует DI-контейнер наonApplicationBootstrapи собирает все провайдеры, прошедшие duck-test на BullMQ Queue (поляname: string, методыgetJobCounts/add).- Новые фичевые модули, регистрирующие
BullModule.registerQueue, автоматически попадают и в Bull Board UI, и в/metrics— без ручной правки observability-слоя.
- Новые фичевые модули, регистрирующие
- Явное перечисление через
QueueObservabilityModule.register({ queues }). Требует обновлять каждый раз. Простой ритуал, который легко забыть — а observability-«дыра» молчаливая (метрик нет, а никто не заметит). - Использовать
@bull-board/nestjs(BullBoardModule.forFeature). Покрывает только UI, для/metricsвсё равно нужен свой реестр — получим дублирование.
Формат и набор метрик
- 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 завершённым джобам.
- OpenMetrics формат — почти тот же, но с обязательным
# EOFи# UNIT. Все наши предполагаемые scraper'ы (Prometheus + Grafana Agent) равно понимают exposition v0.0.4; не привносим лишнего. - 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. Логика:
QueueMetricsService.collectSnapshots()асинхронно опрашивает все обнаруженные очереди (getJobCounts+getJobs(['completed'], 0, 49)).- Перцентили считаются методом nearest-rank на выборке последних
завершённых джоб (
processedOn/finishedOn). Выборка 50 — баланс между стабильностью p95 и стоимостью одногоgetJobsзапроса. 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-метриками.
- Один шаблон onboarding-а фичевого модуля: зарегистрировал
- Отрицательные / компромиссы:
QueueMetricsServiceпересчитывает перцентили на каждый scrape. Для типового scrape-интервала 15–60 с это дёшево (~50 ключей Redis на очередь); если очередей станет много (десятки), стоит кэшировать результат на 5–10 секунд.- Bull Board — общая учётка, не персональная. Допустимо для стартапа; при росте команды можно вынести панель за SSO (отдельный subdomain, например, на тот же Cloudflare Access).
- Метрики
failed-набора не различают «упало по ретраю — скоро повторим» и «упало после исчерпания ретраев». Если потребуется отдельный alert по dead-letter, добавимoptima_queue_dead_lettergauge (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, не приложения.
- OP-46 — общий