Monitoring #
Logs tell what already happened. Monitoring tells what is happening right now — and warns before a problem becomes an incident. Automating monitoring setup with Ansible ensures every server is monitored immediately from the moment it’s first provisioned, with consistent configuration. This article discusses how to deploy a Prometheus + Grafana monitoring stack using Ansible, from agent installation to alert rules that can be reviewed in pull requests.
The important difference with the logging article: logging is an immutable event-level record, monitoring is a stream of aggregated numeric metrics. Both complement each other — logs answer “why did the CPU go up?”, monitoring answers “which host’s CPU went up?”. This article focuses on monitoring; for logs see the logging article, and for how to collect them from many services see the metric-collection article.
Anatomy of the Prometheus Stack #
Prometheus isn’t just a time-series database — it’s a complete ecosystem with components that have specific roles. Understanding this architecture helps us debug problems and design high availability:
flowchart LR
A["Node Exporter<br/>:9100"] -->|"scrape HTTP"| P["Prometheus<br/>:9090"]
B["App Exporter<br/>:9090+"] -->|"scrape HTTP"| P
C["Pushgateway<br/>:9091"] -->|"push"| P
P -->|"query PromQL"| AM["Alertmanager<br/>:9093"]
P -->|"remote_write"| R["Remote Storage<br/>Thanos / Cortex"]
P -->|"datasource"| G["Grafana<br/>:3000"]
AM -->|"route"| SL["Slack / PagerDuty / Email"]
G -->|"dashboard"| U["SRE / Developer"]
style A stroke:#b45309,stroke-width:2px
style B stroke:#b45309,stroke-width:2px
style C stroke:#b45309,stroke-width:2px
style P stroke:#1d4ed8,stroke-width:2px
style AM stroke:#be185d,stroke-width:2px
style G stroke:#15803d,stroke-width:2px
style U stroke:#7e22ce,stroke-width:2pxThe important data flow direction: Prometheus pulls (scrapes) metrics from targets, the targets don’t send (push) to Prometheus. Exception: Pushgateway is used for short-running batch jobs that can’t be scraped.
Prometheus’ pull model has operational consequences we need to understand: if a target behind a firewall isn’t reachable from Prometheus, its metrics won’t come in. For targets in another VPC or behind NAT, use federation orexternal_labelsrelabeling during scrape. For microservices in Kubernetes, service discovery via theprometheus.io/scrapeannotation is more scalable than static config.
The Four Prometheus Metric Types #
Before writing alert rules or dashboards, understand first the four metric types Prometheus can store. This understanding determines function and storage:
| Type | Characteristics | Example Use Case | Function in PromQL |
|---|---|---|---|
| Counter | Only increases, resets to 0 on restart | Total HTTP requests, bytes sent | rate(), increase() |
| Gauge | Arbitrary value, can go up/down | CPU usage, memory used, queue depth | used directly, or avg_over_time() |
| Histogram | Value distribution in buckets | Request latency, response size | histogram_quantile() |
| Summary | Like histogram, quantile computed client-side | SLA latency tracking | direct quantile |
For 90% of use cases, Counter and Gauge are enough. Use histograms when we need p99 latency or distributions. Summaries are rarely used because the quantile is computed client-side — we lose cross-instance aggregation flexibility.
Installing Node Exporter #
Node Exporter is the agent exposing system metrics (CPU, memory, disk, network) in Prometheus format. It must be installed on all production servers, ideally as part of a common role so every new server is automatically monitored:
# roles/node-exporter/tasks/main.yml
---
- name: Create the node_exporter user
user:
name: node_exporter
system: true
shell: /usr/sbin/nologin
home: /var/lib/node_exporter
create_home: false
- name: Download Node Exporter
get_url:
url: >
https://github.com/prometheus/node_exporter/releases/download/
v{{ node_exporter_version }}/node_exporter-{{ node_exporter_version }}.linux-amd64.tar.gz
dest: /tmp/node_exporter.tar.gz
checksum: "sha256:{{ node_exporter_checksum }}"
- name: Extract Node Exporter
unarchive:
src: /tmp/node_exporter.tar.gz
dest: /tmp/
remote_src: true
- name: Install the Node Exporter binary
copy:
src: "/tmp/node_exporter-{{ node_exporter_version }}.linux-amd64/node_exporter"
dest: /usr/local/bin/node_exporter
owner: root
group: root
mode: '0755'
remote_src: true
- name: Deploy the systemd unit file
template:
src: node_exporter.service.j2
dest: /etc/systemd/system/node_exporter.service
notify:
- Reload systemd
- Restart node_exporter
- name: Run Node Exporter
systemd:
name: node_exporter
state: started
enabled: true
{# roles/node-exporter/templates/node_exporter.service.j2 #}
[Unit]
Description=Prometheus Node Exporter
Documentation=https://prometheus.io/docs/guides/node-exporter/
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=node_exporter
Group=node_exporter
ExecStart=/usr/local/bin/node_exporter \
--web.listen-address=:9100 \
--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc|var/lib/docker/.+)($$|/) \
--collector.netclass.ignored-devices=^(veth.*|docker.*|br-.*) \
--collector.netdev.device-exclude=^(veth.*|docker.*|br-.*) \
--no-collector.wifi
Restart=always
RestartSec=5
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
Notice the --collector.netclass.ignored-devices=^(veth.*|docker.*|br-.*) flag — without it, the node_network_* metrics on Docker servers get bombarded with metrics from virtual container interfaces, making physical interface monitoring difficult. Choose the collectors we need; the default Node Exporter enables all collectors, some of which are noisy or irrelevant.
Prometheus Configuration with Inventory-Driven Targets #
Prometheus scrape targets must be able to change over time — new servers added, old servers decommissioned, services scaled. Jinja2 templates that read the inventory automatically adapt — no need to manually edit at deployment time:
# roles/prometheus/tasks/main.yml
---
- name: Deploy the Prometheus configuration
template:
src: prometheus.yml.j2
dest: /etc/prometheus/prometheus.yml
owner: prometheus
group: prometheus
mode: '0644'
validate: promtool check config %s
notify: Reload Prometheus
{# roles/prometheus/templates/prometheus.yml.j2 #}
global:
scrape_interval: {{ prometheus_scrape_interval | default('15s') }}
evaluation_interval: {{ prometheus_evaluation_interval | default('15s') }}
external_labels:
cluster: {{ cluster_name }}
environment: {{ env }}
query_log_file: /var/log/prometheus/queries.log
alerting:
alertmanagers:
- static_configs:
- targets:
- alertmanager:9093
rule_files:
- /etc/prometheus/rules/*.yml
scrape_configs:
# Prometheus scraping itself
- job_name: prometheus
static_configs:
- targets: ['localhost:9090']
# Node Exporter from all servers in the inventory
- job_name: node_exporter
static_configs:
{% for host in groups['all'] %}
- targets: ['{{ hostvars[host]['ansible_default_ipv4']['address'] }}:9100']
labels:
hostname: {{ host }}
environment: {{ env }}
os: {{ hostvars[host].get('ansible_os_family', 'unknown') | lower }}
{% endfor %}
relabel_configs:
- source_labels: [__address__]
target_label: __address__
replacement: '${1}:9100'
# Application metrics if available
- job_name: {{ app_name }}
static_configs:
{% for host in groups['appservers'] %}
- targets: ['{{ hostvars[host]['ansible_default_ipv4']['address'] }}:{{ app_metrics_port | default(9090) }}']
labels:
hostname: {{ host }}
tier: application
{% endfor %}
metrics_path: /metrics
The validate: promtool check config %s parameter is mandatory — it runs promtool on the remote host to validate config syntax and structure before the file is overwritten. Without validation, a wrong configuration makes Prometheus fail to reload and we lose monitoring without knowing the cause.
Anti-Pattern Pair: Hardcoded Targets vs Inventory-Driven #
{# ANTI-PATTERN: targets hardcoded in the config file, must be manually edited when servers are added #}
{# roles/prometheus/templates/prometheus.yml.j2 #}
scrape_configs:
- job_name: node_exporter
static_configs:
- targets:
- '10.0.1.10:9100'
- '10.0.1.11:9100'
- '10.0.1.12:9100'
labels:
environment: production
# Problem: every new server must be manually added to this file, then committed,
# then the playbook run. During auto-scaling (5 servers at once),
# targets will lag behind and new servers won't be monitored.
{# CORRECT: read from the Ansible inventory dynamically #}
{# roles/prometheus/templates/prometheus.yml.j2 #}
scrape_configs:
- job_name: node_exporter
static_configs:
{% for host in groups['all'] %}
- targets: ['{{ hostvars[host]['ansible_default_ipv4']['address'] }}:9100']
labels:
hostname: {{ host }}
environment: {{ env }}
{% endfor %}
# Result: run the playbook on a new server → automatically added to the target list.
# During auto-scaling, re-run the playbook after the instance is ready.
# No file editing or committing needed.
Alert Rules as Code #
Alert rules define the conditions that trigger notifications. Storing them as files managed by Ansible ensures the same alerts apply in all environments and can be reviewed via pull requests before being deployed to production:
# roles/prometheus/tasks/alert-rules.yml
---
- name: Create the rules directory
file:
path: /etc/prometheus/rules
state: directory
owner: prometheus
mode: '0755'
- name: Deploy alert rules
template:
src: "{{ item }}"
dest: "/etc/prometheus/rules/{{ item | basename | replace('.j2', '') }}"
owner: prometheus
mode: '0644'
with_fileglob:
- "templates/rules/*.j2"
notify: Reload Prometheus
- name: Validate the rules syntax
command: promtool check rules /etc/prometheus/rules/*.yml
register: rules_check
changed_when: false
failed_when: rules_check.rc != 0
# roles/prometheus/templates/rules/system.yml.j2
---
groups:
- name: system
rules:
- alert: HighCPUUsage
expr: 100 - (avg by(instance) (irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > {{ alert_cpu_threshold | default(85) }}
for: 5m
labels:
severity: warning
annotations:
summary: "High CPU usage on {{ '{{' }} $labels.instance {{ '}}' }}"
description: "CPU usage {{ '{{' }} $value | humanizePercentage {{ '}}' }} for the last 5 minutes"
runbook_url: "https://runbooks.example.com/cpu-high"
- alert: LowDiskSpace
expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) < {{ alert_disk_threshold | default(0.15) }}
for: 5m
labels:
severity: critical
annotations:
summary: "Disk almost full on {{ '{{' }} $labels.instance {{ '}}' }}"
description: "Disk remaining {{ '{{' }} $value | humanizePercentage {{ '}}' }} (mountpoint /)"
- alert: HostDown
expr: up == 0
for: 2m
labels:
severity: critical
annotations:
summary: "Host unresponsive: {{ '{{' }} $labels.instance {{ '}}' }}"
description: "Prometheus cannot scrape {{ '{{' }} $labels.instance {{ '}}' }} for 2 minutes"
- alert: HighMemoryUsage
expr: (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) > {{ alert_memory_threshold | default(0.90) }}
for: 10m
labels:
severity: warning
annotations:
summary: "High memory usage on {{ '{{' }} $labels.instance {{ '}}' }}"
description: "Memory usage {{ '{{' }} $value | humanizePercentage {{ '}}' }} (threshold 90%)"
The four alerts above are the baseline that must exist in all environments. Add application-specific alerts as needed. Important: every alert we create must have a runbook URL in its annotation — when an alert fires at 3 AM, the on-call team must be able to immediately get mitigation instructions without scrolling through Slack history.
Severity and Routing #
Not all alerts are equally important. Use the severity label for routing in Alertmanager — critical to PagerDuty, warning to a Slack channel, info to weekly email:
# roles/alertmanager/tasks/main.yml — routing config
- name: Deploy the Alertmanager configuration
template:
src: alertmanager.yml.j2
dest: /etc/alertmanager/alertmanager.yml
validate: amtool check-config %s
notify: Restart Alertmanager
# roles/alertmanager/templates/alertmanager.yml.j2
route:
group_by: ['alertname', 'cluster', 'service']
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receiver: 'default'
routes:
- match:
severity: critical
receiver: 'pagerduty'
group_wait: 10s
repeat_interval: 1h
- match:
severity: warning
receiver: 'slack-warn'
group_wait: 1m
repeat_interval: 4h
- match_re:
severity: ^(info|debug)$
receiver: 'null'
receivers:
- name: 'default'
slack_configs:
- channel: '#alerts-general'
send_resolved: true
- name: 'pagerduty'
pagerduty_configs:
- service_key: "{{ vault_pagerduty_service_key }}"
send_resolved: true
description: '{{ .CommonAnnotations.summary }}'
- name: 'slack-warn'
slack_configs:
- channel: '#alerts-warning'
send_resolved: true
title_link: 'https://grafana.example.com/alerting/grafana/{{ .GroupLabels.alertname }}'
- name: 'null'
Avoid alert spam. Alerts firing every 5 minutes make people ignore ALL alerts, including the important ones. Rule of thumb: every alert must be actionable (something can be done), rare (not firing daily), and specific (clear what’s wrong). If our alert fires every hour and no action is taken, the threshold is wrong or the alert is irrelevant — disable it or raise the threshold.
Setting Up Grafana with Automatic Provisioning #
Grafana set up manually through UI clicking will drift within weeks: person A adds a datasource, person B changes a dashboard, and nobody remembers the actual configuration. YAML file provisioning ensures datasources and dashboards are always identical across all environments:
# roles/grafana/tasks/main.yml
---
- name: Install Grafana
apt:
name: "grafana={{ grafana_version }}"
state: present
- name: Deploy the Grafana configuration
template:
src: grafana.ini.j2
dest: /etc/grafana/grafana.ini
owner: root
group: grafana
mode: '0640'
notify: Restart Grafana
- name: Provision the Prometheus datasource
template:
src: datasource-prometheus.yml.j2
dest: /etc/grafana/provisioning/datasources/prometheus.yml
owner: root
group: grafana
mode: '0640'
notify: Restart Grafana
- name: Provision automatic dashboards
copy:
src: "files/dashboards/{{ item }}"
dest: "/etc/grafana/provisioning/dashboards/{{ item }}"
owner: root
group: grafana
mode: '0640'
with_fileglob:
- "*.json"
notify: Restart Grafana
{# templates/datasource-prometheus.yml.j2 #}
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://{{ prometheus_host }}:9090
isDefault: true
editable: false
jsonData:
timeInterval: 15s
httpMethod: POST
manageAlerts: true
prometheusType: Prometheus
# templates/dashboards/dashboards.yml — provider config
apiVersion: 1
providers:
- name: 'default'
orgId: 1
folder: 'Production'
type: file
disableDeletion: true
updateIntervalSeconds: 30
allowUiUpdates: false
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: true
disableDeletion: true and allowUiUpdates: false prevent changes through the UI — all changes must go through Ansible. For dev/staging environments, set allowUiUpdates: true so developers can experiment.
A Healthy Monitoring Setup State #
Monitoring that’s already deployed must keep being monitored for its own condition. If monitoring goes down during an incident, we become blind. Use meta-monitoring to ensure the monitoring itself stays alive:
stateDiagram-v2
[*] --> Setup: "Ansible deploy"
Setup --> Scrape: "Prometheus scrape targets"
Scrape --> Healthy: "all targets up"
Scrape --> Degraded: "1-2 targets down"
Scrape --> Broken: ">50% targets down"
Healthy --> Scrape: "scrape interval"
Degraded --> Alerting: "Alertmanager notify"
Alerting --> Scrape: "target recovered"
Broken --> [*]: "monitoring blind"
Healthy --> [*]: "planned maintenance"Alerts for monitoring-down itself:
# roles/prometheus/templates/rules/monitoring-self.yml.j2
---
groups:
- name: monitoring-health
rules:
- alert: PrometheusTargetDown
expr: up == 0
for: 5m
labels:
severity: warning
annotations:
summary: "Prometheus cannot scrape {{ '{{' }} $labels.instance {{ '}}' }}"
description: "Job {{ '{{' }} $labels.job {{ '}}' }} target {{ '{{' }} $labels.instance {{ '}}' }} down 5 minutes"
- alert: PrometheusDown
expr: absent(up{job="prometheus"})
for: 2m
labels:
severity: critical
annotations:
summary: "Prometheus itself is unmonitored"
description: "Prometheus `up` metric is missing — Prometheus likely crashed"
- alert: AlertmanagerDown
expr: absent(alertmanager_alerts)
for: 5m
labels:
severity: critical
annotations:
summary: "Alertmanager cannot be reached by Prometheus"
- alert: TooManyTargetsDown
expr: 100 * (sum by(job) (up == 0) / count by(job) (up)) > 50
for: 5m
labels:
severity: critical
annotations:
summary: ">50% of {{ '{{' }} $labels.job {{ '}}' }} targets down"
description: "Monitoring blind for this job"
ThePrometheusDownalert is critical without a route to a specific receiver — we can do this withreceiver: pagerdutyin Alertmanager. But be careful: if Prometheus is dead, Prometheus can’t send alerts. The solution: run Alertmanager separately on a different host, and monitor Alertmanager with a health check from an external system (could also be a ping from a different host, or use a SaaS uptime monitoring service).
Relabel Config: Automatic Label Cleanup #
Prometheus metrics come with many default labels we don’t always need. relabel_configs cleans and standardizes labels before they’re stored in the TSDB. The most useful example: drop unnecessary labels and add environment:
# Add to a specific job's scrape_configs
scrape_configs:
- job_name: node_exporter
static_configs:
- targets: ['host1:9100', 'host2:9100']
relabel_configs:
# Drop the original instance label, replace with hostname
- source_labels: [__address__]
regex: '([^:]+)(:\d+)?'
target_label: hostname
replacement: '${1}'
# Drop empty labels
- action: labeldrop
regex: '__meta_kubernetes_pod_label_(?!app|name).*'
# Only scrape targets with the env label
- source_labels: [__meta_ec2_tag_Environment]
regex: 'production'
action: keep
action: keep is very useful when scrape targets are mixed and we only want a subset. Example: in a shared cluster with staging and production, keep only those with the Environment=production tag.
Decision Tree: Prometheus or Alternatives? #
The monitoring stack choice determines many downstream things. This decision tree is for initial orientation:
flowchart TD
A["Need monitoring<br/>for?"] -->|"Kubernetes-native"| K["Prometheus +<br/>Grafana + Loki"]
A -->|"Classic VM / bare metal"| B{"Metric volume<br/>per second?"}
B -->|"< 1M"| E["Prometheus<br/>self-hosted"]
B -->|"1-10M"| F["Prometheus +<br/>Thanos / Mimir"]
B -->|"> 10M"| G["Hosted solution<br/>Datadog / Grafana Cloud"]
A -->|"Cloud-native AWS"| H["CloudWatch +<br/>managed Prometheus"]
A -->|"Strict compliance"| I["Vendor with<br/>SLA & support"]
K --> J["scrape config<br/>via annotations"]
E --> L["static or file_sd"]
F --> M["object storage<br/>S3 / GCS"]
style A stroke:#b45309,stroke-width:2px
style E stroke:#15803d,stroke-width:2px
style F stroke:#15803d,stroke-width:2px
style G stroke:#be185d,stroke-width:2px| Criteria | Prometheus Self-Hosted | Hosted (Datadog/Cloud) |
|---|---|---|
| Cost per host/month | Infrastructure only (~$5-20) | $15-30 per host |
| Custom metrics | Free, unlimited | Paid per metric |
| Configuration | YAML in Git (as code) | UI / API, drift risk |
| Operations | Team needs Prometheus expertise | Vendor handles scaling |
| Lock-in | Low (open format) | High |
| Best for | DevOps/SRE teams already using K8s | Small teams without dedicated ops |
Anti-Pattern Pair: Reload Without Validation vs With Validation #
# ANTI-PATTERN: copying the config file directly without validation
- name: Deploy the Prometheus configuration
copy:
src: prometheus.yml
dest: /etc/prometheus/prometheus.yml
notify: Reload Prometheus
# Problem: if there's a typo or structural error, Prometheus fails to reload.
# Monitoring goes down without an alert firing — because the monitoring itself is down.
# We only realize it when someone reports "Grafana dashboard is empty" some time later.
# CORRECT: validate the syntax before the file is overwritten
- name: Deploy the Prometheus configuration
template:
src: prometheus.yml.j2
dest: /etc/prometheus/prometheus.yml
validate: promtool check config %s # ✓ promtool runs remotely, returns 0 if OK
notify: Reload Prometheus
# Result: if the config is wrong, the task fails before the file is written. Prometheus
# keeps using the valid old config. We get a clear error message
# in the Ansible output: which line, which field is wrong.
promtool is Prometheus’ official CLI for config validation. Install it as a role dependency:
# roles/prometheus/tasks/dependencies.yml
- name: Download promtool
get_url:
url: "https://github.com/prometheus/prometheus/releases/download/v{{ prometheus_version }}/prometheus-{{ prometheus_version }}.linux-amd64.tar.gz"
dest: /tmp/prometheus.tar.gz
- name: Install the promtool binary
unarchive:
src: /tmp/prometheus.tar.gz
dest: /usr/local/bin/
include: ['promtool']
extra_opts: ['--strip-components=1']
remote_src: true
creates: /usr/local/bin/promtool
Anti-Pattern Pair: Same Threshold for All Hosts vs Role-Based Thresholds #
# ANTI-PATTERN: a single threshold for all servers
- alert: HighCPUUsage
expr: 100 - (avg by(instance) (irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 85
for: 5m
# Problem: database servers that are genuinely CPU-intensive for legitimate queries
# will trigger an alert every 5 minutes. The alert becomes noise and tends to be ignored.
# CORRECT: different thresholds per role
- alert: HighCPUUsageDatabase
expr: |
100 - (avg by(instance) (irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 90
for: 10m
labels:
role: database
annotations:
summary: "DB server high CPU: {{ '{{' }} $labels.instance {{ '}}' }}"
- alert: HighCPUUsageApp
expr: |
100 - (avg by(instance) (irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 75
for: 5m
labels:
role: application
annotations:
summary: "App server high CPU: {{ '{{' }} $labels.instance {{ '}}' }}"
# Result: alerts specific to role, realistic thresholds for each
# workload. DB alerts fire at 90% (very high), app alerts at
# 75% (stricter because apps usually aren't CPU-bound).
A more scalable approach: separate alert definitions into per-role files (rules/database.yml, rules/application.yml, rules/worker.yml) and load them via a rule_files glob in the main config. Thresholds can differ, and when adding a new service just add a file.
Connecting Monitoring with Logging #
Monitoring tells “high CPU on host X”. Logging tells “why is CPU high on host X”. Both are most powerful when combined in the same dashboard. The logging article already explained the Loki setup; here we’ll see how to unify labels:
# roles/prometheus/tasks/integration.yml
---
- name: Ensure Prometheus labels are consistent with Loki
lineinfile:
path: /etc/prometheus/prometheus.yml
regexp: '^ environment:'
line: " environment: {{ env }}"
insertbefore: '^scrape_configs:'
notify: Reload Prometheus
Grafana can display log panels (Loki) and metric panels (Prometheus) in one dashboard. Just click an anomaly point on the CPU graph to directly see the error logs on the same host with automatic filters.
Summary #
- Node Exporter on every managed node exposes system metrics — install it on all servers as part of a
commonrole, not as a separate step.- Prometheus config templates use
hostvarsandgroupsto automatically add all servers in the inventory as scrape targets — no manual editing needed when new servers are added.validate: promtool check config %sto validate the Prometheus configuration before saving — a wrong configuration can make Prometheus fail to reload without any alert firing.- Alert rules as template files managed by Ansible are monitoring as code — reviewable in Git, consistent across all environments, and rollbackable via
git revert.- Grafana provisioning via YAML files allows datasources and dashboards to be configured automatically when Grafana restarts — no manual UI clicking that’s prone to drift.
- Pin versions of Prometheus, Node Exporter, and Grafana in
defaults/main.yml— uncontrolled minor updates can break dashboards or change metric formats.- Alerts must have a
runbook_urlin the annotation — when an alert fires, the on-call person needs a direct link to mitigation instructions, not to scroll through Slack history.- Monitor the monitoring itself:
PrometheusDown,AlertmanagerDown, andTooManyTargetsDownalerts ensure we know when blind spots occur.