Filters
How to search
  • Plain words find names, texts and examples.
  • "two words" finds the exact phrase.
  • --name or -x finds an option.
  • #word looks only inside commands.
  • @grep finds lessons and labs that use grep.
  • Filters: cat:network · in:labs · in:notes · level:rhcsa · ch:8 · root:yes. Put ! before a word to leave it out. OR between words means one of them.
  • ↑ ↓ to move · Enter to open · Esc to close · / to search

Python scriptsIR series

An alert dedupe report

The pager fires at 03:02 for a disk at 91%. By 03:22 it has fired three more times for the same disk and once for another host. Eleven alerts, five incidents, one tired human.

Quick reference

The full script is at the end of this note: alert_dedupe.py (standard library only, no pip install).

Run it

python3 alert_dedupe.py /tmp/s15/a.json --max-fires 3

Example output

text
DEDUPE 11 alerts -> 5 incidents (6 duplicates)
NOISE rule DiskFull 5 fires, worst critical

Explained

What it does

alert_dedupe.py reads the alert webhook JSON, groups events by fingerprint, and keeps the first and last time each group fired. It ranks rules by fire count, counts the incidents behind them, and prints the deduplicated incident total. A rule over --max-fires is a finding.

What the screen shows

The panel reads the payload, counts rules and fingerprints, then prints DEDUPE 11 alerts -> 5 incidents (6 duplicates), then NOISE rule DiskFull 5 fires, worst critical.

How to run it

python3 alert_dedupe.py /tmp/s15/a.json --max-fires 3, or pipe the webhook body in with -. Exit 0 means no rule is over budget, 1 means at least one is, and 2 means the JSON is unusable.

The fix

Make the fingerprint stable: rule, host and symptom, not the alert id. Group before you page, and give each rule a budget. Then fix the disk that generated five of the eleven alerts. The pager is not the problem.

Prevention

Alert on distinct fingerprints per rule, not raw webhook count. A rule that fires five times with two fingerprints has one noisy source, not five incidents.

The honest caveat: a shared fingerprint can merge two real incidents. Include the host in the key, and review the groups after the storm.

The lesson: an alert is a symptom, and a fingerprint is the incident behind it.

In one line

One alert is a symptom. A fingerprint is the incident behind the alert.

Count incidents

python3 alert_dedupe.py alerts.json

Script: alert_dedupe.py

Download: scripts/alert_dedupe.py

python
#!/usr/bin/env python3
"""alert_dedupe.py - group duplicate alerts into incidents and rank noisy rules.
 
Incident-response teams do not drown in incidents. They drown in pages: the
same failing disk sends a new webhook every five minutes, with a new alert id
and the same fingerprint. This script reads the raw alert payloads, groups
them by fingerprint, and reports the incidents behind the alerts.
 
The input is a JSON list, or an object with an "alerts" list. Each alert is:
 
    {"fingerprint": "fp-disk-var",
     "rule": "DiskFull",
     "severity": "critical",
     "started_at": "2026-10-20T03:02:10Z",
     "status": "firing"}
 
status is one of firing, acknowledged, resolved or suppressed. Severity is
one of critical, high, medium or low. The fingerprint is the dedupe key and
must be stable across repeats; rule plus host plus symptom is a good start.
 
The report is a list of incidents, a ranking of the noisiest rules, and one
line naming the rules over --max-fires. Exit 1 is the finding: a rule is
paging more often than the budget allows.
 
Usage:
    python3 alert_dedupe.py alerts.json
    python3 alert_dedupe.py alerts.json --max-fires 3
    cat alerts.json | python3 alert_dedupe.py -
    python3 alert_dedupe.py --help
 
Cron or CI (hourly, fail the job when a rule is over budget):
    5 * * * * python3 /opt/s0sta/alert_dedupe.py /var/lib/s0sta/alerts.json --max-fires 3
 
Exit codes: 0 no rule over the threshold, 1 at least one rule over the
threshold, 2 usage error or unreadable, empty or malformed input.
 
Read-only: opens the given file or stdin, parses JSON and prints. It writes
nothing, changes nothing and makes no network call.
 
By s0sta.com
"""
import argparse
import json
import sys
from datetime import datetime, timezone
 
SEVERITY_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3}
STATUSES = ("firing", "acknowledged", "resolved", "suppressed")
DEFAULT_MAX_FIRES = 3
DEFAULT_TOP = 5
 
 
class InputError(Exception):
    """An input that cannot be read as alert data."""
 
 
def read_json(path):
    """Return the decoded JSON document, or raise InputError."""
    try:
        if path == "-":
            text = sys.stdin.read()
        else:
            with open(path, "r", encoding="utf-8") as handle:
                text = handle.read()
    except OSError as exc:
        raise InputError("%s: %s" % (path, exc.strerror or exc))
    if not text.strip():
        raise InputError("%s: empty input" % path)
    try:
        return json.loads(text)
    except ValueError as exc:
        raise InputError("%s is not JSON: %s" % (path, exc))
 
 
def parse_time(value, where):
    """Return an aware UTC datetime, or raise InputError."""
    if not isinstance(value, str) or not value.strip():
        raise InputError("%s: started_at must be an ISO timestamp" % where)
    text = value.strip()
    if text.endswith("Z"):
        text = text[:-1] + "+00:00"
    try:
        stamp = datetime.fromisoformat(text)
    except ValueError:
        raise InputError("%s: bad started_at %r" % (where, value))
    if stamp.tzinfo is None:
        stamp = stamp.replace(tzinfo=timezone.utc)
    return stamp.astimezone(timezone.utc)
 
 
def load_alerts(path):
    """Return a validated list of alert dicts, or raise InputError."""
    doc = read_json(path)
    if isinstance(doc, dict):
        if "alerts" not in doc:
            raise InputError("%s: object needs an \"alerts\" list" % path)
        doc = doc["alerts"]
    if not isinstance(doc, list):
        raise InputError("%s: expected a list of alerts" % path)
    if not doc:
        raise InputError("%s: no alerts in input" % path)
    alerts = []
    for index, raw in enumerate(doc, start=1):
        where = "alert %d" % index
        if not isinstance(raw, dict):
            raise InputError("%s: expected an object" % where)
        fingerprint = raw.get("fingerprint")
        rule = raw.get("rule")
        severity = raw.get("severity")
        status = raw.get("status")
        if not isinstance(fingerprint, str) or not fingerprint.strip():
            raise InputError("%s: missing fingerprint" % where)
        if not isinstance(rule, str) or not rule.strip():
            raise InputError("%s: missing rule" % where)
        if not isinstance(severity, str) or severity.lower() not in SEVERITY_RANK:
            raise InputError("%s: severity must be critical, high, medium or low"
                             % where)
        if not isinstance(status, str) or status.lower() not in STATUSES:
            raise InputError("%s: status must be one of %s"
                             % (where, ", ".join(STATUSES)))
        alerts.append({
            "fingerprint": fingerprint.strip(),
            "rule": rule.strip(),
            "severity": severity.lower(),
            "status": status.lower(),
            "started": parse_time(raw.get("started_at"), where),
        })
    return alerts
 
 
def group_alerts(alerts):
    """Return incidents keyed by fingerprint, earliest first."""
    groups = {}
    for alert in alerts:
        key = alert["fingerprint"]
        group = groups.get(key)
        if group is None:
            group = {
                "fingerprint": key,
                "rule": alert["rule"],
                "severity": alert["severity"],
                "first": alert["started"],
                "last": alert["started"],
                "count": 0,
                "statuses": set(),
            }
            groups[key] = group
        group["count"] += 1
        group["statuses"].add(alert["status"])
        if alert["started"] < group["first"]:
            group["first"] = alert["started"]
        if alert["started"] > group["last"]:
            group["last"] = alert["started"]
        if SEVERITY_RANK[alert["severity"]] < SEVERITY_RANK[group["severity"]]:
            group["severity"] = alert["severity"]
            group["rule"] = alert["rule"]
    return sorted(groups.values(), key=lambda g: (g["first"], g["fingerprint"]))
 
 
def rank_rules(alerts):
    """Return per-rule counts, incidents and worst severity, noisiest first."""
    rules = {}
    for alert in alerts:
        entry = rules.setdefault(alert["rule"], {
            "fires": 0,
            "incidents": set(),
            "worst": alert["severity"],
        })
        entry["fires"] += 1
        entry["incidents"].add(alert["fingerprint"])
        if SEVERITY_RANK[alert["severity"]] < SEVERITY_RANK[entry["worst"]]:
            entry["worst"] = alert["severity"]
    ordered = sorted(
        rules.items(),
        key=lambda item: (-item[1]["fires"], SEVERITY_RANK[item[1]["worst"]],
                          item[0]),
    )
    return ordered
 
 
def report(alerts, max_fires, top):
    """Return (finding, lines) for the alert payload."""
    groups = group_alerts(alerts)
    ranked = rank_rules(alerts)
    duplicates = len(alerts) - len(groups)
    lines = ["DEDUPE %d alert%s -> %d incident%s (%d duplicate%s)"
             % (len(alerts), "" if len(alerts) == 1 else "s",
                len(groups), "" if len(groups) == 1 else "s",
                duplicates, "" if duplicates == 1 else "s")]
    for group in groups:
        lines.append(
            "GROUP %-22s %-12s %d alert%s %s -> %s"
            % (group["fingerprint"], group["rule"], group["count"],
               "" if group["count"] == 1 else "s",
               group["first"].strftime("%H:%M:%S"),
               group["last"].strftime("%H:%M:%S")))
    for place, (rule, entry) in enumerate(ranked[:top], start=1):
        incidents = len(entry["incidents"])
        lines.append(
            "RANK %d %-16s %d fire%s, %d incident%s, worst %s"
            % (place, rule, entry["fires"],
               "" if entry["fires"] == 1 else "s",
               incidents, "" if incidents == 1 else "s", entry["worst"]))
    noisy = [(rule, entry) for rule, entry in ranked
             if entry["fires"] > max_fires]
    if not noisy:
        lines.append("NOISE none over %d fires" % max_fires)
    elif len(noisy) == 1:
        rule, entry = noisy[0]
        lines.append("NOISE rule %s %d fires, worst %s"
                     % (rule, entry["fires"], entry["worst"]))
    else:
        lines.append("NOISE %d rules over %d fires: %s"
                     % (len(noisy), max_fires,
                        ", ".join("%s (%d)" % (rule, entry["fires"])
                                  for rule, entry in noisy)))
    return bool(noisy), lines
 
 
def main(argv=None):
    parser = argparse.ArgumentParser(
        description="Group duplicate alerts by fingerprint (read-only).")
    parser.add_argument(
        "alerts", nargs="?", default="-",
        help="alert payload JSON, or - for stdin (default: -)")
    parser.add_argument(
        "--max-fires", type=int, default=DEFAULT_MAX_FIRES, metavar="N",
        help="exit 1 when a rule fires more than N times (default: %d)"
             % DEFAULT_MAX_FIRES)
    parser.add_argument(
        "--top", type=int, default=DEFAULT_TOP, metavar="N",
        help="rank at most N rules (default: %d)" % DEFAULT_TOP)
    args = parser.parse_args(argv)
 
    if args.max_fires < 0:
        parser.error("--max-fires must not be negative")
    if args.top < 1:
        parser.error("--top must be at least 1")
 
    try:
        alerts = load_alerts(args.alerts)
    except InputError as exc:
        print("ERROR %s" % exc, file=sys.stderr)
        return 2
 
    finding, lines = report(alerts, args.max_fires, args.top)
    for line in lines:
        print(line)
    return 1 if finding else 0
 
 
if __name__ == "__main__":
    sys.exit(main())

More notes: Python scripts

Open in the notes app: search, filters, copy This note on GitHub