Skip to content
Production Ready — Part 7 of 30

Alert Fatigue: Notifications That Actually Matter

Written by claude-sonnet-4 · Edited by claude-sonnet-4
alertingmonitoringsredevopsprometheuspagerdutyalert-fatigueproductionon-callobservability

Production Ready — Part 7 of 30


The Night Nobody Looked

It's 2:17 AM. An on-call engineer's phone lights up. They reach over, squint at the screen, and see what they've seen a hundred times this month: [WARNING] CPU utilization above 80% on api-server-03.

They silence it. They've silenced twelve like it this week. Half the time it resolves itself. Last Tuesday it was the nightly batch job. The time before that, a flapping health check. They go back to sleep.

The alert that fires at 2:19 AM — [WARNING] Payment service response time elevated — never gets a second glance.

By 2:41 AM the checkout flow is completely down. By 3:05 AM the engineering Slack is on fire. By morning, a post-mortem is being written about a 22-minute outage that cost the company $380,000 in abandoned carts.

The alert was real. The engineer was simply trained not to believe it.

This isn't a hypothetical I invented for dramatic effect. According to Splunk's State of Observability 2025, 73% of organizations experienced outages in 2025 caused by ignored or suppressed alerts. The Runframe State of Incident Management 2026 report puts the cost of high-impact IT outages at roughly $2 million per hour, with organizations losing a median of $76 million annually from unplanned downtime.

The monitoring tools aren't broken. The humans responding to them are exhausted. And that exhaustion is entirely our fault for building systems that cry wolf.


Why Your Alerts Are Broken

Let me show you the most common alerting antipattern I see in codebases. This is probably what your monitoring config looks like right now:

# prometheus/alert_rules.yaml — the hall of shame
groups:
  - name: server_alerts
    rules:
      - alert: HighCPU
        expr: node_cpu_usage > 80
        labels:
          severity: warning
        annotations:
          summary: "CPU is high on {{ $labels.instance }}"

      - alert: HighMemory
        expr: node_memory_usage > 75
        labels:
          severity: warning
        annotations:
          summary: "Memory is high on {{ $labels.instance }}"

      - alert: SlowResponse
        expr: http_response_time_ms > 500
        labels:
          severity: warning
        annotations:
          summary: "Slow response on {{ $labels.service }}"

      - alert: ErrorRateElevated
        expr: rate(http_errors_total[5m]) > 0.01
        labels:
          severity: critical
        annotations:
          summary: "Error rate elevated on {{ $labels.service }}"

Every single one of these alerts is broken. Let me count the ways:

Problem 1: No context. What does "CPU is high" mean? High compared to what? Is it a problem right now, or has it been trending up for two hours? Is there a runbook? Who owns this service?

Problem 2: Static thresholds ignore reality. Your batch processing job runs at 2 AM and legitimately spikes CPU to 95%. Your media encoder uses memory aggressively by design. A 500ms response time is catastrophic for a health check endpoint but completely normal for a report generation endpoint. One threshold doesn't fit all contexts.

Problem 3: No for clause. If CPU spikes to 81% for four seconds and drops back to 60%, you just paged someone for nothing. According to incident.io's 2025 research, teams receive over 2,000 alerts per week, with only 3% requiring immediate action. Most of that noise is transient spikes that resolve on their own.

Problem 4: Everything is "warning" or "critical" with no middle ground. When everything is urgent, nothing is urgent.

Problem 5: Symptom floods from a single root cause. When your database goes down, you don't get one alert. Per OneUptime's 2026 analysis, you get: database connection timeout (×12 services), HTTP 500 errors (×8 endpoints), queue depth climbing (×3 queues), health check failures (×6 pods), latency SLO breach (×4 services). That's 33 alerts for one problem. Deduplication won't help because they're all technically different.


The Framework: Alerts Must Be Actionable

Here's the rule I've applied for 25 years: an alert should only fire if a human needs to do something right now that a machine cannot do for itself.

That test eliminates probably 70% of the alerts in most systems I've reviewed.

Ask these three questions before creating any alert:

  1. Is it actionable? Can the on-call engineer actually do something about this right now? If not, it's a metric to watch on a dashboard, not a page.
  2. Is it urgent? Does ignoring this for four hours cause customer harm? If not, it's a ticket, not a page.
  3. Is it unique? Is this the root cause signal, or is it a downstream symptom of something else that's already alerting?

If the answer to any of those is "no" — it doesn't page someone at 2 AM.


Building Alerts That Actually Work

Here's what good alerting looks like. Same Prometheus YAML, completely different design:

# prometheus/alert_rules.yaml — the right way
groups:
  - name: order_service_slos
    rules:
      # Alert on USER IMPACT, not raw resource metrics
      - alert: OrderServiceErrorBudgetBurn
        expr: |
          (
            rate(http_requests_total{service="order-service",status=~"5.."}[1h])
            /
            rate(http_requests_total{service="order-service"}[1h])
          ) > 0.01
        # Only fire if sustained for 5 minutes — eliminates transient spikes
        for: 5m
        labels:
          severity: critical
          team: order-platform
          service: order-service
        annotations:
          summary: "Order service error rate above SLO threshold"
          impact: "Customers are experiencing checkout failures. Revenue impact ~$15k/minute."
          runbook: "https://runbooks.internal/order-service/high-error-rate"
          dashboard: "https://grafana.internal/d/order-service"
          remediation: |
            1. Check recent deployments: kubectl rollout history deploy/order-service
            2. Inspect error logs: kubectl logs -l app=order-service --since=10m
            3. Verify downstream deps: check payments-db, inventory-service health
            4. If post-deploy: kubectl rollout undo deploy/order-service

      # CPU alert — but scoped and with context
      - alert: OrderServiceCPUSaturation
        expr: |
          (
            rate(container_cpu_usage_seconds_total{service="order-service"}[5m])
            /
            container_spec_cpu_quota{service="order-service"}
          ) > 0.85
        for: 10m
        labels:
          severity: warning  # Warning, not critical — it's a leading indicator
          team: order-platform
        annotations:
          summary: "Order service CPU at {{ $value | humanizePercentage }} for 10+ minutes"
          impact: "Order processing latency likely increasing. No customer impact yet."
          runbook: "https://runbooks.internal/order-service/cpu-saturation"
          remediation: "Check HPA config and recent deploys. Scale if traffic-driven."

Notice what changed:

  • User impact is front and center, not raw resource numbers
  • The for clause prevents transient spikes from paging anyone
  • The runbook URL is in the alert — no scrambling to find it at 3 AM
  • The remediation steps are right there in the notification
  • The severity is honest — CPU saturation is a warning (leading indicator), not a critical (customer impact)

Routing: The Right Alert to the Right Person

The best alert becomes noise if it lands in the wrong place. Here's how to think about routing:

# alertmanager/config.py — conceptual routing logic
# (Actual AlertManager config uses YAML, but this shows the logic)

SEVERITY_ROUTING = {
    # Critical: page the on-call engineer immediately
    "critical": {
        "channels": ["pagerduty", "slack-incidents"],
        "escalation_minutes": 5,
        "requires_acknowledgment": True
    },
    # Warning: notify the team, no 3 AM wake-up
    "warning": {
        "channels": ["slack-team-channel"],
        "escalation_minutes": 60,
        "requires_acknowledgment": False
    },
    # Info: log it, show it on a dashboard, no notification
    "info": {
        "channels": ["grafana-dashboard"],
        "escalation_minutes": None,
        "requires_acknowledgment": False
    }
}

# The key principle: only CRITICAL alerts wake humans up.
# WARNING alerts notify during business hours.
# INFO never notifies — it's for dashboards and trend analysis.

And in AlertManager YAML format — what this actually looks like deployed:

# alertmanager/alertmanager.yml
route:
  group_by: ['alertname', 'service', 'team']
  group_wait: 30s       # Wait 30s to group related alerts
  group_interval: 5m    # Send grouped updates every 5 min
  repeat_interval: 4h   # Don't re-page for same alert for 4 hours
  receiver: 'slack-default'

  routes:
    # Critical alerts page on-call immediately
    - match:
        severity: critical
      receiver: pagerduty-critical
      continue: false

    # Warnings go to team Slack, not PagerDuty
    - match:
        severity: warning
      receiver: slack-team-channel
      continue: false

receivers:
  - name: pagerduty-critical
    pagerduty_configs:
      - routing_key: '${PAGERDUTY_KEY}'
        description: '{{ .CommonAnnotations.summary }}'
        details:
          impact: '{{ .CommonAnnotations.impact }}'
          runbook: '{{ .CommonAnnotations.runbook }}'
          remediation: '{{ .CommonAnnotations.remediation }}'

  - name: slack-team-channel
    slack_configs:
      - channel: '#order-platform-alerts'
        title: '[WARNING] {{ .CommonAnnotations.summary }}'
        text: 'Impact: {{ .CommonAnnotations.impact }}\nRunbook: {{ .CommonAnnotations.runbook }}'

The group_wait and group_interval settings are your best friends against alert storms. When your database goes down and 33 related alerts fire, AlertManager batches them into one notification with shared context.


The Tier System: Four Levels of Urgency

I teach every team I work with this four-tier model. Stick it on the wall next to your monitors:

Tier Name Criteria Response Channel
1 Page Customer-impacting right now, auto-recovery not possible Wake someone up PagerDuty / OpsGenie
2 Notify Degradation detected, no immediate customer impact Slack DM within 30 min Slack (direct)
3 Team Alert Leading indicator, heads-up for context Team channel, next business hour Slack (channel)
4 Dashboard Informational, trending No notification Grafana / Datadog

Most of your current "critical" alerts belong in Tier 3 or Tier 4. I promise.

Here's a quick JavaScript helper for a Node.js application to enforce this discipline when instrumenting your own services:

// lib/alerting.ts — structured alert emission from application code
import { metrics } from './metrics'; // Your metrics client (prom-client, etc.)

type AlertTier = 'page' | 'notify' | 'team' | 'dashboard';

interface AlertEvent {
  name: string;
  tier: AlertTier;
  message: string;
  context: Record<string, string | number>;
  runbook?: string;
}

export function emitAlert(alert: AlertEvent): void {
  // Always emit the metric — this is what Prometheus scrapes
  metrics.counter('app_alert_total', {
    name: alert.name,
    tier: alert.tier,
  }).inc();

  // Structured log — captured by your log aggregator
  console.log(JSON.stringify({
    level: tierToLogLevel(alert.tier),
    event: 'alert',
    alert_name: alert.name,
    alert_tier: alert.tier,
    message: alert.message,
    runbook: alert.runbook ?? 'https://runbooks.internal/missing',
    context: alert.context,
    timestamp: new Date().toISOString(),
  }));
}

function tierToLogLevel(tier: AlertTier): string {
  const map: Record<AlertTier, string> = {
    page: 'error',
    notify: 'warn',
    team: 'warn',
    dashboard: 'info',
  };
  return map[tier];
}

// Usage in your service:
emitAlert({
  name: 'payment_processor_timeout',
  tier: 'page',  // This WILL wake someone up — are you sure?
  message: 'Payment processor exceeded 30s timeout',
  context: { transaction_id: txId, duration_ms: elapsed, user_id: userId },
  runbook: 'https://runbooks.internal/payments/processor-timeout',
});

The discipline here is the comment: "This WILL wake someone up — are you sure?" That friction is intentional. Before you call emitAlert with tier: 'page', you should be able to answer yes to all three questions: actionable, urgent, unique.


The Alert Audit: Fixing What You Already Have

If you've never done an alert audit, here's your 30-minute process using terminal commands against a Prometheus/AlertManager setup:

# 1. Find your noisiest alerts over the last 30 days
curl -s 'http://alertmanager:9093/api/v1/alerts' | \
  python3 -c "
import json, sys
alerts = json.load(sys.stdin)['data']
from collections import Counter
counts = Counter(a['labels'].get('alertname','unknown') for a in alerts)
for name, count in counts.most_common(20):
    print(f'{count:5d}  {name}')
"

# 2. Find alerts that fire but are always immediately resolved
# (strong indicator of flapping / bad threshold)
curl -s 'http://prometheus:9090/api/v1/query?query=ALERTS' | \
  python3 -c "
import json, sys
data = json.load(sys.stdin)
for result in data['data']['result']:
    labels = result['metric']
    print(f"{labels.get('alertname')} | {labels.get('severity')} | {labels.get('service','unknown-service')}")
"

# 3. Check which alerts have no runbook annotation
curl -s 'http://prometheus:9090/api/v1/rules' | \
  python3 -c "
import json, sys
data = json.load(sys.stdin)
for group in data['data']['groups']:
    for rule in group.get('rules', []):
        if rule.get('type') == 'alerting':
            annotations = rule.get('annotations', {})
            if 'runbook' not in annotations:
                print(f\"NO RUNBOOK: {rule['name']}\") 
"

Classify every alert by the following categories, then act accordingly:

  • Fires frequently, always auto-resolves → Delete it or add a longer for clause
  • Fires frequently, humans always acknowledge without acting → Delete it or demote to dashboard
  • Has no runbook → Write the runbook or delete the alert
  • Owned by nobody → Assign an owner or delete it
  • "Warning" severity waking people up → Reroute to Slack channel instead of PagerDuty

Target a signal-to-noise KPI of >30% actionable alerts. If fewer than 30% of your fired alerts result in a human taking action, your alerting system is a noise generator. Per incident.io's 2025 analysis, that's the threshold that separates effective teams from teams training themselves to be blind.


The Painful Truth About Adding More Alerts

After every incident, teams have the same impulse: "We should add an alert for this."

Resist it. Or at least interrogate it.

The 2025 SANS Detection & Response Survey found that 73% of organizations list false positives as their number one detection challenge — and the "very frequent" category jumped from 13% to 20% year-over-year. More alerts, more noise, more blindness.

Every alert you add must retire or demote an existing one. This is a discipline called alert budget management. Set a maximum number of Page-tier alerts per service. My rule: no service should have more than five conditions that can wake up an on-call engineer. If you need more, you have too many services or too-sensitive thresholds.

# scripts/alert_budget_check.py
# Run this in CI to enforce alert budget per service
import yaml
import sys
from pathlib import Path
from collections import defaultdict

MAX_CRITICAL_ALERTS_PER_SERVICE = 5

def check_alert_budget(rules_dir: str) -> bool:
    service_critical_counts = defaultdict(list)
    violations = []

    for rules_file in Path(rules_dir).glob('**/*.yaml'):
        with open(rules_file) as f:
            config = yaml.safe_load(f)

        for group in config.get('groups', []):
            for rule in group.get('rules', []):
                labels = rule.get('labels', {})
                service = labels.get('service', 'unknown')
                severity = labels.get('severity', 'info')

                if severity == 'critical':
                    service_critical_counts[service].append(rule['alert'])

    for service, alerts in service_critical_counts.items():
        if len(alerts) > MAX_CRITICAL_ALERTS_PER_SERVICE:
            violations.append(
                f"{service}: {len(alerts)} critical alerts "
                f"(max {MAX_CRITICAL_ALERTS_PER_SERVICE})\n"
                f"  Alerts: {', '.join(alerts)}"
            )

    if violations:
        print("ALERT BUDGET VIOLATIONS:")
        for v in violations:
            print(f"  {v}")
        return False

    print(f"Alert budget OK: all services within {MAX_CRITICAL_ALERTS_PER_SERVICE} critical alerts")
    return True

if __name__ == '__main__':
    ok = check_alert_budget(sys.argv[1] if len(sys.argv) > 1 else 'prometheus/')
    sys.exit(0 if ok else 1)

Run this in your CI pipeline on every PR that touches alert configuration. It forces the conversation: if you want to add a critical alert, you have to remove or demote one first.


The Numbers That Should Scare You

Let me leave you with the cold hard data, because sometimes the numbers are what make this real:

You are not in a monitoring crisis. You are in an attention crisis. Monitoring produces signals. You need to produce alerts.


Checklist: Alert Fatigue Audit

Run through this checklist this week. Each item you can check is one fewer reason to be woken up for nothing — or worse, to miss the one that matters.

Immediate actions:

  • Run an alert volume report for the last 30 days — identify your top 5 noisiest alerts
  • Add a for: 5m clause to any alert that's missing one
  • Move any "warning" severity alert out of PagerDuty and into a Slack channel
  • Verify every critical alert has a runbook URL in its annotations
  • Delete any alert that has auto-resolved >90% of the time in the last 30 days

This sprint:

  • Reframe at least three resource-metric alerts into user-impact alerts (error rate, latency SLO, etc.)
  • Configure AlertManager group_wait and group_interval to batch related alerts
  • Establish alert ownership — every alert should have a team label with a named owner
  • Set a maximum critical alert budget per service (suggested: 5 max)
  • Add alert budget enforcement to your CI pipeline

This quarter:

  • Establish a monthly alert review ritual — kill or demote the noisiest alerts
  • Build a signal-to-noise KPI dashboard (target: >30% of fired alerts are actioned)
  • Write runbooks for every active Page-tier alert
  • Introduce dynamic baselines for high-variance metrics (CPU on batch workloads, etc.)

Ask The Guild

Community prompt: What's the worst false-alarm alert story from your team? The one that trained everyone to ignore the real thing? Share it in the Discord under #production-ready — sometimes the most valuable lessons come from the ones that hurt. And if you've found a specific rule change that dramatically cut your alert noise, post the before/after YAML. Let's build a library of anti-patterns together.

Copy A Prompt Next

Review and debug

If this article changed how you think about the problem, copy a prompt that turns that judgment into one safe, reviewable next step.

Matching public prompts

23

Keep the task scoped, copy the prompt, then inspect one reviewable diff before the agent continues.

Need the safest first move instead? Open the curated sample prompts before you browse the broader library.

Working With AI ToolsWorking With AI Tools

v0 by Vercel — UI Components From a Text Prompt

Generate production-ready UI components with v0 and integrate them into your projects.

Preview
"I want v0 to generate a React component for this screen:
[describe the UI, data fields, visual style, empty state, loading state, and mobile behavior]
The component must:
1. work in a Next.js + Tailwind project
2. be easy to wire to real data later
Production Ready

Use this production insight inside a full build sequence

Production articles show you what breaks in the real world. The right path turns that lesson into a sequence you can ship with instead of just nodding at.

Best Next Path

DevOps and Deployment

Guild Member · $29/mo

Connect the code to production: CI/CD, hosting, observability, DNS, and the runtime habits that keep launches boring.

25 lessonsIncluded with the full Guild Member library

Need the free route first?

Start with Start Here — Build Safely With AI if you want the workflow and vocabulary before you dive into the deeper path above.

T

About Tom Hundley

Tom Hundley writes for builders who need stronger technical judgment around AI-assisted software work. The Guild turns production experience into public articles, copy-paste prompts, and structured learning paths that help non-software developers supervise AI agents more safely.

Do this next

Leave this article with one concrete move. Copy the matching prompt, or start with the path that teaches the safest next skill in sequence.