Commit graph

19 commits

Author SHA1 Message Date
bot-backend
62a560387c feat(ops): измеряем очередь Celery и Redis, до сих пор слепая зона
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI / changes (pull_request) Successful in 15s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Prometheus не видел ни одной серии celery_*/redis_* — переполнение
очереди Site Finder и залипший воркер снаружи выглядели одинаково,
тишиной (issue #3471).

Добавлено (только на продуктовом хосте, профиль apps):
- redis-exporter (oliver006/redis_exporter) — здоровье общего Redis
  (db0 celery-брокер Site Finder, db1 SearchCache trade-in, db2
  glitchtip), адрес через alias gendesign-redis на сети shared, без
  нового сетевого доступа.
- celery-exporter (danihodovic/celery-exporter) — глубина очереди,
  число живых воркеров, счётчик неуспешных задач. Выбран вместо
  redis-exporter --check-keys, потому что дефолтная очередь "celery"
  дала бы только глубину, но не воркеров и не failures.
- Скрейп обоих в alloy-apps.alloy.
- Алерты в infra.yml: RedisDown, NoActiveCeleryWorkers,
  CeleryQueueGrowing (порог 150 предварительный — реальных данных по
  глубине очереди ещё нет, пересмотр через неделю наблюдений).

Имена метрик celery-exporter (celery_queue_length, celery_worker_up,
celery_task_failed_total) — по документации проекта, без прогона на
реальном брокере; сверить после первого деплоя, см. комментарий у
сервиса. promtool check rules — 23 правила, SUCCESS.

Refs #3471
2026-09-12 14:16:24 +03:00
1254294ac3 Merge pull request 'Alertmanager объявлен единственным путём доставки: встроенный алертинг Grafana выключен явно' (#3481) from chore/3158-grafana-alerting-off into main
Some checks are pending
Deploy Metrics / server (push) Waiting to run
Deploy Metrics / agent-apps (push) Blocked by required conditions
Deploy Metrics / agent-infra (push) Blocked by required conditions
2026-09-12 11:08:09 +00:00
5d4d17a5e1 Merge pull request 'Тревоги уровня приложения, cAdvisor и пропавшие фоновые контейнеры; critical переживает подавление' (#3477) from feat/3471-app-alerts-coverage into main
Some checks failed
Deploy Metrics / agent-apps (push) Blocked by required conditions
Deploy Metrics / agent-infra (push) Blocked by required conditions
Deploy Metrics / server (push) Has been cancelled
2026-09-12 11:05:37 +00:00
bot-backend
93451fae08 chore(ops): выключить встроенный Grafana Alerting, единственный путь — Alertmanager
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 14s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Проверка живого API Grafana 12.09.2026: правил алертинга ноль, контакт-поинт
единственный и стоковый — grafana-default-email на example@email.com,
GF_SMTP_* не заданы. Интерфейс выглядит настроенным, кнопка "New alert rule"
работает, а результат молча уходит в никуда — ровно тот случай, из-за
которого никто не проверяет второй, настоящий путь доставки.

Дублирующий движок на том же датасорсе Prometheus надёжности не добавляет
(общая точка отказа), а поддержку удваивает. Решение: Alertmanager —
единственный путь доставки тревог, Grafana только рисует.

GF_UNIFIED_ALERTING_ENABLED: "false" в docker-compose.metrics.yml (секция
grafana) — единственная официальная секция [unified_alerting] в Grafana 11.x,
легаси-[alerting] удалён из Grafana ещё в 9.0 (сверено с grafana.com/docs/
grafana/v11.5/setup-grafana/configure-grafana/#unified_alerting). Разом
убирает Alerting из UI и глушит движок правил, так что искать и вычищать
стоковый контакт-поинт отдельно не требуется.

ops/metrics/grafana/provisioning/alerting/README.md — явная отметка для
следующего человека: провижинить contact points/rules в эту папку не нужно,
Grafana её при выключенном unified alerting не читает.

Closes #3158, refs #3471
2026-09-12 14:01:04 +03:00
bot-backend
655652ae1c feat(ops): алерты уровня приложения и три слепые зоны мониторинга
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 14s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
- AppHighErrorRate / AppHighLatencyP95 (job="app", severity=critical,
  host="apps") — доля 5xx и p95 задержки по http_requests_total /
  http_request_duration_seconds_bucket теперь ловятся Prometheus'ом, а не
  только постфактум в GlitchTip. Пара critical+apps обязательна для
  маршрута telegram-clients в alertmanager.yml.tmpl.
- Inhibit по HostAgentDown больше не гасит critical того же хоста —
  target_matchers сужен до severity="warning" (падение node-exporter
  раньше молча забирало с собой PostgresLongTransactionCritical и
  critical-алерты cAdvisor).
- cAdvisor: keep-фильтр в alloy-infra.alloy резал у него `up` наравне с
  container_*-мусором — job "cadvisor" не публиковал свою же серию `up`.
  Пропущены up/scrape_samples_scraped, добавлен CadvisorDown.
- TradeInBackgroundContainerMissing по absent(container_last_seen) на
  tradein-tgbot/tradein-scraper — эти контейнеры не HTTP-сервисы и в
  up{} не участвуют вовсе; крэш без рестарта раньше не алертился.

Не закрыто: живой, но зависший процесс tgbot/scraper (container_last_seen
не про внутренний прогресс, а про то, что Docker видит контейнер running).

Refs #3471
2026-09-12 13:57:54 +03:00
bot-backend
412d78f357 fix(ops): панель классов ответов больше не стекируется — красная линия и есть число 5xx
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 16s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
11.09 панель «Запросы по классам ответов» дала ложную тревогу: при stacking=normal
верхняя, красная линия рисуется на высоте суммы всех классов, и её положение
читается как объём пятисоток. Фактически за то окно их было шесть.

Стек здесь ничего не даёт: суммарный трафик уже показан отдельной панелью
«Запросов в минуту», а от этой нужна форма каждого класса по отдельности.
Заливка снижена, линия утолщена — без стека 25% заливки перекрывают друг друга.

Описание панели теперь прямо говорит, что линии независимы.

Refs #3471
2026-09-12 13:49:45 +03:00
33714e464e fix(alerts): в тексте тревоги печаталась не та величина, которую текст называет
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m30s
CI / backend-tests (pull_request) Successful in 17m50s
Боевые сообщения в Telegram 12.09:
  «apps / tradein-browser: 2.684e+11% от mem_limit. Дальше OOM-kill.»
  «apps / listings: доля HOT 75.21%.» — при пороге срабатывания «доля < 20%»

Причина общая и она про ФОРМУ выражения, а не про условие: в PromQL `A and B`
возвращает ЗНАЧЕНИЯ ЛЕВОЙ части, отфильтрованные правой. В `$value` попадало A:

  - ContainerNearMemoryLimit: слева стоял `container_spec_memory_limit_bytes` —
    в сообщение уходил лимит в байтах (2 684 354 560), отрендеренный как
    процент. Замер 12.09: настоящее потребление того контейнера — 1.2 % лимита.
  - PostgresLowHotUpdateRatio: слева стоял `rate(tup_upd[6h])` — апдейтов в
    секунду. Это опаснее: 0.7521 превращалось в «75.21%», попадало в
    правдоподобный диапазон и противоречило собственному порогу, но выглядело
    настоящим числом. Замер 12.09 по listings: rate(tup_upd[6h]) = 0.0411 →
    сообщение сказало бы «4.11%», настоящая доля HOT = 0.00%.

Условия срабатывания в обоих случаях были ВЕРНЫ — врал только текст, поэтому
дефект и прожил незамеченным.

Правка: отношение вынесено влево, а побочное условие — внутрь знаменателя
(`X / (Y > 0)`), где оно и фильтрует серии, и защищает от деления на ноль.
Проверено на живом Prometheus (только чтение): новое выражение памяти отдаёт
доли 0.35–0.71 (топ — gendesign-infra-postgres 70.8 %), новое выражение HOT —
доли 0.00–1.00. `promtool check rules` — SUCCESS, 16 rules.

Третье правило того же семейства (PostgresDeadTuplesHigh) верно, но верно
случайно: печатаемая величина совпала с левым операндом. Помечено комментарием,
чтобы его не «причесали» по образцу двух других.

Гейт: backend/tests/ops/test_alert_value_is_the_described_quantity.py — если
описание рендерит `$value` как долю (`humanizePercentage`), левая часть
выражения обязана содержать деление. Фальсификация: вернул файл правил с
origin/main → красные test_percentage_annotations_come_from_a_ratio и
test_known_two_rules_are_fixed; с правкой — 3 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:27:02 +05:00
ed71292f03 fix(metrics): маскировать секреты из query-строки в Alloy до отправки в Loki
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Скруббер #3115 знал одну форму — пароль в DSN (scheme://user:pass@host).
Секрет в query-строке (`?secret=<64 hex>` вебхука GlitchTip, #3154) проходил
насквозь и оседал в Loki на 30 суток ретенции.

Приложение чистит это у себя (#3353), но фильтр стоит на логгере ОДНОГО
процесса. Второй слой на сборщике закрывает всё, что придёт мимо: sidecar,
чужой процесс, будущий логгер без фильтра. Множество имён параметров взято
из log_scrub.py дословно, включая суффиксные client_secret/refresh_token.

Группа захвата стоит на значении, а не на имени параметра: Alloy заменяет
содержимое ГРУПП, а не весь совпавший фрагмент. Группа вокруг `?secret=`
затёрла бы имя и оставила сам секрет — то есть ровно наоборот.

Стадия добавлена и в alloy-infra.alloy: на инфра-хосте Forgejo и GlitchTip
с их `?token=` в адресах пишут в тот же Loki, дефект там тот же.

Closes #3354
2026-09-06 01:46:53 +05:00
bot-backend
b90872b5d7 feat(ops/metrics): инфра-алерты уезжают в тему «метрики», клиенты остаются в «алертах»
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m57s
CI / backend-tests (pull_request) Successful in 17m26s
Форумная группа имеет три темы, но тема «метрики» была пуста: оба прямых
получателя Alertmanager — telegram и telegram-heartbeat — брали топик из той
же переменной METRICS_TELEGRAM_TOPIC_ID, что и сервис alert-ack. Развести их
было нечем, и heartbeat вместе со всем инфраструктурным шумом падал в ленту
клиентских инцидентов.

Смешанные в одной теме, инфраструктура и клиентский инцидент не равны по
срочности и приучают пролистывать обе.

Вводится METRICS_TELEGRAM_INFRA_TOPIC_ID для прямых получателей Alertmanager.
alert-ack и вебхук GlitchTip остаются на прежней переменной, тема поддержки
не тронута. Пока новая переменная не задана, берётся старая — до этого момента
поведение ровно прежнее, а не сломанное.

Клиентская METRICS_TELEGRAM_TOPIC_LINE убрана целиком: после переезда обоих
получателей на инфраструктурную строку шаблон её не содержит, а деплой
продолжал бы её собирать и объявлять в envsubst. Тест, закрепляющий сборку
такой строки, зеленел бы вечно и мешал бы её убрать.

Проверено рендером, а не чтением: при заданной теме telegram и
telegram-heartbeat дают 245, telegram-clients уходит вебхуком без темы; при
незаданной — поля message_thread_id нет вовсе (пустое значение уронило бы
Alertmanager целиком). Логика отката прогнана во всех трёх состояниях
переменных. backend/tests/ops — 86 passed.

Closes #3163
2026-08-27 21:50:51 +03:00
bot-backend
99125b8093 fix(ops/metrics): Prometheus не видел ни одного Alertmanager — цель file_sd осталась пустой
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Профиль alerts включили, а файл целей `alertmanager_targets.yml` остался
плейсхолдером `[]`. Снаружи всё зелёное: alertmanager и alert-ack Up/healthy,
деплой зелёный, в логах ни одной ошибки — при этом `activeAlertmanagers: []`
и 1568 уведомлений в `prometheus_notifications_dropped_total`. Горящий с 26.08
`Watchdog` не доехал никуда, как и HostAgentDown с RemoteWriteStalled.

Причина в том, что включатель профиля и цель для Prometheus лежали в разных
местах: профиль поднимает deploy-metrics.yml по наличию токена и чата, а файл
целей правился руками. Разъезд не ловится ничем — `[]` штатен при выключенном
профиле, поэтому ни валидация, ни healthcheck, ни лог на него не реагируют.

Файл становится производным (`alertmanager_targets.gen.yml`, в .gitignore) и
рендерится деплоем тем же условием, что включает профиль: цель при включённых
алертах, `[]` при выключенных. Рендер идёт до `up` и пишет усечением на месте,
поэтому инод сохраняется и работающий Prometheus подхватывает цель сам — та же
ловушка одиночного бинд-маунта, что уже описана в этом workflow у Alertmanager.

Пустой список пишется явно, а не удалением файла: несуществующий путь docker
подменяет каталогом, и Prometheus не стартует вовсе.

Closes #3155
2026-08-27 21:10:25 +03:00
bot-backend
053a5fb75c feat(observability): кнопка «Принял в работу» под клиентским инцидентом (#3078)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Failing after 1m12s
CI / openapi-codegen-check (pull_request) Successful in 1m55s
Alertmanager инлайн-клавиатуру не поддерживает, а без кнопки нет обратной
связи «человек увидел и взял в работу»: 27.08 продукты лежали 10 часов, и
вопрос «а кто-нибудь это читает» было не к кому адресовать.

ГДЕ ЖИВЁТ. Рядом с Alertmanager, на инфраструктурной машине. У бота МЕРЫ
уже есть приём обновлений, и повесить обработку туда было бы дешевле, но
он работает на продуктовом хосте: при падении продукта кнопка оказалась бы
мёртвой ровно тогда, когда нужна.

ССЫЛКА, А НЕ CALLBACK. Callback требует читателя обновлений бота. Бот один,
и его обновления уже читает МЕРА — второй читатель получил бы 409 Conflict
и отобрал бы сообщения у поддержки.

БЕЗ ПАРОЛЯ НА /ack/*, ОСОЗНАННО. Кнопку жмут ночью с телефона, когда лежит
прод; требование пароля даст ноль нажатий. Защита — 128-битный токен под
конкретное сообщение, живущий сутки; максимум, чего добьётся угадавший, —
ложная отметка в чате, где сразу видно, что её поставил не человек.

ТОЛЬКО КЛИЕНТСКИЙ МАРШРУТ идёт через сервис. Прочие алерты сохраняют прямой
путь в Telegram: чем меньше звеньев, тем надёжнее. Если сервис лёг,
Alertmanager повторяет доставку и переуведомляет каждые 30 минут — алерт
задерживается, но не теряется. Дублировать вторым прямым каналом не стали:
шум в канале тревог опаснее задержки.

Девять тестов дёргают настоящие функции, подменяя один шов — вызов Bot API.
Важнейший: при отказе отправки с клавиатурой сообщение уходит БЕЗ неё —
алерт важнее кнопки.
2026-08-27 13:56:00 +03:00
bot-backend
6f120c6605 feat(observability): клиентский инцидент зовёт дежурного поимённо + чинит сломанное продолжение команды (#3078)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m59s
CI / backend-tests (pull_request) Successful in 17m26s
Две вещи, вторая — исправление собственной ошибки из #3127.

## Дежурного зовут по имени

27.08 продукты лежали 10 часов, и в канале «диск занят на 86 %» и «клиенты
не могут открыть сайт» выглядели одинаково. Появился отдельный маршрут:
severity=critical И host=apps, то есть критично на ПРОДУКТОВОЙ машине —
значит людям недоступна МЕРА и Site Finder, а не «где-то в инфраструктуре
тесно».

У такого сообщения другой текст (🚨 КЛИЕНТЫ ЗАТРОНУТЫ), упоминание дежурного
и repeat_interval 30 минут против 3 часов у прочего критичного: пока
инцидент не погашен, напоминание должно быть неудобным.

Сужение по host=apps существенно. Без него дежурного звали бы на каждую
инфраструктурную мелочь, и тег перестал бы что-либо значить за неделю.

Сам аккаунт в репозиторий не попадает — берётся из METRICS_TELEGRAM_ONCALL.
Дежурный меняется, конфиг в git — нет. Пустая переменная = сообщение без
тега, поведение не ломается.

## Починка: комментарий внутри продолжения команды

В #3127 блок rm -f вместе с комментарием встал МЕЖДУ строками, каждая из
которых заканчивалась обратным слешем. Строки склеиваются, и весь вызов
envsubst уехал в комментарий — конфиг Alertmanager перестал бы рендериться
вовсе, при полностью зелёном деплое.

Синтаксически это корректный шелл, bash -n такое не ловит, а в диффе не
видно: строки выглядят как отдельные. Поэтому проверка структурная и
применяется ко ВСЕМ shell-блокам workflow, а не только к месту ожога.
2026-08-27 13:05:32 +03:00
bot-backend
c093eaafe5 fix(observability): пароли не уезжают в Loki — скруббер учётных данных (#3114)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m58s
CI / backend-tests (pull_request) Successful in 17m33s
postgres_exporter при неудачном скрейпе печатает полный DSN вместе с паролем.
Замер по Loki за сутки: 104 такие строки на инфра-хосте и 106 на продуктовом.
При ретенции 30 дней это порядка 6000 строк с паролями БД в хранилище, доступ
к которому даёт вход в Grafana - причём внешний basic_auth с витрины сегодня
же снят (#3113).

Проверено экспериментом, а не предположено. Напрашивалось передать пароль
отдельно от строки подключения (DATA_SOURCE_URI + DATA_SOURCE_USER +
DATA_SOURCE_PASS_FILE). Прогон на скретч-контейнерах с настоящим файлом
запросов показал: НЕ помогает - экспортер собирает строку сам и логирует её
целиком. Первые три попытки воспроизведения были неинформативны (контейнер
падал сразу; скрейпа не было; не подключён файл кастомных запросов) - утечка
воспроизводится только при неудачном скрейпе с нашим queries.yml.

Стало: ступень loki.process между источником журнала и loki.write, маскирует
пароль в любом URL вида scheme://user:pass@host. Пользователь и адрес
остаются - без них строка ошибки перестаёт годиться для диагностики. Ровно
одна группа захвата: Alloy заменяет содержимое групп, вторая затёрла бы имя
пользователя.

Это защита в глубину, а не замена причине. Конкретно эта ошибка уходит грантом
pg_monitor (запрос pg_wal_bytes в нашем queries.yml требует pg_ls_waldir) -
это боевая БД и остаётся за владельцем. Скруббер же ловит любой пароль в URL,
включая компоненты, о которых мы ещё не знаем.

Регулярка без экранирования намеренно: Alloy не принимает \s в строке
(unknown escape sequence), поэтому класс задан явным пробелом. Оба конфига
прогнаны через `alloy fmt` образом grafana/alloy:v1.6.1 - синтаксис ok.

Тесты (8) берут выражение ИЗ КОНФИГА и применяют к настоящей строке из прода:
пароль исчезает; пользователь и адрес остаются; обычные URL и почтовые адреса
не портятся; журнал направлен в скруббер, а не мимо него. Фальсификация: на
исходных конфигах краснеют все 8. tests/ops целиком - 43 passed.
2026-08-26 15:50:57 +03:00
bot-backend
5b7ef161e3 feat(observability): алерты адресуются в топик форумной группы (#3078)
All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m2s
CI / backend-tests (pull_request) Successful in 17m24s
Бот, которым шлются тревоги, — тот же, что пересылает сообщения поддержки, а
его чат форумный. Без message_thread_id Alertmanager кладёт тревоги в общую
тему, вперемешку с клиентской перепиской.

Поле поддерживается: проверено amtool check-config на том же образе, что
поднимается в проде (prom/alertmanager:v0.28.0). Схема Alertmanager строгая и
неизвестные поля отвергает, так что успешная проверка означает именно
поддержку, а не молчаливое игнорирование.

Подставляется ЦЕЛАЯ СТРОКА, а не значение: envsubst не умеет условий, и при
шаблоне вида `message_thread_id: ${TOPIC_ID}` незаданный топик дал бы
`message_thread_id:` без значения. Это не деградация - Alertmanager с таким
конфигом не стартует вовсе, то есть алертинг исчезает целиком. Деплой
формирует либо всю строку с отступом, либо пустую.

Топик необязателен: без него поле отсутствует, алерты уходят в общую тему,
поведение прежнее.

Попутно добавлена проверка конфига через amtool ДО подъёма стека - по образцу
`caddy validate` ниже в этом же файле. amtool берётся из того же образа, что и
сам Alertmanager, иначе проверялась бы не та версия схемы. Битый конфиг теперь
роняет деплой громко, а не выключает алертинг тихо.

Тесты (4) рендерят шаблон обоими способами и разбирают результат как YAML -
проверяется фактический конфиг, а не наличие нужных слов в тексте. Отдельно
проверено, что переменная объявлена в списке envsubst: забыть её - значит
оставить в конфиге литерал плейсхолдера.

Фальсификация: на исходных файлах краснеют 3 из 4; проходит только тест,
фиксирующий сохранённое поведение при незаданном топике. tests/ops целиком -
35 passed.
2026-08-26 14:10:56 +03:00
bot-backend
a72d39d74d fix(observability): cAdvisor 0.55.1 — 0.52 не видит контейнеры на Docker 29
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
#3108 не починил пустые панели по контейнерам, и правило relabel там было ни
при чём. Настоящая причина глубже: Docker 29 на обоих хостах работает через
containerd-snapshotter (Storage Driver = overlayfs), а cAdvisor 0.52 ищет
метаданные слоя в легаси-хранилище:

    failed to identify the read-write layer ID for container "<id>"
    - open /rootfs/var/lib/docker/image/overlayfs/layerdb/mounts/<id>/mount-id:
      no such file or directory

При снапшоттере этого каталога нет вовсе — в /var/lib/docker/image/ лежит
только identity-cache.db, метаданные слоёв живут в containerd. Обработчик
контейнера не создаётся, и наружу уходит ровно один ряд: корневой cgroup
container_last_seen{id="/"}. Отсюда и «нодата» на панелях контейнеров при
живых node/postgres/app метриках.

0.55.1 умеет читать containerd-снапшоттер. Проверено пробами с ПРОДОВЫМИ
флагами на обоих хостах (одноразовые контейнеры, убраны за собой):

  Beget    (Docker 29.4.1): 22 ряда, все с name=, ошибок rw-layer 0
  Poincare (Docker 29.7.2): 23 ряда, все с name=, ошибок rw-layer 0

Примеры рядов — name="gendesign-alloy", name="gendesign-backend-1",
name="gendesign-osrm-1", с лейблом image. То есть именно то, чего не хватало
панелям.

Заодно поправлен комментарий, который я же вписал в cadvisor_trim в #3108: он
объяснял пустые панели трактовкой пустого regex, а это оказалось неверно.
Правило корректно и остаётся (корневой ряд приходит с name="" и должен
отсеиваться), но объяснение рядом с ним вводило в заблуждение.

Почему тег .1, а не .0: в реестре нет ни v0.53.0, ни v0.54.0, ни v0.55.0 —
только v0.54.1 и v0.55.1. Проверял по списку тегов, а не подбором.

Проверки: yaml.safe_load compose — ok; alloy fmt обоих .alloy в
grafana/alloy:v1.6.1 — exit 0.
2026-08-26 13:40:03 +03:00
bot-backend
c6e15954ba fix(observability): контейнерные метрики терялись целиком + два хвоста стека
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Три независимых дефекта, найденных на живом проде после подъёма стека метрик.

1. cAdvisor-метрики не доезжали ВООБЩЕ. В Prometheus ноль имён container_*
   при 2034 именах всего, хотя cAdvisor отдаёт 880 рядов, scrape-таргет в
   alloy health=up с последним скрейпом 10 мс назад, а remote_write рабочий
   (node/postgres идут через него же и доезжают). Методом исключения — потери
   в prometheus.relabel.cadvisor_trim, во втором правиле:

       rule { source_labels = ["name"], regex = "", action = "drop" }

   Замысел был выкинуть безымянные cgroup-ряды (id="/"). Но regex в Alloy
   документированно дефолтится в (.*), и пустая строка неотличима от
   незаданного значения — такой drop рискует выкидывать вообще всё, что и
   наблюдалось. Заменено на однозначное keep regex=".+" — тот же замысел,
   без зависимости от того, как трактуется пустой regex.

2. healthcheck alloy не мог пройти никогда: дёргал wget, которого в образе
   grafana/alloy нет (как и curl, и nc). Контейнер вечно unhealthy при
   полностью исправном alloy — ложная тревога, маскирующая настоящие сбои.
   Заменено на сырой HTTP через bash /dev/tcp, без внешних утилит.

3. Prometheus раз в минуту писал "lookup alertmanager: no such host" и держал
   up{job="alertmanager"}=0. Alertmanager намеренно за профилем alerts до
   решения #3078 — дефект не в профиле, а в безусловной ссылке на сервис.
   Оба места (alerting.alertmanagers и job_name: alertmanager) переведены на
   file_sd_configs с файлом целей, по умолчанию пустым: целей нет — ошибок
   тоже нет. Prometheus перечитывает file_sd на лету, поэтому включение
   профиля сведётся к наполнению файла, без рестарта и правки конфига.
   Файл целей смонтирован в сервис prometheus явным volume.

Проверено на живом хосте, не на глаз:
- alloy fmt обоих .alloy в одноразовом контейнере grafana/alloy:v1.6.1 - exit 0
- promtool check config в prom/prometheus:v3.1.0 - valid, 16 rules found
- механизм нового healthcheck выполнен внутри работающего gendesign-alloy:
  первая строка ответа "HTTP/1.0 200 OK", grep матчится, RESULT=HEALTHY
- наличие bash/head/grep/printf в образе alloy подтверждено command -v
2026-08-26 13:14:28 +03:00
bot-backend
124cfb3d5d feat(observability): /metrics в обоих бэкендах — счётчики, задержка, дашборд
All checks were successful
CI / backend-tests (pull_request) Successful in 17m30s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m22s
CI Trade-In / backend-tests (pull_request) Successful in 4m51s
Третья часть #3078 и единственная, трогающая прод-код.

До неё числовых рядов у приложений не было вовсе: только логи и исключения в
GlitchTip. Класс отказов «отвечает, но медленно» и «отдаёт 401 потоком» в такой
картине невидим — исключения нет, строка в логе выглядит обычной, а продукт
при этом не работает.

Метка route — ШАБЛОН маршрута, а не путь запроса. Это несущее решение, а не
деталь: кадастровый номер или идентификатор заявки в метке даёт новый временной
ряд на каждую сущность, а ряд у Prometheus стоит памяти постоянно, а не в момент
запроса. Самый известный способ уронить мониторинг тем самым мониторингом.
Незаматченные пути (404, сканеры) сведены в одну метку, иначе тот же взрыв
устроит любой бот, перебирающий адреса. Оба свойства сторожатся тестами, а не
комментарием: тест бьёт тремя разными идентификаторами и требует ОДИН ряд.

Слой регистрируется последним и потому оказывается самым внешним. Изнутри
RBAC-гварда не видно ни отказов авторизации, ни времени, которое он тратит на
резолв сессии в БД auth, — а именно этот путь уже давал инцидент с блокирующим
I/O в middleware (#1202). Упавший исключением запрос считается как 500 в
finally: без этого он просто отсутствовал бы в счётчике, то есть ровно тогда,
когда метрики нужнее всего.

Путь публичен ВНУТРИ и закрыт СНАРУЖИ — это два разных периметра. Скрейп идёт
из docker-сети, где заголовка X-Authenticated-User нет ни у кого, поэтому
/metrics внесён в _PUBLIC_PATHS обоих бэкендов; иначе агент получал бы 401 и
метрик не было бы вовсе. Наружу путь не открывается ни через gendsgn.ru, ни
через meraocenka.ru, и вдобавок закрыт явным respond 404 в обоих site-блоках —
чтобы закрытость осталась решением, а не следствием текущего порядка директив.

Ограничитель частоты и аудит «Меры» не трогались: оба смотрят только на пути
под /api/, скрейп под них не попадает. Проверено тестом, а не чтением.

Прод-поведение не меняется ничем, кроме нового публичного пути: ни один
существующий обработчик, гвард или маршрут не тронут.

Refs #3078
2026-08-26 11:30:18 +03:00
bot-backend
7b6832e90f feat(observability): дашборд баз данных — раздутие, горизонт vacuum, WAL
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Вторая часть #3078. Конфигурация экспортеров приехала первым коммитом, но
смотреть на неё было негде: без витрины ряды есть, а ответа на вопрос нет.

Панели подобраны по разборам постфактум, а не по списку «что обычно рисуют».
Доля апдейтов мимо HOT — потому что у listings она была 0,43 % при 198
апдейтах на строку, и именно это дало 15 ГБ TOAST при 230 МБ живого
содержимого (#2992/#2989), копившиеся 91 день. Возраст самой старой
транзакции — потому что осиротевшие запросы висели 46 часов и держали
горизонт видимости, из-за чего autovacuum не убирал мёртвые строки во всей
базе (#2607). WAL за сутки — потому что 7,02 ГБ при четырёх пользовательских
расчётах это диспропорция, заметная только на ряде. Размер баз — потому что
у GlitchTip нет политики ретенции вовсе, и он растёт без ограничения.

Размеры разложены на heap / индексы / TOAST: суммарный размер таблицы не
объясняет ничего, а именно это разделение объяснило, куда ушли 19 ГБ.

Отдельная панель «экспортер отвечает»: пустой график и упавшая база выглядят
одинаково, и различать их должно что-то явное.

Refs #3078
2026-08-26 11:23:33 +03:00
bot-backend
309d273f3f feat(observability): стек метрик и логов — Prometheus, Loki, Grafana, агенты на обоих хостах
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Метрик в проекте не было ни одной: ни экспортеров, ни /metrics в бэкендах,
единственный канал наблюдения — journald, единственный сигнал об аварии —
исключение в GlitchTip. Из-за этого целый класс отказов невидим в принципе:
задача рапортует done, строк ноль, исключения нет. Так протухли данные на семь
месяцев (#2998), 34 дня был мёртв house_imv_backfill (#2698), 8 суток писал ноль
newbuilding_enrich (#2767), 91 день копилось раздутие listings (#2992).

Grafana не заменяет GlitchTip: ошибки остаются там. Grafana OSS не принимает
Sentry DSN ни одним компонентом, а скрубберы в before_send — требование 152-ФЗ.
Здесь появляется другой класс данных: ряды и алерты по трендам.

Наблюдатель поставлен у ДРУГОГО провайдера, чем наблюдаемое: серверная сторона
на Beget, рядом с GlitchTip. Если ляжет Poincare, мониторинг должен об этом
сказать, а не лечь вместе с ним.

Транспорт push, а не pull: агент на Poincare шлёт remote_write и логи исходящим
HTTPS, поэтому там не открывается ни одного входящего порта сверх 22/80/443.
При обрыве канала Alloy копит в WAL и досылает — pull-скрейп в той же ситуации
терял бы точки именно в аварии, ради которой мониторинг и нужен.

Два контура доступа с разными учётками. Пароль приёмника по построению лежит
открытым на продуктовом хосте, значит его компрометация неизбежна вместе с
хостом; будь это учётка витрины, утёк бы и доступ к дашбордам.

GlitchTip читается прямым SQL, а не Sentry-плагином: у плагина на 6.1.6
stats_v2 отдаёт 500 (баг GlitchTip #381), Events/Discover — 404 (#416), а в
grafana/sentry-datasource слово glitchtip не встречается ни разу. Схема сверена
на живой базе: колонка времени называется timestamp, а не received, и отдельной
таблицы IssueIndex не существует — агрегаты лежат на самой issue_events_issue.

Алерты за профилем alerts: канал доставки — открытый вопрос #3078, и стек не
должен на нём стоять. Деплой предупреждает, что уведомлять пока некому.

Каждая настройка, способная отказать молча, закрыта явно: ретенция Prometheus
задана и по времени и по размеру, retention_enabled у компактора Loki (без него
retention_period не работает вовсе), путь к журналу и запуск Alloy от root
(иначе агент читает ноль записей без ошибки), проверка Caddy до перезагрузки
(на этом хосте тот же Caddy держит git, errors и obsidian).

Refs #3078
2026-08-26 10:45:54 +03:00