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
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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue