# VYNDR OPS RUNBOOK — the product watches itself (Session 8, A1 board) The in-app half (settle alarms, failure pager, quota check, box health, daily pulse) lives in `src/snapshotScheduler.js` + `src/services/opsWatch.js` + `src/services/systemHealth.js` and pushes to ntfy via `src/utils/opsNotify.js`. This runbook is the BOX-SIDE half: the monitors and hooks that catch the cases the app cannot report on itself — a dead container, a failed deploy, a dead box. Channel: `https://ntfy.sh/vyndr-pipeline-kev2026` (override with `NTFY_URL` / `NTFY_TOPIC`; both default in `opsNotify.js`). Zero out-of-pocket: ntfy.sh is free, Uptime Kuma is already on the box. --- ## 1. Phone subscription (do this first — alerts nobody reads are logs) Android / iOS: 1. Install **ntfy** (Play Store, App Store, or F-Droid). 2. Open the app → **+ Subscribe to topic**. 3. Server: `https://ntfy.sh` (default). Topic: `vyndr-pipeline-kev2026`. 4. Android: in the subscription's settings enable **Instant delivery** (foreground service) and exempt ntfy from battery optimization (Settings → Apps → ntfy → Battery → Unrestricted). Without this, Android can delay high-priority pages by hours. 5. Send yourself a probe and confirm the phone buzzes: `curl -s -d "probe" https://ntfy.sh/vyndr-pipeline-kev2026` Desktop (optional): open `https://ntfy.sh/vyndr-pipeline-kev2026` in a browser tab and allow notifications. Note: the topic name is the only secret. Anyone who knows it can read and write the channel. If it ever leaks, rotate via `NTFY_TOPIC` on the container and re-subscribe the phone. ## 2. Uptime Kuma monitors (external heartbeat — catches a dead container) Uptime Kuma already runs on the box. Add THREE monitors (**Add New Monitor** for each): | # | Name | Type | URL | Pass condition | Interval | |---|------|------|-----|----------------|----------| | 1 | `vyndr web` | HTTP(s) | `https://vyndr.app` | status 200 | 60s, retries 3 | | 2 | `vyndr api health` | HTTP(s) — **Keyword** | `https://api.vyndr.app/api/health` | keyword `healthy` present | 60s, retries 3 | | 3 | `vyndr snapshot summary` | HTTP(s) | `https://api.vyndr.app/api/snapshot/summary` | status 200 | 300s, retries 3 | Why keyword on #2: `/api/health` answers 200 `"status":"healthy"` only when Redis AND Supabase check out; degraded mode returns 503 `"degraded"`. The keyword check fails on BOTH a dead container and a degraded one. Monitor #3 is the public cache-only snapshot read — it proves the pipeline's output surface is serving, and it can never drain provider quota. Wire Kuma to the pager: 1. **Settings → Notifications → Setup Notification → ntfy**. 2. Server URL `https://ntfy.sh`, topic `vyndr-pipeline-kev2026`, priority: default 5 (Kuma sends its own priority on down events). 3. Check **Default enabled** so future monitors inherit it, then attach the notification to all three monitors above. ## 3. Coolify deploy-failure -> ntfy Coolify owns deploys, so the app can't report its own failed deploy. Two options, in order of preference: **A. Native notification channel (Coolify v4):** 1. Coolify → **Notifications** (team level). 2. If your Coolify version lists **ntfy**: server `https://ntfy.sh`, topic `vyndr-pipeline-kev2026`, enable only the **Deployment Failed** (and optionally **Deployment Success**) events. 3. If it doesn't list ntfy, use the generic **Webhook** channel pointed at `https://ntfy.sh/vyndr-pipeline-kev2026`. ntfy accepts any POST body as the message text — a JSON payload arrives readable, just ugly. **B. Per-app webhook (any Coolify version):** 1. App → **Webhooks** → add `https://ntfy.sh/vyndr-pipeline-kev2026` for the deployment-failed event. 2. Verify by triggering a deploy of a branch that fails its build; the phone should receive the payload within seconds. Either way, send one manual probe to prove the path before trusting it: `curl -s -d "coolify webhook path test" https://ntfy.sh/vyndr-pipeline-kev2026` ## 4. What the app already pages on its own (for reference — do not duplicate) | Alert | Source | Priority | |-------|--------|----------| | Snapshot success / 0-props / hard failure | `snapshotService` (S56) | default / low / high | | Missed cron slot (per-minute watchdog) | `snapshotScheduler` (S56) | high | | Settlement pass THREW (outcomes or ledger) | `snapshotScheduler` (S8) | high | | Morning settle closed with 0 settles while Postgres held pre-today pending rows | `snapshotScheduler` + `opsWatch.zeroSettleAlarm` (S8) | high | | Sport produced nothing 3 consecutive slots | `opsWatch.createFailureTracker` (S8) | high | | odds-api >= 80% quota (once per day) | `opsWatch.checkQuotaDaily` (S8) | high | | Disk > 85% or memory > 90% | `systemHealth` via daily pulse (S8) | high | | Daily pulse, 9 AM ET (13:00 UTC, `PULSE_HOUR_UTC` to move) | `snapshotScheduler` (S8) | default | Kill switches: `PIPELINE_ALERTS=0` silences everything app-side; `NODE_ENV=test` is silent by design. ## 5. Weekly 5-minute sanity pass 1. Phone got yesterday's 9 AM pulse. If not: check `ops:pulse:*` in Redis and the container log for `[opsWatch] armed`. 2. `GET /api/internal/snapshot/status` (header `x-internal-key`) shows `cron_armed: true`, fresh `last_snapshot`, `overdue: false`. 3. Uptime Kuma dashboard: three greens. 4. Disk line in the pulse trending up week over week means log rotation or Redis growth needs a look before the 85% page ever fires.