Adds Monitoring Setup docs and deploy-scp troubleshooting/debug fixes
Continuous Integration / config (pull_request) Successful in 9s
Continuous Integration / backend-build (pull_request) Successful in 4m27s
Continuous Integration / vulnerability-scan (pull_request) Successful in 4m10s
Continuous Integration / backend-test (pull_request) Canceled after 0s
Continuous Integration / frontend-build (pull_request) Canceled after 0s
Continuous Integration / frontend-test (pull_request) Canceled after 0s
Continuous Integration / frontend-lint (pull_request) Canceled after 0s
Continuous Integration / publish-test (pull_request) Canceled after 0s
Continuous Integration / publish-production (pull_request) Canceled after 0s
Continuous Integration / deploy-test (pull_request) Canceled after 0s
Continuous Integration / deploy-production (pull_request) Canceled after 0s
Continuous Integration / frontend-prepare (pull_request) Canceled after 50s

Monitoring Setup: operations/plans/monitoring-setup-plan.md and
operations/monitoring/monitoring-instructions.md, covering Sentry alert
rules on the security_event tag, UptimeRobot's 6 liveness monitors, and
the two new Umami website entries for the admin SPA.

deployment-instructions.md gains a missing Observability__Environment
host var (without it, both environments would tag Sentry events as
"Production"), the nginx client_max_body_size fix for the 413 seen on
publish-test/production artifact uploads, and two troubleshooting notes
on Gitea Actions re-run behaviour: re-running deploy-test/production
alone loses the run's uploaded artifact, and re-running all jobs on an
existing (rather than a brand new) run can replay stale secrets.

deploy-scp.yaml: step names no longer show literal unresolved
${{ inputs.* }} text (Gitea doesn't interpolate that context in step
names), and a temporary debug step logs PI_MAIN_USERNAME/PASSWORD
length plus a username equality check to diagnose a persistent
Permission denied during the SSH steps, without ever logging the
secret values themselves.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FffvxxJp5wG34Ru48GBig
This commit is contained in:
2026-07-29 19:39:44 +02:00
co-authored by Claude Sonnet 5
parent d81168b7a9
commit 56f4f6fe0f
5 changed files with 382 additions and 7 deletions
@@ -0,0 +1,134 @@
# Monitoring Instructions
All three services below were built into the application during Construction (U4) — this document
only wires them to the real accounts and writes down the configuration that necessarily lives
outside this repository (Sentry alert rules, UptimeRobot monitors, Umami website entries).
**Who does what, where** — reuses the labels from `deployment-instructions.md` § "Who runs what,
where", plus the external services themselves:
- 👤 **pi-main / `gitea-workflow`** — the environment's `shared/env` file (§ 1.5 of
`deployment-instructions.md`)
- ☁️ **Gitea web UI** — repository Variables (Settings → Actions → Variables)
- ☁️ **Sentry web UI** — project settings and alert rules
- ☁️ **UptimeRobot web UI** — monitors and alert contacts
- ☁️ **Umami web UI** — website entries
## 1. Sentry
One project for the whole CMS (D-19); `Observability__Environment` (host-side, § 1.5) and
`VITE_APP_ENV` (build-time) are what tell test and production events apart in that one project —
there is no separate DSN per environment.
### 1.1 Gitea variable
☁️ **Gitea web UI** — Settings → Actions → Variables:
| Variable | Value |
|---|---|
| `VITE_SENTRY_DSN` | the project's DSN (same value used by both `publish-test` and `publish-production`) |
### 1.2 Host configuration
Already documented in `deployment-instructions.md` § 1.5 — `Observability__SentryDsn` and
`Observability__Environment` in each environment's `shared/env` file. `Observability__Environment`
**must** differ between the two files (`test` / `production`); see that section's note on why
`ASPNETCORE_ENVIRONMENT` can't be reused for this.
### 1.3 Alert rules (FR-19)
The six `security_event` tag values already emitted by `SecurityEvents.cs` — alert rules must filter
on the **tag**, not the message text (`nfr-design-patterns.md` Pattern 6):
| `security_event` tag value | Rule type | Threshold |
|---|---|---|
| `failed_login` | Count-based | > 20 events in 5 minutes |
| `authorization_denied` | Count-based | > 20 events in 5 minutes |
| `master_api_key_rejected` | Count-based | > 20 events in 5 minutes |
| `admin_bypass_rejected` | Count-based | > 20 events in 5 minutes |
| `rate_limit_triggered` | Count-based | > 20 events in 5 minutes |
| `migration_failure` | Always-fire | any occurrence (already `Critical` level — the process is exiting) |
20-in-5-minutes is a starting default (no production traffic history exists yet to tune against) —
revisit once real traffic volume is known.
☁️ **Sentry web UI** — Alerts → Create Alert Rule, once per row above:
1. Condition: `tags.security_event equals <value>`
2. For the five count-based rules: "when greater than 20 events, in 5 minutes" (or the platform's
equivalent rate-window phrasing)
3. For `migration_failure`: no count condition — fire on every matching event
4. Action: notify via whichever channel/integration alerts should reach (same destination used
elsewhere for this project is fine — nothing here requires a dedicated channel)
**Note (DEV-01, accepted deviation)**: Sentry's plan in use retains events for roughly 30 days,
short of the 90-day SECURITY-14 minimum. Already accepted as a cost decision at Requirements
Analysis — nothing to configure here, just to be aware of if investigating an old event.
## 2. UptimeRobot
Six monitors — `/health`, `/`, `/admin` × test/production (D-23). What each does and does not prove
(FR-17):
| URL | Proves | Does NOT prove |
|---|---|---|
| `https://test.slpsoftware.nl/health` | The test process is alive (liveness only, D-21) | Database connectivity, migrations applied — no DB check by design (Q17=A) |
| `https://test.slpsoftware.nl/` | Proxy Pi → pi-main routing and static hosting work for test | The API or admin SPA work at all |
| `https://test.slpsoftware.nl/admin` | The admin SPA is served and its assets aren't blocked by CSP | Login/auth actually succeeds — this only checks the shell loads |
| `https://slpsoftware.nl/health` | Same as above, production | Same as above, production |
| `https://slpsoftware.nl/` | Same as above, production | Same as above, production |
| `https://slpsoftware.nl/admin` | Same as above, production | Same as above, production |
☁️ **UptimeRobot web UI** — Add New Monitor, once per row above:
- Monitor Type: **HTTP(s)** (not Keyword/Port/Ping) — checks only the response status code (2xx/3xx),
the same liveness signal `deploy-scp.yaml`'s own `curl -f` health check already relies on
- Interval: 5 minutes is enough for all six — `/health` recovers within a process restart
(`Restart=on-failure`, `RestartSec=5` in the systemd units), no need for a tighter interval
- Alert Contact: the existing contact already configured on this account
**Free-plan note**: 6 monitors at 5-minute intervals fits comfortably within the free plan's 50-monitor
cap and 5-minute minimum interval — no paid tier needed for this feature's monitoring. One gap the
free plan leaves open: no SSL-certificate-expiry monitoring. Certbot's systemd timer renews
automatically (`deployment-instructions.md` § 1.7.1), so the risk is low, but a silently failed
renewal would only be caught reactively — once an HTTP(s) check itself starts failing — rather than
warned about in advance. Accepted as a known gap, not built around, consistent with this project's
other documented deviations (e.g. DEV-01's log-retention gap).
## 3. Umami
Reuses the existing self-hosted instance at `analytics.slpsoftware.nl` (D-24/ASM-05). This section
covers only the **admin SPA's** two website entries — the public website's Umami wiring (also
required by D-25) belongs to whichever website workspace serves `wwwroot/web/` (FR-09/FR-16), not to
this deploy pipeline.
☁️ **Umami web UI** — Websites → Add website, twice (once per environment), domain set to the
admin SPA's real URL (`test.slpsoftware.nl/admin`, `slpsoftware.nl/admin`) — note the resulting
website ID for each.
☁️ **Gitea web UI** — Settings → Actions → Variables:
| Variable | Value |
|---|---|
| `VITE_UMAMI_SCRIPT_URL` | `https://analytics.slpsoftware.nl/script.js` (shared — same instance, both environments) |
| `VITE_UMAMI_WEBSITE_ID_TEST` | the test website's ID from the step above |
| `VITE_UMAMI_WEBSITE_ID_PRODUCTION` | the production website's ID from the step above |
**These must stay in sync with two other Gitea variables that already exist from U5**
(`SECURITY_ALLOWED_SCRIPT_ORIGINS_TEST` / `_PRODUCTION`) and with the matching
`SecurityHeaders__AllowedScriptOrigins__0` / `_AllowedConnectOrigins__0` lines in each environment's
host `shared/env` file (`deployment-instructions.md` § 1.5) — all four must name the same
`https://analytics.slpsoftware.nl` origin, or either the CI gate (REF-U5-01) fails the build, or the
script loads in CI but is silently blocked by CSP on the real host (the gate cannot see the host
file, only the Gitea variable side of this).
## 4. Gitea Variables — Full Summary
For reference, every Actions variable this document and `deployment-instructions.md` § 1.9 together
require:
| Variable | Scope | Set by |
|---|---|---|
| `VITE_SENTRY_DSN` | shared | § 1.1 above |
| `VITE_UMAMI_SCRIPT_URL` | shared | § 3 above |
| `VITE_UMAMI_WEBSITE_ID_TEST` | test | § 3 above |
| `VITE_UMAMI_WEBSITE_ID_PRODUCTION` | production | § 3 above |
| `SECURITY_ALLOWED_SCRIPT_ORIGINS_TEST` / `_PRODUCTION` | per environment | already set at Deployment Setup — must match § 3's Umami origin |
| `DEPLOY_PATH_*`, `SERVICE_NAME_*`, `HEALTH_CHECK_URL_*` | per environment | `deployment-instructions.md` § 1.9 (unrelated to monitoring, listed there) |