gendesign/ops/metrics/postgres/queries.yml
bot-backend 309d273f3f
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
feat(observability): стек метрик и логов — Prometheus, Loki, Grafana, агенты на обоих хостах
Метрик в проекте не было ни одной: ни экспортеров, ни /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

116 lines
7.8 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Дополнительные запросы для postgres_exporter (PG_EXPORTER_EXTEND_QUERY_PATH).
#
# Здесь только то, отсутствие чего уже стоило времени. Каждый блок — ответ на
# конкретный разбор постфактум, а не «полезно иметь».
#
# cache_seconds стоит у всех: экспортер скрейпится раз в 60 с, а часть запросов
# трогает pg_class по всей базе. Кэш держит нагрузку на уровне шума.
# ── Раздутие: обновления, идущие МИМО HOT ────────────────────────────────────
# Разбор #2992/#2989: у `listings` 198 апдейтов на строку при доле HOT 0,43 %.
# Каждый не-HOT апдейт переписывает строку во все индексы и заново тостит
# описание — отсюда TOAST 15 ГБ при ~230 МБ живого содержимого. Копилось 91 день,
# потому что смотреть было не на что.
pg_table_write_amplification:
query: |
SELECT
schemaname AS schema,
relname AS table,
n_tup_ins AS tup_ins,
n_tup_upd AS tup_upd,
n_tup_del AS tup_del,
n_tup_hot_upd AS tup_hot_upd,
n_live_tup AS live_tup,
n_dead_tup AS dead_tup,
COALESCE(EXTRACT(EPOCH FROM (now() - last_autovacuum)), -1) AS last_autovacuum_age_s,
COALESCE(EXTRACT(EPOCH FROM (now() - last_autoanalyze)), -1) AS last_autoanalyze_age_s
FROM pg_stat_user_tables
WHERE n_tup_upd > 0 OR n_live_tup > 10000
cache_seconds: 60
metrics:
- schema: { usage: "LABEL", description: "Схема" }
- table: { usage: "LABEL", description: "Таблица" }
- tup_ins: { usage: "COUNTER", description: "Вставлено строк" }
- tup_upd: { usage: "COUNTER", description: "Обновлено строк" }
- tup_del: { usage: "COUNTER", description: "Удалено строк" }
- tup_hot_upd: { usage: "COUNTER", description: "Из них HOT — не трогают индексы" }
- live_tup: { usage: "GAUGE", description: "Живых строк" }
- dead_tup: { usage: "GAUGE", description: "Мёртвых строк — работа для vacuum" }
- last_autovacuum_age_s: { usage: "GAUGE", description: "Секунд с последнего autovacuum, -1 если не было" }
- last_autoanalyze_age_s: { usage: "GAUGE", description: "Секунд с последнего autoanalyze, -1 если не было" }
# ── Размеры: куда именно уходит диск ─────────────────────────────────────────
# Отдельно heap, индексы и TOAST. Суммарный размер таблицы этого не показывает,
# а именно разделение объясняло, почему `listings` весила 19 ГБ при 230 МБ данных.
pg_table_size_detail:
query: |
SELECT
n.nspname AS schema,
c.relname AS table,
pg_relation_size(c.oid) AS heap_bytes,
pg_indexes_size(c.oid) AS index_bytes,
COALESCE(pg_total_relation_size(c.reltoastrelid), 0) AS toast_bytes,
pg_total_relation_size(c.oid) AS total_bytes
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
AND pg_total_relation_size(c.oid) > 10485760
cache_seconds: 300
metrics:
- schema: { usage: "LABEL", description: "Схема" }
- table: { usage: "LABEL", description: "Таблица" }
- heap_bytes: { usage: "GAUGE", description: "Сами строки" }
- index_bytes: { usage: "GAUGE", description: "Индексы" }
- toast_bytes: { usage: "GAUGE", description: "TOAST — вынесенные длинные значения" }
- total_bytes: { usage: "GAUGE", description: "Итого с учётом всего" }
# ── WAL: сколько журнала генерится ───────────────────────────────────────────
# Замер 20.08: 7,02 ГБ/сутки при примерно четырёх пользовательских расчётах в
# сутки. Диспропорция такого масштаба и есть симптом — но заметить её можно
# только имея ряд.
pg_wal_bytes:
query: |
SELECT
CAST(pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0') AS BIGINT) AS wal_bytes_total,
(SELECT count(*) FROM pg_ls_waldir()) AS wal_segments
cache_seconds: 60
metrics:
- wal_bytes_total: { usage: "COUNTER", description: "Позиция WAL от начала — рост даёт байт/сек" }
- wal_segments: { usage: "GAUGE", description: "Сегментов в pg_wal сейчас" }
# ── Горизонт vacuum и долгие транзакции ──────────────────────────────────────
# Разбор #2607: осиротевшие запросы висели 46 часов и держали горизонт, из-за
# чего vacuum не мог убрать мёртвые строки во всей базе. Одна забытая транзакция
# отравляет весь кластер, и снаружи это выглядит просто как «база пухнет».
pg_activity_horizon:
query: |
SELECT
COALESCE(MAX(EXTRACT(EPOCH FROM (now() - xact_start))), 0) AS oldest_xact_age_s,
COALESCE(MAX(EXTRACT(EPOCH FROM (now() - query_start))), 0) AS oldest_query_age_s,
count(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_transaction,
count(*) FILTER (WHERE wait_event_type = 'Lock') AS waiting_on_lock,
count(*) FILTER (WHERE backend_type = 'client backend') AS client_backends
FROM pg_stat_activity
WHERE backend_type = 'client backend'
cache_seconds: 30
metrics:
- oldest_xact_age_s: { usage: "GAUGE", description: "Возраст самой старой транзакции, сек" }
- oldest_query_age_s: { usage: "GAUGE", description: "Возраст самого старого запроса, сек" }
- idle_in_transaction: { usage: "GAUGE", description: "Открыта транзакция и ничего не делает — держит горизонт" }
- waiting_on_lock: { usage: "GAUGE", description: "Ждут блокировку" }
- client_backends: { usage: "GAUGE", description: "Клиентских соединений" }
# ── Размер баз ───────────────────────────────────────────────────────────────
# У GlitchTip нет политики ретенции вообще (проверено: grep по retention даёт
# ноль попаданий), база растёт без ограничения. Нужен ряд, чтобы поймать это
# до того, как кончится диск.
pg_database_size_bytes_detail:
query: |
SELECT datname AS database, pg_database_size(datname) AS bytes
FROM pg_database
WHERE datistemplate = false AND datallowconn = true
cache_seconds: 300
metrics:
- database: { usage: "LABEL", description: "База" }
- bytes: { usage: "GAUGE", description: "Размер, байт" }