Compare commits
No commits in common. "cfcdb9393cd4f42ecd4e5412234bb5dd851b03bb" and "03d9745b4f47e37cc3057e7653f406dc2b2447eb" have entirely different histories.
cfcdb9393c
...
03d9745b4f
2 changed files with 6 additions and 135 deletions
|
|
@ -1,108 +0,0 @@
|
||||||
"""Гейт: `$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,19 +125,11 @@ 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_memory_working_set_bytes{name!=""}
|
container_spec_memory_limit_bytes{name!=""} > 0
|
||||||
/ (container_spec_memory_limit_bytes{name!=""} > 0) > 0.90
|
and container_memory_working_set_bytes{name!=""}
|
||||||
|
/ container_spec_memory_limit_bytes{name!=""} > 0.90
|
||||||
for: 15m
|
for: 15m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
|
|
@ -180,20 +172,12 @@ 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.5) < 0.2
|
/ rate(pg_table_write_amplification_tup_upd[6h]) < 0.2
|
||||||
for: 6h
|
for: 6h
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
|
|
@ -201,11 +185,6 @@ 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