Skip to content

Commit 00f0625

Browse files
manoahLinksclaude
andcommitted
feat(observability): add on-call paging receivers and synthetic WebSocket probe
Alertmanager previously named a receiver `pagerduty-critical` but pointed it at a local webhook, so nothing actually paged. Replace it with real integrations and make the WebSocket endpoint's availability directly measured rather than inferred. On-call paging (#1492): - Add `oncall-pagerduty` (Events API v2) and `oncall-opsgenie` receivers, with credentials read from files mounted at /etc/alertmanager/secrets so keys stay out of version control. Route critical alerts to PagerDuty; switching vendors is a one-line receiver change. - Leave warning and info alerts on their existing non-paging receivers. - Suppress duplicate pages three ways: group the critical route by subsystem+service instead of alertname, pin the Opsgenie alias to that same incident identity (PagerDuty derives dedup_key from the group key), and add inhibit rules so a critical alert silences same-service warnings and BackendDown silences the probe alerts it causes. Also relax the critical group_interval from 1m to 5m. Synthetic WebSocket probe (#1494): - Verify the blackbox handshake properly: assert the response echoes `Upgrade: websocket` and returns the Sec-WebSocket-Accept digest derived from the probe key, so a bare 101 from a proxy no longer counts as success. - Give the portfolio-websocket job its own 30s interval and 15s timeout instead of inheriting the 15s global scrape interval. - Label WebSocketHandshakeFailed with `subsystem: api-gateway` so it groups and inhibits alongside the other api-gateway alerts, and add WebSocketProbeStalled to catch an absent probe — otherwise a dead exporter looks healthy. Document the escalation policy, dedup mechanisms, and probe behaviour in docs/OBSERVABILITY.md. Closes #1492 Closes #1494 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 310e8df commit 00f0625

8 files changed

Lines changed: 193 additions & 12 deletions

File tree

deployment/docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ services:
191191
- observability
192192
volumes:
193193
- ./observability/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
194+
- ./observability/alertmanager/oncall-secrets:/etc/alertmanager/secrets:ro
194195
- alertmanager_data:/alertmanager
195196
command:
196197
- --config.file=/etc/alertmanager/alertmanager.yml

deployment/observability/alertmanager/alertmanager.yml

Lines changed: 72 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
global:
22
resolve_timeout: 5m
3+
pagerduty_url: https://events.pagerduty.com/v2/enqueue
4+
opsgenie_api_url: https://api.opsgenie.com/
35

46
route:
57
receiver: default
@@ -9,27 +11,37 @@ route:
911
repeat_interval: 4h
1012
routes:
1113
# ----------------------------------------------------
12-
# CRITICAL ALERTS: High-priority, aggressive paging
14+
# CRITICAL ALERTS: Page the on-call engineer
15+
#
16+
# Grouping is deliberately coarse here (subsystem + service instead of
17+
# alertname) so one underlying incident that trips several rules collapses
18+
# into a single notification group. Alertmanager derives the PagerDuty
19+
# dedup_key / Opsgenie alias from that group key, so repeated evaluations of
20+
# the same incident update the existing page instead of opening a new one.
21+
#
22+
# Switch to Opsgenie by pointing this route at `oncall-opsgenie`. Only ever
23+
# route to one vendor — listing both would page the on-call twice.
1324
# ----------------------------------------------------
1425
- match:
1526
severity: critical
16-
receiver: pagerduty-critical
27+
receiver: oncall-pagerduty
28+
group_by: ['subsystem', 'service']
1729
group_wait: 10s
18-
group_interval: 1m
30+
group_interval: 5m
1931
repeat_interval: 1h
2032
routes:
2133
- match:
2234
subsystem: portfolio-engine
23-
receiver: pagerduty-critical
35+
receiver: oncall-pagerduty
2436
- match:
2537
subsystem: api-gateway
26-
receiver: pagerduty-critical
38+
receiver: oncall-pagerduty
2739
- match:
2840
subsystem: database
29-
receiver: pagerduty-critical
41+
receiver: oncall-pagerduty
3042
- match:
3143
subsystem: system
32-
receiver: pagerduty-critical
44+
receiver: oncall-pagerduty
3345

3446
# ----------------------------------------------------
3547
# WARNING ALERTS: Asynchronous logging / Slack warning
@@ -64,15 +76,65 @@ route:
6476
group_interval: 10m
6577
repeat_interval: 24h
6678

79+
# ------------------------------------------------------------------
80+
# Suppression rules: keep one incident to one page.
81+
# ------------------------------------------------------------------
82+
inhibit_rules:
83+
# A critical alert is already paging the on-call engineer, so the warning and
84+
# info alerts describing the same failing service add no new information.
85+
- source_matchers:
86+
- severity = critical
87+
target_matchers:
88+
- severity =~ "warning|info"
89+
equal: ['subsystem', 'service']
90+
91+
# The backend process being down is the root cause of every blackbox probe
92+
# failure aimed at it. Page for BackendDown only, not for each dependent probe.
93+
- source_matchers:
94+
- alertname = BackendDown
95+
target_matchers:
96+
- alertname =~ "BackendReadinessFailed|BackendApiRootFailed|ApiDocsProbeFailed|WebSocketHandshakeFailed"
97+
6798
receivers:
6899
- name: default
69100
webhook_configs:
70101
- url: http://host.docker.internal:5001/alerts
71102
send_resolved: true
72103

73-
- name: pagerduty-critical
74-
webhook_configs:
75-
- url: http://host.docker.internal:5001/alerts/critical
104+
# On-call paging via PagerDuty Events API v2. The integration routing key is
105+
# read from a mounted secret file so it never lands in version control; see
106+
# deployment/observability/alertmanager/oncall-secrets/README.md.
107+
- name: oncall-pagerduty
108+
pagerduty_configs:
109+
- routing_key_file: /etc/alertmanager/secrets/pagerduty_routing_key
110+
severity: critical
111+
client: stellar-portfolio-rebalancer
112+
description: '{{ or .CommonLabels.subsystem "platform" }}/{{ or .CommonLabels.service "unknown" }}: {{ or .CommonAnnotations.summary .CommonLabels.alertname }}'
113+
send_resolved: true
114+
details:
115+
alertname: '{{ .CommonLabels.alertname }}'
116+
subsystem: '{{ .CommonLabels.subsystem }}'
117+
service: '{{ .CommonLabels.service }}'
118+
firing_alerts: '{{ .Alerts.Firing | len }}'
119+
description: '{{ .CommonAnnotations.description }}'
120+
runbook: 'docs/OBSERVABILITY.md#on-call-escalation-policy'
121+
122+
# Opsgenie equivalent of the receiver above. `alias` pins the deduplication
123+
# identity to the incident (subsystem + service) so Opsgenie keeps appending to
124+
# the open alert rather than creating a new one per evaluation cycle.
125+
- name: oncall-opsgenie
126+
opsgenie_configs:
127+
- api_key_file: /etc/alertmanager/secrets/opsgenie_api_key
128+
priority: P1
129+
alias: 'stellar-portfolio-{{ or .CommonLabels.subsystem "platform" }}-{{ or .CommonLabels.service "unknown" }}'
130+
message: '{{ .CommonLabels.alertname }} ({{ or .CommonLabels.subsystem "platform" }})'
131+
description: '{{ or .CommonAnnotations.description .CommonAnnotations.summary }}'
132+
source: alertmanager
133+
# Opsgenie tags are a single comma-separated string, not a YAML list.
134+
tags: 'stellar-portfolio-rebalancer,critical'
135+
responders:
136+
- name: stellar-portfolio-oncall
137+
type: team
76138
send_resolved: true
77139

78140
- name: slack-warnings
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
pagerduty_routing_key
2+
opsgenie_api_key
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Alertmanager on-call secrets
2+
3+
This directory is mounted read-only into the Alertmanager container at
4+
`/etc/alertmanager/secrets`. Alertmanager reads the paging credential from a file
5+
rather than an inline config value, so the key never enters version control.
6+
7+
Create exactly one file, matching the vendor the `severity: critical` route in
8+
`../alertmanager.yml` points at:
9+
10+
| Vendor | File name | Contents |
11+
| --------- | ----------------------- | --------------------------------------------------- |
12+
| PagerDuty | `pagerduty_routing_key` | The Events API v2 integration key for the service |
13+
| Opsgenie | `opsgenie_api_key` | The API key of an Opsgenie API integration |
14+
15+
```bash
16+
printf '%s' "$PAGERDUTY_ROUTING_KEY" > pagerduty_routing_key
17+
chmod 600 pagerduty_routing_key
18+
```
19+
20+
Using `printf` rather than `echo` avoids a trailing newline. Do not commit the
21+
resulting file — the `.gitignore` in this directory already excludes both key
22+
names.
23+
24+
The directory is named `oncall-secrets` rather than `secrets` because the
25+
repository root `.gitignore` excludes every `secrets/` directory, which would
26+
have hidden this README from review as well.
27+
28+
Until a key file exists, critical alerts will fail to deliver and Alertmanager
29+
will log a notification error. Local development that does not need paging can
30+
point the critical route at the `default` webhook receiver instead.

deployment/observability/blackbox/blackbox.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ modules:
77
method: GET
88
preferred_ip_protocol: ip4
99

10+
# Synthetic WebSocket probe. Sends a real RFC 6455 opening handshake and only
11+
# reports success when the server completes the protocol switch, so a plain
12+
# HTTP response from a partially-started backend still counts as a failure.
1013
websocket:
1114
prober: http
1215
timeout: 10s
@@ -19,4 +22,9 @@ modules:
1922
Sec-WebSocket-Version: "13"
2023
Sec-WebSocket-Key: "x3JJHMbDL1EzLkh9GBhXDw=="
2124
valid_status_codes: [101]
25+
fail_if_header_not_matches:
26+
- header: Upgrade
27+
regexp: (?i)websocket
28+
- header: Sec-WebSocket-Accept
29+
regexp: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
2230
preferred_ip_protocol: ip4

deployment/observability/prometheus/alerts.yml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,22 @@ groups:
6060
for: 5m
6161
labels:
6262
severity: critical
63+
subsystem: api-gateway
6364
service: websocket
6465
annotations:
6566
summary: Backend WebSocket handshake is failing
66-
description: The external WebSocket probe is unable to complete a handshake to the backend.
67+
description: The synthetic WebSocket probe cannot complete an RFC 6455 opening handshake against the backend. Real-time portfolio and risk pushes are not reaching connected clients. Inhibited when BackendDown is already firing.
68+
69+
- alert: WebSocketProbeStalled
70+
expr: absent(probe_success{job="portfolio-websocket", instance="http://backend:3001/"})
71+
for: 10m
72+
labels:
73+
severity: warning
74+
subsystem: api-gateway
75+
service: websocket
76+
annotations:
77+
summary: Synthetic WebSocket probe is not reporting
78+
description: Prometheus has no probe_success sample for the WebSocket endpoint, so availability is currently unmonitored. Check that the blackbox-exporter container is running and reachable.
6779

6880
- alert: Elevated5xxRate
6981
expr: sum(rate(stellar_portfolio_http_requests_total{status_code=~"5.."}[5m])) / clamp_min(sum(rate(stellar_portfolio_http_requests_total[5m])), 1) > 0.05

deployment/observability/prometheus/prometheus.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,13 @@ scrape_configs:
3838
- target_label: __address__
3939
replacement: blackbox-exporter:9115
4040

41+
# Synthetic uptime probe for the WebSocket endpoint. The backend upgrades any
42+
# non-/ws/portfolio/ path onto the robust broadcast socket, so the root URL is
43+
# the externally reachable WS entrypoint. Probed on its own 30s interval rather
44+
# than the 15s global default to keep handshake churn on the socket low.
4145
- job_name: portfolio-websocket
46+
scrape_interval: 30s
47+
scrape_timeout: 15s
4248
metrics_path: /probe
4349
params:
4450
module: [websocket]

docs/OBSERVABILITY.md

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,31 @@ The current deployment probes:
2323

2424
The blackbox configuration is stored in `deployment/observability/blackbox/blackbox.yml`, and Prometheus scrape jobs are defined in `deployment/observability/prometheus/prometheus.yml`.
2525

26+
### Synthetic WebSocket probe
27+
28+
WebSocket availability is measured directly rather than inferred from HTTP metrics. The `websocket` module in `blackbox.yml` issues a real RFC 6455 opening handshake — `Connection: Upgrade`, `Upgrade: websocket`, `Sec-WebSocket-Version: 13` and a fixed `Sec-WebSocket-Key` — and only records success when the server:
29+
30+
1. answers with status `101 Switching Protocols`,
31+
2. echoes an `Upgrade: websocket` response header, and
32+
3. returns the `Sec-WebSocket-Accept` digest derived from the probe's key.
33+
34+
Checking the accept digest matters because a `101` alone only proves something in front of the backend agreed to switch protocols. The digest is `SHA1(key + RFC 6455 GUID)` base64-encoded, so a correct value proves the peer that answered is a real WebSocket server that read the probe's key — not a proxy or load balancer echoing a status line.
35+
36+
The probe target is the backend root URL. `backend/src/index.ts` routes every upgrade request that is not under `/ws/portfolio/` onto the robust broadcast socket, so the root URL is the externally reachable WS entrypoint and needs no authentication to complete a handshake.
37+
38+
The `portfolio-websocket` scrape job runs the probe every 30s with a 15s timeout — its own interval rather than the 15s global default, to keep handshake churn on the socket low while still detecting an outage inside one alert evaluation window.
39+
40+
Failures feed the existing Prometheus/Alertmanager pipeline through two rules in `prometheus/alerts.yml`:
41+
42+
| Alert | Fires when | Severity | Route |
43+
| --- | --- | --- | --- |
44+
| `WebSocketHandshakeFailed` | `probe_success == 0` for 5m | critical | pages on-call, suppressed while `BackendDown` is firing |
45+
| `WebSocketProbeStalled` | no `probe_success` sample for 10m | warning | non-paging warnings channel |
46+
47+
`WebSocketProbeStalled` covers the blind spot where the exporter itself is down: without it, a missing probe looks identical to a healthy one.
48+
49+
To probe a deployed environment, add its public WS origin to the `portfolio-websocket` job targets. Use the `http://` or `https://` scheme (not `ws://`) — the blackbox HTTP prober performs the upgrade over an ordinary HTTP request.
50+
2651
## Backend
2752

2853
Backend observability is enabled with environment variables in [backend/.env.example](C:\Users\HP\Documents\students\drips\stellar-portfolio-rebalancer\backend.env.example).
@@ -119,6 +144,7 @@ Prometheus alerts are preconfigured for:
119144
- backend metrics endpoint down
120145
- backend readiness failures
121146
- frontend uptime failures
147+
- WebSocket handshake failures and a stalled WebSocket probe
122148
- elevated backend 5xx rate
123149
- failed rebalance queue jobs
124150
- stale Reflector price rows observed in the last 15 minutes
@@ -132,7 +158,41 @@ The backend exports dedicated price-quality metrics:
132158
- `stellar_portfolio_reflector_stale_prices_total`
133159
- `stellar_portfolio_reflector_fallback_usage_total`
134160

135-
Alertmanager ships alerts to `http://host.docker.internal:5001/alerts` by default. Replace that receiver with your Slack, PagerDuty, Opsgenie, or webhook destination before production rollout.
161+
Alertmanager ships non-critical alerts to `http://host.docker.internal:5001/alerts` by default. Replace those receivers with your Slack or webhook destination before production rollout. Critical alerts page the on-call engineer instead — see below.
162+
163+
## On-Call Escalation Policy
164+
165+
Alert routing lives in `deployment/observability/alertmanager/alertmanager.yml`. Severity decides the channel, and only `critical` wakes a human:
166+
167+
| Severity | Receiver | Channel | Group wait | Re-notify |
168+
| --- | --- | --- | --- | --- |
169+
| `critical` | `oncall-pagerduty` | PagerDuty (or Opsgenie) page | 10s | 1h |
170+
| `warning` | `slack-warnings` | Slack, no paging | 30s | 12h |
171+
| `info` | `diagnostic-logs` | Log sink, no paging | 1m | 24h |
172+
173+
### Choosing a vendor
174+
175+
Both a PagerDuty and an Opsgenie receiver are defined. The `severity: critical` route points at `oncall-pagerduty`; switch vendors by changing that route's `receiver` to `oncall-opsgenie`. **Route to one vendor only** — pointing at both would page the on-call engineer twice for every incident.
176+
177+
Credentials are read from files mounted read-only at `/etc/alertmanager/secrets`, backed by `deployment/observability/alertmanager/oncall-secrets/` on the host. See the README in that directory for the file names and setup command; the key files themselves are git-ignored. Without a key file, critical alerts fail to deliver and Alertmanager logs a notification error.
178+
179+
### Avoiding duplicate pages
180+
181+
Three mechanisms keep one incident to one page:
182+
183+
1. **Coarse grouping.** The critical route groups by `subsystem` + `service` rather than `alertname`, so several rules tripping on the same outage land in one notification.
184+
2. **Stable deduplication identity.** Alertmanager derives the PagerDuty `dedup_key` from that group key, and the Opsgenie receiver pins `alias` to `stellar-portfolio-<subsystem>-<service>`. Repeat evaluations update the open incident instead of opening a new one, and the resolved notification closes it.
185+
3. **Inhibition rules.** A firing `critical` alert suppresses `warning` and `info` alerts for the same `subsystem` + `service`. `BackendDown` additionally suppresses the blackbox probe alerts that depend on the backend (`BackendReadinessFailed`, `BackendApiRootFailed`, `ApiDocsProbeFailed`, `WebSocketHandshakeFailed`), since the process being down is their root cause.
186+
187+
### Escalation path
188+
189+
1. **0–10s** — a critical alert fires; Alertmanager holds it for `group_wait` to collect related alerts into the same page.
190+
2. **10s** — the page reaches the primary on-call engineer. The payload carries `alertname`, `subsystem`, `service`, the firing-alert count, and a link back to this document.
191+
3. **Acknowledge and triage.** Cross-check the matching Sentry release and environment tags first — that narrows the search to the exact build that produced the failure. See [TRIAGE.md](TRIAGE.md).
192+
4. **1h unacknowledged** — Alertmanager re-notifies (`repeat_interval: 1h`). Configure secondary-responder escalation in the PagerDuty/Opsgenie escalation policy itself, not here; Alertmanager only delivers the page.
193+
5. **Resolution.** Alertmanager sends a resolve notification (`send_resolved: true`) and the incident closes automatically when the underlying alert stops firing.
194+
195+
Adding a new critical alert requires `severity: critical` plus `subsystem` and `service` labels. Without those two labels the alert still pages, but it groups on its own and cannot be inhibited by a related root-cause alert.
136196

137197
### Queue Operations Dashboard
138198

0 commit comments

Comments
 (0)