Ich baute clientseitige Fehlererfassung, weil ich Fehler echter Nutzer sehen wollte, die sich lokal nicht zuverlässig nachstellen ließen. Das Meldesystem tat genau, was ich verlangt hatte: Er fing Fehler ab und schickte sie mir.
Das Problem war, dass fast alles gleich wichtig aussah.
Ein externes Analytics-Skript lädt nicht? Roter Alarm. Werbung wird blockiert? Roter Alarm. Ein Crawler erreicht Google Analytics nicht? Roter Alarm. Ein Videovorschau ruft play() auf und wird pausiert, bevor die Promise abgeschlossen ist? Roter Alarm. Ein nutzloses Script error. ohne brauchbare Quelle oder Stack? Ebenfalls roter Alarm.
Im selben Strom lagen aber Fehler, die die Anwendung wirklich beschädigen konnten: eine eigene URL, die versehentlich zu https://example.comhttps://example.com/... zusammengesetzt worden war, und eine eigene Next.js-Datei unter /_next/static/chunks/..., die ein Browser nicht laden konnte.
Die Erfassung funktionierte. Das Monitoring nicht.
Seitdem trenne ich beides klar. Ein Fehlerereignis ist ein Beleg dafür, dass etwas passiert ist. Es ist noch keine Diagnose, keine Priorität und kein Incident.
Der erste Fehler war, alles „Error“ zu nennen
Mein frühes Modell sah ungefähr so aus:
Browser meldet Fehler
↓
CLIENT ERROR senden
↓
Entwickler muss reagieren
Damit wurden zu viele Fragen zusammengeworfen. Ist eigener Code betroffen? Ist die aktive Seite kaputt? War es erwartete Abbruchlogik? Hatte der Browser genug Informationen über die Quelle? Hat sich die Anwendung erholt? Sind zehn Meldungen zehn Incidents oder zehn Symptome eines einzigen Problems?
Solange diese Fragen offen sind, sollte ein Event nicht automatisch zum Alarm werden.
Bei einer frühen Auswertung lagen ungefähr achtzehn Meldungen vor. Der Großteil war Drittanbieter- oder Lifecycle-Rauschen. Zwei Fälle waren deutlich wichtiger: die fehlerhafte eigene URL war eindeutig ein Bug; der fehlgeschlagene eigene JavaScript-Chunk konnte die Seite ernsthaft beeinträchtigen. Trotzdem zeigte das Meldesystem fast alles mit derselben Dringlichkeit.
Damit wurde klar: „alle Browserfehler sammeln“ und „Produktionsmonitoring bauen“ sind verschiedene Aufgaben. Die Erfassung bewahrt Evidenz. Das Monitoring verdichtet Evidenz zu Entscheidungen.
Der Browser hat keinen universellen Fehlerkanal
Clientfehler kommen nicht mit derselben Semantik.
Das error-Event auf window deckt synchrone Scriptfehler ab und spielt auch bei Ressourcenfehlern eine Rolle. Eine unbehandelte abgelehnte Promise läuft über unhandledrejection. Elemente, die Skripte, Bilder oder Medien laden, können eigene error-Events auslösen. React und Next.js ergänzen Framework-Error-Boundaries.
window.error
→ ein synchroner Scriptfehler kann entkommen sein
unhandledrejection
→ eine Promise war zu diesem Zeitpunkt unbehandelt abgelehnt
Element-error
→ eine Ressource konnte nicht geladen oder verwendet werden
Framework-Boundary
→ Rendering oder Ausführung erreichten eine Fehlergrenze
Ein globaler Hook sieht ein Symptom an einer Systemgrenze. Er kennt nicht zwangsläufig die gesamte Ursache.
Deshalb normalisiere ich nicht mehr sofort alles zu einem generischen Error mit identischer Schwere.
Eigentum ist der erste sinnvolle Filter
Die wichtigste erste Trennung ist: Wem gehört der fehlerhafte Code oder die Ressource?
Ein Ausfall von /_next/static/chunks/app/... ist etwas anderes als ein Werbe-SDK auf einer fremden Origin. Eine von meinem Code erzeugte falsche URL ist nicht dasselbe wie ein blockierter Analytics-Request. Erweiterungscode ist wieder eine eigene Klasse.
- eigene Anwendung: JavaScript, CSS, APIs, Medien und generierte URLs;
- Framework/Laufzeit: Next.js- und React-Pfade innerhalb der Ausführung;
- externe Integrationen: Analytics, Werbung, Widgets und SDKs;
- Umgebung: Erweiterungen, Crawler, Netzwerkzustand, Privacy-Werkzeuge und Browserbesonderheiten.
Das heißt nicht, Drittanbieter zu ignorieren. Eine kaputte Analytics- oder Zahlungsintegration kann sehr wichtig sein. Aber Integrationsgesundheit ist nicht automatisch ein Absturz der Anwendung.
Wenn beides im gleichen Notfallkanal landet, verliert der Kanal seine Bedeutung.
Zwei eigene Fehler zeigten mir, was wirklich handlungsrelevant ist
Die fehlerhafte URL war eindeutig:
https://example.comhttps://example.com/resource
Dafür brauchte ich keine Theorie über Adblocker, VPN oder Browserpolitik. Die URL selbst war falsch. Mein Code hatte effektiv eine Origin vor einen Wert gesetzt, der bereits absolut war.
Das Event war handlungsrelevant, weil Evidenz und Eigentum eindeutig waren und der Fehler auf kontrollierbaren Code zeigte.
Beim Next.js-Chunk war es anders:
Failed to load script:
/_next/static/chunks/9253.647385b4be0958e4.js
Die Ressource gehörte ebenfalls zur Anwendung und konnte die Seite brechen. Aber das Event bewies nicht, warum der Chunk fehlte. Ein alter Client konnte ein Asset eines früheren Deployments anfordern. Ein Request konnte timeouten. Proxy oder CDN konnten scheitern. Die Verbindung konnte abbrechen. Oder die Datei konnte tatsächlich fehlen.
Die richtige Aussage war nicht „Ursache gefunden“, sondern „hohe Priorität, mehr Evidenz nötig“.
Hohe Schwere und geringe Sicherheit über die Ursache können gleichzeitig wahr sein.
Script error. ist ein Hinweis, kein Stacktrace
Error: Script error.
filename: unknown
line: 0
column: 0
Diese Meldung wirkt dramatisch und liefert fast nichts.
Browser beschränken Details von Cross-Origin-Scriptfehlern absichtlich. MDN beschreibt, dass ohne passende CORS-Konfiguration über window.onerror nur eingeschränkte Informationen verfügbar sind; das crossorigin-Verhalten von <script> beeinflusst die Fehlerdetails direkt.
Ein einzelnes undurchsichtiges Script error. darf deshalb nicht automatisch zu „meine App ist abgestürzt“ werden. Es kann eigener Code, Drittanbieter-Code, injizierter Code oder schlicht nicht einsehbarer Cross-Origin-Code sein.
Ich behalte es als Telemetrie, korreliere Route, Build, Browser und Nachbarereignisse, alarmiere aber nicht allein wegen eines 0:0-Events. Ein Cluster rund um einen Release ändert die Bewertung.
Unbekannt bedeutet weder harmlos noch kritisch.
Ein AbortError kann echt und trotzdem erwartet sein
Videovorschaus machten das besonders deutlich:
AbortError:
The play() request was interrupted by a call to pause()
HTMLMediaElement.play() liefert eine Promise, die abgelehnt werden kann. Medien-Lifecycle-Operationen können laufende Wiedergabe absichtlich abbrechen; MDN dokumentiert außerdem, dass load() offene Play-Promises mit AbortError abbricht.
In einer Vorschauliste ist das ohne Produktfehler möglich: Element erscheint, play() startet, Nutzer scrollt weiter, Element verlässt den Viewport und wird pausiert oder ersetzt.
Die Promise-Rejection ist real. Ein Nutzerincident muss trotzdem nicht existieren.
Die richtige Behandlung gehört nahe an den Aufruf: erwarteten Abbruch von echtem Playback-Fehler unterscheiden. unhandledrejection ist ein Sicherheitsnetz, nicht die erste Stelle für normale Komponentenlogik.
Drittanbieterfehler brauchen ein eigenes Gesundheitsmodell
Frühe Logs enthielten viele Fehler von Analytics- und Werbedomains. Einige kamen aus Privacy-Browsern, andere von Crawlern. Besonders wenig aussagekräftig war ein Crawler, der Google Analytics nicht laden konnte.
Das belegt einen fehlgeschlagenen Request, aber kaum Nutzerwirkung.
Das Sammeln war nicht falsch. Falsch war, ihn im selben Incident-Strom wie einen fehlgeschlagenen eigenen JavaScript-Chunk zu behandeln.
- Ist die Anwendung für den Nutzer kaputt?
- Ist eine externe Integration gesund?
Blockierte Werbung gehört in Ad-Auslieferung-Metriken. Analytics-Ausfälle können Analytics-Coverage messen. Beides sollte nicht als Frontend-Crash alarmieren, solange Kernfunktionen nicht davon abhängen.
Getrennt lassen sich Drittanbieterprobleme sogar besser nach Anbieter, Browser und Region aggregieren.
navigator.onLine ist Kontext, kein Erreichbarkeitsbeweis
Ich speicherte auch, ob der Browser sich für online hielt. Das ist hilfreich, solange man es nicht als Wahrheit behandelt.
Es gab Netzwerkfehler mit:
Online: true
Das ist kein Widerspruch. MDN weist ausdrücklich darauf hin, dass navigator.onLine auf Heuristiken von Browser und Betriebssystem basiert. LAN-Konnektivität bedeutet nicht, dass meine Origin erreichbar ist. VPN, Firewall, DNS und Teilausfälle machen das noch komplexer.
online === false
→ starkes Indiz für ein Umgebungsproblem
online === true
→ beweist NICHT, dass Origin oder Ressource erreichbar waren
So vermeidet das Monitoring falsche Sicherheit.
Eine einzige Ursache kann mehrere Browserereignisse erzeugen
Mit besserer Erfassung wurde sichtbar, dass ein Incident mehrere Meldungen erzeugen kann.
Ein Chunk kann zuerst resource.error auslösen, danach ChunkLoadError, anschließend eine React/Next.js-Fehlergrenze und schließlich Wiederherstellungslogik. Werden alle Ebenen separat alarmiert, sieht eine Nutzeraktion wie mehrere Ausfälle aus.
Fünf Meldungen wirken wie fünf Nutzer, obwohl sie aus einer Session und einer Ressource stammen können.
Textbasierte Deduplication reicht nicht. Es braucht Incident-Korrelation:
Session
+ kurzes Zeitfenster
+ normalisierte Fehlerklasse
+ eigene Ressource
+ Client-Build
+ Route
Rohereignisse bleiben erhalten, aber der Mensch bekommt die aussagekräftigste Darstellung. Eine Boundary mit eigenem Stack und exakter Chunk-URL macht einen vorherigen generischen Ressourcenalarm meist überflüssig.
Incidents alarmieren, Events speichern.
Der Kontext um den Fehler wurde wichtiger als der Fehlertext
Spätere Telemetrie enthielt deutlich mehr Struktur:
clientBuildId
resource URL
resourceResponseStatus
resourceTransferSize
resourceDurationMs
serviceWorkerVersion
serviceWorkerController
serviceWorkerState
chunkRecoveryScheduled
online
stack / component stack
Bei Medien kamen Fehlercode, beobachtbarer HTTP-Status, tatsächlicher Content-Type und eine Einordnung als HTTP- oder Netzwerkproblem hinzu.
Damit konnte ich fragen: Begann es mit einem Build? Kam eine HTTP-Antwort? Kontrollierte ein Service Worker die Seite? Lief Wiederherstellung? Zeigen mehrere Events auf dieselbe Ressource? War die aktive Route betroffen?
Die Resource Timing API kann Dauer, Transferdaten und teilweise Response-Status liefern. Cross-Origin-Daten sind eingeschränkt, Cache-Hits können transferSize: 0 liefern und responseStatus ist nicht überall verfügbar. null und 0 sind deshalb legitime Zustände, keine Einladung zum Raten.
ChunkLoadError ist ein Symptom, kein 404-Detektor
Ein späteres Ereignis änderte meine Interpretation deutlich.
Ein Next.js-Layout-Chunk löste ChunkLoadError aus, während die Telemetrie Folgendes enthielt:
resourceResponseStatus: 200
resourceDurationMs: 170523
serviceWorkerState: activated
chunkRecoveryScheduled: true
Die Messung lag bei ungefähr 170 Sekunden. Unabhängig von der exakten Ursache reichte das aus, um diese Gleichung abzulehnen:
ChunkLoadError === Server lieferte 404
Andere Chunkfehler hatten keinen beobachtbaren Status; manche waren Timeouts, manche enthielten Transferdaten. Gleicher Error-Typ, unterschiedliche Evidenz.
Das ist bei Next.js wichtig, weil Dateien unter /_next/static/ typischerweise content-gehasht und unveränderlich sind. Die aktuelle Next.js-Dokumentation zum Self-Hosting beschreibt langlebige Cache-Header für solche Assets. Ein Chunkfehler kann daher mit alten Clients, Deployment-Skew, Netzwerk, Proxy, CDN, Cache, Service Worker oder tatsächlich fehlenden Artefakten zusammenhängen.
Das Alerting soll keine Ursache erfinden, sondern Evidenz für die Untersuchung bewahren.
Medienfehler zeigten dasselbe auf einer anderen Ebene
Ein Videoelement meldete beispielsweise:
MEDIA_ELEMENT_ERROR: Format error
Das klingt sofort nach Codec-Inkompatibilität.
Zusätzliche Auslieferungsprüfung zeigte bei einigen Fällen aber:
HTTP status: 410
Content-Type: text/html; charset=utf-8
failure kind: http
Der Browser forderte Video an und bekam eine HTTP-Fehlerseite als HTML. Das Medienelement konnte HTML nicht decodieren und meldete deshalb einen Formatfehler. Die nützliche Diagnose lag bei der Auslieferung.
Die Schicht, die einen Fehler bemerkt, muss nicht die verursachende Schicht sein.
„Codecfehler“, „Netzwerkausfall“, „Cache-Bug“ oder „fehlender Chunk“ sind Schlussfolgerungen. Telemetrie sollte zuerst Beobachtungen festhalten.
Ich bewerte Browserfehler auf fünf Dimensionen
1. Eigentum
Eigener Code, Framework/Laufzeit, externe Integration oder Umgebung?
2. Nutzerwirkung
Ist aktive Navigation, Rendering, Authentifizierung, Chat, Checkout oder ein anderer Kernfluss kaputt, oder nur ein optionaler Ad-, Analytics-, Preload- oder Vorschau-Pfad?
3. Evidenzqualität
Gibt es eigenen Stack, Ressourcen-URL, HTTP-Status, Build-ID und Component Stack, oder nur Script error. bei 0:0?
4. Wiederholung und Verteilung
Ein Event in einer Session oder dieselbe Signatur über Nutzer, Routen und Browser nach demselben Release?
5. Wiederherstellung
Hat sich die Anwendung erholt? Wurde Chunk-Reload geplant? Funktionierte ein Fallback? Ist der Nutzer blockiert?
eigene Ursache wahrscheinlich
+ hohe Nutzerwirkung
+ starke Evidenz
+ mehrere Sessions
+ kein Recovery
= dringender Incident
Drittanbieter
+ optional
+ schwache Evidenz
+ isoliert
+ Nutzer nicht betroffen
= Metrik oder niedrige Priorität
Unterdrückung muss konservativ sein
Wer von Rauschen genervt ist, baut leicht eine riesige Regex-Blacklist. Damit kann man echte Fehler verstecken.
Jeden AbortError zu unterdrücken kann echte abgebrochene API-Requests verbergen. Jedes Script error. zu löschen kann einen browserbezogenen Cluster verstecken. Jeden Drittanbieter zu ignorieren kann einen kaputten Payment-, Auth- oder Consent-Provider verdecken.
ALERT
→ starker Incident, der Aufmerksamkeit braucht
RETAIN / AGGREGATE
→ speichern und zählen; erst bei Cluster alarmieren
METRIC / SAMPLE
→ erwartetes oder schwaches Rauschen; Trend und Beispiele erhalten
Das System wird leiser, ohne blind zu werden.
Der Klassifikator ist vor allem ein Regelwerk im Code
Dieses Beispiel ist nicht aus meinem Produktivcode. Es zeigt die gewünschte Policy:
function classifyClientEvent(event) {
const owner = classifyOwner(event);
if (isExpectedMediaCancellation(event)) {
return { severity: "metric", reason: "expected-cancellation" };
}
if (owner === "first-party" && breaksActiveRoute(event)) {
return { severity: "alert", reason: "first-party-user-impact" };
}
if (isActiveFirstPartyChunkFailure(event)) {
return { severity: "alert", reason: "application-chunk" };
}
if (owner === "third-party") {
return { severity: "aggregate", reason: "integration-health" };
}
if (isOpaqueScriptError(event)) {
return { severity: "aggregate", reason: "insufficient-evidence" };
}
return { severity: "aggregate", reason: "needs-correlation" };
}
Die eigentliche Schwierigkeit steckt in Funktionen wie breaksActiveRoute(): Entscheidend sind Routenkontext, Ressourcenzuordnung, Daten der Fehlergrenze und manchmal produktspezifisches Wissen.
Ein Fingerprint sollte dem Incident folgen, nicht dem Text
Identische Strings sind schlechte Fingerprints. Minified Offsets, Chunk-Hashes, dynamische IDs und Browserformulierungen ändern sich.
{
errorClass,
normalizedFirstPartyResource,
routeFamily,
clientBuild,
sessionId,
shortTimeBucket
}
Je nach Fehlerklasse ist der oberste eigene Stackframe, die normalisierte Ressource oder die Auslieferungsklasse wichtiger.
resource.error
→ ChunkLoadError
→ framework boundary
→ recovery scheduled
wird so zu einem Incident mit vier Beobachtungen statt zu vier Alarmen.
Der dringende Kanal braucht eine viel engere Aufgabe
Sofort alarmieren würde ich vor allem eigene Runtimefehler mit Nutzerwirkung, relevante React/Next.js-Boundaries, benötigtes eigenes JavaScript/CSS, wiederholte ChunkLoadError über Sessions, blockierende Kern-API-Fehler und klare eigene Invarianten wie kaputte generierte URLs.
Ein einzelnes Script error., ein eigener Ressourcenfehler mit erfolgreichem Wiederherstellung, unklare Medienfehler und browserbezogene Anomalien würde ich zunächst behalten und aggregieren.
Bekannte Ad-/Analytics-Ausfälle, erwartete AbortError-Abbrüche, crawlerbezogenes Drittanbieterrauschen, klare Offline-Hinweise und optionale spekulative Ressourcen gehören meist in Metriken oder Samples.
Ein dringender Alarm sollte handlungsrelevante Nutzerwirkung repräsentieren, nicht die Lautstärke des Browsers.
Ich messe lieber Incidents als eine globale „Fehlerzahl“
- eigene Incidents pro 1.000 Sessions;
- betroffene Sessions nach Build-ID;
- Error-Boundaries nach Route;
- Chunkfehler nach Ressource und Deployment;
- Fehlerrate externer Integrationen pro Anbieter;
- Volumen erwarteter Abbrüche, damit Trends sichtbar bleiben;
- Wiederherstellung-Erfolgsrate;
- eindeutige Incidents gegenüber Roh-Events.
„Ein Event ist passiert“ ist selten ein sinnvoller Schwellwert. „Derselbe eigene Incident betrifft mehrere unabhängige Sessions des neuen Builds und Wiederherstellung schlägt fehl“ ist wesentlich handlungsnäher.
Clientseitiges Monitoring beweist die Ursache trotzdem nicht
Fehlender Response-Status kann fehlende API-Sicht, Browser-Support, Cross-Origin-Beschränkung, Abbruch oder andere Beobachtbarkeitslücken bedeuten. Ein aktiver Service Worker beweist nicht, dass er eine stale Ressource lieferte. ChunkLoadError nach einem Deployment beweist kein Version-Skew. online: true beweist keine Erreichbarkeit.
Client-Telemetrie reduziert Hypothesen. Server- und Proxy-Logs, Deployment-Manifeste, Cache-Zustand und Reproduktion können weiterhin nötig sein.
Observability sollte außerdem nicht grenzenlose Datensammlung bedeuten. Jedes Feld braucht einen diagnostischen Grund.
Bessere Telemetrie ist nicht automatisch mehr Telemetrie, sondern trennschärfere Telemetrie.
Meine Regel heute: Events sammeln, Incidents untersuchen, Wirkung alarmieren
Früher sollte mein Fehlermelder nur beantworten: „Ist irgendetwas schiefgegangen?“ In Produktion ist die Frage zu breit. Irgendwo scheitert immer ein Crawler, ein Privacy-Filter, eine Medien-Promise, ein Netzwerk oder ein externes SDK.
Gehört es uns?
Hat der Nutzer Funktionalität verloren?
Wie stark ist die Evidenz?
Wiederholt es sich?
Hat sich die Anwendung erholt?
Sind das mehrere Events oder ein Incident?
Mit diesen Fragen wurde aus dem roten Meldungsstrom ein Engineering-Werkzeug.
Ein Browserfehler ist eine Beobachtung. Ein Incident ist eine korrelierte Erklärung von Nutzerwirkung. Ein Alarm ist die Entscheidung, dass ein Mensch handeln soll.
Diese drei Dinge möchte ich nicht mehr gleichsetzen.