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
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:
commit
cfcdb9393c
2 changed files with 135 additions and 6 deletions
108
backend/tests/ops/test_alert_value_is_the_described_quantity.py
Normal file
108
backend/tests/ops/test_alert_value_is_the_described_quantity.py
Normal 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
|
||||||
|
)
|
||||||
|
|
@ -125,11 +125,19 @@ groups:
|
||||||
description: "{{ $labels.host }} / {{ $labels.name }}: больше трёх стартов за полчаса."
|
description: "{{ $labels.host }} / {{ $labels.name }}: больше трёх стартов за полчаса."
|
||||||
|
|
||||||
# Подошёл к своему mem_limit — следующий шаг OOM-kill.
|
# Подошёл к своему 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
|
- alert: ContainerNearMemoryLimit
|
||||||
expr: |
|
expr: |
|
||||||
container_spec_memory_limit_bytes{name!=""} > 0
|
container_memory_working_set_bytes{name!=""}
|
||||||
and container_memory_working_set_bytes{name!=""}
|
/ (container_spec_memory_limit_bytes{name!=""} > 0) > 0.90
|
||||||
/ container_spec_memory_limit_bytes{name!=""} > 0.90
|
|
||||||
for: 15m
|
for: 15m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
|
|
@ -172,12 +180,20 @@ groups:
|
||||||
|
|
||||||
# Раздутие. Не мгновенный сигнал, а тренд — но именно его отсутствие
|
# Раздутие. Не мгновенный сигнал, а тренд — но именно его отсутствие
|
||||||
# позволило 91 день не замечать 198 апдейтов на строку.
|
# позволило 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
|
- alert: PostgresLowHotUpdateRatio
|
||||||
expr: |
|
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_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
|
for: 6h
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
|
|
@ -185,6 +201,11 @@ groups:
|
||||||
summary: "Обновления идут мимо HOT"
|
summary: "Обновления идут мимо HOT"
|
||||||
description: "{{ $labels.host }} / {{ $labels.table }}: доля HOT {{ $value | humanizePercentage }}. Каждый такой апдейт переписывает строку во все индексы и заново тостит длинные поля — так набегает раздутие."
|
description: "{{ $labels.host }} / {{ $labels.table }}: доля HOT {{ $value | humanizePercentage }}. Каждый такой апдейт переписывает строку во все индексы и заново тостит длинные поля — так набегает раздутие."
|
||||||
|
|
||||||
|
# Третье правило того же семейства `A and B` — и единственное, где текст
|
||||||
|
# верен: `$value` тут печатается без humanize, а слева стоит ровно то, что
|
||||||
|
# описание и называет («N мёртвых»). Совпадение, а не заслуга формы: если
|
||||||
|
# когда-нибудь захочется печатать здесь ДОЛЮ, отношение придётся вынести
|
||||||
|
# влево, как в двух правилах выше.
|
||||||
- alert: PostgresDeadTuplesHigh
|
- alert: PostgresDeadTuplesHigh
|
||||||
expr: |
|
expr: |
|
||||||
pg_table_write_amplification_dead_tup > 1000000
|
pg_table_write_amplification_dead_tup > 1000000
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue