dev: Baukasten Git-Deploy und Betriebsüberwachung für andere Laravel-Projekte (dev/git-deploy-monitoring) — Anleitungen, Checkliste, Vorlagen für Laravel, deploy.sh und Monitoring-Server
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 <noreply@anthropic.com>
This commit is contained in:
parent
c385ad9037
commit
2215826f37
38 changed files with 2958 additions and 2 deletions
|
|
@ -1,5 +1,7 @@
|
||||||
# Git-Deploy
|
# 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)
|
**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
|
Bis `v1.1.0` gingen Deploys als Paket per SFTP auf den Server
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ Ausgeführt mit Deploy v1.1.0 — vollständiger Ablauf in [`deploy-v1.1.0.md`](
|
||||||
**Infrastruktur (einmalig)**
|
**Infrastruktur (einmalig)**
|
||||||
|
|
||||||
1. GlitchTip auf sys.adametz.media (Docker Compose: web, worker, Postgres,
|
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).
|
`pressekonto-server` (PHP/Laravel) und `pressekonto-browser` (JavaScript).
|
||||||
Alert-Regel je Projekt: Mail an das Ops-Postfach bei neuem Fehlertyp.
|
Alert-Regel je Projekt: Mail an das Ops-Postfach bei neuem Fehlertyp.
|
||||||
2. ntfy: Server (ntfy.sh oder selbst gehostet), Topic mit Zugriffsschutz,
|
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
|
- **Worker-Prüfung im Deploy** (`monitoring:queue-probe`, Ende von
|
||||||
`deploy.sh`, Exit 3 ohne Worker).
|
`deploy.sh`, Exit 3 ohne Worker).
|
||||||
- **Runbook:** kein pauschales `queue:retry all` mehr; Vorgehen bei stehendem 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.
|
||||||
|
|
|
||||||
261
dev/git-deploy-monitoring/01-git-deploy.md
Normal file
261
dev/git-deploy-monitoring/01-git-deploy.md
Normal file
|
|
@ -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 <connection> --queue=<queues> --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 — <Inhalt>
|
||||||
|
|
||||||
|
Deploy: <keine Migration | N Migrationen>, <neue .env-Werte>
|
||||||
|
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 <vorheriger Tag>
|
||||||
|
```
|
||||||
|
|
||||||
|
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 <uuid>`.
|
||||||
|
- **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/<site>-error.log` ist für `ploi` lesbar.
|
||||||
256
dev/git-deploy-monitoring/02-fehlerprotokoll-glitchtip.md
Normal file
256
dev/git-deploy-monitoring/02-fehlerprotokoll-glitchtip.md
Normal file
|
|
@ -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` |
|
||||||
|
|---|---|---|
|
||||||
|
| `<app>-server` | PHP / Laravel | `SENTRY_LARAVEL_DSN` |
|
||||||
|
| `<app>-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` | `<meta name="pk-error-reporting">` 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 `<head>` jedes Layouts (Build-Ordner und Bereich anpassen):
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-monitoring.browser-errors build="build" area="web" />
|
||||||
|
```
|
||||||
|
|
||||||
|
`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 provozieren Fehler absichtlich — nie an das echte GlitchTip -->
|
||||||
|
<env name="SENTRY_LARAVEL_DSN" value="" force="true"/>
|
||||||
|
<env name="SENTRY_DSN" value="" force="true"/>
|
||||||
|
<env name="SENTRY_BROWSER_DSN" value="" force="true"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
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/<ID>/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).
|
||||||
222
dev/git-deploy-monitoring/03-betriebsueberwachung.md
Normal file
222
dev/git-deploy-monitoring/03-betriebsueberwachung.md
Normal file
|
|
@ -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(<Minuten>)` mit **kurzer** Sperrdauer
|
||||||
|
(Standard wäre 24 h — ein abgestürzter Lauf blockiert sonst einen Tag) und
|
||||||
|
`->onFailure($alertOnFailure('<name>'))`.
|
||||||
|
|
||||||
|
`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 <dein-benutzer> 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 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `<app> /health` | HTTP(s), `https://meineapp.de/health`, Header `{"Authorization": "Bearer <MONITORING_HEALTH_TOKEN>"}`, Status `200-299` | 60 s | 2 |
|
||||||
|
| `<app> /up` je Domain | HTTP(s), `https://<domain>/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/<site>-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.
|
||||||
62
dev/git-deploy-monitoring/04-checkliste-neues-projekt.md
Normal file
62
dev/git-deploy-monitoring/04-checkliste-neues-projekt.md
Normal file
|
|
@ -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(<kurz>)` und `onFailure`
|
||||||
|
- [ ] `AppServiceProvider::boot()`: `Queue::failing` → `OperationalAlert`, `AboutCommand::add`
|
||||||
|
- [ ] Vite-Eingang `resources/js/error-reporting.js`; `<x-monitoring.browser-errors build="build" area="…" />` in jedem Layout-`<head>`
|
||||||
|
- [ ] 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 <token>" 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`
|
||||||
121
dev/git-deploy-monitoring/README.md
Normal file
121
dev/git-deploy-monitoring/README.md
Normal file
|
|
@ -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 `<input>` → Alpine-Fehler im Browser, nur durch GlitchTip gefunden | Browserfehler erfassen; Muster mit Wächtertest abfangen |
|
||||||
176
dev/git-deploy-monitoring/vorlagen/deploy.sh
Executable file
176
dev/git-deploy-monitoring/vorlagen/deploy.sh
Executable file
|
|
@ -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."
|
||||||
24
dev/git-deploy-monitoring/vorlagen/env-beispiel.env
Normal file
24
dev/git-deploy-monitoring/vorlagen/env-beispiel.env
Normal file
|
|
@ -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 „<app>-server“ (PHP/Laravel)
|
||||||
|
SENTRY_BROWSER_DSN= # Projekt „<app>-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
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Monitoring\HealthProbe;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minütlicher Herzschlag des Schedulers. Bleibt er aus,
|
||||||
|
* melden `/health` und der Systemstatus den Scheduler als ausgefallen —
|
||||||
|
* `onFailure` der einzelnen Tasks greift in diesem Fall nicht mehr.
|
||||||
|
*/
|
||||||
|
class MonitoringHeartbeat extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'monitoring:heartbeat';
|
||||||
|
|
||||||
|
protected $description = 'Schreibt den Herzschlag des Schedulers (Grundlage für /health und den Systemstatus).';
|
||||||
|
|
||||||
|
public function handle(HealthProbe $probe): int
|
||||||
|
{
|
||||||
|
$probe->recordHeartbeat();
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Jobs\Monitoring\QueueProbe;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Sleep;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob ein Queue-Worker läuft: stellt einen Test-Job ein und wartet,
|
||||||
|
* bis er abgeholt ist (Aufruf am Ende von dev/betrieb/deploy.sh).
|
||||||
|
*
|
||||||
|
* Hintergrund: `queue:restart` beendet den Worker, neu starten muss ihn
|
||||||
|
* Supervisor. Läuft Supervisor nicht, bleibt die Queue still stehen.
|
||||||
|
*/
|
||||||
|
class MonitoringQueueProbe extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'monitoring:queue-probe
|
||||||
|
{--wait=60 : Sekunden, die auf den Worker gewartet wird}';
|
||||||
|
|
||||||
|
protected $description = 'Prüft mit einem Test-Job, ob ein Queue-Worker läuft.';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$token = Str::random(16);
|
||||||
|
$wait = max(0, (int) $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Services\Monitoring\HealthProbe;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tiefer Health-Check für den externen Monitor.
|
||||||
|
*
|
||||||
|
* `/up` zeigt nur, dass die App bootet. `/health` prüft zusätzlich DB,
|
||||||
|
* Cache, Queue-Rückstau, Scheduler-Herzschlag und Plattenplatz: 200 solange
|
||||||
|
* nichts kritisch ist, sonst 503. Zugang nur mit MONITORING_HEALTH_TOKEN
|
||||||
|
* (Bearer oder `?token=`); ohne konfiguriertes Token gibt es den Endpunkt nicht.
|
||||||
|
*/
|
||||||
|
class HealthController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, HealthProbe $probe): JsonResponse
|
||||||
|
{
|
||||||
|
$token = (string) config('monitoring.health.token');
|
||||||
|
$given = (string) ($request->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Support\Monitoring\ErrorReportScrubber;
|
||||||
|
use App\Support\Monitoring\ErrorSignal;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
use function Illuminate\Support\defer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meldet Antworten, hinter denen ein Bedien- oder Programmierfehler steckt,
|
||||||
|
* obwohl keine Exception gemeldet wird:
|
||||||
|
*
|
||||||
|
* - 404 mit Referrer der eigenen Domain — irgendwo bei uns steht ein toter Link
|
||||||
|
* - 403/429 für angemeldete Nutzer — die Oberfläche führt sie dorthin
|
||||||
|
* - 419 — Formular oder Livewire-Komponente abgelaufen, Eingaben sind verloren
|
||||||
|
*
|
||||||
|
* Gesendet wird nach der Antwort (`defer()`), der Nutzer wartet nicht auf GlitchTip.
|
||||||
|
*/
|
||||||
|
class ReportErrorSignals
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ErrorSignal $errorSignal) {}
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$response = $next($request);
|
||||||
|
|
||||||
|
$signal = $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs\Monitoring;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-Job für `monitoring:queue-probe`: tut nichts außer zu quittieren,
|
||||||
|
* dass ein Worker ihn abgeholt hat. Läuft auf der Standard-Queue —
|
||||||
|
* der Worker muss sie bedienen.
|
||||||
|
*/
|
||||||
|
class QueueProbe implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public int $tries = 1;
|
||||||
|
|
||||||
|
public function __construct(public string $token) {}
|
||||||
|
|
||||||
|
public static function cacheKey(string $token): string
|
||||||
|
{
|
||||||
|
return 'monitoring:queue-probe:'.$token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
Cache::put(self::cacheKey($this->token), now()->toIso8601String(), now()->addMinutes(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Monitoring;
|
||||||
|
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Prüfungen, die ein externer Monitor jede Minute abfragt
|
||||||
|
*: Datenbank, Cache, Queue-Rückstau,
|
||||||
|
* Scheduler-Herzschlag, Plattenplatz. Bewusst billig — keine Zählungen über
|
||||||
|
* große Tabellen, damit `/health` selbst keine Last erzeugt.
|
||||||
|
*
|
||||||
|
* Ein Admin-Systemstatus kann `scheduler()` und `disk()` mitbenutzen.
|
||||||
|
*/
|
||||||
|
class HealthProbe
|
||||||
|
{
|
||||||
|
public const StatusOk = 'ok';
|
||||||
|
|
||||||
|
public const StatusWarn = 'warn';
|
||||||
|
|
||||||
|
public const StatusCritical = 'critical';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datei statt Datenbank/Cache: übersteht `cache:clear`, braucht keine
|
||||||
|
* Migration. Bei mehreren App-Servern stattdessen eine DB-Zeile nehmen.
|
||||||
|
*/
|
||||||
|
public static function heartbeatPath(): string
|
||||||
|
{
|
||||||
|
return storage_path('framework/scheduler-heartbeat');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordHeartbeat(): void
|
||||||
|
{
|
||||||
|
file_put_contents(self::heartbeatPath(), now()->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<array{key: string, label: string, status: string, detail: string}>
|
||||||
|
*/
|
||||||
|
public function checks(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
$this->database(),
|
||||||
|
$this->cache(),
|
||||||
|
$this->queue(),
|
||||||
|
$this->scheduler(),
|
||||||
|
$this->disk(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{status: string}> $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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Monitoring;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Sentry\Event;
|
||||||
|
use Sentry\EventHint;
|
||||||
|
use Sentry\UserDataBag;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Letzte Station vor GlitchTip (`before_send` in config/sentry.php).
|
||||||
|
*
|
||||||
|
* Personenbezug nur über die interne User-ID. Tokens und Signaturen stecken
|
||||||
|
* in Query-Strings (signierte Links) und in Pfaden
|
||||||
|
* (`/reset-password/{token}`, Magic Links) — beides wird ersetzt, bevor das
|
||||||
|
* Ereignis den Server verlässt. E-Mail-Adressen in Fehlermeldungen (z. B.
|
||||||
|
* QueryException mit eingesetzten Bindings) ebenso.
|
||||||
|
*/
|
||||||
|
class ErrorReportScrubber
|
||||||
|
{
|
||||||
|
public const Filtered = '[Filtered]';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query-Parameter, die unverändert mitgehen dürfen (`utm_*` immer).
|
||||||
|
* Je Projekt anpassen — dieselbe Liste steht in error-reporting.js.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $request
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Monitoring;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Sentry\Severity;
|
||||||
|
use Sentry\State\Scope;
|
||||||
|
|
||||||
|
use function Sentry\captureMessage;
|
||||||
|
use function Sentry\withScope;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fehler ohne Exception als GlitchTip-Warnung: tote
|
||||||
|
* interne Links, verweigerte Zugriffe, abgelaufene Seiten. Gruppiert wird je
|
||||||
|
* Signal und Ort; je Gruppe gehen höchstens fünf Meldungen in zehn Minuten
|
||||||
|
* raus, damit ein Crawler mit gefälschtem Referrer GlitchTip nicht flutet.
|
||||||
|
*/
|
||||||
|
class ErrorSignal
|
||||||
|
{
|
||||||
|
public const DeadInternalLink = 'dead_internal_link';
|
||||||
|
|
||||||
|
public const AccessDenied = 'access_denied';
|
||||||
|
|
||||||
|
public const PageExpired = 'page_expired';
|
||||||
|
|
||||||
|
public const Throttled = 'throttled';
|
||||||
|
|
||||||
|
private const Labels = [
|
||||||
|
self::DeadInternalLink => '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<string, mixed> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\Monitoring;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versionskennung des ausgelieferten Stands — `config('app.version')` und
|
||||||
|
* Release in GlitchTip. Vorrang hat der konfigurierte Wert (APP_VERSION bzw.
|
||||||
|
* SENTRY_RELEASE); sonst die Datei `RELEASE` im Projektwurzelverzeichnis, die
|
||||||
|
* beim Deploy entsteht (`git describe --tags --always --dirty > 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentrale Betriebs-Alerts: meldet fehlgeschlagene Scheduler-Tasks und
|
||||||
|
* Queue-Jobs (Verdrahtung: siehe 03-betriebsueberwachung.md). Der Log-Eintrag (`critical`) ist die verlässliche Ebene und läuft
|
||||||
|
* immer; Mail und ntfy-Push sind Best-Effort, unabhängig
|
||||||
|
* voneinander, und dürfen den auslösenden Prozess nie mit in den Abgrund
|
||||||
|
* reißen (Mailserver down, ntfy nicht erreichbar o. ä.).
|
||||||
|
*/
|
||||||
|
class OperationalAlert
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Betriebs-Alerts
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Fehlgeschlagene Scheduler-Tasks und Queue-Jobs werden immer als
|
||||||
|
| `critical` ins Log geschrieben (rotierter `daily`-Channel) und – wenn
|
||||||
|
| aktiviert und eine Adresse gesetzt ist – zusätzlich per Best-Effort-Mail
|
||||||
|
| gemeldet. Die Mail ist bewusst nur die zweite Ebene: schlägt der Versand
|
||||||
|
| fehl, bleibt der Log-Eintrag als verlässliche Spur bestehen.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'alerts' => [
|
||||||
|
'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,
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
180
dev/git-deploy-monitoring/vorlagen/laravel/config/sentry.php
Normal file
180
dev/git-deploy-monitoring/vorlagen/laravel/config/sentry.php
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Support\Monitoring\ErrorReportScrubber;
|
||||||
|
use App\Support\Monitoring\Release;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sentry Laravel SDK configuration file.
|
||||||
|
*
|
||||||
|
* Ziel ist das selbst gehostete GlitchTip, das das
|
||||||
|
* Sentry-Protokoll spricht. Ohne DSN ist das SDK ein No-op. Abweichend vom
|
||||||
|
* Paket-Standard: kein Personenbezug außer der User-ID, keine
|
||||||
|
* Request-Bodys, keine Log-/Cache-Breadcrumbs (Lognachrichten tragen bei uns
|
||||||
|
* E-Mail-Adressen), keine Metriken (versteht GlitchTip nicht).
|
||||||
|
*
|
||||||
|
* @see https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
|
||||||
|
// @see https://docs.sentry.io/concepts/key-terms/dsn-explainer/
|
||||||
|
'dsn' => 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),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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 <meta name="pk-error-reporting">, 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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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')))
|
||||||
|
<meta name="pk-error-reporting" content="{{ json_encode([
|
||||||
|
'dsn' => config('monitoring.browser.dsn'),
|
||||||
|
'release' => config('sentry.release'),
|
||||||
|
'environment' => config('sentry.environment') ?? app()->environment(),
|
||||||
|
'sampleRate' => config('monitoring.browser.sample_rate'),
|
||||||
|
'userId' => auth()->id(),
|
||||||
|
'area' => $area,
|
||||||
|
]) }}" />
|
||||||
|
@vite(['resources/js/error-reporting.js'], $build)
|
||||||
|
@endif
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browserfehler: Meta-Konfiguration und Skript nur mit
|
||||||
|
* DSN; die User-ID ist der einzige Personenbezug.
|
||||||
|
*/
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->withoutVite();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nothing is rendered without a browser dsn', function () {
|
||||||
|
config()->set('monitoring.browser.dsn', null);
|
||||||
|
|
||||||
|
expect(Blade::render('<x-monitoring.browser-errors />'))->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('<x-monitoring.browser-errors build="build/portal" area="portal" />');
|
||||||
|
|
||||||
|
preg_match('/<meta name="pk-error-reporting" content="([^"]+)"/', $html, $match);
|
||||||
|
$config = json_decode(html_entity_decode($match[1]), true);
|
||||||
|
|
||||||
|
expect($config)->toMatchArray([
|
||||||
|
'dsn' => 'https://public@glitchtip.test/2',
|
||||||
|
'release' => 'abc1234',
|
||||||
|
'userId' => $user->id,
|
||||||
|
'area' => 'portal',
|
||||||
|
])
|
||||||
|
->and($html)->not->toContain('kunde@example.com');
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Monitoring\ErrorReportScrubber;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Sentry\Event;
|
||||||
|
use Sentry\ExceptionDataBag;
|
||||||
|
use Sentry\SentrySdk;
|
||||||
|
use Symfony\Component\Console\Exception\CommandNotFoundException;
|
||||||
|
use Tests\Support\FakeGlitchTip;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlitchTip-Anbindung: gemeldete Exceptions gehen
|
||||||
|
* raus, aber nur mit User-ID, ohne Tokens und ohne E-Mail-Adressen.
|
||||||
|
*/
|
||||||
|
test('reported exceptions reach glitchtip with the user id only', function () {
|
||||||
|
$glitchTip = FakeGlitchTip::install();
|
||||||
|
$user = User::factory()->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();
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Monitoring\ErrorSignal;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Tests\Support\FakeGlitchTip;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fehler ohne Exception: tote interne Links,
|
||||||
|
* 403/429 für Angemeldete und 419 werden als GlitchTip-Warnung gemeldet.
|
||||||
|
*/
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->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);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Services\Monitoring\HealthProbe;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tiefer Health-Check und Scheduler-Herzschlag. Der Herzschlag liegt in
|
||||||
|
* einer Datei unter storage/ — vor und nach jedem Test entfernen.
|
||||||
|
*/
|
||||||
|
beforeEach(function () {
|
||||||
|
@unlink(HealthProbe::heartbeatPath());
|
||||||
|
config()->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'));
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Support\OperationalAlert;
|
||||||
|
use Illuminate\Contracts\Queue\Job;
|
||||||
|
use Illuminate\Http\Client\Request;
|
||||||
|
use Illuminate\Mail\Events\MessageSent;
|
||||||
|
use Illuminate\Queue\Events\JobFailed;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Betriebs-Alerts: Log immer, Mail und ntfy unabhängig voneinander und
|
||||||
|
* Best-Effort — ein ausgefallener Kanal darf den auslösenden Prozess nie stören.
|
||||||
|
*/
|
||||||
|
beforeEach(function () {
|
||||||
|
config()->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'));
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\Monitoring\QueueProbe;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Illuminate\Support\Sleep;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Worker-Prüfung am Ende von deploy.sh: ohne laufenden Worker (Supervisor
|
||||||
|
* gestoppt) soll der Deploy das sofort melden.
|
||||||
|
*/
|
||||||
|
test('a running worker picks up the probe job', function () {
|
||||||
|
Sleep::fake();
|
||||||
|
|
||||||
|
$this->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();
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Support;
|
||||||
|
|
||||||
|
use Sentry\ClientBuilder;
|
||||||
|
use Sentry\Event;
|
||||||
|
use Sentry\SentrySdk;
|
||||||
|
use Sentry\Transport\Result;
|
||||||
|
use Sentry\Transport\ResultStatus;
|
||||||
|
use Sentry\Transport\TransportInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fängt Sentry-Events ab, statt sie an GlitchTip zu senden. Der Client wird
|
||||||
|
* mit den Datenschutz-Optionen aus config/sentry.php gebaut, damit Tests den
|
||||||
|
* echten `before_send`-Weg sehen.
|
||||||
|
*
|
||||||
|
* Nutzung: $glitchTip = FakeGlitchTip::install(); … $glitchTip->events
|
||||||
|
*/
|
||||||
|
class FakeGlitchTip implements TransportInterface
|
||||||
|
{
|
||||||
|
/** @var list<Event> */
|
||||||
|
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<string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return array_values(array_map(
|
||||||
|
static fn (Event $event): string => (string) ($event->getMessage() ?? $event->getExceptions()[0]?->getValue()),
|
||||||
|
$this->events,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Support\Monitoring\Release;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release-Kennung für GlitchTip: SENTRY_RELEASE vor der
|
||||||
|
* beim SFTP-Deploy hochgeladenen Datei RELEASE.
|
||||||
|
*/
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->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', '<git rev-parse --short HEAD> # bei jedem Deploy');
|
||||||
|
|
||||||
|
expect(Release::current(null, $this->dir))->toBeNull();
|
||||||
|
});
|
||||||
14
dev/git-deploy-monitoring/vorlagen/server/Caddyfile
Normal file
14
dev/git-deploy-monitoring/vorlagen/server/Caddyfile
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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://<GLITCHTIP_DOMAIN> ö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:
|
||||||
17
dev/git-deploy-monitoring/vorlagen/server/ntfy-compose.yml
Normal file
17
dev/git-deploy-monitoring/vorlagen/server/ntfy-compose.yml
Normal file
|
|
@ -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
|
||||||
13
dev/git-deploy-monitoring/vorlagen/server/ntfy-server.yml
Normal file
13
dev/git-deploy-monitoring/vorlagen/server/ntfy-server.yml
Normal file
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -21,7 +21,7 @@ Entscheidungen: [`../betrieb/monitoring-plan.md`](../betrieb/monitoring-plan.md)
|
||||||
| Projekt 2 | `browser-pressekonto` — JavaScript-Fehler aus dem Browser |
|
| Projekt 2 | `browser-pressekonto` — JavaScript-Fehler aus dem Browser |
|
||||||
| Umgebungen | `production` (Live), `local` (Entwicklung). Nur `production` ist Betrieb |
|
| Umgebungen | `production` (Live), `local` (Entwicklung). Nur `production` ist Betrieb |
|
||||||
| Release | Git-Tag aus der Datei `RELEASE` (z. B. `v1.1.0`), siehe Abschnitt 6 |
|
| 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`) |
|
| 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
|
GlitchTip ist **nicht** das einzige Signal. Daneben: `/health` (externer
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue