You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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#1492Closes#1494
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
description: '{{ or .CommonLabels.subsystem "platform" }}/{{ or .CommonLabels.service "unknown" }}: {{ or .CommonAnnotations.summary .CommonLabels.alertname }}'
Copy file name to clipboardExpand all lines: deployment/observability/prometheus/alerts.yml
+13-1Lines changed: 13 additions & 1 deletion
Original file line number
Diff line number
Diff line change
@@ -60,10 +60,22 @@ groups:
60
60
for: 5m
61
61
labels:
62
62
severity: critical
63
+
subsystem: api-gateway
63
64
service: websocket
64
65
annotations:
65
66
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.
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.
Copy file name to clipboardExpand all lines: docs/OBSERVABILITY.md
+61-1Lines changed: 61 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -23,6 +23,31 @@ The current deployment probes:
23
23
24
24
The blackbox configuration is stored in `deployment/observability/blackbox/blackbox.yml`, and Prometheus scrape jobs are defined in `deployment/observability/prometheus/prometheus.yml`.
25
25
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
+
26
51
## Backend
27
52
28
53
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:
119
144
- backend metrics endpoint down
120
145
- backend readiness failures
121
146
- frontend uptime failures
147
+
- WebSocket handshake failures and a stalled WebSocket probe
122
148
- elevated backend 5xx rate
123
149
- failed rebalance queue jobs
124
150
- stale Reflector price rows observed in the last 15 minutes
@@ -132,7 +158,41 @@ The backend exports dedicated price-quality metrics:
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:
|`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.
0 commit comments