Merge pull request 'Тревоги печатали не ту величину, которую называли: «2.684e+11% от mem_limit» и «доля HOT 75.21%» при пороге 20%' (#3464) from fix/alert-value-is-not-the-ratio into main
All checks were successful
Deploy / changes (push) Successful in 12s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Metrics / server (push) Successful in 22s
Deploy Metrics / agent-infra (push) Successful in 27s
Deploy Metrics / agent-apps (push) Successful in 29s
Deploy / build-worker (push) Successful in 45s
Deploy / build-backend (push) Successful in 47s
Deploy / deploy (push) Successful in 1m12s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 1m42s

This commit is contained in:
bot-backend 2026-09-12 10:12:52 +00:00
commit cfcdb9393c
2 changed files with 135 additions and 6 deletions

View file

@ -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
)

View file

@ -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