Логи и мониторинг
Бот пишет логи в 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 — 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-клиента):
-- 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 бота. Никакого нового исходящего соединения из приложения не появляется.
services:
app:
logging:
driver: gelf
options:
gelf-address: "udp://graylog.internal:12201"
tag: reviewgate-botgelf-address резолвится демоном Docker с хоста, а не из сети compose — имя сервиса (udp://graylog:12201) не сработает, даже если Graylog в том же compose. Укажите IP/FQDN, доступный с хоста, либо udp://localhost:12201, если порт опубликован.message — отдельным шагом ниже.Развернуть JSON в поля Graylog
Docker GELF-драйвер кладёт строку stdout в поле message как есть — он транспорт, а не парсер. Поэтому вся JSON-запись приходит одной строкой внутри message, а поля джобы (project_path, mr_iid, head_sha, vendor) — внутри неё, ещё не развёрнуты. Метаданные контейнера (container_name, image_name, tag) Docker добавляет от себя — они сразу видны как поля. Разверните JSON на стороне Graylog — одним из двух способов ниже (не обоими).
active. Порядок менялся между версиями Graylog и настраивается вручную. Для способа 2 ниже порядок роли не играет: pipeline привязывается к Default Stream («All messages»), куда попадают все сообщения независимо от порядка. Он важен, только если позже привязать pipeline к пользовательскому стриму.Способ 1 — JSON-экстрактор на входе (рекомендуем: проще, без стримов)
- System → Inputs → у вашего GELF-input нажмите Manage extractors.
- Get started → Load Message — подтянет свежую запись, чтобы выбрать поле.
- Напротив поля
message→ Select extractor type → JSON. - Задайте Key prefix =
rg_и включите Flatten structures. Префикс обязателен — иначе внутренниеlevel/message/timestampперезапишут одноимённые служебные поля Graylog:messageиlevelзатрутся, а кривойtimestampможет привести к тому, что запись вовсе отбросится при индексации. С префиксом ключи станутrg_message/rg_levelи не столкнутся. - Create extractor.
Готово — экстрактор работает автоматически на всех новых сообщениях этого input; стрим и pipeline не нужны. Поля станут rg_project_path, rg_mr_iid, rg_context и т.д.
Способ 2 — Pipeline rule (чистые имена полей, если источников несколько)
Три объекта, и все три обязательны — обычно забывают третий:
- System → Pipelines → вкладка Manage rules → Create Rule → вставьте правило ниже → Save (проверит синтаксис).
- Manage pipelines → Add new pipeline → в Stage 0 добавьте это правило.
- Pipeline connections → Edit connections → подключите pipeline к Default Stream (All messages) → Save.
0. Именно на этом обычно «не парсится».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Проверка
- Search → раскройте новое сообщение → появились поля
project_path,mr_iid,context(способ 2) илиrg_project_pathи т.д. (способ 1). - Способ 2: System → Pipelines → Manage rules — у правила растёт Executed, а Failed = 0.
- Финальная проверка: поиск
project_path:"группа/проект"(или с префиксомrg_) возвращает сообщения.
mr_iid / context работают нативно. Для ELK / Loki — та же идея: JSON-парсер в коллекторе (Filebeat, Promtail) на строке лога.Метрики ревью в Postgres
Если боту задан DATABASE_URL, каждое ревью пишет строку в таблицу reviewgate_review_metrics: модели, токены, число находок, отброшенных судьёй, длительность. Только метаданные — ни кода, ни диффа, ни текста находок. Без DATABASE_URL учёт просто выключен, ревью работает. Строки старше METRICS_RETENTION_DAYS удаляются при записи.
| Колонка | Что внутри |
|---|---|
surface | webhook — проверка MR/PR, cli — прогон разработчика через сервер ревью. У строк, записанных до появления колонки, пусто — читать как webhook |
user_login, user_id | кто запустил прогон CLI; у ревью MR/PR пусто — там автор виден в самом MR/PR |
project_id, mr_iid | проект и MR/PR; mr_iid пуст у прогонов CLI — они идут вне MR/PR |
status | reviewed, cancelled (прогон CLI брошен — разработчик прервал или потерял связь), repeat_head (событие пришло на уже отревьюенную вершину — прогон пропущен, деньги не потрачены) либо причина сбоя: key_error, context_overflow, output_truncated, provider_overloaded и прочие — по ним видно, обо что спотыкается команда |
gen_*_tokens, val_*_tokens, cache_*_tokens | расход по ролям: генераторы, судья (валидатор либо арбитр), запись и чтение кэша |
findings_kept, findings_dropped | сколько находок осталось и сколько отсеял судья — та самая точность ревью |
Три запроса, которые обычно и нужны:
-- 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 в конфиге).