GlitchTip: Fehlerprotokoll fuer Server, Fehlersignale und Browser
- sentry/sentry-laravel 4.28; config/sentry.php gehaertet (keine PII, keine Request-Bodys, before_send ErrorReportScrubber) - Handler: Integration::captureUnhandledException, CommandNotFoundException nicht melden - ReportErrorSignals (global): toter interner Link (nach Pfad wegen Catch-all-Route), 403/429 fuer Angemeldete, 419; gedrosselt - Browser: selbst gehostetes SDK (esbuild-Bundle 11.5.0), Loader ohne Build-Schritt und ohne Regex-Lookbehind, in 7 Layouts mit area-Tag - phpunit.xml: DSNs und ntfy-URL leer, CACHE_DRIVER=array (Tests schrieben bisher in den lokalen Datei-Cache) - dev/glitch-tip/README.md mit Instanz, Org, Projekt-IDs, API-Auswertung Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
36feed6324
commit
0b02b1b15c
28 changed files with 1453 additions and 61 deletions
159
app/Support/Monitoring/ErrorReportScrubber.php
Normal file
159
app/Support/Monitoring/ErrorReportScrubber.php
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support\Monitoring;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Sentry\Event;
|
||||
use Sentry\EventHint;
|
||||
use Sentry\UserDataBag;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Letzte Station vor GlitchTip (`before_send` in config/sentry.php).
|
||||
*
|
||||
* Personenbezug nur über die interne User-ID. Tokens und Signaturen stecken
|
||||
* in Query-Strings (signierte Links) und in Pfaden
|
||||
* (`/reset-password/{token}`, Magic Links) — beides wird ersetzt, bevor das
|
||||
* Ereignis den Server verlässt. E-Mail-Adressen in Fehlermeldungen (z. B.
|
||||
* QueryException mit eingesetzten Bindings) ebenso.
|
||||
*/
|
||||
class ErrorReportScrubber
|
||||
{
|
||||
public const Filtered = '[Filtered]';
|
||||
|
||||
/**
|
||||
* Query-Parameter, die unverändert mitgehen dürfen (`utm_*` immer).
|
||||
* Dieselbe Liste steht in public/js/error-reporting/error-reporting.js.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const AllowedQueryParameters = ['page', 'q', 'tab', 'sort', 'lang', 'month', 'year'];
|
||||
|
||||
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: 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue