From a0fe09811dad2d9e402bc39cc68f471f6fbb8cfc Mon Sep 17 00:00:00 2001 From: h Date: Sun, 20 Sep 2026 21:14:31 +0000 Subject: [PATCH] =?UTF-8?q?feat(hands,jobs):=20=D0=B4=D0=BB=D1=8F=D1=89?= =?UTF-8?q?=D0=B0=D1=8F=D1=81=D1=8F=20=D0=B1=D0=B5=D0=B4=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=BF=D1=80=D0=BE=D0=BF=D0=B0=D0=B4=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=20-=20=D1=81=D1=83=D1=82=D0=BE=D1=87=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?=D1=81=D0=B2=D0=BE=D0=B4=D0=BA=D0=B0=20=D0=B8=20=D0=B2=D1=81?= =?UTF-8?q?=D0=BF=D0=BB=D0=B5=D1=81=D0=BA=20=D0=BF=D1=80=D0=BE=D1=82=D0=B8?= =?UTF-8?q?=D0=B2=20=D1=81=D0=B2=D0=BE=D0=B5=D0=B9=20=D0=BD=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- beaver_agent/hands/komodo_logs.py | 290 ++++++++++++++++++++++++++++-- beaver_agent/jobs/__init__.py | 2 + beaver_agent/jobs/logs.py | 34 ++-- tests/test_komodo_logs.py | 190 ++++++++++++++++++++ 4 files changed, 492 insertions(+), 24 deletions(-) diff --git a/beaver_agent/hands/komodo_logs.py b/beaver_agent/hands/komodo_logs.py index 790bb64..bc9ffc3 100644 --- a/beaver_agent/hands/komodo_logs.py +++ b/beaver_agent/hands/komodo_logs.py @@ -6,6 +6,12 @@ и обратный переход «было → нет» - с временем последнего события, не с временем прохода (логи берутся с `--timestamps`, время в строке настоящее). +Молчать - не то же самое, что забыть. Беда, идущая неделю, после первого +доклада не исчезает: она переходит в длящиеся и раз в сутки идёт отдельной +короткой сводкой (имена и числа, без строк), а если за проход её стало в разы +больше собственной нормы - снова дёргает сразу. Затихание наоборот: не тревога, +а строка «стихло» с временем последнего события. + Показанную строку лога чистит общий `security.redact` - тот же, что стоит на логах и на выдаче MCP: правила в одном месте, и он знает литеральные значения секретов из env. Голые блобы без имени рядом он не трогает намеренно - id @@ -37,12 +43,16 @@ __all__ = [ "SIGNALS", "TAIL", "Entry", + "Ongoing", "Report", "Seen", "StackScan", + "Surge", "Sweep", "brief", + "daily", "digest", + "grouped", "read_stack", ] @@ -94,10 +104,33 @@ MAX_TROUBLE = 10 GAP_NAMES = 3 UNPARSED_LOUD = 10 """Столько неразобранных строк на проход - уже не обрезанная голова, а дыра.""" +MAX_DAILY = 20 KEEP = timedelta(days=14) """Сколько помнить беду, которой больше не видно: дальше пусть будет новой.""" + +QUIET_AFTER = timedelta(hours=24) +"""Сутки без единого нового события - беда считается стихшей, даже если её +строка ещё лежит в хвосте. Иначе на тихом стеке «стихло» ждало бы неделями, +пока строку не вытеснит.""" + +SURGE_FACTOR = 4.0 +"""Во сколько раз проход должен перебить собственную норму беды, чтобы снова +стоить тревоги. Проходов восемь в сутки, и счёт за проход сам по себе гуляет +вдвое (деплой, ночной крон, перезапуск) - вчетверо это уже не дрожание. +Считается по среднему за прошлые активные проходы, то есть у каждой беды +норма своя; хвост ограничен, поэтому взрыв упирается в TAIL и видно скорее +«стало на порядок», чем точную кратность.""" +SURGE_MIN = 10 +"""Пол в штуках: без него «было одно, стало четыре» будило бы каждый раз.""" +SURGE_PASSES = 3 +"""Пока беду видели меньше трёх раз, нормы у неё нет и сравнивать не с чем.""" +SURGE_HOLD = timedelta(hours=12) +"""Одна беда - не чаще раза в полсуток, даже если растёт ступенями.""" +TREND_UP, TREND_DOWN = 1.5, 0.67 +"""Полтора раза в сутках - уже тренд, между ними - то же самое.""" + KEY_CHARS = 12 -STATE_VERSION = 1 +STATE_VERSION = 2 EPOCH = datetime(1970, 1, 1, tzinfo=UTC) @@ -112,6 +145,15 @@ class Hit: sample: str at: datetime | None count: int + times: tuple[datetime, ...] = () + """Время каждого повтора в хвосте: хвост перечитывается каждый проход, и + без этого одни и те же строки считались бы заново по восемь раз в сутки.""" + + def since(self, after: datetime | None) -> int: + """Сколько повторов случилось позже ``after`` - то есть новых для нас.""" + if after is None: + return self.count + return sum(1 for t in self.times if t > after) @dataclass(frozen=True, slots=True) @@ -199,13 +241,68 @@ class Entry: looked: datetime | None = None """Когда её последний раз видели в хвосте: по этому и забываем.""" count: int = 0 + """Всего повторов, посчитанных приростом, а не перечитыванием хвоста.""" + passes: int = 0 + """В скольких проходах она прирастала: по ним и считается её норма.""" + day_count: int = 0 + day_before: int = 0 + day_earlier: int = 0 + """Повторы за текущие сутки и за двое прошлых - из них сводка и тренд.""" told: bool = False """Сказали ли о ней мастеру. Инжект не доехал - не сказали, повторим.""" + announced: bool = False + """Про неё говорили именно как про новую, а не приняли в базовую линию: + «стихло» бывает только про то, о чём мастер слышал.""" + quieted: datetime | None = None + """Когда доложили, что стихла. Появится новое событие - снова новость.""" + surged: datetime | None = None + """Когда последний раз докладывали о всплеске: чаще раза в полсуток - нет.""" @property def where(self) -> str: return f"{self.stack}/{self.service}" + @property + def norm(self) -> float: + """Своя норма: сколько повторов у неё бывает за активный проход.""" + return self.count / self.passes if self.passes else 0.0 + + +@dataclass(frozen=True, slots=True) +class Ongoing: + """Длящееся одним рядом: стек, сервис и сигнал, а не отдельная строка. + + Один сервис сыплет десяток похожих строк (у caddy их семь видов), и в + сводке каждая своей строкой - та же стена, от которой сводку и делали. + """ + + where: str + signal: str + first: datetime | None + day_before: int + day_earlier: int + kinds: int + """Сколько разных строк слилось в этот ряд.""" + + def trend(self) -> str: + if not self.day_earlier: + return "первые сутки" + ratio = self.day_before / self.day_earlier + if ratio >= TREND_UP: + return f"растёт (было {self.day_earlier})" + if ratio <= TREND_DOWN: + return f"падает (было {self.day_earlier})" + return f"ровно (было {self.day_earlier})" + + +@dataclass(frozen=True, slots=True) +class Surge: + """Знакомая беда, которой за проход стало в разы больше своей нормы.""" + + entry: Entry + gain: int + norm: float + @dataclass(frozen=True, slots=True) class Report: @@ -213,7 +310,14 @@ class Report: sweep: Sweep new: tuple[Entry, ...] = () + surge: tuple[Surge, ...] = () quiet: tuple[Entry, ...] = () + ongoing: tuple[Entry, ...] = () + """Длящееся: молчит в обычном проходе, идёт в суточную сводку.""" + summary: bool = False + """Сутки закрылись и сводку по длящемуся ещё не отдавали.""" + day: str = "" + """Сутки, за которые считается сводка (те, что только что закрылись).""" trouble: tuple[str, ...] = () gaps: tuple[str, ...] = () cold: bool = False @@ -225,7 +329,8 @@ class Report: @property def worth_telling(self) -> bool: - return bool(self.new or self.quiet or self.trouble or self.cold) + """Суточная сводка сюда не входит: она едет своим инжектом.""" + return bool(self.new or self.surge or self.quiet or self.trouble or self.cold) @dataclass(slots=True) @@ -239,6 +344,10 @@ class Seen: trouble: tuple[str, ...] = () """О каких отказах уже сказали: Komodo лежит - скажем один раз, не каждые 3 ч.""" last_pass: datetime | None = None + day: str | None = None + """Локальные сутки, которым принадлежат суточные счётчики записей.""" + summary_day: str | None = None + """Сутки, за которые сводка по длящемуся уже доехала до мастера.""" fresh: bool = True """Файла не было: базовую линию только снимаем, стену старых бед не шлём.""" @@ -261,7 +370,14 @@ class Seen: last=_parse(row.get("last")), looked=_parse(row.get("looked")), count=int(row.get("count", 0)), + passes=int(row.get("passes", 0)), + day_count=int(row.get("day_count", 0)), + day_before=int(row.get("day_before", 0)), + day_earlier=int(row.get("day_earlier", 0)), told=bool(row.get("told")), + announced=bool(row.get("announced")), + quieted=_parse(row.get("quieted")), + surged=_parse(row.get("surged")), ) for key, row in raw.get("entries", {}).items() } @@ -271,6 +387,8 @@ class Seen: baselined=set(raw.get("baselined", ())), trouble=tuple(raw.get("trouble", ())), last_pass=_parse(raw.get("last_pass")), + day=raw.get("day"), + summary_day=raw.get("summary_day"), fresh=False, ) @@ -278,6 +396,8 @@ class Seen: body = { "version": STATE_VERSION, "last_pass": _iso(self.last_pass), + "day": self.day, + "summary_day": self.summary_day, "baselined": sorted(self.baselined), "trouble": list(self.trouble), "entries": { @@ -290,7 +410,14 @@ class Seen: "last": _iso(e.last), "looked": _iso(e.looked), "count": e.count, + "passes": e.passes, + "day_count": e.day_count, + "day_before": e.day_before, + "day_earlier": e.day_earlier, "told": e.told, + "announced": e.announced, + "quieted": _iso(e.quieted), + "surged": _iso(e.surged), } for key, e in self.entries.items() }, @@ -301,13 +428,24 @@ class Seen: tmp.replace(self.path) def mark_told(self, report: Report) -> None: - """Инжект доехал: новое больше не новое, стихшее забывается.""" + """Инжект доехал: новое сказано, стихшее помечено, всплеск выдержан. + + Стихшее не выбрасывается, а помечается: его строка может ещё лежать в + хвосте, и выброшенная запись завтра же вернулась бы как «новая». + """ for entry in report.new: entry.told = True + entry.announced = True + for item in report.surge: + item.entry.surged = report.sweep.at for entry in report.quiet: - self.entries.pop(entry.key, None) + entry.quieted = report.sweep.at self.trouble = report.trouble + def mark_daily(self, report: Report) -> None: + """Сводка доехала: за эти сутки больше не повторяем.""" + self.summary_day = report.day + def _iso(at: datetime | None) -> str | None: return at.isoformat() if at else None @@ -381,6 +519,7 @@ def read_stack(stack: str, log: dict[str, Any]) -> StackScan: sample=(before.sample if before else redact(rest)[:SAMPLE_CHARS]), at=at or (before.at if before else None), count=(before.count if before else 0) + 1, + times=((*before.times, at) if before else (at,)), ) return StackScan( stack=stack, @@ -404,14 +543,41 @@ def _forget(seen: Seen, now: datetime) -> None: del seen.entries[key] -def digest(seen: Seen, sweep: Sweep) -> Report: - """Сравнить проход с прошлым: что новое, что стихло, о чём молчать.""" +def _roll(seen: Seen, day: str) -> None: + """Сутки сменились: закрытый день уезжает в `day_before`, счёт с нуля.""" + if seen.day == day: + return + if seen.day is not None: + for entry in seen.entries.values(): + entry.day_earlier = entry.day_before + entry.day_before = entry.day_count + entry.day_count = 0 + seen.day = day + + +def _surging(entry: Entry, gain: int, at: datetime) -> bool: + """Стал ли знакомый фон снова новостью: в разы выше собственной нормы.""" + if not entry.told or entry.quieted is not None: + return False + if entry.passes < SURGE_PASSES or gain < SURGE_MIN: + return False + if gain < SURGE_FACTOR * entry.norm: + return False + return entry.surged is None or at - entry.surged > SURGE_HOLD + + +def digest(seen: Seen, sweep: Sweep, tz: tzinfo = UTC) -> Report: + """Сравнить проход с прошлым: что новое, что всплеск, что стихло, что длится.""" gaps = sweep.gaps(seen.last_pass) _forget(seen, sweep.at) + day = sweep.at.astimezone(tz).date().isoformat() + _roll(seen, day) looked = {scan.stack for scan in sweep.read} newcomers = looked - seen.baselined hits = {hit.key: hit for scan in sweep.read for hit in scan.hits} baseline = 0 + born: set[str] = set() + surge: list[Surge] = [] for key, hit in hits.items(): entry = seen.entries.get(key) if entry is None: @@ -421,24 +587,54 @@ def digest(seen: Seen, sweep: Sweep) -> Report: service=hit.service, signal=hit.signal, sample=hit.sample, - first=hit.at or sweep.at, + first=hit.times[0] if hit.times else sweep.at, looked=sweep.at, ) seen.entries[key] = entry + born.add(key) if hit.stack in newcomers: # Стек читаем впервые: в хвосте лежит всё его прошлое, и вываливать # его как «новое» - врать. Принимаем за данность, дальше - только рост. entry.told = True baseline += 1 - entry.last = hit.at or entry.last or sweep.at entry.looked = sweep.at - entry.count += hit.count + gain = hit.since(entry.last) + if not gain: + # Та же строка, что и в прошлый проход: хвост перечитан, беда не росла. + continue + if _surging(entry, gain, sweep.at): + surge.append(Surge(entry=entry, gain=gain, norm=entry.norm)) + if entry.quieted is not None: + # Стихшая беда вернулась: это снова новость, а не продолжение. + entry.quieted = None + entry.told = False + entry.count += gain + entry.passes += 1 + entry.day_count += gain + entry.last = hit.at or entry.last or sweep.at seen.baselined |= looked - # Стихло - только у стеков, которые прочитались: чего не видели, того не судим. + # Стихло - только про то, о чём говорили, и только у прочитанных стеков: + # чего не видели, того не судим, а про фон мастер и не слышал. quiet = tuple( e for e in seen.entries.values() - if e.told and e.stack in looked and e.key not in hits + if e.announced + and e.quieted is None + and e.stack in looked + and e.key not in born + and ( + e.key not in hits + or (e.last is not None and sweep.at - e.last > QUIET_AFTER) + ) + ) + # Длящееся: молчит в проходе, но живёт в состоянии и едет в суточную сводку. + ongoing = tuple( + e + for e in seen.entries.values() + if e.quieted is None + and e.day_before > 0 + and e.last is not None + and sweep.at - e.last <= QUIET_AFTER ) seen.last_pass = sweep.at # Холодный старт без единого прочитанного стека базой не считается: иначе @@ -450,7 +646,11 @@ def digest(seen: Seen, sweep: Sweep) -> Report: return Report( sweep=sweep, new=tuple(sorted(fresh, key=lambda e: (e.stack, e.service))), + surge=tuple(sorted(surge, key=lambda s: -s.gain)), quiet=tuple(sorted(quiet, key=lambda e: (e.stack, e.service))), + ongoing=tuple(sorted(ongoing, key=lambda e: -e.day_before)), + summary=bool(ongoing) and seen.summary_day != day, + day=day, trouble=trouble, gaps=gaps, cold=cold, @@ -505,6 +705,66 @@ def _entry_line(entry: Entry, tz: tzinfo, *, new: bool) -> str: return head + entry.sample +def _surge_line(item: Surge, tz: tzinfo) -> str: + entry = item.entry + return ( + f"- {entry.where} {_when(entry.last, tz)} {entry.signal}: " + f"×{item.gain} за проход при норме ×{item.norm:.1f}, " + f"идёт с {_when(entry.first, tz)} - {entry.sample}" + ) + + +def grouped(entries: tuple[Entry, ...]) -> tuple[Ongoing, ...]: + """Слить длящееся по стеку, сервису и сигналу - рядов, а не строк.""" + rows: dict[tuple[str, str], list[Entry]] = {} + for entry in entries: + rows.setdefault((entry.where, entry.signal), []).append(entry) + out = [ + Ongoing( + where=where, + signal=signal, + first=min((e.first for e in group if e.first), default=None), + day_before=sum(e.day_before for e in group), + day_earlier=sum(e.day_earlier for e in group), + kinds=len(group), + ) + for (where, signal), group in rows.items() + ] + return tuple(sorted(out, key=lambda o: -o.day_before)) + + +def _daily_line(row: Ongoing, tz: tzinfo) -> str: + kinds = f", строк {row.kinds} видов" if row.kinds > 1 else "" + return ( + f"- {row.where} {row.signal}{kinds}, с {_when(row.first, tz)}, " + f"за сутки {row.day_before}, {row.trend()}" + ) + + +def daily(report: Report, tz: tzinfo = UTC) -> str: + """Суточная сводка по длящемуся: имена и числа, без строк лога. + + Отдельный инжект и отдельная мысль: в проходе это молчит, но раз в сутки + мастер видит, что именно идёт до сих пор и в какую сторону. + """ + rows = grouped(report.ongoing) + lines = [ + f"{SELF_MARK}: длящееся, сводка за {report.day}.", + ( + f"Идёт до сих пор: {len(rows)} рядов " + f"({len(report.ongoing)} разных строк), за закрытые сутки:" + ), + ] + lines.extend(_daily_line(row, tz) for row in rows[:MAX_DAILY]) + if len(rows) > MAX_DAILY: + lines.append(f"…и ещё {len(rows) - MAX_DAILY} рядов.") + lines.append( + "Новым это не считается и в проходах молчит; строки смотри " + "komodo(logs) или komodo(search_logs)." + ) + return "\n".join(lines) + + def brief(report: Report, tz: tzinfo = UTC) -> str: """Инжект: что нового, где, сколько раз - и всегда область просмотра.""" sweep = report.sweep @@ -531,8 +791,14 @@ def brief(report: Report, tz: tzinfo = UTC) -> str: lines.extend(_entry_line(e, tz, new=True) for e in report.new[:MAX_NEW]) if len(report.new) > MAX_NEW: lines.append(f"…и ещё {len(report.new) - MAX_NEW} - смотри komodo(logs).") - elif not report.cold and sweep.read: + elif not report.cold and sweep.read and not report.surge: lines.append("Нового в этой области нет.") + if report.surge: + lines.append( + f"Всплеск знакомого ({len(report.surge)}) - строка не новая, " + "но её стало в разы больше своей нормы:" + ) + lines.extend(_surge_line(item, tz) for item in report.surge[:MAX_NEW]) if report.quiet: lines.append(f"Стихло ({len(report.quiet)}), время - события, не прохода:") lines.extend(_entry_line(e, tz, new=False) for e in report.quiet[:MAX_QUIET]) diff --git a/beaver_agent/jobs/__init__.py b/beaver_agent/jobs/__init__.py index 9e3342d..0b14706 100644 --- a/beaver_agent/jobs/__init__.py +++ b/beaver_agent/jobs/__init__.py @@ -32,6 +32,8 @@ jobs = [ Job("куратор", curate, cron="0 2,8,12,16,20 * * *", critical=False), # Логи - раз в 3 часа и на ":40": на ":00" ротация и куратор. Чаще смысла # мало (36 стеков = 36 чтений за проход), реже - хвост уезжает совсем. + # Проход 01:40 - первый после полуночи: он же несёт суточную сводку по + # длящемуся, чтобы фон не исчезал из виду, доложенный однажды. Job("логи", logs_watch, cron="40 1,4,7,10,13,16,19,22 * * *", critical=False), Job("deploy", deploy, webhook=True), Job("komodo", komodo_alert, webhook=True, dedupe=False), diff --git a/beaver_agent/jobs/logs.py b/beaver_agent/jobs/logs.py index 4285e7e..ae62dd8 100644 --- a/beaver_agent/jobs/logs.py +++ b/beaver_agent/jobs/logs.py @@ -25,6 +25,7 @@ from beaver_agent.hands.komodo_logs import ( StackScan, Sweep, brief, + daily, digest, read_stack, ) @@ -99,26 +100,35 @@ async def sweep( async def logs_watch(run: JobRun) -> None: - """Проход по логам: состояние на диске, инжект - только на новом.""" + """Проход по логам: состояние на диске, инжект - на новом и раз в сутки. + + Инжекта два и они разные: обычный - про новое, всплески и отбой; суточный - + про длящееся, которое в проходах молчит. Первый проход суток у нас в 01:40, + он же и несёт сводку: в 00:00 ротация мастера, лезть туда незачем. + """ if KOMODO is None: _log.info("логи: руки komodo нет, проход пропущен") return seen = Seen.load(STATE_FILE) - report = digest(seen, await sweep(KOMODO)) - if not report.worth_telling: - seen.save() - _log.info( - "логи: нового нет, стеков прочитано %s из %s", - len(report.sweep.read), - report.sweep.in_scope, - ) - return - if await run.inject_master(brief(report, ZONE), urgency="normal", origin="логи"): + report = digest(seen, await sweep(KOMODO), ZONE) + if report.worth_telling and await run.inject_master( + brief(report, ZONE), urgency="normal", origin="логи" + ): seen.mark_told(report) + if report.summary and await run.inject_master( + daily(report, ZONE), urgency="normal", origin="логи" + ): + seen.mark_daily(report) seen.save() _log.info( - "логи: новое %s, стихло %s, не прочитано %s", + "логи: новое %s, всплеск %s, стихло %s, длится %s (сводка %s), " + "не прочитано %s, стеков %s из %s", len(report.new), + len(report.surge), len(report.quiet), + len(report.ongoing), + report.summary, len(report.trouble), + len(report.sweep.read), + report.sweep.in_scope, ) diff --git a/tests/test_komodo_logs.py b/tests/test_komodo_logs.py index 8be19b7..1490013 100644 --- a/tests/test_komodo_logs.py +++ b/tests/test_komodo_logs.py @@ -8,7 +8,9 @@ from beaver_agent.hands.komodo_logs import ( StackScan, Sweep, brief, + daily, digest, + grouped, read_stack, ) from beaver_agent.jobs.logs import sweep as sweep_fleet @@ -467,3 +469,191 @@ def test_a_bare_blob_stays_readable(): ) (hit,) = scan.hits assert "kJ8sdfLKJ23kjhsdf98234x" in hit.sample + + +def stamps(day, hour, count, start=0): + return [ + f"2026-09-{day:02d}T{hour:02d}:{m:02d}:00.0" + for m in range(start, start + count) + ] + + +def burst(stack, times, text="ERROR всё та же беда", service="svc"): + """Хвост стека, где одна и та же беда повторилась в перечисленные минуты.""" + return read_stack(stack, log(*(line(service, t, text) for t in times))) + + +def at(day, hour, minute=0): + return datetime(2026, 9, day, hour, minute, tzinfo=UTC) + + +def told(state, report): + state.mark_told(report) + return report + + +def test_a_continuing_trouble_is_told_once_and_then_keeps_quiet(tmp_path): + state = seen(tmp_path) + first = told( + state, digest(state, swept([burst("gitea", stamps(20, 9, 3))], at=at(20, 10))) + ) + assert len(first.new) == 1 + + # Хвост перечитан: те же строки плюс свежие - беда идёт, но новостью не стала. + later = digest( + state, + swept([burst("gitea", stamps(20, 9, 3) + stamps(20, 12, 4))], at=at(20, 13)), + ) + assert later.new == () and later.surge == () and later.quiet == () + assert not later.worth_telling + assert later.ongoing == () # за прошлые сутки её ещё не было + + +def test_the_reread_tail_does_not_inflate_the_count(tmp_path): + state = seen(tmp_path) + told(state, digest(state, swept([burst("gitea", stamps(20, 9, 3))], at=at(20, 10)))) + for hour in (13, 16, 19): + digest(state, swept([burst("gitea", stamps(20, 9, 3))], at=at(20, hour))) + (entry,) = state.entries.values() + assert entry.count == 3 # три повтора, а не три на каждый из четырёх проходов + assert entry.passes == 1 + + +def test_a_continuing_trouble_comes_back_in_the_daily_summary(tmp_path): + state = seen(tmp_path) + told(state, digest(state, swept([burst("gitea", stamps(20, 9, 5))], at=at(20, 10)))) + + # Первый проход следующих суток - 01:40, он же несёт сводку. + night = digest( + state, + swept([burst("gitea", stamps(20, 9, 5) + stamps(21, 1, 2))], at=at(21, 1, 40)), + ) + assert night.summary and len(night.ongoing) == 1 + assert night.new == () and not night.worth_telling + text = daily(night, UTC) + assert "длящееся, сводка за 2026-09-21" in text + assert "gitea/svc ошибка, с 20.09 09:00, за сутки 5, первые сутки" in text + assert "ERROR" not in text # образцов строк в сводке нет намеренно + told(state, night) + state.mark_daily(night) + + # Тот же день, следующий проход: сводку уже отдали. + again = digest( + state, + swept([burst("gitea", stamps(20, 9, 5) + stamps(21, 1, 2))], at=at(21, 4, 40)), + ) + assert again.ongoing and not again.summary + + +def test_the_summary_says_which_way_it_is_going(tmp_path): + state = seen(tmp_path) + told(state, digest(state, swept([burst("gitea", stamps(20, 9, 2))], at=at(20, 10)))) + day_two = swept( + [burst("gitea", stamps(20, 9, 2) + stamps(21, 9, 8))], at=at(21, 10) + ) + told(state, digest(state, day_two)) + third = digest( + state, + swept( + [burst("gitea", stamps(20, 9, 2) + stamps(21, 9, 8) + stamps(22, 1, 1))], + at=at(22, 1, 40), + ), + ) + assert "за сутки 8, растёт (было 2)" in daily(third, UTC) + + +def test_a_surge_of_the_familiar_breaks_the_silence(tmp_path): + state = seen(tmp_path) + state_report = digest( + state, swept([burst("gitea", stamps(20, 9, 1))], at=at(20, 10)) + ) + told(state, state_report) + for hour, minute in ((13, 12), (16, 15)): + digest( + state, + swept([burst("gitea", stamps(20, hour - 1, 1, minute))], at=at(20, hour)), + ) + (entry,) = state.entries.values() + assert entry.passes == 3 and entry.norm == 1.0 + + spike = digest(state, swept([burst("gitea", stamps(20, 18, 12))], at=at(20, 19))) + (item,) = spike.surge + assert item.gain == 12 and item.norm == 1.0 + assert spike.new == () and spike.worth_telling + text = brief(spike) + assert "Всплеск знакомого (1)" in text + assert "×12 за проход при норме ×1.0" in text + assert "Нового в этой области нет" not in text + + # Второй такой же проход внутри выдержки молчит: это уже не новость. + told(state, spike) + more = digest(state, swept([burst("gitea", stamps(20, 21, 12))], at=at(20, 22))) + assert more.surge == () and not more.worth_telling + + +def test_a_small_jump_is_not_a_surge(tmp_path): + state = seen(tmp_path) + told(state, digest(state, swept([burst("gitea", stamps(20, 9, 1))], at=at(20, 10)))) + for hour in (13, 16): + digest( + state, + swept([burst("gitea", stamps(20, hour - 1, 1, hour))], at=at(20, hour)), + ) + # Вчетверо больше, но всего четыре штуки: пол в SURGE_MIN держит тишину. + small = digest(state, swept([burst("gitea", stamps(20, 18, 4))], at=at(20, 19))) + assert small.surge == () and not small.worth_telling + + +def test_a_trouble_gone_silent_is_reported_even_while_its_line_lingers(tmp_path): + state = seen(tmp_path) + tail = burst("gitea", stamps(20, 9, 2)) + told(state, digest(state, swept([tail], at=at(20, 10)))) + + # Сутки та же строка лежит в хвосте, новых событий нет. + assert not digest(state, swept([tail], at=at(20, 22))).quiet + stopped = digest(state, swept([tail], at=at(21, 12))) + (gone,) = stopped.quiet + assert gone.last == at(20, 9, 1) + assert "последнее в 20.09 09:01" in brief(stopped) + + told(state, stopped) + assert digest(state, swept([tail], at=at(21, 16))).quiet == () + + +def test_a_trouble_that_returns_after_the_all_clear_is_new_again(tmp_path): + state = seen(tmp_path) + tail = burst("gitea", stamps(20, 9, 2)) + told(state, digest(state, swept([tail], at=at(20, 10)))) + told(state, digest(state, swept([tail], at=at(21, 12)))) # стихла + + back = digest( + state, + swept([burst("gitea", stamps(20, 9, 2) + stamps(21, 18, 3))], at=at(21, 19)), + ) + assert len(back.new) == 1 and back.worth_telling + + +def test_the_background_of_the_baseline_never_claims_to_have_stopped(tmp_path): + # Про фон, принятый в базовую линию, мастер не слышал - «стихло» про него врёт. + state = seen(tmp_path, known=()) + state.fresh = False + cold = digest(state, swept([burst("lodge", stamps(20, 9, 2))], at=at(20, 10))) + assert cold.new == () and cold.baseline == 1 + gone = digest(state, swept([read_stack("lodge", log())], at=at(20, 13))) + assert gone.quiet == () and not gone.worth_telling + + +def test_the_summary_folds_the_variants_of_one_service_into_one_row(tmp_path): + state = seen(tmp_path) + # Разные по смыслу строки: отличие в одних цифрах - это одна и та же беда. + variants = [ + burst("gitea", stamps(20, 9, 4), text=f"ERROR {what}") + for what in ("push отклонён", "миграция не прошла", "ssh-ключ отвергнут") + ] + told(state, digest(state, swept(variants, at=at(20, 10)))) + night = digest(state, swept(variants, at=at(21, 1, 40))) + (row,) = grouped(night.ongoing) + assert row.kinds == 3 and row.day_before == 12 + text = daily(night, UTC) + assert "Идёт до сих пор: 1 рядов (3 разных строк)" in text + assert "gitea/svc ошибка, строк 3 видов, с 20.09 09:00, за сутки 12" in text