From 32e1349517527943a0d9232c7fc6a9c37d5294c4 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Wed, 7 Oct 2026 09:45:16 +0000 Subject: [PATCH] =?UTF-8?q?dev:=20Baukasten=20Git-Deploy=20und=20Betriebs?= =?UTF-8?q?=C3=BCberwachung=20f=C3=BCr=20andere=20Laravel-Projekte=20(dev/?= =?UTF-8?q?git-deploy-monitoring)=20=E2=80=94=20Anleitungen,=20Checkliste,?= =?UTF-8?q?=20Vorlagen=20f=C3=BCr=20Laravel,=20deploy.sh=20und=20Monitorin?= =?UTF-8?q?g-Server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vorlagen in einem frischen Laravel 12 eingebaut (30 Tests grün), deploy.sh gegen ein simuliertes Repository durchgespielt (Deploy, Abbruch bei Migrationen, Drift, Rollback, fehlender Worker). GlitchTip-Aufbewahrung heißt seit Version 6 GLITCHTIP_RETENTION_DAYS — in der Projekt-Doku korrigiert. Co-Authored-By: Claude Opus 5.5 --- dev/betrieb/git-deploy.md | 2 + dev/betrieb/monitoring-plan.md | 9 +- dev/git-deploy-monitoring/01-git-deploy.md | 261 ++++++++++++++++++ .../02-fehlerprotokoll-glitchtip.md | 256 +++++++++++++++++ .../03-betriebsueberwachung.md | 222 +++++++++++++++ .../04-checkliste-neues-projekt.md | 62 +++++ dev/git-deploy-monitoring/README.md | 121 ++++++++ dev/git-deploy-monitoring/vorlagen/deploy.sh | 176 ++++++++++++ .../vorlagen/env-beispiel.env | 24 ++ .../Console/Commands/MonitoringHeartbeat.php | 25 ++ .../Console/Commands/MonitoringQueueProbe.php | 50 ++++ .../app/Http/Controllers/HealthController.php | 39 +++ .../Http/Middleware/ReportErrorSignals.php | 96 +++++++ .../app/Jobs/Monitoring/QueueProbe.php | 31 +++ .../app/Services/Monitoring/HealthProbe.php | 198 +++++++++++++ .../Monitoring/ErrorReportScrubber.php | 159 +++++++++++ .../app/Support/Monitoring/ErrorSignal.php | 68 +++++ .../app/Support/Monitoring/Release.php | 36 +++ .../laravel/app/Support/OperationalAlert.php | 96 +++++++ .../vorlagen/laravel/config/monitoring.php | 73 +++++ .../vorlagen/laravel/config/sentry.php | 180 ++++++++++++ .../resources/js/error-reporting-sdk.js | 5 + .../laravel/resources/js/error-reporting.js | 128 +++++++++ .../monitoring/browser-errors.blade.php | 14 + .../Monitoring/BrowserErrorReportingTest.php | 38 +++ .../Feature/Monitoring/ErrorReportingTest.php | 95 +++++++ .../Feature/Monitoring/ErrorSignalsTest.php | 71 +++++ .../Feature/Monitoring/HealthEndpointTest.php | 91 ++++++ .../Monitoring/OperationalAlertTest.php | 82 ++++++ .../Feature/Monitoring/QueueProbeTest.php | 39 +++ .../laravel/tests/Support/FakeGlitchTip.php | 63 +++++ .../tests/Unit/Monitoring/ReleaseTest.php | 38 +++ .../vorlagen/server/Caddyfile | 14 + .../vorlagen/server/glitchtip-compose.yml | 54 ++++ .../vorlagen/server/ntfy-compose.yml | 17 ++ .../vorlagen/server/ntfy-server.yml | 13 + .../vorlagen/server/uptime-kuma-compose.yml | 12 + dev/glitch-tip/README.md | 2 +- 38 files changed, 2958 insertions(+), 2 deletions(-) create mode 100644 dev/git-deploy-monitoring/01-git-deploy.md create mode 100644 dev/git-deploy-monitoring/02-fehlerprotokoll-glitchtip.md create mode 100644 dev/git-deploy-monitoring/03-betriebsueberwachung.md create mode 100644 dev/git-deploy-monitoring/04-checkliste-neues-projekt.md create mode 100644 dev/git-deploy-monitoring/README.md create mode 100755 dev/git-deploy-monitoring/vorlagen/deploy.sh create mode 100644 dev/git-deploy-monitoring/vorlagen/env-beispiel.env create mode 100644 dev/git-deploy-monitoring/vorlagen/laravel/app/Console/Commands/MonitoringHeartbeat.php create mode 100644 dev/git-deploy-monitoring/vorlagen/laravel/app/Console/Commands/MonitoringQueueProbe.php create mode 100644 dev/git-deploy-monitoring/vorlagen/laravel/app/Http/Controllers/HealthController.php create mode 100644 dev/git-deploy-monitoring/vorlagen/laravel/app/Http/Middleware/ReportErrorSignals.php create mode 100644 dev/git-deploy-monitoring/vorlagen/laravel/app/Jobs/Monitoring/QueueProbe.php create mode 100644 dev/git-deploy-monitoring/vorlagen/laravel/app/Services/Monitoring/HealthProbe.php create mode 100644 dev/git-deploy-monitoring/vorlagen/laravel/app/Support/Monitoring/ErrorReportScrubber.php create mode 100644 dev/git-deploy-monitoring/vorlagen/laravel/app/Support/Monitoring/ErrorSignal.php create mode 100644 dev/git-deploy-monitoring/vorlagen/laravel/app/Support/Monitoring/Release.php create mode 100644 dev/git-deploy-monitoring/vorlagen/laravel/app/Support/OperationalAlert.php create mode 100644 dev/git-deploy-monitoring/vorlagen/laravel/config/monitoring.php create mode 100644 dev/git-deploy-monitoring/vorlagen/laravel/config/sentry.php create mode 100644 dev/git-deploy-monitoring/vorlagen/laravel/resources/js/error-reporting-sdk.js create mode 100644 dev/git-deploy-monitoring/vorlagen/laravel/resources/js/error-reporting.js create mode 100644 dev/git-deploy-monitoring/vorlagen/laravel/resources/views/components/monitoring/browser-errors.blade.php create mode 100644 dev/git-deploy-monitoring/vorlagen/laravel/tests/Feature/Monitoring/BrowserErrorReportingTest.php create mode 100644 dev/git-deploy-monitoring/vorlagen/laravel/tests/Feature/Monitoring/ErrorReportingTest.php create mode 100644 dev/git-deploy-monitoring/vorlagen/laravel/tests/Feature/Monitoring/ErrorSignalsTest.php create mode 100644 dev/git-deploy-monitoring/vorlagen/laravel/tests/Feature/Monitoring/HealthEndpointTest.php create mode 100644 dev/git-deploy-monitoring/vorlagen/laravel/tests/Feature/Monitoring/OperationalAlertTest.php create mode 100644 dev/git-deploy-monitoring/vorlagen/laravel/tests/Feature/Monitoring/QueueProbeTest.php create mode 100644 dev/git-deploy-monitoring/vorlagen/laravel/tests/Support/FakeGlitchTip.php create mode 100644 dev/git-deploy-monitoring/vorlagen/laravel/tests/Unit/Monitoring/ReleaseTest.php create mode 100644 dev/git-deploy-monitoring/vorlagen/server/Caddyfile create mode 100644 dev/git-deploy-monitoring/vorlagen/server/glitchtip-compose.yml create mode 100644 dev/git-deploy-monitoring/vorlagen/server/ntfy-compose.yml create mode 100644 dev/git-deploy-monitoring/vorlagen/server/ntfy-server.yml create mode 100644 dev/git-deploy-monitoring/vorlagen/server/uptime-kuma-compose.yml diff --git a/dev/betrieb/git-deploy.md b/dev/betrieb/git-deploy.md index 841f56b9..aef22126 100644 --- a/dev/betrieb/git-deploy.md +++ b/dev/betrieb/git-deploy.md @@ -1,5 +1,7 @@ # Git-Deploy +> Allgemeine, projektunabhängige Fassung mit Vorlagen: [`../git-deploy-monitoring/`](../git-deploy-monitoring/README.md). + **Stand:** 06.10.2026 · **Status:** umgestellt am 06.10.2026, erster Git-Deploy `v1.1.3` (Protokoll unten) Bis `v1.1.0` gingen Deploys als Paket per SFTP auf den Server diff --git a/dev/betrieb/monitoring-plan.md b/dev/betrieb/monitoring-plan.md index 8480219f..10731dc7 100644 --- a/dev/betrieb/monitoring-plan.md +++ b/dev/betrieb/monitoring-plan.md @@ -82,7 +82,7 @@ Ausgeführt mit Deploy v1.1.0 — vollständiger Ablauf in [`deploy-v1.1.0.md`]( **Infrastruktur (einmalig)** 1. GlitchTip auf sys.adametz.media (Docker Compose: web, worker, Postgres, - Redis; `GLITCHTIP_MAX_EVENT_LIFE_DAYS=90`). Zwei Projekte anlegen: + Redis; `GLITCHTIP_RETENTION_DAYS=90`, vor GlitchTip 6 `GLITCHTIP_MAX_EVENT_LIFE_DAYS`). Zwei Projekte anlegen: `pressekonto-server` (PHP/Laravel) und `pressekonto-browser` (JavaScript). Alert-Regel je Projekt: Mail an das Ops-Postfach bei neuem Fehlertyp. 2. ntfy: Server (ntfy.sh oder selbst gehostet), Topic mit Zugriffsschutz, @@ -307,3 +307,10 @@ Serialisierung ohne `$viaSyndication`) — entfernt, nicht nachgesendet. - **Worker-Prüfung im Deploy** (`monitoring:queue-probe`, Ende von `deploy.sh`, Exit 3 ohne Worker). - **Runbook:** kein pauschales `queue:retry all` mehr; Vorgehen bei stehendem Worker. + +### 07.10.2026 — Allgemeine Fassung für andere Projekte + +Git-Deploy und Betriebsüberwachung als übertragbarer Baukasten mit Vorlagen +(Laravel-Dateien, `deploy.sh`, Docker-Compose für GlitchTip/ntfy/Uptime Kuma): +[`../git-deploy-monitoring/`](../git-deploy-monitoring/README.md). In einem +frischen Laravel 12 eingebaut und getestet. diff --git a/dev/git-deploy-monitoring/01-git-deploy.md b/dev/git-deploy-monitoring/01-git-deploy.md new file mode 100644 index 00000000..348bf4a7 --- /dev/null +++ b/dev/git-deploy-monitoring/01-git-deploy.md @@ -0,0 +1,261 @@ +# 01 — Git-Deploy per Tag + +Ausgeliefert wird ein **Git-Tag**, nie ein Branch-Stand. Das Live-Verzeichnis ist +ein Git-Checkout dieses Tags. Ein Skript (`vorlagen/deploy.sh`) führt den Deploy +von Hand per SSH aus und bricht ab, **bevor** es etwas verändert, wenn etwas nicht +stimmt. + +Erprobt mit: Ploi-verwaltetem Ubuntu-Server, PHP-FPM 8.4, Nginx, Supervisor, +MySQL, Forgejo als Git-Server. Läuft genauso mit Forge, plain VPS, GitHub/GitLab. + +--- + +## Grundsätze + +- **Nur Tags** (`vX.Y.Z`, SemVer): Feature-Release erhöht die mittlere Stelle, + Hotfix die letzte. Die Tag-Nachricht ist die Release-Notiz (Inhalt, Migrationen, + neue `.env`-Werte, genauer Deploy-Befehl). +- **Deploy nur von Hand** per SSH. Push-Webhooks und „Quick Deploy“ im + Server-Panel bleiben aus — ein Push ist noch kein Release. +- **Assets lokal bauen und committen** (`public/build` versioniert). Auf dem + Server gibt es kein Node. Vite benennt Dateien nach Inhalt — ohne + Frontend-Änderung kein Diff. +- **Auf dem Server wird nichts von Hand geändert.** Das Skript bricht ab, sobald + der Checkout vom Tag abweicht. Auch Hotfixes laufen über Commit → Tag → Deploy. +- **Kein `git clean` auf Live.** `.env`, `storage/`, `vendor/`, `RELEASE`, + `public/storage` sind ignoriert und bleiben unberührt. +- **Deploy im selben Verzeichnis** mit `php artisan down` (Sekunden), keine + Release-Verzeichnisse — `storage/app` bleibt an seinem Ort. +- **Migrationen sind additiv.** Der alte Code muss mit dem neuen Schema laufen; + dann ist der Rollback einfach der vorherige Tag. Kein `migrate:rollback` auf Live. + +--- + +## Voraussetzungen im Repository + +`.gitignore` muss enthalten bzw. darf **nicht** enthalten: + +```gitignore +/RELEASE # schreibt deploy.sh beim Deploy +/.env +/vendor +/node_modules +/public/hot +/public/storage +/storage/*.key +# NICHT ignorieren: /public/build (wird versioniert) +``` + +Prüfen, ob ein Muster versehentlich echte Dateien trifft (macOS-Muster wie +`Icon` treffen bei `core.ignorecase=true` auch `…/icon/`): + +```bash +git ls-files --others --ignored --exclude-standard | grep -v -E '^(vendor|node_modules|storage)/' | head -50 +``` + +Version in der App: `vorlagen/laravel/app/Support/Monitoring/Release.php` plus + +```php +// config/app.php +'version' => App\Support\Monitoring\Release::current(env('APP_VERSION')), +``` + +`deploy.sh` nach `dev/betrieb/deploy.sh` (oder anders, dann `DEPLOY_SCRIPT` +im Skript anpassen) und die Einstellungen oben setzen: + +```bash +DEPLOY_SCRIPT="dev/betrieb/deploy.sh" # Pfad im Repository +PHP_FPM_SERVICE="php8.4-fpm" # systemd-Name +ABOUT_SECTION="" # Abschnitt aus AboutCommand::add, leer = RELEASE ausgeben +QUEUE_PROBE=1 # 0, wenn das Projekt keine Queue hat +``` + +`QUEUE_PROBE=1` setzt `monitoring:queue-probe` voraus (03). + +--- + +## A — Einmalig: Server-Zugang zum Git-Server + +Auf dem Server als App-Benutzer (bei Ploi `ploi`): + +1. `git --version` +2. Deploy-Key erzeugen und im Git-Server **nur lesend** am Repository eintragen: + + ```bash + ssh-keygen -t ed25519 -f ~/.ssh/deploy_meineapp -N "" -C "meineapp-live" + cat ~/.ssh/deploy_meineapp.pub + ``` + +3. `~/.ssh/config`: + + ``` + Host git.example.com + Port 222 + User git + IdentityFile ~/.ssh/deploy_meineapp + IdentitiesOnly yes + ``` + +4. Host-Key aufnehmen, Fingerprint mit dem im Git-Server angezeigten vergleichen: + + ```bash + ssh-keyscan -p 222 git.example.com >> ~/.ssh/known_hosts + ssh-keygen -lf <(ssh-keyscan -p 222 git.example.com 2>/dev/null) + ``` + +5. Test: `git ls-remote ssh://git@git.example.com:222/team/meineapp.git` zeigt die Tags. +6. **FPM-Reload ohne Passwort testen** (Ploi erlaubt das dem Benutzer `ploi`): + + ```bash + ps -o pid,lstart,args -C php-fpm8.4 | head -3 + echo "" | sudo -S service php8.4-fpm reload; echo "Exit: $?" + sleep 2; ps -o pid,lstart,args -C php-fpm8.4 | head -3 + ``` + + Exit 0, `master process` mit gleicher PID, `pool www` neu → das Skript lädt + FPM selbst. Fragt `sudo` nach einem Passwort: beim Deploy + `DEPLOY_FPM_RELOAD=` setzen, das Skript wartet dann auf Enter, bis FPM im + Panel neu geladen ist. **Immer Reload, nie Restart** — Restart entfernt kurz + den Socket, Nginx liefert 502. +7. **Supervisor und Worker prüfen:** `systemctl is-enabled supervisor` → `enabled`; + im Panel ein Worker mit `php artisan queue:work --queue= --tries=3 --max-time=3600`. + +## B — Einmalig: Live-Verzeichnis zum Checkout machen + +### B1 — Neues Projekt + +```bash +cd ~ && git clone ssh://git@git.example.com:222/team/meineapp.git meineapp.de +cd meineapp.de && git checkout --detach v1.0.0 +cp /pfad/zur/.env .env && composer install --no-dev --optimize-autoloader +echo v1.0.0 > RELEASE && php artisan migrate --force && php artisan config:cache +``` + +### B2 — Bestehendes Verzeichnis (bisher SFTP o. Ä.) an Ort und Stelle umwandeln + +Erst ein Tag `vA` erstellen, der **genau** dem Live-Stand entspricht. + +```bash +cd ~ && tar czf backup-vor-git.tgz --exclude=./storage --exclude=./node_modules -C ~/meineapp.de . +cd ~/meineapp.de +git init -q +git remote add origin ssh://git@git.example.com:222/team/meineapp.git +git fetch --tags origin +git update-ref --no-deref HEAD "$(git rev-parse 'vA^{commit}')" +git read-tree HEAD # Index = Tag, Dateien bleiben unangetastet +git status --porcelain # Abweichungen sichten +``` + +| Zeile | Bedeutung | Vorgehen | +|---|---|---| +| ` M datei` | Live weicht vom Tag ab | `git diff -- datei`. Fehlt die Änderung im Repository: erst lokal nachziehen, neuer Tag. Sonst Altstand, wird überschrieben | +| ` D datei` | fehlt auf Live | meist Entwicklerdatei; der Checkout legt sie an | +| `?? datei` | nur auf Live | **nicht löschen** — nach `~/vor-git/` verschieben oder in `.gitignore` aufnehmen (neuer Tag) | + +Bekannte Funde: fehlende `.gitignore` auf Live (dann erscheinen `.env`, +`vendor/` als `??` — verschwinden nach dem Checkout), per SFTP gelöschte +Altdateien, Pfade mit Umlauten (Git setzt sie in Anführungszeichen — beim +Filtern mit `grep` beachten). + +Wenn nichts Unerklärtes übrig ist: + +```bash +git checkout -q -f --detach vA +git status --porcelain # muss leer sein +``` + +Beim ersten Deploy existiert `deploy.sh` evtl. noch nicht im Live-Stand: + +```bash +git show vB:dev/betrieb/deploy.sh > /tmp/deploy.sh && bash /tmp/deploy.sh vB +``` + +--- + +## C — Release lokal (jedes Mal) + +```bash +# 1. sauber und grün +git status # nichts offen +php artisan test --compact --parallel && vendor/bin/phpstan analyse + +# 2. Assets bauen und committen +npm ci && npm run build +git add public/build +git diff --cached --quiet public/build || git commit -m "build: Assets für vX.Y.Z" + +# 3. Tag mit Release-Notiz, pushen (Tags mitnehmen!) +git tag -a vX.Y.Z -m "vX.Y.Z — + +Deploy: , + bash dev/betrieb/deploy.sh vX.Y.Z [--migrations]" +git push origin main --follow-tags +``` + +## D — Deploy (jedes Mal, auf dem Server) + +1. Bringt der Tag Migrationen mit: **DB-Backup**. +2. Neue `.env`-Werte laut Release-Notiz vorher eintragen. +3. Deploy: + + ```bash + cd ~/meineapp.de + bash dev/betrieb/deploy.sh vX.Y.Z # ohne Migrationen + bash dev/betrieb/deploy.sh vX.Y.Z --migrations # mit Migrationen + ``` + +Ablauf im Skript: + +| Schritt | Bei Fehler | +|---|---| +| Arbeitsverzeichnis sauber? | Abbruch, nichts verändert | +| `git fetch --tags`, Tag vorhanden? | Abbruch, nichts verändert | +| Neue Migrationen? Ohne `--migrations` | Abbruch, nichts verändert | +| `down` → Checkout → `RELEASE` → `composer install` → `migrate` → Livewire-Assets → Caches → `queue:restart` → FPM-Reload → `up` | Seite bleibt im Wartungsmodus, Skript nennt den Rollback-Befehl | +| Version ausgeben | — | +| `monitoring:queue-probe --wait=60` | **Exit 3**: live, aber kein Worker — Supervisor/Worker prüfen | + +Optionen: `--clear-schedule` (setzt `withoutOverlapping`-Sperren zurück, nur +wenn sich Sperrdauern geändert haben). Lange Migrationen: +`DB_LOCK_WAIT_TIMEOUT=300 bash dev/betrieb/deploy.sh vX.Y.Z --migrations`. + +Exit-Codes: `0` fertig · `1` abgebrochen · `2` falscher Aufruf · `3` kein Worker. + +Hinweis: Das Skript läuft aus einer Kopie des **vorher** ausgecheckten Stands +(Bash liest Skripte stückweise, Git ersetzt die Datei beim Checkout). Neue +Schritte im Skript greifen also erst beim übernächsten Deploy — beim +einführenden Deploy von Hand nachholen. + +4. Prüfen: `/health` mit Token zeigt `"version":"vX.Y.Z"`, Uptime Kuma grün, + GlitchTip ohne neue Einträge, Logs ohne neue Fehler. + +## E — Rollback + +```bash +bash dev/betrieb/deploy.sh +``` + +Baut `vendor/` und Caches für den alten Tag neu auf. Nur bei Datenproblemen +das DB-Backup einspielen. + +--- + +## Fallstricke + +- **FPM-Restart statt Reload** → 502 für Sekunden (Nginx: `connect() to unix:…fpm.sock failed`). + Einzelne `recv() failed (104: Connection reset by peer)` genau zum Deploy-Zeitpunkt + sind normal (laufende Anfragen beim Reload). Abhilfe wäre + `process_control_timeout = 10s` im Abschnitt `[global]` von + `/etc/php/8.4/fpm/php-fpm.conf` (root) — **nie in die Pool-Datei** (`www.conf`), + dort ist der Schlüssel unbekannt und FPM startet nicht mehr. +- **Supervisor gestoppt** → `queue:restart` beendet den Worker, niemand startet + ihn neu. Deshalb die Worker-Prüfung am Ende. Notlösung ohne sudo: + `nohup php artisan queue:work --tries=3 --max-time=3600 >> storage/logs/worker-manual.log 2>&1 &` +- **Nie `queue:retry all`** nach Ausfällen: alte Jobs tragen den Stand ihrer + Klasse von damals und scheitern oder verschicken veraltete Mails. Erst + `queue:failed` ansehen, gezielt `queue:retry `. +- **Push ohne Tags** → `deploy.sh` findet den Tag nicht. Immer `--follow-tags`. +- **`RELEASE` nicht von Hand neu erzeugen** auf einem anderen Stand als dem Tag + (ergibt `vX.Y.Z-3-gabc-dirty`). Das Skript schreibt sie. +- **Server-Panel-Logs**: Ploi führt für Sites kein Nginx-Zugriffsprotokoll; + das Fehlerlog `/var/log/nginx/-error.log` ist für `ploi` lesbar. diff --git a/dev/git-deploy-monitoring/02-fehlerprotokoll-glitchtip.md b/dev/git-deploy-monitoring/02-fehlerprotokoll-glitchtip.md new file mode 100644 index 00000000..770b3db8 --- /dev/null +++ b/dev/git-deploy-monitoring/02-fehlerprotokoll-glitchtip.md @@ -0,0 +1,256 @@ +# 02 — Fehlerprotokoll mit GlitchTip + +GlitchTip ist ein selbst gehostetes, Sentry-kompatibles Fehlerprotokoll. Die App +spricht es über die **offiziellen Sentry-SDKs** an (`sentry/sentry-laravel`, +`@sentry/browser`); ohne DSN sind beide ein No-op. Gleiche Fehler werden zu +einem Eintrag gruppiert (Häufigkeit, betroffene URLs, Release), gemeldet wird +nur ein neuer Fehlertyp. + +--- + +## 1. GlitchTip-Server (einmalig, für alle Projekte) + +Vorlage: `vorlagen/server/glitchtip-compose.yml` (GlitchTip 6, Postgres 18, +Valkey; gegen die offizielle `compose.sample.yml` abgleichen). + +1. `SECRET_KEY` (`openssl rand -hex 32`), `GLITCHTIP_DOMAIN`, `EMAIL_URL`, + `DEFAULT_FROM_EMAIL` setzen. Aufbewahrung: `GLITCHTIP_RETENTION_DAYS` + (Standard 90 — ältere Versionen hießen `GLITCHTIP_MAX_EVENT_LIFE_DAYS`). +2. `docker compose -f glitchtip-compose.yml up -d`, Reverse Proxy mit TLS + (`vorlagen/server/Caddyfile`). +3. Erstes Konto anlegen, danach `ENABLE_USER_REGISTRATION: "False"`, neu starten. +4. Organisation anlegen (Slug merken, z. B. `meine-org`). + +## 2. Je Projekt: zwei GlitchTip-Projekte + +| Projekt | Plattform | `.env` | +|---|---|---| +| `-server` | PHP / Laravel | `SENTRY_LARAVEL_DSN` | +| `-browser` | JavaScript | `SENTRY_BROWSER_DSN` | + +Getrennt, weil Browserfehler (Erweiterungen, alte Browser) eine andere +Qualität haben als Serverfehler. Je Projekt eine **Alert-Regel**: Mail bei +neuem Fehlertyp, wenn möglich nur für Umgebung `production`. + +Umgebungen: `SENTRY_ENVIRONMENT=production` auf Live, `local` lokal. Lokal die +DSN nur zum Testen setzen. + +--- + +## 3. Laravel-Anbindung (Server) + +```bash +composer require sentry/sentry-laravel +``` + +Dateien aus `vorlagen/laravel/` kopieren: + +| Datei | Zweck | +|---|---| +| `config/sentry.php` | gehärtete Konfiguration (ersetzt die veröffentlichte) | +| `app/Support/Monitoring/ErrorReportScrubber.php` | `before_send`: nur User-ID, Tokens/Signaturen aus URL/Query/Referer, E-Mails aus Meldungen | +| `app/Support/Monitoring/Release.php` | Version aus `APP_VERSION` oder Datei `RELEASE` | +| `app/Support/Monitoring/ErrorSignal.php` | Warnung ohne Exception, gedrosselt (5 je 10 min und Ort) | +| `app/Http/Middleware/ReportErrorSignals.php` | 404 mit eigenem Referrer, 403/429 für Angemeldete, 419 | + +`bootstrap/app.php`: + +```php +use App\Http\Middleware\ReportErrorSignals; +use Sentry\Laravel\Integration; +use Symfony\Component\Console\Exception\CommandNotFoundException; + + ->withMiddleware(function (Middleware $middleware): void { + // Fehler ohne Exception (toter interner Link, 403/429 für Angemeldete, 419) — + // global, weil 404 vor dem Routing entsteht + $middleware->append(ReportErrorSignals::class); + }) + ->withExceptions(function (Exceptions $exceptions): void { + // Gemeldete Exceptions an GlitchTip (ohne DSN ein No-op); + // Laravels dontReport (404, Validierung, Auth) gilt weiter + Integration::handles($exceptions); + + // Vertippte artisan-Befehle sind kein Anwendungsfehler + $exceptions->dontReport(CommandNotFoundException::class); + }) +``` + +`config/app.php`: + +```php +'version' => App\Support\Monitoring\Release::current(env('APP_VERSION')), +``` + +`config/sentry.php` liest `release` aus derselben Quelle. + +### Was die gehärtete `config/sentry.php` anders macht + +| Option | Wert | Warum | +|---|---|---| +| `send_default_pii` | `false` (fest) | keine IP, keine Cookies, keine Header mit Personenbezug | +| `max_request_body_size` | `never` | Formular- und JSON-Inhalte verlassen den Server nie | +| `before_send` | `ErrorReportScrubber::beforeSend` | User-ID statt Person, Tokens raus, E-Mails → `[email]` | +| `breadcrumbs.logs`, `breadcrumbs.cache` | `false` | Lognachrichten tragen oft E-Mail-Adressen | +| `enable_metrics` | `false` | versteht GlitchTip nicht | +| `ignore_transactions` | `/up`, `/health` | Monitor-Aufrufe sind kein Signal | + +**Je Projekt anpassen:** `ErrorReportScrubber::AllowedQueryParameters` — welche +Query-Parameter unverändert mitgehen dürfen (`utm_*` immer). Dieselbe Liste +steht in `error-reporting.js`. + +--- + +## 4. Browserfehler + +```bash +npm install @sentry/browser +``` + +| Datei | Zweck | +|---|---| +| `resources/js/error-reporting.js` | hört auf `error` und `unhandledrejection`; lädt das SDK erst beim ersten Fehler (1,5 kB statt 32 kB auf fehlerfreien Seiten); filtert URLs und E-Mails | +| `resources/js/error-reporting-sdk.js` | Re-Export der benötigten SDK-Funktionen (Tree-Shaking) | +| `resources/views/components/monitoring/browser-errors.blade.php` | `` mit DSN, Release, User-ID, Bereich — nur mit `SENTRY_BROWSER_DSN` | + +Vite-Eingang ergänzen (`vite.config.js`, `input`): + +```js +input: ['resources/css/app.css', 'resources/js/app.js', 'resources/js/error-reporting.js'], +``` + +Im `` jedes Layouts (Build-Ordner und Bereich anpassen): + +```blade + +``` + +`area` erscheint in GlitchTip als Tag (z. B. `web`, `portal`, `auth`). + +`config/monitoring.php` aus der Vorlage liefert `monitoring.browser.dsn`. + +--- + +## 5. Tests — nie an das echte GlitchTip melden + +`phpunit.xml`: + +```xml + + + + +``` + +Tests aus der Vorlage: `tests/Feature/Monitoring/ErrorReportingTest.php` +(Datenschutzfilter, Wächtertest „the test suite never reports to the real +glitchtip“), `ErrorSignalsTest.php`, `BrowserErrorReportingTest.php`, +`tests/Unit/Monitoring/ReleaseTest.php`. Ereignisse fängt +`tests/Support/FakeGlitchTip.php` ab (`FakeGlitchTip::install()`). + +Die Tests setzen `RefreshDatabase` voraus (in `tests/Pest.php` aktivieren) und +eine `User`-Factory. + +--- + +## 6. Regeln für den Code + +- **Keine variablen Zahlen in Fehlermeldungen**, wenn sie gruppiert werden + sollen — GlitchTip gruppiert nach Text. Variable Werte in den Kontext oder + einen Fingerprint setzen (Muster: `ErrorSignal`). +- **Vorübergehende Fremdfehler nicht einzeln melden** (Timeouts, 5xx, 429 von + externen APIs): einmal wiederholen, dann `Log::warning`; Alarm erst, wenn ein + Lauf abbricht oder die Mehrheit scheitert. Muster: + + ```php + try { + $response = Http::timeout(20) + ->retry(2, 3000, fn (Throwable $e) => $e instanceof ConnectionException + || ($e instanceof RequestException && ($e->response->serverError() || $e->response->status() === 429)), + throw: false) + ->post($url, $payload); + } catch (ConnectionException $e) { + throw new ServiceTemporarilyUnavailable('… nicht erreichbar', previous: $e); // eigene Klasse + } + // Aufrufer: catch (ServiceTemporarilyUnavailable) → Log::warning, sonst report() + ``` + +- **Keine Personenbezüge** in Meldungen bauen; der Filter ist das Netz, nicht der Plan. + +--- + +## 7. Auswertung per API (für Entwickler und KI-Chats) + +Lese-Token in GlitchTip (Profil ▸ Auth Tokens): Scopes `project:read`, +`event:read` — **kein** Schreibrecht. Lokal als `GLITCHTIP_API_TOKEN` in die +`.env`, nie ausgeben, nie committen. Ohne `org:read` liefert die +Organisationsliste 403; Projekte und Issues funktionieren. + +```bash +T=$(grep -E "^GLITCHTIP_API_TOKEN=" .env | cut -d= -f2- | tr -d "'\"") +B=https://errors.example.com/api/0 +ORG=meine-org + +# Projekte (IDs) +curl -s -H "Authorization: Bearer $T" "$B/projects/" | python3 -c " +import json,sys; [print(p['id'], p['slug']) for p in json.load(sys.stdin)]" + +# Offene Einträge eines Projekts, nur Live +curl -s -H "Authorization: Bearer $T" \ + "$B/organizations/$ORG/issues/?project=1&query=is:unresolved&environment=production&limit=100" \ +| python3 -c " +import json,sys +for i in json.load(sys.stdin): + print(i['id'], i['count'], i['firstSeen'][:16], i['lastSeen'][:16], '|', (i['title'] or '')[:100])" + +# Letztes Ereignis: Umgebung, Release, URL, Stacktrace +curl -s -H "Authorization: Bearer $T" "$B/issues//events/latest/" | python3 -c " +import json,sys +e=json.load(sys.stdin); t={x['key']:x['value'] for x in e.get('tags',[])} +print('env:',t.get('environment'),'| release:',t.get('release'),'| area:',t.get('area')) +for en in e.get('entries',[]): + if en['type']=='exception': + for v in en['data'].get('values',[]): + print('EXC',v.get('type'),':',(v.get('value') or '')[:300]) + for f in ((v.get('stacktrace') or {}).get('frames') or [])[-8:]: + print(' ',f.get('filename'),f.get('function'),f.get('lineNo')) + if en['type']=='request': print('REQ',en['data'].get('url')) + if en['type']=='breadcrumbs': + for b in en['data'].get('values',[])[-6:]: print('BC',b.get('category'),(b.get('message') or '')[:120])" +``` + +### Ablauf „schau in GlitchTip“ + +1. Offene Einträge beider Projekte abrufen, zuerst `environment=production`. +2. Einordnen: + + | Klasse | Erkennen | Vorgehen | + |---|---|---| + | Test-/Entwicklungsrauschen | Umgebung `local`, Texte aus Tests | nichts am Code; auflösen | + | Bedienfehler am Server | z. B. `CommandNotFoundException` | `dontReport` | + | Externer Dienst vorübergehend | Timeouts/5xx fremder APIs | Muster aus Abschnitt 6 | + | Echter Bug | Stacktrace in `app/`, `resources/`, Browserfehler mit eigener URL | roter Test → Fix → Deploy | + | Unklar | z. B. `Error: [object Object]` | Erfassung verbessern, nächstes Auftreten abwarten | + +3. Bei Browserfehlern zeigen die Frames oft nur `livewire.min.js` — der Hinweis + steckt im Funktionsnamen (`[Alpine] __('…')` = Alpine-Ausdruck) → in + `resources/views` danach suchen. Kann ein Muster wiederkommen: Wächtertest + über alle Dateien. +4. Fix ausliefern (Hotfix-Tag), danach in GlitchTip auflösen (von Hand — + Token ist nur lesend). Kommt der Fehler mit neuem Release wieder, öffnet + GlitchTip den Eintrag neu. +5. **Breadcrumbs lesen**: Sie zeigen die SQL-Abfragen vor dem Fehler — oft + erkennt man daran Job, Modell und Datensatz (z. B. `failed_jobs`-Insert → + Fehler in einem Queue-Job; `payload.createdAt` verrät alte Jobs). + +Im Projekt eine Kurzfassung mit den echten Werten (Instanz, Org-Slug, +Projekt-IDs) unter `dev/glitch-tip/README.md` ablegen und in `CLAUDE.md` +verweisen — dann finden KI-Chats sie automatisch. + +--- + +## 8. Datenschutz (vor dem Einschalten) + +- Datenschutzerklärung: Fehlerprotokollierung (Dienst selbst gehostet, + Kategorien: User-ID, URL, Browser, Fehlermeldung; Frist = Aufbewahrung in + GlitchTip, z. B. 90 Tage; Rechtsgrundlage berechtigtes Interesse am sicheren Betrieb). +- Kein Auftragsverarbeiter, wenn GlitchTip auf eigenem Server läuft (Hoster ggf. AVV). diff --git a/dev/git-deploy-monitoring/03-betriebsueberwachung.md b/dev/git-deploy-monitoring/03-betriebsueberwachung.md new file mode 100644 index 00000000..d1cfc19e --- /dev/null +++ b/dev/git-deploy-monitoring/03-betriebsueberwachung.md @@ -0,0 +1,222 @@ +# 03 — Betriebsüberwachung: /health, Herzschlag, Worker, Alarme, Uptime Kuma + +GlitchTip (02) sieht nur Fehler, die PHP noch melden kann. Dieser Teil deckt den +Rest ab: Scheduler steht, Worker steht, Platte voll, Server weg — und meldet es +aufs Handy. + +--- + +## 1. Bausteine in der App + +Dateien aus `vorlagen/laravel/` kopieren: + +| Datei | Zweck | +|---|---| +| `config/monitoring.php` | Alarme (Mail, ntfy), Browser-DSN, Health-Token, Schwellen | +| `app/Support/OperationalAlert.php` | Alarm: immer `Log::critical` (Kanal `monitoring`), dazu Text-Mail und ntfy — unabhängig voneinander, Best-Effort | +| `app/Services/Monitoring/HealthProbe.php` | Prüfungen DB, Cache, Queue-Rückstau, Scheduler-Herzschlag, Plattenplatz | +| `app/Http/Controllers/HealthController.php` | `GET /health` mit Token → JSON, 200 oder 503 | +| `app/Console/Commands/MonitoringHeartbeat.php` | `monitoring:heartbeat`, minütlich vom Scheduler | +| `app/Console/Commands/MonitoringQueueProbe.php` + `app/Jobs/Monitoring/QueueProbe.php` | `monitoring:queue-probe`: Test-Job, wartet bis zum Abholen (Ende von `deploy.sh`) | +| `tests/Feature/Monitoring/HealthEndpointTest.php`, `QueueProbeTest.php` | Tests | + +Log-Kanal `monitoring` in `config/logging.php` ergänzen (eigene Datei, rotierend): + +```php +'monitoring' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/monitoring.log'), + 'level' => 'debug', + 'days' => 14, +], +``` + +### Verdrahtung + +`bootstrap/app.php` — Route außerhalb der `web`-Gruppe (keine Session, kein CSRF): + +```php +use App\Http\Controllers\HealthController; +use Illuminate\Support\Facades\Route; + + ->withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + then: function (): void { + // Tiefer Health-Check für den externen Monitor + Route::get('/health', HealthController::class) + ->middleware('throttle:30,1') + ->name('health.deep'); + }, + ) +``` + +Hat das Projekt Basic Auth (Staging) oder eine Host-Whitelist (`trustHosts`): +`/health` von Basic Auth ausnehmen (der Monitor schickt seinen eigenen +`Authorization`-Header) und IP/`localhost` als Host zulassen. + +`routes/console.php`: + +```php +use App\Console\Commands\MonitoringHeartbeat; +use App\Support\OperationalAlert; +use Illuminate\Console\Scheduling\Event; +use Illuminate\Support\Facades\Schedule; + +// Betriebs-Alert, wenn ein geplanter Task fehlschlägt — an JEDEN Task hängen +$alertOnFailure = fn (string $task) => function (Event $event) use ($task): void { + OperationalAlert::send("Geplanter Task fehlgeschlagen: {$task}", ['exit_code' => $event->exitCode]); +}; + +// Herzschlag: fällt der Scheduler aus (Cron weg, Server hängt), greift kein +// onFailure mehr — dann meldet /health den fehlenden Herzschlag +Schedule::command(MonitoringHeartbeat::class) + ->everyMinute() + ->withoutOverlapping(2) + ->onFailure($alertOnFailure('monitoring:heartbeat')); + +// Fehlgeschlagene Jobs nach 30 Tagen entfernen +Schedule::command('queue:prune-failed', ['--hours=720']) + ->dailyAt('03:55') + ->withoutOverlapping(30) + ->onFailure($alertOnFailure('queue:prune-failed')); +``` + +Bei allen eigenen Tasks: `withoutOverlapping()` mit **kurzer** Sperrdauer +(Standard wäre 24 h — ein abgestürzter Lauf blockiert sonst einen Tag) und +`->onFailure($alertOnFailure(''))`. + +`app/Providers/AppServiceProvider.php` → `boot()`: + +```php +use App\Support\OperationalAlert; +use Illuminate\Foundation\Console\AboutCommand; +use Illuminate\Queue\Events\JobFailed; +use Illuminate\Support\Facades\Queue; + + // Betriebs-Alert bei endgültig fehlgeschlagenem Queue-Job + Queue::failing(function (JobFailed $event): void { + OperationalAlert::send("Queue-Job fehlgeschlagen: {$event->job->resolveName()}", [ + 'connection' => $event->connectionName, + 'queue' => $event->job->getQueue(), + 'exception' => $event->exception->getMessage(), + ]); + }); + + // Ausgelieferte Version in `php artisan about` (Abschnitt-Name = ABOUT_SECTION in deploy.sh) + AboutCommand::add('App', fn (): array => ['Version' => config('app.version') ?? '—']); +``` + +### Die Prüfungen von `/health` + +| Prüfung | `critical` (→ 503) | `warn` (200) | +|---|---|---| +| `database` | `select 1` scheitert | — | +| `cache` | Schreiben/Lesen scheitert | — | +| `queue` | ältester wartender Job ≥ `MONITORING_QUEUE_BACKLOG_SECONDS` (300) — nur Treiber `database` | — | +| `scheduler` | kein Herzschlag oder ≥ 10 min alt | ≥ 3 min alt | +| `disk` | freier Platz < `MONITORING_DISK_MIN_FREE_PERCENT` (10 %) | < 20 % | + +Ohne konfiguriertes `MONITORING_HEALTH_TOKEN` gibt es den Endpunkt nicht (404), +falsches Token → 404. Token im Header `Authorization: Bearer …` oder `?token=`. +Antwort enthält `version`. + +Der Herzschlag liegt in `storage/framework/scheduler-heartbeat` (übersteht +`cache:clear`, keine Migration). **Mehrere App-Server:** stattdessen eine +DB-Zeile, sonst prüft jeder Server nur seine eigene Datei. + +Andere Queue-Treiber (Redis, SQS): `queue`-Prüfung meldet „nicht prüfbar“; +dann eine eigene Prüfung ergänzen (z. B. `Queue::size()` plus Alter des +ältesten Jobs) oder Horizon-Status abfragen. + +--- + +## 2. ntfy — Push aufs Handy + +Server einmalig: `vorlagen/server/ntfy-compose.yml` + `ntfy-server.yml` +(`auth-default-access: deny-all` — ohne Anmeldung kein Lesen, kein Schreiben). + +Je Projekt: + +```bash +ntfy user add meineapp-alerts # Passwort wird nicht gebraucht +ntfy access meineapp-alerts meineapp-ops write-only # darf nur schreiben +ntfy access meineapp-ops read-only # entfällt bei Admin-Rolle +ntfy token add meineapp-alerts # → tk_… in MONITORING_NTFY_TOKEN +``` + +Test: `curl -H "Authorization: Bearer tk_…" -d "Test" https://push.example.com/meineapp-ops`. +Handy: ntfy-App, Server hinzufügen (mit eigenem Benutzer), Topic abonnieren. + +Warum eigener Benutzer mit Token: gerät der Token in falsche Hände, kann damit +niemand mitlesen; `ntfy token remove` sperrt ihn ohne Passwortwechsel. + +`OperationalAlert` sendet JSON an die Basis-URL (`topic`, `title`, `message`, +`priority`, `tags`) — UTF-8-sicher, anders als Header-basierte Aufrufe. + +--- + +## 3. Uptime Kuma — externer Monitor + +Auf einem **anderen** Server als die App (`vorlagen/server/uptime-kuma-compose.yml`). +Admin-Konto anlegen, 2FA aktivieren. + +| Monitor | Typ / Ziel | Intervall | Wiederholungen | +|---|---|---|---| +| ` /health` | HTTP(s), `https://meineapp.de/health`, Header `{"Authorization": "Bearer "}`, Status `200-299` | 60 s | 2 | +| ` /up` je Domain | HTTP(s), `https:///up` | 5 min | 1 | +| Zertifikate | Settings ▸ Certificate Expiry Notification | 14 Tage vorher | — | + +Benachrichtigung: ntfy (Server, Topic, Priorität 4, Access Token) und SMTP-Mail. + +Ein stehender Worker fällt so nach etwa 5 + 2 Minuten auf (Queue-Schwelle plus +Wiederholungen) — ein zusätzlicher Alarm aus dem Scheduler ist dann überflüssig. + +**Test:** zweiter Monitor mit falschem Token → 404 → Alarm muss auf Handy und +per Mail ankommen; danach löschen. + +**Zweite Ebene (empfohlen):** kostenloser externer Dienst (z. B. UptimeRobot) +auf `https://meineapp.de/up` mit Mail — meldet auch, wenn der Monitoring-Server +selbst ausfällt. + +--- + +## 4. Worker-Prüfung im Deploy + +`deploy.sh` ruft am Ende `php artisan monitoring:queue-probe --wait=60` auf: +Test-Job auf die Standard-Queue, Abholung wird über den Cache quittiert. Kein +Worker → Exit 3 mit Hinweis. Von Hand jederzeit: + +```bash +php artisan monitoring:queue-probe +ps -eo pid,lstart,args | grep "queue:work" | grep -v grep +``` + +Voraussetzung: Der Worker bedient die Standard-Queue, und App und Worker teilen +denselben Cache-Store (nicht `array`). + +--- + +## 5. Erweiterungen (Muster aus Pressekonto, nicht in den Vorlagen) + +- **Admin-Systemstatus-Seite**: Kacheln aus `HealthProbe::checks()` plus + projektspezifische Prüfungen (fehlende Permissions, `APP_DEBUG` an, + ausstehende Datenmigrationen, letzte fehlgeschlagene Jobs). +- **Fehlgeschlagene Logins**: falsches Passwort / Konto ohne Passwort / + Sperre in einen Aktivitäts-Verlauf, mit Einstieg (`livewire`/`fortify`) und + gekürztem HMAC der IP statt Klartext. Unbekannte Adressen **nur zählen** + (Tag × Einstieg × IP-Hash), sonst verdecken Credential-Stuffing-Wellen + (bei Pressekonto 250/Tag aus 50 Quellen) die relevanten Einträge. +- **Webanalyse**: Umami selbst gehostet, cookielos; Query-Parameter mit + `data-before-send` filtern (signierte Links!), im Admin und bei + Impersonation nicht tracken. +- **Nginx-Fehlerlog** gelegentlich zusammenfassen: + + ```bash + sed -E 's/^[0-9\/]+ [0-9:]+ //; s/[0-9]+#[0-9]+: \*[0-9]+ //; s/client: [0-9a-f.:]+/client: X/; s/, (server|request|upstream|host|referrer): .*//' \ + /var/log/nginx/-error.log | cut -c1-180 | sort | uniq -c | sort -rn | head -25 + ``` + + `access forbidden by rule` = Scanner suchen `/.env` (gut so); + `recv() failed (104…)` nur zu Deploy-Zeiten = Reload, unkritisch. diff --git a/dev/git-deploy-monitoring/04-checkliste-neues-projekt.md b/dev/git-deploy-monitoring/04-checkliste-neues-projekt.md new file mode 100644 index 00000000..ebf2ff90 --- /dev/null +++ b/dev/git-deploy-monitoring/04-checkliste-neues-projekt.md @@ -0,0 +1,62 @@ +# 04 — Checkliste: Übernahme in ein neues Projekt + +Platzhalter: `meineapp` (Projektname), `meineapp.de` (Live-Domain), +`errors.example.com` / `push.example.com` / `status.example.com` (Monitoring-Server), +`git.example.com` (Git-Server). Details in 01–03. + +## 0. Monitoring-Server (nur beim ersten Projekt) + +- [ ] GlitchTip: `vorlagen/server/glitchtip-compose.yml`, `SECRET_KEY`, Domain, SMTP, `GLITCHTIP_RETENTION_DAYS` +- [ ] Erstes Konto, danach `ENABLE_USER_REGISTRATION: "False"`; Organisation angelegt +- [ ] ntfy: `ntfy-compose.yml` + `ntfy-server.yml` (`deny-all`), eigener Admin-Benutzer, Handy-App verbunden +- [ ] Uptime Kuma: `uptime-kuma-compose.yml`, Admin mit 2FA, ntfy- und Mail-Benachrichtigung +- [ ] Reverse Proxy mit TLS für alle drei (`Caddyfile` oder vorhandener Proxy) +- [ ] Monitoring-Server ≠ App-Server + +## 1. Monitoring-Server: je Projekt + +- [ ] GlitchTip-Projekte `meineapp-server` (PHP/Laravel) und `meineapp-browser` (JavaScript), DSNs notiert +- [ ] Alert-Regel je Projekt: Mail bei neuem Fehlertyp (möglichst nur `production`) +- [ ] ntfy: Benutzer `meineapp-alerts`, `write-only` auf `meineapp-ops`, Token erzeugt; eigener Benutzer liest +- [ ] Lese-Token für die GlitchTip-API (`project:read`, `event:read`) für die lokale `.env` + +## 2. Laravel-Teil + +- [ ] `composer require sentry/sentry-laravel` · `npm install @sentry/browser` +- [ ] `vorlagen/laravel/` ins Projekt kopieren (Pfade 1:1) +- [ ] `ErrorReportScrubber::AllowedQueryParameters` und `ALLOWED_QUERY` in `error-reporting.js` angepasst +- [ ] `bootstrap/app.php`: `/health`-Route, `ReportErrorSignals`, `Integration::handles`, `dontReport(CommandNotFoundException)` (02 §3, 03 §1) +- [ ] `config/app.php`: `'version' => Release::current(env('APP_VERSION'))` +- [ ] `config/logging.php`: Kanal `monitoring` +- [ ] `routes/console.php`: `$alertOnFailure`, Herzschlag, `queue:prune-failed`; alle eigenen Tasks mit `withoutOverlapping()` und `onFailure` +- [ ] `AppServiceProvider::boot()`: `Queue::failing` → `OperationalAlert`, `AboutCommand::add` +- [ ] Vite-Eingang `resources/js/error-reporting.js`; `` in jedem Layout-`` +- [ ] Basic Auth / `trustHosts`: `/health` ausgenommen bzw. erreichbar +- [ ] `phpunit.xml`: Sentry-DSNs leer (`force="true"`); `tests/Pest.php` mit `RefreshDatabase` +- [ ] `.env.example` um `vorlagen/env-beispiel.env` ergänzt +- [ ] Tests grün: `php artisan test --compact tests/Feature/Monitoring tests/Unit/Monitoring` (30 Tests) +- [ ] Datenschutzerklärung: Fehlerprotokollierung (Kategorien, Frist, Rechtsgrundlage) + +## 3. Git-Deploy + +- [ ] `.gitignore`: `/RELEASE` ignoriert, `/public/build` **nicht** ignoriert; ignorierte Dateien gesichtet (01, Voraussetzungen) +- [ ] `vorlagen/deploy.sh` → `dev/betrieb/deploy.sh`, Einstellungen oben gesetzt (`DEPLOY_SCRIPT`, `PHP_FPM_SERVICE`, `ABOUT_SECTION`, `QUEUE_PROBE`) +- [ ] Assets gebaut und committet, Tag `vX.Y.Z` mit Release-Notiz, `git push --follow-tags` +- [ ] Server: Deploy-Key (nur lesen), `~/.ssh/config`, Host-Key-Fingerprint geprüft, `git ls-remote` zeigt Tags +- [ ] Server: FPM-**Reload** ohne Passwort getestet (sonst `DEPLOY_FPM_RELOAD=`) +- [ ] Server: `systemctl is-enabled supervisor` → `enabled`; Worker im Panel mit richtigen Queues +- [ ] Server-Panel: „Quick Deploy“/Push-Webhook aus +- [ ] Live-Verzeichnis: neu geklont (B1) oder umgewandelt (B2, Abweichungen gesichtet, `git status` leer) +- [ ] Live-`.env`: DSNs, `SENTRY_ENVIRONMENT=production`, ntfy-Werte, **eigenes** `MONITORING_HEALTH_TOKEN`, `MONITORING_ALERT_EMAIL` +- [ ] Erster Deploy: `bash dev/betrieb/deploy.sh vX.Y.Z [--migrations]` → endet mit „Queue-Worker läuft“ + +## 4. Abnahme + +- [ ] `php artisan about` zeigt die Version +- [ ] `curl -s -H "Authorization: Bearer " https://meineapp.de/health` → `"status":"ok"`, richtige `version` +- [ ] `php artisan tinker --execute 'report(new RuntimeException("Test"));'` → erscheint in GlitchTip (`production`, Release = Tag) +- [ ] Browser-Konsole: `setTimeout(() => { throw new Error('Test test@example.com') })` → GlitchTip Browser, E-Mail als `[email]` +- [ ] `php artisan tinker --execute 'App\Support\OperationalAlert::send("Test");'` → Push und Mail +- [ ] Uptime Kuma: `/health` (mit Header) und `/up` je Domain grün; Test-Monitor mit falschem Token löst Alarm aus +- [ ] Zweite Ebene (externer Dienst auf `/up`) eingerichtet +- [ ] Im Projekt: Kurz-Doku mit echten Werten (`dev/glitch-tip/README.md`), Verweis in `CLAUDE.md` diff --git a/dev/git-deploy-monitoring/README.md b/dev/git-deploy-monitoring/README.md new file mode 100644 index 00000000..8b270dc9 --- /dev/null +++ b/dev/git-deploy-monitoring/README.md @@ -0,0 +1,121 @@ +# Git-Deploy und Betriebsüberwachung — Baukasten für Laravel-Projekte + +**Stand:** 07.10.2026 · **Herkunft:** Pressekonto, live seit v1.1.3 (Git-Deploy) bzw. v1.1.0 (Monitoring) +**Für:** Entwickler und KI-Chats, die dieses Setup in ein anderes Laravel-Projekt übernehmen. + +Dieser Ordner beschreibt ein erprobtes Betriebs-Setup aus zwei Teilen und liefert +die Dateien dafür mit: + +1. **Git-Deploy per Tag** — ausgeliefert wird nur ein Git-Tag, von Hand per SSH, + mit einem Skript, das abbricht, bevor es etwas kaputt macht. +2. **Betriebsüberwachung** — Fehler, Ausfälle und stehende Hintergrundprozesse + fallen auf, bevor Kunden sie melden: GlitchTip (Fehlerprotokoll), `/health` + (tiefer Health-Check), Uptime Kuma (externer Monitor), ntfy (Push aufs Handy). + +Die Laravel-Vorlagen sind in einem frischen Laravel 12 (12.69, PHP 8.4, Pest 3) +eingebaut und getestet (30 Tests grün); `deploy.sh` ist gegen ein simuliertes +Repository durchgespielt (Deploy, Abbruch bei Migrationen, Drift, Rollback, +fehlender Worker). + +--- + +## Inhalt + +| Datei | Wofür | +|---|---| +| [`01-git-deploy.md`](01-git-deploy.md) | Prinzip, einmalige Einrichtung, Release, Deploy, Rollback, Fallstricke | +| [`02-fehlerprotokoll-glitchtip.md`](02-fehlerprotokoll-glitchtip.md) | GlitchTip aufsetzen, Laravel anbinden (Server + Browser), Datenschutz, Auswertung per API | +| [`03-betriebsueberwachung.md`](03-betriebsueberwachung.md) | `/health`, Scheduler-Herzschlag, Worker-Prüfung, Alarme (Mail + ntfy), Uptime Kuma | +| [`04-checkliste-neues-projekt.md`](04-checkliste-neues-projekt.md) | Abhakliste für die Übernahme in ein neues Projekt | +| `vorlagen/deploy.sh` | Deploy-Skript (Einstellungen oben im Skript) | +| `vorlagen/env-beispiel.env` | alle `.env`-Werte mit Kommentar | +| `vorlagen/laravel/…` | PHP/JS/Blade-Dateien und Tests, Pfade wie im Projekt — 1:1 kopierbar | +| `vorlagen/server/…` | Docker-Compose für GlitchTip, ntfy, Uptime Kuma; ntfy-Konfiguration; Caddyfile | + +--- + +## Architektur + +``` + ┌────────────────────────── Monitoring-Server ──────────────────────────┐ + │ GlitchTip (errors.…) ntfy (push.…) Uptime Kuma (status.…) │ + └─────▲──────────────────────▲──────────────────────┬────────────────────┘ + Fehler (Sentry-SDK) │ Betriebs-Alerts │ prüft jede │ Minute + │ (JSON-POST) │ /health, /up │ + ┌─────────────────────┴──────────────────────┴──────────────────────▼────────────────────┐ + │ App-Server: Laravel │ + │ • Exceptions + Fehlersignale → GlitchTip • Scheduler schreibt Herzschlag/Minute │ + │ • fehlgeschlagene Jobs/Tasks → Mail + ntfy • /health: DB, Cache, Queue, Scheduler, │ + │ • deploy.sh: Tag auschecken, Worker prüfen Platte → 200 / 503 │ + └─────────────────────▲───────────────────────────────────────────────────────────────────┘ + │ git fetch --tags (Deploy-Key, nur lesen) + ┌────────┴────────┐ + │ Git-Server │ ← lokal: Commit, Assets bauen, Tag, push + │ (Forgejo o. Ä.) │ + └─────────────────┘ +``` + +**Wichtig:** Uptime Kuma läuft auf einem anderen Server als die App — sonst fällt +der Monitor mit dem überwachten System aus. ntfy liegt auf demselben Server wie +Uptime Kuma; fällt dieser aus, kommt kein Push. Dafür gibt es eine zweite Ebene +(kostenloser externer Dienst, nur Mail), siehe 03. + +### Wer meldet was? + +| Ausfall | Wer merkt es | Kanal | +|---|---|---| +| Exception im Code (Request, Job, Command) | Sentry-SDK | GlitchTip → Mail bei neuem Fehlertyp | +| JavaScript-Fehler im Browser | `error-reporting.js` | GlitchTip (Projekt Browser) | +| Toter interner Link, 403/429 für Angemeldete, 419 | `ReportErrorSignals` | GlitchTip (Warnung) | +| Job endgültig fehlgeschlagen | `Queue::failing` | Mail + ntfy | +| Geplanter Task fehlgeschlagen | `onFailure` | Mail + ntfy | +| Worker steht | `/health` (Queue-Rückstau) | Uptime Kuma → ntfy/Mail | +| Scheduler/Cron steht | `/health` (Herzschlag) | Uptime Kuma | +| Platte fast voll, DB/Cache weg | `/health` | Uptime Kuma | +| Server/PHP-FPM/Nginx/SSL weg | `/up`, `/health` nicht erreichbar | Uptime Kuma (+ zweite Ebene) | +| Worker kommt nach dem Deploy nicht zurück | `deploy.sh` (Test-Job) | Exit 3 im Terminal | + +--- + +## Reihenfolge für ein neues Projekt + +1. **Monitoring-Server** einmalig aufsetzen (GlitchTip, ntfy, Uptime Kuma) — wird + von allen Projekten geteilt. Je Projekt nur neue GlitchTip-Projekte, ein + ntfy-Topic und Monitore. +2. **Laravel-Teil** einbauen (02, 03), Tests grün, ausliefern. +3. **Git-Deploy** einrichten (01) — für ein bestehendes Live-Verzeichnis mit der + Umwandlung an Ort und Stelle. +4. **Uptime Kuma** auf `/health` richten, Alarm testen. + +Die Abhakliste dazu: [`04-checkliste-neues-projekt.md`](04-checkliste-neues-projekt.md). + +--- + +## Bewusst nicht im Baukasten + +- **Zero-Downtime mit Release-Verzeichnissen** (Envoyer/Deployer-Stil): Wer + große Dateien unter `storage/app` hält, deployt besser im selben Verzeichnis + mit `artisan down` (wenige Sekunden). +- **Admin-Systemstatus-Seite**: projektspezifisch. `HealthProbe::scheduler()` + und `disk()` lassen sich dort mitbenutzen. +- **Webanalyse (Umami)** und **Auswertung fehlgeschlagener Logins**: in + Pressekonto vorhanden, aber eng mit dem Projekt verwoben. Muster in 03, + Abschnitt „Erweiterungen“. + +--- + +## Erfahrungen aus dem Betrieb (warum es so gebaut ist) + +| Erfahrung | Folge im Baukasten | +|---|---| +| Die Testsuite meldete absichtlich provozierte Fehler („Queue weg“) an das echte GlitchTip | DSNs in `phpunit.xml` leer, Wächtertest | +| GlitchTip gruppiert nach Text — `…after 20002 ms` und `…20001 ms` wurden getrennte Einträge | keine variablen Zahlen in Meldungen; Fingerprint bei Signalen | +| Ein Google-Aussetzer erzeugte 28 Fehlermeldungen in einer Nacht | vorübergehende Fremdfehler: Retry, `Log::warning`, Alarm erst bei Abbruch | +| Nach `queue:restart` kam der Worker nicht zurück — Supervisor lief nicht. Eine Stunde blieben Jobs liegen | Worker-Prüfung am Ende von `deploy.sh`, `/health` prüft den Rückstau | +| FPM per „Restart“ statt „Reload“ → Socket kurz weg → 502 | `deploy.sh` macht `reload`; Restart nie | +| Version auf Live zeigte `v1.1.0-3-g…-dirty` | `deploy.sh` schreibt `RELEASE` = Tag selbst | +| macOS-Muster `Icon` in `.gitignore` traf bei `core.ignorecase` auch `resources/views/flux/icon/` | bei der Umstellung `git status` auf Live genau sichten (01, Fallstricke) | +| Per SFTP gelöschte Dateien lagen noch auf Live | Umwandlung zeigt Abweichungen, bevor ausgecheckt wird | +| `deploy.sh` wird beim Checkout ersetzt, während Bash es liest | Skript läuft aus einer Kopie | +| Alte fehlgeschlagene Jobs scheiterten nach `queue:retry all` an geänderten Klassen | nie pauschal `retry all`; `queue:prune-failed` nach 30 Tagen | +| `:placeholder="__('…')"` an `` → Alpine-Fehler im Browser, nur durch GlitchTip gefunden | Browserfehler erfassen; Muster mit Wächtertest abfangen | diff --git a/dev/git-deploy-monitoring/vorlagen/deploy.sh b/dev/git-deploy-monitoring/vorlagen/deploy.sh new file mode 100755 index 00000000..eb7950ee --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/deploy.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +# +# Deploy eines Git-Tags auf Live — Vorlage aus dev/git-deploy-monitoring +# (Anleitung: 01-git-deploy.md). Im Projekt z. B. als dev/betrieb/deploy.sh +# ablegen; Pfad und Einstellungen im Block „Projekt-Einstellungen“ anpassen. +# +# Aufruf im App-Verzeichnis auf dem Server: +# +# cd ~/meine-app.de +# bash dev/betrieb/deploy.sh v1.2.0 # ohne neue Migrationen +# bash dev/betrieb/deploy.sh v1.2.0 --migrations # mit Migrationen, DB-Backup vorher! +# +# Optionen: +# --migrations Neue Migrationen ausführen. Ohne diese Option bricht das +# Skript ab, sobald der Tag neue Migrationen mitbringt. +# --clear-schedule php artisan schedule:clear-cache (alte withoutOverlapping-Sperren). +# +# Umgebung (optional): +# DEPLOY_FPM_RELOAD Befehl zum Neuladen von PHP-FPM (Standard siehe unten). +# Leer setzen, wenn FPM von Hand im Server-Panel neu geladen +# wird — dann wartet das Skript auf Enter. Nie „Restart“: +# der entfernt kurz den Socket, Nginx liefert 502. +# +# Exit-Codes: 0 fertig, 1 abgebrochen (siehe Ausgabe), 2 falscher Aufruf, +# 3 live, aber kein Queue-Worker hat den Test-Job abgeholt. +# +# Rollback: dasselbe Skript mit dem vorherigen Tag. + +set -euo pipefail + +# ---- Projekt-Einstellungen -------------------------------------------------- +DEPLOY_SCRIPT="dev/betrieb/deploy.sh" # Pfad dieses Skripts im Repository +PHP_FPM_SERVICE="php8.4-fpm" # systemd-Name des FPM-Dienstes +ABOUT_SECTION="" # z. B. "meineapp" (AboutCommand::add), leer = RELEASE ausgeben +QUEUE_PROBE=1 # 1 = monitoring:queue-probe am Ende, 0 = Projekt ohne Queue +# ----------------------------------------------------------------------------- + +# Git ersetzt diese Datei beim Checkout. Bash liest Skripte stückweise, deshalb +# läuft der Deploy aus einer Kopie. +if [[ -z "${DEPLOY_COPY:-}" ]]; then + copy="$(mktemp)" + cp "$0" "$copy" + DEPLOY_COPY="$copy" exec bash "$copy" "$@" +fi +trap 'rm -f "$DEPLOY_COPY"' EXIT + +tag="${1:-}" +shift || true +run_migrations=0 +clear_schedule=0 + +for option in "$@"; do + case "$option" in + --migrations) run_migrations=1 ;; + --clear-schedule) clear_schedule=1 ;; + *) echo "Unbekannte Option: $option" >&2; exit 2 ;; + esac +done + +if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Aufruf: bash $DEPLOY_SCRIPT vX.Y.Z [--migrations] [--clear-schedule]" >&2 + exit 2 +fi + +if [[ ! -f artisan || ! -d .git ]]; then + echo "Im App-Verzeichnis aufrufen (artisan und .git fehlen hier)." >&2 + exit 2 +fi + +step() { printf '\n==> %s\n' "$*"; } + +step "Arbeitsverzeichnis prüfen" +drift="$(git status --porcelain)" +if [[ -n "$drift" ]]; then + echo "Das Arbeitsverzeichnis weicht vom ausgecheckten Stand ab:" >&2 + echo "$drift" >&2 + echo "Erst klären (git diff), dann deployen. Nichts wurde verändert." >&2 + exit 1 +fi + +step "Tags von origin holen" +git fetch --quiet --tags origin + +if ! git rev-parse --quiet --verify "refs/tags/$tag^{commit}" > /dev/null; then + echo "Tag $tag gibt es auf origin nicht." >&2 + exit 1 +fi + +previous="$(cat RELEASE 2>/dev/null || git describe --tags --always)" +echo "Live: $previous → Ziel: $tag" + +if [[ "$(git rev-parse HEAD)" == "$(git rev-parse "$tag^{commit}")" ]]; then + echo "$tag ist bereits ausgecheckt. Nichts zu tun." + exit 0 +fi + +new_migrations="$(git diff --name-only --diff-filter=A HEAD "$tag" -- database/migrations)" +if [[ -n "$new_migrations" ]]; then + echo "Neue Migrationen:" + echo "$new_migrations" | sed 's/^/ /' + if [[ "$run_migrations" -ne 1 ]]; then + echo "Abbruch: DB-Backup ziehen und mit --migrations erneut aufrufen. Nichts wurde verändert." >&2 + exit 1 + fi +fi + +trap 'echo; echo "!! Deploy abgebrochen. Die Seite bleibt im Wartungsmodus." >&2; echo "!! Rollback: bash $DEPLOY_SCRIPT $previous" >&2; rm -f "$DEPLOY_COPY"' ERR + +step "Wartungsmodus" +php artisan down --retry=60 + +step "Checkout $tag" +git checkout --quiet --detach "$tag" +echo "$tag" > RELEASE + +step "Composer" +composer install --no-dev --optimize-autoloader --no-interaction + +if [[ -n "$new_migrations" ]]; then + step "Migrationen" + php artisan migrate --force +fi + +if [[ -d public/vendor/livewire ]]; then + php artisan vendor:publish --tag=livewire:assets --force +fi + +step "Caches" +php artisan config:cache +php artisan route:cache +php artisan view:clear +php artisan view:cache +php artisan event:cache + +if [[ "$clear_schedule" -eq 1 ]]; then + php artisan schedule:clear-cache +fi + +step "Worker und PHP-FPM" +php artisan queue:restart +default_fpm_reload="echo '' | sudo -S service $PHP_FPM_SERVICE reload" +fpm_reload="${DEPLOY_FPM_RELOAD-$default_fpm_reload}" +if [[ -n "$fpm_reload" ]]; then + bash -c "$fpm_reload" +else + echo "PHP-FPM jetzt im Server-Panel neu laden (Reload, nicht Restart), danach Enter." + read -r +fi + +step "Wartungsmodus aus" +php artisan up + +step "Version" +if [[ -n "$ABOUT_SECTION" ]]; then + php artisan about --only="$ABOUT_SECTION" +else + cat RELEASE +fi + +trap - ERR + +# Läuft ein Worker? `queue:restart` oben beendet ihn; neu starten muss ihn +# Supervisor. Läuft Supervisor nicht, steht die Queue still, ohne dass es +# jemand merkt. +if [[ "$QUEUE_PROBE" -eq 1 ]]; then + step "Queue-Worker" + if ! php artisan monitoring:queue-probe --wait=60; then + echo + echo "!! Deploy $tag ist live, aber KEIN Queue-Worker läuft." >&2 + echo "!! Jetzt Supervisor und den Worker im Server-Panel prüfen — sonst bleiben Jobs und Mails liegen." >&2 + exit 3 + fi +fi + +echo +echo "Deploy $tag fertig (vorher $previous). Weiter mit den Prüfungen aus der Deploy-Anleitung." diff --git a/dev/git-deploy-monitoring/vorlagen/env-beispiel.env b/dev/git-deploy-monitoring/vorlagen/env-beispiel.env new file mode 100644 index 00000000..c40ccbad --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/env-beispiel.env @@ -0,0 +1,24 @@ +# --- Version (leer lassen: kommt aus der Datei RELEASE, die deploy.sh schreibt) +APP_VERSION= + +# --- GlitchTip (02-fehlerprotokoll-glitchtip.md) +SENTRY_LARAVEL_DSN= # Projekt „-server“ (PHP/Laravel) +SENTRY_BROWSER_DSN= # Projekt „-browser“ (JavaScript), öffentlich +SENTRY_ENVIRONMENT=production # lokal: local +SENTRY_RELEASE= # leer lassen + +# --- Betriebs-Alerts (03-betriebsueberwachung.md) +MONITORING_ALERTS_ENABLED=true +MONITORING_ALERT_EMAIL=ops@example.com +MONITORING_NTFY_URL=https://push.example.com +MONITORING_NTFY_TOPIC=meineapp-ops +MONITORING_NTFY_TOKEN= # tk_… des Benutzers meineapp-alerts (nur Schreibrecht) +MONITORING_NTFY_PRIORITY=4 + +# --- Tiefer Health-Check /health (Bearer-Token für Uptime Kuma) +MONITORING_HEALTH_TOKEN= # openssl rand -hex 24 — je Umgebung ein eigener Wert +MONITORING_QUEUE_BACKLOG_SECONDS=300 +MONITORING_DISK_MIN_FREE_PERCENT=10 + +# --- Nur lokal: Lesezugriff auf die GlitchTip-API für die Auswertung +GLITCHTIP_API_TOKEN= # Scopes project:read, event:read — nie committen diff --git a/dev/git-deploy-monitoring/vorlagen/laravel/app/Console/Commands/MonitoringHeartbeat.php b/dev/git-deploy-monitoring/vorlagen/laravel/app/Console/Commands/MonitoringHeartbeat.php new file mode 100644 index 00000000..c75e5fd8 --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/laravel/app/Console/Commands/MonitoringHeartbeat.php @@ -0,0 +1,25 @@ +recordHeartbeat(); + + return self::SUCCESS; + } +} diff --git a/dev/git-deploy-monitoring/vorlagen/laravel/app/Console/Commands/MonitoringQueueProbe.php b/dev/git-deploy-monitoring/vorlagen/laravel/app/Console/Commands/MonitoringQueueProbe.php new file mode 100644 index 00000000..a1aa6d00 --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/laravel/app/Console/Commands/MonitoringQueueProbe.php @@ -0,0 +1,50 @@ +option('wait')); + + QueueProbe::dispatch($token); + + for ($second = 0; $second <= $wait; $second++) { + if (Cache::has(QueueProbe::cacheKey($token))) { + Cache::forget(QueueProbe::cacheKey($token)); + $this->info(sprintf('Queue-Worker läuft (Test-Job nach %d s abgeholt).', $second)); + + return self::SUCCESS; + } + + if ($second < $wait) { + Sleep::for(1)->second(); + } + } + + $this->error(sprintf('Kein Queue-Worker hat den Test-Job innerhalb von %d s abgeholt.', $wait)); + $this->line('Worker prüfen: läuft Supervisor (bzw. der Worker im Server-Panel)? Notfalls: nohup php artisan queue:work --tries=3 --max-time=3600 &'); + + return self::FAILURE; + } +} diff --git a/dev/git-deploy-monitoring/vorlagen/laravel/app/Http/Controllers/HealthController.php b/dev/git-deploy-monitoring/vorlagen/laravel/app/Http/Controllers/HealthController.php new file mode 100644 index 00000000..df4c89a3 --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/laravel/app/Http/Controllers/HealthController.php @@ -0,0 +1,39 @@ +bearerToken() ?? $request->query('token', '')); + + abort_if($token === '' || ! hash_equals($token, $given), 404); + + $checks = $probe->checks(); + $status = HealthProbe::worstStatus($checks); + + return response()->json([ + 'status' => $status, + 'version' => config('app.version'), + 'checked_at' => now()->toIso8601String(), + 'checks' => collect($checks)->mapWithKeys(fn (array $check): array => [ + $check['key'] => ['status' => $check['status'], 'detail' => $check['detail']], + ]), + ], $status === HealthProbe::StatusCritical ? 503 : 200) + ->header('Cache-Control', 'no-store'); + } +} diff --git a/dev/git-deploy-monitoring/vorlagen/laravel/app/Http/Middleware/ReportErrorSignals.php b/dev/git-deploy-monitoring/vorlagen/laravel/app/Http/Middleware/ReportErrorSignals.php new file mode 100644 index 00000000..c0c22d66 --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/laravel/app/Http/Middleware/ReportErrorSignals.php @@ -0,0 +1,96 @@ +signalFor($request, $response->getStatusCode()); + + if ($signal !== null) { + $location = $this->locationOf($request); + $context = [ + 'status' => $response->getStatusCode(), + 'method' => $request->method(), + 'path' => '/'.ltrim($request->path(), '/'), + 'referer' => $this->refererPath($request), + 'livewire' => $request->hasHeader('X-Livewire'), + ]; + + defer(fn () => $this->errorSignal->report($signal, $location, $context)); + } + + return $response; + } + + private function signalFor(Request $request, int $status): ?string + { + return match (true) { + $status === 404 && $this->hasInternalReferer($request) => ErrorSignal::DeadInternalLink, + $status === 403 && $request->user() !== null => ErrorSignal::AccessDenied, + $status === 419 => ErrorSignal::PageExpired, + $status === 429 && $request->user() !== null => ErrorSignal::Throttled, + default => null, + }; + } + + /** + * Livewire-Requests laufen alle über denselben Update-Endpunkt — der Ort + * ist dort die Seite, auf der der Nutzer stand. + */ + private function locationOf(Request $request): string + { + if ($request->hasHeader('X-Livewire')) { + return 'livewire '.($this->refererPath($request) ?? '/'); + } + + $routeName = $request->route()?->getName(); + + return $routeName !== null + ? $routeName + : '/'.ltrim($request->path(), '/'); + } + + private function hasInternalReferer(Request $request): bool + { + $referer = $request->headers->get('referer'); + + return is_string($referer) && parse_url($referer, PHP_URL_HOST) === $request->getHost(); + } + + private function refererPath(Request $request): ?string + { + $referer = $request->headers->get('referer'); + + if (! is_string($referer) || $referer === '') { + return null; + } + + $path = parse_url($referer, PHP_URL_PATH); + + return is_string($path) ? ErrorReportScrubber::scrubPath($path) : null; + } +} diff --git a/dev/git-deploy-monitoring/vorlagen/laravel/app/Jobs/Monitoring/QueueProbe.php b/dev/git-deploy-monitoring/vorlagen/laravel/app/Jobs/Monitoring/QueueProbe.php new file mode 100644 index 00000000..c267b252 --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/laravel/app/Jobs/Monitoring/QueueProbe.php @@ -0,0 +1,31 @@ +token), now()->toIso8601String(), now()->addMinutes(10)); + } +} diff --git a/dev/git-deploy-monitoring/vorlagen/laravel/app/Services/Monitoring/HealthProbe.php b/dev/git-deploy-monitoring/vorlagen/laravel/app/Services/Monitoring/HealthProbe.php new file mode 100644 index 00000000..d73723ed --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/laravel/app/Services/Monitoring/HealthProbe.php @@ -0,0 +1,198 @@ +toIso8601String(), LOCK_EX); + } + + public function lastHeartbeat(): ?Carbon + { + $value = @file_get_contents(self::heartbeatPath()); + + return is_string($value) && trim($value) !== '' ? Carbon::parse(trim($value)) : null; + } + + /** + * @return list + */ + public function checks(): array + { + return [ + $this->database(), + $this->cache(), + $this->queue(), + $this->scheduler(), + $this->disk(), + ]; + } + + /** + * @param list $checks + */ + public static function worstStatus(array $checks): string + { + $statuses = array_column($checks, 'status'); + + return match (true) { + in_array(self::StatusCritical, $statuses, true) => self::StatusCritical, + in_array(self::StatusWarn, $statuses, true) => self::StatusWarn, + default => self::StatusOk, + }; + } + + /** + * @return array{key: string, label: string, status: string, detail: string} + */ + public function scheduler(): array + { + $lastBeat = $this->lastHeartbeat(); + $warnMinutes = (int) config('monitoring.scheduler.heartbeat_warn_minutes'); + $criticalMinutes = (int) config('monitoring.scheduler.heartbeat_critical_minutes'); + $minutesAgo = $lastBeat !== null ? (int) $lastBeat->diffInMinutes(now(), absolute: true) : null; + + return $this->check( + 'scheduler', + 'Scheduler', + match (true) { + $minutesAgo === null, $minutesAgo >= $criticalMinutes => self::StatusCritical, + $minutesAgo >= $warnMinutes => self::StatusWarn, + default => self::StatusOk, + }, + $lastBeat === null + ? 'Noch kein Herzschlag — läuft `schedule:run` per Cron (bzw. `schedule:work`)?' + : 'Letzter Herzschlag '.$lastBeat->format('d.m.Y H:i:s').' (vor '.$minutesAgo.' Min.).', + ); + } + + /** + * @return array{key: string, label: string, status: string, detail: string} + */ + public function disk(): array + { + $path = storage_path(); + $free = @disk_free_space($path); + $total = @disk_total_space($path); + + if ($free === false || $total === false || $total <= 0) { + return $this->check('disk', 'Plattenplatz', self::StatusWarn, 'Plattenplatz nicht ermittelbar.'); + } + + $freePercent = round($free / $total * 100, 1); + $minPercent = (float) config('monitoring.health.disk_min_free_percent'); + + return $this->check( + 'disk', + 'Plattenplatz', + match (true) { + $freePercent < $minPercent => self::StatusCritical, + $freePercent < $minPercent * 2 => self::StatusWarn, + default => self::StatusOk, + }, + sprintf('%s %% frei (%s GB von %s GB).', $freePercent, round($free / 1024 ** 3, 1), round($total / 1024 ** 3, 1)), + ); + } + + /** + * @return array{key: string, label: string, status: string, detail: string} + */ + private function database(): array + { + try { + DB::select('select 1'); + + return $this->check('database', 'Datenbank', self::StatusOk, 'Verbindung steht.'); + } catch (Throwable $exception) { + return $this->check('database', 'Datenbank', self::StatusCritical, 'Keine Verbindung: '.Str::limit($exception->getMessage(), 120)); + } + } + + /** + * @return array{key: string, label: string, status: string, detail: string} + */ + private function cache(): array + { + try { + $value = Str::random(8); + Cache::put('monitoring:health-probe', $value, 60); + + return Cache::get('monitoring:health-probe') === $value + ? $this->check('cache', 'Cache', self::StatusOk, 'Schreiben und Lesen funktionieren.') + : $this->check('cache', 'Cache', self::StatusCritical, 'Geschriebener Wert kommt nicht zurück.'); + } catch (Throwable $exception) { + return $this->check('cache', 'Cache', self::StatusCritical, 'Nicht erreichbar: '.Str::limit($exception->getMessage(), 120)); + } + } + + /** + * @return array{key: string, label: string, status: string, detail: string} + */ + private function queue(): array + { + if (config('queue.default') !== 'database') { + return $this->check('queue', 'Queue', self::StatusOk, 'Treiber '.config('queue.default').' — Rückstau hier nicht prüfbar.'); + } + + try { + $now = now()->getTimestamp(); + $oldestAvailable = DB::table('jobs') + ->whereNull('reserved_at') + ->where('available_at', '<=', $now) + ->min('available_at'); + } catch (Throwable $exception) { + return $this->check('queue', 'Queue', self::StatusCritical, 'Jobs-Tabelle nicht lesbar: '.Str::limit($exception->getMessage(), 120)); + } + + $waitingSeconds = $oldestAvailable !== null ? max(0, $now - (int) $oldestAvailable) : 0; + $criticalSeconds = (int) config('monitoring.health.queue_backlog_critical_seconds'); + + return $this->check( + 'queue', + 'Queue', + $waitingSeconds >= $criticalSeconds ? self::StatusCritical : self::StatusOk, + $oldestAvailable === null + ? 'Kein wartender Job.' + : 'Ältester wartender Job seit '.$waitingSeconds.' s.'.($waitingSeconds >= $criticalSeconds ? ' Worker gestoppt?' : ''), + ); + } + + /** + * @return array{key: string, label: string, status: string, detail: string} + */ + private function check(string $key, string $label, string $status, string $detail): array + { + return ['key' => $key, 'label' => $label, 'status' => $status, 'detail' => $detail]; + } +} diff --git a/dev/git-deploy-monitoring/vorlagen/laravel/app/Support/Monitoring/ErrorReportScrubber.php b/dev/git-deploy-monitoring/vorlagen/laravel/app/Support/Monitoring/ErrorReportScrubber.php new file mode 100644 index 00000000..76bdaf56 --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/laravel/app/Support/Monitoring/ErrorReportScrubber.php @@ -0,0 +1,159 @@ + + */ + public const AllowedQueryParameters = ['page', 'q', 'tab', 'sort']; + + private const EmailPattern = '/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i'; + + /** Pfadsegmente, die wie ein Token aussehen (Magic Links, Reset-Links). */ + private const TokenSegmentPattern = '#(?<=/)[A-Za-z0-9_\-]{32,}(?=/|$)#'; + + public static function beforeSend(Event $event, ?EventHint $hint = null): ?Event + { + $userId = self::authenticatedUserId(); + + $event->setUser($userId !== null ? UserDataBag::createFromUserIdentifier($userId) : null); + $event->setRequest(self::scrubRequest($event->getRequest())); + + foreach ($event->getExceptions() as $exception) { + $exception->setValue(self::scrubText($exception->getValue())); + } + + if ($event->getMessage() !== null) { + $formatted = $event->getMessageFormatted(); + + $event->setMessage( + self::scrubText($event->getMessage()), + $event->getMessageParams(), + $formatted !== null ? self::scrubText($formatted) : null, + ); + } + + return $event; + } + + /** + * @param array $request + * @return array + */ + public static function scrubRequest(array $request): array + { + if (isset($request['url']) && is_string($request['url'])) { + $request['url'] = self::scrubUrl($request['url']); + } + + if (isset($request['query_string']) && is_string($request['query_string'])) { + $request['query_string'] = self::scrubQuery($request['query_string']); + } + + unset($request['data'], $request['cookies']); + + foreach ($request['headers'] ?? [] as $name => $values) { + if (strtolower((string) $name) === 'referer') { + $request['headers'][$name] = array_map( + static fn (mixed $value): string => self::scrubUrl((string) $value), + (array) $values, + ); + } + } + + return $request; + } + + public static function scrubUrl(string $url): string + { + $query = parse_url($url, PHP_URL_QUERY); + $withoutQuery = $query !== null && $query !== false ? Str::before($url, '?') : $url; + $scrubbedPath = self::scrubPath(self::scrubPathTokens($withoutQuery)); + + return $query !== null && $query !== false && $query !== '' + ? $scrubbedPath.'?'.self::scrubQuery($query) + : $scrubbedPath; + } + + public static function scrubQuery(string $query): string + { + parse_str($query, $parameters); + + $scrubbed = []; + foreach ($parameters as $name => $value) { + $isAllowed = in_array((string) $name, self::AllowedQueryParameters, true) + || str_starts_with((string) $name, 'utm_'); + + $scrubbed[$name] = $isAllowed && is_string($value) ? $value : self::Filtered; + } + + return http_build_query($scrubbed, '', '&', PHP_QUERY_RFC3986); + } + + public static function scrubPath(string $path): string + { + return self::scrubText((string) preg_replace(self::TokenSegmentPattern, self::Filtered, $path)); + } + + public static function scrubText(string $text): string + { + return (string) preg_replace(self::EmailPattern, '[email]', $text); + } + + /** + * Ersetzt Routenparameter, deren Name auf ein Geheimnis hindeutet + * (`token`, `hash`, `signature`), im Pfad der aktuellen Route. + */ + private static function scrubPathTokens(string $url): string + { + try { + $route = request()->route(); + } catch (Throwable) { + return $url; + } + + if ($route === null) { + return $url; + } + + foreach ($route->parameters() as $name => $value) { + if (is_string($value) && $value !== '' && Str::contains(strtolower((string) $name), ['token', 'hash', 'signature'])) { + $url = str_replace(rawurlencode($value), self::Filtered, str_replace($value, self::Filtered, $url)); + } + } + + return $url; + } + + private static function authenticatedUserId(): int|string|null + { + try { + return Auth::id(); + } catch (Throwable) { + return null; + } + } +} diff --git a/dev/git-deploy-monitoring/vorlagen/laravel/app/Support/Monitoring/ErrorSignal.php b/dev/git-deploy-monitoring/vorlagen/laravel/app/Support/Monitoring/ErrorSignal.php new file mode 100644 index 00000000..db66cbc5 --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/laravel/app/Support/Monitoring/ErrorSignal.php @@ -0,0 +1,68 @@ + 'Toter interner Link', + self::AccessDenied => 'Zugriff verweigert (angemeldet)', + self::PageExpired => 'Seite abgelaufen (419)', + self::Throttled => 'Drosselung trifft angemeldeten Nutzer', + ]; + + private const MaxReportsPerWindow = 5; + + private const WindowSeconds = 600; + + /** + * @param array $context + */ + public function report(string $signal, string $location, array $context = []): bool + { + $location = Str::limit($location, 200, ''); + + $reported = RateLimiter::attempt( + 'error-signal:'.sha1($signal.'|'.$location), + self::MaxReportsPerWindow, + static fn (): bool => true, + self::WindowSeconds, + ); + + if ($reported === false) { + return false; + } + + withScope(function (Scope $scope) use ($signal, $location, $context): void { + $scope->setFingerprint(['error-signal', $signal, $location]); + $scope->setTag('signal', $signal); + $scope->setContext('signal', $context); + + captureMessage((self::Labels[$signal] ?? $signal).': '.$location, Severity::warning()); + }); + + return true; + } +} diff --git a/dev/git-deploy-monitoring/vorlagen/laravel/app/Support/Monitoring/Release.php b/dev/git-deploy-monitoring/vorlagen/laravel/app/Support/Monitoring/Release.php new file mode 100644 index 00000000..48e6aeb0 --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/laravel/app/Support/Monitoring/Release.php @@ -0,0 +1,36 @@ + RELEASE`) — + * der Live-Server hat kein Git, solange per SFTP deployt wird. + * + * Versionen sind Git-Tags nach SemVer: Feature-Deploy erhöht die mittlere, + * Hotfix die letzte Stelle. Auf dem Tag ergibt das „v1.1.0“, mit späteren + * Commits „v1.1.0-2-gabc1234“, mit uncommitteten Änderungen „…-dirty“. + */ +class Release +{ + public static function current(?string $configured, ?string $basePath = null): ?string + { + $configured = trim((string) $configured); + + if ($configured !== '') { + return $configured; + } + + $file = rtrim($basePath ?? base_path(), '/').'/RELEASE'; + + if (! is_file($file)) { + return null; + } + + $release = trim((string) file_get_contents($file)); + + return preg_match('/^[A-Za-z0-9._\-]{1,64}$/', $release) === 1 ? $release : null; + } +} diff --git a/dev/git-deploy-monitoring/vorlagen/laravel/app/Support/OperationalAlert.php b/dev/git-deploy-monitoring/vorlagen/laravel/app/Support/OperationalAlert.php new file mode 100644 index 00000000..7b3cc56f --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/laravel/app/Support/OperationalAlert.php @@ -0,0 +1,96 @@ + $context + */ + public static function send(string $summary, array $context = []): void + { + Log::channel('monitoring')->critical('[monitoring] '.$summary, $context); + + if (! config('monitoring.alerts.enabled')) { + return; + } + + self::mail($summary, $context); + self::push($summary, $context); + } + + /** + * @param array $context + */ + private static function mail(string $summary, array $context): void + { + $recipient = config('monitoring.alerts.mail_to'); + + if (empty($recipient)) { + return; + } + + try { + Mail::raw( + $summary."\n\n".json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + fn ($message) => $message->to($recipient)->subject('['.config('app.name').'] Betriebs-Alert: '.Str::limit($summary, 80)), + ); + } catch (Throwable $e) { + Log::channel('monitoring')->error('[monitoring] Alert-Mail konnte nicht versendet werden.', [ + 'summary' => $summary, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * JSON-Publish an die ntfy-Basis-URL (UTF-8-sicher, anders als Header). + * + * @param array $context + */ + private static function push(string $summary, array $context): void + { + $url = (string) config('monitoring.alerts.ntfy.url'); + $topic = (string) config('monitoring.alerts.ntfy.topic'); + + if ($url === '' || $topic === '') { + return; + } + + $details = collect($context) + ->map(fn (mixed $value, string $key): string => $key.': '.(is_scalar($value) || $value === null ? (string) $value : json_encode($value))) + ->implode("\n"); + + try { + Http::timeout(5) + ->connectTimeout(3) + ->when(filled(config('monitoring.alerts.ntfy.token')), fn ($request) => $request->withToken((string) config('monitoring.alerts.ntfy.token'))) + ->post(rtrim($url, '/'), [ + 'topic' => $topic, + 'title' => config('app.name').' ('.app()->environment().'): Betriebs-Alert', + 'message' => Str::limit(trim($summary."\n\n".$details), 3500), + 'priority' => (int) config('monitoring.alerts.ntfy.priority'), + 'tags' => ['rotating_light'], + ]) + ->throw(); + } catch (Throwable $e) { + Log::channel('monitoring')->error('[monitoring] ntfy-Push konnte nicht versendet werden.', [ + 'summary' => $summary, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/dev/git-deploy-monitoring/vorlagen/laravel/config/monitoring.php b/dev/git-deploy-monitoring/vorlagen/laravel/config/monitoring.php new file mode 100644 index 00000000..87a7f5e3 --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/laravel/config/monitoring.php @@ -0,0 +1,73 @@ + [ + 'enabled' => (bool) env('MONITORING_ALERTS_ENABLED', true), + + // Ops-Postfach. Leer = keine Alert-Mail (Log und ntfy laufen trotzdem). + 'mail_to' => env('MONITORING_ALERT_EMAIL'), + + // Zweiter Alarmweg: Push über ntfy, JSON-POST + // an die Basis-URL. Ohne URL oder Topic aus. + 'ntfy' => [ + 'url' => env('MONITORING_NTFY_URL'), + 'topic' => env('MONITORING_NTFY_TOPIC'), + 'token' => env('MONITORING_NTFY_TOKEN'), + 'priority' => (int) env('MONITORING_NTFY_PRIORITY', 4), + ], + ], + + /* + |-------------------------------------------------------------------------- + | Browserfehler + |-------------------------------------------------------------------------- + | + | Eigenes GlitchTip-Projekt für JavaScript-Fehler. Die DSN ist öffentlich + | (steht im HTML), sie erlaubt nur das Einliefern von Ereignissen. Ohne + | DSN wird kein Skript eingebunden. + | + */ + + 'browser' => [ + 'dsn' => env('SENTRY_BROWSER_DSN'), + 'sample_rate' => (float) env('SENTRY_BROWSER_SAMPLE_RATE', 1.0), + ], + + /* + |-------------------------------------------------------------------------- + | Health-Endpunkt und Scheduler-Herzschlag + |-------------------------------------------------------------------------- + | + | `/health` antwortet nur mit gültigem Token (Bearer-Header oder + | `?token=`); ohne konfiguriertes Token gibt es den Endpunkt nicht (404). + | Der Scheduler schreibt jede Minute einen Herzschlag; bleibt er aus, ist + | der Cron-Eintrag weg oder der Server hängt. + | + */ + + 'health' => [ + 'token' => env('MONITORING_HEALTH_TOKEN'), + 'queue_backlog_critical_seconds' => (int) env('MONITORING_QUEUE_BACKLOG_SECONDS', 300), + 'disk_min_free_percent' => (float) env('MONITORING_DISK_MIN_FREE_PERCENT', 10), + ], + + 'scheduler' => [ + 'heartbeat_warn_minutes' => 3, + 'heartbeat_critical_minutes' => 10, + ], + +]; diff --git a/dev/git-deploy-monitoring/vorlagen/laravel/config/sentry.php b/dev/git-deploy-monitoring/vorlagen/laravel/config/sentry.php new file mode 100644 index 00000000..a3a71cc8 --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/laravel/config/sentry.php @@ -0,0 +1,180 @@ + env('SENTRY_LARAVEL_DSN', env('SENTRY_DSN')), + + // @see https://spotlightjs.com/ + // 'spotlight' => env('SENTRY_SPOTLIGHT', false), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#logger + // 'logger' => Sentry\Logger\DebugFileLogger::class, // By default this will log to `storage_path('logs/sentry.log')` + + // The release version of your application + // Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD')) + // Gleiche Kennung wie config('app.version'): SENTRY_RELEASE bzw. + // APP_VERSION, sonst die beim Deploy geschriebene Datei RELEASE. + 'release' => Release::current(env('SENTRY_RELEASE', env('APP_VERSION'))), + + // When left empty or `null` the Laravel environment will be used (usually discovered from `APP_ENV` in your `.env`) + 'environment' => env('SENTRY_ENVIRONMENT'), + + // Override the organization ID used for trace continuation checks. + 'org_id' => env('SENTRY_ORG_ID') === null ? null : (int) env('SENTRY_ORG_ID'), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#sample_rate + 'sample_rate' => env('SENTRY_SAMPLE_RATE') === null ? 1.0 : (float) env('SENTRY_SAMPLE_RATE'), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#traces_sample_rate + 'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_TRACES_SAMPLE_RATE'), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#profiles_sample_rate + 'profiles_sample_rate' => env('SENTRY_PROFILES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_PROFILES_SAMPLE_RATE'), + + // Only continue incoming traces when the organization IDs are compatible with this SDK instance. + 'strict_trace_continuation' => env('SENTRY_STRICT_TRACE_CONTINUATION', false), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_logs + 'enable_logs' => env('SENTRY_ENABLE_LOGS', false), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_metrics + 'enable_metrics' => env('SENTRY_ENABLE_METRICS', false), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#log_flush_threshold + 'log_flush_threshold' => env('SENTRY_LOG_FLUSH_THRESHOLD') === null ? null : (int) env('SENTRY_LOG_FLUSH_THRESHOLD'), + + // The minimum log level that will be sent to Sentry as logs using the `sentry_logs` logging channel + 'logs_channel_level' => env('SENTRY_LOG_LEVEL', env('SENTRY_LOGS_LEVEL', env('LOG_LEVEL', 'debug'))), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#send_default_pii + 'send_default_pii' => false, + + // Formular- und JSON-Inhalte verlassen den Server nie. + 'max_request_body_size' => 'never', + + // User-ID statt Person, Tokens aus URL/Query/Referer, E-Mails aus Meldungen. + 'before_send' => [ErrorReportScrubber::class, 'beforeSend'], + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_exceptions + // 'ignore_exceptions' => [], + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_transactions + 'ignore_transactions' => [ + // Ignore Laravel's default health URL + '/up', + '/health', + ], + + // Breadcrumb specific configuration + 'breadcrumbs' => [ + // Capture Laravel logs as breadcrumbs + 'logs' => env('SENTRY_BREADCRUMBS_LOGS_ENABLED', false), + + // Capture Laravel cache events (hits, writes etc.) as breadcrumbs + 'cache' => env('SENTRY_BREADCRUMBS_CACHE_ENABLED', false), + + // Capture Livewire components like routes as breadcrumbs + 'livewire' => env('SENTRY_BREADCRUMBS_LIVEWIRE_ENABLED', true), + + // Capture SQL queries as breadcrumbs + 'sql_queries' => env('SENTRY_BREADCRUMBS_SQL_QUERIES_ENABLED', true), + + // Capture SQL query bindings (parameters) in SQL query breadcrumbs + 'sql_bindings' => env('SENTRY_BREADCRUMBS_SQL_BINDINGS_ENABLED', false), + + // Capture queue job information as breadcrumbs + 'queue_info' => env('SENTRY_BREADCRUMBS_QUEUE_INFO_ENABLED', true), + + // Capture command information as breadcrumbs + 'command_info' => env('SENTRY_BREADCRUMBS_COMMAND_JOBS_ENABLED', true), + + // Capture HTTP client request information as breadcrumbs + 'http_client_requests' => env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true), + + // Capture send notifications as breadcrumbs + 'notifications' => env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true), + ], + + // Performance monitoring specific configuration + 'tracing' => [ + // Trace queue jobs as their own transactions (this enables tracing for queue jobs) + 'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', true), + + // Capture queue jobs as spans when executed on the sync driver + 'queue_jobs' => env('SENTRY_TRACE_QUEUE_JOBS_ENABLED', true), + + // Capture SQL queries as spans + 'sql_queries' => env('SENTRY_TRACE_SQL_QUERIES_ENABLED', true), + + // Capture SQL query bindings (parameters) in SQL query spans + 'sql_bindings' => env('SENTRY_TRACE_SQL_BINDINGS_ENABLED', false), + + // Capture where the SQL query originated from on the SQL query spans + 'sql_origin' => env('SENTRY_TRACE_SQL_ORIGIN_ENABLED', true), + + // Define a threshold in milliseconds for SQL queries to resolve their origin + 'sql_origin_threshold_ms' => env('SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS', 100), + + // Capture views rendered as spans + 'views' => env('SENTRY_TRACE_VIEWS_ENABLED', true), + + // Capture Livewire components as spans + 'livewire' => env('SENTRY_TRACE_LIVEWIRE_ENABLED', true), + + // Capture HTTP client requests as spans + 'http_client_requests' => env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ENABLED', true), + + // Capture Laravel cache events (hits, writes etc.) as spans + 'cache' => env('SENTRY_TRACE_CACHE_ENABLED', true), + + // Capture Redis operations as spans (this enables Redis events in Laravel) + 'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false), + + // Capture where the Redis command originated from on the Redis command spans + 'redis_origin' => env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true), + + // Capture send notifications as spans + 'notifications' => env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true), + + // Enable tracing for requests without a matching route (404's) + 'missing_routes' => env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false), + + // Configures if the performance trace should continue after the response has been sent to the user until the application terminates + // This is required to capture any spans that are created after the response has been sent like queue jobs dispatched using `dispatch(...)->afterResponse()` for example + 'continue_after_response' => env('SENTRY_TRACE_CONTINUE_AFTER_RESPONSE', true), + + // Capture AI agent interactions as spans (requires laravel/ai) + 'gen_ai' => env('SENTRY_TRACE_GEN_AI_ENABLED', true), + + // Capture AI invoke_agent spans + 'gen_ai_invoke_agent' => env('SENTRY_TRACE_GEN_AI_INVOKE_AGENT_ENABLED', true), + + // Capture AI chat spans + 'gen_ai_chat' => env('SENTRY_TRACE_GEN_AI_CHAT_ENABLED', true), + + // Capture AI execute_tool spans + 'gen_ai_execute_tool' => env('SENTRY_TRACE_GEN_AI_EXECUTE_TOOL_ENABLED', true), + + // Capture AI embeddings spans + 'gen_ai_embeddings' => env('SENTRY_TRACE_GEN_AI_EMBEDDINGS_ENABLED', true), + + // Enable the tracing integrations supplied by Sentry (recommended) + 'default_integrations' => env('SENTRY_TRACE_DEFAULT_INTEGRATIONS_ENABLED', true), + ], + +]; diff --git a/dev/git-deploy-monitoring/vorlagen/laravel/resources/js/error-reporting-sdk.js b/dev/git-deploy-monitoring/vorlagen/laravel/resources/js/error-reporting-sdk.js new file mode 100644 index 00000000..402c8a1a --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/laravel/resources/js/error-reporting-sdk.js @@ -0,0 +1,5 @@ +/** + * Nur die benötigten SDK-Funktionen — der dynamische Import in + * error-reporting.js zieht so nicht das ganze Paket (Replay, Feedback …) nach. + */ +export { captureException, init, setTag, setUser } from '@sentry/browser'; diff --git a/dev/git-deploy-monitoring/vorlagen/laravel/resources/js/error-reporting.js b/dev/git-deploy-monitoring/vorlagen/laravel/resources/js/error-reporting.js new file mode 100644 index 00000000..6b936664 --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/laravel/resources/js/error-reporting.js @@ -0,0 +1,128 @@ +/** + * Browserfehler an GlitchTip. + * + * Fehlerfreie Seiten laden nur diese paar Zeilen: Das Sentry-SDK wird erst + * beim ersten Fehler nachgeladen (eigener Chunk) und meldet dann genau diesen + * Fehler. Konfiguration kommt aus , das die + * Blade-Komponente x-monitoring.browser-errors setzt. + * + * Fehlgeschlagene Livewire-Requests (5xx, 419) meldet bereits der Server. + */ +const config = (() => { + const meta = document.querySelector('meta[name="pk-error-reporting"]'); + + try { + return meta ? JSON.parse(meta.content) : null; + } catch { + return null; + } +})(); + +const ALLOWED_QUERY = ['page', 'q', 'tab', 'sort']; // wie ErrorReportScrubber::AllowedQueryParameters +const TOKEN_SEGMENT = /(?<=\/)[A-Za-z0-9_-]{32,}(?=\/|$)/g; +const EMAIL = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi; + +/** Gleiche Regeln wie ErrorReportScrubber auf dem Server. */ +function scrubUrl(value) { + try { + const url = new URL(value, window.location.origin); + url.pathname = url.pathname.replace(TOKEN_SEGMENT, '[Filtered]'); + for (const key of [...url.searchParams.keys()]) { + if (!ALLOWED_QUERY.includes(key) && !key.startsWith('utm_')) { + url.searchParams.set(key, '[Filtered]'); + } + } + url.hash = ''; + + return url.toString().replace(EMAIL, '[email]'); + } catch { + return '[Filtered]'; + } +} + +let sdk = null; + +function loadSdk() { + sdk ??= import('./error-reporting-sdk.js').then((Sentry) => { + Sentry.init({ + dsn: config.dsn, + release: config.release || undefined, + environment: config.environment, + sampleRate: config.sampleRate ?? 1, + sendDefaultPii: false, + // Eigene Listener unten statt GlobalHandlers (sonst doppelt); + // keine Breadcrumbs (Klicktexte, URLs) und keine Sessions. + integrations: (defaults) => + defaults.filter( + (integration) => + !['GlobalHandlers', 'Breadcrumbs', 'BrowserSession', 'BrowserApiErrors'].includes(integration.name), + ), + ignoreErrors: ['ResizeObserver loop', 'Script error.', 'AbortError'], + denyUrls: [/^chrome-extension:\/\//, /^moz-extension:\/\//, /^safari-(web-)?extension:\/\//], + beforeSend(event) { + if (event.request?.url) { + event.request.url = scrubUrl(event.request.url); + } + if (event.request?.headers?.Referer) { + event.request.headers.Referer = scrubUrl(event.request.headers.Referer); + } + for (const exception of event.exception?.values ?? []) { + if (exception.value) { + exception.value = exception.value.replace(EMAIL, '[email]'); + } + } + + return event; + }, + }); + + if (config.userId) { + Sentry.setUser({ id: String(config.userId) }); + } + Sentry.setTag('area', config.area); + + return Sentry; + }); + + return sdk; +} + +/** + * Promise-Ablehnungen mit Objekt statt Error kamen als „Error: [object Object]“ + * an — Typ und Inhalt mitschicken, + * damit die Quelle erkennbar ist. + */ +function rejectionToError(reason) { + if (reason instanceof Error) { + return reason; + } + + const kind = reason?.constructor?.name ?? typeof reason; + let text; + try { + text = typeof reason === 'string' ? reason : JSON.stringify(reason); + } catch { + text = String(reason); + } + + return new Error(`Unbehandelte Ablehnung (${kind}): ${String(text ?? '').slice(0, 300)}`); +} + +function capture(error) { + loadSdk() + .then((Sentry) => Sentry.captureException(error)) + .catch(() => {}); +} + +if (config?.dsn) { + window.addEventListener('error', (event) => { + // Ressourcenfehler (Bild, Skript) haben kein `error` — nur echte Skriptfehler. + if (event.error) { + capture(event.error); + } + }); + + window.addEventListener('unhandledrejection', (event) => { + capture(rejectionToError(event.reason)); + }); +} diff --git a/dev/git-deploy-monitoring/vorlagen/laravel/resources/views/components/monitoring/browser-errors.blade.php b/dev/git-deploy-monitoring/vorlagen/laravel/resources/views/components/monitoring/browser-errors.blade.php new file mode 100644 index 00000000..0b1b07b8 --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/laravel/resources/views/components/monitoring/browser-errors.blade.php @@ -0,0 +1,14 @@ +@props(['build' => 'build', 'area' => 'web']) +{{-- Browserfehler an GlitchTip. Ohne SENTRY_BROWSER_DSN + wird nichts eingebunden; das SDK selbst lädt erst beim ersten Fehler. --}} +@if (filled(config('monitoring.browser.dsn'))) + + @vite(['resources/js/error-reporting.js'], $build) +@endif diff --git a/dev/git-deploy-monitoring/vorlagen/laravel/tests/Feature/Monitoring/BrowserErrorReportingTest.php b/dev/git-deploy-monitoring/vorlagen/laravel/tests/Feature/Monitoring/BrowserErrorReportingTest.php new file mode 100644 index 00000000..f8d10f93 --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/laravel/tests/Feature/Monitoring/BrowserErrorReportingTest.php @@ -0,0 +1,38 @@ +withoutVite(); +}); + +test('nothing is rendered without a browser dsn', function () { + config()->set('monitoring.browser.dsn', null); + + expect(Blade::render(''))->toBe(''); +}); + +test('the configuration carries dsn, release and the user id only', function () { + config()->set('monitoring.browser.dsn', 'https://public@glitchtip.test/2'); + config()->set('sentry.release', 'abc1234'); + $user = User::factory()->create(['email' => 'kunde@example.com']); + $this->actingAs($user); + + $html = Blade::render(''); + + preg_match('/toMatchArray([ + 'dsn' => 'https://public@glitchtip.test/2', + 'release' => 'abc1234', + 'userId' => $user->id, + 'area' => 'portal', + ]) + ->and($html)->not->toContain('kunde@example.com'); +}); diff --git a/dev/git-deploy-monitoring/vorlagen/laravel/tests/Feature/Monitoring/ErrorReportingTest.php b/dev/git-deploy-monitoring/vorlagen/laravel/tests/Feature/Monitoring/ErrorReportingTest.php new file mode 100644 index 00000000..ca527458 --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/laravel/tests/Feature/Monitoring/ErrorReportingTest.php @@ -0,0 +1,95 @@ +create(['email' => 'kunde@example.com']); + $this->actingAs($user); + + report(new RuntimeException('Import für kunde@example.com fehlgeschlagen')); + + expect($glitchTip->events)->toHaveCount(1); + + $event = $glitchTip->events[0]; + expect($event->getUser()?->getId())->toBe($user->id) + ->and($event->getUser()?->getEmail())->toBeNull() + ->and($event->getExceptions()[0]->getValue())->toBe('Import für [email] fehlgeschlagen'); +}); + +test('exceptions laravel does not report stay out of glitchtip', function () { + $glitchTip = FakeGlitchTip::install(); + + $this->get('/diese-seite-gibt-es-nicht')->assertNotFound(); + + expect($glitchTip->events)->toBe([]); +}); + +test('a mistyped artisan command is not reported', function () { + $glitchTip = FakeGlitchTip::install(); + + report(new CommandNotFoundException('Command "config:cachels" is not defined.')); + + expect($glitchTip->events)->toBe([]); +}); + +test('request data loses tokens, signatures and bodies', function () { + $scrubbed = ErrorReportScrubber::scrubRequest([ + 'url' => 'https://app.test/einladung/5?signature=abc123&expires=1700000000&page=2', + 'query_string' => 'signature=abc123&expires=1700000000&page=2&utm_source=nl', + 'data' => ['password' => 'geheim'], + 'cookies' => ['app_session' => 'x'], + 'headers' => ['referer' => ['https://app.test/reset-password/'.str_repeat('a', 64).'?email=kunde@example.com']], + ]); + + expect($scrubbed['url'])->toBe('https://app.test/einladung/5?signature=%5BFiltered%5D&expires=%5BFiltered%5D&page=2') + ->and($scrubbed['query_string'])->toBe('signature=%5BFiltered%5D&expires=%5BFiltered%5D&page=2&utm_source=nl') + ->and($scrubbed)->not->toHaveKeys(['data', 'cookies']) + ->and($scrubbed['headers']['referer'][0])->toBe('https://app.test/reset-password/[Filtered]?email=%5BFiltered%5D'); +}); + +test('route parameters named like secrets are removed from the url', function () { + Route::middleware('web')->get('/_test/magic/{token}', function () { + $event = Event::createEvent(); + $event->setRequest(['url' => request()->fullUrl()]); + + return ErrorReportScrubber::beforeSend($event)?->getRequest()['url']; + }); + + $this->get('/_test/magic/kurz123') + ->assertSuccessful() + ->assertSee('/_test/magic/[Filtered]', false) + ->assertDontSee('kurz123'); +}); + +test('messages and exception values lose e-mail addresses', function () { + $event = Event::createEvent(); + $event->setMessage('Bounce von info@firma.de', [], 'Bounce von info@firma.de'); + $event->setExceptions([new ExceptionDataBag(new RuntimeException('Duplicate entry \'a.b@c.org\''))]); + + $scrubbed = ErrorReportScrubber::beforeSend($event); + + expect($scrubbed?->getMessage())->toBe('Bounce von [email]') + ->and($scrubbed?->getMessageFormatted())->toBe('Bounce von [email]') + ->and($scrubbed?->getExceptions()[0]->getValue())->toBe('Duplicate entry \'[email]\'') + ->and($scrubbed?->getUser())->toBeNull(); +}); + +test('the test suite never reports to the real glitchtip', function () { + // Ohne diese Absicherung landeten absichtlich provozierte Fehler + // („Queue weg“, „Datenbank nicht erreichbar“) als Issues in GlitchTip. + expect(config('sentry.dsn'))->toBeEmpty() + ->and(config('monitoring.browser.dsn'))->toBeEmpty() + ->and(SentrySdk::getCurrentHub()->getClient()?->getOptions()->getDsn())->toBeNull(); +}); diff --git a/dev/git-deploy-monitoring/vorlagen/laravel/tests/Feature/Monitoring/ErrorSignalsTest.php b/dev/git-deploy-monitoring/vorlagen/laravel/tests/Feature/Monitoring/ErrorSignalsTest.php new file mode 100644 index 00000000..2b831591 --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/laravel/tests/Feature/Monitoring/ErrorSignalsTest.php @@ -0,0 +1,71 @@ +withoutDefer(); + $this->glitchTip = FakeGlitchTip::install(); + + Route::middleware('web')->get('/_test/forbidden', fn () => abort(403))->name('test.forbidden'); + Route::middleware('web')->get('/_test/expired', fn () => abort(419))->name('test.expired'); + Route::middleware('web')->get('/_test/throttled', fn () => abort(429))->name('test.throttled'); +}); + +test('a 404 reached from one of our own pages is reported as a dead internal link', function () { + $this->get('/gibt-es-nicht', ['referer' => url('/de/presse')])->assertNotFound(); + + expect($this->glitchTip->messages())->toBe(['Toter interner Link: /gibt-es-nicht']); + + $event = $this->glitchTip->events[0]; + expect($event->getTags())->toMatchArray(['signal' => ErrorSignal::DeadInternalLink]) + ->and($event->getFingerprint())->toBe(['error-signal', ErrorSignal::DeadInternalLink, '/gibt-es-nicht']) + ->and($event->getContexts()['signal']['referer'])->toBe('/de/presse'); +}); + +test('a 404 without referer or from another site is not reported', function () { + $this->get('/gibt-es-nicht')->assertNotFound(); + $this->get('/gibt-es-nicht', ['referer' => 'https://google.com/search'])->assertNotFound(); + + expect($this->glitchTip->events)->toBe([]); +}); + +test('403 and 429 are reported only for signed-in users', function () { + $this->get('/_test/forbidden')->assertForbidden(); + $this->get('/_test/throttled')->assertTooManyRequests(); + + expect($this->glitchTip->events)->toBe([]); + + $this->actingAs(User::factory()->create()); + $this->get('/_test/forbidden')->assertForbidden(); + $this->get('/_test/throttled')->assertTooManyRequests(); + + expect($this->glitchTip->messages())->toBe([ + 'Zugriff verweigert (angemeldet): test.forbidden', + 'Drosselung trifft angemeldeten Nutzer: test.throttled', + ]); +}); + +test('an expired page is always reported, for livewire with the page as location', function () { + $this->get('/_test/expired')->assertStatus(419); + $this->get('/_test/expired', ['X-Livewire' => '1', 'referer' => url('/me/pressemitteilungen/neu')])->assertStatus(419); + + expect($this->glitchTip->messages())->toBe([ + 'Seite abgelaufen (419): test.expired', + 'Seite abgelaufen (419): livewire /me/pressemitteilungen/neu', + ]); +}); + +test('each signal group is capped at five reports per window', function () { + foreach (range(1, 7) as $attempt) { + $this->get('/_test/expired'); + } + + expect($this->glitchTip->events)->toHaveCount(5); +}); diff --git a/dev/git-deploy-monitoring/vorlagen/laravel/tests/Feature/Monitoring/HealthEndpointTest.php b/dev/git-deploy-monitoring/vorlagen/laravel/tests/Feature/Monitoring/HealthEndpointTest.php new file mode 100644 index 00000000..0b2bea2d --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/laravel/tests/Feature/Monitoring/HealthEndpointTest.php @@ -0,0 +1,91 @@ +set('monitoring.health.token', 'geheimes-monitor-token'); + config()->set('monitoring.health.disk_min_free_percent', 0); + config()->set('queue.default', 'database'); +}); + +afterEach(function () { + @unlink(HealthProbe::heartbeatPath()); +}); + +function healthHeaders(): array +{ + return ['Authorization' => 'Bearer geheimes-monitor-token']; +} + +test('the endpoint does not exist without a configured token or with a wrong one', function () { + app(HealthProbe::class)->recordHeartbeat(); + + $this->get('/health', healthHeaders())->assertOk(); + + $this->get('/health', ['Authorization' => 'Bearer falsch'])->assertNotFound(); + $this->get('/health')->assertNotFound(); + + config()->set('monitoring.health.token', null); + $this->get('/health', healthHeaders())->assertNotFound(); +}); + +test('a healthy system answers 200 with every check', function () { + config()->set('app.version', 'v1.1.0'); + $this->artisan('monitoring:heartbeat')->assertSuccessful(); + + $this->get('/health?token=geheimes-monitor-token') + ->assertOk() + ->assertHeader('Cache-Control', 'no-store, private') + ->assertJsonPath('status', 'ok') + ->assertJsonPath('version', 'v1.1.0') + ->assertJsonPath('checks.database.status', 'ok') + ->assertJsonPath('checks.cache.status', 'ok') + ->assertJsonPath('checks.queue.status', 'ok') + ->assertJsonPath('checks.scheduler.status', 'ok') + ->assertJsonStructure(['checks' => ['disk' => ['status', 'detail']]]); +}); + +test('a missing or stale heartbeat makes the endpoint fail', function () { + $this->get('/health', healthHeaders()) + ->assertStatus(503) + ->assertJsonPath('checks.scheduler.status', 'critical'); + + app(HealthProbe::class)->recordHeartbeat(); + + $this->travel(4)->minutes(); + $this->get('/health', healthHeaders())->assertOk()->assertJsonPath('checks.scheduler.status', 'warn'); + + $this->travel(7)->minutes(); + $this->get('/health', healthHeaders())->assertStatus(503)->assertJsonPath('checks.scheduler.status', 'critical'); +}); + +test('a queue backlog older than five minutes makes the endpoint fail', function () { + app(HealthProbe::class)->recordHeartbeat(); + + DB::table('jobs')->insert([ + 'queue' => 'default', + 'payload' => '{}', + 'attempts' => 0, + 'reserved_at' => null, + 'available_at' => now()->subMinutes(6)->getTimestamp(), + 'created_at' => now()->subMinutes(6)->getTimestamp(), + ]); + + $this->get('/health', healthHeaders()) + ->assertStatus(503) + ->assertJsonPath('checks.queue.status', 'critical'); +}); + +test('the endpoint skips the session and the web middleware group', function () { + app(HealthProbe::class)->recordHeartbeat(); + + $this->get('/health', healthHeaders()) + ->assertOk() + ->assertCookieMissing(config('session.cookie')); +}); diff --git a/dev/git-deploy-monitoring/vorlagen/laravel/tests/Feature/Monitoring/OperationalAlertTest.php b/dev/git-deploy-monitoring/vorlagen/laravel/tests/Feature/Monitoring/OperationalAlertTest.php new file mode 100644 index 00000000..9de5664a --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/laravel/tests/Feature/Monitoring/OperationalAlertTest.php @@ -0,0 +1,82 @@ +set('monitoring.alerts.enabled', true); + config()->set('monitoring.alerts.mail_to', null); + config()->set('monitoring.alerts.ntfy', ['url' => null, 'topic' => null, 'token' => null, 'priority' => 4]); +}); + +test('an alert always logs critical to the monitoring channel', function () { + $monitoringLog = Mockery::spy(LoggerInterface::class); + Log::shouldReceive('channel')->with('monitoring')->andReturn($monitoringLog); + + OperationalAlert::send('Geplanter Task fehlgeschlagen: demo', ['exit_code' => 1]); + + $monitoringLog->shouldHaveReceived('critical')->once(); +}); + +test('an alert mails the configured recipient', function () { + config()->set('monitoring.alerts.mail_to', 'ops@example.com'); + Event::fake([MessageSent::class]); + + OperationalAlert::send('Etwas ist kaputt', ['exit_code' => 1]); + + Event::assertDispatched(MessageSent::class, function (MessageSent $event): bool { + $recipients = array_map(fn ($address): string => $address->getAddress(), $event->message->getTo()); + + return in_array('ops@example.com', $recipients, true) + && str_contains((string) $event->message->getSubject(), 'Etwas ist kaputt'); + }); +}); + +test('an alert is pushed to ntfy as json with token and priority', function () { + config()->set('monitoring.alerts.ntfy', ['url' => 'https://push.example.com/', 'topic' => 'app-ops', 'token' => 'tk_geheim', 'priority' => 4]); + Http::fake(['push.example.com*' => Http::response(['id' => 'x'])]); + + OperationalAlert::send('Queue-Job fehlgeschlagen: Demo', ['exit_code' => 1]); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://push.example.com' + && $request->hasHeader('Authorization', 'Bearer tk_geheim') + && $request['topic'] === 'app-ops' + && $request['priority'] === 4 + && str_contains($request['message'], 'exit_code: 1')); +}); + +test('a failing push never bubbles up and nothing is sent when alerts are off', function () { + config()->set('monitoring.alerts.ntfy', ['url' => 'https://push.example.com', 'topic' => 'app-ops', 'token' => null, 'priority' => 4]); + Http::fake(['push.example.com*' => Http::response('down', 502)]); + + OperationalAlert::send('Etwas ist kaputt'); + Http::assertSentCount(1); + + config()->set('monitoring.alerts.enabled', false); + OperationalAlert::send('Etwas ist kaputt'); + Http::assertSentCount(1); +}); + +test('a failed queue job triggers an alert via the registered listener', function () { + config()->set('monitoring.alerts.ntfy', ['url' => 'https://push.example.com', 'topic' => 'app-ops', 'token' => null, 'priority' => 4]); + Http::fake(); + + $job = Mockery::mock(Job::class); + $job->shouldReceive('resolveName')->andReturn('App\\Jobs\\Demo'); + $job->shouldReceive('getQueue')->andReturn('default'); + + event(new JobFailed('database', $job, new RuntimeException('boom'))); + + Http::assertSent(fn (Request $request): bool => str_contains($request['message'], 'App\\Jobs\\Demo')); +}); diff --git a/dev/git-deploy-monitoring/vorlagen/laravel/tests/Feature/Monitoring/QueueProbeTest.php b/dev/git-deploy-monitoring/vorlagen/laravel/tests/Feature/Monitoring/QueueProbeTest.php new file mode 100644 index 00000000..69c04b25 --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/laravel/tests/Feature/Monitoring/QueueProbeTest.php @@ -0,0 +1,39 @@ +artisan('monitoring:queue-probe', ['--wait' => 30]) + ->expectsOutputToContain('Queue-Worker läuft (Test-Job nach 0 s abgeholt).') + ->assertSuccessful(); + + Sleep::assertSleptTimes(0); +}); + +test('without a worker the probe fails after the waiting time with a hint', function () { + Sleep::fake(); + Queue::fake(); + + $this->artisan('monitoring:queue-probe', ['--wait' => 5]) + ->expectsOutputToContain('Kein Queue-Worker hat den Test-Job innerhalb von 5 s abgeholt.') + ->expectsOutputToContain('Worker prüfen') + ->assertFailed(); + + Queue::assertPushed(QueueProbe::class, fn (QueueProbe $job): bool => $job->queue === null); + Sleep::assertSleptTimes(5); +}); + +test('the probe job only acknowledges its own token', function () { + (new QueueProbe('abc'))->handle(); + + expect(cache()->has(QueueProbe::cacheKey('abc')))->toBeTrue() + ->and(cache()->has(QueueProbe::cacheKey('xyz')))->toBeFalse(); +}); diff --git a/dev/git-deploy-monitoring/vorlagen/laravel/tests/Support/FakeGlitchTip.php b/dev/git-deploy-monitoring/vorlagen/laravel/tests/Support/FakeGlitchTip.php new file mode 100644 index 00000000..f3c38b1a --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/laravel/tests/Support/FakeGlitchTip.php @@ -0,0 +1,63 @@ +events + */ +class FakeGlitchTip implements TransportInterface +{ + /** @var list */ + public array $events = []; + + public static function install(): self + { + $transport = new self; + + $client = ClientBuilder::create([ + 'dsn' => 'https://public@glitchtip.test/1', + 'default_integrations' => false, + 'send_default_pii' => config('sentry.send_default_pii'), + 'max_request_body_size' => config('sentry.max_request_body_size'), + 'before_send' => config('sentry.before_send'), + ])->setTransport($transport)->getClient(); + + SentrySdk::getCurrentHub()->bindClient($client); + + return $transport; + } + + public function send(Event $event): Result + { + $this->events[] = $event; + + return new Result(ResultStatus::success(), $event); + } + + public function close(?int $timeout = null): Result + { + return new Result(ResultStatus::success()); + } + + /** + * @return list + */ + public function messages(): array + { + return array_values(array_map( + static fn (Event $event): string => (string) ($event->getMessage() ?? $event->getExceptions()[0]?->getValue()), + $this->events, + )); + } +} diff --git a/dev/git-deploy-monitoring/vorlagen/laravel/tests/Unit/Monitoring/ReleaseTest.php b/dev/git-deploy-monitoring/vorlagen/laravel/tests/Unit/Monitoring/ReleaseTest.php new file mode 100644 index 00000000..e0aff22b --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/laravel/tests/Unit/Monitoring/ReleaseTest.php @@ -0,0 +1,38 @@ +dir = sys_get_temp_dir().'/release-test-'.uniqid(); + mkdir($this->dir); +}); + +afterEach(function () { + @unlink($this->dir.'/RELEASE'); + @rmdir($this->dir); +}); + +test('the configured value wins over the release file', function () { + file_put_contents($this->dir.'/RELEASE', "abc1234\n"); + + expect(Release::current('v1.2.0', $this->dir))->toBe('v1.2.0'); +}); + +test('the release file is used when nothing is configured', function () { + file_put_contents($this->dir.'/RELEASE', "98c465b-dirty\n"); + + expect(Release::current(null, $this->dir))->toBe('98c465b-dirty') + ->and(Release::current(' ', $this->dir))->toBe('98c465b-dirty'); +}); + +test('without file or with garbage there is no release', function () { + expect(Release::current(null, $this->dir))->toBeNull(); + + file_put_contents($this->dir.'/RELEASE', ' # bei jedem Deploy'); + + expect(Release::current(null, $this->dir))->toBeNull(); +}); diff --git a/dev/git-deploy-monitoring/vorlagen/server/Caddyfile b/dev/git-deploy-monitoring/vorlagen/server/Caddyfile new file mode 100644 index 00000000..68b54215 --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/server/Caddyfile @@ -0,0 +1,14 @@ +# Reverse Proxy mit automatischem TLS (Let's Encrypt) für den Monitoring-Server. +# Wer schon nginx/Traefik/Ploi nutzt, trägt dort dieselben drei Ziele ein. + +errors.example.com { + reverse_proxy 127.0.0.1:8000 +} + +push.example.com { + reverse_proxy 127.0.0.1:2586 +} + +status.example.com { + reverse_proxy 127.0.0.1:3001 +} diff --git a/dev/git-deploy-monitoring/vorlagen/server/glitchtip-compose.yml b/dev/git-deploy-monitoring/vorlagen/server/glitchtip-compose.yml new file mode 100644 index 00000000..3f5cc499 --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/server/glitchtip-compose.yml @@ -0,0 +1,54 @@ +# GlitchTip — selbst gehostetes Fehlerprotokoll (Sentry-kompatibel). +# Grundlage: offizielle compose.sample.yml (GlitchTip 6, Stand 10/2026), +# https://glitchtip.com/documentation/install — vor dem Einsatz abgleichen. +# +# Start: docker compose -f glitchtip-compose.yml up -d +# Danach: https:// öffnen, Konto anlegen, dann +# ENABLE_USER_REGISTRATION auf "False" setzen und neu starten. + +x-environment: &default-environment + DATABASE_URL: postgres://postgres:postgres@postgres:5432/postgres + VALKEY_URL: redis://valkey:6379 + SECRET_KEY: change_me # openssl rand -hex 32 + GLITCHTIP_DOMAIN: https://errors.example.com # mit Schema + DEFAULT_FROM_EMAIL: errors@example.com + EMAIL_URL: smtp://user:password@smtp.example.com:587 # Alert-Mails + ENABLE_USER_REGISTRATION: "False" # nach dem ersten Konto aus + GLITCHTIP_RETENTION_DAYS: "90" # Aufbewahrung — in der Datenschutzerklärung nennen + ENABLE_ADMIN: "False" + ENABLE_OPENAPI: "False" + # Weniger RAM (256–512 MB): Valkey abschalten und Postgres als Queue nutzen + # VALKEY_URL: "" + # GLITCHTIP_ENABLE_LOGS: "False" + # GLITCHTIP_ENABLE_UPTIME: "False" # wir nutzen Uptime Kuma + +x-depends_on: &default-depends_on + - postgres + - valkey + +services: + postgres: + image: postgres:18 + environment: + POSTGRES_HOST_AUTH_METHOD: "trust" # nur im internen Docker-Netz erreichbar + restart: unless-stopped + volumes: + - pg-data:/var/lib/postgresql + valkey: + image: valkey/valkey:9 + restart: unless-stopped + web: + image: glitchtip/glitchtip:6 # neue Hauptversionen: https://glitchtip.com/blog/ + depends_on: *default-depends_on + ports: + - "127.0.0.1:8000:8000" # nur für den Reverse Proxy + environment: + <<: *default-environment + SERVER_ROLE: all_in_one + restart: unless-stopped + volumes: + - uploads:/code/uploads + +volumes: + pg-data: + uploads: diff --git a/dev/git-deploy-monitoring/vorlagen/server/ntfy-compose.yml b/dev/git-deploy-monitoring/vorlagen/server/ntfy-compose.yml new file mode 100644 index 00000000..964a59f1 --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/server/ntfy-compose.yml @@ -0,0 +1,17 @@ +# ntfy — Push-Benachrichtigungen aufs Handy (Betriebs-Alerts, Uptime Kuma). +# Start: docker compose -f ntfy-compose.yml up -d +# Konfiguration: ./ntfy/server.yml (Vorlage ntfy-server.yml) + +services: + ntfy: + image: binwiederhier/ntfy + command: serve + restart: unless-stopped + environment: + TZ: Europe/Berlin + volumes: + - ./ntfy/server.yml:/etc/ntfy/server.yml:ro + - ./ntfy/cache:/var/cache/ntfy + - ./ntfy/lib:/var/lib/ntfy + ports: + - "127.0.0.1:2586:80" # nur für den Reverse Proxy diff --git a/dev/git-deploy-monitoring/vorlagen/server/ntfy-server.yml b/dev/git-deploy-monitoring/vorlagen/server/ntfy-server.yml new file mode 100644 index 00000000..6fcffda2 --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/server/ntfy-server.yml @@ -0,0 +1,13 @@ +# ntfy-Serverkonfiguration (im Container: /etc/ntfy/server.yml) +base-url: "https://push.example.com" +behind-proxy: true +cache-file: "/var/cache/ntfy/cache.db" +auth-file: "/var/lib/ntfy/user.db" +# Ohne Anmeldung darf niemand lesen oder schreiben — der Topic-Name ist kein Geheimnis. +auth-default-access: "deny-all" + +# Benutzer und Rechte danach im Container anlegen: +# docker compose -f ntfy-compose.yml exec ntfy ntfy user add --role=admin ich +# docker compose -f ntfy-compose.yml exec ntfy ntfy user add meineapp-alerts +# docker compose -f ntfy-compose.yml exec ntfy ntfy access meineapp-alerts meineapp-ops write-only +# docker compose -f ntfy-compose.yml exec ntfy ntfy token add meineapp-alerts # → tk_… in die App-.env diff --git a/dev/git-deploy-monitoring/vorlagen/server/uptime-kuma-compose.yml b/dev/git-deploy-monitoring/vorlagen/server/uptime-kuma-compose.yml new file mode 100644 index 00000000..9445d592 --- /dev/null +++ b/dev/git-deploy-monitoring/vorlagen/server/uptime-kuma-compose.yml @@ -0,0 +1,12 @@ +# Uptime Kuma — externer Monitor (prüft /health und /up von außen). +# Auf einem ANDEREN Server als die überwachte App betreiben, sonst fällt er mit ihr aus. +# Start: docker compose -f uptime-kuma-compose.yml up -d + +services: + uptime-kuma: + image: louislam/uptime-kuma:2 # aktuelle Hauptversion prüfen + restart: unless-stopped + volumes: + - ./uptime-kuma:/app/data + ports: + - "127.0.0.1:3001:3001" # nur für den Reverse Proxy diff --git a/dev/glitch-tip/README.md b/dev/glitch-tip/README.md index cb6b1c79..75edfc93 100644 --- a/dev/glitch-tip/README.md +++ b/dev/glitch-tip/README.md @@ -21,7 +21,7 @@ Entscheidungen: [`../betrieb/monitoring-plan.md`](../betrieb/monitoring-plan.md) | Projekt 2 | `browser-pressekonto` — JavaScript-Fehler aus dem Browser | | Umgebungen | `production` (Live), `local` (Entwicklung). Nur `production` ist Betrieb | | Release | Git-Tag aus der Datei `RELEASE` (z. B. `v1.1.0`), siehe Abschnitt 6 | -| Aufbewahrung | 90 Tage (`GLITCHTIP_MAX_EVENT_LIFE_DAYS` auf dem GlitchTip-Server) | +| Aufbewahrung | 90 Tage (`GLITCHTIP_RETENTION_DAYS` auf dem GlitchTip-Server; ältere Versionen: `GLITCHTIP_MAX_EVENT_LIFE_DAYS`) | | Alarmierung | GlitchTip-Alert-Regel: Mail bei neuem Fehlertyp. Betriebs-Alerts der App (fehlgeschlagene Jobs/Tasks) laufen getrennt über Mail + ntfy (`push.adametz.media`, Topic `pressekonto-ops`) | GlitchTip ist **nicht** das einzige Signal. Daneben: `/health` (externer