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:
Kevin Adametz 2026-10-07 09:45:16 +00:00
parent d083110b00
commit 32e1349517
38 changed files with 2958 additions and 2 deletions

View file

@ -1,5 +1,7 @@
# Git-Deploy
> Allgemeine, projektunabhängige Fassung mit Vorlagen: [`../git-deploy-monitoring/`](../git-deploy-monitoring/README.md).
**Stand:** 06.10.2026 · **Status:** umgestellt am 06.10.2026, erster Git-Deploy `v1.1.3` (Protokoll unten)
Bis `v1.1.0` gingen Deploys als Paket per SFTP auf den Server

View file

@ -82,7 +82,7 @@ Ausgeführt mit Deploy v1.1.0 — vollständiger Ablauf in [`deploy-v1.1.0.md`](
**Infrastruktur (einmalig)**
1. GlitchTip auf sys.adametz.media (Docker Compose: web, worker, Postgres,
Redis; `GLITCHTIP_MAX_EVENT_LIFE_DAYS=90`). Zwei Projekte anlegen:
Redis; `GLITCHTIP_RETENTION_DAYS=90`, vor GlitchTip 6 `GLITCHTIP_MAX_EVENT_LIFE_DAYS`). Zwei Projekte anlegen:
`pressekonto-server` (PHP/Laravel) und `pressekonto-browser` (JavaScript).
Alert-Regel je Projekt: Mail an das Ops-Postfach bei neuem Fehlertyp.
2. ntfy: Server (ntfy.sh oder selbst gehostet), Topic mit Zugriffsschutz,
@ -307,3 +307,10 @@ Serialisierung ohne `$viaSyndication`) — entfernt, nicht nachgesendet.
- **Worker-Prüfung im Deploy** (`monitoring:queue-probe`, Ende von
`deploy.sh`, Exit 3 ohne Worker).
- **Runbook:** kein pauschales `queue:retry all` mehr; Vorgehen bei stehendem Worker.
### 07.10.2026 — Allgemeine Fassung für andere Projekte
Git-Deploy und Betriebsüberwachung als übertragbarer Baukasten mit Vorlagen
(Laravel-Dateien, `deploy.sh`, Docker-Compose für GlitchTip/ntfy/Uptime Kuma):
[`../git-deploy-monitoring/`](../git-deploy-monitoring/README.md). In einem
frischen Laravel 12 eingebaut und getestet.

View 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.

View 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).

View 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.

View 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`

View 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 |

View 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."

View 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

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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');
}
}

View file

@ -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;
}
}

View file

@ -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));
}
}

View file

@ -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];
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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(),
]);
}
}
}

View file

@ -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,
],
];

View 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),
],
];

View file

@ -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';

View file

@ -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));
});
}

View file

@ -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

View file

@ -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');
});

View file

@ -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();
});

View file

@ -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);
});

View file

@ -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'));
});

View file

@ -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'));
});

View file

@ -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();
});

View file

@ -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,
));
}
}

View file

@ -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();
});

View 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
}

View file

@ -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:

View 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

View 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

View file

@ -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

View file

@ -21,7 +21,7 @@ Entscheidungen: [`../betrieb/monitoring-plan.md`](../betrieb/monitoring-plan.md)
| Projekt 2 | `browser-pressekonto` — JavaScript-Fehler aus dem Browser |
| Umgebungen | `production` (Live), `local` (Entwicklung). Nur `production` ist Betrieb |
| Release | Git-Tag aus der Datei `RELEASE` (z. B. `v1.1.0`), siehe Abschnitt 6 |
| Aufbewahrung | 90 Tage (`GLITCHTIP_MAX_EVENT_LIFE_DAYS` auf dem GlitchTip-Server) |
| Aufbewahrung | 90 Tage (`GLITCHTIP_RETENTION_DAYS` auf dem GlitchTip-Server; ältere Versionen: `GLITCHTIP_MAX_EVENT_LIFE_DAYS`) |
| Alarmierung | GlitchTip-Alert-Regel: Mail bei neuem Fehlertyp. Betriebs-Alerts der App (fehlgeschlagene Jobs/Tasks) laufen getrennt über Mail + ntfy (`push.adametz.media`, Topic `pressekonto-ops`) |
GlitchTip ist **nicht** das einzige Signal. Daneben: `/health` (externer