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

Alerts nobody acts on

The pager fires eleven times before lunch. The login-failed rule has fired fifty times this month and been actioned twice. Everyone clicks acknowledge, nobody looks, and the one real alert waits behind the noise.

Quick reference

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

Run it

python3 alert_fatigue.py alerts.json

Example output

text
FINDING 3 noisy rules of 8
NOISE login-failed ratio 0.04

Explained

What it does

alert_fatigue.py reads the alert history and computes the signal ratio for each rule: actioned alerts divided by alerts fired. Rules with fewer than --min-fired fires are ignored. A rule below --threshold is ranked as noise. The list is sorted from the lowest ratio up, so the worst rule is first.

How to run it

python3 alert_fatigue.py alerts.json --days 30. Exit 0 means every judged rule clears the threshold, 1 means at least one does not, 2 means bad input. It never edits or silences a rule.

What the screen shows

The fourteen lines count fires and actions per rule, compute the ratios, filter by the minimum and the threshold, then print the count and the worst rule. On the dirty fixture: FINDING 3 noisy rules of 8 and NOISE login-failed ratio 0.04.

The fix

Delete the rule, or rewrite it until it names a symptom a human can act on. A noisy rule does not just waste pages; it teaches the team that the pager is optional.

Prevention

Review the ratios monthly with the on-call lead. Every page should have an owner, a runbook and a remembered action.

The honest caveat: a low ratio can mean the alert is right and the response is broken. Read the actions before you delete the rule.

The lesson: attention is the budget, and noise spends it.

In one line

Noise is not an alert. A page nobody acts on trains the team to ignore the next one.

Count the signal

python3 alert_fatigue.py alerts.json

Script: alert_fatigue.py

Download: scripts/alert_fatigue.py

python
#!/usr/bin/env python3
"""Rank alert rules by how rarely an alert leads to an action.
 
Reads an alert history and computes one number per rule:
 
    signal ratio = actioned alerts / alerts fired
 
A rule that fires fifty times and is actioned twice has a ratio of 0.04. It
is not an alert, it is a habit. The tool ranks rules from the noisiest up and
exits 1 when any rule with enough fires falls below --threshold.
 
The history is JSON, one object per alert:
 
    [{"rule": "login-failed", "fired_at": "2026-10-20T03:14:00Z",
      "actioned": false},
     {"rule": "checkout-latency", "fired_at": "2026-10-20T09:02:00Z",
      "actioned": true}]
 
"actioned" means a human did something because of the alert: acknowledged
and fixed, or acknowledged and closed as expected. An alert that was seen and
ignored is not actioned.
 
A rule needs --min-fired alerts (default 5) before it is judged. The window
is all history by default; --days N looks at the last N days. The threshold
is a minimum acceptable signal ratio, 0.10 by default.
 
Usage:
    python3 alert_fatigue.py alerts.json
    python3 alert_fatigue.py alerts.json --threshold 0.20 --days 30
 
Exit codes: 0 no rule below the threshold, 1 at least one noisy rule, 2
usage, or a history that is missing, unreadable, invalid or empty.
 
CI (monthly review, fail when the pager is mostly noise):
    python3 alert_fatigue.py alerts.json --days 30 || exit 1
 
Cron (Monday 08:00, send the noise ranking to the on-call lead):
    0 8 * * 1 python3 /opt/s0sta/alert_fatigue.py /var/lib/s0sta/alerts.json
 
The script is read-only. It reads the export and prints a ranking; it never
silences, edits or deletes a rule. A low ratio can also mean the alert is
correct but the runbook is bad: read the actions before you delete the rule.
 
Standard library only, Python 3.8+.
 
By s0sta.com
"""
import argparse
import datetime
import json
import sys
 
 
class InputError(Exception):
    """Anything that should end the run with exit code 2."""
 
 
def load_history(path):
    """Return the list of alert events, or raise InputError."""
    try:
        with open(path, encoding="utf-8") as handle:
            doc = json.load(handle)
    except OSError as exc:
        raise InputError("cannot read %s: %s" % (path, exc))
    except ValueError as exc:
        raise InputError("%s is not valid JSON: %s" % (path, exc))
    if not isinstance(doc, list) or not doc:
        raise InputError("%s has no non-empty alert list" % path)
    return doc
 
 
def event_fields(item, number):
    """Validate one alert event and return (rule, date, actioned)."""
    if not isinstance(item, dict):
        raise InputError("event %d is not an object" % number)
    rule = item.get("rule")
    fired_at = item.get("fired_at")
    actioned = item.get("actioned")
    if not isinstance(rule, str) or not rule:
        raise InputError("event %d has no rule" % number)
    if not isinstance(fired_at, str) or len(fired_at) < 10:
        raise InputError("event %s has no fired_at timestamp" % rule)
    try:
        when = datetime.date.fromisoformat(fired_at[:10])
    except ValueError:
        raise InputError("event %s has an unreadable fired_at: %s"
                         % (rule, fired_at))
    if not isinstance(actioned, bool):
        raise InputError("event %s has no boolean actioned" % rule)
    return rule, when, actioned
 
 
def ratio(value):
    """argparse type for a signal ratio between 0 and 1."""
    try:
        number = float(value)
    except ValueError:
        raise argparse.ArgumentTypeError("%s is not a number" % value)
    if not 0.0 <= number <= 1.0:
        raise argparse.ArgumentTypeError("%s is outside 0..1" % value)
    return number
 
 
def positive(value):
    """argparse type for a positive integer."""
    try:
        number = int(value)
    except ValueError:
        raise argparse.ArgumentTypeError("%s is not an integer" % value)
    if number < 1:
        raise argparse.ArgumentTypeError("%s is less than 1" % value)
    return number
 
 
def nonneg(value):
    """argparse type for a non-negative integer."""
    try:
        number = int(value)
    except ValueError:
        raise argparse.ArgumentTypeError("%s is not an integer" % value)
    if number < 0:
        raise argparse.ArgumentTypeError("%s is negative" % value)
    return number
 
 
def main(argv=None):
    parser = argparse.ArgumentParser(
        description="Rank alert rules by signal ratio and exit 1 when the "
                    "pager is mostly noise.",
        epilog="Exit codes: 0 quiet enough, 1 noisy rule found, "
               "2 usage or input error.")
    parser.add_argument("history", help="JSON list of alert events")
    parser.add_argument("--threshold", type=ratio, default=0.10,
                        help="minimum signal ratio (default: 0.10)")
    parser.add_argument("--min-fired", type=positive, default=5,
                        help="fires before a rule is judged (default: 5)")
    parser.add_argument("--days", type=nonneg, default=0,
                        help="only the last N days (default: all history)")
    parser.add_argument("--top", type=positive, default=20,
                        help="maximum noisy rules to print (default: 20)")
    args = parser.parse_args(argv)
 
    try:
        history = load_history(args.history)
        cutoff = None
        if args.days:
            cutoff = datetime.date.today() - datetime.timedelta(days=args.days)
        counts = {}
        in_window = 0
        for number, item in enumerate(history, 1):
            rule, when, actioned = event_fields(item, number)
            if cutoff is not None and when < cutoff:
                continue
            in_window += 1
            fired, acted = counts.get(rule, (0, 0))
            counts[rule] = (fired + 1, acted + int(actioned))
        if not in_window:
            raise InputError("no alert events in the last %d days" % args.days)
    except InputError as exc:
        print("ERR %s" % exc, file=sys.stderr)
        return 2
 
    noisy = []
    for rule, (fired, acted) in counts.items():
        if fired < args.min_fired:
            continue
        signal = acted / fired
        if signal < args.threshold:
            noisy.append((signal, -fired, rule, fired, acted))
    noisy.sort()
 
    if noisy:
        print("FINDING %d noisy rules of %d" % (len(noisy), len(counts)))
        for signal, _, rule, fired, acted in noisy[:args.top]:
            print("NOISE %s ratio %.2f fired %d actioned %d"
                  % (rule, signal, fired, acted))
        return 1
    print("OK every judged rule is at or above %.2f signal" % args.threshold)
    return 0
 
 
if __name__ == "__main__":
    sys.exit(main())

More notes: Python scripts

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