- 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>
158 lines
5 KiB
PHP
158 lines
5 KiB
PHP
<?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;
|
|
}
|
|
}
|
|
}
|