Monitoring and Alerting: Prometheus, Grafana, Golden Signals
A staff-engineer guide to Prometheus scraping, PromQL, Grafana dashboards, golden signals, and alert design that avoids fatigue.
Monitoring and Alerting Basics
Collecting metrics is the easy part — Micrometer and Actuator do most of that work for you. The hard part, and the part that actually determines whether your on-call rotation is sustainable, is deciding what to look at, what to alert on, and how to word an alert so the person paged at 3am knows what to do in the first ten seconds of reading it. This guide covers the Prometheus/Grafana stack end to end and, more importantly, the discipline of designing dashboards and alerts that surface real problems without training your team to ignore pages.
1. How Prometheus Scraping Works
Prometheus operates on a pull model: it periodically scrapes an HTTP endpoint on each target and stores whatever numeric samples it finds, each tagged with labels, as a time series. This is the opposite of most logging pipelines, which push data outward — the target doesn't send anything anywhere, it just exposes current values whenever asked.
Making a Spring Boot service scrapable
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>management.endpoints.web.exposure.include=health,prometheus
management.metrics.tags.application=${spring.application.name}# prometheus.yml
scrape_configs:
- job_name: 'spring-boot-services'
metrics_path: '/actuator/prometheus'
scrape_interval: 15s
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
action: keep
regex: true
- source_labels: [__meta_kubernetes_pod_label_app]
target_label: appIn Kubernetes, Prometheus almost never scrapes static hostnames — it uses service discovery (kubernetes_sd_configs) to automatically find pods as they're created and destroyed, filtered by annotations like prometheus.io/scrape: "true". This is why you annotate pod specs rather than hand-maintain a scrape target list.
Scrape interval trade-offs
| Interval | Resolution | Storage cost | When |
|---|---|---|---|
| 5-10s | High — catches brief spikes | High | Critical low-latency services, small fleets |
| 15-30s | Standard | Moderate | Most production services (Prometheus default is 15s scrape / evaluation) |
| 60s+ | Coarse — can miss short-lived spikes | Low | Low-priority background services, cost-sensitive at scale |
2. PromQL Basics
PromQL is Prometheus's query language for slicing and aggregating time series. A working knowledge of a handful of functions covers the vast majority of production dashboards and alerts.
# Raw counter value (rarely useful directly — counters only go up)
http_server_requests_seconds_count{uri="/orders", method="POST"}
# Per-second rate over a 5-minute window — the actual useful signal
rate(http_server_requests_seconds_count{uri="/orders"}[5m])
# Error rate as a percentage of total requests
sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m]))
/
sum(rate(http_server_requests_seconds_count[5m]))
* 100
# p99 latency from a histogram
histogram_quantile(0.99,
sum(rate(http_server_requests_seconds_bucket{uri="/orders"}[5m])) by (le)
)
# Connection pool saturation
hikaricp_connections_pending{pool="HikariPool-1"} > 0| Function | Purpose |
|---|---|
rate() | Per-second average rate of increase for a counter over a time window — always use this, never graph a raw counter |
sum() by (label) | Aggregate across instances/pods while preserving one dimension (e.g., per-endpoint) |
histogram_quantile() | Compute a percentile (p50/p95/p99) from histogram bucket data |
increase() | Total increase over a window (e.g., total errors in the last hour) |
irate() | Instantaneous rate from the last two data points — spikier, use for fast-moving graphs, not alerts |
Never graph or alert on a raw counter value — it only ever goes up (or resets to 0 on restart), so the number itself is meaningless without a rate. Always wrap counters in rate() or increase(). This is the single most common PromQL mistake for engineers new to Prometheus.
3. Grafana Dashboards
Grafana visualizes Prometheus (and other) data sources. The failure mode to avoid is the "wall of graphs" dashboard — 40 panels nobody reads during an incident because nobody can tell which ones matter in the first five seconds.
Dashboard design principles
| Principle | Why |
|---|---|
| Top row = golden signals for this service | The four panels that answer "is this service healthy right now" at a glance |
| One dashboard per service, not one giant dashboard | Faster to load, faster to reason about, ownership is clear |
| Consistent time ranges and label filters across panels | Comparing panels side by side only works if they're looking at the same window |
| Link dashboards to traces/logs | A latency panel should let you click through to the relevant traces, not just show a number |
| Use template variables for environment/region/instance | One dashboard reused across prod/staging, not copy-pasted per environment |
{
"title": "Order Service — Overview",
"templating": {
"list": [
{ "name": "environment", "type": "query", "query": "label_values(environment)" }
]
},
"panels": [
{ "title": "Request Rate", "targets": [{ "expr": "sum(rate(http_server_requests_seconds_count{application=\"order-service\", environment=\"$environment\"}[5m]))" }] },
{ "title": "Error Rate %", "targets": [{ "expr": "sum(rate(http_server_requests_seconds_count{application=\"order-service\", status=~\"5..\"}[5m])) / sum(rate(http_server_requests_seconds_count{application=\"order-service\"}[5m])) * 100" }] },
{ "title": "p99 Latency", "targets": [{ "expr": "histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{application=\"order-service\"}[5m])) by (le))" }] },
{ "title": "CPU / Memory Saturation", "targets": [{ "expr": "process_cpu_usage{application=\"order-service\"}" }] }
]
}Build one fleet-level overview dashboard and one per-service dashboard, and make sure every alert links directly to the specific dashboard panel relevant to it. An alert that says "error rate high" with no link to a dashboard sends the on-call engineer hunting for the right graph while the incident continues.
4. The Four Golden Signals
Google's SRE book distills what to monitor for any service down to four signals. If you only have bandwidth to build four dashboard panels and four alert rules, build these.
| Signal | What to actually measure | Common pitfall |
|---|---|---|
| Latency | p50/p95/p99, separately for success and error responses | Averaging in fast error responses (e.g., instant 400s) makes overall latency look artificially good |
| Traffic | Requests/sec, messages consumed/sec, active connections | Ignoring traffic shape (bursty vs steady) when setting thresholds |
| Errors | 5xx rate, but also business-logic failures that return 200 (e.g., "payment declined" wrapped in a 200 response) | Only counting HTTP status codes and missing semantic failures |
| Saturation | Connection pool pending, thread pool queue depth, CPU throttling, heap usage | Alerting on CPU % alone without checking if it's actually causing latency impact |
Always separate error-response latency from success-response latency. A service returning fast 500s during an outage can make your "average latency" dashboard look better during the incident, because failed requests return almost instantly while successful ones were slow. Measure latency conditioned on outcome, not blended across all responses.
5. Designing Alerts That Don't Cause Fatigue
The purpose of an alert is to get a human to take an action they wouldn't have taken otherwise. Every alert that pages someone with nothing actionable to do erodes trust in the entire alerting system — eventually people start silencing channels instead of reading pages, which is how real incidents get missed.
Symptom-based vs cause-based alerting
Page on symptoms, not causes. "CPU is at 85%" is not, by itself, a customer-facing problem — plenty of healthy services run hot on CPU with no latency impact. "p99 latency on the checkout endpoint exceeded 2 seconds for 5 minutes" is a symptom that directly reflects user pain. Use cause-level metrics (CPU, memory, pool saturation) for dashboards and root-cause investigation, and reserve pages for symptom-level, user-impacting thresholds.
A well-formed alert rule
# alerting-rules.yml
groups:
- name: order-service-slo
rules:
- alert: OrderServiceHighErrorRate
expr: |
sum(rate(http_server_requests_seconds_count{application="order-service", status=~"5.."}[5m]))
/
sum(rate(http_server_requests_seconds_count{application="order-service"}[5m]))
> 0.02
for: 5m
labels:
severity: page
team: orders
annotations:
summary: "order-service error rate above 2% for 5 minutes"
description: "Error rate is {{ $value | humanizePercentage }}. Check the order-service dashboard: https://grafana.internal/d/order-service"
runbook_url: "https://runbooks.internal/order-service-high-error-rate"| Element | Why it matters |
|---|---|
for: 5m | Requires the condition to hold for a sustained window, not a single noisy sample — prevents flapping on transient blips |
severity: page vs severity: ticket | Not every alert needs a human woken up at 3am; route by actual urgency |
annotations.description with the live value | The person paged shouldn't have to open a dashboard just to know how bad it is |
runbook_url | Turns "what do I do" into a link, not a memory test at 3am |
| Threshold tied to an SLO, not a guess | > 0.02 should trace back to an agreed error budget, not an arbitrary round number |
The alert severity ladder
| Severity | Response | Example |
|---|---|---|
| Page (wake someone up) | Immediate, human paged now | User-facing error rate or latency SLO breach |
| Ticket / Slack notify | Look at it during business hours | Disk usage trending toward a threshold in 3 days |
| Dashboard only | No notification, just visible | Cause-level metrics like raw CPU%, GC pause count |
Run a regular alert audit: for every page fired in the last month, ask "did this require immediate human action, and was the response documented anywhere?" Alerts that consistently get acknowledged and ignored, or that always resolve themselves before anyone acts, should be demoted to a ticket or removed entirely. An alerting system that pages for things nobody acts on trains people to ignore pages — which is worse than having no alert at all.
Routing and deduplication with Alertmanager
Prometheus fires alerts; Alertmanager decides what to do with them — grouping related firings into a single notification, deduplicating repeats, silencing known issues, and routing by team/severity. Without it, a single outage that trips ten alert rules across twenty pods pages your on-call twenty times in the same minute instead of once.
# alertmanager.yml
route:
receiver: default-slack
group_by: ["alertname", "application"]
group_wait: 30s # wait briefly to batch related alerts into one notification
group_interval: 5m # minimum time between updates to an existing group
repeat_interval: 4h # don't re-page for an already-acknowledged, still-firing alert
routes:
- matchers: ["severity=page"]
receiver: pagerduty-oncall
continue: true
- matchers: ["severity=ticket"]
receiver: jira-ticket
- matchers: ["team=orders"]
receiver: orders-slack
receivers:
- name: pagerduty-oncall
pagerduty_configs:
- routing_key: "${PAGERDUTY_ROUTING_KEY}"
- name: orders-slack
slack_configs:
- channel: "#orders-alerts"| Concept | What it does | Why it matters |
|---|---|---|
group_by | Bundles alerts sharing the same labels into one notification | 20 pods failing the same check page once, not 20 times |
group_wait | Delays the first notification briefly to catch related alerts starting together | Avoids a burst of near-duplicate pages during the opening seconds of an incident |
repeat_interval | Controls how often a still-firing alert re-notifies | Long enough to avoid re-paging every few minutes for a known, being-worked issue |
| Silences | Manually suppress a specific alert for a bounded time | Planned maintenance, known issue already being fixed, avoids needless pages |
A silence with no expiry is an alert that quietly stops working forever. Always set a bounded silence duration, and treat any silence still active after its original incident closed as a bug to fix, not a permanent alerting decision.
Multi-window, multi-burn-rate alerting (SLO-based)
Simple threshold alerts either fire too late (average over a long window smooths out real spikes) or too often (a short window catches noise). A common refinement pairs a fast, sensitive window with a slow, high-confidence window and requires both to agree:
- alert: OrderServiceErrorBudgetBurnFast
expr: |
(
sum(rate(http_server_requests_seconds_count{application="order-service", status=~"5.."}[5m]))
/ sum(rate(http_server_requests_seconds_count{application="order-service"}[5m]))
) > (14.4 * 0.001)
and
(
sum(rate(http_server_requests_seconds_count{application="order-service", status=~"5.."}[1h]))
/ sum(rate(http_server_requests_seconds_count{application="order-service"}[1h]))
) > (14.4 * 0.001)
for: 2m
labels:
severity: pageThis pattern — borrowed from Google's SRE workbook — pages fast on a genuinely severe burn rate (would exhaust a 30-day error budget in a few hours) while requiring corroboration from a longer window, which filters out single-minute noise without delaying real incidents by hours. It's more setup than a flat threshold, but it materially reduces both false pages and missed incidents for teams that track formal SLOs.
Key takeaways
- Prometheus pulls metrics on a schedule from a known endpoint (
/actuator/prometheus); Kubernetes deployments almost always use service discovery rather than static scrape targets. - Never graph or alert on a raw counter — always wrap it in
rate()orincrease(), or the number is meaningless. - Build the four golden signals — latency, traffic, errors, saturation — as the top row of every service dashboard before anything else.
- Measure latency separately for success and error responses; blended latency can look artificially good during an outage of fast failures.
- Page on symptoms (user-facing latency/error SLO breaches), not causes (CPU%, memory%) — causes belong on dashboards for root-cause work, not in pages.
- Every alert needs a
for:window to avoid flapping, a runbook link, and a live value in the description — a page with no next action is a page that trains people to ignore pages. - Regularly audit alerts for ones that fire without action or resolve themselves; demote or delete them, because alert fatigue is what causes real incidents to get missed.
Interview Questions
- How does Prometheus's pull-based scraping model differ from a push-based metrics pipeline, and what are the trade-offs?
- Why should you never alert on a raw Prometheus counter value directly?
- What are the four golden signals, and what does each one actually measure?
- Why is it important to measure latency separately for successful and failed requests?
- What's the difference between a symptom-based alert and a cause-based alert? Give an example of each.
- Why might paging on "CPU > 80%" alone be a bad alerting strategy?
- What does the
for:clause in a Prometheus alert rule do, and why does it matter? - Design an alert rule for an error-rate SLO breach. What fields would you include beyond the raw expression?
- What is a multi-window, multi-burn-rate alert, and what problem does it solve compared to a single threshold?
- How would you decide whether an alert should page someone immediately versus just create a ticket?
- What's wrong with a dashboard that has 40 panels and no clear "check this first" section?
- Explain
histogram_quantile()— what data does it need, and why can't you compute a percentile from a plain counter? - How does Kubernetes service discovery in Prometheus avoid the need to hand-maintain a list of scrape targets?
- What would you look for in a monthly alert audit, and what actions might come out of it?