diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..f336a9fbd --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +## Goal + + +## Changes +- + +## Testing + + +## Checklist +- [ ] Title is a clear sentence (≤ 70 chars) +- [ ] Commits are signed (`git log --show-signature`) +- [ ] `submissions/labN.md` updated diff --git a/.gitignore b/.gitignore index 1c0a1e94b..966b15200 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,5 @@ Thumbs.db # *.sbom.cdx.json, zap-*.html/json, trivy-*.txt (Lab 9 scan evidence) # flake.nix, flake.lock (Lab 11) # wasm/main.go, spin.toml, go.sum (Lab 12) +data/ +app/data/ diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 000000000..7f43d41ae --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,24 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.24.6-bookworm AS builder +WORKDIR /src +COPY go.mod go.su[m] ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build \ + -trimpath \ + -ldflags='-s -w' \ + -o /out/quicknotes . +RUN mkdir -p /data-empty + +FROM busybox:1.37-uclibc AS busybox + +FROM gcr.io/distroless/static:nonroot +WORKDIR /app +COPY --from=builder /out/quicknotes /app/quicknotes +COPY --from=builder /src/seed.json /app/seed.json +COPY --from=busybox /bin/wget /bin/wget +COPY --from=builder --chown=65532:65532 /data-empty /data +USER nonroot:nonroot +EXPOSE 8080 +ENTRYPOINT ["/app/quicknotes"] diff --git a/app/handlers.go b/app/handlers.go index c534979c5..8fd457568 100644 --- a/app/handlers.go +++ b/app/handlers.go @@ -50,6 +50,9 @@ func (sw *statusWriter) WriteHeader(code int) { func (s *Server) wrap(h http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { sw := &statusWriter{ResponseWriter: w, code: 200} + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cross-Origin-Resource-Policy", "same-origin") + w.Header().Set("Cache-Control", "no-store") h(sw, r) s.requestsTotal.Add(1) if c, ok := s.requestsByCode[sw.code]; ok { diff --git a/app/handlers_test.go b/app/handlers_test.go index 9dff2e3e5..461b348ba 100644 --- a/app/handlers_test.go +++ b/app/handlers_test.go @@ -130,4 +130,3 @@ func TestMetrics_ExposesPrometheusFormat(t *testing.T) { } } } - diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 000000000..f03d2da38 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,56 @@ +services: + quicknotes: + build: ./app + image: quicknotes:lab6 + ports: + - "8080:8080" + environment: + ADDR: ":8080" + DATA_PATH: /data/notes.json + SEED_PATH: /app/seed.json + healthcheck: + test: ["CMD", "/bin/wget", "-q", "-O", "-", "http://127.0.0.1:8080/health"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + volumes: + - quicknotes-data:/data + restart: unless-stopped + + prometheus: + image: prom/prometheus:v3.7.3 + volumes: + - ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./monitoring/prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro + ports: + - "9090:9090" + depends_on: + quicknotes: + condition: service_healthy + restart: unless-stopped + + grafana: + image: grafana/grafana:13.1.2 + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: Qn-Lab6-Gf-9x2v + volumes: + - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro + - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro + ports: + - "3000:3000" + depends_on: + - prometheus + restart: unless-stopped + + +volumes: + quicknotes-data: diff --git a/docs/runbook/high-error-rate.md b/docs/runbook/high-error-rate.md new file mode 100644 index 000000000..08860b7aa --- /dev/null +++ b/docs/runbook/high-error-rate.md @@ -0,0 +1,61 @@ +# Runbook — HighErrorRate + +**Alert:** `HighErrorRate` +**Severity:** `page` +**Expression:** 4xx+5xx ratio > 5% sustained for 5 minutes + +--- + +## What this alert means + +More than one in twenty requests to QuickNotes is failing, and has been for at +least five minutes — users are being turned away right now. + +--- + +## Triage steps + +1. **Confirm the service is up at all.** +A non-200 or a container not in `healthy` state means this is an outage, not + an error-rate problem — skip to Mitigation 1. + +2. **Find out which status code dominates.** Open the Errors panel on the + *QuickNotes — Golden Signals* dashboard, or query Prometheus directly: +4xx means clients are sending bad requests — a broken caller, a bad deploy on + their side, or someone probing. 5xx means QuickNotes itself is failing. + +3. **Check whether a deploy correlates with the onset.** Compare the time the + ratio started climbing against the container start time: +If the error onset lines up with a restart, treat the deploy as the cause + until proven otherwise. + +4. **Check disk and the data volume.** The store writes to `/data`; a full or + read-only volume produces 5xx on every write: +--- + +## Mitigations + +1. **Roll back to the previous image.** Fastest fix when the onset correlates + with a deploy. Stop the current container first — starting a second instance + on the same port fails with `bind: address already in use`: +Verify with `curl -s http://localhost:8080/health` before declaring recovery. + +2. **Restart the container.** Clears wedged state such as an exhausted + connection pool or a stuck file handle. Data survives — it lives in the named + volume, not the container: +3. **If the errors are 4xx from a single caller**, the application is behaving + correctly and the fix is upstream. Rate-limit or block that client at the + proxy rather than changing QuickNotes. + +--- + +## Post-incident + +Write a blameless postmortem covering: timeline (first bad request → alert fired +→ mitigation → recovery), user impact in requests and minutes, root cause, and +the specific change that prevents recurrence. Follow the structure used in +`submissions/lab4.md` §2.4 — summary, timeline, what went wrong, why it was hard +to see, what prevents it. + +Then check this alert itself: if it fired and no user was actually affected, +that is a tuning bug, and it belongs in the postmortem's action items. diff --git a/evidence/lab8/lab8-alert-firing.png b/evidence/lab8/lab8-alert-firing.png new file mode 100644 index 000000000..858dd7188 Binary files /dev/null and b/evidence/lab8/lab8-alert-firing.png differ diff --git a/evidence/lab8/lab8-dashboard.png b/evidence/lab8/lab8-dashboard.png new file mode 100644 index 000000000..911fe64ad Binary files /dev/null and b/evidence/lab8/lab8-dashboard.png differ diff --git a/evidence/lab8/prometheus-state.txt b/evidence/lab8/prometheus-state.txt new file mode 100644 index 000000000..049a035b0 --- /dev/null +++ b/evidence/lab8/prometheus-state.txt @@ -0,0 +1,21 @@ +$ curl -s http://localhost:9090/api/v1/targets | jq ... +{ + "job": "quicknotes", + "health": "up", + "scrapeUrl": "http://quicknotes:8080/metrics" +} + +$ curl -s http://localhost:9090/api/v1/rules | jq ... +{ + "name": "HighErrorRate", + "state": "firing", + "duration": 300, + "labels": { + "severity": "page" + }, + "activeAt": "2026-08-06T17:28:12.464142167Z", + "value": "4.7951176983435045e-01" +} + +$ error ratio at firing time +0.47951176983435045 diff --git a/monitoring/grafana/dashboards/golden-signals.json b/monitoring/grafana/dashboards/golden-signals.json new file mode 100644 index 000000000..abe119eb2 --- /dev/null +++ b/monitoring/grafana/dashboards/golden-signals.json @@ -0,0 +1,105 @@ +{ + "uid": "quicknotes-golden", + "title": "QuickNotes — Golden Signals", + "tags": ["quicknotes", "sre"], + "timezone": "browser", + "schemaVersion": 39, + "version": 1, + "refresh": "10s", + "time": { "from": "now-30m", "to": "now" }, + "panels": [ + { + "id": 1, + "type": "timeseries", + "title": "Latency (proxy: scrape duration)", + "description": "QuickNotes exposes no request-duration histogram, so true per-request latency is unavailable. This panel shows Prometheus scrape duration for the target as the closest available proxy for how long the app takes to answer an HTTP request. See submissions/lab8.md for why this substitution was necessary.", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "fieldConfig": { + "defaults": { + "unit": "s", + "custom": { "lineWidth": 2, "fillOpacity": 10 } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "scrape_duration_seconds{job=\"quicknotes\"}", + "legendFormat": "scrape duration" + } + ] + }, + { + "id": 2, + "type": "timeseries", + "title": "Traffic (requests/sec)", + "description": "rate() of the total HTTP request counter over a 5m window.", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "fieldConfig": { + "defaults": { + "unit": "reqps", + "custom": { "lineWidth": 2, "fillOpacity": 10 } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "rate(quicknotes_http_requests_total[5m])", + "legendFormat": "requests/sec" + } + ] + }, + { + "id": 3, + "type": "timeseries", + "title": "Errors (4xx + 5xx ratio)", + "description": "Ratio of 4xx and 5xx responses to all responses. This is the expression the alert rule fires on.", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "min": 0, + "max": 1, + "custom": { "lineWidth": 2, "fillOpacity": 10 }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "red", "value": 0.05 } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "sum(rate(quicknotes_http_responses_by_code_total{code=~\"4..|5..\"}[5m])) / clamp_min(sum(rate(quicknotes_http_responses_by_code_total[5m])), 0.001)", + "legendFormat": "error ratio" + } + ] + }, + { + "id": 4, + "type": "timeseries", + "title": "Saturation (notes stored)", + "description": "quicknotes_notes_total gauge — the store is in-process and file-backed, so the number of stored notes is the closest available saturation signal.", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { "lineWidth": 2, "fillOpacity": 10 } + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "expr": "quicknotes_notes_total", + "legendFormat": "notes stored" + } + ] + } + ] +} diff --git a/monitoring/grafana/provisioning/dashboards/dashboard.yml b/monitoring/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 000000000..f5d651502 --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: golden-signals + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + options: + path: /var/lib/grafana/dashboards diff --git a/monitoring/grafana/provisioning/datasources/datasource.yml b/monitoring/grafana/provisioning/datasources/datasource.yml new file mode 100644 index 000000000..86fd3465e --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/datasource.yml @@ -0,0 +1,8 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true diff --git a/monitoring/prometheus/alerts.yml b/monitoring/prometheus/alerts.yml new file mode 100644 index 000000000..abf3f747c --- /dev/null +++ b/monitoring/prometheus/alerts.yml @@ -0,0 +1,16 @@ +groups: + - name: quicknotes + rules: + - alert: HighErrorRate + expr: | + sum(rate(quicknotes_http_responses_by_code_total{code=~"4..|5.."}[5m])) + / + clamp_min(sum(rate(quicknotes_http_responses_by_code_total[5m])), 0.001) + > 0.05 + for: 5m + labels: + severity: page + annotations: + summary: "QuickNotes error ratio above 5% for 5 minutes" + description: "Error ratio is {{ $value | humanizePercentage }} over the last 5 minutes." + runbook_url: "https://github.com/HNS2112/DevOps-Intro/blob/feature/lab8/docs/runbook/high-error-rate.md" diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml new file mode 100644 index 000000000..182182d74 --- /dev/null +++ b/monitoring/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 15s + +rule_files: + - /etc/prometheus/alerts.yml + +scrape_configs: + - job_name: quicknotes + static_configs: + - targets: ['quicknotes:8080'] diff --git a/submissions/lab8.md b/submissions/lab8.md new file mode 100644 index 000000000..8265309dc --- /dev/null +++ b/submissions/lab8.md @@ -0,0 +1,408 @@ +# Lab 8 — SRE & Monitoring: Golden Signals Dashboard + One Good Alert + +**Author:** HNS ([@HNS2112](https://github.com/HNS2112)) +**Date:** 6 August 2026 +**Environment:** Ubuntu 24.04, Docker 29.1.3, Prometheus v3.7.3, Grafana 13.1.2 + +Raw output is committed under `evidence/lab8/`; configuration under `monitoring/`. + +--- + +## Task 1 — Prometheus + Grafana with a Provisioned Dashboard + +### 1.1 Layout + +``` +monitoring/ +├── prometheus/ +│ ├── prometheus.yml +│ └── alerts.yml +└── grafana/ + ├── provisioning/ + │ ├── datasources/datasource.yml + │ └── dashboards/dashboard.yml + └── dashboards/golden-signals.json +``` + +### 1.2 Prometheus config + +```yaml +global: + scrape_interval: 15s + +rule_files: + - /etc/prometheus/alerts.yml + +scrape_configs: + - job_name: quicknotes + static_configs: + - targets: ['quicknotes:8080'] +``` + +The target is `quicknotes:8080` — the Compose *service name*, not `localhost`. +Inside the Compose network Docker's embedded DNS resolves service names to +container IPs. `localhost` inside the Prometheus container would mean Prometheus +itself. + +### 1.3 Grafana provisioning + +**Data source** (`provisioning/datasources/datasource.yml`): + +```yaml +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true +``` + +**Dashboard provider** (`provisioning/dashboards/dashboard.yml`): + +```yaml +apiVersion: 1 + +providers: + - name: golden-signals + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + options: + path: /var/lib/grafana/dashboards +``` + +The provider's `path` must match where the JSON is mounted in the Compose file — +this is the pairing the lab's pitfalls list calls out, and getting it wrong +produces a Grafana that starts fine and shows nothing. + +### 1.4 The four panels + +QuickNotes exposes these metrics: + +``` +quicknotes_notes_total gauge +quicknotes_notes_created_total counter +quicknotes_notes_deleted_total counter +quicknotes_http_requests_total counter +quicknotes_http_responses_by_code_total counter (label: code) +``` + +| Signal | Query | +|---|---| +| Latency | `scrape_duration_seconds{job="quicknotes"}` | +| Traffic | `rate(quicknotes_http_requests_total[5m])` | +| Errors | `sum(rate(quicknotes_http_responses_by_code_total{code=~"4..\|5.."}[5m])) / clamp_min(sum(rate(quicknotes_http_responses_by_code_total[5m])), 0.001)` | +| Saturation | `quicknotes_notes_total` | + +**On the Latency panel.** QuickNotes exposes no request-duration histogram, so +true per-request latency is not measurable from these metrics. Requirement 1.3 +allows a proxy. Rather than the suggested request-rate substitution — which +duplicates the Traffic panel and measures volume, not time — this dashboard uses +`scrape_duration_seconds`: the time Prometheus spends fetching `/metrics`, which +is at least a real measurement of how long the application takes to answer an +HTTP request. It is still a proxy, and a poor one: it measures one endpoint on a +15-second cadence and would not show a slow `POST /notes` at all. Fixing this +properly means instrumenting the application with a histogram, which is outside +this lab's scope but is the honest answer to "how do I monitor latency here". + +**On the Errors panel.** The denominator is wrapped in `clamp_min(..., 0.001)`. +Without it, a period with zero traffic divides by zero and the panel — and the +alert built on the same expression — produces `NaN` rather than `0`. The clamp +makes the idle case evaluate to zero, which is what "no errors" should look like. + +### 1.5 Compose extension + +```yaml + prometheus: + image: prom/prometheus:v3.7.3 + volumes: + - ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./monitoring/prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro + ports: + - "9090:9090" + depends_on: + quicknotes: + condition: service_healthy + restart: unless-stopped + + grafana: + image: grafana/grafana:13.1.2 + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: Qn-Lab6-Gf-9x2v + volumes: + - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro + - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro + ports: + - "3000:3000" + depends_on: + - prometheus + restart: unless-stopped +``` + +`condition: service_healthy` is where the Lab 6 healthcheck pays off, exactly as +the lab text predicted: Prometheus waits for QuickNotes to be *ready*, not merely +started, so the first scrape does not fail against a container that is still +booting. + +The admin password is set explicitly rather than left at Grafana's default. It is +a throwaway credential committed to a public repository, which is acceptable only +because this stack is local and disposable — in anything real this belongs in a +secret store, not in `compose.yaml`. + +### 1.6 Verification + +```console +$ curl -s http://localhost:9090/api/v1/targets | jq '.data.activeTargets[] | {job, health, scrapeUrl}' +{ + "job": "quicknotes", + "health": "up", + "scrapeUrl": "http://quicknotes:8080/metrics" +} +``` + +After ~700 mixed requests: + +![Golden signals dashboard](../evidence/lab8/lab8-dashboard.png) + +All four panels carry data: latency 1–3 ms, traffic peaking near 1.7 req/s, +error ratio flat at 0%, saturation at 22 notes. + +### 1.7 Design questions + +#### a) Pull vs push — which side must be reachable? + +Prometheus pulls, so **Prometheus must be able to reach QuickNotes**, not the +other way round. QuickNotes needs no knowledge of Prometheus at all: it exposes +`/metrics` and does not care who reads it. In this stack that reachability is +provided by the Compose network, where `quicknotes:8080` resolves. + +The failure mode when Prometheus cannot reach the target is specific and worth +knowing: the target goes `up == 0` and **series stop, they do not go to zero**. +Graphs show gaps, not flat lines at zero. Anything computed over that window — +including the error-ratio alert — has no data rather than good data. So "the +dashboard looks empty" is ambiguous: it means either no traffic or no scrape, and +`up` is the metric that distinguishes them. That is why `up == 0` deserves its own +alert in any real setup. + +The architectural consequence is that pull suits environments Prometheus can +reach and enumerate — a Compose network, a Kubernetes cluster — and suits +short-lived jobs and NAT'd targets badly, which is what the Pushgateway exists to +patch around. + +#### b) What breaks at `scrape_interval: 5s`? At `5m`? + +At **5s** the cost is volume and honesty. Storage and cardinality triple against +the 15s default for data that mostly repeats. Worse, it does not buy proportional +resolution: `rate()` over a 5m window still smooths across the same period, so +the extra samples change little in the graphs while tripling the write load. And +a scrape that takes longer than the interval starts overlapping itself — on this +setup scrapes take 1–3 ms, so there is headroom, but that headroom is what the +interval is really budgeting. + +At **5m** the problem is that most queries silently break. Prometheus marks a +series stale after roughly 5 minutes without a sample, so a 5m interval sits +right at the edge of staleness. `rate(...[5m])` needs at least two samples in the +window and would frequently have one, returning nothing. Alerts with `for: 5m` +would evaluate against a single data point. And detection latency becomes 5 +minutes at best: an outage starting just after a scrape is invisible until the +next one. + +The general rule the two cases show: the scrape interval sets the floor on +detection latency and the ceiling on query resolution, and every query window in +the system has to be several times larger than it. + +#### c) `rate()` vs `irate()` vs `delta()` for the Traffic panel + +**`rate()`** is correct here. It computes the per-second average increase of a +counter across the whole window, using every sample in it, and it handles counter +resets — a process restart takes the counter back to zero, and `rate()` accounts +for that rather than reporting a huge negative spike. + +**`irate()`** uses only the last two samples in the window. It reacts fast and is +useful for short, volatile spikes, but on a dashboard it produces a jagged line +that swings with individual scrapes. For "how much traffic is this service +taking", that noise is a liability — and on a graph rendered at lower resolution +than the scrape interval, `irate()` silently skips data points entirely. + +**`delta()`** is the wrong tool twice over: it is meant for gauges, not counters, +so it does not handle resets, and it returns a total change rather than a +per-second rate, which is not comparable across different window lengths. + +#### d) Why provision Grafana from files? + +Because a dashboard clicked together in the UI lives only in Grafana's own +database. `docker compose down -v` deletes it, a fresh machine does not have it, +and there is no diff to review when it changes. + +Provisioning makes the dashboard a file in the repository: it is versioned, +reviewable in a PR, reproducible on any machine that clones the repo, and +recreated automatically after the stack is destroyed. This lab is the direct +demonstration — the dashboard was never touched in the UI; it appeared because a +JSON file was mounted at the path the provider config names, and the provider +picked it up within its 10-second poll. + +The same argument that makes infrastructure-as-code worth the friction applies to +monitoring configuration, and for the same reason: the alternative is state that +exists only where someone once clicked. + +--- + +## Task 2 — One Good Alert + Runbook + +### 2.1 The alert rule + +`monitoring/prometheus/alerts.yml`: + +```yaml +groups: + - name: quicknotes + rules: + - alert: HighErrorRate + expr: | + sum(rate(quicknotes_http_responses_by_code_total{code=~"4..|5.."}[5m])) + / + clamp_min(sum(rate(quicknotes_http_responses_by_code_total[5m])), 0.001) + > 0.05 + for: 5m + labels: + severity: page + annotations: + summary: "QuickNotes error ratio above 5% for 5 minutes" + description: "Error ratio is {{ $value | humanizePercentage }} over the last 5 minutes." + runbook_url: "https://github.com/HNS2112/DevOps-Intro/blob/feature/lab8/docs/runbook/high-error-rate.md" +``` + +Requirements met: 5% threshold, `for: 5m` sustained gate, `severity: page` label, +and a `runbook_url` annotation pointing at a document in this repository. + +### 2.2 Triggering it + +A load script ran healthy traffic alongside two deliberately failing requests per +cycle — a malformed JSON `POST /notes` and a `GET /notes/99999` — producing an +error ratio near 48%. + +**The first attempt failed, instructively.** The script ran for 7 minutes but its +error traffic ended before the `for: 5m` window elapsed; the rule reached +`pending`, then dropped back to `inactive` without ever firing: + +```console +{ "name": "HighErrorRate", "state": "pending" } +... +{ "name": "HighErrorRate", "state": "inactive", "activeAt": null } +``` + +That is the sustained-breach gate doing precisely its job: a burst that stops +before five minutes is not paged on. The second run sustained the errors long +enough: + +```console +$ curl -s -G .../query --data-urlencode 'query=' +0.43706311806514775 + +$ curl -s http://localhost:9090/api/v1/rules | jq ... +{ + "name": "HighErrorRate", + "state": "firing", + "duration": 300, + "labels": { "severity": "page" }, + "activeAt": "2026-08-06T17:28:12.464142167Z", + "value": "4.7951176983435045e-01" +} +``` + +![Alert firing](../evidence/lab8/lab8-alert-firing.png) + +The full `inactive → pending → firing` transition was observed, with the value at +firing time at 47.95%. + +### 2.3 Runbook + +Full text: [`docs/runbook/high-error-rate.md`](../docs/runbook/high-error-rate.md). + +It covers the four required sections — what the alert means, four ordered triage +steps, three mitigations, and post-incident actions. Two things in it are specific +to what earlier labs uncovered rather than generic advice: the rollback step warns +to stop the old container first, because Lab 4 showed that starting a second +instance on the same port fails with `bind: address already in use`; and the +restart step notes that data survives, because Lab 6 established that state lives +in a named volume rather than the container. + +### 2.4 Design questions + +#### e) Why "sustained for 5 minutes" instead of firing immediately? + +Because a single failed request is not an incident, and paging on one destroys +the value of the page. Transient errors are normal: a client sends malformed +input, a connection drops mid-request, a container restarts during a deploy. An +alert that fires on the first bad request would page several times a day for +conditions that resolve themselves before anyone opens a laptop. + +The `for:` clause encodes the distinction between "something failed" and +"something is broken". This lab demonstrated both sides in sequence: the first +load run produced a real 40%+ error ratio, reached `pending`, and correctly never +paged because it stopped after a few minutes. The second sustained it and fired. +The same expression, two different outcomes — decided entirely by duration. + +There is a cost, and it should be named: five minutes of user-visible errors +elapse before anyone is told. For a service where that is too long, the answer is +a tighter window plus a higher threshold, not the removal of the gate. + +#### f) Symptom alerts vs cause alerts + +The rule above is a symptom alert: it fires on something users experience — +requests failing. A cause alert for QuickNotes would be something like "container +memory above 80%" or "the `/data` volume is more than 90% full". + +Cause alerts are worse for two reasons that pull in opposite directions. They +fire when nothing is wrong — memory at 85% with every request succeeding is a page +about a number, not about a problem. And they miss what they were meant to catch: +QuickNotes can fail for reasons that touch neither memory nor disk, so a wall of +green cause alerts is compatible with a completely broken service. + +A symptom alert catches every cause, including the ones nobody anticipated, +because it watches the outcome rather than a guessed-at mechanism. Cause metrics +still belong on the dashboard — they are how you *diagnose* after the symptom +alert wakes you — but they should not be what wakes you. + +#### g) A quantitative threshold for alert fatigue + +The workable measure is the **precision of the page**: of the last N pages, what +fraction corresponded to a real user-visible problem that required human action? + +A concrete line: **if more than 25% of pages turn out to be non-actionable, the +alert is broken.** One in four false pages is enough for on-call to start +assuming the next one is noise too, which is the exact failure mode that makes +alert fatigue more dangerous than under-alerting — the alert still fires, and it +is still ignored. + +Two supporting numbers make it measurable in practice. Track the share of pages +closed with no action taken; if it climbs above a quarter, the alert needs tuning +rather than the on-call needing more discipline. And track pages per on-call +shift: more than one or two routinely means the threshold is too tight or the +`for:` window too short, regardless of whether each individual page was +technically correct. + +Both are worth reviewing in the postmortem, which is why the runbook's +post-incident section asks explicitly whether this alert fired without a user +being affected. + +--- + +## Bonus Task — Synthetic Monitoring from the Outside + +Not attempted. + +--- + +## Summary + +| Task | Status | +|------|--------| +| Task 1 — Prometheus + Grafana + provisioned 4-panel dashboard | Complete | +| Task 2 — Sustained-breach alert, runbook, firing observed | Complete | +| Bonus — Checkly synthetic probe | Not attempted |