diff --git a/backend/tests/ops/test_alert_value_is_the_described_quantity.py b/backend/tests/ops/test_alert_value_is_the_described_quantity.py new file mode 100644 index 00000000..0a08c66c --- /dev/null +++ b/backend/tests/ops/test_alert_value_is_the_described_quantity.py @@ -0,0 +1,108 @@ +"""Гейт: `$value` в тексте алерта — это та величина, которую текст называет. + +Найдено 12.09.2026 по боевым сообщениям в Telegram: + + «apps / tradein-browser: 2.684e+11% от mem_limit. Дальше OOM-kill.» + «apps / listings: доля HOT 75.21%.» (при пороге срабатывания «доля < 20%») + +Причина у обоих одна и она про ФОРМУ выражения, а не про условие. В PromQL +`A and B` возвращает ЗНАЧЕНИЯ ЛЕВОЙ части, отфильтрованные правой. Значит в +`$value` попадает A, а не то отношение, ради которого правило написано: + + - ContainerNearMemoryLimit: слева стоял `container_spec_memory_limit_bytes`, + и в сообщение уходил ЛИМИТ В БАЙТАХ, отрендеренный `humanizePercentage` + (2 684 354 560 → «2.684e+11%»). Условие при этом срабатывало верно. + - PostgresLowHotUpdateRatio: слева стоял `rate(tup_upd[6h])` — АПДЕЙТОВ В + СЕКУНДУ. Это опаснее: 0.7521 превращалось в «75.21%», число попадало в + правдоподобный диапазон и противоречило собственному порогу («доля < 20%»), + но выглядело настоящим. + +Отсюда инвариант, который здесь и проверяется: **если описание рендерит +`$value` как долю (`humanizePercentage`), выражение обязано возвращать долю** — +то есть его ЛЕВАЯ часть (до первого `and`/`unless`) обязана содержать деление. +Отсев побочных условий переносится внутрь знаменателя (`X / (Y > 0)`), а не в +`and` слева. + +Гейт намеренно не пытается «понять» PromQL целиком: он ловит ровно ту форму, +которая уже дважды уехала в прод, и не мешает правилам, печатающим абсолютные +величины без humanize (PostgresDeadTuplesHigh, PostgresIdleInTransaction). +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import yaml + +_RULES_DIR = Path(__file__).resolve().parents[3] / "ops" / "metrics" / "prometheus" / "rules" + + +def _alerts() -> list[tuple[str, str, str, dict]]: + """(файл, имя алерта, expr, annotations) по всем файлам правил.""" + out: list[tuple[str, str, str, dict]] = [] + for path in sorted(_RULES_DIR.glob("*.yml")): + doc = yaml.safe_load(path.read_text(encoding="utf-8")) + for group in doc.get("groups", []): + for rule in group.get("rules", []): + if "alert" in rule: + out.append( + ( + path.name, + rule["alert"], + rule.get("expr", ""), + rule.get("annotations") or {}, + ) + ) + return out + + +def _left_of_and(expr: str) -> str: + """Часть выражения ДО первого бинарного `and`/`unless` — её значения и видит $value.""" + parts = re.split(r"\band\b|\bunless\b", expr, maxsplit=1) + return parts[0] + + +def test_rules_dir_is_found() -> None: + assert _RULES_DIR.is_dir(), f"нет каталога правил: {_RULES_DIR}" + assert _alerts(), "правила не распарсились — гейт был бы зелёным впустую" + + +def test_percentage_annotations_come_from_a_ratio() -> None: + """Текст обещает долю → выражение обязано её и возвращать.""" + broken: list[str] = [] + for fname, name, expr, ann in _alerts(): + text = " ".join(str(v) for v in ann.values()) + if "humanizePercentage" not in text: + continue + left = _left_of_and(expr) + if "/" not in left: + broken.append( + f"{fname}::{name}: описание печатает $value как долю, но левая часть " + f"выражения (её и видит $value) деления не содержит: {' '.join(left.split())!r}" + ) + assert not broken, "\n".join(broken) + + +def test_known_two_rules_are_fixed() -> None: + """Именные проверки для двух правил, которые уже соврали в проде.""" + by_name = {name: (expr, ann) for _, name, expr, ann in _alerts()} + + expr, ann = by_name["ContainerNearMemoryLimit"] + flat = " ".join(expr.split()) + assert flat.startswith("container_memory_working_set_bytes"), ( + "слева должно стоять потребление, иначе в Telegram уедет лимит в байтах: " + flat + ) + assert "(container_spec_memory_limit_bytes" in flat and "> 0)" in flat, ( + "отсев нулевого лимита должен стоять В ЗНАМЕНАТЕЛЕ, а не в `and` слева: " + flat + ) + assert "humanizePercentage" in " ".join(ann.values()) + + expr, ann = by_name["PostgresLowHotUpdateRatio"] + flat = " ".join(expr.split()) + assert flat.startswith("rate(pg_table_write_amplification_tup_hot_upd"), ( + "слева должен стоять числитель доли HOT, иначе печатается rate(tup_upd): " + flat + ) + assert "/ (rate(pg_table_write_amplification_tup_upd[6h]) > 0.5)" in flat, ( + "гейт по объёму апдейтов должен жить в знаменателе — он же защищает от 0/0: " + flat + ) diff --git a/ops/metrics/prometheus/rules/infra.yml b/ops/metrics/prometheus/rules/infra.yml index 8b24f2b4..91d9a154 100644 --- a/ops/metrics/prometheus/rules/infra.yml +++ b/ops/metrics/prometheus/rules/infra.yml @@ -125,11 +125,19 @@ groups: description: "{{ $labels.host }} / {{ $labels.name }}: больше трёх стартов за полчаса." # Подошёл к своему mem_limit — следующий шаг OOM-kill. + # + # ФОРМА ВЫРАЖЕНИЯ ВАЖНА, а не только условие. `A and B` возвращает ЗНАЧЕНИЯ + # ЛЕВОЙ части, отфильтрованные правой, — то есть в `$value` попадает именно + # A. Прежняя запись (`limit > 0 and working_set/limit > 0.90`) слала в + # Telegram лимит В БАЙТАХ, отрендеренный как процент: боевое сообщение + # 12.09 — «2.684e+11% от mem_limit» при limit = 2 684 354 560 Б. Условие + # при этом срабатывало верно, врал только текст. Поэтому отношение стоит + # СЛЕВА, а отсев нулевого лимита убран внутрь знаменателя: `(X > 0)` + # выбрасывает серии без лимита ДО деления. - 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 + container_memory_working_set_bytes{name!=""} + / (container_spec_memory_limit_bytes{name!=""} > 0) > 0.90 for: 15m labels: severity: warning @@ -172,12 +180,20 @@ groups: # Раздутие. Не мгновенный сигнал, а тренд — но именно его отсутствие # позволило 91 день не замечать 198 апдейтов на строку. + # + # Та же ловушка `A and B`, что и у ContainerNearMemoryLimit, и здесь она + # опаснее: в `$value` попадал `rate(tup_upd[6h])` — АПДЕЙТОВ В СЕКУНДУ, а + # текст называл это долей HOT. Боевое сообщение 12.09 — «доля HOT 75.21%» + # при пороге срабатывания «доля < 20%»: число само себе противоречило и + # выглядело правдоподобно, поэтому никто не заметил (замер 12.09 по той же + # таблице listings: rate(tup_upd[6h]) = 0.0411 → сообщение сказало бы + # «4.11%», настоящая доля HOT = 0.00%). Гейт по объёму апдейтов + # (> 0.5/с — «трафик есть, значит вопрос осмыслен») перенесён внутрь + # знаменателя: там он и фильтрует серии, и защищает от деления на ноль. - 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 + / (rate(pg_table_write_amplification_tup_upd[6h]) > 0.5) < 0.2 for: 6h labels: severity: warning @@ -185,6 +201,11 @@ groups: summary: "Обновления идут мимо HOT" description: "{{ $labels.host }} / {{ $labels.table }}: доля HOT {{ $value | humanizePercentage }}. Каждый такой апдейт переписывает строку во все индексы и заново тостит длинные поля — так набегает раздутие." + # Третье правило того же семейства `A and B` — и единственное, где текст + # верен: `$value` тут печатается без humanize, а слева стоит ровно то, что + # описание и называет («N мёртвых»). Совпадение, а не заслуга формы: если + # когда-нибудь захочется печатать здесь ДОЛЮ, отношение придётся вынести + # влево, как в двух правилах выше. - alert: PostgresDeadTuplesHigh expr: | pg_table_write_amplification_dead_tup > 1000000