Jede Debugging-Session beginnt an derselben Stelle: Du öffnest die DevTools. In der Console steht der Fehler, im Netzwerk-Tab der fehlgeschlagene Request, und nach einer Minute weißt du, was schiefgelaufen ist.
Das Problem ist, dass die DevTools auf deinem Rechner offen sind und der Bug auf einem anderen passiert.
Dieser Leitfaden schließt diese Lücke. Er zeigt, wie du eine Seite so instrumentierst, dass du, wenn im Browser einer anderen Person etwas kaputtgeht, dieselben drei Panels bekommst, die du selbst geöffnet hättest. Es geht darum, was du patchst, in welcher Reihenfolge, und um die konkreten Fallen, die eine naive Implementierung zur Quelle neuer Bugs machen.
Fang mit der Reihenfolge an
Fast jede Anleitung zum Erfassen der Console-Ausgabe beginnt mit dem Monkey-Patch. Das ist der falsche Anfang, denn schwierig ist nicht, wie du patchst, sondern wann.
Deine Instrumentierung sieht nur, was nach ihrem Laden passiert. Alles, was davor geloggt wurde, ist weg. Und die Fehler, die du am dringendsten brauchst, sind überproportional oft frühe: ein fehlgeschlagener Config-Fetch, ein Drittanbieter-Skript, das beim Initialisieren geworfen hat, ein Hydration-Mismatch. Bis dein Bundle zum Melden von Bugs heruntergeladen, geparst und initialisiert ist, ist der interessante Teil der Timeline schon Geschichte.
Dafür gibt es zwei Ansätze, und du brauchst beide.
Lade die Capture-Schicht zuerst. Setz sie vor dein Anwendungs-Bundle, synchron, in <head>. Halte sie klein. Das spricht für einen Capture-Stub, der die Hooks installiert und sonst nichts, während die schwerere UI danach per Lazy Loading nachkommt.
Puffere, bevor du weißt, wohin damit. Die Hooks sollten ab der ersten Millisekunde in einen Ringpuffer im Speicher schreiben, lange bevor irgendwer auf „Bug melden“ geklickt hat. Wird dann ein Bericht geöffnet, ist der Puffer schon voller Verlauf. Nur so lassen sich Logs von vor dem Öffnen des Widgets erfassen: Erfasst hat nie das Widget.
Die Console patchen, ohne sie kaputtzumachen
Die Mechanik ist einfach:
const BUFFER = [];
const LEVELS = ['log', 'info', 'warn', 'error', 'debug'];
for (const level of LEVELS) {
const original = console[level].bind(console);
console[level] = (...args) => {
BUFFER.push({ level, ts: Date.now(), args: serialize(args) });
original(...args);
};
}
In der Praxis gehen dabei vier Dinge schief.
Du machst die Quellenangabe in den DevTools kaputt. Die Console zeigt jetzt jede Zeile so an, als käme sie aus deinem Wrapper statt von der aufrufenden Stelle. Chrome und Firefox respektieren beide einen //# sourceURL-Kommentar und Blackboxing-Regeln, aber die verlässliche Lösung ist, beim Aufruf einen Stack-Trace zu erfassen und den Frame des Aufrufers mit dem Eintrag zu speichern. Diesen Frame willst du ohnehin im Bericht haben.
Du rufst dich selbst rekursiv auf. Wenn serialize wirft, dein Error-Handler loggt und dein Logging gepatcht ist, hast du eine Endlosschleife, die den Tab komplett einfriert. Sichere dich mit einem Reentrancy-Flag ab und mach den ganzen Hook zum No-op, wenn darin irgendetwas fehlschlägt. Eine Instrumentierung, die die Seite abstürzen lässt, ist schlimmer als gar keine.
Du hältst Objekte am Leben. Wer rohe Argumente in einen Puffer schiebt, hält eine starke Referenz auf jedes jemals geloggte Objekt: DOM-Knoten, Response-Bodys, ganze Komponentenbäume. In einer langlebigen Single-Page-App ist das ein Memory Leak, das so lange wächst, wie der Tab offen ist. Serialisiere sofort in eine begrenzte String-Darstellung und lass die Referenz los.
Nicht nur du patchst. Analytics-Skripte, Error-Tracker und die Dev-Tools von Frameworks patchen console ebenfalls. Wenn du das Original beim Laden sicherst und jemand nach dir patcht, liegt dessen Wrapper über deinem, und du siehst trotzdem alles. Hat jemand vor dir gepatcht, siehst du die Ausgabe dieses Wrappers. Beides funktioniert. Was nicht funktioniert, ist die Annahme, du wärst allein. Setz console[level] beim Teardown nie auf das Original zurück, sonst überschreibst du den Wrapper, den jemand nach dir gesetzt hat. Stell es nur wieder her, wenn du noch ganz oben in der Kette bist.
Serialisierung ist der Großteil der Arbeit
JSON.stringify scheitert an zirkulären Referenzen, lässt Funktionen und undefined stillschweigend weg, wirft bei BigInt und macht aus Error-Objekten {}, also ausgerechnet aus dem Fall, der dich am meisten interessiert.
Ein Serializer, der für Fehlerberichte taugt, muss Folgendes abdecken: zirkuläre Referenzen (durch eine Markierung ersetzen), Error-Instanzen (Name, Message und Stack, ausdrücklich), DOM-Knoten (Tag, ID, Klassen, nicht den Teilbaum), Map und Set sowie Grenzen für Tiefe und Länge, damit ein einzelnes riesiges Objekt nicht den ganzen Puffer belegt. Kürze lange Strings mit einer sichtbaren Markierung statt stillschweigend. Eine Log-Zeile mit "[truncated at 2000 chars]" sagt dem Entwicklungsteam etwas, eine Zeile, die einfach aufhört, nicht.
Fehler, die du nie geloggt hast
Die meisten Fehler in Produktion übergibt dein Code nie an console.error. Sie werden geworfen, und etwas anderes behandelt sie. Zwei globale Hooks schließen die Lücke:
window.addEventListener('error', (e) => {
record({ kind: 'uncaught', message: e.message, filename: e.filename,
line: e.lineno, col: e.colno, stack: e.error?.stack });
}, true);
window.addEventListener('unhandledrejection', (e) => {
record({ kind: 'unhandled-rejection', reason: serialize(e.reason) });
});
Nutze für error die Capture-Phase, damit du Events auch dann siehst, wenn weiter unten jemand die Propagation stoppt. Und beachte, dass das error-Event auch bei fehlgeschlagenen Ressourcen feuert, etwa bei einem kaputten Bild oder einem Skript mit 404. Dann ist das Target ein Element statt eines Error-Objekts. Diese Fälle solltest du getrennt erfassen: Ein fehlendes Bundle erklärt sehr viele Berichte nach dem Muster „die Seite ist einfach leer“.
Das Netzwerk erfassen
Zwei APIs zum Wrappen und eine als Auffangnetz.
fetch ist die einfache. Wrappe sie, erfasse Methode, URL und Startzeit und, sobald das Promise abgeschlossen ist, Status und Dauer. Erfasse auch Rejections: Ein Netzwerkfehler erzeugt ein abgelehntes Promise, keine Response, und „der Request wurde nie fertig“ ist ein deutlich anderer Befund als „der Request hat 500 zurückgegeben“.
XMLHttpRequest zählt immer noch. Viele Abhängigkeiten nutzen es intern, und wenn du nur fetch wrappst, bekommst du Berichte, in denen der fehlgeschlagene Request unsichtbar ist. Patche open, um Methode und URL an der Instanz abzulegen, patche dann send und hör auf loadend.
PerformanceObserver auf dem Entry-Typ resource ist das Auffangnetz. Er sieht jeden Request, den der Browser gemacht hat, auch Bilder, Stylesheets, Fonts, sendBeacon und alles, was ein Skript abgesetzt hat, das sich eine Referenz auf fetch geholt hat, bevor du gepatcht hast. Er liefert Timing und Transfergröße, aber weder Statuscodes noch Bodys, und ergänzt die Wrapper damit, statt sie zu ersetzen.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
record({ url: entry.name, type: entry.initiatorType,
duration: entry.duration, size: entry.transferSize });
}
}).observe({ type: 'resource', buffered: true });
Der wichtige Teil ist buffered: true: Damit bekommst du Einträge von vor der Erstellung des Observers und holst dir einen Teil der frühen Timeline zurück, der sonst verloren wäre.
Bodys sind der gefährliche Teil
Request- und Response-Bodys sind das Nützlichste, was du an einen Fehlerbericht hängen kannst, und das, was einen Fehlerbericht am ehesten zum Sicherheitsvorfall macht. In Bodys stecken Auth-Tokens, personenbezogene Daten, Links zum Zurücksetzen von Passwörtern und Zahlungsdaten.
Der vertretbare Standard ist aus, mit ausdrücklichem Opt-in pro Projekt. In BugDump heißt dieses Opt-in captureNetworkBodies, und es ist absichtlich nicht der Standard, obwohl es die Berichte besser macht. Wenn du das selbst baust, entscheide genauso.
Sind Bodys an, gibt es auch mechanische Fallen. Der Body einer Response ist ein Stream, der sich nur einmal lesen lässt: Du musst vor dem Lesen clone() aufrufen, sonst wirft das eigene await res.json() der Anwendung, weil der Body schon gelesen ist. Begrenze die Größe, die du liest. Und lies asynchron, damit du die Anwendung nicht dabei blockierst, die Response selbst zu verarbeiten.
Sensible Daten filterst du im Browser
Den stärksten Datenschutz bekommst du, wenn sensible Daten den Rechner nie verlassen. Das heißt: filtern beim Erfassen, im Client, nicht auf dem Server nach dem Upload.
Zwei Filter decken das meiste ab. Ein Console-Filter verwirft Einträge nach Level oder Muster, etwa für die Bibliothek, die bei jedem Render ein Session-Token auf debug loggt. Ein Netzwerkfilter verwirft oder ändert Einträge nach URL. So schließt du deinen Auth-Endpunkt aus, deinen Zahlungsanbieter und die Analytics-Beacons, die sonst 90 % des Logs ausmachen würden.
Bugdump.init({
apiKey: 'your-api-key',
networkFilter: (req) => !req.url.includes('/auth/'),
consoleFilter: (entry) => entry.level !== 'debug',
});
Prädikate schlagen hier Konfiguration. Jede Allowlist auf Regex-Basis, die du dir ausdenkst, ist für irgendwen falsch, eine Funktion für niemanden.
Wie groß der Puffer sein muss und wann du aufhörst
Ein Fehlerbericht ist keine Observability-Pipeline. Du erfasst ein paar Minuten einer Session, keine ganze Flotte.
Ringpuffer mit harten Obergrenzen, in der Größenordnung von ein paar Hundert Console-Einträgen und ein paar Hundert Netzwerk-Einträgen, reichen fast immer, weil das nützliche Fenster die Sekunden sind, bevor die Person aufgegeben hat. Behalte die neuesten Einträge und wirf vorne raus. Zähl mit, wie viele du verworfen hast, damit der Bericht „die letzten 200 von 4.318 Einträgen“ anzeigen kann, statt stillschweigend nahezulegen, das Log sei kurz gewesen.
Dieselbe Logik gilt für das, was du nicht baust. Auflösung von Source Maps, Log-Level pro Modul, Sampling, Aufbewahrungsrichtlinien, Aggregation: Das sind die Probleme eines Produkts für Error-Monitoring. Hinter einem Fehlerbericht steht eine Person, die gerade da war und dir sagt, was sie vorhatte. Dieser Kontext leistet den Großteil dessen, was anderswo die Aggregation leistet.
Was ich empfehle
Alles oben lässt sich in ein paar Hundert Zeilen bauen. Verstehen lohnt sich auch, wenn du es nicht selbst baust, denn genau diese Eigenschaften solltest du bei jedem Tool prüfen, das du einsetzt: Wird es vor dem App-Bundle installiert? Puffert es ab dem Laden? Übersteht es, wenn jemand anderes patcht? Sind Bodys standardmäßig aus? Kannst du vor dem Upload schwärzen?
BugDump macht all das hinter einem einzigen Script-Tag, rendert seine UI in einem Shadow DOM, damit in keine Richtung etwas durchsickert, und hängt die erfassten Console- und Netzwerk-Logs an das, was die Person ins Berichtsfeld tippt. Der kostenlose Plan umfasst 500 Berichte pro Projekt, ohne Kreditkarte.
Entscheidend ist aber die Capture-Schicht, egal wer sie schreibt. Der Rest des Tools sorgt nur dafür, dass ihre Daten bei dir ankommen.
JavaScript-Error-Tracking für kleine Web-Apps: was du wirklich brauchst – Fehler abfangen, Wiederholungen gruppieren und den Stack im eigenen Code lesen, ohne großes Monitoring-Tool.
So fügst du deiner Website einen Button für Fehlerberichte hinzu – drei Wege zum Melde-Button: Script-Tag, npm-Paket oder Initialisierung im Code, mit Tipps zur Konfiguration.
Bei mir läuft’s: ein Leitfaden für nicht reproduzierbare Bugs – acht Unterschiede zwischen zwei Rechnern und welche erfassten Daten jeden davon entlarven.