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
20
.env.example
20
.env.example
|
|
@ -36,6 +36,22 @@ LOG_LEVEL=debug
|
|||
# gemeldet (Fallback: LEGAL_CONTACT_EMAIL). In Prod echten Mailer voraussetzen.
|
||||
MONITORING_ALERTS_ENABLED=true
|
||||
MONITORING_ALERT_EMAIL=
|
||||
# Zweiter Alarmweg per ntfy (Monitoring-Plan M3) — ohne URL/Topic aus.
|
||||
MONITORING_NTFY_URL=
|
||||
MONITORING_NTFY_TOPIC=
|
||||
MONITORING_NTFY_TOKEN=
|
||||
MONITORING_NTFY_PRIORITY=4
|
||||
# Tiefer Health-Check /health für den externen Monitor (Bearer oder ?token=).
|
||||
# Ohne Token gibt es den Endpunkt nicht.
|
||||
MONITORING_HEALTH_TOKEN=
|
||||
|
||||
# Fehler an GlitchTip (selbst gehostet, Sentry-Protokoll; Monitoring-Plan
|
||||
# 1.1/1.2). Ohne DSN passiert nichts. SENTRY_RELEASE bei jedem Deploy auf
|
||||
# den Git-Hash setzen, dann ordnet GlitchTip Fehler der Version zu.
|
||||
SENTRY_LARAVEL_DSN=
|
||||
SENTRY_BROWSER_DSN=
|
||||
SENTRY_ENVIRONMENT=
|
||||
SENTRY_RELEASE=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-Domain-Setup (config/domains.php)
|
||||
|
|
@ -254,6 +270,10 @@ BILLING_DEPUBLICATION_PRICE_CENTS=3500
|
|||
# config/domains.php). Erst beim Go-Live der Webseiten auf true setzen —
|
||||
# die Datenschutzerklärung (Abschnitt 5) beschreibt den Einsatz bereits.
|
||||
UMAMI_ENABLED=false
|
||||
# Kundenbereich und Zuordnung zur User-ID getrennt (Monitoring-Plan M6) —
|
||||
# erst nach Ergänzung der Datenschutzerklärung einschalten.
|
||||
UMAMI_PORTAL_ENABLED=false
|
||||
UMAMI_IDENTIFY_USERS=false
|
||||
|
||||
# Haupt-E-Mail für alle rechtlichen Anliegen (Impressum, Datenschutz, AGB,
|
||||
# Meldeverfahren, Richtlinien) — auf allen drei Domains identisch.
|
||||
|
|
|
|||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -71,3 +71,8 @@ docs/legal/
|
|||
|
||||
# Schlüsseldateien (Search-Console-Service-Konto), nie ins Repo
|
||||
config/files/
|
||||
|
||||
# Release-Kennung für GlitchTip, entsteht beim Deploy (dev/betrieb/monitoring-plan.md)
|
||||
/RELEASE
|
||||
/deploy-files.txt
|
||||
/deploy-*.tgz
|
||||
|
|
|
|||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\HealthController;
|
||||
use App\Http\Middleware\BasicAuthMiddleware;
|
||||
use App\Http\Middleware\EnsureSessionIsNotRevoked;
|
||||
use App\Http\Middleware\LogApiUsage;
|
||||
|
|
@ -7,6 +8,7 @@ use App\Http\Middleware\RecordMailLinkClick;
|
|||
use App\Http\Middleware\RedirectPortalOnlyPagesFromHub;
|
||||
use App\Http\Middleware\RedirectWwwToApex;
|
||||
use App\Http\Middleware\RejectLegacyApiKeys;
|
||||
use App\Http\Middleware\ReportErrorSignals;
|
||||
use App\Http\Middleware\SecurityHeaders;
|
||||
use App\Http\Middleware\SetCurrentPortal;
|
||||
use App\Http\Middleware\SetEdition;
|
||||
|
|
@ -17,6 +19,7 @@ use Illuminate\Http\Request;
|
|||
use Illuminate\Support\Facades\Route;
|
||||
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
|
||||
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
|
||||
use Sentry\Laravel\Integration;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
|
|
@ -28,6 +31,12 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
// Legacy-API-Aliasse (/service/… ohne /api-Präfix) in der
|
||||
// api-Middleware-Gruppe — Übernahme bestehender Integrationen.
|
||||
Route::middleware('api')->group(__DIR__.'/../routes/legacy-api.php');
|
||||
|
||||
// Tiefer Health-Check für den externen Monitor (Monitoring-Plan
|
||||
// 1.5) — ohne web-/api-Gruppe: keine Session, kein API-Log.
|
||||
Route::get('/health', HealthController::class)
|
||||
->middleware('throttle:30,1')
|
||||
->name('health.deep');
|
||||
},
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
|
|
@ -40,6 +49,11 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
// Security-Header auf allen Antworten (Web + API), Proxy-unabhängig.
|
||||
$middleware->append(SecurityHeaders::class);
|
||||
|
||||
// Fehler ohne Exception (tote interne Links, 403/429 für Angemeldete,
|
||||
// 419) als GlitchTip-Warnung — global, weil 404 vor jedem Routing
|
||||
// entsteht (Monitoring-Plan 1.3).
|
||||
$middleware->append(ReportErrorSignals::class);
|
||||
|
||||
// Portal-Kontext nach dem Theme-Provider setzen (liest config('app.theme'))
|
||||
$middleware->append(SetCurrentPortal::class);
|
||||
|
||||
|
|
@ -143,5 +157,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
}, subdomains: false);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
//
|
||||
// Gemeldete Exceptions zusätzlich an GlitchTip (Monitoring-Plan 1.1).
|
||||
// Ohne SENTRY_LARAVEL_DSN ein No-op; Laravels dontReport-Liste (404,
|
||||
// Validierung, Auth) gilt weiter — diese Fälle meldet
|
||||
// ReportErrorSignals gezielt als Warnung.
|
||||
Integration::handles($exceptions);
|
||||
})->create();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Support\Monitoring\Release;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|
|
@ -15,6 +17,20 @@ return [
|
|||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Version
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Ausgelieferter Stand (dev/betrieb/monitoring-plan.md, Versionierung):
|
||||
| APP_VERSION, sonst die beim Deploy geschriebene Datei RELEASE
|
||||
| (`git describe --tags --always --dirty`, z. B. „v1.1.0“). Dieselbe
|
||||
| Kennung nutzen GlitchTip, /health und der Admin-Systemstatus.
|
||||
|
|
||||
*/
|
||||
|
||||
'version' => Release::current(env('APP_VERSION')),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Environment
|
||||
|
|
|
|||
|
|
@ -21,6 +21,54 @@ return [
|
|||
// Ops-Postfach. Fällt auf die zentrale Rechts-/Betreiber-Adresse
|
||||
// zurück, solange kein dediziertes Monitoring-Postfach gesetzt ist.
|
||||
'mail_to' => env('MONITORING_ALERT_EMAIL', env('LEGAL_CONTACT_EMAIL', 'info@pressekonto.com')),
|
||||
|
||||
// Zweiter Alarmweg (Monitoring-Plan M3): 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 (Monitoring-Plan 1.2)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 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 (Monitoring-Plan 1.4, 1.5)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| `/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
config/sentry.php
Normal file
180
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 (Monitoring-Plan M1), das das
|
||||
* Sentry-Protokoll spricht. Ohne DSN ist das SDK ein No-op. Abweichend vom
|
||||
* Paket-Standard (M4): 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),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -57,5 +57,11 @@ return [
|
|||
'umami' => [
|
||||
'enabled' => env('UMAMI_ENABLED', false),
|
||||
'script_url' => env('UMAMI_SCRIPT_URL', 'https://sys.adametz.media/config.js'),
|
||||
|
||||
// Kundenbereich (Monitoring-Plan M2/M6): eigener Schalter, und die
|
||||
// Zuordnung zur User-ID noch einmal getrennt — beides erst nach der
|
||||
// Ergänzung der Datenschutzerklärung einschalten.
|
||||
'portal_enabled' => env('UMAMI_PORTAL_ENABLED', false),
|
||||
'identify_users' => env('UMAMI_IDENTIFY_USERS', false),
|
||||
],
|
||||
];
|
||||
|
|
|
|||
5
resources/js/error-reporting-sdk.js
Normal file
5
resources/js/error-reporting-sdk.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* Nur die benötigten SDK-Funktionen — der dynamische Import in
|
||||
* error-reporting.js zieht so nicht das ganze Paket (Replay, Feedback …) nach.
|
||||
*/
|
||||
export { captureException, init, setTag, setUser } from '@sentry/browser';
|
||||
107
resources/js/error-reporting.js
Normal file
107
resources/js/error-reporting.js
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* Browserfehler an GlitchTip (Monitoring-Plan 1.2, M5).
|
||||
*
|
||||
* 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 = ['src', 'pr', 'ref', 'page', 'q', 'tab', 'edition'];
|
||||
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;
|
||||
}
|
||||
|
||||
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(event.reason instanceof Error ? event.reason : new Error(String(event.reason)));
|
||||
});
|
||||
}
|
||||
|
|
@ -59,6 +59,7 @@
|
|||
wire:model würden brechen (siehe Browser-Logs vor diesem Fix). --}}
|
||||
@vite([$themeCssPath], $assetsDir)
|
||||
@livewireStyles
|
||||
<x-monitoring.browser-errors :build="$assetsDir" area="auth" />
|
||||
</head>
|
||||
|
||||
<body class="font-sans text-ink antialiased" style="background-color: #f6f4ef;">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
@props(['build' => 'build/web', 'area' => 'web'])
|
||||
{{-- Browserfehler an GlitchTip (Monitoring-Plan 1.2). 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
|
||||
|
|
@ -1,13 +1,57 @@
|
|||
@props(['area' => 'web'])
|
||||
@php
|
||||
// Selbst gehostete, cookielose Webanalyse (Datenschutz Abschnitt 5).
|
||||
// Deaktiviert bis zum Go-Live (UMAMI_ENABLED, config/services.php);
|
||||
// die Website-ID kommt je Brand aus config/domains.php.
|
||||
// Webseiten: UMAMI_ENABLED. Kundenbereich (Monitoring-Plan M2, M6, M7):
|
||||
// zusätzlich UMAMI_PORTAL_ENABLED, nur unter `me.*` und nie während einer
|
||||
// Impersonation; die Zuordnung zur User-ID nur mit UMAMI_IDENTIFY_USERS.
|
||||
// Die Website-ID kommt je Brand aus config/domains.php, `data-tag`
|
||||
// trennt Portal und Webseiten in der Auswertung.
|
||||
$umamiHost = request()->getHost();
|
||||
$umamiBrandKey = str_contains($umamiHost, 'pressekonto')
|
||||
? 'pressekonto'
|
||||
: (str_contains($umamiHost, 'presseecho') ? 'presseecho' : 'businessportal24');
|
||||
$umamiWebsiteId = config('domains.domains.'.$umamiBrandKey.'.brand.umami_website_id');
|
||||
$umamiIsPortal = $area === 'portal';
|
||||
$umamiEnabled = config('services.umami.enabled') && $umamiWebsiteId && (! $umamiIsPortal || (
|
||||
config('services.umami.portal_enabled')
|
||||
&& request()->routeIs('me.*')
|
||||
&& ! app(\App\Actions\Admin\UserImpersonation::class)->isActive()
|
||||
));
|
||||
$umamiIdentity = $umamiIsPortal && config('services.umami.identify_users') && auth()->check()
|
||||
? 'user-'.auth()->id()
|
||||
: null;
|
||||
@endphp
|
||||
@if (config('services.umami.enabled') && $umamiWebsiteId)
|
||||
<script defer src="{{ config('services.umami.script_url') }}" data-website-id="{{ $umamiWebsiteId }}"></script>
|
||||
@if ($umamiEnabled)
|
||||
{{-- Query-Filter (M8): Nur utm_*, src, pr, ref, page, q, tab gehen raus —
|
||||
signierte Links und Tokens nie. Tokenartige Pfadsegmente ebenso. --}}
|
||||
<script>
|
||||
window.pkUmamiBeforeSend = window.pkUmamiBeforeSend || function (type, payload) {
|
||||
var allowed = ['src', 'pr', 'ref', 'page', 'q', 'tab'];
|
||||
var scrub = function (value) {
|
||||
if (!value) { return value; }
|
||||
try {
|
||||
var url = new URL(value, window.location.origin);
|
||||
var relative = value.charAt(0) === '/';
|
||||
url.pathname = url.pathname.replace(/\/[A-Za-z0-9_-]{32,}(?=\/|$)/g, '/[Filtered]');
|
||||
Array.from(url.searchParams.keys()).forEach(function (key) {
|
||||
if (allowed.indexOf(key) === -1 && key.indexOf('utm_') !== 0) { url.searchParams.delete(key); }
|
||||
});
|
||||
return relative ? url.pathname + url.search : url.toString();
|
||||
} catch (e) {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
payload.url = scrub(payload.url);
|
||||
payload.referrer = scrub(payload.referrer);
|
||||
return payload;
|
||||
};
|
||||
</script>
|
||||
<script defer
|
||||
src="{{ config('services.umami.script_url') }}"
|
||||
data-website-id="{{ $umamiWebsiteId }}"
|
||||
data-tag="{{ $area }}"
|
||||
data-before-send="pkUmamiBeforeSend"
|
||||
data-performance="true"
|
||||
@if ($umamiIdentity) onload="window.umami && window.umami.identify('{{ $umamiIdentity }}')" @endif
|
||||
></script>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ new #[Layout('components.layouts.app'), Title('Betriebsüberwachung')] class ext
|
|||
|
||||
<p class="text-sm text-[color:var(--color-text-muted)]">
|
||||
{{ __('Stand') }}: {{ $health['generated_at']->format('d.m.Y H:i:s') }}
|
||||
· {{ __('Version') }}: {{ config('app.version') ?? __('unbekannt (keine RELEASE-Datei)') }}
|
||||
</p>
|
||||
|
||||
{{-- ============== QUEUE-KPIs ============== --}}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
use App\Models\User;
|
||||
use App\Services\Auth\LoginCompletion;
|
||||
use App\Support\LoginRedirect;
|
||||
use Illuminate\Auth\Events\Failed;
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
|
@ -40,6 +41,10 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
if (! $user || ! $user->password || ! Hash::check($this->password, $user->password)) {
|
||||
RateLimiter::hit($this->throttleKey());
|
||||
|
||||
// Standard-Event wie bei Auth::attempt() — ohne Passwort; der
|
||||
// Aktivitäts-Verlauf leitet den Grund am User ab (Monitoring 1.7).
|
||||
event(new Failed('web', $user, ['email' => $this->email]));
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('auth.failed'),
|
||||
]);
|
||||
|
|
@ -98,7 +103,9 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
return;
|
||||
}
|
||||
|
||||
event(new Lockout(request()));
|
||||
// Die Adresse steckt im Livewire-Payload, nicht im Request-Input —
|
||||
// für den Aktivitäts-Verlauf mitgeben.
|
||||
event(new Lockout(request()->duplicate(request: ['email' => $this->email])));
|
||||
|
||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||
|
||||
|
|
|
|||
|
|
@ -43,3 +43,7 @@
|
|||
writeCookie();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<x-monitoring.browser-errors build="build/portal" area="portal" />
|
||||
|
||||
<x-web.umami area="portal" />
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@
|
|||
@stack('styles')
|
||||
|
||||
<x-web.umami />
|
||||
<x-monitoring.browser-errors :build="$domainConfig['assets_dir'] ?? 'build/web'" area="web" />
|
||||
|
||||
</head>
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use App\Console\Commands\CheckMailDomains;
|
|||
use App\Console\Commands\ExpireGrandfathering;
|
||||
use App\Console\Commands\FetchMailBounces;
|
||||
use App\Console\Commands\GenerateManualInvoices;
|
||||
use App\Console\Commands\MonitoringHeartbeat;
|
||||
use App\Console\Commands\PublishScheduledPressReleases;
|
||||
use App\Console\Commands\PurgeConversionEvents;
|
||||
use App\Console\Commands\PurgeExpiredPressReleaseDrafts;
|
||||
|
|
@ -232,3 +233,22 @@ Schedule::command(RefreshMarketData::class)
|
|||
->withoutOverlapping(15)
|
||||
->runInBackground()
|
||||
->onFailure($alertOnFailure('market:refresh'));
|
||||
|
||||
// ========================================
|
||||
// Betriebsüberwachung (Monitoring-Plan Phase 1)
|
||||
// ========================================
|
||||
|
||||
// Herzschlag: Fällt der Scheduler komplett 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 (täglich 03:55). Bis dahin
|
||||
// sind sie im Systemstatus sichtbar und per `queue:retry` wiederholbar.
|
||||
Schedule::command('queue:prune-failed', ['--hours=720'])
|
||||
->dailyAt('03:55')
|
||||
->withoutOverlapping(30)
|
||||
->runInBackground()
|
||||
->onFailure($alertOnFailure('queue:prune-failed'));
|
||||
|
|
|
|||
|
|
@ -40,6 +40,18 @@ test('admin can open the system health report', function () {
|
|||
->assertSee('Queue-Worker');
|
||||
});
|
||||
|
||||
test('the system health report shows the deployed version', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('app.version', 'v1.1.0');
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
$this->actingAs($admin);
|
||||
|
||||
$this->get('https://pressekonto.test/admin/reports/system-health')
|
||||
->assertSuccessful()
|
||||
->assertSee('Version: v1.1.0');
|
||||
});
|
||||
|
||||
test('snapshot reports an ok queue when there are no waiting jobs', function () {
|
||||
/** @var TestCase $this */
|
||||
$snapshot = app(AdminSystemHealth::class)->snapshot();
|
||||
|
|
|
|||
76
tests/Feature/Auth/FailedLoginActivityTest.php
Normal file
76
tests/Feature/Auth/FailedLoginActivityTest.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\User;
|
||||
use Livewire\Volt\Volt;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
|
||||
/**
|
||||
* Gescheiterte Anmeldungen im Aktivitäts-Verlauf (Monitoring-Plan 1.7, M9):
|
||||
* mit Grund und User-Bezug, ohne Passwort und ohne Klartext-IP.
|
||||
*/
|
||||
function failedLoginEvents(ActivityType $type)
|
||||
{
|
||||
return ActivityEvent::query()->where('type', $type->value)->orderBy('id')->get();
|
||||
}
|
||||
|
||||
test('a wrong password is recorded with the account and the reason', function () {
|
||||
$user = User::factory()->create(['email' => 'kunde@example.com']);
|
||||
|
||||
Volt::test('auth.login')
|
||||
->set('email', 'kunde@example.com')
|
||||
->set('password', 'falsches-passwort')
|
||||
->call('login')
|
||||
->assertHasErrors('email');
|
||||
|
||||
$event = failedLoginEvents(ActivityType::LoginFailed)->sole();
|
||||
|
||||
expect($event->user_id)->toBe($user->id)
|
||||
->and($event->meta['reason'])->toBe('wrong_password')
|
||||
->and($event->meta['ip_hash'])->toHaveLength(12)
|
||||
->and($event->description)->toContain('falsches Passwort')
|
||||
->and(json_encode($event->toArray()))->not->toContain('falsches-passwort')
|
||||
->and(json_encode($event->toArray()))->not->toContain('127.0.0.1');
|
||||
});
|
||||
|
||||
test('an account without password is told apart from an unknown address', function () {
|
||||
User::factory()->create(['email' => 'legacy@example.com', 'password' => null]);
|
||||
|
||||
foreach (['legacy@example.com', 'niemand@example.com'] as $email) {
|
||||
Volt::test('auth.login')->set('email', $email)->set('password', 'irgendwas')->call('login');
|
||||
}
|
||||
|
||||
$events = failedLoginEvents(ActivityType::LoginFailed);
|
||||
|
||||
expect($events->pluck('meta.reason')->all())->toBe(['no_password', 'unknown_account'])
|
||||
->and($events[1]->user_id)->toBeNull()
|
||||
->and($events[1]->description)->not->toContain('niemand@example.com');
|
||||
});
|
||||
|
||||
test('a lockout is recorded for the targeted account', function () {
|
||||
$user = User::factory()->create(['email' => 'kunde@example.com']);
|
||||
|
||||
foreach (range(1, 6) as $attempt) {
|
||||
Volt::test('auth.login')->set('email', 'kunde@example.com')->set('password', 'falsch')->call('login');
|
||||
}
|
||||
|
||||
$lockout = failedLoginEvents(ActivityType::LoginLockout)->sole();
|
||||
|
||||
expect($lockout->user_id)->toBe($user->id)
|
||||
->and($lockout->description)->toContain('gesperrt')
|
||||
->and(failedLoginEvents(ActivityType::LoginFailed))->toHaveCount(5);
|
||||
});
|
||||
|
||||
test('a wrong two-factor code is recorded', function () {
|
||||
$user = User::factory()->create(['is_active' => true]);
|
||||
$user->forceFill([
|
||||
'two_factor_secret' => encrypt(app(Google2FA::class)->generateSecretKey()),
|
||||
'two_factor_confirmed_at' => now(),
|
||||
])->save();
|
||||
|
||||
$this->withSession(['login.id' => $user->id, 'login.remember' => false])
|
||||
->post('/two-factor-challenge', ['code' => '000000']);
|
||||
|
||||
expect(failedLoginEvents(ActivityType::TwoFactorFailed)->sole()->user_id)->toBe($user->id);
|
||||
});
|
||||
38
tests/Feature/Monitoring/BrowserErrorReportingTest.php
Normal file
38
tests/Feature/Monitoring/BrowserErrorReportingTest.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
|
||||
/**
|
||||
* Browserfehler (Monitoring-Plan 1.2): 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');
|
||||
});
|
||||
77
tests/Feature/Monitoring/ErrorReportingTest.php
Normal file
77
tests/Feature/Monitoring/ErrorReportingTest.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\Monitoring\ErrorReportScrubber;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Sentry\Event;
|
||||
use Sentry\ExceptionDataBag;
|
||||
use Tests\Support\FakeGlitchTip;
|
||||
|
||||
/**
|
||||
* GlitchTip-Anbindung (Monitoring-Plan 1.1, M4): 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('request data loses tokens, signatures and bodies', function () {
|
||||
$scrubbed = ErrorReportScrubber::scrubRequest([
|
||||
'url' => 'https://pressekonto.test/newsroom/claim/5?signature=abc123&expires=1700000000&src=mail_publish',
|
||||
'query_string' => 'signature=abc123&expires=1700000000&src=mail_publish&utm_source=nl',
|
||||
'data' => ['password' => 'geheim'],
|
||||
'cookies' => ['pressekonto_session' => 'x'],
|
||||
'headers' => ['referer' => ['https://pressekonto.test/reset-password/'.str_repeat('a', 64).'?email=kunde@example.com']],
|
||||
]);
|
||||
|
||||
expect($scrubbed['url'])->toBe('https://pressekonto.test/newsroom/claim/5?signature=%5BFiltered%5D&expires=%5BFiltered%5D&src=mail_publish')
|
||||
->and($scrubbed['query_string'])->toBe('signature=%5BFiltered%5D&expires=%5BFiltered%5D&src=mail_publish&utm_source=nl')
|
||||
->and($scrubbed)->not->toHaveKeys(['data', 'cookies'])
|
||||
->and($scrubbed['headers']['referer'][0])->toBe('https://pressekonto.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();
|
||||
});
|
||||
71
tests/Feature/Monitoring/ErrorSignalsTest.php
Normal file
71
tests/Feature/Monitoring/ErrorSignalsTest.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\Monitoring\ErrorSignal;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Tests\Support\FakeGlitchTip;
|
||||
|
||||
/**
|
||||
* Fehler ohne Exception (Monitoring-Plan 1.3): 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);
|
||||
});
|
||||
102
tests/Feature/Monitoring/HealthEndpointTest.php
Normal file
102
tests/Feature/Monitoring/HealthEndpointTest.php
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Admin\AdminSystemHealth;
|
||||
use App\Services\Monitoring\HealthProbe;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Tiefer Health-Check und Scheduler-Herzschlag (Monitoring-Plan 1.4, 1.5).
|
||||
*/
|
||||
beforeEach(function () {
|
||||
config()->set('monitoring.health.token', 'geheimes-monitor-token');
|
||||
config()->set('monitoring.health.disk_min_free_percent', 0);
|
||||
config()->set('queue.default', 'database');
|
||||
});
|
||||
|
||||
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 answers on every brand domain and skips basic auth', function (string $domainKey) {
|
||||
config()->set('auth.basic.enabled', true);
|
||||
config()->set('auth.basic.user', 'staging');
|
||||
config()->set('auth.basic.password', 'staging');
|
||||
app(HealthProbe::class)->recordHeartbeat();
|
||||
|
||||
$this->get('http://'.config($domainKey).'/health', healthHeaders())
|
||||
->assertOk()
|
||||
->assertJsonPath('status', 'ok');
|
||||
})->with(['domains.domain_portal', 'domains.domain_presseecho', 'domains.domain_businessportal']);
|
||||
|
||||
test('the admin system health shows scheduler and disk', function () {
|
||||
$checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key');
|
||||
|
||||
expect($checks->get('scheduler')['status'])->toBe('critical')
|
||||
->and($checks->get('scheduler')['detail'])->toContain('Noch kein Herzschlag')
|
||||
->and($checks)->toHaveKey('disk');
|
||||
|
||||
app(HealthProbe::class)->recordHeartbeat();
|
||||
|
||||
$checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key');
|
||||
expect($checks->get('scheduler')['status'])->toBe('ok');
|
||||
});
|
||||
|
|
@ -3,7 +3,9 @@
|
|||
use App\Mail\OperationalAlertMail;
|
||||
use App\Support\OperationalAlert;
|
||||
use Illuminate\Contracts\Queue\Job;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Queue\Events\JobFailed;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
|
@ -74,3 +76,57 @@ test('a failing queue job triggers an operational alert via the registered liste
|
|||
return str_contains($mail->summary, 'ClassifyPressRelease');
|
||||
});
|
||||
});
|
||||
|
||||
test('an alert is also pushed to ntfy as json with token and priority', function () {
|
||||
config()->set('monitoring.alerts.enabled', true);
|
||||
config()->set('monitoring.alerts.mail_to', null);
|
||||
config()->set('monitoring.alerts.ntfy', [
|
||||
'url' => 'https://ntfy.example.com/',
|
||||
'topic' => 'pressekonto-ops',
|
||||
'token' => 'tk_geheim',
|
||||
'priority' => 4,
|
||||
]);
|
||||
Http::fake(['ntfy.example.com/*' => Http::response(['id' => 'abc'])]);
|
||||
|
||||
OperationalAlert::send('Geplanter Task fehlgeschlagen: archive:purge', ['exit_code' => 1, 'queue' => ['a' => 'b']]);
|
||||
|
||||
Http::assertSent(function (Request $request) {
|
||||
return $request->url() === 'https://ntfy.example.com'
|
||||
&& $request->hasHeader('Authorization', 'Bearer tk_geheim')
|
||||
&& $request['topic'] === 'pressekonto-ops'
|
||||
&& $request['priority'] === 4
|
||||
&& str_contains($request['message'], 'archive:purge')
|
||||
&& str_contains($request['message'], 'exit_code: 1')
|
||||
&& str_contains($request['message'], 'queue: {"a":"b"}');
|
||||
});
|
||||
});
|
||||
|
||||
test('a failing ntfy push neither bubbles up nor stops the mail', function () {
|
||||
config()->set('monitoring.alerts.enabled', true);
|
||||
config()->set('monitoring.alerts.mail_to', 'ops@example.com');
|
||||
config()->set('monitoring.alerts.ntfy.url', 'https://ntfy.example.com');
|
||||
config()->set('monitoring.alerts.ntfy.topic', 'pressekonto-ops');
|
||||
Mail::fake();
|
||||
Http::fake(['ntfy.example.com/*' => Http::response('down', 502)]);
|
||||
|
||||
OperationalAlert::send('Etwas ist kaputt');
|
||||
|
||||
Mail::assertSent(OperationalAlertMail::class);
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
test('no push is sent when alerts are disabled or ntfy is not configured', function () {
|
||||
Http::fake();
|
||||
config()->set('monitoring.alerts.ntfy.url', 'https://ntfy.example.com');
|
||||
config()->set('monitoring.alerts.ntfy.topic', null);
|
||||
config()->set('monitoring.alerts.enabled', true);
|
||||
|
||||
OperationalAlert::send('Etwas ist kaputt');
|
||||
|
||||
config()->set('monitoring.alerts.ntfy.topic', 'pressekonto-ops');
|
||||
config()->set('monitoring.alerts.enabled', false);
|
||||
|
||||
OperationalAlert::send('Etwas ist kaputt');
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\Admin\UserImpersonation;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Tests\TestCase;
|
||||
|
||||
test('the umami snippet stays off until the go-live switch is set', function () {
|
||||
|
|
@ -27,3 +30,78 @@ test('the umami snippet renders the website id of the requested brand', function
|
|||
'businessportal24' => ['businessportal24.test', 'f03a5fc3-ac44-488f-9118-84004861099f'],
|
||||
'pressekonto hub' => ['pressekonto.test', 'ca440b65-a5bd-42c1-8cdb-96f34734003c'],
|
||||
]);
|
||||
|
||||
test('the web snippet carries the query filter, web vitals and no identity', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('services.umami.enabled', true);
|
||||
config()->set('services.umami.identify_users', true);
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$this->get('https://presseecho.test/de/faq')
|
||||
->assertSuccessful()
|
||||
->assertSee('data-tag="web"', false)
|
||||
->assertSee('data-before-send="pkUmamiBeforeSend"', false)
|
||||
->assertSee('window.pkUmamiBeforeSend', false)
|
||||
->assertSee('data-performance="true"', false)
|
||||
->assertDontSee('umami.identify', false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Kundenbereich (Monitoring-Plan M2, M6, M7).
|
||||
*/
|
||||
function umamiCustomer(): User
|
||||
{
|
||||
test()->seed(RolesAndPermissionsSeeder::class);
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
return $customer;
|
||||
}
|
||||
|
||||
test('the customer area is tracked only with its own switch', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('services.umami.enabled', true);
|
||||
config()->set('services.umami.portal_enabled', false);
|
||||
$this->actingAs(umamiCustomer());
|
||||
|
||||
$this->get(route('me.dashboard'))->assertSuccessful()->assertDontSee('sys.adametz.media', false);
|
||||
|
||||
config()->set('services.umami.portal_enabled', true);
|
||||
|
||||
$this->get(route('me.dashboard'))
|
||||
->assertSuccessful()
|
||||
->assertSee('data-tag="portal"', false)
|
||||
->assertSee('data-website-id="ca440b65-a5bd-42c1-8cdb-96f34734003c"', false)
|
||||
->assertDontSee('umami.identify', false);
|
||||
});
|
||||
|
||||
test('customers are identified by user id only when identify is switched on', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('services.umami.enabled', true);
|
||||
config()->set('services.umami.portal_enabled', true);
|
||||
config()->set('services.umami.identify_users', true);
|
||||
$customer = umamiCustomer();
|
||||
$this->actingAs($customer);
|
||||
|
||||
$this->get(route('me.dashboard'))
|
||||
->assertSuccessful()
|
||||
->assertSee("window.umami.identify('user-{$customer->id}')", false)
|
||||
->assertDontSee("identify('{$customer->email}", false);
|
||||
});
|
||||
|
||||
test('the admin area and impersonated sessions are never tracked', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('services.umami.enabled', true);
|
||||
config()->set('services.umami.portal_enabled', true);
|
||||
config()->set('services.umami.identify_users', true);
|
||||
$customer = umamiCustomer();
|
||||
$admin = User::factory()->create(['is_active' => true, 'is_super_admin' => true]);
|
||||
|
||||
$this->actingAs($admin)->get(route('dashboard'))->assertSuccessful()->assertDontSee('sys.adametz.media', false);
|
||||
|
||||
$this->actingAs($customer)
|
||||
->withSession([UserImpersonation::SessionKey => $admin->id])
|
||||
->get(route('me.dashboard'))
|
||||
->assertSuccessful()
|
||||
->assertDontSee('sys.adametz.media', false);
|
||||
});
|
||||
|
|
|
|||
63
tests/Support/FakeGlitchTip.php
Normal file
63
tests/Support/FakeGlitchTip.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
use Sentry\ClientBuilder;
|
||||
use Sentry\Event;
|
||||
use Sentry\SentrySdk;
|
||||
use Sentry\Transport\Result;
|
||||
use Sentry\Transport\ResultStatus;
|
||||
use Sentry\Transport\TransportInterface;
|
||||
|
||||
/**
|
||||
* Fängt Sentry-Events ab, statt sie an GlitchTip zu senden. Der Client wird
|
||||
* mit den Datenschutz-Optionen aus config/sentry.php gebaut, damit Tests den
|
||||
* echten `before_send`-Weg sehen.
|
||||
*
|
||||
* Nutzung: $glitchTip = FakeGlitchTip::install(); … $glitchTip->events
|
||||
*/
|
||||
class FakeGlitchTip implements TransportInterface
|
||||
{
|
||||
/** @var list<Event> */
|
||||
public array $events = [];
|
||||
|
||||
public static function install(): self
|
||||
{
|
||||
$transport = new self;
|
||||
|
||||
$client = ClientBuilder::create([
|
||||
'dsn' => 'https://public@glitchtip.test/1',
|
||||
'default_integrations' => false,
|
||||
'send_default_pii' => config('sentry.send_default_pii'),
|
||||
'max_request_body_size' => config('sentry.max_request_body_size'),
|
||||
'before_send' => config('sentry.before_send'),
|
||||
])->setTransport($transport)->getClient();
|
||||
|
||||
SentrySdk::getCurrentHub()->bindClient($client);
|
||||
|
||||
return $transport;
|
||||
}
|
||||
|
||||
public function send(Event $event): Result
|
||||
{
|
||||
$this->events[] = $event;
|
||||
|
||||
return new Result(ResultStatus::success(), $event);
|
||||
}
|
||||
|
||||
public function close(?int $timeout = null): Result
|
||||
{
|
||||
return new Result(ResultStatus::success());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return array_values(array_map(
|
||||
static fn (Event $event): string => (string) ($event->getMessage() ?? $event->getExceptions()[0]?->getValue()),
|
||||
$this->events,
|
||||
));
|
||||
}
|
||||
}
|
||||
38
tests/Unit/Monitoring/ReleaseTest.php
Normal file
38
tests/Unit/Monitoring/ReleaseTest.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
use App\Support\Monitoring\Release;
|
||||
|
||||
/**
|
||||
* Release-Kennung für GlitchTip (Monitoring-Plan 1.1): 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();
|
||||
});
|
||||
|
|
@ -28,6 +28,7 @@ export default defineConfig({
|
|||
"resources/css/portal.css",
|
||||
"resources/js/app.js",
|
||||
"resources/js/portal-form-hooks.js",
|
||||
"resources/js/error-reporting.js",
|
||||
],
|
||||
refresh: portalRefreshPaths,
|
||||
hotFile: PORTAL_HOT_FILE,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export default defineConfig({
|
|||
"resources/css/web/theme-presseecho.css",
|
||||
"resources/css/web/theme-pressekonto.css",
|
||||
"resources/js/app.js",
|
||||
"resources/js/error-reporting.js",
|
||||
],
|
||||
refresh: webRefreshPaths,
|
||||
hotFile: WEB_HOT_FILE,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue