diff --git a/.forgejo/workflows/deploy-metrics.yml b/.forgejo/workflows/deploy-metrics.yml
new file mode 100644
index 00000000..64bfbb5e
--- /dev/null
+++ b/.forgejo/workflows/deploy-metrics.yml
@@ -0,0 +1,267 @@
+name: Deploy Metrics
+
+# Деплой стека наблюдаемости (#3078). Двухсторонний, и это существенно:
+#
+# server — на ИНФРАСТРУКТУРНЫЙ хост (Beget): Prometheus, Loki, Grafana,
+# Alertmanager. Наблюдатель намеренно живёт у другого провайдера,
+# чем наблюдаемое.
+# agent — на ОБА хоста: node-exporter, cAdvisor, postgres-exporter, Alloy.
+# Агент на продуктовом хосте шлёт push'ем, поэтому там не открывается
+# ни одного входящего порта.
+#
+# Продуктовый стек не трогается вовсе: другой project-name, другие compose-файлы,
+# deploy.yml остаётся в стороне.
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - "docker-compose.metrics.yml"
+ - "docker-compose.metrics-agent.yml"
+ - "ops/metrics/**"
+ - "caddy/sites/infra.caddy"
+ - "caddy/metrics-ui.caddy.snippet"
+ - "caddy/metrics-ingest.caddy.snippet"
+ - "scripts/setup-metrics-secrets.sh"
+ - ".forgejo/workflows/deploy-metrics.yml"
+ workflow_dispatch:
+
+concurrency:
+ group: deploy-metrics
+ cancel-in-progress: false
+
+jobs:
+ # ═══ СЕРВЕРНАЯ СТОРОНА — инфраструктурный хост ════════════════════════════
+ server:
+ runs-on: ubuntu-latest
+ if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
+ steps:
+ - uses: actions/checkout@v4
+
+ # Тот же приём, что в deploy-obsidian.yml (#3062, #3029): адресат и отпечаток
+ # берутся В ПАРЕ, без перекрёстного фолбэка. Сверять ключ Beget'а с отпечатком
+ # Poincare — гарантированный отказ.
+ - name: Адресат и подлинность инфраструктурного хоста
+ id: target
+ env:
+ INFRA_HOST: ${{ secrets.INFRA_DEPLOY_HOST }}
+ INFRA_FINGERPRINT: ${{ secrets.INFRA_DEPLOY_SSH_FINGERPRINT }}
+ MAIN_FINGERPRINT: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
+ run: |
+ set -euo pipefail
+ if [ -n "${INFRA_HOST:-}" ]; then
+ HOST_FINGERPRINT="${INFRA_FINGERPRINT:-}"
+ SRC="INFRA_DEPLOY_SSH_FINGERPRINT"
+ else
+ HOST_FINGERPRINT="${MAIN_FINGERPRINT:-}"
+ SRC="DEPLOY_SSH_FINGERPRINT"
+ fi
+ case "${HOST_FINGERPRINT}" in
+ *[![:print:]]*)
+ echo "ОШИБКА: ${SRC} содержит перевод строки или непечатный символ." >&2
+ exit 1
+ ;;
+ esac
+ echo "fingerprint=${HOST_FINGERPRINT}" >> "$GITHUB_OUTPUT"
+ if [ -z "${HOST_FINGERPRINT:-}" ]; then
+ echo "::warning title=SSH без проверки подлинности хоста::${SRC} не задан — ключ хоста НЕ проверяется (#3029)."
+ fi
+
+ - name: Поднять серверный стек
+ uses: appleboy/ssh-action@v1.0.3
+ with:
+ host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }}
+ username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
+ key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
+ port: ${{ secrets.INFRA_DEPLOY_PORT || secrets.DEPLOY_PORT || 22 }}
+ fingerprint: ${{ steps.target.outputs.fingerprint }}
+ command_timeout: 15m
+ script: |
+ set -euo pipefail
+ cd /opt/gendesign
+
+ git fetch origin main
+ git reset --hard origin/main
+
+ docker network inspect gendesign_shared >/dev/null 2>&1 \
+ || docker network create gendesign_shared
+
+ # Окружение нужно в shell, а не только в env_file: проверки вида
+ # ${VAR:?} у compose работают по переменным ОКРУЖЕНИЯ ПРОЦЕССА.
+ if [ -f backend/.env.runtime ]; then
+ set -a; . backend/.env.runtime; set +a
+ fi
+
+ # ── Проверка ДО подъёма, а не после ────────────────────────────
+ # Пустой токен даёт Alertmanager, который стартует зелёным и молча
+ # ничего не шлёт. Это ровно тот класс тихого отказа, ради которого
+ # весь стек и заводится, — ловим на пороге.
+ missing=""
+ for v in GRAFANA_ADMIN_PASSWORD GLITCHTIP_RO_PASSWORD \
+ METRICS_INGEST_USER METRICS_INGEST_PASSWORD; do
+ eval "val=\${$v:-}"
+ [ -z "$val" ] && missing="$missing $v"
+ done
+ if [ -n "$missing" ]; then
+ echo "ОШИБКА: в окружении хоста не заданы:$missing"
+ echo "Запусти один раз: bash scripts/setup-metrics-secrets.sh"
+ exit 1
+ fi
+
+ # Алерты включаются, только когда канал доставки реально задан.
+ # Поднимать Alertmanager с пустым токеном нельзя: он стартует
+ # зелёным и молча ничего не шлёт — ровно тот тихий отказ, ради
+ # которого весь стек и заводится.
+ PROFILES=""
+ if [ -n "${METRICS_TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${METRICS_TELEGRAM_CHAT_ID:-}" ]; then
+ PROFILES="alerts"
+ mkdir -p ops/metrics/alertmanager
+ METRICS_TELEGRAM_BOT_TOKEN="$METRICS_TELEGRAM_BOT_TOKEN" \
+ METRICS_TELEGRAM_CHAT_ID="$METRICS_TELEGRAM_CHAT_ID" \
+ envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID}' \
+ < ops/metrics/alertmanager/alertmanager.yml.tmpl \
+ > ops/metrics/alertmanager/alertmanager.yml
+ chmod 600 ops/metrics/alertmanager/alertmanager.yml
+ echo "Алерты: канал задан, Alertmanager поднимается."
+ else
+ echo "::warning title=Алерты выключены::METRICS_TELEGRAM_BOT_TOKEN/CHAT_ID не заданы. Метрики и логи собираются, но при срабатывании правила НИКТО не будет уведомлён. Канал доставки — открытый вопрос #3078."
+ fi
+
+ # ── read-only роль для датасорса GlitchTip ─────────────────────
+ # Идемпотентно. Прав на запись не выдаём вовсе: датасорс Grafana
+ # обязан быть безопасен даже при полном доступе к дашбордам.
+ bash scripts/setup-metrics-grafana-role.sh
+
+ COMPOSE_PROFILES="$PROFILES" \
+ docker compose -p gendesign-metrics -f docker-compose.metrics.yml pull --quiet
+ COMPOSE_PROFILES="$PROFILES" \
+ docker compose -p gendesign-metrics -f docker-compose.metrics.yml up -d --remove-orphans
+
+ # ── Caddy: СНАЧАЛА проверить, потом применять ──────────────────
+ # На этом хосте тот же Caddy обслуживает git., errors. и obsidian.
+ # Синтаксическая ошибка в infra.caddy положила бы их все, включая
+ # сам Forgejo, из которого идёт деплой. Поэтому validate — обязателен,
+ # и reload делается только после успешной проверки.
+ if docker compose -p gendesign -f docker-compose.prod.yml ps caddy --quiet | grep -q .; then
+ if docker compose -p gendesign -f docker-compose.prod.yml \
+ exec -T caddy caddy validate --config /etc/caddy/Caddyfile; then
+ docker compose -p gendesign -f docker-compose.prod.yml \
+ exec -T caddy caddy reload --config /etc/caddy/Caddyfile
+ echo "Caddy: конфиг проверен и перезагружен."
+ else
+ echo "ОШИБКА: Caddyfile не проходит проверку — reload НЕ выполнен."
+ echo "Работающий Caddy не тронут, домены живы. Чинить конфиг и повторять."
+ exit 1
+ fi
+ fi
+
+ # ── Приёмка ────────────────────────────────────────────────────
+ for i in $(seq 1 30); do
+ if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null; then
+ break
+ fi
+ sleep 3
+ done
+ docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps
+
+ # ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════
+ agent-apps:
+ runs-on: ubuntu-latest
+ needs: server
+ if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Агент на продуктовом хосте
+ uses: appleboy/ssh-action@v1.0.3
+ with:
+ host: ${{ secrets.DEPLOY_HOST }}
+ username: ${{ secrets.DEPLOY_USER }}
+ key: ${{ secrets.DEPLOY_SSH_KEY }}
+ port: ${{ secrets.DEPLOY_PORT || 22 }}
+ fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
+ command_timeout: 15m
+ script: |
+ set -euo pipefail
+ cd /opt/gendesign
+
+ git fetch origin main
+ git reset --hard origin/main
+
+ docker network inspect gendesign_shared >/dev/null 2>&1 \
+ || docker network create gendesign_shared
+
+ if [ -f backend/.env.runtime ]; then
+ set -a; . backend/.env.runtime; set +a
+ fi
+
+ if [ -z "${METRICS_INGEST_PASSWORD:-}" ]; then
+ echo "ОШИБКА: METRICS_INGEST_PASSWORD не задан — агенту нечем авторизоваться."
+ echo "Запусти на инфраструктурном хосте: bash scripts/setup-metrics-secrets.sh"
+ exit 1
+ fi
+
+ # DSN экспортеров собираются из уже имеющихся паролей БД, если их
+ # ещё нет. Отдельных секретов не заводим — лишняя копия пароля это
+ # лишнее место, откуда он может утечь.
+ bash scripts/setup-metrics-exporter-dsn.sh
+
+ set -a; . backend/.env.runtime; set +a
+
+ METRICS_ROLE=apps \
+ METRICS_ALLOY_CONFIG=alloy-apps.alloy \
+ COMPOSE_PROFILES=apps \
+ docker compose -p gendesign-metrics-agent \
+ -f docker-compose.metrics-agent.yml pull --quiet
+
+ METRICS_ROLE=apps \
+ METRICS_ALLOY_CONFIG=alloy-apps.alloy \
+ COMPOSE_PROFILES=apps \
+ docker compose -p gendesign-metrics-agent \
+ -f docker-compose.metrics-agent.yml up -d
+
+ sleep 10
+ METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES=apps \
+ docker compose -p gendesign-metrics-agent \
+ -f docker-compose.metrics-agent.yml ps
+
+ agent-infra:
+ runs-on: ubuntu-latest
+ needs: server
+ if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Агент на инфраструктурном хосте
+ uses: appleboy/ssh-action@v1.0.3
+ with:
+ host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }}
+ username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
+ key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
+ port: ${{ secrets.INFRA_DEPLOY_PORT || secrets.DEPLOY_PORT || 22 }}
+ fingerprint: ${{ secrets.INFRA_DEPLOY_SSH_FINGERPRINT }}
+ command_timeout: 15m
+ script: |
+ set -euo pipefail
+ cd /opt/gendesign
+
+ if [ -f backend/.env.runtime ]; then
+ set -a; . backend/.env.runtime; set +a
+ fi
+
+ METRICS_ROLE=infra \
+ METRICS_ALLOY_CONFIG=alloy-infra.alloy \
+ COMPOSE_PROFILES=infra \
+ docker compose -p gendesign-metrics-agent \
+ -f docker-compose.metrics-agent.yml pull --quiet
+
+ METRICS_ROLE=infra \
+ METRICS_ALLOY_CONFIG=alloy-infra.alloy \
+ COMPOSE_PROFILES=infra \
+ docker compose -p gendesign-metrics-agent \
+ -f docker-compose.metrics-agent.yml up -d
+
+ sleep 10
+ METRICS_ROLE=infra METRICS_ALLOY_CONFIG=alloy-infra.alloy COMPOSE_PROFILES=infra \
+ docker compose -p gendesign-metrics-agent \
+ -f docker-compose.metrics-agent.yml ps
diff --git a/.gitignore b/.gitignore
index 53ceef6e..2ece89ee 100644
--- a/.gitignore
+++ b/.gitignore
@@ -98,3 +98,7 @@ ds-bundle/
.design-sync/.cache/
.design-sync/learnings/
.design-sync/node_modules
+
+# Боевой конфиг Alertmanager собирается на хосте из .tmpl (deploy-metrics.yml):
+# содержит токен бота и идентификатор чата, поэтому в репозиторий не попадает.
+ops/metrics/alertmanager/alertmanager.yml
diff --git a/caddy/metrics-ingest.caddy.snippet b/caddy/metrics-ingest.caddy.snippet
new file mode 100644
index 00000000..b99d098f
--- /dev/null
+++ b/caddy/metrics-ingest.caddy.snippet
@@ -0,0 +1,15 @@
+# Приём метрик и логов от агентов — машинный контур metrics.gendsgn.ru/ingest/*.
+#
+# ОТДЕЛЬНАЯ УЧЁТКА, А НЕ ТА ЖЕ, ЧТО У ВИТРИНЫ. Пароль приёмника лежит в открытом
+# виде в окружении продуктового хоста (агенту нужно им авторизоваться), то есть
+# компрометация Poincare раскрывает его автоматически. Если бы это была учётка
+# витрины, вместе с ней утёк бы и доступ к самим дашбордам и к Prometheus, где
+# видна вся картина инфраструктуры. Разделение ограничивает ущерб записью.
+#
+# Пароль — METRICS_INGEST_PASSWORD, задан на ОБОИХ хостах (на Beget как источник
+# истины, на Poincare для агента). Сюда попадает только bcrypt-хеш.
+# Сгенерирован scripts/setup-metrics-secrets.sh 2026-08-26.
+
+basic_auth bcrypt "GenDesign Metrics Ingest" {
+ alloy JDJhJDE0JGMvTmxIenZUbW00cEtJVm01OGl0dmVRL0VSNk1mNjIwbDFvQWl1VTRwaEVaa1FHWXFUdEVl # агент Alloy, пароль в METRICS_INGEST_PASSWORD
+}
diff --git a/caddy/metrics-ui.caddy.snippet b/caddy/metrics-ui.caddy.snippet
new file mode 100644
index 00000000..7652bd8c
--- /dev/null
+++ b/caddy/metrics-ui.caddy.snippet
@@ -0,0 +1,19 @@
+# Витрина мониторинга — внешний контур доступа к metrics.gendsgn.ru.
+#
+# Пароль живёт в окружении хоста как METRICS_UI_PASSWORD (backend/.env.runtime),
+# сюда попадает только bcrypt-хеш — его публикация ничего не раскрывает.
+# Сгенерирован ops/../scripts/setup-metrics-secrets.sh 2026-08-26.
+#
+# ЭТО ВТОРОЙ СЛОЙ, А НЕ ЕДИНСТВЕННЫЙ. У Grafana есть собственный вход с ролями
+# (GF_USERS_ALLOW_SIGN_UP=false), и он остаётся включённым. Внешний basic_auth
+# нужен потому, что дашборд смотрит в интернет: он отсекает сканеры и любую
+# будущую дыру в самой Grafana до того, как она станет доступной снаружи.
+# Если два запроса пароля подряд окажутся неудобны — убрать ОДНУ строку
+# `import caddy/metrics-ui.caddy.snippet` из infra.caddy, вход Grafana останется.
+#
+# Формат: username base64(bcrypt_hash) # комментарий
+# Caddy 2.11+ требует именно base64 от bcrypt-хеша.
+
+basic_auth bcrypt "GenDesign Metrics" {
+ metrics JDJhJDE0JFpObUNJUzVGZnd3blRKQXE3cDIxVWVEaG1udjY0cHVyVmY3OE9Ga2xubjE5RC90elZkL0dD # витрина, пароль в METRICS_UI_PASSWORD на Beget
+}
diff --git a/caddy/sites/infra.caddy b/caddy/sites/infra.caddy
index e8ecc929..b39f3de7 100644
--- a/caddy/sites/infra.caddy
+++ b/caddy/sites/infra.caddy
@@ -62,3 +62,44 @@ git.gendsgn.ru {
output file /var/log/caddy/git.gendsgn.ru.log
}
}
+
+# Мониторинг — Grafana + приёмник метрик и логов (задача #3078).
+# DNS: A-record metrics.gendsgn.ru → 46.173.16.127 (заведена 2026-08-26).
+#
+# ПОЧЕМУ ЗДЕСЬ, А НЕ В apps.caddy. Наблюдатель сознательно поставлен у ДРУГОГО
+# провайдера, чем наблюдаемое: продукт живёт на Selectel Poincare, и если ляжет
+# он, мониторинг обязан выжить и сказать об этом. Стек поднимается отдельным
+# compose-проектом gendesign-metrics и виден Caddy через сеть gendesign_shared.
+#
+# ДВА КОНТУРА С РАЗНЫМИ УЧЁТКАМИ:
+# /ingest/* — машинный. Агент Alloy с Poincare шлёт сюда remote_write и логи
+# исходящим HTTPS. Благодаря этому на Poincare не открыт НИ ОДИН
+# входящий порт сверх 22/80/443.
+# всё прочее — витрина Grafana под отдельным паролем.
+# Пароль приёмника по построению лежит в открытом виде на продуктовом хосте;
+# разделив учётки, мы не отдаём вместе с ним доступ к дашбордам.
+metrics.gendsgn.ru {
+ encode zstd gzip
+
+ # handle_path срезает префикс: Prometheus получает /api/v1/write,
+ # как если бы обращались к нему напрямую.
+ handle_path /ingest/prometheus/* {
+ import ../metrics-ingest.caddy.snippet
+ reverse_proxy prometheus:9090
+ }
+
+ # Loki ожидает путь /loki/api/v1/push — он и остаётся после среза /ingest/loki.
+ handle_path /ingest/loki/* {
+ import ../metrics-ingest.caddy.snippet
+ reverse_proxy loki:3100
+ }
+
+ handle {
+ import ../metrics-ui.caddy.snippet
+ reverse_proxy grafana:3000
+ }
+
+ log {
+ output file /var/log/caddy/metrics.gendsgn.ru.log
+ }
+}
diff --git a/docker-compose.metrics-agent.yml b/docker-compose.metrics-agent.yml
new file mode 100644
index 00000000..e079e638
--- /dev/null
+++ b/docker-compose.metrics-agent.yml
@@ -0,0 +1,223 @@
+# Стек наблюдаемости — АГЕНТСКАЯ сторона. Поднимается на КАЖДОМ хосте: и на Beget
+# (рядом с сервером), и на Poincare (рядом с продуктом).
+#
+# docker compose -p gendesign-metrics-agent -f docker-compose.metrics-agent.yml up -d
+#
+# Что делает: собирает метрики хоста (node-exporter), контейнеров (cAdvisor), баз
+# (postgres-exporter) и логи journald, после чего Alloy отправляет всё на приёмник.
+#
+# ПОЧЕМУ АГЕНТ, А НЕ СКРЕЙП ИЗ ЦЕНТРА. Prometheus на Beget не может дотянуться до
+# экспортеров на Poincare, не открыв там входящие порты — а ufw на Poincare намеренно
+# держит только 22/80/443. Агент решает это push'ем: исходящий HTTPS уже разрешён.
+# Побочный выигрыш — при обрыве канала Alloy копит в WAL и досылает, тогда как
+# pull-скрейп просто теряет точки.
+#
+# КОНФИГУРАЦИЯ ЗАВИСИТ ОТ ХОСТА — задаётся переменными окружения, файл один:
+#
+# На Beget (инфраструктура, приёмник рядом):
+# METRICS_ROLE=infra
+# METRICS_ALLOY_CONFIG=alloy-infra.alloy # пишет напрямую в prometheus:9090 / loki:3100
+# COMPOSE_PROFILES=infra
+#
+# На Poincare (продукт, приёмник за интернетом):
+# METRICS_ROLE=apps
+# METRICS_ALLOY_CONFIG=alloy-apps.alloy # пишет в https://metrics.gendsgn.ru под basic_auth
+# COMPOSE_PROFILES=apps
+# METRICS_INGEST_USER / METRICS_INGEST_PASSWORD — учётка приёмника
+#
+# Профили решают, какие postgres-exporter'ы поднимать: на Poincare две базы
+# (Птица + МЕРА), на Beget одна (infra-postgres с forgejo и glitchtip).
+
+x-logging: &default-logging
+ driver: journald
+
+services:
+ # ── Alloy: единый агент метрик и логов ───────────────────────────────────────
+ # Почему Alloy, а не Promtail: у Promtail EOL 2 марта 2026. Alloy читает journald
+ # нативно и умеет одновременно скрейпить Prometheus-эндпоинты.
+ alloy:
+ image: grafana/alloy:v1.6.1
+ container_name: gendesign-alloy
+ restart: unless-stopped
+ # root нужен для чтения /var/log/journal. Штатный пользователь `alloy` требует
+ # членства в группах adm и systemd-journal — внутри контейнера этих групп с
+ # правильными gid хоста нет, и агент молча читает НОЛЬ записей. Проверено как
+ # известные грабли: отказ выглядит как «логов просто нет».
+ user: root
+ command:
+ - "run"
+ - "--server.http.listen-addr=0.0.0.0:12345"
+ - "--storage.path=/var/lib/alloy/data"
+ - "/etc/alloy/config.alloy"
+ env_file:
+ - path: ./backend/.env.runtime
+ required: false
+ - path: ./backend/.env
+ required: false
+ environment:
+ # Метка хоста попадает во все ряды и логи — без неё графики двух машин
+ # сливаются в один и разобрать, где что, невозможно.
+ METRICS_HOST_LABEL: ${METRICS_ROLE:?zadaj METRICS_ROLE=infra ili apps}
+ METRICS_INGEST_USER: ${METRICS_INGEST_USER:-}
+ METRICS_INGEST_PASSWORD: ${METRICS_INGEST_PASSWORD:-}
+ volumes:
+ - ./ops/metrics/alloy/${METRICS_ALLOY_CONFIG:?zadaj METRICS_ALLOY_CONFIG}:/etc/alloy/config.alloy:ro
+ # Явный путь к журналу обязателен. Без него libsystemd применяет
+ # SD_JOURNAL_LOCAL_ONLY и хостовые логи из контейнера не видны — при этом
+ # ошибки нет, просто пустой поток.
+ - /var/log/journal:/var/log/journal:ro
+ - /etc/machine-id:/etc/machine-id:ro
+ - alloy_data:/var/lib/alloy/data
+ expose:
+ - "12345"
+ networks:
+ - shared
+ mem_limit: 512m
+ logging: *default-logging
+ healthcheck:
+ test: ["CMD-SHELL", "wget -q --spider http://localhost:12345/-/ready || exit 1"]
+ interval: 30s
+ timeout: 10s
+ retries: 5
+ start_period: 30s
+
+ # ── node-exporter: CPU, память, диск, сеть, IO хоста ─────────────────────────
+ node-exporter:
+ image: prom/node-exporter:v1.8.2
+ container_name: gendesign-node-exporter
+ restart: unless-stopped
+ command:
+ - "--path.procfs=/host/proc"
+ - "--path.sysfs=/host/sys"
+ - "--path.rootfs=/host/root"
+ # Без исключения оверлеев метрика файловой системы засоряется десятками
+ # слоёв docker, и «свободное место на диске» перестаёт читаться глазами.
+ - "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc|var/lib/docker/.+)($$|/)"
+ - "--collector.filesystem.fs-types-exclude=^(autofs|binfmt_misc|cgroup2?|configfs|debugfs|devpts|devtmpfs|fusectl|hugetlbfs|mqueue|nsfs|overlay|proc|procfs|pstore|rpc_pipefs|securityfs|selinuxfs|squashfs|sysfs|tracefs)$$"
+ pid: host
+ volumes:
+ - /proc:/host/proc:ro
+ - /sys:/host/sys:ro
+ - /:/host/root:ro,rslave
+ expose:
+ - "9100"
+ networks:
+ - shared
+ mem_limit: 128m
+ logging: *default-logging
+
+ # ── cAdvisor: память и CPU по контейнерам ────────────────────────────────────
+ # Читает докер-сокет, поэтому видит ВСЕ контейнеры хоста независимо от сетей —
+ # отдельно подключать его к gendesign_default не нужно.
+ cadvisor:
+ image: gcr.io/cadvisor/cadvisor:v0.52.1
+ container_name: gendesign-cadvisor
+ restart: unless-stopped
+ privileged: true
+ devices:
+ - /dev/kmsg:/dev/kmsg
+ command:
+ # Урезаем набор метрик: полный cAdvisor выдаёт тысячи рядов на контейнер и
+ # раздувает TSDB на порядок ради данных, которые никто не смотрит.
+ - "--docker_only=true"
+ - "--housekeeping_interval=30s"
+ - "--disable_metrics=percpu,sched,tcp,udp,advtcp,process,hugetlb,referenced_memory,cpu_topology,resctrl"
+ - "--store_container_labels=false"
+ volumes:
+ - /:/rootfs:ro
+ - /var/run:/var/run:ro
+ - /sys:/sys:ro
+ - /var/lib/docker/:/var/lib/docker:ro
+ - /dev/disk/:/dev/disk:ro
+ expose:
+ - "8080"
+ networks:
+ - shared
+ mem_limit: 384m
+ logging: *default-logging
+
+ # ── postgres-exporter: Птица (только на Poincare) ────────────────────────────
+ # WAL/сутки, n_tup_upd против n_tup_hot_upd, рост TOAST, коннекты, горизонт
+ # vacuum — то, из-за отсутствия чего раздутие копилось 91 день незамеченным.
+ postgres-exporter-gendesign:
+ image: quay.io/prometheuscommunity/postgres-exporter:v0.16.0
+ container_name: gendesign-pg-exporter-gendesign
+ restart: unless-stopped
+ profiles: ["apps"]
+ env_file:
+ - path: ./backend/.env.runtime
+ required: false
+ environment:
+ DATA_SOURCE_NAME: ${GENDESIGN_EXPORTER_DSN:?nyzhen v backend/.env.runtime}
+ PG_EXPORTER_EXTEND_QUERY_PATH: /etc/pg-queries.yml
+ volumes:
+ - ./ops/metrics/postgres/queries.yml:/etc/pg-queries.yml:ro
+ expose:
+ - "9187"
+ networks:
+ - shared
+ - product
+ mem_limit: 128m
+ logging: *default-logging
+
+ # ── postgres-exporter: МЕРА (только на Poincare) ─────────────────────────────
+ postgres-exporter-tradein:
+ image: quay.io/prometheuscommunity/postgres-exporter:v0.16.0
+ container_name: gendesign-pg-exporter-tradein
+ restart: unless-stopped
+ profiles: ["apps"]
+ env_file:
+ - path: ./backend/.env.runtime
+ required: false
+ environment:
+ DATA_SOURCE_NAME: ${TRADEIN_EXPORTER_DSN:?nyzhen v backend/.env.runtime}
+ PG_EXPORTER_EXTEND_QUERY_PATH: /etc/pg-queries.yml
+ volumes:
+ - ./ops/metrics/postgres/queries.yml:/etc/pg-queries.yml:ro
+ expose:
+ - "9187"
+ networks:
+ - shared
+ mem_limit: 128m
+ logging: *default-logging
+
+ # ── postgres-exporter: инфраструктурная БД (только на Beget) ─────────────────
+ # forgejo + glitchtip. Нужен и сам по себе, и как страховка: рост базы glitchtip
+ # ничем не ограничен — политики ретенции у GlitchTip нет вообще.
+ postgres-exporter-infra:
+ image: quay.io/prometheuscommunity/postgres-exporter:v0.16.0
+ container_name: gendesign-pg-exporter-infra
+ restart: unless-stopped
+ profiles: ["infra"]
+ env_file:
+ - path: ./backend/.env.runtime
+ required: false
+ environment:
+ DATA_SOURCE_NAME: ${INFRA_EXPORTER_DSN:?nyzhen v backend/.env.runtime}
+ PG_EXPORTER_EXTEND_QUERY_PATH: /etc/pg-queries.yml
+ volumes:
+ - ./ops/metrics/postgres/queries.yml:/etc/pg-queries.yml:ro
+ expose:
+ - "9187"
+ networks:
+ - shared
+ - infra
+ mem_limit: 128m
+ logging: *default-logging
+
+volumes:
+ alloy_data:
+
+networks:
+ shared:
+ external: true
+ name: gendesign_shared
+ # Сеть продуктового стека Птицы — в ней gendesign-postgres-1 на Poincare.
+ product:
+ external: true
+ name: gendesign_default
+ # На Beget это та же по имени сеть, но с инфраструктурными контейнерами.
+ # Разные алиасы в файле нужны, чтобы профили не тянули лишнюю сеть на чужом хосте.
+ infra:
+ external: true
+ name: gendesign_default
diff --git a/docker-compose.metrics.yml b/docker-compose.metrics.yml
new file mode 100644
index 00000000..4b70b18d
--- /dev/null
+++ b/docker-compose.metrics.yml
@@ -0,0 +1,202 @@
+# Стек наблюдаемости — СЕРВЕРНАЯ сторона. Живёт на инфраструктурном хосте (Beget).
+#
+# Деплоится ОТДЕЛЬНО от продуктового стека, своим project-name:
+# docker compose -p gendesign-metrics -f docker-compose.metrics.yml up -d
+#
+# ПОЧЕМУ ЗДЕСЬ, А НЕ РЯДОМ С ПРОДОМ. После переезда 25.08 продукт живёт на Selectel
+# Poincare, инфраструктура (Forgejo, GlitchTip, CouchDB) осталась на Beget. Наблюдатель
+# сознательно поставлен у ДРУГОГО провайдера, чем наблюдаемое: если ляжет Poincare,
+# мониторинг должен об этом сказать, а не лечь вместе с ним.
+#
+# ТРАНСПОРТ — PUSH, А НЕ PULL. Prometheus не ходит на Poincare за метриками: там агент
+# Alloy сам шлёт remote_write и loki.write исходящим HTTPS через metrics.gendsgn.ru.
+# Поэтому на Poincare не открывается НИ ОДНОГО входящего порта — ufw там остаётся
+# 22/80/443. Приёмник включён флагом --web.enable-remote-write-receiver ниже.
+#
+# СЕТИ. Обе внешние, обе уже существуют на Beget:
+# gendesign_shared — через неё Caddy ходит на grafana:3000 (и couchdb, garmin-mcp)
+# gendesign_default — в ней infra-postgres, glitchtip, forgejo: их скрейпит агент
+# Тот же приём, что у Caddy — он подключён к обеим.
+#
+# ПОРТЫ НАРУЖУ НЕ ПУБЛИКУЮТСЯ. Только expose. Наружу выходит один хост
+# metrics.gendsgn.ru через Caddy под basic_auth (caddy/sites/infra.caddy).
+# 3000/9090/3100/9093 на 0.0.0.0 не биндятся сознательно: на Beget уже был случай,
+# когда redis уехал в интернет из-за лишней записи ports в базовом compose-файле.
+
+x-logging: &default-logging
+ driver: journald
+
+services:
+ # ── Prometheus: хранилище временных рядов + движок правил ────────────────────
+ prometheus:
+ image: prom/prometheus:v3.1.0
+ container_name: gendesign-prometheus
+ restart: unless-stopped
+ user: "65534:65534" # nobody — том создаётся снаружи, см. deploy-metrics.yml
+ command:
+ - "--config.file=/etc/prometheus/prometheus.yml"
+ - "--storage.tsdb.path=/prometheus"
+ # Ретенция задана ЯВНО и по обоим измерениям. Без --storage.tsdb.retention.size
+ # диск съедается молча: по умолчанию ограничение только по времени, а сколько
+ # это в байтах — зависит от числа рядов, которое растёт само.
+ - "--storage.tsdb.retention.time=30d"
+ - "--storage.tsdb.retention.size=8GB"
+ # Приёмник push-метрик от агентов. Без флага remote_write отвечает 404,
+ # и агент на Poincare будет молча копить в WAL, а графики останутся пустыми.
+ - "--web.enable-remote-write-receiver"
+ # Нужен для перезагрузки правил без рестарта (deploy-metrics.yml дёргает).
+ - "--web.enable-lifecycle"
+ - "--web.external-url=https://metrics.gendsgn.ru/prometheus"
+ - "--web.route-prefix=/"
+ volumes:
+ - ./ops/metrics/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
+ - ./ops/metrics/prometheus/rules:/etc/prometheus/rules:ro
+ - prometheus_data:/prometheus
+ expose:
+ - "9090"
+ networks:
+ - shared
+ - infra
+ mem_limit: 2g
+ logging: *default-logging
+ healthcheck:
+ test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"]
+ interval: 30s
+ timeout: 10s
+ retries: 5
+ start_period: 30s
+
+ # ── Loki: логи. Monolithic-режим — рекомендован до ~20 ГБ/сутки ──────────────
+ # У нас ~39 МБ/сутки (замер 24.08), запас в 500 раз. Микросервисный режим здесь
+ # был бы чистой сложностью без выигрыша.
+ loki:
+ image: grafana/loki:3.3.2
+ container_name: gendesign-loki
+ restart: unless-stopped
+ user: "10001:10001"
+ command: ["-config.file=/etc/loki/loki-config.yml"]
+ volumes:
+ - ./ops/metrics/loki/loki-config.yml:/etc/loki/loki-config.yml:ro
+ - loki_data:/loki
+ expose:
+ - "3100"
+ networks:
+ - shared
+ mem_limit: 1g
+ logging: *default-logging
+ healthcheck:
+ test: ["CMD", "wget", "-q", "--spider", "http://localhost:3100/ready"]
+ interval: 30s
+ timeout: 10s
+ retries: 10
+ start_period: 60s
+
+ # ── Alertmanager: маршрутизация и дедупликация алертов ───────────────────────
+ # Шлёт в Telegram НАПРЯМУЮ с Beget, а не через бэкенд МЕРЫ. Это осознанно:
+ # доставка алерта не должна зависеть от хоста, про который алерт. Если Poincare
+ # лёг, сообщение об этом обязано уйти без его участия.
+ alertmanager:
+ image: prom/alertmanager:v0.28.0
+ container_name: gendesign-alertmanager
+ restart: unless-stopped
+ user: "65534:65534"
+ # За профилем: канал доставки алертов — открытый вопрос #3078 (тот же чат,
+ # что у вебхука GlitchTip, или отдельный; порог ночной побудки). Пока он не
+ # решён, стек метрик поднимается и работает без Alertmanager, а не ждёт его.
+ # Включается добавлением `alerts` в COMPOSE_PROFILES — см. часть 5.
+ profiles: ["alerts"]
+ command:
+ - "--config.file=/etc/alertmanager/alertmanager.yml"
+ - "--storage.path=/alertmanager"
+ - "--web.external-url=https://metrics.gendsgn.ru/alertmanager"
+ env_file:
+ - path: ./backend/.env.runtime
+ required: false
+ - path: ./backend/.env
+ required: false
+ environment:
+ # Токен и чат берутся из окружения — в репозиторий не попадают.
+ # Отсутствие проверяется в deploy-metrics.yml ДО подъёма: пустой токен
+ # даёт Alertmanager, который стартует зелёным и молча ничего не шлёт.
+ METRICS_TELEGRAM_BOT_TOKEN: ${METRICS_TELEGRAM_BOT_TOKEN:-}
+ METRICS_TELEGRAM_CHAT_ID: ${METRICS_TELEGRAM_CHAT_ID:-}
+ volumes:
+ - ./ops/metrics/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
+ - alertmanager_data:/alertmanager
+ expose:
+ - "9093"
+ networks:
+ - shared
+ mem_limit: 256m
+ logging: *default-logging
+ healthcheck:
+ test: ["CMD", "wget", "-q", "--spider", "http://localhost:9093/-/healthy"]
+ interval: 30s
+ timeout: 10s
+ retries: 5
+
+ # ── Grafana: витрина ─────────────────────────────────────────────────────────
+ grafana:
+ image: grafana/grafana:11.5.1
+ container_name: gendesign-grafana
+ restart: unless-stopped
+ user: "472:472"
+ env_file:
+ - path: ./backend/.env.runtime
+ required: false
+ - path: ./backend/.env
+ required: false
+ environment:
+ # Доступ закрыт Caddy basic_auth снаружи. Внутренний вход Grafana оставлен
+ # включённым намеренно: анонимный Viewer + отключённый логин лишили бы
+ # возможности что-то настроить, а один общий пароль в Caddy не даёт
+ # разделения ролей внутри.
+ GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
+ GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-}
+ GF_SERVER_ROOT_URL: https://metrics.gendsgn.ru/
+ GF_SERVER_SERVE_FROM_SUB_PATH: "false"
+ # Телеметрия наружу — выключена. Отдельный хост, отдельный провайдер, и не
+ # хочется, чтобы наблюдатель сам ходил в интернет без нужды.
+ GF_ANALYTICS_REPORTING_ENABLED: "false"
+ GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
+ GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES: "false"
+ GF_NEWS_NEWS_FEED_ENABLED: "false"
+ GF_USERS_ALLOW_SIGN_UP: "false"
+ # Датасорс к БД GlitchTip: пароль read-only роли из окружения.
+ GLITCHTIP_RO_PASSWORD: ${GLITCHTIP_RO_PASSWORD:-}
+ TRADEIN_RO_PASSWORD: ${TRADEIN_RO_PASSWORD:-}
+ GENDESIGN_RO_PASSWORD: ${GENDESIGN_RO_PASSWORD:-}
+ volumes:
+ - ./ops/metrics/grafana/provisioning:/etc/grafana/provisioning:ro
+ - ./ops/metrics/grafana/dashboards:/var/lib/grafana/dashboards:ro
+ - grafana_data:/var/lib/grafana
+ expose:
+ - "3000"
+ networks:
+ - shared
+ - infra
+ mem_limit: 512m
+ logging: *default-logging
+ depends_on:
+ - prometheus
+ - loki
+ healthcheck:
+ test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/api/health || exit 1"]
+ interval: 30s
+ timeout: 10s
+ retries: 5
+ start_period: 30s
+
+volumes:
+ prometheus_data:
+ loki_data:
+ grafana_data:
+ alertmanager_data:
+
+networks:
+ shared:
+ external: true
+ name: gendesign_shared
+ infra:
+ external: true
+ name: gendesign_default
diff --git a/docs/observability.md b/docs/observability.md
new file mode 100644
index 00000000..590c426c
--- /dev/null
+++ b/docs/observability.md
@@ -0,0 +1,164 @@
+# Наблюдаемость: метрики, логи, алерты
+
+Задача #3078. Стек живёт по адресу `https://metrics.gendsgn.ru/`.
+
+## Зачем это заведено
+
+Метрик в проекте не было ни одной — ни Prometheus, ни экспортеров, ни `/metrics`
+в бэкендах. Единственным каналом наблюдения оставался journald, а единственным
+сигналом об аварии — исключение в GlitchTip. Из-за этого целый класс отказов был
+невидим в принципе: задача рапортует `done`, строк ноль, исключения нет, метрики
+нет. Так протухли данные на семь месяцев (#2846/#2998), так 34 дня был мёртв
+`house_imv_backfill` (#2698), так 8 суток писал ноль записей `newbuilding_enrich`
+(#2767), так 91 день копилось раздутие `listings` (#2992).
+
+Grafana **не заменяет** GlitchTip. Ошибки остаются там: Grafana OSS не принимает
+Sentry DSN ни одним компонентом, а 549 строк скрубберов в `before_send` — это
+требование 152-ФЗ, а не фича трекера. Здесь появляется другой класс данных —
+числовые ряды и алерты по трендам вместо алертов по событиям.
+
+## Топология
+
+```
+ Poincare (188.246.224.93) — продукт Beget (46.173.16.127) — инфраструктура
+ ┌──────────────────────────────┐ ┌────────────────────────────────────┐
+ │ node-exporter │ │ Prometheus ← приёмник remote_write │
+ │ cAdvisor │ HTTPS │ Loki ← приёмник логов │
+ │ postgres-exporter × 2 │ ──────────► │ Grafana ← витрина │
+ │ Alloy ──── push ────────────┼─ 443 ─────► │ Alertmanager (за профилем alerts) │
+ └──────────────────────────────┘ │ node-exporter, cAdvisor, Alloy │
+ │ postgres-exporter (infra) │
+ └────────────────────────────────────┘
+```
+
+**Наблюдатель стоит у другого провайдера, чем наблюдаемое.** Если ляжет Poincare,
+мониторинг обязан выжить и сказать об этом, а не лечь вместе с ним.
+
+**Транспорт — push.** Prometheus не ходит на Poincare: там агент сам шлёт
+исходящим HTTPS. Поэтому на продуктовом хосте не открыто ни одного входящего
+порта сверх 22/80/443. Побочный выигрыш: при обрыве канала Alloy копит в WAL и
+досылает, а pull-скрейп в той же ситуации просто потерял бы точки — то есть
+именно в аварии, ради которой мониторинг и заводится.
+
+## Что где лежит
+
+| Файл | Назначение |
+|---|---|
+| `docker-compose.metrics.yml` | серверная сторона, только Beget |
+| `docker-compose.metrics-agent.yml` | агенты, оба хоста; профили `apps` / `infra` |
+| `ops/metrics/prometheus/` | конфиг и правила алертов |
+| `ops/metrics/loki/` | конфиг Loki |
+| `ops/metrics/alloy/alloy-infra.alloy` | агент на Beget — пишет напрямую по docker-сети |
+| `ops/metrics/alloy/alloy-apps.alloy` | агент на Poincare — пишет по HTTPS под basic_auth |
+| `ops/metrics/postgres/queries.yml` | дополнительные запросы экспортера БД |
+| `ops/metrics/grafana/` | источники данных и дашборды |
+| `caddy/sites/infra.caddy` | site-блок `metrics.gendsgn.ru` |
+| `scripts/setup-metrics-secrets.sh` | разовая подготовка учёток |
+
+## Первый запуск
+
+```bash
+# 1. На инфраструктурном хосте — один раз. Пароли не печатает, печатает хеши.
+ssh gendesign 'cd /opt/gendesign && bash scripts/setup-metrics-secrets.sh'
+
+# 2. Хеши из вывода вставить в caddy/metrics-ui.caddy.snippet и
+# caddy/metrics-ingest.caddy.snippet, закоммитить.
+
+# 3. Деплой.
+# Forgejo → Actions → Deploy Metrics → Run workflow
+```
+
+Пароль витрины смотреть на хосте, в переписку не копировать:
+
+```bash
+ssh gendesign "grep '^METRICS_UI_PASSWORD=' /opt/gendesign/backend/.env.runtime"
+```
+
+## Два контура доступа, две учётки
+
+`metrics.gendsgn.ru/ingest/*` — машинный, для агента. Пароль этого контура по
+построению лежит в открытом виде на продуктовом хосте: агенту нужно им
+авторизоваться. Значит, компрометация Poincare раскрывает его автоматически.
+Если бы это была учётка витрины, вместе с ней утёк бы доступ к дашбордам и к
+Prometheus, где видна вся инфраструктура. Разделение ограничивает ущерб записью.
+
+Всё остальное — витрина Grafana под отдельным паролем. Это **второй слой**: у
+Grafana остаётся собственный вход с ролями. Внешний basic_auth отсекает сканеры
+и любую будущую дыру в самой Grafana до того, как она станет достижимой снаружи.
+Если два запроса пароля подряд неудобны — убрать одну строку `import
+caddy/metrics-ui.caddy.snippet` из `infra.caddy`; вход Grafana останется.
+
+## GlitchTip читается прямым SQL, а не плагином
+
+Плагин `grafana/sentry-datasource` GlitchTip официально документирует, но на
+нашей 6.1.6 половина путей нерабочая: `stats_v2` с фильтром по проекту отдаёт
+500 (открытый баг GlitchTip #381 с 2025-01-10), Events/Discover — 404 (#416),
+Metrics/Spans/Tags вообще Sentry-only. В самом `grafana/sentry-datasource` слово
+«glitchtip» не встречается ни разу — апстрим связку не тестирует.
+
+Прямой SQL правок в GlitchTip не требует вовсе, нужен только read-only
+пользователь (`scripts/setup-metrics-grafana-role.sh`, прав на запись нет).
+Схема проверена на живой базе 26.08:
+
+- `issue_events_issue` — `count`, `first_seen`, `last_seen`, `status`, `level`,
+ `project_id`, `title`, `culprit`
+- `issue_events_issueaggregate` — `(issue_id, organization_id, date, count)`,
+ партиционирована по неделям с почасовыми под-партициями
+- `issue_events_issueevent` — колонка времени называется **`timestamp`**
+ (плюс `created`); колонки `received` в этой версии нет
+
+Отдельной таблицы `IssueIndex` **не существует** — все агрегаты лежат на самой
+`issue_events_issue`. В более ранних описаниях этой задачи она упоминалась;
+это была ошибка, проверено глазами.
+
+**Граница:** доступ read-only. Тренды — в Grafana, а assign / resolve / ignore,
+стектрейсы и breadcrumbs — только в GlitchTip. Окна остаётся два: витрина и
+рабочее место. Это осознанно, а не недоделка.
+
+## Алерты
+
+Пока выключены профилем. Канал доставки — открытый вопрос #3078: тот же чат, что
+у вебхука GlitchTip, или отдельный, и при каком пороге будить ночью. Стек метрик
+работает и без них, но **при срабатывании правила никто не будет уведомлён** —
+деплой пишет об этом предупреждением, чтобы это не стало сюрпризом.
+
+Включение: задать `METRICS_TELEGRAM_BOT_TOKEN` и `METRICS_TELEGRAM_CHAT_ID` в
+окружении инфраструктурного хоста и перезапустить деплой. Профиль `alerts`
+включится сам.
+
+Alertmanager шлёт в Telegram **напрямую с Beget**, а не через бэкенд МЕРЫ, хотя
+рабочий путь доставки там уже есть. Причина простая: сообщение о том, что лёг
+Poincare, не должно идти через сервис на Poincare.
+
+Отдельно живёт правило `Watchdog` — оно горит всегда и раз в 12 часов
+подтверждает, что цепочка правило → Alertmanager → Telegram → человек цела.
+Существует ради того, чтобы его отсутствие было заметно: молчащий канал — самый
+частый способ узнать об аварии последним. В проекте МЕРЫ наружу не ушло ни одного
+сообщения с 30 мая, и выяснилось это случайно (#2673).
+
+## Известные грабли
+
+- **Alloy запущен от root.** Штатный пользователь `alloy` требует членства в
+ группах `adm` и `systemd-journal`; внутри контейнера этих групп с нужными gid
+ нет, и агент молча читает ноль записей журнала. Отказ выглядит как «логов
+ просто нет».
+- **Путь к журналу задан явно** (`/var/log/journal`). Без него libsystemd
+ применяет `SD_JOURNAL_LOCAL_ONLY`, и хостовый журнал из контейнера не виден —
+ тоже без ошибки.
+- **`retention_period` у Loki не работает без `retention_enabled` у компактора.**
+ Loki примет конфиг и будет копить вечно.
+- **Ретенция Prometheus задана и по времени, и по размеру.** Только по времени —
+ значит, объём в байтах зависит от числа рядов, которое растёт само.
+- **Caddy проверяется до перезагрузки.** На Beget тот же Caddy обслуживает
+ `git.`, `errors.` и `obsidian.`; ошибка в `infra.caddy` положила бы их все,
+ включая Forgejo, из которого идёт деплой.
+
+## Что ещё не сделано
+
+- `/metrics` в бэкендах (часть 3) — единственная часть, трогающая прод-код
+- экспортер поверх `scrape_runs`: success_ratio, свежесть приёмника, утилизация,
+ счётчик `cancelled` (часть 4)
+- алерты и синтетический heartbeat (часть 5)
+- `pg_stat_statements` для кластера Птицы — требует рестарта прод-БД, отдельно
+- честные `started_at` / `finished_at` у прогонов (#2702) — предусловие для
+ графиков пропускной способности: пока start/finish врут, врут и графики
diff --git a/ops/metrics/alertmanager/alertmanager.yml.tmpl b/ops/metrics/alertmanager/alertmanager.yml.tmpl
new file mode 100644
index 00000000..4e807845
--- /dev/null
+++ b/ops/metrics/alertmanager/alertmanager.yml.tmpl
@@ -0,0 +1,74 @@
+# Шаблон конфигурации Alertmanager. Боевой файл собирается на хосте при деплое
+# (`envsubst` в deploy-metrics.yml) и в репозиторий не попадает — токен бота и
+# идентификатор чата живут в окружении хоста, а не в git.
+#
+# ПОЧЕМУ TELEGRAM НАПРЯМУЮ, А НЕ ЧЕРЕЗ БЭКЕНД МЕРЫ. У МЕРЫ уже есть рабочий путь
+# доставки (вебхук GlitchTip → бот), и соблазн переиспользовать его велик. Но тогда
+# сообщение о том, что лёг Poincare, шло бы через сервис НА Poincare. Алерт обязан
+# уметь уйти без участия хоста, про который он написан.
+#
+# Telegram с Beget работает — проверено серией замеров 25.08: TLS-рукопожатие
+# 18 из 20, getMe 4 из 4. Ломается только длинный long-poll (30 с), а отправка
+# сообщения — короткий запрос.
+
+global:
+ resolve_timeout: 5m
+
+route:
+ receiver: telegram
+ # Группируем по алерту и хосту: пятнадцать контейнеров одного хоста, упавших
+ # разом, — это одно событие, а не пятнадцать сообщений.
+ group_by: ["alertname", "host"]
+ group_wait: 45s
+ group_interval: 5m
+ # Повтор раз в 6 часов. Чаще — приучает игнорировать, реже — можно проспать.
+ repeat_interval: 6h
+
+ routes:
+ # Watchdog не должен смешиваться с настоящими алертами и не должен молчать:
+ # это «сторож сторожа», он горит всегда и подтверждает, что канал доставки жив.
+ - receiver: telegram-heartbeat
+ matchers:
+ - alertname = "Watchdog"
+ group_wait: 0s
+ group_interval: 12h
+ repeat_interval: 12h
+
+ # Критичное — без задержки на группировку.
+ - receiver: telegram
+ matchers:
+ - severity = "critical"
+ group_wait: 10s
+ repeat_interval: 3h
+
+inhibit_rules:
+ # Если хост целиком недоступен, не сыпать отдельно про каждый его сервис.
+ - source_matchers: [alertname = "HostAgentDown"]
+ target_matchers: [severity =~ "warning|critical"]
+ equal: ["host"]
+
+receivers:
+ - name: telegram
+ telegram_configs:
+ - bot_token: "${METRICS_TELEGRAM_BOT_TOKEN}"
+ chat_id: ${METRICS_TELEGRAM_CHAT_ID}
+ api_url: "https://api.telegram.org"
+ parse_mode: HTML
+ send_resolved: true
+ message: |
+ {{ if eq .Status "firing" }}🔴{{ else }}🟢{{ end }} {{ .CommonLabels.alertname }}{{ if .CommonLabels.host }} · {{ .CommonLabels.host }}{{ end }}
+ {{ range .Alerts }}
+ {{ .Annotations.summary }}
+ {{ if .Annotations.description }}{{ .Annotations.description }}{{ end }}
+ {{ end }}
+
+ - name: telegram-heartbeat
+ telegram_configs:
+ - bot_token: "${METRICS_TELEGRAM_BOT_TOKEN}"
+ chat_id: ${METRICS_TELEGRAM_CHAT_ID}
+ api_url: "https://api.telegram.org"
+ parse_mode: HTML
+ send_resolved: false
+ message: |
+ ⚪ Мониторинг жив — сторож отчитался, канал доставки работает.
+ Если это сообщение перестало приходить дважды подряд, замолчал сам мониторинг.
diff --git a/ops/metrics/alloy/alloy-apps.alloy b/ops/metrics/alloy/alloy-apps.alloy
new file mode 100644
index 00000000..638e3268
--- /dev/null
+++ b/ops/metrics/alloy/alloy-apps.alloy
@@ -0,0 +1,172 @@
+// Alloy — агент на ПРОДУКТОВОМ хосте (Poincare). Приёмник стоит у другого провайдера,
+// поэтому доставка идёт исходящим HTTPS через metrics.gendsgn.ru под basic_auth.
+//
+// ПОЧЕМУ PUSH. Альтернатива — открыть на Poincare входящие порты под скрейп из центра.
+// ufw там держит только 22/80/443, и расширять периметр ради мониторинга — плохой
+// размен. Исходящий HTTPS уже разрешён, ничего открывать не нужно.
+//
+// ПРИ ОБРЫВЕ КАНАЛА агент копит в WAL и досылает. Pull-скрейп в этой ситуации просто
+// потерял бы точки — а обрыв между площадками это ровно тот случай, ради которого
+// мониторинг и заводится.
+
+logging {
+ level = "warn"
+ format = "logfmt"
+}
+
+// ═══ КУДА ОТПРАВЛЯЕМ ═══════════════════════════════════════════════════════════
+
+prometheus.remote_write "central" {
+ endpoint {
+ url = "https://metrics.gendsgn.ru/ingest/prometheus/api/v1/write"
+
+ basic_auth {
+ username = sys.env("METRICS_INGEST_USER")
+ password = sys.env("METRICS_INGEST_PASSWORD")
+ }
+
+ // Очередь на случай недоступности приёмника. Дефолты рассчитаны на облако с
+ // быстрым каналом; здесь между площадками 17 мс и приёмник может уехать в
+ // рестарт при деплое — даём запас, чтобы не терять точки на ровном месте.
+ queue_config {
+ capacity = 10000
+ max_shards = 5
+ min_shards = 1
+ max_samples_per_send = 2000
+ batch_send_deadline = "10s"
+ retry_on_http_429 = true
+ }
+ }
+
+ // Досыл после обрыва. Держим сутки: суточного окна хватает и на ночной рестарт
+ // приёмника, и на разбор утром.
+ wal {
+ truncate_frequency = "2h"
+ max_keepalive_time = "24h"
+ }
+
+ external_labels = {
+ host = sys.env("METRICS_HOST_LABEL"),
+ }
+}
+
+loki.write "central" {
+ endpoint {
+ url = "https://metrics.gendsgn.ru/ingest/loki/loki/api/v1/push"
+
+ basic_auth {
+ username = sys.env("METRICS_INGEST_USER")
+ password = sys.env("METRICS_INGEST_PASSWORD")
+ }
+ }
+
+ external_labels = {
+ host = sys.env("METRICS_HOST_LABEL"),
+ }
+}
+
+// ═══ МЕТРИКИ ХОСТА И КОНТЕЙНЕРОВ ═══════════════════════════════════════════════
+
+prometheus.scrape "node" {
+ targets = [
+ { __address__ = "node-exporter:9100", job = "node" },
+ ]
+ forward_to = [prometheus.remote_write.central.receiver]
+ scrape_interval = "30s"
+}
+
+prometheus.scrape "cadvisor" {
+ targets = [
+ { __address__ = "cadvisor:8080", job = "cadvisor" },
+ ]
+ forward_to = [prometheus.relabel.cadvisor_trim.receiver]
+ scrape_interval = "30s"
+}
+
+prometheus.relabel "cadvisor_trim" {
+ forward_to = [prometheus.remote_write.central.receiver]
+
+ rule {
+ source_labels = ["__name__"]
+ regex = "container_(memory_(usage_bytes|working_set_bytes|rss)|cpu_(usage_seconds_total|cfs_throttled_seconds_total)|network_(receive|transmit)_bytes_total|fs_(usage|limit)_bytes|last_seen|spec_memory_limit_bytes|start_time_seconds|processes)"
+ action = "keep"
+ }
+
+ rule {
+ source_labels = ["name"]
+ regex = ""
+ action = "drop"
+ }
+}
+
+// ═══ МЕТРИКИ ОБЕИХ БОЕВЫХ БД ═══════════════════════════════════════════════════
+// Именно здесь живут ряды, из-за отсутствия которых раздутие `listings` копилось
+// 91 день незамеченным: WAL в сутки, n_tup_upd против n_tup_hot_upd, рост TOAST.
+
+prometheus.scrape "postgres" {
+ targets = [
+ { __address__ = "gendesign-pg-exporter-gendesign:9187", job = "postgres", db = "gendesign" },
+ { __address__ = "gendesign-pg-exporter-tradein:9187", job = "postgres", db = "tradein" },
+ ]
+ forward_to = [prometheus.remote_write.central.receiver]
+ scrape_interval = "60s"
+}
+
+// ═══ МЕТРИКИ ПРИЛОЖЕНИЙ ════════════════════════════════════════════════════════
+// Эндпоинты появляются в части 3. До этого скрейп просто отдаёт `up 0` — и это
+// правильно: цель видна как недоступная, а не отсутствует молча.
+
+prometheus.scrape "apps" {
+ targets = [
+ { __address__ = "gendesign-backend-1:8000", job = "app", app = "sitefinder" },
+ { __address__ = "tradein-backend:8000", job = "app", app = "mera" },
+ ]
+ metrics_path = "/metrics"
+ forward_to = [prometheus.remote_write.central.receiver]
+ scrape_interval = "30s"
+ scrape_timeout = "20s"
+}
+
+// ═══ ЛОГИ ══════════════════════════════════════════════════════════════════════
+
+loki.source.journal "host" {
+ path = "/var/log/journal"
+ max_age = "12h"
+ format_as_json = false
+ labels = {
+ job = "journal",
+ }
+ relabel_rules = loki.relabel.journal.rules
+ forward_to = [loki.write.central.receiver]
+}
+
+loki.relabel "journal" {
+ forward_to = []
+
+ rule {
+ source_labels = ["__journal__systemd_unit"]
+ target_label = "unit"
+ }
+ rule {
+ source_labels = ["__journal__hostname"]
+ target_label = "node"
+ }
+ rule {
+ source_labels = ["__journal_container_name"]
+ target_label = "container"
+ }
+ rule {
+ source_labels = ["__journal_priority_keyword"]
+ target_label = "level"
+ }
+}
+
+// ═══ САМОКОНТРОЛЬ ══════════════════════════════════════════════════════════════
+
+prometheus.exporter.self "alloy" {}
+
+prometheus.scrape "alloy_self" {
+ targets = prometheus.exporter.self.alloy.targets
+ forward_to = [prometheus.remote_write.central.receiver]
+ scrape_interval = "60s"
+}
diff --git a/ops/metrics/alloy/alloy-infra.alloy b/ops/metrics/alloy/alloy-infra.alloy
new file mode 100644
index 00000000..17c00c47
--- /dev/null
+++ b/ops/metrics/alloy/alloy-infra.alloy
@@ -0,0 +1,136 @@
+// Alloy — агент на ИНФРАСТРУКТУРНОМ хосте (Beget). Приёмник стоит рядом, на этом же
+// хосте, поэтому пишем напрямую по docker-сети: без TLS, без basic_auth, без интернета.
+//
+// Вариант для продуктового хоста — alloy-apps.alloy: там тот же набор источников, но
+// доставка идёт исходящим HTTPS через metrics.gendsgn.ru.
+//
+// Метка host проставляется здесь, один раз, в external_labels — если ставить её ещё и
+// на стороне Prometheus, результат зависит от honor_labels и перестаёт быть очевидным.
+
+logging {
+ level = "warn"
+ format = "logfmt"
+}
+
+// ═══ КУДА ОТПРАВЛЯЕМ ═══════════════════════════════════════════════════════════
+
+prometheus.remote_write "central" {
+ endpoint {
+ url = "http://prometheus:9090/api/v1/write"
+ }
+
+ external_labels = {
+ host = sys.env("METRICS_HOST_LABEL"),
+ }
+}
+
+loki.write "central" {
+ endpoint {
+ url = "http://loki:3100/loki/api/v1/push"
+ }
+
+ external_labels = {
+ host = sys.env("METRICS_HOST_LABEL"),
+ }
+}
+
+// ═══ МЕТРИКИ ХОСТА И КОНТЕЙНЕРОВ ═══════════════════════════════════════════════
+
+prometheus.scrape "node" {
+ targets = [
+ { __address__ = "node-exporter:9100", job = "node" },
+ ]
+ forward_to = [prometheus.remote_write.central.receiver]
+ scrape_interval = "30s"
+}
+
+prometheus.scrape "cadvisor" {
+ targets = [
+ { __address__ = "cadvisor:8080", job = "cadvisor" },
+ ]
+ forward_to = [prometheus.relabel.cadvisor_trim.receiver]
+ scrape_interval = "30s"
+}
+
+// cAdvisor отдаёт ряды на каждый слой файловой системы и каждое устройство.
+// Без прополки TSDB растёт на порядок ради данных, которые никто не открывает.
+prometheus.relabel "cadvisor_trim" {
+ forward_to = [prometheus.remote_write.central.receiver]
+
+ rule {
+ source_labels = ["__name__"]
+ regex = "container_(memory_(usage_bytes|working_set_bytes|rss)|cpu_(usage_seconds_total|cfs_throttled_seconds_total)|network_(receive|transmit)_bytes_total|fs_(usage|limit)_bytes|last_seen|spec_memory_limit_bytes|start_time_seconds|processes)"
+ action = "keep"
+ }
+
+ // Служебные контейнеры docker без имени только зашумляют графики.
+ rule {
+ source_labels = ["name"]
+ regex = ""
+ action = "drop"
+ }
+}
+
+// ═══ МЕТРИКИ ИНФРАСТРУКТУРНОЙ БД ═══════════════════════════════════════════════
+// Профиль infra в docker-compose.metrics-agent.yml поднимает ровно один экспортер —
+// по кластеру с базами forgejo и glitchtip.
+
+prometheus.scrape "postgres_infra" {
+ targets = [
+ { __address__ = "gendesign-pg-exporter-infra:9187", job = "postgres", db = "infra" },
+ ]
+ forward_to = [prometheus.remote_write.central.receiver]
+ scrape_interval = "60s"
+}
+
+// ═══ ЛОГИ ══════════════════════════════════════════════════════════════════════
+
+loki.source.journal "host" {
+ // Путь задан ЯВНО. Без него libsystemd включает SD_JOURNAL_LOCAL_ONLY, и из
+ // контейнера хостовый журнал не виден — при этом ошибки нет, просто пустой поток.
+ path = "/var/log/journal"
+ // При рестарте агента не тянем всю историю заново: journald держит ~13 суток,
+ // а повторная заливка создала бы дубликаты и упёрлась в reject_old_samples.
+ max_age = "12h"
+ format_as_json = false
+ labels = {
+ job = "journal",
+ }
+ relabel_rules = loki.relabel.journal.rules
+ forward_to = [loki.write.central.receiver]
+}
+
+loki.relabel "journal" {
+ forward_to = []
+
+ // Внутренние поля journald приходят с префиксом __ и без переименования
+ // отбрасываются. Оставляем ровно те, по которым потом фильтруют.
+ rule {
+ source_labels = ["__journal__systemd_unit"]
+ target_label = "unit"
+ }
+ rule {
+ source_labels = ["__journal__hostname"]
+ target_label = "node"
+ }
+ rule {
+ source_labels = ["__journal_container_name"]
+ target_label = "container"
+ }
+ rule {
+ source_labels = ["__journal_priority_keyword"]
+ target_label = "level"
+ }
+}
+
+// ═══ САМОКОНТРОЛЬ ══════════════════════════════════════════════════════════════
+// Метрики самого агента. Нужны для алерта «агент замолчал»: без них молчание
+// источника неотличимо от «всё хорошо, событий нет».
+
+prometheus.exporter.self "alloy" {}
+
+prometheus.scrape "alloy_self" {
+ targets = prometheus.exporter.self.alloy.targets
+ forward_to = [prometheus.remote_write.central.receiver]
+ scrape_interval = "60s"
+}
diff --git a/ops/metrics/grafana/dashboards/host.json b/ops/metrics/grafana/dashboards/host.json
new file mode 100644
index 00000000..1a2d7c66
--- /dev/null
+++ b/ops/metrics/grafana/dashboards/host.json
@@ -0,0 +1,332 @@
+{
+ "uid": "gendesign-host",
+ "title": "Хост и контейнеры",
+ "description": "CPU, память, диск и контейнеры обоих хостов. Метка host: infra = Beget (Forgejo, GlitchTip, мониторинг), apps = Poincare (Птица и МЕРА).",
+ "tags": ["gendesign", "infra"],
+ "timezone": "browser",
+ "editable": false,
+ "schemaVersion": 39,
+ "refresh": "1m",
+ "time": { "from": "now-6h", "to": "now" },
+ "templating": {
+ "list": [
+ {
+ "name": "host",
+ "type": "query",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "query": "label_values(node_uname_info, host)",
+ "refresh": 1,
+ "includeAll": true,
+ "multi": true,
+ "current": { "text": "All", "value": "$__all" }
+ }
+ ]
+ },
+ "panels": [
+ {
+ "type": "row",
+ "title": "Сводка",
+ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }
+ },
+ {
+ "type": "stat",
+ "title": "Свободно на корневом разделе",
+ "description": "Место, оставшееся на /. При заполнении диска Postgres останавливается — это не «медленно», а полная остановка записи.",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 5, "w": 6, "x": 0, "y": 1 },
+ "targets": [
+ {
+ "refId": "A",
+ "expr": "node_filesystem_avail_bytes{mountpoint=\"/\", host=~\"$host\"}",
+ "legendFormat": "{{host}}"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "bytes",
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ { "color": "red", "value": null },
+ { "color": "orange", "value": 10737418240 },
+ { "color": "green", "value": 32212254720 }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "options": { "colorMode": "value", "graphMode": "area", "textMode": "auto" }
+ },
+ {
+ "type": "stat",
+ "title": "Доступно памяти",
+ "description": "MemAvailable — то, что ядро реально может отдать под новую нагрузку. Отличается от «free»: кэш страниц отдаётся по требованию и в нехватку не считается.",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 5, "w": 6, "x": 6, "y": 1 },
+ "targets": [
+ {
+ "refId": "A",
+ "expr": "node_memory_MemAvailable_bytes{host=~\"$host\"}",
+ "legendFormat": "{{host}}"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "bytes",
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ { "color": "red", "value": null },
+ { "color": "orange", "value": 1073741824 },
+ { "color": "green", "value": 3221225472 }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "options": { "colorMode": "value", "graphMode": "area", "textMode": "auto" }
+ },
+ {
+ "type": "stat",
+ "title": "Загрузка (load1 на ядро)",
+ "description": "Нормировано на число ядер: 1.0 означает «занято ровно столько, сколько есть». Без нормировки число несравнимо между хостами с разным CPU.",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 5, "w": 6, "x": 12, "y": 1 },
+ "targets": [
+ {
+ "refId": "A",
+ "expr": "node_load1{host=~\"$host\"} / count by (host) (node_cpu_seconds_total{mode=\"idle\", host=~\"$host\"})",
+ "legendFormat": "{{host}}"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "short",
+ "decimals": 2,
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ { "color": "green", "value": null },
+ { "color": "orange", "value": 0.8 },
+ { "color": "red", "value": 1.5 }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "options": { "colorMode": "value", "graphMode": "area", "textMode": "auto" }
+ },
+ {
+ "type": "stat",
+ "title": "Аптайм",
+ "description": "Время с последней загрузки. Внезапное обнуление — перезагрузка, о которой стоит знать.",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 5, "w": 6, "x": 18, "y": 1 },
+ "targets": [
+ {
+ "refId": "A",
+ "expr": "time() - node_boot_time_seconds{host=~\"$host\"}",
+ "legendFormat": "{{host}}"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": { "unit": "s", "decimals": 0 },
+ "overrides": []
+ },
+ "options": { "colorMode": "none", "graphMode": "none", "textMode": "auto" }
+ },
+ {
+ "type": "row",
+ "title": "Ресурсы во времени",
+ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 6 }
+ },
+ {
+ "type": "timeseries",
+ "title": "CPU по режимам",
+ "description": "iowait отдельно от user/system: высокий iowait означает, что процессор ждёт диск, и добавлять ядер бесполезно.",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 7 },
+ "targets": [
+ {
+ "refId": "A",
+ "expr": "sum by (host, mode) (rate(node_cpu_seconds_total{mode!=\"idle\", host=~\"$host\"}[5m])) / on (host) group_left count by (host) (node_cpu_seconds_total{mode=\"idle\", host=~\"$host\"})",
+ "legendFormat": "{{host}} · {{mode}}"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percentunit",
+ "min": 0,
+ "custom": { "fillOpacity": 20, "stacking": { "mode": "normal" }, "showPoints": "never" }
+ },
+ "overrides": []
+ }
+ },
+ {
+ "type": "timeseries",
+ "title": "Память",
+ "description": "Занято = всего минус доступно. Своп показан отдельно: его рост означает, что рабочий набор перестал помещаться.",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 7 },
+ "targets": [
+ {
+ "refId": "A",
+ "expr": "node_memory_MemTotal_bytes{host=~\"$host\"} - node_memory_MemAvailable_bytes{host=~\"$host\"}",
+ "legendFormat": "{{host}} · занято"
+ },
+ {
+ "refId": "B",
+ "expr": "node_memory_SwapTotal_bytes{host=~\"$host\"} - node_memory_SwapFree_bytes{host=~\"$host\"}",
+ "legendFormat": "{{host}} · своп"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "bytes",
+ "min": 0,
+ "custom": { "fillOpacity": 15, "showPoints": "never" }
+ },
+ "overrides": []
+ }
+ },
+ {
+ "type": "timeseries",
+ "title": "Занятость дисков",
+ "description": "По точкам монтирования. Порог алерта — 85 %, критический — 93 %.",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 },
+ "targets": [
+ {
+ "refId": "A",
+ "expr": "1 - node_filesystem_avail_bytes{fstype!~\"tmpfs|overlay\", host=~\"$host\"} / node_filesystem_size_bytes{fstype!~\"tmpfs|overlay\", host=~\"$host\"}",
+ "legendFormat": "{{host}} · {{mountpoint}}"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percentunit",
+ "min": 0,
+ "max": 1,
+ "custom": { "fillOpacity": 10, "showPoints": "never", "thresholdsStyle": { "mode": "dashed" } },
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ { "color": "green", "value": null },
+ { "color": "orange", "value": 0.85 },
+ { "color": "red", "value": 0.93 }
+ ]
+ }
+ },
+ "overrides": []
+ }
+ },
+ {
+ "type": "timeseries",
+ "title": "Дисковый ввод-вывод",
+ "description": "Байты чтения и записи по устройствам. Всплеск записи по ночам — это бэкапы (00:30 и 01:30 UTC), так и должно быть.",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 },
+ "targets": [
+ {
+ "refId": "A",
+ "expr": "sum by (host) (rate(node_disk_read_bytes_total{host=~\"$host\"}[5m]))",
+ "legendFormat": "{{host}} · чтение"
+ },
+ {
+ "refId": "B",
+ "expr": "sum by (host) (rate(node_disk_written_bytes_total{host=~\"$host\"}[5m]))",
+ "legendFormat": "{{host}} · запись"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "Bps",
+ "custom": { "fillOpacity": 10, "showPoints": "never" }
+ },
+ "overrides": []
+ }
+ },
+ {
+ "type": "row",
+ "title": "Контейнеры",
+ "gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 }
+ },
+ {
+ "type": "timeseries",
+ "title": "Память по контейнерам (топ-15)",
+ "description": "working set, а не usage: usage включает переиспользуемый кэш и завышает картину. Именно по этой метрике сравнивается фактическое потребление с mem_limit.",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 10, "w": 14, "x": 0, "y": 24 },
+ "targets": [
+ {
+ "refId": "A",
+ "expr": "topk(15, container_memory_working_set_bytes{name!=\"\", host=~\"$host\"})",
+ "legendFormat": "{{host}} · {{name}}"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "bytes",
+ "custom": { "fillOpacity": 10, "showPoints": "never" }
+ },
+ "overrides": []
+ }
+ },
+ {
+ "type": "table",
+ "title": "Приближение к mem_limit",
+ "description": "Доля от заданного лимита. Пустая строка означает, что лимит не выставлен вовсе — тогда потребление сверху не ограничено ничем, кроме памяти хоста.",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 10, "w": 10, "x": 14, "y": 24 },
+ "targets": [
+ {
+ "refId": "A",
+ "instant": true,
+ "format": "table",
+ "expr": "sort_desc(container_memory_working_set_bytes{name!=\"\", host=~\"$host\"} / (container_spec_memory_limit_bytes{name!=\"\", host=~\"$host\"} > 0))",
+ "legendFormat": "{{name}}"
+ }
+ ],
+ "transformations": [
+ { "id": "organize", "options": { "excludeByName": { "Time": true, "job": true, "instance": true, "id": true, "image": true } } }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percentunit",
+ "custom": { "align": "auto", "cellOptions": { "type": "gauge" } },
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ { "color": "green", "value": null },
+ { "color": "orange", "value": 0.8 },
+ { "color": "red", "value": 0.9 }
+ ]
+ }
+ },
+ "overrides": []
+ }
+ },
+ {
+ "type": "timeseries",
+ "title": "Перезапуски контейнеров",
+ "description": "Число стартов за полчаса. Больше трёх — цикл перезапусков: снаружи такой контейнер выглядит поднятым, а деплой при этом зелёный.",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 8, "w": 24, "x": 0, "y": 34 },
+ "targets": [
+ {
+ "refId": "A",
+ "expr": "changes(container_start_time_seconds{name!=\"\", host=~\"$host\"}[30m]) > 0",
+ "legendFormat": "{{host}} · {{name}}"
+ }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "short",
+ "min": 0,
+ "custom": { "drawStyle": "bars", "fillOpacity": 60, "showPoints": "never" }
+ },
+ "overrides": []
+ }
+ }
+ ]
+}
diff --git a/ops/metrics/grafana/dashboards/postgres.json b/ops/metrics/grafana/dashboards/postgres.json
new file mode 100644
index 00000000..7f3b343b
--- /dev/null
+++ b/ops/metrics/grafana/dashboards/postgres.json
@@ -0,0 +1,300 @@
+{
+ "uid": "gendesign-postgres",
+ "title": "Базы данных",
+ "description": "Три кластера под одним взглядом: Птица и МЕРА на Poincare, инфраструктурная база (Forgejo, GlitchTip) на Beget. Панели подобраны по разборам постфактум — каждая отвечает на вопрос, который однажды уже задавали задним числом.",
+ "tags": ["gendesign", "postgres"],
+ "timezone": "browser",
+ "editable": false,
+ "schemaVersion": 39,
+ "refresh": "1m",
+ "time": { "from": "now-24h", "to": "now" },
+ "templating": {
+ "list": [
+ {
+ "name": "db",
+ "label": "Кластер",
+ "type": "query",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "query": "label_values(pg_up, db)",
+ "refresh": 1,
+ "includeAll": true,
+ "multi": true,
+ "current": { "text": "All", "value": "$__all" }
+ }
+ ]
+ },
+ "panels": [
+ { "type": "row", "title": "Сводка", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 } },
+
+ {
+ "type": "stat",
+ "title": "Экспортер отвечает",
+ "description": "Ноль означает, что метрик по этому кластеру нет вовсе. Пустой график и упавшая база выглядят одинаково — эта панель их различает.",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 5, "w": 6, "x": 0, "y": 1 },
+ "targets": [
+ { "refId": "A", "expr": "pg_up{db=~\"$db\"}", "legendFormat": "{{db}}" }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "mappings": [
+ { "type": "value", "options": { "0": { "text": "нет связи", "color": "red", "index": 0 }, "1": { "text": "отвечает", "color": "green", "index": 1 } } }
+ ],
+ "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "green", "value": 1 } ] }
+ },
+ "overrides": []
+ },
+ "options": { "colorMode": "background", "graphMode": "none", "textMode": "value_and_name" }
+ },
+
+ {
+ "type": "stat",
+ "title": "Занято соединений",
+ "description": "Доля от max_connections. Упереться в потолок означает, что новые запросы получают отказ на подключении — со стороны приложения это выглядит как недоступность базы, а не как нагрузка.",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 5, "w": 6, "x": 6, "y": 1 },
+ "targets": [
+ { "refId": "A", "expr": "sum by (db) (pg_stat_database_numbackends{db=~\"$db\"}) / on (db) group_left max by (db) (pg_settings_max_connections{db=~\"$db\"})", "legendFormat": "{{db}}" }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percentunit",
+ "min": 0,
+ "max": 1,
+ "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 0.7 }, { "color": "red", "value": 0.9 } ] }
+ },
+ "overrides": []
+ },
+ "options": { "colorMode": "value", "graphMode": "area", "textMode": "value_and_name" }
+ },
+
+ {
+ "type": "stat",
+ "title": "Самая старая транзакция",
+ "description": "Разбор #2607: осиротевшие запросы висели 46 часов и держали горизонт видимости, из-за чего autovacuum не мог убрать мёртвые строки во всей базе. Одна забытая транзакция отравляет весь кластер, а снаружи это выглядит просто как «база пухнет».",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 5, "w": 6, "x": 12, "y": 1 },
+ "targets": [
+ { "refId": "A", "expr": "max by (db) (pg_activity_horizon_oldest_xact_age_s{db=~\"$db\"})", "legendFormat": "{{db}}" }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "s",
+ "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 900 }, { "color": "red", "value": 3600 } ] }
+ },
+ "overrides": []
+ },
+ "options": { "colorMode": "value", "graphMode": "area", "textMode": "value_and_name" }
+ },
+
+ {
+ "type": "stat",
+ "title": "WAL за сутки",
+ "description": "Замер 20.08 по Птице: 7,02 ГБ журнала в сутки при примерно четырёх пользовательских расчётах за тот же срок. Диспропорция такого масштаба и есть симптом — но увидеть её можно только имея ряд.",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 5, "w": 6, "x": 18, "y": 1 },
+ "targets": [
+ { "refId": "A", "expr": "rate(pg_wal_bytes_wal_bytes_total{db=~\"$db\"}[1h]) * 86400", "legendFormat": "{{db}}" }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "bytes",
+ "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 5368709120 }, { "color": "red", "value": 21474836480 } ] }
+ },
+ "overrides": []
+ },
+ "options": { "colorMode": "value", "graphMode": "area", "textMode": "value_and_name" }
+ },
+
+ { "type": "row", "title": "Раздутие: почему база растёт быстрее данных", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 6 } },
+
+ {
+ "type": "timeseries",
+ "title": "Доля апдейтов мимо HOT (топ-10)",
+ "description": "HOT-апдейт переписывает только строку. Всё остальное переписывает её ещё и во всех индексах, а длинные поля заново тостит. У listings доля HOT была 0,43 % при 198 апдейтах на строку — отсюда 15 ГБ TOAST при 230 МБ живого содержимого (#2992/#2989). Копилось 91 день, потому что смотреть было не на что. Устойчивое значение выше 0,8 у часто обновляемой таблицы — повод править fillfactor или сам паттерн записи.",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 9, "w": 14, "x": 0, "y": 7 },
+ "targets": [
+ { "refId": "A", "expr": "topk(10, 1 - (rate(pg_table_write_amplification_tup_hot_upd{db=~\"$db\"}[1h]) / (rate(pg_table_write_amplification_tup_upd{db=~\"$db\"}[1h]) > 0.01)))", "legendFormat": "{{db}} · {{table}}" }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percentunit",
+ "min": 0,
+ "max": 1,
+ "custom": { "fillOpacity": 8, "showPoints": "never", "lineWidth": 2 },
+ "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 0.8 } ] }
+ },
+ "overrides": []
+ }
+ },
+
+ {
+ "type": "table",
+ "title": "Мёртвые строки и давность уборки",
+ "description": "Мёртвых строк много само по себе не страшно — страшно, когда autovacuum до них давно не доходил. Значение -1 в возрасте означает, что уборки не было ни разу с момента запуска: такая таблица растёт без ограничения.",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 9, "w": 10, "x": 14, "y": 7 },
+ "targets": [
+ { "refId": "A", "instant": true, "format": "table", "expr": "topk(15, pg_table_write_amplification_dead_tup{db=~\"$db\"} > 10000)" },
+ { "refId": "B", "instant": true, "format": "table", "expr": "pg_table_write_amplification_last_autovacuum_age_s{db=~\"$db\"}" }
+ ],
+ "transformations": [
+ { "id": "joinByField", "options": { "byField": "table", "mode": "outer" } },
+ {
+ "id": "organize",
+ "options": {
+ "excludeByName": { "Time": true, "Time 1": true, "Time 2": true, "job": true, "job 1": true, "job 2": true, "instance": true, "instance 1": true, "instance 2": true, "host": true, "host 1": true, "host 2": true, "schema": true, "schema 1": true, "schema 2": true, "db 2": true, "cluster": true, "cluster 1": true, "cluster 2": true },
+ "renameByName": { "db 1": "Кластер", "table": "Таблица", "Value #A": "Мёртвых строк", "Value #B": "Уборка была, сек назад" }
+ }
+ },
+ { "id": "sortBy", "options": { "fields": {}, "sort": [ { "field": "Мёртвых строк", "desc": true } ] } }
+ ],
+ "fieldConfig": {
+ "defaults": { "custom": { "align": "auto", "cellOptions": { "type": "auto" } } },
+ "overrides": [
+ {
+ "matcher": { "id": "byName", "options": "Уборка была, сек назад" },
+ "properties": [
+ { "id": "unit", "value": "s" },
+ { "id": "custom.cellOptions", "value": { "type": "color-text" } },
+ { "id": "thresholds", "value": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 86400 }, { "color": "red", "value": 604800 } ] } }
+ ]
+ },
+ {
+ "matcher": { "id": "byName", "options": "Мёртвых строк" },
+ "properties": [ { "id": "unit", "value": "short" } ]
+ }
+ ]
+ }
+ },
+
+ {
+ "type": "timeseries",
+ "title": "Куда уходит диск: TOAST отдельно от строк",
+ "description": "Общий размер таблицы этого не показывает, а именно разделение объясняло, почему listings весила 19 ГБ. TOAST — вынесенные длинные значения: он растёт при каждом не-HOT апдейте текстового поля, даже если сам текст не изменился.",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 9, "w": 14, "x": 0, "y": 16 },
+ "targets": [
+ { "refId": "A", "expr": "topk(5, pg_table_size_detail_toast_bytes{db=~\"$db\"})", "legendFormat": "{{db}} · {{table}} · TOAST" },
+ { "refId": "B", "expr": "topk(5, pg_table_size_detail_index_bytes{db=~\"$db\"})", "legendFormat": "{{db}} · {{table}} · индексы" },
+ { "refId": "C", "expr": "topk(5, pg_table_size_detail_heap_bytes{db=~\"$db\"})", "legendFormat": "{{db}} · {{table}} · строки" }
+ ],
+ "fieldConfig": {
+ "defaults": { "unit": "bytes", "min": 0, "custom": { "fillOpacity": 10, "showPoints": "never", "lineWidth": 2 } },
+ "overrides": []
+ }
+ },
+
+ {
+ "type": "timeseries",
+ "title": "Размер баз",
+ "description": "У GlitchTip нет политики ретенции вообще — проверено, grep по retention даёт ноль попаданий. База растёт без ограничения, и нужен ряд, чтобы поймать это до того, как кончится диск.",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 9, "w": 10, "x": 14, "y": 16 },
+ "targets": [
+ { "refId": "A", "expr": "pg_database_size_bytes_detail_bytes{db=~\"$db\"}", "legendFormat": "{{db}} · {{database}}" }
+ ],
+ "fieldConfig": {
+ "defaults": { "unit": "bytes", "min": 0, "custom": { "fillOpacity": 10, "showPoints": "never", "lineWidth": 2 } },
+ "overrides": []
+ }
+ },
+
+ { "type": "row", "title": "Нагрузка", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 } },
+
+ {
+ "type": "timeseries",
+ "title": "Транзакции в секунду",
+ "description": "Откаты отдельно от фиксаций. Ровный фон откатов — это не «иногда бывает», а поток ошибок, который в логах может не отражаться вовсе.",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 8, "w": 8, "x": 0, "y": 26 },
+ "targets": [
+ { "refId": "A", "expr": "sum by (db) (rate(pg_stat_database_xact_commit{db=~\"$db\"}[5m]))", "legendFormat": "{{db}} · зафиксировано" },
+ { "refId": "B", "expr": "sum by (db) (rate(pg_stat_database_xact_rollback{db=~\"$db\"}[5m]))", "legendFormat": "{{db}} · откачено" }
+ ],
+ "fieldConfig": {
+ "defaults": { "unit": "ops", "min": 0, "custom": { "fillOpacity": 8, "showPoints": "never", "lineWidth": 2 } },
+ "overrides": [
+ { "matcher": { "id": "byRegexp", "options": ".*откачено.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] }
+ ]
+ }
+ },
+
+ {
+ "type": "timeseries",
+ "title": "Попадание в кэш",
+ "description": "Доля чтений, обслуженных из shared_buffers. Провал означает, что рабочий набор перестал помещаться в память — обычно это следствие раздутия, а не роста самих данных.",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 8, "w": 8, "x": 8, "y": 26 },
+ "targets": [
+ { "refId": "A", "expr": "sum by (db) (rate(pg_stat_database_blks_hit{db=~\"$db\"}[5m])) / clamp_min(sum by (db) (rate(pg_stat_database_blks_hit{db=~\"$db\"}[5m]) + rate(pg_stat_database_blks_read{db=~\"$db\"}[5m])), 1)", "legendFormat": "{{db}}" }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percentunit",
+ "min": 0,
+ "max": 1,
+ "custom": { "fillOpacity": 8, "showPoints": "never", "lineWidth": 2 },
+ "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "orange", "value": 0.9 }, { "color": "green", "value": 0.98 } ] }
+ },
+ "overrides": []
+ }
+ },
+
+ {
+ "type": "timeseries",
+ "title": "Временные файлы",
+ "description": "Постгрес пишет на диск, когда сортировка или хеш не помещаются в work_mem. Всплеск здесь объясняет запросы, которые «вдруг стали медленными» без единого изменения в коде.",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 8, "w": 8, "x": 16, "y": 26 },
+ "targets": [
+ { "refId": "A", "expr": "sum by (db) (rate(pg_stat_database_temp_bytes{db=~\"$db\"}[5m]))", "legendFormat": "{{db}}" }
+ ],
+ "fieldConfig": {
+ "defaults": { "unit": "Bps", "min": 0, "custom": { "fillOpacity": 20, "showPoints": "never", "lineWidth": 1 } },
+ "overrides": []
+ }
+ },
+
+ {
+ "type": "timeseries",
+ "title": "Состояние соединений",
+ "description": "idle in transaction — открытая транзакция, которая ничего не делает: именно она держит горизонт и мешает уборке. Ожидание блокировки — запросы, которые стоят друг за другом; растущая линия здесь читается снаружи как «сайт подвис».",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 34 },
+ "targets": [
+ { "refId": "A", "expr": "pg_activity_horizon_client_backends{db=~\"$db\"}", "legendFormat": "{{db}} · клиентских" },
+ { "refId": "B", "expr": "pg_activity_horizon_idle_in_transaction{db=~\"$db\"}", "legendFormat": "{{db}} · idle in transaction" },
+ { "refId": "C", "expr": "pg_activity_horizon_waiting_on_lock{db=~\"$db\"}", "legendFormat": "{{db}} · ждут блокировку" }
+ ],
+ "fieldConfig": {
+ "defaults": { "unit": "short", "min": 0, "custom": { "fillOpacity": 8, "showPoints": "never", "lineWidth": 2 } },
+ "overrides": [
+ { "matcher": { "id": "byRegexp", "options": ".*idle in transaction.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] },
+ { "matcher": { "id": "byRegexp", "options": ".*ждут блокировку.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] }
+ ]
+ }
+ },
+
+ {
+ "type": "timeseries",
+ "title": "Взаимоблокировки",
+ "description": "Взаимоблокировка снимается сервером принудительно: одна из транзакций получает ошибку. Для пользователя это неудавшееся действие, для логов приложения — исключение без внятной причины.",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 34 },
+ "targets": [
+ { "refId": "A", "expr": "sum by (db) (increase(pg_stat_database_deadlocks{db=~\"$db\"}[1h]))", "legendFormat": "{{db}}" }
+ ],
+ "fieldConfig": {
+ "defaults": {
+ "unit": "short",
+ "min": 0,
+ "custom": { "fillOpacity": 40, "showPoints": "never", "lineWidth": 1, "drawStyle": "bars" },
+ "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 1 } ] }
+ },
+ "overrides": []
+ }
+ }
+ ]
+}
diff --git a/ops/metrics/grafana/provisioning/dashboards/dashboards.yml b/ops/metrics/grafana/provisioning/dashboards/dashboards.yml
new file mode 100644
index 00000000..bc47c477
--- /dev/null
+++ b/ops/metrics/grafana/provisioning/dashboards/dashboards.yml
@@ -0,0 +1,16 @@
+apiVersion: 1
+
+providers:
+ - name: gendesign
+ orgId: 1
+ folder: GenDesign
+ type: file
+ disableDeletion: false
+ # Правки через интерфейс не переживают пересоздание контейнера: файл на диске
+ # перетрёт их обратно. Это намеренно — дашборд должен лежать в git, иначе
+ # через полгода никто не скажет, почему панель считает именно так.
+ allowUiUpdates: false
+ updateIntervalSeconds: 30
+ options:
+ path: /var/lib/grafana/dashboards
+ foldersFromFilesStructure: false
diff --git a/ops/metrics/grafana/provisioning/datasources/datasources.yml b/ops/metrics/grafana/provisioning/datasources/datasources.yml
new file mode 100644
index 00000000..f76904a5
--- /dev/null
+++ b/ops/metrics/grafana/provisioning/datasources/datasources.yml
@@ -0,0 +1,75 @@
+apiVersion: 1
+
+# Источники данных задаются файлом, а не руками в интерфейсе: настройка, сделанная
+# кликами, живёт только в томе и теряется при пересоздании контейнера — ровно та
+# ловушка, из-за которой при переезде «healthy» ничего не значил.
+
+datasources:
+ - name: Prometheus
+ uid: prometheus
+ type: prometheus
+ access: proxy
+ url: http://prometheus:9090
+ isDefault: true
+ jsonData:
+ # Совпадает со scrape_interval: иначе Grafana подбирает шаг сама и на
+ # длинных окнах рисует пилу там, где данные ровные.
+ timeInterval: 30s
+ httpMethod: POST
+ manageAlerts: false
+ editable: false
+
+ - name: Loki
+ uid: loki
+ type: loki
+ access: proxy
+ url: http://loki:3100
+ jsonData:
+ maxLines: 2000
+ # Связка логов с метриками по общей метке host.
+ derivedFields: []
+ editable: false
+
+ - name: Alertmanager
+ uid: alertmanager
+ type: alertmanager
+ access: proxy
+ url: http://alertmanager:9093
+ jsonData:
+ implementation: prometheus
+ handleGrafanaManagedAlerts: false
+ editable: false
+
+ # ── GlitchTip через ПРЯМОЙ SQL, а не через Sentry-плагин ────────────────────
+ # Плагин grafana/sentry-datasource официально документирован GlitchTip'ом, но
+ # на нашей 6.1.6 половина его путей нерабочая: stats_v2 с фильтром по проекту
+ # отдаёт 500 (открытый баг GlitchTip #381 с 2025-01-10), Events/Discover — 404
+ # (#416), Metrics/Spans/Tags вообще Sentry-only. В самом grafana/sentry-datasource
+ # слова «glitchtip» не встречается ни разу — апстрим эту связку не тестирует.
+ #
+ # Прямой SQL от этого свободен и правок в GlitchTip не требует вовсе, нужен
+ # только read-only пользователь. Схема проверена на живой базе 26.08:
+ # issue_events_issue несёт count / first_seen / last_seen / status / level,
+ # а issue_events_issueaggregate (issue_id, organization_id, date, count)
+ # партиционирована по неделям — ряды и топ-N без сканов сырья.
+ #
+ # ГРАНИЦА: доступ read-only. Тренды — здесь, а assign/resolve, стектрейсы и
+ # breadcrumbs — только в самом GlitchTip. Окна остаётся два: витрина и рабочее
+ # место, и это осознанно, а не недоделка.
+ - name: GlitchTip DB
+ uid: glitchtip-db
+ type: postgres
+ access: proxy
+ url: gendesign-infra-postgres:5432
+ database: glitchtip
+ user: grafana_ro
+ secureJsonData:
+ password: ${GLITCHTIP_RO_PASSWORD}
+ jsonData:
+ sslmode: disable
+ postgresVersion: 1600
+ timescaledb: false
+ maxOpenConns: 4
+ maxIdleConns: 2
+ connMaxLifetime: 14400
+ editable: false
diff --git a/ops/metrics/loki/loki-config.yml b/ops/metrics/loki/loki-config.yml
new file mode 100644
index 00000000..7919be2f
--- /dev/null
+++ b/ops/metrics/loki/loki-config.yml
@@ -0,0 +1,78 @@
+# Loki — monolithic (all-in-one). Рекомендованный режим до ~20 ГБ/сутки.
+# Наш объём — ~39 МБ/сутки (замер 24.08), то есть запас в 500 раз. Микросервисная
+# раскладка здесь была бы сложностью без единого выигрыша.
+
+auth_enabled: false # один арендатор; разграничение снаружи, на Caddy basic_auth
+
+server:
+ http_listen_port: 3100
+ grpc_listen_port: 9096
+ log_level: warn
+ # Логи Alloy шлёт пачками; дефолтные 4 МБ на gRPC-сообщение при всплеске
+ # (например, рестарт с досылом из WAL) дают 'grpc: received message larger than max'.
+ grpc_server_max_recv_msg_size: 16777216
+ grpc_server_max_send_msg_size: 16777216
+
+common:
+ instance_addr: 127.0.0.1
+ path_prefix: /loki
+ storage:
+ filesystem:
+ chunks_directory: /loki/chunks
+ rules_directory: /loki/rules
+ replication_factor: 1
+ ring:
+ kvstore:
+ store: inmemory
+
+schema_config:
+ configs:
+ - from: 2026-08-01
+ store: tsdb
+ object_store: filesystem
+ schema: v13
+ index:
+ prefix: index_
+ period: 24h
+
+storage_config:
+ tsdb_shipper:
+ active_index_directory: /loki/tsdb-index
+ cache_location: /loki/tsdb-cache
+ filesystem:
+ directory: /loki/chunks
+
+limits_config:
+ # Приём «из прошлого». Агент после обрыва досылает накопленное из WAL —
+ # с дефолтным окном (1 неделя приёма, но reject_old_samples_max_age) часть
+ # догоняемых строк молча отбрасывается с 'entry too far behind'.
+ reject_old_samples: true
+ reject_old_samples_max_age: 168h
+ # Потолок на арендатора. 39 МБ/сутки = ~0.5 МБ/с в пике; 8 МБ/с — щедрый запас,
+ # но не «безлимит», чтобы взбесившийся источник не съел диск за ночь.
+ ingestion_rate_mb: 8
+ ingestion_burst_size_mb: 16
+ max_streams_per_user: 5000
+ max_label_names_per_series: 20
+ # Ретенция. Journald на хостах держит ~13 дней при потолке 500 МБ; в Loki
+ # смысл хранить дольше — ради разбора инцидентов постфактум.
+ retention_period: 720h
+ volume_enabled: true
+
+compactor:
+ working_directory: /loki/compactor
+ delete_request_store: filesystem
+ # Без retention_enabled параметр retention_period выше НЕ работает: Loki
+ # примет его в конфиг и будет копить вечно. Классическая тихая настройка.
+ retention_enabled: true
+ retention_delete_delay: 2h
+ compaction_interval: 10m
+
+ruler:
+ storage:
+ type: local
+ local:
+ directory: /loki/rules
+
+analytics:
+ reporting_enabled: false
diff --git a/ops/metrics/postgres/queries.yml b/ops/metrics/postgres/queries.yml
new file mode 100644
index 00000000..8e76e960
--- /dev/null
+++ b/ops/metrics/postgres/queries.yml
@@ -0,0 +1,116 @@
+# Дополнительные запросы для 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: "Размер, байт" }
diff --git a/ops/metrics/prometheus/prometheus.yml b/ops/metrics/prometheus/prometheus.yml
new file mode 100644
index 00000000..35c52b97
--- /dev/null
+++ b/ops/metrics/prometheus/prometheus.yml
@@ -0,0 +1,58 @@
+# Prometheus — серверная сторона, живёт на Beget.
+#
+# СКРЕЙПА ЧУЖИХ ХОСТОВ ЗДЕСЬ НЕТ. Метрики с Poincare приходят push'ем через
+# remote-write receiver (--web.enable-remote-write-receiver), потому что открывать
+# входящие порты на продуктовом хосте ради мониторинга — плохой размен.
+# Локально скрейпится только то, что стоит на этом же хосте.
+#
+# Метка `host` проставляется агентом Alloy на своей стороне (external_labels),
+# поэтому здесь её нет: если задать и там и тут, honor_labels-семантика сделает
+# результат неочевидным.
+
+global:
+ scrape_interval: 30s
+ scrape_timeout: 10s
+ evaluation_interval: 30s
+ external_labels:
+ cluster: gendesign
+
+rule_files:
+ - /etc/prometheus/rules/*.yml
+
+# Пока профиль alerts выключен, этой цели не существует и Prometheus раз в
+# интервал пишет в лог, что не смог её разрешить. Это шум, а не отказ: правила
+# считаются и видны в интерфейсе, просто уведомлять некому. Молча выключать
+# alerting не стали — тогда включение алертов потребовало бы правки конфига,
+# а не одной переменной.
+alerting:
+ alertmanagers:
+ - static_configs:
+ - targets: ["alertmanager:9093"]
+
+scrape_configs:
+ # Сам Prometheus. Нужен не для красоты: по нему строится алерт на здоровье
+ # приёмника — пустая панель неотличима от «ошибок нет», и это надо различать.
+ - job_name: prometheus
+ static_configs:
+ - targets: ["localhost:9090"]
+ labels:
+ host: infra
+
+ - job_name: alertmanager
+ static_configs:
+ - targets: ["alertmanager:9093"]
+ labels:
+ host: infra
+
+ - job_name: loki
+ static_configs:
+ - targets: ["loki:3100"]
+ labels:
+ host: infra
+
+ - job_name: grafana
+ static_configs:
+ - targets: ["grafana:3000"]
+ labels:
+ host: infra
+ metrics_path: /metrics
diff --git a/ops/metrics/prometheus/rules/infra.yml b/ops/metrics/prometheus/rules/infra.yml
new file mode 100644
index 00000000..8b24f2b4
--- /dev/null
+++ b/ops/metrics/prometheus/rules/infra.yml
@@ -0,0 +1,208 @@
+# Правила алертов: инфраструктура и здоровье самого мониторинга.
+#
+# Принцип отбора — тот же, что у задачи #3078: сюда попадает только то, что уже
+# ломалось молча. Правила «на всякий случай» не заводим: лишний алерт, который
+# никто не разбирает, обесценивает остальные.
+
+groups:
+ # ── Здоровье самого наблюдателя ─────────────────────────────────────────────
+ # Пустая панель неотличима от «всё хорошо». Эти правила закрывают именно это.
+ - name: monitoring-self
+ interval: 60s
+ rules:
+ # Всегда горит. Существует ради того, чтобы его ОТСУТСТВИЕ было заметно:
+ # раз в 12 часов приходит подтверждение, что цепочка правило → Alertmanager
+ # → Telegram → человек цела. Молчащий канал — самый частый способ узнать
+ # об аварии последним (в проекте МЕРЫ наружу не ушло ни одного сообщения
+ # с 30 мая, и это выяснилось случайно).
+ - alert: Watchdog
+ expr: vector(1)
+ labels:
+ severity: none
+ annotations:
+ summary: "Сторож мониторинга"
+
+ # Агент замолчал. На Poincare это единственный источник всех метрик:
+ # если он умер, графики просто перестанут обновляться, оставаясь зелёными.
+ - alert: HostAgentDown
+ expr: up{job="node"} == 0 or absent(up{job="node", host="apps"})
+ for: 5m
+ labels:
+ severity: critical
+ annotations:
+ summary: "Агент метрик не отвечает"
+ description: "Хост {{ $labels.host }}: node-exporter недоступен более 5 минут. Метрики этого хоста больше не поступают."
+
+ # Приёмник перестал принимать push. Симптом со стороны центра.
+ - alert: RemoteWriteStalled
+ expr: |
+ absent_over_time(up{job="node", host="apps"}[15m])
+ for: 5m
+ labels:
+ severity: critical
+ annotations:
+ summary: "С продуктового хоста 15 минут не приходят метрики"
+ description: "Либо лёг агент на Poincare, либо оборван канал до metrics.gendsgn.ru, либо приёмник не принимает remote-write."
+
+ # ── Хост ────────────────────────────────────────────────────────────────────
+ - name: host
+ interval: 60s
+ rules:
+ # Диск. На Beget уже был случай, когда занято 79 % и никто не смотрел;
+ # порог 85 % даёт запас на реакцию, а не сообщает о свершившемся факте.
+ - alert: DiskSpaceLow
+ expr: |
+ (1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
+ / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) > 0.85
+ for: 15m
+ labels:
+ severity: warning
+ annotations:
+ summary: "Диск занят больше 85 %"
+ description: "{{ $labels.host }} {{ $labels.mountpoint }}: занято {{ $value | humanizePercentage }}."
+
+ - alert: DiskSpaceCritical
+ expr: |
+ (1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
+ / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) > 0.93
+ for: 5m
+ labels:
+ severity: critical
+ annotations:
+ summary: "Диск почти кончился"
+ description: "{{ $labels.host }} {{ $labels.mountpoint }}: занято {{ $value | humanizePercentage }}. Postgres при заполнении диска останавливается."
+
+ # Прогноз важнее порога: он ловит утечку до того, как она упрётся в стену.
+ - alert: DiskWillFillIn24h
+ expr: |
+ predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}[6h], 24*3600) < 0
+ for: 30m
+ labels:
+ severity: warning
+ annotations:
+ summary: "По текущему темпу диск кончится за сутки"
+ description: "{{ $labels.host }} {{ $labels.mountpoint }}: экстраполяция по последним 6 часам."
+
+ - alert: MemoryPressure
+ expr: |
+ (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) > 0.90
+ for: 15m
+ labels:
+ severity: warning
+ annotations:
+ summary: "Памяти доступно меньше 10 %"
+ description: "{{ $labels.host }}: свободной памяти {{ $value | humanizePercentage }} от общей."
+
+ # Своп сам по себе не беда, но резкий рост означает, что что-то перестало
+ # помещаться. На Beget своп в 1,78 ГБ был симптомом сосуществования прода
+ # и инфраструктуры — и рассосался ровно в момент переезда.
+ - alert: SwapGrowing
+ expr: |
+ node_memory_SwapTotal_bytes > 0
+ and (1 - node_memory_SwapFree_bytes / node_memory_SwapTotal_bytes) > 0.50
+ for: 30m
+ labels:
+ severity: warning
+ annotations:
+ summary: "Своп занят больше половины"
+ description: "{{ $labels.host }}: обычно означает, что рабочий набор перестал помещаться в память."
+
+ # ── Контейнеры ──────────────────────────────────────────────────────────────
+ - name: containers
+ interval: 60s
+ rules:
+ # Цикл перезапусков. Контейнер в restart-loop снаружи выглядит «поднятым»,
+ # а деплой при этом зелёный — именно так чуть не уехал в прод пустой пароль
+ # у infra-postgres (#3061).
+ - alert: ContainerRestartLoop
+ expr: |
+ changes(container_start_time_seconds{name!=""}[30m]) > 3
+ for: 5m
+ labels:
+ severity: warning
+ annotations:
+ summary: "Контейнер перезапускается по кругу"
+ description: "{{ $labels.host }} / {{ $labels.name }}: больше трёх стартов за полчаса."
+
+ # Подошёл к своему mem_limit — следующий шаг OOM-kill.
+ - alert: ContainerNearMemoryLimit
+ expr: |
+ container_spec_memory_limit_bytes{name!=""} > 0
+ and container_memory_working_set_bytes{name!=""}
+ / container_spec_memory_limit_bytes{name!=""} > 0.90
+ for: 15m
+ labels:
+ severity: warning
+ annotations:
+ summary: "Контейнер у своего потолка памяти"
+ description: "{{ $labels.host }} / {{ $labels.name }}: {{ $value | humanizePercentage }} от mem_limit. Дальше OOM-kill."
+
+ # ── Postgres ────────────────────────────────────────────────────────────────
+ - name: postgres
+ interval: 60s
+ rules:
+ # Забытая транзакция держит горизонт vacuum и отравляет весь кластер.
+ # Разбор #2607: осиротевшие запросы висели 46 часов.
+ - alert: PostgresLongTransaction
+ expr: pg_activity_horizon_oldest_xact_age_s > 3600
+ for: 10m
+ labels:
+ severity: warning
+ annotations:
+ summary: "Транзакция открыта больше часа"
+ description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value | humanizeDuration }}. Пока она жива, vacuum не может убрать мёртвые строки во ВСЕЙ базе."
+
+ - alert: PostgresLongTransactionCritical
+ expr: pg_activity_horizon_oldest_xact_age_s > 21600
+ for: 10m
+ labels:
+ severity: critical
+ annotations:
+ summary: "Транзакция открыта больше шести часов"
+ description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value | humanizeDuration }}. Это уже влияет на размер базы."
+
+ - alert: PostgresIdleInTransaction
+ expr: pg_activity_horizon_idle_in_transaction > 3
+ for: 15m
+ labels:
+ severity: warning
+ annotations:
+ summary: "Соединения висят в открытой транзакции"
+ description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value }} шт. Обычно это незакрытая сессия в коде."
+
+ # Раздутие. Не мгновенный сигнал, а тренд — но именно его отсутствие
+ # позволило 91 день не замечать 198 апдейтов на строку.
+ - alert: PostgresLowHotUpdateRatio
+ expr: |
+ rate(pg_table_write_amplification_tup_upd[6h]) > 0.5
+ and
+ rate(pg_table_write_amplification_tup_hot_upd[6h])
+ / rate(pg_table_write_amplification_tup_upd[6h]) < 0.2
+ for: 6h
+ labels:
+ severity: warning
+ annotations:
+ summary: "Обновления идут мимо HOT"
+ description: "{{ $labels.host }} / {{ $labels.table }}: доля HOT {{ $value | humanizePercentage }}. Каждый такой апдейт переписывает строку во все индексы и заново тостит длинные поля — так набегает раздутие."
+
+ - alert: PostgresDeadTuplesHigh
+ expr: |
+ pg_table_write_amplification_dead_tup > 1000000
+ and pg_table_write_amplification_dead_tup
+ / (pg_table_write_amplification_live_tup + 1) > 0.5
+ for: 1h
+ labels:
+ severity: warning
+ annotations:
+ summary: "Мёртвых строк больше половины от живых"
+ description: "{{ $labels.host }} / {{ $labels.table }}: {{ $value }} мёртвых. Autovacuum не справляется либо заблокирован долгой транзакцией."
+
+ # WAL. Замер 20.08: 7 ГБ/сутки при четырёх пользовательских расчётах.
+ - alert: PostgresWalRateHigh
+ expr: rate(pg_wal_bytes_wal_bytes_total[1h]) > 104857600 / 3600
+ for: 2h
+ labels:
+ severity: warning
+ annotations:
+ summary: "WAL пишется быстрее 100 МБ/час"
+ description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value | humanize1024 }}B/с. Стоит сверить с реальной пользовательской нагрузкой — расхождение означает лишние записи."
diff --git a/scripts/setup-metrics-exporter-dsn.sh b/scripts/setup-metrics-exporter-dsn.sh
new file mode 100644
index 00000000..ff618cab
--- /dev/null
+++ b/scripts/setup-metrics-exporter-dsn.sh
@@ -0,0 +1,77 @@
+#!/usr/bin/env bash
+# Собирает строки подключения для postgres_exporter на продуктовом хосте из
+# паролей, которые там уже есть. Идемпотентен: заданные значения не трогает.
+#
+# ПОЧЕМУ НЕ ЗАВОДИМ ОТДЕЛЬНЫЕ СЕКРЕТЫ. Каждая новая копия пароля — ещё одно
+# место, откуда он может утечь, и ещё одно, которое забудут повернуть при
+# ротации. Экспортеру нужны те же учётки, что уже лежат в окружении хоста.
+#
+# Запускать на продуктовом хосте. Вызывается из deploy-metrics.yml.
+set -euo pipefail
+
+ENVF=/opt/gendesign/backend/.env.runtime
+
+if [ ! -f "$ENVF" ]; then
+ echo " ОШИБКА: не найден файл окружения бэкенда." >&2
+ exit 1
+fi
+
+set -a
+# shellcheck source=/dev/null
+. "$ENVF"
+set +a
+
+add_key() {
+ local key="$1" value="$2"
+ if grep -qE "^${key}=" "$ENVF" 2>/dev/null; then
+ echo " $key — уже задан, не трогаю"
+ return 0
+ fi
+ if [ -z "$value" ]; then
+ echo " $key — нечем заполнить, пропускаю (метрики этой базы не поедут)"
+ return 0
+ fi
+ printf '%s=%s\n' "$key" "$value" >> "$ENVF"
+ echo " $key — записан"
+}
+
+# Экспортер ходит по сетевому алиасу, а не по IP: адреса контейнеров меняются
+# при каждом пересоздании, и DSN с IP протух бы на первом же деплое.
+add_key GENDESIGN_EXPORTER_DSN \
+ "${GENDESIGN_EXPORTER_DSN:-${DATABASE_URL:-}}"
+
+add_key TRADEIN_EXPORTER_DSN \
+ "${TRADEIN_EXPORTER_DSN:-${TRADEIN_DATABASE_URL:-}}"
+
+# postgres_exporter не понимает схему postgresql+psycopg:// из SQLAlchemy —
+# ему нужна чистая postgresql://. Приводим, если досталась приложенческая форма.
+python3 - "$ENVF" <<'PY'
+import io, re, sys
+
+path = sys.argv[1]
+with io.open(path, encoding="utf-8") as fh:
+ lines = fh.readlines()
+
+changed = False
+out = []
+for line in lines:
+ m = re.match(r'^((?:GENDESIGN|TRADEIN)_EXPORTER_DSN)=(.*)$', line.rstrip('\n'))
+ if not m:
+ out.append(line)
+ continue
+ key, dsn = m.group(1), m.group(2)
+ fixed = re.sub(r'^postgresql\+\w+://', 'postgresql://', dsn)
+ fixed = re.sub(r'^postgres\+\w+://', 'postgresql://', fixed)
+ if 'sslmode=' not in fixed:
+ fixed += ('&' if '?' in fixed else '?') + 'sslmode=disable'
+ if fixed != dsn:
+ changed = True
+ print(" %s — схема приведена к postgresql:// для экспортера" % key)
+ out.append("%s=%s\n" % (key, fixed))
+
+if changed:
+ with io.open(path, "w", encoding="utf-8", newline="\n") as fh:
+ fh.writelines(out)
+PY
+
+echo " строки подключения экспортеров готовы"
diff --git a/scripts/setup-metrics-grafana-role.sh b/scripts/setup-metrics-grafana-role.sh
new file mode 100644
index 00000000..3da10af8
--- /dev/null
+++ b/scripts/setup-metrics-grafana-role.sh
@@ -0,0 +1,78 @@
+#!/usr/bin/env bash
+# Заводит read-only роль grafana_ro в базе glitchtip — под датасорс Grafana.
+# Идемпотентен: повторный запуск только досогласует права.
+#
+# ПОЧЕМУ ОТДЕЛЬНАЯ РОЛЬ, А НЕ ВЛАДЕЛЕЦ БАЗЫ. Датасорс Grafana доступен всякому,
+# кто вошёл в интерфейс, и позволяет выполнять произвольный SQL в панелях.
+# Владельческая роль сделала бы витрину способом уронить трекер ошибок. Здесь
+# прав на запись нет вовсе, поэтому худшее, что можно сделать через панель, —
+# медленный SELECT.
+#
+# Запускать на инфраструктурном хосте. Вызывается из deploy-metrics.yml.
+set -euo pipefail
+
+CONT=gendesign-infra-postgres
+DB=glitchtip
+ROLE=grafana_ro
+
+: "${GLITCHTIP_RO_PASSWORD:?GLITCHTIP_RO_PASSWORD не задан — запусти scripts/setup-metrics-secrets.sh}"
+
+if ! docker inspect "$CONT" >/dev/null 2>&1; then
+ echo " $CONT не найден — пропускаю создание роли."
+ exit 0
+fi
+
+# Ищем роль с правом заводить других. Имя суперпользователя в этом кластере
+# нигде не зафиксировано, а угадывать «postgres» неверно: в образе оно задаётся
+# переменной POSTGRES_USER и здесь ею не является.
+SUPER=""
+for candidate in glitchtip forgejo postgres; do
+ if docker exec "$CONT" psql -U "$candidate" -d postgres -tAc \
+ "SELECT 1 FROM pg_roles WHERE rolname = CURRENT_USER AND (rolsuper OR rolcreaterole)" 2>/dev/null \
+ | grep -q 1; then
+ SUPER="$candidate"
+ break
+ fi
+done
+
+if [ -z "$SUPER" ]; then
+ echo " ОШИБКА: не нашёл роль с правом CREATE ROLE в $CONT." >&2
+ echo " Проверь вручную: docker exec $CONT psql -U <роль> -c '\\du'" >&2
+ exit 1
+fi
+echo " привилегированная роль: $SUPER"
+
+# Пароль передаётся через переменную окружения psql, а не в тексте запроса —
+# иначе он осел бы в pg_stat_statements и в логе запросов.
+docker exec -e RO_PASS="$GLITCHTIP_RO_PASSWORD" -i "$CONT" \
+ psql -U "$SUPER" -d "$DB" -v ON_ERROR_STOP=1 <<'SQL'
+\set ro_pass `echo "$RO_PASS"`
+
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ro') THEN
+ CREATE ROLE grafana_ro LOGIN;
+ RAISE NOTICE 'роль grafana_ro создана';
+ ELSE
+ RAISE NOTICE 'роль grafana_ro уже есть';
+ END IF;
+END
+$$;
+
+ALTER ROLE grafana_ro WITH PASSWORD :'ro_pass';
+
+-- Ограничение на число соединений: панель с автообновлением способна открыть
+-- их десятками, а это тот же кластер, где живёт Forgejo.
+ALTER ROLE grafana_ro CONNECTION LIMIT 8;
+
+GRANT CONNECT ON DATABASE glitchtip TO grafana_ro;
+GRANT USAGE ON SCHEMA public TO grafana_ro;
+GRANT SELECT ON ALL TABLES IN SCHEMA public TO grafana_ro;
+
+-- Таблицы событий партиционированы по неделям: новые партиции появляются сами,
+-- и без этой строки датасорс начал бы отдавать пустоту на свежих данных,
+-- оставаясь при этом «рабочим». Тихий отказ ровно того сорта, который мы ловим.
+ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO grafana_ro;
+SQL
+
+echo " grafana_ro: права выданы на $DB"
diff --git a/scripts/setup-metrics-secrets.sh b/scripts/setup-metrics-secrets.sh
new file mode 100644
index 00000000..f1fba79f
--- /dev/null
+++ b/scripts/setup-metrics-secrets.sh
@@ -0,0 +1,108 @@
+#!/usr/bin/env bash
+# Разовая подготовка учётных данных стека наблюдаемости (#3078).
+#
+# Запускать НА ИНФРАСТРУКТУРНОМ ХОСТЕ (Beget), один раз. Идемпотентен: уже
+# заданные значения переиспользуются, ничего не перезаписывается.
+#
+# ЧТО ДЕЛАЕТ:
+# 1. Генерирует пароли приёмника, витрины, администратора Grafana и read-only
+# роли к базе GlitchTip; дописывает их в окружение хоста.
+# 2. Кладёт пароль приёмника на продуктовый хост — агенту нужно им
+# авторизоваться при отправке метрик и логов.
+# 3. Печатает bcrypt-хеши для caddy/metrics-*.caddy.snippet.
+#
+# ЧЕГО НЕ ДЕЛАЕТ: не печатает сами пароли. Хеш публиковать безопасно, пароль —
+# нет, а вывод скрипта попадает в журнал деплоя и в историю терминала.
+#
+# Токен Telegram скрипт НЕ генерирует — его нужно задать руками:
+# METRICS_TELEGRAM_BOT_TOKEN, METRICS_TELEGRAM_CHAT_ID
+set -euo pipefail
+
+ENVF=/opt/gendesign/backend/.env.runtime
+REMOTE_ENVF=/opt/gendesign/backend/.env.runtime
+RSSH=(ssh -o BatchMode=yes -o ConnectTimeout=15 selectel)
+STAMP=$(date -u +%Y%m%d%H%M%S)
+
+if [ ! -f "$ENVF" ]; then
+ echo "ОШИБКА: не найден файл окружения бэкенда на этом хосте." >&2
+ exit 1
+fi
+
+# Копия перед первой правкой — одна на запуск, а не на каждый ключ.
+backup_once() {
+ [ -f "${ENVF}.bak-metrics-${STAMP}" ] && return 0
+ cp -p "$ENVF" "${ENVF}.bak-metrics-${STAMP}"
+}
+
+have() { grep -qE "^${1}=" "$ENVF" 2>/dev/null; }
+value_of() { grep -m1 -E "^${1}=" "$ENVF" | cut -d= -f2-; }
+
+# 32 символа из [A-Za-z0-9]: помещается в basic_auth без экранирования и не
+# ломает разбор .env, где кавычки и знак равенства создают сюрпризы.
+gen_pass() { tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32; }
+
+ensure() {
+ local key="$1" val="$2"
+ if have "$key"; then
+ echo " $key — уже есть, переиспользую"
+ else
+ backup_once
+ printf '%s=%s\n' "$key" "$val" >> "$ENVF"
+ echo " $key — сгенерирован"
+ fi
+}
+
+echo "=== учётки на инфраструктурном хосте ==="
+ensure METRICS_INGEST_USER "alloy"
+ensure METRICS_INGEST_PASSWORD "$(gen_pass)"
+ensure METRICS_UI_USER "metrics"
+ensure METRICS_UI_PASSWORD "$(gen_pass)"
+ensure GRAFANA_ADMIN_USER "admin"
+ensure GRAFANA_ADMIN_PASSWORD "$(gen_pass)"
+ensure GLITCHTIP_RO_PASSWORD "$(gen_pass)"
+
+INGEST_PASS=$(value_of METRICS_INGEST_PASSWORD)
+UI_PASS=$(value_of METRICS_UI_PASSWORD)
+GT_RO=$(value_of GLITCHTIP_RO_PASSWORD)
+
+ensure INFRA_EXPORTER_DSN \
+ "postgresql://grafana_ro:${GT_RO}@gendesign-infra-postgres:5432/glitchtip?sslmode=disable"
+
+echo
+echo "=== пароль приёмника на продуктовый хост ==="
+if "${RSSH[@]}" "grep -qE '^METRICS_INGEST_PASSWORD=' ${REMOTE_ENVF}" 2>/dev/null; then
+ echo " уже задан, не трогаю"
+else
+ "${RSSH[@]}" "cp -p ${REMOTE_ENVF} ${REMOTE_ENVF}.bak-metrics-${STAMP}"
+ # Значение идёт по stdin, а не аргументом команды: аргументы видны в
+ # `ps` на обеих машинах и оседают в истории оболочки.
+ printf 'METRICS_INGEST_USER=alloy\nMETRICS_INGEST_PASSWORD=%s\n' "$INGEST_PASS" \
+ | "${RSSH[@]}" "cat >> ${REMOTE_ENVF}"
+ "${RSSH[@]}" "chown --reference=${REMOTE_ENVF}.bak-metrics-${STAMP} ${REMOTE_ENVF}; \
+ chmod --reference=${REMOTE_ENVF}.bak-metrics-${STAMP} ${REMOTE_ENVF}"
+ echo " записан, права сохранены по резервной копии"
+fi
+
+echo
+echo "=== bcrypt-хеши для caddy/metrics-*.caddy.snippet ==="
+echo " (пароли не печатаются; хеши безопасны для git)"
+echo
+for pair in "alloy:${INGEST_PASS}:metrics-ingest" "metrics:${UI_PASS}:metrics-ui"; do
+ user="${pair%%:*}"; rest="${pair#*:}"
+ pass="${rest%%:*}"; file="${rest#*:}"
+ hash=$(docker run --rm caddy:2 caddy hash-password --plaintext "$pass" 2>/dev/null | tr -d '\r\n')
+ b64=$(printf '%s' "$hash" | base64 -w 0)
+ printf ' caddy/%s.caddy.snippet\n %-8s %s\n' "$file" "$user" "$b64"
+done
+
+echo
+echo "=== что осталось сделать руками ==="
+have METRICS_TELEGRAM_BOT_TOKEN \
+ && echo " METRICS_TELEGRAM_BOT_TOKEN — задан" \
+ || echo " METRICS_TELEGRAM_BOT_TOKEN — НЕ задан, алерты никуда не уйдут"
+have METRICS_TELEGRAM_CHAT_ID \
+ && echo " METRICS_TELEGRAM_CHAT_ID — задан" \
+ || echo " METRICS_TELEGRAM_CHAT_ID — НЕ задан, алерты никуда не уйдут"
+echo
+echo " Пароль витрины смотреть так (в переписку не копировать):"
+echo " grep '^METRICS_UI_PASSWORD=' $ENVF"