Files
beaver-agent/beaver_agent/hands/komodo_logs.py
T

812 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Логи флота периодами: что в хвостах похоже на беду и чего там раньше не было.
Ходит и читает джоб `jobs/logs.py`, судит этот модуль. Правило одно: мастера
дёргает только НОВОЕ. То, что молотит в логе третьи сутки, уже сказано один
раз и больше не показывается; показывается то, чего в прошлом проходе не было,
и обратный переход «было → нет» - с временем последнего события, не с временем
прохода (логи берутся с `--timestamps`, время в строке настоящее).
Молчать - не то же самое, что забыть. Беда, идущая неделю, после первого
доклада не исчезает: она переходит в длящиеся и раз в сутки идёт отдельной
короткой сводкой (имена и числа, без строк), а если за проход её стало в разы
больше собственной нормы - снова дёргает сразу. Затихание наоборот: не тревога,
а строка «стихло» с временем последнего события.
Показанную строку лога чистит общий `security.redact` - тот же, что стоит на
логах и на выдаче MCP: правила в одном месте, и он знает литеральные значения
секретов из env. Голые блобы без имени рядом он не трогает намеренно - id
задач, сессий и коммитов это ровно то, по чему беду потом и опознают.
Показание всегда несёт свою область: сколько стеков в области, сколько из них
прочиталось, какой отрезок логов попал в хвост. Без этого «всё чисто»
неотличимо от «половину не посмотрел», поэтому чего не смог - тоже в инжекте.
"""
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta, tzinfo
from typing import TYPE_CHECKING, Any
from beaver_gateway.security.redact import redact
from beaver_agent.hands.komodo import ANSI
if TYPE_CHECKING:
from pathlib import Path
__all__ = [
"SCOPE",
"SIGNALS",
"TAIL",
"Entry",
"Ongoing",
"Report",
"Seen",
"StackScan",
"Surge",
"Sweep",
"brief",
"daily",
"digest",
"grouped",
"read_stack",
]
SCOPE = ("dell-home", "personal-main")
"""Свои серверы. Машины заказчиков (md-cars-*, veris-*, ms-agents-*) не наши:
там чужой прод и чужие секреты, и читать их логи в свой контекст - нельзя."""
TAIL = 2000
"""Строк на контейнер, и число из замера, а не «чтобы хватало».
Замер 20.09.2026 по всем 36 стекам области: при 500 строках хвост покрывал
меньше трёх часов (промежуток между проходами) ровно у одного стека -
`dell-home-music`, ~1170 строк/ч, то есть 51 минута; следующий по болтливости
`personal-main-caddy` покрывал 11,6 часа, `beaver-agent` - 20 ч, медиана по
области около 110 часов. 2000 строк закрывают и music (~3,4 часа) почти
бесплатно: тихие стеки отдают столько, сколько у них есть, растут три-четыре
болтливых. Где хвост всё равно короче промежутка, инжект говорит об этом сам."""
SIGNALS: tuple[tuple[str, str], ...] = (
("критично", r"\bCRITICAL\b|\bFATAL\b|\bEMERG\b"),
("паника", r"\bpanic:|\bpanicked at\b|\bfatal error:"),
("oom", r"\bOOM\b|OOMKilled|[Oo]ut of memory|oom[-_]kill"),
("traceback", r"Traceback \(most recent call last\)|\bUnhandled exception\b"),
("падение", r"exited with code (?!0\b)\d+|\bexit status [1-9]|Exited \([1-9]\d*\)"),
("рестарт-цикл", r"[Rr]estarting \(\d+\)|back-?off.{0,20}restart|CrashLoopBackOff"),
("healthcheck", r"health-?\s?check (?:failed|error)|\bunhealthy\b"),
("ошибка", r"\bERROR\b|\bERR!\b"),
)
"""Что считается бедой: метка и выражение. Правится одним местом; порядок -
приоритет, строка получает первую подошедшую метку."""
MATCHERS = tuple((label, re.compile(pattern)) for label, pattern in SIGNALS)
SELF_MARK = "Логи флота"
"""Свой же инжект видно в stdout gateway: ловить себя по кругу не надо."""
LINE_RE = re.compile(r"^(?P<service>[^\s|]+)\s*\|\s?(?P<rest>.*)$")
STAMP_RE = re.compile(
r"^(?P<ts>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)(?:\s(?P<text>.*))?$"
)
"""Пустая строка тоже со штампом (`сервис | время` и ничего) - это не мусор."""
DIGITS_RE = re.compile(r"\b[0-9a-f]{7,}\b|\d+")
"""Числа и хеши в строке - шум: без них «то же самое» узнаётся между проходами."""
SAMPLE_CHARS = 180
MAX_NEW = 25
MAX_QUIET = 10
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 = 2
EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
@dataclass(frozen=True, slots=True)
class Hit:
"""Одна беда в хвосте одного стека, уже слитая по повторам."""
key: str
stack: str
service: str
signal: str
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)
class StackScan:
"""Что дал один стек: находки, отрезок его хвоста или причина отказа."""
stack: str
hits: tuple[Hit, ...] = ()
first: datetime | None = None
last: datetime | None = None
lines: int = 0
unparsed: int = 0
"""Строки без `сервис | время`: обрезанная голова хвоста и прочий мусор."""
error: str | None = None
@property
def ok(self) -> bool:
return self.error is None
@dataclass(frozen=True, slots=True)
class Sweep:
"""Проход целиком - вместе с тем, какую область он покрыл."""
at: datetime
scans: tuple[StackScan, ...] = ()
in_scope: int = 0
fleet: int = 0
summary: int | None = None
tail: int = TAIL
scope: tuple[str, ...] = SCOPE
unreachable: str | None = None
"""Komodo не ответил вовсе - ни одного стека, и это не «чисто»."""
@property
def read(self) -> tuple[StackScan, ...]:
return tuple(s for s in self.scans if s.ok)
@property
def trouble(self) -> tuple[str, ...]:
if self.unreachable:
return (f"Komodo не ответил: {self.unreachable}",)
out = [f"{s.stack}: {s.error}" for s in self.scans if not s.ok]
if self.summary is not None and self.summary != self.fleet:
out.append(
f"листание дало {self.fleet} стеков, а GetStacksSummary - "
f"{self.summary}: список неполон"
)
return tuple(out)
@property
def span(self) -> tuple[datetime | None, datetime | None]:
"""Отрезок, который реально попал в хвосты всех прочитанных стеков."""
firsts = [s.first for s in self.read if s.first]
lasts = [s.last for s in self.read if s.last]
return (min(firsts) if firsts else None, max(lasts) if lasts else None)
@property
def shortest(self) -> StackScan | None:
"""Стек с самым коротким хвостом: он и ограничивает, что мы видели."""
stamped = [s for s in self.read if s.first]
if not stamped:
return None
return max(stamped, key=lambda s: s.first or EPOCH)
def gaps(self, since: datetime | None) -> tuple[str, ...]:
"""Стеки, чей хвост начался позже прошлого прохода: там дыра."""
if since is None:
return ()
return tuple(s.stack for s in self.read if s.first and s.first > since)
@dataclass(slots=True)
class Entry:
"""Беда, которую уже видели: когда впервые, когда последний раз, сколько раз."""
key: str
stack: str
service: str
signal: str
sample: str
first: datetime | None = None
last: datetime | None = None
"""Время события из строки лога, а не время прохода."""
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:
"""Что из прохода стоит мастера."""
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
"""Первый проход: состояния не было, и вся область легла в базовую линию."""
baseline: int = 0
"""Сколько бед легло в базовую линию в этом проходе."""
baselined: tuple[str, ...] = ()
"""Стеки, впервые прочитанные в этом проходе: их прошлое не показывается."""
@property
def worth_telling(self) -> bool:
"""Суточная сводка сюда не входит: она едет своим инжектом."""
return bool(self.new or self.surge or self.quiet or self.trouble or self.cold)
@dataclass(slots=True)
class Seen:
"""Состояние между проходами: файл на диске, переживает передеплой."""
path: Path
entries: dict[str, Entry] = field(default_factory=dict)
baselined: set[str] = field(default_factory=set)
"""Стеки, чьё прошлое уже принято за данность: их старое не «новое»."""
trouble: tuple[str, ...] = ()
"""О каких отказах уже сказали: Komodo лежит - скажем один раз, не каждые 3 ч."""
last_pass: datetime | None = None
day: str | None = None
"""Локальные сутки, которым принадлежат суточные счётчики записей."""
summary_day: str | None = None
"""Сутки, за которые сводка по длящемуся уже доехала до мастера."""
fresh: bool = True
"""Файла не было: базовую линию только снимаем, стену старых бед не шлём."""
@classmethod
def load(cls, path: Path) -> Seen:
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return cls(path=path)
if raw.get("version") != STATE_VERSION:
return cls(path=path)
entries = {
key: Entry(
key=key,
stack=row["stack"],
service=row["service"],
signal=row["signal"],
sample=row["sample"],
first=_parse(row.get("first")),
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()
}
return cls(
path=path,
entries=entries,
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,
)
def save(self) -> None:
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": {
key: {
"stack": e.stack,
"service": e.service,
"signal": e.signal,
"sample": e.sample,
"first": _iso(e.first),
"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()
},
}
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps(body, ensure_ascii=False), encoding="utf-8")
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:
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
def _parse(raw: Any) -> datetime | None:
if not isinstance(raw, str):
return None
try:
at = datetime.fromisoformat(raw)
except ValueError:
return None
return at if at.tzinfo else at.replace(tzinfo=UTC)
def _normal(text: str) -> str:
"""Строка без чисел, хешей и адресов: по ней узнаётся «та же самая беда»."""
return DIGITS_RE.sub("#", ANSI.sub("", text)).strip()
def _key(stack: str, service: str, text: str) -> str:
raw = f"{stack}\0{service}\0{_normal(text)}".encode()
return hashlib.blake2s(raw, digest_size=KEY_CHARS // 2).hexdigest()
def signal_of(text: str) -> str | None:
for label, matcher in MATCHERS:
if matcher.search(text):
return label
return None
def read_stack(stack: str, log: dict[str, Any]) -> StackScan:
"""Ответ GetStackLog → находки стека; ошибка Komodo остаётся ошибкой."""
if not isinstance(log, dict) or "error" in log:
error = log.get("error") if isinstance(log, dict) else "ответ не разобран"
return StackScan(stack=stack, error=str(error)[:200])
text = (log.get("stdout") or "") + "\n" + (log.get("stderr") or "")
found: dict[str, Hit] = {}
stamps: list[datetime] = []
lines = 0
unparsed = 0
for raw in text.splitlines():
line = ANSI.sub("", raw).strip()
if not line:
continue
match = LINE_RE.match(line)
stamped = STAMP_RE.match(match.group("rest")) if match else None
if match is None or stamped is None:
# Голову хвоста Komodo обрезает по месту, и от прохода к проходу она
# разная: недоразобранная строка - не находка, иначе каждый раз «ново».
unparsed += 1
continue
lines += 1
service = match.group("service")
at = datetime.fromisoformat(stamped.group("ts")).astimezone(UTC)
stamps.append(at)
rest = stamped.group("text") or ""
if SELF_MARK in rest:
continue
signal = signal_of(rest)
if signal is None:
continue
key = _key(stack, service, rest)
before = found.get(key)
found[key] = Hit(
key=key,
stack=stack,
service=service,
signal=signal,
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,
hits=tuple(found.values()),
first=min(stamps) if stamps else None,
last=max(stamps) if stamps else None,
lines=lines,
unparsed=unparsed,
)
def _forget(seen: Seen, now: datetime) -> None:
"""Беду, которой давно не видно в хвостах, забываем: вернётся - будет новой.
Считаем по времени прохода, в котором её видели, а не по времени события:
строка от прошлого месяца может спокойно лежать в хвосте тихого стека, и
забывать её - значит каждый проход объявлять её новой.
"""
for key, entry in list(seen.entries.items()):
if entry.told and entry.looked is not None and now - entry.looked > KEEP:
del seen.entries[key]
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:
entry = Entry(
key=key,
stack=hit.stack,
service=hit.service,
signal=hit.signal,
sample=hit.sample,
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.looked = sweep.at
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.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
# Холодный старт без единого прочитанного стека базой не считается: иначе
# первый удачный проход вывалит всю область как «новое».
cold = seen.fresh and bool(sweep.read)
seen.fresh = seen.fresh and not sweep.read
fresh = tuple(e for e in seen.entries.values() if not e.told)
trouble = sweep.trouble if sweep.trouble != seen.trouble else ()
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,
baseline=baseline,
baselined=tuple(sorted(newcomers)),
)
def _when(at: datetime | None, tz: tzinfo) -> str:
return at.astimezone(tz).strftime("%d.%m %H:%M") if at else "время не в строке"
def _scope_line(sweep: Sweep, gaps: tuple[str, ...], tz: tzinfo) -> str:
since, until = sweep.span
where = ", ".join(sweep.scope)
if sweep.unreachable:
return f"Область: {where} - не прочитано ничего, списка стеков нет."
out = [
(
f"Область: {where} - прочитано {len(sweep.read)} стеков из "
f"{sweep.in_scope} в области (во флоте {sweep.fleet})"
),
(
f"хвост {sweep.tail} строк на стек: строки с {_when(since, tz)} "
f"по {_when(until, tz)}"
),
]
short = sweep.shortest
if short is not None:
out.append(
f"самый короткий хвост у {short.stack} - только с {_when(short.first, tz)}"
)
blind = sum(s.unparsed for s in sweep.read)
if blind > UNPARSED_LOUD:
out.append(f"строк без метки времени пропущено {blind} - их не смотрел")
if gaps:
names = ", ".join(gaps[:GAP_NAMES])
more = f" и ещё {len(gaps) - GAP_NAMES}" if len(gaps) > GAP_NAMES else ""
out.append(
f"у {len(gaps)} стеков хвост начался позже прошлого прохода "
f"({names}{more}) - между проходами часть строк прошла мимо"
)
return "; ".join(out) + "."
def _entry_line(entry: Entry, tz: tzinfo, *, new: bool) -> str:
when = _when(entry.last, tz)
times = f" ×{entry.count}" if entry.count > 1 else ""
head = f"- {entry.where} {when}{times} {entry.signal}: "
if not new:
head = f"- {entry.where} {entry.signal}, последнее в {when}: "
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
lines = [f"{SELF_MARK}: проход {sweep.at.astimezone(tz):%d.%m %H:%M}."]
lines.append(_scope_line(sweep, report.gaps, tz))
if report.cold:
lines.append(
f"Первый проход: снял базовую линию, в хвостах {report.baseline} "
"таких строк - дальше дёргаю только тем, чего в прошлом проходе не было."
)
elif report.baselined:
names = ", ".join(report.baselined[:GAP_NAMES])
more = (
f" и ещё {len(report.baselined) - GAP_NAMES}"
if len(report.baselined) > GAP_NAMES
else ""
)
lines.append(
f"Впервые прочитаны ({report.baseline} строк в базу): {names}{more} - "
"их прошлое принял за данность, показываю только то, что появится дальше."
)
if report.new:
lines.append(f"Новое ({len(report.new)}):")
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 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])
if len(report.quiet) > MAX_QUIET:
lines.append(f"…и ещё {len(report.quiet) - MAX_QUIET}.")
if report.trouble:
lines.append(f"Не смог посмотреть ({len(report.trouble)}):")
lines.extend(f"- {t}" for t in report.trouble[:MAX_TROUBLE])
lines.append("Это только чтение: чинить или забить - решаешь ты.")
return "\n".join(lines)