monitoring: Betriebsüberwachung Phase 1 — GlitchTip (Server- und Browserfehler), Fehlersignale ohne Exception, /health mit Scheduler-Herzschlag, ntfy-Alarme, fehlgeschlagene Anmeldungen im Verlauf, Umami im Kundenbereich, Versionsnummer
- GlitchTip über die Sentry-SDKs; Datenschutzfilter (nur User-ID, keine
Request-Bodys, Tokens aus URL/Query/Referer, E-Mails aus Meldungen).
Browser-SDK erst beim ersten Fehler nachgeladen.
- ReportErrorSignals: tote interne Links (404 mit eigenem Referrer), 403/429
für Angemeldete, 419 als gedrosselte Warnung.
- /health (Bearer-Token): DB, Cache, Queue-Rückstau, Scheduler-Herzschlag,
Plattenplatz → 200/503; Scheduler und Plattenplatz auch im Systemstatus.
- OperationalAlert zusätzlich per ntfy (JSON-POST), unabhängig von der Mail.
- Verlauf: login_failed (Grund unbekannt/kein Passwort/falsch),
login_lockout, two_factor_failed; IP nur als gekürzter HMAC.
- Umami: Kundenbereich mit eigenem Schalter und identify('user-<id>'),
nie im Admin/bei Impersonation; Query-Filter auch auf den Webseiten
(signierte Links gingen bisher an Umami).
- queue:prune-failed nach 30 Tagen.
- Versionsnummer: config('app.version') aus APP_VERSION oder Datei RELEASE
(git describe --tags), in GlitchTip, /health, Systemstatus, artisan about.
Plan: dev/betrieb/monitoring-plan.md (Entscheidungen M1–M9).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2079dfdaa8
commit
d884eb7482
42 changed files with 1896 additions and 9 deletions
25
app/Console/Commands/MonitoringHeartbeat.php
Normal file
25
app/Console/Commands/MonitoringHeartbeat.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Monitoring\HealthProbe;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Minütlicher Herzschlag des Schedulers (Monitoring-Plan 1.4). 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -37,6 +37,13 @@ enum ActivityType: string
|
|||
/** Korrekturweg Bildnachweis/Kennzeichnung (Bildkennzeichnung E.6). */
|
||||
case ImageCorrection = 'image_correction';
|
||||
|
||||
/** Fehlgeschlagene Anmeldung, Sperre, falscher 2FA-Code (Monitoring-Plan 1.7). */
|
||||
case LoginFailed = 'login_failed';
|
||||
|
||||
case LoginLockout = 'login_lockout';
|
||||
|
||||
case TwoFactorFailed = 'two_factor_failed';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
|
|
@ -63,6 +70,9 @@ enum ActivityType: string
|
|||
self::ManualInvoice => 'Rechnung (MAN)',
|
||||
self::EvidenceAccess => 'Beweiszugriff',
|
||||
self::ImageCorrection => 'Bildkorrektur',
|
||||
self::LoginFailed => 'Anmeldung fehlgeschlagen',
|
||||
self::LoginLockout => 'Anmeldesperre',
|
||||
self::TwoFactorFailed => '2FA fehlgeschlagen',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -90,6 +100,8 @@ enum ActivityType: string
|
|||
self::ImageCorrection => 'blue',
|
||||
self::MailSuppressed => 'orange',
|
||||
self::ManualInvoice => 'amber',
|
||||
self::LoginFailed, self::TwoFactorFailed => 'orange',
|
||||
self::LoginLockout => 'red',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
39
app/Http/Controllers/HealthController.php
Normal file
39
app/Http/Controllers/HealthController.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Monitoring\HealthProbe;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Tiefer Health-Check für den externen Monitor (Monitoring-Plan 1.5).
|
||||
*
|
||||
* `/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');
|
||||
}
|
||||
}
|
||||
|
|
@ -50,6 +50,13 @@ class BasicAuthMiddleware
|
|||
return $next($request);
|
||||
}
|
||||
|
||||
// Skip Basic Auth für den Health-Endpunkt — er hat ein eigenes
|
||||
// Bearer-Token, und ein Monitor kann nicht beides im
|
||||
// Authorization-Header schicken (Monitoring-Plan 1.5).
|
||||
if ($request->is('health')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Zugangsdaten aus der Config (gespeist aus .env). Ist nichts
|
||||
// konfiguriert, wird der Gate nicht erzwungen.
|
||||
$user = config('auth.basic.user');
|
||||
|
|
|
|||
96
app/Http/Middleware/ReportErrorSignals.php
Normal file
96
app/Http/Middleware/ReportErrorSignals.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\Monitoring\ErrorReportScrubber;
|
||||
use App\Support\Monitoring\ErrorSignal;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
use function Illuminate\Support\defer;
|
||||
|
||||
/**
|
||||
* Meldet Antworten, hinter denen ein Bedien- oder Programmierfehler steckt,
|
||||
* obwohl keine Exception gemeldet wird (Monitoring-Plan 1.3):
|
||||
*
|
||||
* - 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,11 +9,16 @@ use App\Models\User;
|
|||
use App\Services\ActivityLog;
|
||||
use App\Services\Auth\LoginCompletion;
|
||||
use App\Services\Conversion\ConversionRecorder;
|
||||
use Illuminate\Auth\Events\Failed;
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Auth\Events\Login;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\Request;
|
||||
use Laravel\Fortify\Events\TwoFactorAuthenticationFailed;
|
||||
|
||||
/**
|
||||
* Protokolliert Registrierungen und Logins im Aktivitäts-Verlauf.
|
||||
* Protokolliert Registrierungen, Logins und gescheiterte Anmeldungen im
|
||||
* Aktivitäts-Verlauf.
|
||||
*
|
||||
* Der Anmeldeweg kommt als Request-Attribut `auth_method` von der jeweils
|
||||
* einloggenden Stelle (Magic-Link-/Google-Controller, Impersonation), nach
|
||||
|
|
@ -28,6 +33,12 @@ class RecordAuthActivity
|
|||
{
|
||||
public const AUTH_METHOD_ATTRIBUTE = 'auth_method';
|
||||
|
||||
private const FAILURE_LABELS = [
|
||||
'unknown_account' => 'unbekannte Adresse',
|
||||
'no_password' => 'Konto ohne Passwort (Magic Link/Google)',
|
||||
'wrong_password' => 'falsches Passwort',
|
||||
];
|
||||
|
||||
private const METHOD_LABELS = [
|
||||
'password' => 'Passwort',
|
||||
'magic-link' => 'Magic-Link',
|
||||
|
|
@ -84,4 +95,69 @@ class RecordAuthActivity
|
|||
meta: ['method' => $method],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gescheiterte Passwort-Anmeldung (Monitoring-Plan 1.7). Aus den
|
||||
* Zugangsdaten wird nur die Adresse gelesen — nie das Passwort. Der
|
||||
* Grund unterscheidet Angriff/Tippfehler von einem Bedienproblem: Ein
|
||||
* Konto ohne Passwort heißt, der Kunde kennt den Magic-Link-Weg nicht.
|
||||
*/
|
||||
public function recordFailedLogin(Failed $event): void
|
||||
{
|
||||
$user = $event->user instanceof User ? $event->user : null;
|
||||
$reason = match (true) {
|
||||
$user === null => 'unknown_account',
|
||||
$user->password === null => 'no_password',
|
||||
default => 'wrong_password',
|
||||
};
|
||||
|
||||
$this->activityLog->record(
|
||||
ActivityType::LoginFailed,
|
||||
$user !== null
|
||||
? sprintf('Anmeldung für %s (%s) fehlgeschlagen — %s.', $user->name, $user->email, self::FAILURE_LABELS[$reason])
|
||||
: 'Anmeldung mit unbekannter Adresse fehlgeschlagen.',
|
||||
user: $user,
|
||||
meta: ['reason' => $reason, 'ip_hash' => self::ipHash(request())],
|
||||
actorLabel: $user->name ?? 'unbekannt',
|
||||
);
|
||||
}
|
||||
|
||||
public function recordLockout(Lockout $event): void
|
||||
{
|
||||
$email = (string) $event->request->input('email', '');
|
||||
$user = $email !== '' ? User::query()->where('email', $email)->first() : null;
|
||||
|
||||
$this->activityLog->record(
|
||||
ActivityType::LoginLockout,
|
||||
$user !== null
|
||||
? sprintf('Anmeldung für %s (%s) nach zu vielen Fehlversuchen gesperrt.', $user->name, $user->email)
|
||||
: 'Anmeldung nach zu vielen Fehlversuchen gesperrt (unbekannte Adresse).',
|
||||
user: $user,
|
||||
meta: ['ip_hash' => self::ipHash($event->request)],
|
||||
actorLabel: $user->name ?? 'unbekannt',
|
||||
);
|
||||
}
|
||||
|
||||
public function recordTwoFactorFailed(TwoFactorAuthenticationFailed $event): void
|
||||
{
|
||||
$user = $event->user;
|
||||
|
||||
$this->activityLog->record(
|
||||
ActivityType::TwoFactorFailed,
|
||||
sprintf('%s (%s) hat einen falschen 2FA- bzw. Wiederherstellungs-Code eingegeben.', $user->name, $user->email),
|
||||
user: $user,
|
||||
meta: ['ip_hash' => self::ipHash(request())],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gekürzter HMAC der IP (M9): erkennt „dieselbe Quelle“ über mehrere
|
||||
* Versuche, ohne die Adresse selbst zu speichern.
|
||||
*/
|
||||
public static function ipHash(Request $request): ?string
|
||||
{
|
||||
$ip = $request->ip();
|
||||
|
||||
return $ip !== null ? substr(hash_hmac('sha256', $ip, (string) config('app.key')), 0, 12) : null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ use App\Services\Newsletter\NullNewsletterSyncClient;
|
|||
use App\Services\PressRelease\PressReleaseService;
|
||||
use App\Support\OperationalAlert;
|
||||
use App\Support\WebPageCache;
|
||||
use Illuminate\Auth\Events\Failed;
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Auth\Events\Login;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
|
|
@ -51,6 +53,7 @@ use Illuminate\Auth\Notifications\ResetPassword;
|
|||
use Illuminate\Auth\Notifications\VerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Events\QueryExecuted;
|
||||
use Illuminate\Foundation\Console\AboutCommand;
|
||||
use Illuminate\Mail\Events\MessageSending;
|
||||
use Illuminate\Mail\Events\MessageSent;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
|
@ -62,6 +65,7 @@ use Illuminate\Support\Facades\Queue;
|
|||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Laravel\Cashier\Cashier;
|
||||
use Laravel\Fortify\Events\TwoFactorAuthenticationFailed;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
|
@ -99,6 +103,9 @@ class AppServiceProvider extends ServiceProvider
|
|||
{
|
||||
Model::preventLazyLoading(app()->isLocal());
|
||||
|
||||
// Ausgelieferte Version in `php artisan about` (Datei RELEASE bzw. APP_VERSION).
|
||||
AboutCommand::add('Pressekonto', fn (): array => ['Version' => config('app.version') ?? '—']);
|
||||
|
||||
// Die Generations-Nummer der Seiten-Caches wird prozessintern gemerkt.
|
||||
// Ein Queue-Worker lebt über beliebig viele Jobs hinweg und würde sonst
|
||||
// dauerhaft mit dem Stand seines ersten Zugriffs arbeiten — Keys einer
|
||||
|
|
@ -166,6 +173,9 @@ class AppServiceProvider extends ServiceProvider
|
|||
// Login; Formular-Eingänge laufen über den ActivityLogObserver unten.
|
||||
Event::listen(Registered::class, [RecordAuthActivity::class, 'recordRegistered']);
|
||||
Event::listen(Login::class, [RecordAuthActivity::class, 'recordLogin']);
|
||||
Event::listen(Failed::class, [RecordAuthActivity::class, 'recordFailedLogin']);
|
||||
Event::listen(Lockout::class, [RecordAuthActivity::class, 'recordLockout']);
|
||||
Event::listen(TwoFactorAuthenticationFailed::class, [RecordAuthActivity::class, 'recordTwoFactorFailed']);
|
||||
|
||||
// Anmeldezeitpunkt in der Session — Grundlage für das Beenden fremder
|
||||
// Sessions nach einer Kontoübernahme (Audit K-04, SessionRevocation).
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use App\Models\PressReleaseAttachment;
|
|||
use App\Models\PressReleaseImage;
|
||||
use App\Services\Archive\ArchivePendingQueue;
|
||||
use App\Services\Archive\EvidenceArchive;
|
||||
use App\Services\Monitoring\HealthProbe;
|
||||
use App\Services\PressRelease\Images\PressReleaseImageRemover;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
|
@ -160,6 +161,12 @@ class AdminSystemHealth
|
|||
'detail' => $this->queueWorkerDetail($queue),
|
||||
];
|
||||
|
||||
// Scheduler-Herzschlag und Plattenplatz — dieselben Prüfungen, die
|
||||
// der externe Monitor über /health abfragt (Monitoring-Plan 1.4/1.5).
|
||||
$probe = app(HealthProbe::class);
|
||||
$checks[] = $probe->scheduler();
|
||||
$checks[] = $probe->disk();
|
||||
|
||||
// Neue Permissions ausgerollt?
|
||||
$expected = PermissionCatalog::adminOnly();
|
||||
$existing = Permission::query()->whereIn('name', $expected)->pluck('name')->all();
|
||||
|
|
|
|||
193
app/Services/Monitoring/HealthProbe.php
Normal file
193
app/Services/Monitoring/HealthProbe.php
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Monitoring;
|
||||
|
||||
use App\Models\AppSetting;
|
||||
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
|
||||
* (Monitoring-Plan 1.4, 1.5): Datenbank, Cache, Queue-Rückstau,
|
||||
* Scheduler-Herzschlag, Plattenplatz. Bewusst billig — keine Zählungen über
|
||||
* große Tabellen, damit `/health` selbst keine Last erzeugt.
|
||||
*
|
||||
* Der Systemstatus im Admin (AdminSystemHealth) übernimmt Scheduler und
|
||||
* Plattenplatz von hier.
|
||||
*/
|
||||
class HealthProbe
|
||||
{
|
||||
public const StatusOk = 'ok';
|
||||
|
||||
public const StatusWarn = 'warn';
|
||||
|
||||
public const StatusCritical = 'critical';
|
||||
|
||||
public const HeartbeatKey = 'monitoring.scheduler_heartbeat';
|
||||
|
||||
public function recordHeartbeat(): void
|
||||
{
|
||||
AppSetting::setValue(self::HeartbeatKey, now()->toIso8601String());
|
||||
}
|
||||
|
||||
public function lastHeartbeat(): ?Carbon
|
||||
{
|
||||
$value = AppSetting::getValueFresh(self::HeartbeatKey);
|
||||
|
||||
return is_string($value) ? Carbon::parse($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];
|
||||
}
|
||||
}
|
||||
158
app/Support/Monitoring/ErrorReportScrubber.php
Normal file
158
app/Support/Monitoring/ErrorReportScrubber.php
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<?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`, Monitoring-Plan M4).
|
||||
*
|
||||
* Personenbezug nur über die interne User-ID. Tokens und Signaturen stecken
|
||||
* bei uns in Query-Strings (signierte Links, Claim-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.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const AllowedQueryParameters = ['src', 'pr', 'ref', 'page', 'q', 'tab', 'edition'];
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
68
app/Support/Monitoring/ErrorSignal.php
Normal file
68
app/Support/Monitoring/ErrorSignal.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support\Monitoring;
|
||||
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Sentry\Severity;
|
||||
use Sentry\State\Scope;
|
||||
|
||||
use function Sentry\captureMessage;
|
||||
use function Sentry\withScope;
|
||||
|
||||
/**
|
||||
* Fehler ohne Exception als GlitchTip-Warnung (Monitoring-Plan 1.3): 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;
|
||||
}
|
||||
}
|
||||
36
app/Support/Monitoring/Release.php
Normal file
36
app/Support/Monitoring/Release.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support\Monitoring;
|
||||
|
||||
/**
|
||||
* Versionskennung des ausgelieferten Stands — `config('app.version')` und
|
||||
* Release in GlitchTip. Vorrang hat der konfigurierte Wert (APP_VERSION bzw.
|
||||
* SENTRY_RELEASE); sonst die Datei `RELEASE` im Projektwurzelverzeichnis, die
|
||||
* beim Deploy entsteht (`git describe --tags --always --dirty > RELEASE`) —
|
||||
* der Live-Server hat kein Git, solange per SFTP deployt wird.
|
||||
*
|
||||
* Versionen sind Git-Tags nach SemVer: Feature-Deploy erhöht die mittlere,
|
||||
* Hotfix die letzte Stelle. Auf dem Tag ergibt das „v1.1.0“, mit späteren
|
||||
* Commits „v1.1.0-2-gabc1234“, mit uncommitteten Änderungen „…-dirty“.
|
||||
*/
|
||||
class Release
|
||||
{
|
||||
public static function current(?string $configured, ?string $basePath = null): ?string
|
||||
{
|
||||
$configured = trim((string) $configured);
|
||||
|
||||
if ($configured !== '') {
|
||||
return $configured;
|
||||
}
|
||||
|
||||
$file = rtrim($basePath ?? base_path(), '/').'/RELEASE';
|
||||
|
||||
if (! is_file($file)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$release = trim((string) file_get_contents($file));
|
||||
|
||||
return preg_match('/^[A-Za-z0-9._\-]{1,64}$/', $release) === 1 ? $release : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,15 +3,18 @@
|
|||
namespace App\Support;
|
||||
|
||||
use App\Mail\OperationalAlertMail;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Zentrale Betriebs-Alerts (R5.3): meldet fehlgeschlagene Scheduler-Tasks und
|
||||
* Queue-Jobs. Der Log-Eintrag (`critical`) ist die verlässliche Ebene und läuft
|
||||
* immer; die Mail ist Best-Effort und darf den auslösenden Prozess nie mit in
|
||||
* den Abgrund reißen (Mailserver down o. ä.).
|
||||
* immer; Mail und ntfy-Push (Monitoring-Plan M3) 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
|
||||
{
|
||||
|
|
@ -26,6 +29,15 @@ class OperationalAlert
|
|||
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)) {
|
||||
|
|
@ -41,4 +53,42 @@ class OperationalAlert
|
|||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue