Alert routing service with silences and escalation

0
⑂ RemixPractice

Build the routing core of an alerting service. The monitoring system calls fire(labels, now) for an active alert on every evaluation cycle (every 30 seconds or so) and resolve(labels, now) once when it clears. Your router decides who gets notified. Every method takes the current time; time never goes backwards. Methods return the notifications to send as (receiver, kind, fingerprint) tuples, where fingerprint is the alert's labels sorted by key and joined as k=v with commas.

Part 1: routing. Routes are an ordered list of {"match": {label: value}, "receiver": str, "continue": bool}. An alert matches a route when every matcher equals the alert's label. The first matching route wins; if it has continue: true, keep walking and collect more receivers. No match: the default receiver. Implement route(labels) -> [receiver] (no duplicates).

Part 2: dedupe and resolve. fire notifies each receiver (kind = "firing") unless that receiver already heard about this alert less than repeat_interval seconds ago. Label order must not matter. resolve sends "resolved" only to receivers that were notified, then forgets the alert; resolving an unknown alert sends nothing.

Part 3: silences, ack, escalation. silence(matchers, start, end) -> id mutes matching alerts while start <= now < end; expire_silence(id) ends one early. An empty matcher set is rejected. A route may have "escalate": {"after": seconds, "to": receiver}: tick(now) pages to once (kind = "escalated") if the alert is still active, not acked and not silenced, after seconds after its first notification. ack(labels) stops escalation. Escalation receivers also get the resolve.

class AlertRouter:
    def __init__(self, routes: list, default_receiver: str, repeat_interval: int): ...
    def route(self, labels: dict) -> list: ...
    def fire(self, labels: dict, now: int) -> list: ...
    def resolve(self, labels: dict, now: int) -> list: ...
    def silence(self, matchers: dict, start: int, end: int) -> int: ...
    def expire_silence(self, silence_id: int) -> bool: ...
    def ack(self, labels: dict) -> bool: ...
    def tick(self, now: int) -> list: ...
OpenAIPractical codingMedium

What L5 looks like

  • Picks the fingerprint as identity and explains why label order must not matter.
  • Gets the subtle rules right without prompting: resolved goes only to receivers that were paged, the silence window is half-open, escalation runs from the first page and stops on ack.
  • Keeps time-driven work in tick and returns notifications instead of sending them, so the design stays testable as parts are added.
Interviewer expectations for every level ↓

Approach

This is a practical round: a small service with a spec that grows in three parts. The grade is mostly about whether the code stays easy to change when part 3 arrives, so spend two minutes on the data model before typing.

What an alert is. The monitoring system calls fire(labels, now) on every evaluation cycle (say every 30 seconds) for as long as the condition holds, then resolve(labels, now) once. So fire arrives many times for the same alert, and the router's main job is to turn that stream into a few notifications. Identity is the label set: {"alertname": "DiskFull", "host": "db1", ...}. Sort the labels into a string (the fingerprint) so the order the caller used does not matter.

Part 1: routing. Routes are an ordered list. Walk it; the first route whose matchers all equal the alert's labels wins, unless that route says continue, in which case keep walking and collect more receivers. No match: the default receiver. This is how Alertmanager-style routing trees behave, flattened to a list, and it is the same "first match unless fall-through" rule as a firewall.

Part 2: dedupe, repeat, resolve. State per fingerprint: when each receiver was last notified. A fire notifies a receiver only if it never heard about this alert, or heard more than repeat_interval ago. resolve removes the state and sends resolved only to receivers that were actually notified. Telling someone an alert they never saw is over is noise, and in a pager it is worse than noise.

Part 3: silences, ack, escalation.

  • A silence is (matchers, start, end). While active (start <= now < end, half-open so back-to-back silences do not overlap or leave a gap), matching alerts are tracked but not sent. The state must still be tracked, so a resolve during the silence cleans up.
  • An unacked alert on a route with escalate: {after, to} pages to once, after seconds after the first notification. Measure from the first page, not the first fire: if the alert was silenced for an hour, nobody has been paged yet and escalating immediately would wake a manager about something no one was told about.
  • tick(now) runs escalation. Keeping time-driven work in tick (instead of a timer per alert) keeps the whole thing deterministic and testable with a fake clock.

Data model.

Structure Holds Why
routes (list) Ordered matchers and receivers Order is the semantics
_alerts: dict[fingerprint, Alert] labels, notified_at, last_sent[receiver], acked, escalated One place for everything about an active alert
_silences: dict[id, (matchers, start, end)] Active and future silences Ids so a silence can be expired early

Code

from __future__ import annotations

from dataclasses import dataclass, field


def fingerprint(labels: dict) -> str:
    """Stable identity of an alert: its labels, sorted."""
    return ",".join(f"{k}={labels[k]}" for k in sorted(labels))


def matches(matchers: dict, labels: dict) -> bool:
    return all(labels.get(k) == v for k, v in matchers.items())


@dataclass
class Alert:
    labels: dict
    notified_at: int | None = None              # first notification time
    last_sent: dict = field(default_factory=dict)  # receiver -> time
    acked: bool = False
    escalated: set = field(default_factory=set)    # escalation receivers paged


class AlertRouter:
    def __init__(self, routes: list, default_receiver: str, repeat_interval: int) -> None:
        self.routes = routes
        self.default_receiver = default_receiver
        self.repeat_interval = repeat_interval
        self._alerts: dict = {}    # fingerprint -> Alert, in first-fire order
        self._silences: dict = {}  # id -> (matchers, start, end)
        self._next_silence = 1

    # -- part 1: routing -----------------------------------------------------
    def matching_routes(self, labels: dict) -> list:
        out = []
        for route in self.routes:
            if matches(route["match"], labels):
                out.append(route)
                if not route.get("continue", False):
                    break
        return out

    def route(self, labels: dict) -> list:
        receivers = []
        for r in self.matching_routes(labels):
            if r["receiver"] not in receivers:
                receivers.append(r["receiver"])
        return receivers or [self.default_receiver]

    # -- part 2: dedupe, repeat, resolve -------------------------------------
    def fire(self, labels: dict, now: int) -> list:
        fp = fingerprint(labels)
        alert = self._alerts.get(fp)
        if alert is None:
            alert = self._alerts[fp] = Alert(dict(labels))
        if self._silenced(labels, now):
            return []
        sent = []
        for receiver in self.route(labels):
            last = alert.last_sent.get(receiver)
            if last is None or now - last >= self.repeat_interval:
                alert.last_sent[receiver] = now
                sent.append((receiver, "firing", fp))
        if sent and alert.notified_at is None:
            alert.notified_at = now
        return sent

    def resolve(self, labels: dict, now: int) -> list:
        fp = fingerprint(labels)
        alert = self._alerts.pop(fp, None)
        if alert is None:
            return []
        # Only people who heard it was firing hear it is over.
        paged = list(alert.last_sent) + sorted(alert.escalated)
        return [(receiver, "resolved", fp) for receiver in paged]

    # -- part 3: silences, ack, escalation -----------------------------------
    def silence(self, matchers: dict, start: int, end: int) -> int:
        if not matchers:
            raise ValueError("a silence needs at least one matcher")
        if end <= start:
            raise ValueError("a silence must end after it starts")
        sid = self._next_silence
        self._next_silence += 1
        self._silences[sid] = (dict(matchers), start, end)
        return sid

    def expire_silence(self, silence_id: int) -> bool:
        return self._silences.pop(silence_id, None) is not None

    def ack(self, labels: dict) -> bool:
        alert = self._alerts.get(fingerprint(labels))
        if alert is None:
            return False
        alert.acked = True
        return True

    def tick(self, now: int) -> list:
        sent = []
        for fp, alert in self._alerts.items():
            if alert.acked or alert.notified_at is None or self._silenced(alert.labels, now):
                continue
            for r in self.matching_routes(alert.labels):
                esc = r.get("escalate")
                if not esc or esc["to"] in alert.escalated:
                    continue
                if now - alert.notified_at >= esc["after"]:
                    alert.escalated.add(esc["to"])
                    sent.append((esc["to"], "escalated", fp))
        return sent

    def _silenced(self, labels: dict, now: int) -> bool:
        return any(
            start <= now < end and matches(m, labels) for m, start, end in self._silences.values()
        )

Choices worth saying out loud:

  • Rejecting an empty silence. An empty matcher set matches every alert. In production that is the "someone muted everything during an incident" outage. Refuse it; a deliberate global mute can be a separate, audited call.
  • Receivers deduped per alert, not per route. Two routes with the same receiver (a continue route and a later one) must not page the same person twice for one fire.
  • Returning notifications instead of sending them. The router decides; a sender delivers. That split is what makes it testable, and it is where retries and rate limits per receiver go later.

Complexity

Let R be the number of routes, S the number of silences and A the number of active alerts.

  • route: O(R × m), m = matchers per route.
  • fire: O(R × m + S) — the silence check scans every silence. With thousands of silences, index them by one label (most silences name alertname or host) and scan only the candidates.
  • tick: O(A × (R + S)). At 10,000 active alerts, 100 routes and a tick every 10 seconds that is about 10^6 matcher checks per tick, which is fine in-process. Caching each alert's matching routes on the Alert (routes rarely change) cuts it to O(A).

Worked numbers for "is the dedupe worth it": 2,000 hosts evaluated every 30 s with a disk alert firing on 5% of them sends 100 fire calls per cycle, 12,000 per hour. With a 1-hour repeat_interval the on-call channel gets 100 messages in that hour instead of 12,000.

Tests

from alerts import AlertRouter

ROUTES = [
    {"match": {"team": "storage", "severity": "critical"}, "receiver": "page-storage",
     "escalate": {"after": 600, "to": "page-storage-lead"}},
    {"match": {"team": "storage"}, "receiver": "slack-storage"},
    {"match": {"severity": "critical"}, "receiver": "slack-incidents", "continue": True},
    {"match": {"team": "inference"}, "receiver": "page-inference"},
]
DISK = {"alertname": "DiskFull", "team": "storage", "severity": "critical", "host": "db1"}
GPU = {"alertname": "GpuXid", "team": "inference", "severity": "critical", "host": "g7"}


def router():
    return AlertRouter(ROUTES, default_receiver="slack-catchall", repeat_interval=3600)


def test_first_match_wins():
    assert router().route(DISK) == ["page-storage"]


def test_continue_keeps_matching():
    assert router().route(GPU) == ["slack-incidents", "page-inference"]


def test_no_match_goes_to_default():
    assert router().route({"alertname": "Heartbeat", "team": "web"}) == ["slack-catchall"]


def test_empty_matcher_matches_everything():
    r = AlertRouter([{"match": {}, "receiver": "all"}], "none", 60)
    assert r.route({"x": "1"}) == ["all"]
from alerts import AlertRouter

ROUTES = [
    {"match": {"team": "storage", "severity": "critical"}, "receiver": "page-storage",
     "escalate": {"after": 600, "to": "page-storage-lead"}},
    {"match": {"team": "storage"}, "receiver": "slack-storage"},
    {"match": {"severity": "critical"}, "receiver": "slack-incidents", "continue": True},
    {"match": {"team": "inference"}, "receiver": "page-inference"},
]
DISK = {"alertname": "DiskFull", "team": "storage", "severity": "critical", "host": "db1"}
GPU = {"alertname": "GpuXid", "team": "inference", "severity": "critical", "host": "g7"}


def router():
    return AlertRouter(ROUTES, default_receiver="slack-catchall", repeat_interval=3600)


def test_fire_dedupes_until_repeat_interval():
    r = router()
    fp = "alertname=DiskFull,host=db1,severity=critical,team=storage"
    assert r.fire(DISK, 0) == [("page-storage", "firing", fp)]
    assert r.fire(DISK, 60) == []
    assert r.fire(DISK, 3599) == []
    assert r.fire(DISK, 3600) == [("page-storage", "firing", fp)]


def test_label_order_does_not_matter():
    r = router()
    r.fire(DISK, 0)
    assert r.fire(dict(reversed(list(DISK.items()))), 1) == []


def test_resolve_tells_only_who_was_paged():
    r = router()
    r.fire(GPU, 0)
    out = r.resolve(GPU, 100)
    assert [(rec, kind) for rec, kind, _ in out] == [("slack-incidents", "resolved"), ("page-inference", "resolved")]
    assert r.resolve(GPU, 101) == []


def test_refire_after_resolve_pages_again():
    r = router()
    r.fire(DISK, 0)
    r.resolve(DISK, 10)
    assert len(r.fire(DISK, 20)) == 1
from alerts import AlertRouter

ROUTES = [
    {"match": {"team": "storage", "severity": "critical"}, "receiver": "page-storage",
     "escalate": {"after": 600, "to": "page-storage-lead"}},
    {"match": {"team": "storage"}, "receiver": "slack-storage"},
    {"match": {"severity": "critical"}, "receiver": "slack-incidents", "continue": True},
    {"match": {"team": "inference"}, "receiver": "page-inference"},
]
DISK = {"alertname": "DiskFull", "team": "storage", "severity": "critical", "host": "db1"}


def router():
    return AlertRouter(ROUTES, default_receiver="slack-catchall", repeat_interval=3600)


def test_silence_window_is_half_open():
    r = router()
    r.silence({"host": "db1"}, start=100, end=200)
    assert r.fire(DISK, 100) == []
    assert r.fire(DISK, 199) == []
    assert len(r.fire(DISK, 200)) == 1


def test_silence_needs_matchers():
    r = router()
    try:
        r.silence({}, 0, 10)
    except ValueError:
        return
    assert False, "an empty silence would mute every alert"


def test_escalates_once_when_not_acked():
    r = router()
    r.fire(DISK, 0)
    assert r.tick(599) == []
    assert [x[:2] for x in r.tick(600)] == [("page-storage-lead", "escalated")]
    assert r.tick(1200) == []
    resolved = [rec for rec, _, _ in r.resolve(DISK, 1300)]
    assert resolved == ["page-storage", "page-storage-lead"]


def test_ack_stops_escalation():
    r = router()
    r.fire(DISK, 0)
    assert r.ack(DISK) is True
    assert r.tick(10_000) == []
    assert r.ack({"alertname": "nope"}) is False


def test_escalation_clock_starts_at_first_page_not_first_fire():
    r = router()
    r.silence({"alertname": "DiskFull"}, 0, 500)
    r.fire(DISK, 0)     # silenced: nobody was paged
    assert r.tick(600) == []
    r.fire(DISK, 500)   # first real page
    assert r.tick(1099) == []
    assert len(r.tick(1100)) == 1


def test_expired_silence_unmutes():
    r = router()
    sid = r.silence({"host": "db1"}, 0, 10_000)
    assert r.fire(DISK, 5) == []
    assert r.expire_silence(sid) is True
    assert len(r.fire(DISK, 6)) == 1

Follow-ups

  • "Group alerts." A rack loses power and 40 HostDown alerts fire in the same second. Add group_by: ["alertname", "rack"] to a route. Alerts with the same group key collect into one notification: wait group_wait (say 30 s) after the first one so the rest can join, then send one message listing all of them, and send updates no more often than group_interval (5 min). The dedupe state moves from per alert to per (route, group key).
  • "Inhibit." When DatacenterDown fires for dc=us-east, suppress every HostDown with the same dc. An inhibition rule is (source matchers, target matchers, labels that must be equal). Check it at send time, like a silence that is switched on by another alert.
  • "Flapping." An alert that resolves and fires every 2 minutes pages every 2 minutes with this code, because resolve clears the dedupe state. Keep resolved state for a short hold time (say 5 min) and treat a re-fire within it as the same incident, or require the condition to hold for N cycles before the first page (for: 2m in the alert rule).
  • "Run two replicas for high availability." Two routers must not both page. Options: one active leader with a lease (simple, a failover gap of one lease length); or both active, sharing the notification log (who was told what, when) through gossip or a small store, with each replica waiting a few seconds × its index before sending so the other's log entry arrives first. Alertmanager uses the second; it accepts an occasional duplicate page in exchange for never missing one. Say which way you lean: for paging, a duplicate beats a miss.
  • "Delivery fails." The pager API returns 503. Retry with backoff and jitter in the sender, keyed by (receiver, fingerprint, kind) so retries stay idempotent, and alert on the alerting pipeline itself with a dead man's switch: an always-firing alert whose absence pages through a separate path.
  • "Regex matchers." Allow {"host": "~db[0-9]+"}. Compile once when the route is loaded, never per alert, and anchor the pattern so db1 does not match proddb10.

Interviewer expectations

Mid-level (L4)

  • Correct first-match routing with continue and a default receiver.
  • Dedupes repeat fires with a per-alert, per-receiver last-sent time and sends resolved notifications.
  • Clean tests for each part with a fake clock.

Senior (L5)

  • Picks the fingerprint as identity and explains why label order must not matter.
  • Gets the subtle rules right without prompting: resolved goes only to receivers that were paged, the silence window is half-open, escalation runs from the first page and stops on ack.
  • Keeps time-driven work in tick and returns notifications instead of sending them, so the design stays testable as parts are added.

Staff (L6+)

  • Brings in grouping, inhibition and flapping as the real sources of pager noise, with concrete windows.
  • Designs HA without double pages or missed pages, states which failure they prefer and why, and adds a dead man's switch for the alerting path itself.
  • Sizes the work per tick and knows where it stops scaling (silence scans, route matching) and how to index it.

Make it yours

Write each chapter yourself, check it against this answer, then let the coach grill you. Practice it with the 3-level tests, run in your browser.