feat(syslog): pattern alerts: telling phrases counted per source each minute for alert rules, storage-trouble presets (#6001)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m31s
CI / integration (push) Failing after 1m40s
CI / publish (push) Skipped

A pattern (phrases, a severity, a source, a window) is counted every minute
into plugin_metrics as lines_matching_<key> per added source, zero included,
so an ordinary alert rule fires on a match and clears once the window passes.
Each count reads only the lines stored since the last, by line id; a new or
changed pattern reads its window once.

Patterns page with ready-made storage-trouble presets and "Alert on it",
which opens the rule form aimed at the count. Source names are kept unique,
and a rename moves its counts and the rules on them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 06:22:54 -04:00
co-authored by Claude Opus 5.5
parent dadec4e355
commit 8eae3d3825
14 changed files with 892 additions and 16 deletions
+31 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, SmallInteger, String, Text
from sqlalchemy import JSON, BigInteger, Boolean, DateTime, ForeignKey, Index, SmallInteger, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from steward.core.ids import time_ordered_id
@@ -65,3 +65,33 @@ class SyslogLine(Base):
STATES = ("offered", "accepted", "ignored")
class SyslogPattern(Base):
"""A telling phrase to count (#6001): lines whose message holds any of
`phrases` (case-insensitive) and, when `max_severity` is set, whose
severity is that or worse. Every minute each added source it covers (all,
or `source_id`'s) gets a sample of how many of its lines matched in the
last `window_minutes`, as metric `lines_matching_<key>`, so an ordinary
alert rule fires on a match and clears once the window passes it."""
__tablename__ = "syslog_patterns"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
key: Mapped[str] = mapped_column(String(48), nullable=False, unique=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
phrases: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
max_severity: Mapped[int | None] = mapped_column(SmallInteger, nullable=True)
source_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("syslog_sources.id", ondelete="CASCADE"), nullable=True)
window_minutes: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=15)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
@property
def metric(self) -> str:
return metric_for(self.key)
def metric_for(key: str) -> str:
return f"lines_matching_{key}"