post

Node.js debuggen und beobachten: Inspector, debug und mehr

Vom console.log-Reflex zum eingebauten Inspector: node --inspect, das debug-Modul, nodemon und ein schneller Speicherblick mit process.memoryUsage.

In fast jedem Node.js-Training kommt irgendwann der Moment, in dem jemand einen Fehler sucht und ich über die Schulter schaue. Und fast immer sehe ich dasselbe: eine Zeile console.log(x), dann noch eine, dann console.log('HIER'), dann console.log('HIER 2'). Das ist kein Vorwurf – ich habe jahrelang genauso gearbeitet, und für einen schnellen Blick ist das auch völlig in Ordnung. Aber es ist ein Werkzeug, und Node.js bringt von Haus aus deutlich schärfere mit. In diesem Beitrag zeige ich, wie ich meinen Teilnehmerinnen und Teilnehmern den Weg vom console.log-Reflex zu echten Breakpoints, schaltbarem Logging und einem ersten Blick auf Speicherverbrauch nahebringe.

Der rote Faden ist dabei eine kleine Eskalationsleiter. Ganz unten steht console.log – immer an, unstrukturiert, aber sofort da. Eine Stufe höher liegt das debug-Modul, das sich über eine Umgebungsvariable an- und ausschalten lässt, ohne dass ich Code anfasse. Darüber sitzt der eingebaute Inspector mit interaktiven Breakpoints in Chrome DevTools oder VS Code. Und ganz oben, wenn es um Performance oder Speicher geht, nutze ich denselben Inspector als Profiler. Wer diese vier Stufen sauber trennen kann, hört auf, blind zu raten.

Warum console.log an seine Grenzen kommt

console.log hat zwei Eigenschaften, die es im Kleinen praktisch und im Großen unangenehm machen. Erstens ist es immer aktiv: Was einmal im Code steht, wird bei jedem Durchlauf ausgeführt, auch in Produktion, wo es die Ausgabe verschmutzt und je nach Menge sogar Performance kostet. Zweitens ist es unstrukturiert – ich sehe einen Wert, aber ich kann nicht mit einem Schalter entscheiden, ob ich gerade die HTTP-Schicht oder den Datenbankzugriff sehen will, ohne die Zeilen wieder auszukommentieren.

Wer sich einmal mit dem Ereignismodell von Node.js beschäftigt hat, kennt das Problem in verschärfter Form: Bei asynchronem Code ist die Reihenfolge der Ausgaben nicht immer die, die man erwartet, und ein console.log mitten in einem Callback erzählt nur einen Bruchteil der Geschichte. Genau hier lohnt sich der Schritt zu Werkzeugen, die den laufenden Prozess anhalten und untersuchen können.

Der eingebaute Inspector: --inspect und --inspect-brk

Das Wichtigste zuerst, weil es in Trainings oft für Überraschung sorgt: Der Debugger steckt bereits in Node.js. Es gibt kein Extra-Paket zu installieren. Historisch gab es einmal ein separates Modul (node-inspector mit dem Befehl node-debug app.js), aber das ist seit Node 6 überholt und heute nur noch eine Randnotiz. Seit Node 6.3 spricht der Prozess selbst das Chrome DevTools Protocol über einen WebSocket – und genau darum funktionieren sowohl die Chrome DevTools als auch VS Code als Client.

Gestartet wird das mit einem Flag:

node --inspect ./src/server.js

Auf stderr erscheint dann eine Zeile wie diese:

Debugger listening on ws://127.0.0.1:9229/8f2c...-uuid
For help, see: https://nodejs.org/en/docs/inspector

Der Prozess bindet standardmäßig an 127.0.0.1:9229. Wichtig ist der Unterschied zwischen den beiden zentralen Flags:

  • --inspect startet nur den Debug-Server, der Code läuft ganz normal weiter. Das nutze ich bei einem Server, der ohnehin dauerhaft läuft und auf Anfragen wartet.
  • --inspect-brk startet ebenfalls den Debug-Server, hält aber vor der ersten Zeile meines eigenen Codes an. Das ist ideal für kurz laufende Skripte, die sonst durchgerauscht wären, bevor ich überhaupt einen Client verbinden konnte.
node --inspect-brk ./src/server.js

Für ein Skript, das nach 200 Millisekunden fertig ist, ist --inspect-brk praktisch die einzige sinnvolle Wahl – mit --inspect allein wäre der Prozess längst beendet, bevor ich in den DevTools auf "connect" geklickt habe. Diesen Unterschied frage ich in Trainings gerne ab, weil er in der Praxis viel Zeit spart.

Wer den Inspector nicht beim Start, sondern erst bei einem bereits laufenden Prozess aktivieren will, kann unter Unix-Systemen das Signal SIGUSR1 schicken. Unter Windows steht dieser Weg nicht zur Verfügung.

Vom Flag zum Client

Der offene Inspector-Port ist nur die halbe Miete – ich brauche noch einen Client, der sich verbindet. Zwei Wege nutze ich regelmäßig.

In Chrome (oder Edge) öffne ich chrome://inspect beziehungsweise edge://inspect. Unter "Remote Target" taucht der laufende Node-Prozess auf, ein Klick auf "inspect" öffnet die vertrauten DevTools mit Sources, Breakpoints, Call Stack und Konsole. Über "Configure" lassen sich Host und Port anpassen, falls ich nicht die Standardwerte verwende.

In VS Code lege ich mir eine Attach-Konfiguration in der .vscode/launch.json an:

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "attach",
      "name": "Attach to Node",
      "port": 9229
    }
  ]
}

Damit hänge ich mich an einen Prozess, den ich vorher mit --inspect gestartet habe. Alternativ gibt es in VS Code den "Auto Attach"-Modus, der sich automatisch an Node-Prozesse hängt, die aus dem integrierten Terminal gestartet werden. Beide Wege enden an derselben Stelle: interaktiven Breakpoints, an denen ich Variablen inspizieren und mich Schritt für Schritt durch den Code bewegen kann.

Der folgende Ablauf fasst zusammen, wie Flag, Agent und Client zusammenspielen:

flowchart LR
  A["node --inspect<br/>oder --inspect-brk"] --> B["Inspector-Agent<br/>CDP über ws://127.0.0.1:9229"]
  B --> C["Chrome DevTools<br/>chrome://inspect"]
  B --> D["VS Code<br/>request: attach"]
  C --> E["Breakpoints<br/>CPU-Profil<br/>Heap-Snapshot"]
  D --> E

Die Kernaussage dahinter: Beide Clients reden dasselbe Protokoll mit demselben eingebauten Agenten. Ich muss mich also nicht für ein Ökosystem entscheiden – ich wähle den Client, der mir gerade besser passt.

Das debug-Modul: schaltbares Logging ohne Codeänderung

Nicht jedes Problem verdient einen Breakpoint. Oft will ich nur nachvollziehen, welchen Weg eine Anfrage durch meine Schichten nimmt – aber eben nur dann, wenn ich es gerade untersuche, und ohne die Ausgabe dauerhaft im Code zu verankern. Genau dafür gibt es das debug-Modul, zum Stichtag in Version 4.3.4. Viele bekannte Bibliotheken nutzen es intern, und für eigenen Code ist es schnell eingebaut.

Der Kern ist eine Factory, die man mit einem Namespace aufruft:

const debug = require('debug')('app:http');

function handleRequest(req, res) {
  debug('incoming request %s %s', req.method, req.url);
  // ... handle the request ...
}

Der Clou steckt nicht im Code, sondern im Start. Standardmäßig gibt diese Zeile nichts aus. Erst wenn ich die Umgebungsvariable DEBUG setze, wird der passende Namespace sichtbar:

DEBUG=app:* node server.js

Das * ist eine Wildcard, und mit einem führenden Minus schließe ich Namespaces gezielt aus. So sehe ich zum Beispiel alles aus app, aber nicht die gesprächige Datenbankschicht:

DEBUG=app:*,-app:db node server.js

Damit ist der zentrale Vorteil erreicht: Ich schalte Logging an und aus, ohne eine einzige Zeile Code zu ändern. Das debug-Modul bringt außerdem automatische Zeitdifferenzen zwischen den Ausgaben mit (die +0ms-Angaben), färbt bei einem echten Terminal die Namespaces ein und kennt Format-Platzhalter wie %s, %d, %j, %o und %O.

Ein Detail, das regelmäßig für Verwirrung sorgt und das ich deshalb immer betone: Die Ausgabe von debug geht auf stderr, nicht auf stdout. Wer die Ausgabe umleiten oder mit grep filtern will und dabei nur stdout betrachtet, sieht schlicht nichts. Und DEBUG greift nur für Bibliotheken, die selbst debug verwenden – es ist kein globaler Schalter für jede beliebige Log-Ausgabe.

nodemon: Auto-Reload in der Entwicklung

Ein Werkzeug, das streng genommen nicht zum Debuggen gehört, aber die Feedback-Schleife enorm verkürzt, ist nodemon (zum Stichtag rund Version 2.0.20). Es beobachtet Dateien und startet den Prozess bei jeder Änderung neu, sodass ich nicht ständig von Hand Strg+C und neu starten muss. Ich nehme es als Dev-Abhängigkeit ins Projekt:

nodemon --watch ./ --ignore ./node_modules ./bin/www

Schöner ist eine nodemon.json im Projektwurzelverzeichnis, weil sie die Konfiguration festhält und sich gut mit dem Inspector kombinieren lässt:

{
  "watch": ["src"],
  "ext": "js,json",
  "ignore": ["node_modules"],
  "exec": "node --inspect src/server.js"
}

Über exec starte ich den Prozess direkt mit --inspect, sodass ich Auto-Reload und Debugger zusammen bekomme. Node.js selbst brachte zum Stichtag zwar bereits einen experimentellen Watch-Modus mit, aber für den Alltag setze ich weiterhin auf nodemon, weil es ausgereift und flexibel konfigurierbar ist.

Ein schneller Blick auf den Speicher

Bevor ich zum eigentlichen Profiling komme, gibt es einen sehr günstigen Zwischenschritt, den ich gerne zeige, weil er ohne jedes Werkzeug auskommt: process.memoryUsage(). Diese Funktion liefert ein Objekt mit mehreren Werten, alle in Bytes:

const { rss, heapTotal, heapUsed, external } = process.memoryUsage();

console.error(
  `rss=${(rss / 1e6).toFixed(1)}MB ` +
  `heapUsed=${(heapUsed / 1e6).toFixed(1)}MB ` +
  `heapTotal=${(heapTotal / 1e6).toFixed(1)}MB`
);

Die wichtigsten Felder in der Praxis: heapUsed und heapTotal betreffen den von V8 verwalteten JavaScript-Heap, während rss (Resident Set Size) den gesamten Speicher des Prozesses angibt, inklusive nativer Anteile. Wenn ich in einer Schleife oder über mehrere Anfragen hinweg zusehe, wie heapUsed immer weiter steigt und nach der Garbage Collection nicht mehr zurückgeht, habe ich einen ersten, sehr konkreten Hinweis auf ein Speicherleck. Für einen schnellen RSS-Wert allein gibt es seit Node 15.6 zusätzlich process.memoryUsage.rss().

Das ersetzt kein richtiges Profiling, aber es ist ein billiger Frühwarnwert – gerade bei langlaufenden Prozessen oder bei Verarbeitung großer Datenmengen, wie sie beim Umgang mit Streams und Backpressure auftreten.

Profiling über denselben Inspector

Wenn der grobe Blick mit process.memoryUsage() nicht reicht, führt der nächste Schritt zurück zum Inspector. Dieselbe Verbindung, die ich zum Setzen von Breakpoints nutze, öffnet in den DevTools auch die Tabs für Analyse: Unter "Memory" nehme ich einen Heap-Snapshot auf und sehe, welche Objekte wie viel Speicher belegen. Unter "Profiler" beziehungsweise "Performance" zeichne ich ein CPU-Profil auf und finde heraus, in welchen Funktionen die Zeit tatsächlich verbraucht wird.

Wer lieber ohne grafisches Frontend arbeitet, hat CLI-Alternativen. node --prof schreibt ein V8-Log, das ich anschließend mit node --prof-process in einen lesbaren Bericht verwandle:

node --prof ./src/server.js
# ... generate load, then stop the process ...
node --prof-process isolate-0x*.log > processed.txt

Daneben gibt es seit Node 12 die noch experimentellen Flags --cpu-prof und --heap-prof, die direkt Profildateien im DevTools-Format erzeugen. Für die meisten Trainingsteilnehmer bleibt aber der interaktive Weg über die DevTools der eingängigste Einstieg, weil er dieselbe Oberfläche nutzt, die sie vom Frontend ohnehin kennen.

Die vier Stufen im Überblick

Zurück zur Eskalationsleiter vom Anfang. Ich fasse die vier Werkzeuge bewusst als Stufen zusammen, weil jede ihren eigenen Zweck hat und keine die andere vollständig ersetzt:

  • console.log – immer aktiv, unstrukturiert, sofort verfügbar. Gut für den schnellen Einzelblick, schlecht als Dauerlösung und in Produktion fehl am Platz.
  • debug – namespacegesteuert, über die DEBUG-Umgebungsvariable ohne Codeänderung schaltbar, Ausgabe auf stderr. Ideal, um in der Entwicklung gezielt Schichten sichtbar zu machen.
  • Inspector – interaktive Breakpoints in Chrome DevTools oder VS Code, über --inspect beziehungsweise --inspect-brk. Der Schritt vom Vermuten zum Untersuchen.
  • Profiler – derselbe Inspector, aber für CPU-Profile und Heap-Snapshots. Kommt zum Einsatz, wenn es nicht um Korrektheit, sondern um Performance und Speicher geht.
flowchart TB
  A["console.log<br/>immer an, unstrukturiert"] --> B["debug<br/>DEBUG-Env, namespacegesteuert"]
  B --> C["Inspector<br/>interaktive Breakpoints"]
  C --> D["Profiler<br/>CPU-Profil und Heap-Snapshot"]

Zwei Fallstricke, die teuer werden können

Zum Schluss zwei Dinge, die ich in jedem Training ausdrücklich anspreche, weil sie in Produktion echten Schaden anrichten.

Der erste betrifft console.log in Produktion. Was in der Entwicklung harmlos aussieht, wird im Dauerbetrieb zum Problem: Die Ausgabe ist unstrukturiert, lässt sich nicht per Schalter abstellen, verschmutzt stdout und kostet je nach Menge Performance. Für die Entwicklung ist debug die bessere Wahl, und für Produktion gehört ein richtiger Logger her, der Log-Level, strukturierte Ausgabe und gezieltes Ein- und Ausschalten beherrscht.

Der zweite Fallstrick ist gefährlicher und wird oft unterschätzt: einen offenen --inspect-Port stehen zu lassen oder ihn an 0.0.0.0 zu binden. Der Debug-Port erlaubt das Ausführen beliebigen Codes im Prozess – wer ihn erreicht, kann den Server übernehmen. Deshalb bindet Node.js von Haus aus nur an 127.0.0.1, und dabei sollte es in aller Regel bleiben. Muss ich wirklich einen entfernten Prozess debuggen, tunnele ich den Port über SSH statt ihn öffentlich zu exponieren:

ssh -L 9229:localhost:9229 user@remote-host

Und das Debug-Flag hat in keinem Produktions-Startskript etwas verloren. Diese Trennung zwischen Entwicklungs- und Produktionsbetrieb passt gut zu dem, was ich schon im Node.js-Training zur Event-Loop-Architektur betone: Werkzeuge, die intern in den laufenden Prozess greifen, sind mächtig – und mit Macht kommt hier die Pflicht, den Zugang eng zu halten.

Ein Ausblick

Wer noch tiefer gehen will, findet in Node.js zwei fortgeschrittene Bausteine, die ich hier nur als Ausblick nenne, weil sie eigene Beiträge verdienen: async_hooks, um den Lebenszyklus asynchroner Ressourcen zu verfolgen, und diagnostics_channel, um strukturierte Diagnose-Ereignisse zwischen Bibliotheken und Beobachtern auszutauschen. Beide sind eher etwas für Werkzeugbauer und Bibliotheksautoren als für den täglichen Fehlerfang.

Fazit

Der Weg vom console.log-Reflex zu bewusstem Debuggen ist kein großer Sprung, sondern eine kleine Leiter mit vier Sprossen. Für den schnellen Blick bleibt console.log, aber sobald es strukturierter wird, übernimmt debug mit seinem schaltbaren Logging. Wird es wirklich knifflig, hält der eingebaute Inspector den Prozess an und lässt mich Variablen und Aufrufstapel untersuchen – wahlweise in Chrome DevTools oder VS Code, denn beide sprechen dasselbe Protokoll. Und wenn Performance oder Speicher das Thema sind, wechselt derselbe Inspector in den Profiler-Modus, während process.memoryUsage() schon vorher einen billigen Frühwarnwert liefert. Wer diese Stufen kennt und den Debug-Port konsequent verschlossen hält, hört auf zu raten und fängt an zu untersuchen.

Weiterführende Quellen

Kommentare

Kommentar schreiben