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