эксплуатация

Логи и мониторинг

Бот пишет логи в stdout контейнера. Каждая строка ревью помечена тегом джобы — поток остаётся читаемым, даже когда к одному боту подключено несколько команд. Для централизованного стека (Graylog / ELK / Loki) есть структурный JSON-формат с полями конкретного MR/PR.

Тег джобы в логах

Смотреть логи можно как обычно: docker compose logs app (или docker compose logs -f app для потока). Каждая строка внутри ревью несёт тег джобы [группа/проект!7273 @40b1db1f] — путь репозитория, номер MR/PR и SHA головного коммита; разделитель перед номером зависит от вендора: ! у GitLab, # у GitHub. Когда команд несколько, grep по проекту или номеру собирает историю конкретного ревью, а строки соседних задач не путаются между собой.

Видно, на чём стоит ревью

Долгие шаги ревью объявляются в логе до вызова модели — строкой вида → Judge claude-opus-5: waiting for a response… — а по возвращении ответа идёт привычная строка с токенами и длительностью. Без первой строки минуты ожидания выглядели в логе как остановка: по записям было не отличить работающую джобу от зависшей. Метки ролей в логе английские и одноязычные: Generator — генератор, Extra generator — дополнительный генератор ансамбля, Judge — судья, Fix check — фокусная проверка фиксов, Chairman — арбитр (в логе он объявляется председателем панели). По ним сразу видно, какой именно вызов затянулся.

Структурный формат (JSON)

Для централизованного лог-стека включите структурный формат: LOG_FORMAT=json в .env. Атрибуция джобы уходит отдельными полями — project_path / mr_iid / head_sha / vendor / attempt — они фильтруются в Graylog, ELK и Loki без парсинга текста. По умолчанию (text) формат человекочитаемый, с тем же тегом в строке.

.env — структурные логи
# .env — LOG_FORMAT takes two values (strictly lower case):
#   text — human readable (THE DEFAULT), with the job tag inside the line
#   json — structured: one JSON line per record, with the job fields separate
LOG_FORMAT=json

# an example json record (a single line):
# {"level":"log","pid":1,"timestamp":1751970923893,"message":"Найдено замечаний: 2",
#  "context":"ReviewProcessor","project_path":"frontend/shop","mr_iid":7273,
#  "head_sha":"40b1db1f3b4beb7833a79682acae2c738328b159"}

Бюджет ревью: дашборд для владельца лимитов

Потолок трат (…_BUDGET_TOKENS) виден не только в сводках: каждый прогон пишет в Postgres-метрики худший достигнутый барьер (budget_barrier: approaching / exceeded / stop) и расход по бэкендам JSON'ом (tokens_by_backend). Владельцу лимитов не нужно читать MR/PR — достаточно панели Grafana поверх той же базы (или любого SQL-клиента):

SQL для панели Grafana — барьеры бюджета за неделю
-- budget thresholds over 7 days: how many runs hit one, and on which backend
SELECT date_trunc('day', created_at) AS day,
       budget_barrier,
       count(*)                      AS runs,
       -- total spend per backend: unfolding the tokens_by_backend JSON
       jsonb_object_agg_sample.backend,
       sum(jsonb_object_agg_sample.tokens::bigint) AS tokens
FROM reviewgate_review_metrics,
     LATERAL jsonb_each_text(tokens_by_backend::jsonb)
       AS jsonb_object_agg_sample(backend, tokens)
WHERE created_at > now() - interval '7 days'
  AND budget_barrier IS NOT NULL
GROUP BY 1, 2, 4
ORDER BY 1 DESC, tokens DESC;

Алертинг — вашими существующими средствами: на барьерах превышения бот пишет WARN/ERROR в свой лог (journald / Graylog ниже), стоп виден и в метриках (budget_barrier = 'stop').

Отправка в Graylog

Бот сам во внешние системы ничего не шлёт — логи уходят штатным лог-драйвером Docker (GELF) из compose бота. Никакого нового исходящего соединения из приложения не появляется.

docker-compose.yml — отправка логов в Graylog
services:
  app:
    logging:
      driver: gelf
      options:
        gelf-address: "udp://graylog.internal:12201"
        tag: reviewgate-bot
адрес резолвит хост, не compose-сетьgelf-address резолвится демоном Docker с хоста, а не из сети compose — имя сервиса (udp://graylog:12201) не сработает, даже если Graylog в том же compose. Укажите IP/FQDN, доступный с хоста, либо udp://localhost:12201, если порт опубликован.
на стороне GraylogСоздайте Input «GELF UDP» на 12201 (или «GELF TCP», если критично не терять записи — UDP доставку не гарантирует). Чтобы поля джобы стали фильтруемыми, их нужно развернуть из message — отдельным шагом ниже.

Развернуть JSON в поля Graylog

Docker GELF-драйвер кладёт строку stdout в поле message как есть — он транспорт, а не парсер. Поэтому вся JSON-запись приходит одной строкой внутри message, а поля джобы (project_path, mr_iid, head_sha, vendor) — внутри неё, ещё не развёрнуты. Метаданные контейнера (container_name, image_name, tag) Docker добавляет от себя — они сразу видны как поля. Разверните JSON на стороне Graylog — одним из двух способов ниже (не обоими).

порядок процессоров (для этого рецепта не критичен)System → Configurations → Message Processors: рекомендуется, чтобы Message Filter Chain выполнялся раньше Pipeline Processor (тогда пайплайнам видны поля экстракторов и роутинг по стримам), оба — active. Порядок менялся между версиями Graylog и настраивается вручную. Для способа 2 ниже порядок роли не играет: pipeline привязывается к Default Stream («All messages»), куда попадают все сообщения независимо от порядка. Он важен, только если позже привязать pipeline к пользовательскому стриму.

Способ 1 — JSON-экстрактор на входе (рекомендуем: проще, без стримов)

  1. System → Inputs → у вашего GELF-input нажмите Manage extractors.
  2. Get startedLoad Message — подтянет свежую запись, чтобы выбрать поле.
  3. Напротив поля messageSelect extractor typeJSON.
  4. Задайте Key prefix = rg_ и включите Flatten structures. Префикс обязателен — иначе внутренние level / message / timestamp перезапишут одноимённые служебные поля Graylog: message и level затрутся, а кривой timestamp может привести к тому, что запись вовсе отбросится при индексации. С префиксом ключи станут rg_message / rg_level и не столкнутся.
  5. Create extractor.

Готово — экстрактор работает автоматически на всех новых сообщениях этого input; стрим и pipeline не нужны. Поля станут rg_project_path, rg_mr_iid, rg_context и т.д.

Способ 2 — Pipeline rule (чистые имена полей, если источников несколько)

Три объекта, и все три обязательны — обычно забывают третий:

  1. System → Pipelines → вкладка Manage rulesCreate Rule → вставьте правило ниже → Save (проверит синтаксис).
  2. Manage pipelinesAdd new pipeline → в Stage 0 добавьте это правило.
  3. Pipeline connectionsEdit connections → подключите pipeline к Default Stream (All messages)Save.
без привязки к стриму правило не выполняетсяШаг 3 — самый частый пропуск. Правило, не привязанное к стриму, просто не запускается: счётчик Executed у него остаётся 0. Именно на этом обычно «не парсится».
Graylog — pipeline rule
rule "reviewgate: unfold the json from message"
when
  has_field("tag") && to_string($message.tag) == "reviewgate-bot"
  && starts_with(to_string($message.message), "{")
then
  // the message line is the bot's JSON record; we take the fields we need explicitly.
  // The service fields level/message/timestamp are NOT overwritten (that would break the record time).
  let j = parse_json(to_string($message.message));
  set_fields(select_jsonpath(j, {
    "project_path": "$.project_path",
    "mr_iid":       "$.mr_iid",
    "head_sha":     "$.head_sha",
    "context":      "$.context",
    "note_id":      "$.note_id",
    "attempt":      "$.attempt",
    "rg_message":   "$.message",
    "rg_level":     "$.level"
  }));
end

Проверка

только на новом сообщенииЭкстрактор и pipeline действуют лишь на записи, пришедшие после настройки — уже сохранённые не переразбираются. Дёрните свежую строку (любой MR/PR → бот залогирует) и открывайте новое сообщение.
  1. Search → раскройте новое сообщение → появились поля project_path, mr_iid, context (способ 2) или rg_project_path и т.д. (способ 1).
  2. Способ 2: System → Pipelines → Manage rules — у правила растёт Executed, а Failed = 0.
  3. Финальная проверка: поиск project_path:"группа/проект" (или с префиксом rg_) возвращает сообщения.
если поля не появилисьТри частые причины: (1) pipeline не привязан к стриму (способ 2, шаг 3); (2) открыли старое сообщение — нужно новое; (3) Pipeline Processor выключен (System → Configurations → Message Processors).
после этогоАгрегаты и дашборды по mr_iid / context работают нативно. Для ELK / Loki — та же идея: JSON-парсер в коллекторе (Filebeat, Promtail) на строке лога.

Метрики ревью в Postgres

Если боту задан DATABASE_URL, каждое ревью пишет строку в таблицу reviewgate_review_metrics: модели, токены, число находок, отброшенных судьёй, длительность. Только метаданные — ни кода, ни диффа, ни текста находок. Без DATABASE_URL учёт просто выключен, ревью работает. Строки старше METRICS_RETENTION_DAYS удаляются при записи.

КолонкаЧто внутри
surfacewebhook — проверка MR/PR, cli — прогон разработчика через сервер ревью. У строк, записанных до появления колонки, пусто — читать как webhook
user_login, user_idкто запустил прогон CLI; у ревью MR/PR пусто — там автор виден в самом MR/PR
project_id, mr_iidпроект и MR/PR; mr_iid пуст у прогонов CLI — они идут вне MR/PR
statusreviewed, cancelled (прогон CLI брошен — разработчик прервал или потерял связь), repeat_head (событие пришло на уже отревьюенную вершину — прогон пропущен, деньги не потрачены) либо причина сбоя: key_error, context_overflow, output_truncated, provider_overloaded и прочие — по ним видно, обо что спотыкается команда
gen_*_tokens, val_*_tokens, cache_*_tokensрасход по ролям: генераторы, судья (валидатор либо арбитр), запись и чтение кэша
findings_kept, findings_droppedсколько находок осталось и сколько отсеял судья — та самая точность ревью

Три запроса, которые обычно и нужны:

psql
-- who ran how many reviews in 30 days, and what it cost in tokens
SELECT user_login,
       count(*)                                  AS runs,
       sum(gen_in_tokens + val_in_tokens)        AS in_tokens,
       sum(gen_out_tokens + val_out_tokens)      AS out_tokens
  FROM reviewgate_review_metrics
 WHERE surface = 'cli' AND created_at > now() - interval '30 days'
 GROUP BY user_login
 ORDER BY out_tokens DESC;

-- comparing the surfaces: reviews of pull requests against runs in the editor
SELECT coalesce(surface, 'webhook') AS surface, count(*) AS runs,
       sum(gen_out_tokens + val_out_tokens) AS out_tokens
  FROM reviewgate_review_metrics
 WHERE created_at > now() - interval '30 days'
 GROUP BY 1;

-- what the team trips over (empty means everything goes through)
SELECT status, count(*) FROM reviewgate_review_metrics
 WHERE status <> 'reviewed' AND created_at > now() - interval '7 days'
 GROUP BY status ORDER BY 2 DESC;
стоимость считается по токенам, а не хранитсяЦену прогона таблица не пишет: прайс моделей живёт в конфигурации и меняется, а пересчитать исторические строки по новому прайсу нельзя. Умножайте токены на свои ставки — либо смотрите строку стоимости в самом ревью (cost.show в конфиге).

В логах нет кода

только метаданные ревьюБот принципиально не логирует дифф и код клиента — только метаданные ревью (проект, номер MR/PR, SHA, модели, счётчики находок, токены). Отправка логов в ваш лог-стек не расширяет поверхность утечки. Подробнее о границах приватности — на странице «Безопасность».