What makes a good alert

An alert interrupts a person. The IT service management view, with thresholds and alert fatigue, is explained in Monitoring and Event Management; this page is about building alerts with Prometheus and Alertmanager that deserve the interruption. Google's Site Reliability Engineering (SRE) book is blunt about the cost:

"Paging a human is a quite expensive use of an employee's time. … When pages occur too frequently, employees second-guess, skim, or even ignore incoming alerts."

The same chapter adds that "you should never trigger an alert simply because 'something seems a bit weird.'" A good alert passes four tests: it is urgent (waiting until morning would hurt users), actionable (a human can do something), real (it rarely fires without a problem) and owned (it reaches the team that can fix it, with a runbook).

Symptom-based alerting means alerting on what users experience, such as failed requests, slow responses or readings that do not arrive, rather than on the internal causes that might produce it, such as high CPU, a busy disk or one restarted container. There are three reasons. A symptom covers every cause, including the ones nobody predicted. A cause often does no harm: a CPU at 90% that still answers quickly is a well-used server, not an incident. And a symptom tells the person who is woken up why it matters. Causes still belong on dashboards, where the person investigating the symptom will look for them.

Not every alert needs to wake someone. Kestrel uses two severity labels: severity="page" for problems that need a human now, at any hour, and severity="ticket" for problems that need a human this working day. How pages and tickets are handled by the people on call is the subject of On-Call. Alerting on how fast the error budget burns is the most refined form of symptom-based alerting and has its own page.

Alerting rules in Prometheus

An alerting rule is a PromQL expression that Prometheus evaluates at a fixed interval, together with a name, an optional for duration, labels and annotations; every series the expression returns becomes an active alert. Rules live in rule files, grouped by name, and are loaded through rule_files in prometheus.yml. The expressions often use recording rules such as job:http_requests_errors:ratio_rate5m, explained in PromQL.

Kestrel's rule file rules/alerts.yml:

groups:
  - name: kestrel-alerts
    rules:
      - alert: TargetDown
        expr: up == 0
        for: 2m
        labels:
          severity: ticket
        annotations:
          summary: "{{ $labels.instance }} ({{ $labels.job }}) cannot be scraped"
          description: "Prometheus has not been able to scrape {{ $labels.instance }} for more than 2 minutes."

      - alert: MeterApiHighErrorRate
        expr: job:http_requests_errors:ratio_rate5m{job="meter-api"} > 0.05
        for: 2m
        labels:
          severity: page
          team: metering
        annotations:
          summary: "meter-api returns {{ $value | humanizePercentage }} errors"
          description: "More than 5% of meter-api requests failed with a 5xx status over the last 5 minutes."
          runbook_url: "https://wiki.kestrel.example/runbooks/meter-api-errors"

      - alert: MeterApiSlow
        expr: job:http_request_duration_seconds:p95_5m{job="meter-api"} > 0.25
        for: 2m
        labels:
          severity: ticket
          team: metering
        annotations:
          summary: "meter-api p95 latency is {{ $value | humanizeDuration }}"

      - alert: PortalSlow
        expr: job:http_request_duration_seconds:p95_5m{job="portal"} > 0.5
        for: 5m
        labels:
          severity: ticket
          team: portal
        annotations:
          summary: "Customer portal p95 latency is {{ $value | humanizeDuration }}"

      - alert: MeterReadingsQueueFilling
        expr: |
          predict_linear(meter_readings_queue_depth[15m], 3600)
            > on (instance) meter_readings_queue_limit
        for: 5m
        labels:
          severity: ticket
          team: metering
        annotations:
          summary: "Readings queue on {{ $labels.instance }} will be full within an hour"
          description: "Current depth {{ with printf `meter_readings_queue_depth{instance=%q}` $labels.instance | query }}{{ . | first | value | humanize }}{{ end }} readings."

      - alert: PostgresConnectionsHigh
        expr: pg_connections_in_use / pg_connections_max > 0.8
        for: 5m
        labels:
          severity: ticket
          team: platform
        annotations:
          summary: "{{ $labels.instance }} uses {{ $value | humanizePercentage }} of max_connections"

      - alert: BillingExportStale
        expr: time() - billing_export_last_success_timestamp_seconds > 26 * 3600
        for: 10m
        labels:
          severity: ticket
          team: billing
        annotations:
          summary: "No successful billing export for {{ $value | humanizeDuration }}"
          description: "The nightly export normally finishes shortly after 02:30. Invoices for yesterday are not on their way."

The parts of a rule:

  • alert: the name, which becomes the alertname label.
  • expr: the condition. The rule fires for every series the expression returns, so up == 0 creates one alert per unreachable target.
  • for: how long the condition must hold before the alert fires (next section).
  • labels: added to the alert; Alertmanager routes on them. Keep them few and stable.
  • annotations: text for humans, with templates: {{ $labels.instance }} inserts a label, {{ $value }} the value of the expression, and functions such as humanizePercentage and humanizeDuration make it readable. Put the link to the runbook in an annotation.

Two rules show patterns worth copying. BillingExportStale alerts on the absence of success: Joost Bakker's nightly export pushes the time of its last successful run, and the alert fires when that is more than 26 hours ago. That catches a crash, a hang, a job that was never started and a billing server that is down, with one rule, as long as the timestamp itself is still there; pair it with absent() (see PromQL) for the case where the series disappears. MeterReadingsQueueFilling alerts on a prediction, "full within an hour", instead of on a fixed threshold. It also contains a mistake, which the inhibition section uncovers.

promtool check rules rules/alerts.yml
Checking rules/alerts.yml
  SUCCESS: 7 rules found

Catching mistakes before they load

promtool check rules belongs in the pipeline that deploys rule files: a file Prometheus cannot load is rejected completely, and Prometheus keeps running the old rules. A file with three mistakes:

groups:
  - name: kestrel-broken
    rules:
      - record: job:http requests:rate5m
        expr: sum by (job) (rate(http_requests_total[5m]))

      - alert: MeterApiHighErrorRate
        expr: sum(rate(http_requests_total{job="meter-api",code=~"5.."}[5m]) / sum(rate(http_requests_total{job="meter-api"}[5m])) > 0.05
        for: 2m

      - alert: PortalSlow
        expr: job:http_request_duration_seconds:p95_5m{job="portal"} > 0.5
        for: 5 minutes
        annotations:
          summary: "Portal p95 is {{ $value | humanizeDuration }"
promtool check rules broken-rules.yml
Checking broken-rules.yml
  FAILED:
broken-rules.yml: unknown unit " minutes" in duration "5 minutes"
broken-rules.yml: unknown unit " minutes" in duration "5 minutes"

The duration error stops the check before anything else is looked at (and is printed twice). After changing 5 minutes to 5m, the next run finds the missing parenthesis and the broken template:

Checking broken-rules.yml
  FAILED:
broken-rules.yml: 8:15: group "kestrel-broken", rule 2, "MeterApiHighErrorRate": could not parse expression: 1:124: parse error: unclosed left parenthesis
broken-rules.yml: group "kestrel-broken", rule 3, "PortalSlow": annotation "summary": template: __alert_PortalSlow:1: unexpected "}" in operand

The space in job:http requests:rate5m is not reported: since Prometheus 3.0 any UTF-8 string is a valid metric name. It would still be a bad name, because every query would have to quote it. Fix the errors one run at a time, and keep the exit code in mind: in a test run, a duplicated recording rule produced "FAILED" and a lint message, but promtool still exited with 0 unless --lint-fatal was given (then it exited with 3). A pipeline that only checks the exit code needs that flag.

Pending, firing and resolved

A pending alert is an alert whose condition is true but has not yet been true for the whole for duration; Prometheus does not send it anywhere. The documentation describes for as checking "that the alert continues to be active during each evaluation" for that long. Only when the condition has held at every evaluation for the full duration does the alert become firing and go to Alertmanager. If the condition is false at one evaluation while pending, the alert disappears and the clock starts again. Rules without for fire at the first evaluation.

The life of an alert. Prometheus evaluates a rule. While the condition is true for less than the for duration the alert is pending; after that it is firing and is sent to Alertmanager. Alertmanager deduplicates, groups, applies inhibition and silences, follows the routing tree and notifies a receiver such as a pager, a ticket system or a chat channel. When the condition stops, the alert is resolved. Rule every 15 s Pending for: 2m Firing Alertmanager deduplicate group inhibit · silence route Pager Ticket Chat condition false: back to inactive
Prometheus decides whether an alert fires; Alertmanager decides who hears about it, when and how often.

Prometheus keeps active alerts as series named ALERTS, with an alertstate label. In a test run, the target web03:8080 had nothing listening, so TargetDown (with for: 2m) went through both states after Prometheus started at 16:58:02 UTC:

promtool query range --start=2026-10-06T16:58:00Z --end=2026-10-06T17:01:00Z --step=30s http://localhost:9090 'ALERTS{alertname="TargetDown"}'
ALERTS{alertname="TargetDown", alertstate="firing", env="production", instance="web03:8080", job="portal", severity="ticket", team="portal"} =>
1 @[1791306030]
1 @[1791306060]
ALERTS{alertname="TargetDown", alertstate="pending", env="production", instance="web03:8080", job="portal", severity="ticket", team="portal"} =>
1 @[1791305940]
1 @[1791305970]
1 @[1791306000]

Pending at the steps from 16:59:00 (1791305940) to 17:00:00 and firing from 17:00:30 on: the condition became true at 16:58:25, so the alert fired two minutes later, at 17:00:25. Prometheus also stores the moment the condition became true in ALERTS_FOR_STATE, so a restart in the middle of a for period does not start the clock from zero.

Choosing for and keep_firing_for

for trades speed for certainty. Without it, one bad scrape or a few slow seconds during a deploy pages someone. With a very long for, a real outage runs for half an hour before anyone is told. A few minutes is typical for pages; tickets can wait longer. The opposite problem is an alert that flaps: a value hovering around the threshold resolves and fires again, sending a "resolved" message in the middle of an incident. keep_firing_for (since Prometheus 2.42) keeps an alert firing for a set time after the condition was last met. A simulation of one rule evaluated every 15 seconds, with a single bad evaluation at 17:09:30 and a dip below the threshold at 17:12:30:

EVAL = 15  # seconds between rule evaluations

# Is the error ratio above 5%? One value per evaluation, starting at 17:09:00.
condition = [False, False, True, False, False, False,           # a single bad evaluation
             True, True, True, True, True, True, True, True,
             False,                                             # one dip below the threshold
             True, True, True, True, False, False, False, False, False]


def simulate(hold, keep_firing):
    """Prometheus alert states for `for: hold` and `keep_firing_for: keep_firing` (seconds)."""
    state, active_at, false_since, log = "inactive", None, None, []
    for i, met in enumerate(condition):
        t = i * EVAL
        if met:
            false_since = None
            if state == "inactive":
                state, active_at = "pending", t
            if state == "pending" and t - active_at >= hold:
                state = "firing"
        elif state == "firing" and keep_firing:
            false_since = t if false_since is None else false_since
            if t - false_since >= keep_firing:
                state = "inactive"
        else:
            state = "inactive"
        log.append(state)
    return log


def show(log):
    changes = [(i, s) for i, s in enumerate(log) if i == 0 or s != log[i - 1]]
    return ", ".join(f"17:{9 + i * EVAL // 60:02d}:{i * EVAL % 60:02d} {s}" for i, s in changes)


print("for: 1m                    ", show(simulate(60, 0)))
print("for: 1m, keep_firing_for: 1m", show(simulate(60, 60)))
for: 1m                     17:09:00 inactive, 17:09:30 pending, 17:09:45 inactive, 17:10:30 pending, 17:11:30 firing, 17:12:30 inactive, 17:12:45 pending, 17:13:45 inactive
for: 1m, keep_firing_for: 1m 17:09:00 inactive, 17:09:30 pending, 17:09:45 inactive, 17:10:30 pending, 17:11:30 firing, 17:14:45 inactive

The single bad evaluation never fires, thanks to for. Without keep_firing_for, the dip at 17:12:30 resolves the alert, it goes pending again, and resolves for good before it can fire a second time: the on-call engineer gets a "resolved" message while the problem is still there. With keep_firing_for: 1m the alert fires once, from 17:11:30 to 17:14:45.

Testing alert rules

promtool check rules proves a file loads; it does not prove the rule fires when it should. promtool test rules does: it feeds invented series into the rules and compares the alerts at given times with what you expect. Kestrel's test file (tests/alerts_test.yml):

rule_files:
  - ../rules/recording.yml
  - ../rules/alerts.yml

evaluation_interval: 15s

tests:
  - interval: 15s
    input_series:
      # 100 requests per 15 s; from minute 10 on, 10 of them fail.
      - series: 'http_requests_total{job="meter-api",instance="meter-api-01:8080",method="POST",route="/v1/readings",code="202"}'
        values: '0+100x80'
      - series: 'http_requests_total{job="meter-api",instance="meter-api-01:8080",method="POST",route="/v1/readings",code="503"}'
        values: '0x40 10+10x40'
      - series: 'up{job="portal",instance="web03:8080"}'
        values: '1x20 0x60'

    alert_rule_test:
      - eval_time: 9m
        alertname: MeterApiHighErrorRate
        exp_alerts: []

      - eval_time: 15m
        alertname: MeterApiHighErrorRate
        exp_alerts:
          - exp_labels:
              job: meter-api
              severity: page
              team: metering
            exp_annotations:
              summary: "meter-api returns 9.091% errors"
              description: "More than 5% of meter-api requests failed with a 5xx status over the last 5 minutes."
              runbook_url: "https://wiki.kestrel.example/runbooks/meter-api-errors"

      - eval_time: 8m
        alertname: TargetDown
        exp_alerts:
          - exp_labels:
              job: portal
              instance: web03:8080
              severity: ticket
            exp_annotations:
              summary: "web03:8080 (portal) cannot be scraped"
              description: "Prometheus has not been able to scrape web03:8080 for more than 2 minutes."

  - interval: 1m
    input_series:
      # Test time starts at 0 (the Unix epoch), so a value of 0 means
      # "the last export succeeded when the test started".
      - series: 'billing_export_last_success_timestamp_seconds{job="billing",instance="billing01"}'
        values: '0x1800'
    alert_rule_test:
      - eval_time: 25h
        alertname: BillingExportStale
        exp_alerts: []
      - eval_time: 27h
        alertname: BillingExportStale
        exp_alerts:
          - exp_labels:
              job: billing
              instance: billing01
              severity: ticket
              team: billing
            exp_annotations:
              summary: "No successful billing export for 1d 3h 0m 0s"
              description: "The nightly export normally finishes shortly after 02:30. Invoices for yesterday are not on their way."

Series values use a short notation: '0+100x80' starts at 0 and adds 100 at each of 80 steps; '1x20 0x60' is 1 at the start and for 20 more steps (up to 5m), then 0 for the next 61 samples. The test also checks the rendered annotations, so a broken template or a wrong percentage is caught too. The first version evaluated TargetDown at 7m:

promtool test rules tests/alerts_test.yml
  FAILED:
    alertname: TargetDown, time: 7m, 
        exp:[
            0:
              Labels:{alertname="TargetDown", instance="web03:8080", job="portal", severity="ticket"}
              Annotations:{description="Prometheus has not been able to scrape web03:8080 for more than 2 minutes.", summary="web03:8080 (portal) cannot be scraped"}
            ], 
        got:[]

The test was wrong, not the rule: up is 0 from 5m15s on (step 21 of 15 seconds), so with for: 2m the alert is still pending at 7m and fires from 7m15s. With eval_time: 8m:

  SUCCESS

Writing the test forces you to think through exactly when an alert should fire, and that is often where the real mistakes surface. Run these tests in the same pipeline as promtool check rules.

Alertmanager

Prometheus' own documentation says its alerting rules are "good at figuring what is broken right now, but they are not a fully-fledged notification solution". That is the job of Alertmanager: the Prometheus component that receives firing alerts from one or more Prometheus servers, deduplicates and groups them, applies inhibition rules and silences, and sends notifications to receivers such as a pager service, email, chat or a webhook according to a routing tree. As of October 2026 the current version is 0.34.1.

Kestrel's alertmanager.yml, maintained by Tomasz Nowak in the NOC (in this test version every receiver is a webhook on localhost:5001; in production they would be the paging service, the ticket system and a mailbox):

# Alertmanager configuration for Kestrel Energy
route:
  receiver: ops-tickets
  group_by: [alertname, job]
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  routes:
    - matchers:
        - severity="page"
      receiver: oncall-pager
      group_wait: 10s
      repeat_interval: 1h
    - matchers:
        - team="billing"
      receiver: billing-team
      group_by: [alertname]
      repeat_interval: 12h

inhibit_rules:
  # While a page is open for a job, hold back ticket-level alerts for the same job.
  - source_matchers:
      - severity="page"
    target_matchers:
      - severity="ticket"
    equal: [job]

receivers:
  - name: ops-tickets
    webhook_configs:
      - url: http://localhost:5001/tickets
        send_resolved: true
  - name: oncall-pager
    webhook_configs:
      - url: http://localhost:5001/page
        send_resolved: true
  - name: billing-team
    webhook_configs:
      - url: http://localhost:5001/billing
amtool check-config alertmanager.yml
Checking 'alertmanager.yml'  SUCCESS
Found:
 - global config
 - route
 - 1 inhibit rules
 - 3 receivers
 - 0 templates

Two typical mistakes, a route pointing at a receiver billing that is not defined, and a misspelled option (group_by_labels), each fail the check:

Checking 'alertmanager.yml'  FAILED: undefined receiver "billing" used in route

amtool: error: failed to validate 1 file(s)
Checking 'alertmanager.yml'  FAILED: yaml: unmarshal errors:
  line 6: field group_by_labels not found in type config.plain

amtool: error: failed to validate 1 file(s)

For high availability, run two or three Alertmanagers as a cluster (they share silences and notification state) and list all of them in every Prometheus server's configuration. The documentation is explicit that Prometheus should not reach them through a load balancer: each Prometheus sends every alert to every Alertmanager, and the cluster makes sure only one notification goes out.

Routing and grouping

A routing tree is the nested set of routes in Alertmanager's configuration that decides which receiver gets an alert and with which timing. Every alert enters at the top-level route, which must match all alerts, and goes down to the first child route whose matchers fit; it stops there unless the route sets continue: true. An alert that matches no child stays with the top-level route and its receiver.

Kestrel's routing tree. The default route sends to ops-tickets, grouped by alertname and job. Its first child matches severity="page" and sends to oncall-pager with group_wait 10 seconds and repeat_interval 1 hour. Its second child matches team="billing" and sends to billing-team, grouped by alertname, with repeat_interval 12 hours. A page from the billing team stops at the first child. default route → ops-tickets group_by: [alertname, job] 1st 2nd severity="page" → oncall-pager wait 10s · repeat 1h team="billing" → billing-team by alertname · repeat 12h
Child routes are tried in order and the first match wins. Anything that is not a page and not from billing becomes a ticket.

amtool draws the tree and, more usefully, tells you where a given set of labels would end up, without sending anything:

amtool config routes show --config.file=alertmanager.yml
amtool config routes test --config.file=alertmanager.yml severity=page job=meter-api alertname=MeterApiHighErrorRate
amtool config routes test --config.file=alertmanager.yml severity=ticket team=billing alertname=BillingExportStale
Routing tree:
.
└── default-route  receiver: ops-tickets
    ├── {severity="page"}  receiver: oncall-pager
    └── {team="billing"}  receiver: billing-team
oncall-pager
billing-team

With --tree you see the path, which shows that a page from the billing team goes to the pager, not to the billing mailbox, because the page route comes first:

amtool config routes test --config.file=alertmanager.yml --tree severity=page team=billing
Matching routes:
.
└── default-route
    └── {severity="page"}  receiver: oncall-pager


oncall-pager

With --verify.receivers the command becomes a test that fails (exit code 1) when the result is not what you expect, which makes it suitable for a pipeline:

amtool config routes test --config.file=alertmanager.yml --verify.receivers=oncall-pager severity=ticket team=metering
ops-tickets
WARNING: Expected receivers did not match resolved receivers.

Grouping and timing

Alert grouping is Alertmanager's bundling of alerts that share the values of the route's group_by labels into one notification, so a failure that triggers many alerts at once produces one message per group instead of one per alert. The documentation's example is a network partition: hundreds of alerts, and "one only wants to get a single page". Three timers control a group:

Setting Default Meaning
group_wait 30s How long a new group waits before its first notification, so alerts that start together arrive together
group_interval 5m How long to wait before notifying about changes to a group that was already notified (new alerts, resolved alerts)
repeat_interval 4h How long to wait before sending the same unchanged notification again (should be a multiple of group_interval)

Kestrel shortens group_wait to 10 seconds for pages: for a page, arriving twenty seconds sooner matters more than bundling a few more alerts into the first message. During the test incident, the first page went out exactly 10 seconds after the fast-burn alert fired at 17:12:33 UTC. This is what the webhook receiver got (Alertmanager's webhook format, version 4):

{
  "receiver": "oncall-pager",
  "status": "firing",
  "alerts": [
    {
      "status": "firing",
      "labels": {
        "alertname": "MeterApiErrorBudgetFastBurn",
        "job": "meter-api",
        "severity": "page",
        "team": "metering"
      },
      "annotations": {
        "description": "At this rate 2% of the monthly budget is gone within an hour. Error ratio over 1h: 2.987%.",
        "summary": "meter-api is burning its 30-day error budget at more than 14.4x"
      },
      "startsAt": "2026-10-06T17:12:33.182Z",
      "endsAt": "0001-01-01T00:00:00Z",
      "generatorURL": "http://monitor01:9090/graph?g0.expr=job%3Aslo_errors_per_request%3Aratio_rate1h%7Bjob%3D%22meter-api%22%7D+%3E+%2814.4+%2A+0.001%29+and+job%3Aslo_errors_per_request%3Aratio_rate5m%7Bjob%3D%22meter-api%22%7D+%3E+%2814.4+%2A+0.001%29&g0.tab=1",
      "fingerprint": "7893f423d547d8a6"
    }
  ],
  "notification_reason": "first notification",
  "groupLabels": {
    "alertname": "MeterApiErrorBudgetFastBurn",
    "job": "meter-api"
  },
  "commonLabels": {
    "alertname": "MeterApiErrorBudgetFastBurn",
    "job": "meter-api",
    "severity": "page",
    "team": "metering"
  },
  "commonAnnotations": {
    "description": "At this rate 2% of the monthly budget is gone within an hour. Error ratio over 1h: 2.987%.",
    "summary": "meter-api is burning its 30-day error budget at more than 14.4x"
  },
  "routeLabels": {},
  "externalURL": "http://monitor01:9093",
  "version": "4",
  "groupKey": "{}/{severity=\"page\"}:{alertname=\"MeterApiErrorBudgetFastBurn\", job=\"meter-api\"}",
  "truncatedAlerts": 0
}

groupLabels are the group_by values, commonLabels what all alerts in the group share, and endsAt of year 1 means "still firing". The generatorURL links back to the expression in Prometheus. Paging services and chat integrations receive the same information in their own formats. The main notifications during the incident (times in UTC; a selection from what the receiver logged):

Time Receiver Status Alert
17:05:55 ops-tickets firing TargetDown (web03)
17:12:43 oncall-pager firing MeterApiErrorBudgetFastBurn
17:14:35 oncall-pager firing MeterApiHighErrorRate
17:23:40 ops-tickets firing MeterReadingsQueueFilling (2 alerts, one group)
17:26:55 billing-team firing BillingExportStale
17:32:43 oncall-pager resolved MeterApiErrorBudgetFastBurn
17:34:35 oncall-pager resolved MeterApiHighErrorRate
17:35:18 ops-tickets firing MeterApiErrorBudgetSlowBurn (held back while the pages were open)

The meter-api problem started at 17:09:07 and was over at 17:27:25. Two pages for one problem is acceptable (one is the budget burn, one the error ratio; they are grouped separately because their alertname differs). The resolved messages came at the next group_interval tick after the conditions cleared, not the moment they cleared.

Inhibition

Inhibition is Alertmanager's suppression of notifications for some alerts (the targets) while certain other alerts (the sources) are firing, when both have the same values for the labels listed in equal. The classic use: when a whole site is down, do not also send fifty alerts about services on that site. Kestrel's rule holds back ticket-level alerts for a job while a page for the same job is open, because the person handling the page is already looking at that service. At 17:15:25 UTC:

amtool alert query --alertmanager.url=http://localhost:9093 --active --silenced --inhibited
Alertname                    Starts At                Summary                                                          State       
TargetDown                   2026-10-06 17:05:25 UTC  web03:8080 (portal) cannot be scraped                            active      
MeterApiErrorBudgetFastBurn  2026-10-06 17:12:33 UTC  meter-api is burning its 30-day error budget at more than 14.4x  active      
MeterApiSlow                 2026-10-06 17:13:25 UTC  meter-api p95 latency is 409.2ms                                 suppressed  
MeterApiHighErrorRate        2026-10-06 17:14:25 UTC  meter-api returns 8.157% errors                                  active      
PortalSlow                   2026-10-06 17:14:55 UTC  Customer portal p95 latency is 2.147s                            suppressed  

"suppressed" covers both inhibited and silenced alerts. Asking for only the inhibited ones shows that MeterApiSlow is held back by the inhibit rule, while PortalSlow is suppressed for another reason (a silence on alertname="PortalSlow", job="portal", set while a fix for the slow invoice PDFs was deployed; silences are explained below):

amtool alert --alertmanager.url=http://localhost:9093 query --inhibited
Alertname     Starts At                Summary                           State       
MeterApiSlow  2026-10-06 17:13:25 UTC  meter-api p95 latency is 409.2ms  suppressed  

When inhibition silently does not apply

Later in the incident, MeterReadingsQueueFilling reached the ticket queue although it is a meter-api ticket and the meter-api pages were still open. The reason was in the rule expression. With on (instance), a comparison keeps only the matching label in its result:

promtool query instant http://localhost:9090 'predict_linear(meter_readings_queue_depth[15m], 3600) > on (instance) meter_readings_queue_limit'
{instance="meter-api-01:8080"} => 421307.14245082915 @[1791307638.367]
…

The same comparison without on (instance):

promtool query instant http://localhost:9090 'predict_linear(meter_readings_queue_depth[15m], 3600) > meter_readings_queue_limit'
{env="production", instance="meter-api-01:8080", job="meter-api", team="metering"} => 421312.8901219524 @[1791307638.431]
{env="production", instance="meter-api-02:8080", job="meter-api", team="metering"} => 419527.0574925534 @[1791307638.431]

(The first output is trimmed to one line; the second instance looks the same.) The first form drops job, env and team. The inhibit rule compares job: the source has job="meter-api", the target has no job at all, so they are not equal and nothing is inhibited. Here both sides already have identical labels, so the plain comparison is the fix:

predict_linear(meter_readings_queue_depth[15m], 3600) > meter_readings_queue_limit

The general lesson: routing and inhibition depend on labels, and aggregations (sum by, on, ignoring) can remove them without any error. Test alert labels with promtool test rules and check the labels in real notifications.

The opposite trap is documented in Alertmanager's own example configuration: a missing label and an empty label count as equal. If none of the labels in equal exist on either alert, they are "equal", and the inhibition applies. An inhibit rule with equal: [cluster] in a setup without a cluster label mutes every matching target everywhere.

Both effects, reproduced: the same alerts, the same routes and the same inhibit rule as in Kestrel's configuration, plus the PortalSlow silence.

# Some of the alerts firing at about 17:24 UTC (labels only)
alerts = [
    {"alertname": "TargetDown", "job": "portal", "instance": "web03:8080", "severity": "ticket", "team": "portal"},
    {"alertname": "MeterApiErrorBudgetFastBurn", "job": "meter-api", "severity": "page", "team": "metering"},
    {"alertname": "MeterApiHighErrorRate", "job": "meter-api", "severity": "page", "team": "metering"},
    {"alertname": "MeterApiSlow", "job": "meter-api", "severity": "ticket", "team": "metering"},
    {"alertname": "MeterApiErrorBudgetSlowBurn", "job": "meter-api", "severity": "ticket", "team": "metering"},
    {"alertname": "PortalSlow", "job": "portal", "severity": "ticket", "team": "portal"},
    {"alertname": "MeterReadingsQueueFilling", "instance": "meter-api-01:8080", "severity": "ticket", "team": "metering"},
    {"alertname": "MeterReadingsQueueFilling", "instance": "meter-api-02:8080", "severity": "ticket", "team": "metering"},
]

# Same structure as alertmanager.yml: first matching child route wins.
ROOT = {"receiver": "ops-tickets", "group_by": ["alertname", "job"]}
ROUTES = [
    {"match": {"severity": "page"}, "receiver": "oncall-pager"},
    {"match": {"team": "billing"}, "receiver": "billing-team", "group_by": ["alertname"]},
]
INHIBIT = {"source": {"severity": "page"}, "target": {"severity": "ticket"}, "equal": ["job"]}
SILENCES = [{"alertname": "PortalSlow", "job": "portal"}]


def matches(alert, matchers):
    return all(alert.get(name, "") == value for name, value in matchers.items())


def inhibited(alert):
    if not matches(alert, INHIBIT["target"]):
        return False
    return any(
        source is not alert and matches(source, INHIBIT["source"])
        and all(source.get(name, "") == alert.get(name, "") for name in INHIBIT["equal"])
        for source in alerts
    )


groups = {}
for alert in alerts:
    if any(matches(alert, s) for s in SILENCES):
        print(f"silenced   {alert['alertname']}")
        continue
    if inhibited(alert):
        print(f"inhibited  {alert['alertname']}")
        continue
    route = next((r for r in ROUTES if matches(alert, r["match"])), ROOT)
    group_by = route.get("group_by", ROOT["group_by"])
    key = (route["receiver"], tuple(f'{name}="{alert[name]}"' for name in group_by if name in alert))
    groups.setdefault(key, []).append(alert)

for (receiver, labels), members in groups.items():
    print(f"notify {receiver:<13} {{{', '.join(labels)}}}  {len(members)} alert(s)")
inhibited  MeterApiSlow
inhibited  MeterApiErrorBudgetSlowBurn
silenced   PortalSlow
notify ops-tickets   {alertname="TargetDown", job="portal"}  1 alert(s)
notify oncall-pager  {alertname="MeterApiErrorBudgetFastBurn", job="meter-api"}  1 alert(s)
notify oncall-pager  {alertname="MeterApiHighErrorRate", job="meter-api"}  1 alert(s)
notify ops-tickets   {alertname="MeterReadingsQueueFilling"}  2 alert(s)

The two meter-api tickets that carry job are held back; the queue alert without job slips through, and its two instances arrive as one grouped ticket. As an experiment, remove "job": "meter-api" from the two page alerts: the missing-equals-empty rule then makes them inhibit the queue alert, while MeterApiSlow and the slow-burn ticket are no longer held back.

Silences

A silence is a temporary mute in Alertmanager: a set of label matchers, a start and end time, an author and a comment. Matching alerts are still evaluated and visible, but no notifications are sent until the silence expires. Silences are for known, temporary situations: a planned change, or a problem that is already being handled and does not need to page again every hour.

Before web03 was reinstalled under change CHG-2297, its TargetDown alert was silenced for 30 minutes (the test setup user was amara). Without a comment, amtool refuses; this first attempt was also far too broad, as it would have muted every meter-api ticket:

amtool silence add --alertmanager.url=http://localhost:9093 --author=amara --duration=30m 'alertname=~"Meter.*"' severity=ticket
amtool: error: comment required by config
amtool silence add --alertmanager.url=http://localhost:9093 --author=amara --duration=30m --comment='web03 is being reinstalled (CHG-2297)' alertname=TargetDown instance=web03:8080
amtool silence query --alertmanager.url=http://localhost:9093 instance=web03:8080
44fa6b3d-15e9-4e0a-9bea-57e96731d0e7
ID                                    Matchers                                      Ends At                  Created By  Comment                                
44fa6b3d-15e9-4e0a-9bea-57e96731d0e7  alertname="TargetDown" instance="web03:8080"  2026-10-06 17:57:39 UTC  amara       web03 is being reinstalled (CHG-2297)  

silence add prints the new silence's ID. When the work is done earlier than planned, expire the silence instead of letting it run out, so a new problem with web03 is not hidden. -q prints only IDs, which makes this a one-liner:

amtool silence expire --alertmanager.url=http://localhost:9093 $(amtool silence query --alertmanager.url=http://localhost:9093 -q instance=web03:8080)

silence expire prints nothing on success. A query afterward shows only the header: no active silences.

amtool silence query --alertmanager.url=http://localhost:9093
ID  Matchers  Ends At  Created By  Comment  

For recurring quiet periods, such as a weekly maintenance window or tickets that should only arrive during office hours, use time intervals in the configuration instead of a silence someone must remember to create. A fragment (time zone names follow the IANA database):

time_intervals:
  - name: office-hours
    time_intervals:
      - weekdays: ["monday:friday"]
        times:
          - start_time: "08:00"
            end_time: "18:00"
        location: Europe/Amsterdam

route:
  receiver: ops-tickets
  routes:
    - matchers:
        - team="billing"
      receiver: billing-team
      active_time_intervals:
        - office-hours

active_time_intervals sends notifications only inside the interval; mute_time_intervals does the opposite. Alerts are still evaluated and shown during muted periods.

Fighting alert fatigue

Alert sets decay. Services change, thresholds that made sense a year ago fire every night, and people learn to swipe pages away. The cure is a regular review, monthly or after every on-call week, with three questions per alert: did it fire, did someone have to act, and was it urgent? A simple review of a month of meter-api pages in the portal rotation:

from collections import defaultdict

# September's meter-api pages in the portal rotation: (alert, hour of day, did someone have to act?)
pages = [
    ("MeterApiHighErrorRate", 3, True), ("MeterApiHighErrorRate", 14, True),
    ("MeterApiErrorBudgetFastBurn", 3, True), ("MeterApiErrorBudgetFastBurn", 10, True),
    ("DiskAlmostFull", 2, False), ("DiskAlmostFull", 4, False), ("DiskAlmostFull", 23, False),
    ("DiskAlmostFull", 1, True), ("DiskAlmostFull", 5, False), ("DiskAlmostFull", 3, False),
    ("CpuHigh", 2, False), ("CpuHigh", 9, False), ("CpuHigh", 13, False),
    ("CpuHigh", 0, False), ("CpuHigh", 22, False),
]

stats = defaultdict(lambda: {"pages": 0, "night": 0, "acted": 0})
for alert, hour, acted in pages:
    s = stats[alert]
    s["pages"] += 1
    s["night"] += hour < 7 or hour >= 22
    s["acted"] += acted

print(f"{'Alert':<28}{'Pages':>6}{'At night':>9}{'Acted':>7}  Verdict")
for alert, s in sorted(stats.items(), key=lambda item: -item[1]["pages"]):
    share = s["acted"] / s["pages"]
    verdict = "keep" if share >= 0.5 else "fix, demote to a ticket, or delete"
    print(f"{alert:<28}{s['pages']:>6}{s['night']:>9}{share:>7.0%}  {verdict}")
night = sum(s["night"] for s in stats.values())
print(f"{len(pages)} pages, {night} of them between 22:00 and 07:00")
Alert                        Pages At night  Acted  Verdict
DiskAlmostFull                   6        6    17%  fix, demote to a ticket, or delete
CpuHigh                          5        3     0%  fix, demote to a ticket, or delete
MeterApiHighErrorRate            2        1   100%  keep
MeterApiErrorBudgetFastBurn      2        1   100%  keep
15 pages, 11 of them between 22:00 and 07:00

Eleven of the fifteen pages came at night, and ten were for alerts nobody needed to act on. The two symptom alerts earned every page. DiskAlmostFull probably needs a prediction ("full within four hours", with predict_linear) instead of a fixed percentage, and CpuHigh is a cause that belongs on a dashboard. Turning them into tickets or dashboard panels cuts the pages from fifteen to four without hiding a single user-facing problem. That matters for people: every night page costs sleep, and the on-call page covers rest and compensation after night pages.

Some other habits that keep alerting healthy:

  • Every page has a runbook link in its annotations, and every alert has an owning team in its labels.
  • Alert on the alerting. If Prometheus or Alertmanager is down, nothing pages. A common pattern is a "watchdog" alert that always fires and is sent to an external service, which raises the alarm when it stops arriving. Alertmanager's own metric alertmanager_notifications_failed_total shows failed deliveries.
  • Test the whole path after every change to receivers, with a test alert added by hand (amtool alert add alertname=TestNotification …). In a test run, with the receiver switched off, Alertmanager logged one warning at the first failed attempt and gave up five minutes later:
time=2026-10-06T19:38:50.525+02:00 level=WARN source=retry_stage.go:165 msg="Notify attempt failed, will retry later" component=dispatcher receiver=ops-tickets integration=webhook[0] aggrGroup="{}:{alertname=\"TestNotification\", job=\"alertmanager-test\"}" attempts=1 err="Post \"<redacted>\": dial tcp 127.0.0.1:5001: connect: connection refused"
time=2026-10-06T19:43:50.530+02:00 level=ERROR source=dispatch.go:589 msg="Notify for alerts failed" component=dispatcher aggrGroup="{}:{alertname=\"TestNotification\", job=\"alertmanager-test\"}" num_alerts=1 err="ops-tickets/webhook[0]: notify retry canceled after 14 attempts: Post \"<redacted>\": dial tcp 127.0.0.1:5001: connect: connection refused"

A pager integration with an expired key fails the same way, and silently from the on-call engineer's point of view: the alert fires, nobody is told. Alert on failed notifications, and send that alert through a different channel than the one that failed.