Zum Inhalt springen
Deutsch

DevTools — Überblick

Ein guter Teil dessen, warum sich die BEAM so anfühlt, wie sie sich anfühlt: Man kann das System sehen. observer verbindet sich mit einem laufenden Node und zeigt Prozessbaum, Mailboxes, Last. DevTools ist diese Idee für actor-ts — eine einbettbare Web-UI, die du an ein laufendes ActorSystem hängst.

import { ActorSystem } from 'actor-ts';
import { DevTools, DevToolsOptions } from 'actor-ts/devtools';
const system = ActorSystem.create('orders');
const devtoolsOptions = DevToolsOptions.create().withPort(9333);
const devtools = await DevTools.attach(system, devtoolsOptions);
console.log(devtools.url); // http://127.0.0.1:9333

Öffnest du diese URL, landest du auf der Übersicht: was dieses System ist, was es tut, und wohin sich das entwickelt. Die Nav-Leiste links ist der Weg in jedes andere Werkzeug.

DevTools ist ein Produkt, zusammengesetzt aus mehreren Werkzeugen. Die Nav-Leiste zeigt immer die vollständige Liste: Ein Werkzeug, das dieses System nicht anbieten kann, bleibt ausgegraut — mit Begründung, denn nichts soll sich hinter einem Klick verstecken, der ins Leere läuft.

PanelZeigt
ÜbersichtSystemidentität, Uptime, Live-Zahlen, Verläufe
ActorsLive-Actor-Baum, Cell-States, Mailbox-Tiefen, ausgelastetste Actors
ClusterNode-Topologie, Shard-Verteilung, Membership-Historie
TracingFlame-Graph und Waterfall über aufgezeichnete Message-Spans
Explain-PlanDie letzten Nachrichten eines Actors, mit Timings
Time TravelJournal durchsehen, Zustand zu beliebigem Zeitpunkt rekonstruieren
ProfilerWo das Actor-System seine Zeit verbringt
Dead LettersNachrichten, die das System nicht zustellen konnte, und warum
Event StreamLive-Tail des Event-Busses und die Cluster-Topics
KonfigurationJeder aufgelöste HOCON-Key und die setzende Schicht
Nachricht sendenJSON an einen Actor — aus, außer bestätigt

Alle sprechen ein gemeinsames Tap-Protokoll über einen einzigen WebSocket — sie teilen sich eine Verbindung, statt dass jedes seine eigene öffnet.

Die Übersicht selbst besteht aus drei Abschnitten, in der Reihenfolge, in der man die Fragen tatsächlich stellt:

  • Common — Actor-System, actor-ts-Version, Uptime, Runtime, Cluster-Status. Die Uptime ist die des Systems, vom Server gelesen — sie übersteht damit einen Reload der Seite und eine abgerissene Verbindung. Die Version ist die des laufenden Frameworks, direkt aus dem Handshake; beim Überfahren erscheint die Version des Tap-Protokolls.
  • Numbers — Aktoren, Nachrichten pro Sekunde, verarbeitete Nachrichten, Spawn- und Stop-Raten, Restarts, Mailbox-Rückstau, gestashte Nachrichten, suspendierte Aktoren, Dead Letters, Mailbox-Drops und Handler-p99.
  • Charts — dieselben Zahlen über die letzten Minuten, auf drei Diagramme verteilt, damit sich ein Füllstand und eine Rate nie eine y-Achse teilen, dazu die aktuell vollsten Mailboxen.

Jedes Panel hier ist eine Live-Ansicht — und genau das ist falsch in dem Moment, in dem es interessant wird: die Zeile, die du lesen willst, ist weg, bevor du sie gelesen hast. Pause in der Kopfzeile — oder die Taste P, überall außerhalb eines Textfelds — hält die ganze Ansicht auf einmal an. Resume lässt sie weiterlaufen.

Zwei Dinge stehen still, und das zweite ist der eigentliche Punkt:

  • Die Daten. Kein Panel faltet Neues ein, solange die Zeit steht.
  • Die Uhr, gegen die die Panels gelesen werden. Jede „vor wie langer Zeit”-Angabe der UI hält mit der Ansicht still, die sie beschreibt — Uptime, das zuletzt gesehen eines fortgegangenen Knotens und das stopped 12s ago an einem beendeten Aktor. Das macht die Pause erst brauchbar statt bloß still: das Actors-Panel behält einen gestoppten Aktor 30 Sekunden lang und kehrt ihn dann weg — und 30 Sekunden sind deutlich weniger, als das Lesen eines Supervisionsbaums dauert. Pausiert bleibt er stehen.

Nichts, was während der Pause passiert ist, geht verloren; die beiden Arten von Strom kommen aber unterschiedlich dorthin:

StrömeWährend der PauseBeim Fortsetzen
Event-Stream, Tracing-Spans, Profilergehalten, ältestes fällt ab einer Obergrenze wegder Reihe nach nachgereicht
Overview-Zahlen, Actors, Cluster, Mailboxenverworfenfrischer Snapshot — die Ansicht springt auf jetzt

Die Aufteilung ist kein Kompromiss, sondern das, was die jeweiligen Daten sind. Ein Tail ist seine Frames, und der Server hält keine Vergangenheit vor, aus der sie sich zurückholen ließen — Halten ist dort die einzige Art, sie nicht zu verlieren. Ein Baum oder eine Mitgliederliste beantwortet immer nur „was gilt jetzt”, und ein frischer Snapshot beantwortet genau das — billiger, als jedes Delta nachzuspielen. Die Kopfzeile sagt, wie viele Frames gehalten werden, und benennt, was die Obergrenze verwerfen musste.

In den Charts entsteht dabei kein Loch. Der Server zeichnet seine Zahlen durchgehend auf, ob jemand hinsieht oder nicht; beim Fortsetzen wird das Fenster neu gelesen und die pausierte Strecke füllt sich.

Zwei Dinge halten bewusst nicht an:

  • Die Verbindungsprüfung. Ein Knoten, der während deiner Pause stirbt, löst weiterhin den Dialog No node reachable aus. Ein pausierter und ein toter Bildschirm sehen gleich aus, also muss der, den du nicht angefordert hast, sich melden.
  • Der Server. Die Taps laufen weiter; Pausieren ist eine Eigenschaft deiner Ansicht, nicht des beobachteten Systems. Deshalb kostet eine Pause das Aktorsystem auch nichts zusätzlich und darf beliebig lange dauern.

DevTools.attach bindet einen eigenen Server — meist das, was du willst, denn der DevTools-Port sollte getrennt vom Anwendungsport gefirewallt werden.

  1. Attachen, am besten hinter einem Flag, damit Produktion es nie startet:

    if (process.env.DEVTOOLS === '1') {
    await DevTools.attach(system); // http://127.0.0.1:9333
    }
  2. URL öffnen, die das zurückgegebene Binding meldet. Mit port: 0 sucht das Betriebssystem einen freien Port aus, und das Binding sagt dir welchen.

  3. Detachen, wenn du fertig bist. Das passiert auch automatisch im CoordinatedShutdown, ein SIGTERM gibt den Port also von selbst frei:

    await devtools.detach();

Willst du DevTools neben deine Management-Endpunkte legen statt auf einen eigenen Port, hol dir die Routen und binde sie selbst:

import { BearerTokenAuth, concat, path } from 'actor-ts/http';
import { DevTools, DevToolsOptions } from 'actor-ts/devtools';
const devtoolsOptions = DevToolsOptions.create()
.withAuth(BearerTokenAuth({ tokens: [process.env.DEVTOOLS_TOKEN!] }));
const routes = concat(
managementRoutes(system, cluster),
path('devtools', DevTools.mount(system, devtoolsOptions)),
);
await system.http(8558, { host: '127.0.0.1' }).bind(routes);

Welche Middleware auch immer diesen Teilbaum umschließt, gilt damit auch für DevTools.

mount verlangt die Absicherung vorab, und zwar aus diesem Grund: Anders als attach erfährt es nie, wo seine Routen landen. host wird auf diesem Weg gar nicht gelesen — es bindet ja der Aufrufer —, also kann DevTools einen privaten Port nicht von einem öffentlichen unterscheiden, und es rät nicht. auth oder ipAllowlist sind die direkte und die robustere Antwort: Beide umschließen den zurückgegebenen Baum, die Absicherung wandert also mit, egal wo er eingehängt wird. Sichert der umgebende Server den Einhängepunkt bereits ab, sag es stattdessen so:

const devtoolsOptions = DevToolsOptions.create().withAllowUngatedMount();

DevTools schreibt dann eine Zeile ins Log, die festhält, dass es ohne eigene Absicherung läuft — den Rest richtig zu machen, ist deine Sache.

Builder-first wie überall im Framework; ein einfaches Objekt tut es genauso.

const devtoolsOptions = DevToolsOptions.create()
.withPort(9333)
.withHost('127.0.0.1')
.withPanels({ timeTravel: false });
OptionDefaultBedeutung
host'127.0.0.1'Interface zum Binden. Alles Routbare braucht eine Absicherung — siehe unten.
port9333Port zum Binden; 0 sucht einen freien.
auth—Middleware um den gesamten Baum (z. B. BearerTokenAuth).
ipAllowlist—Middleware um den gesamten Baum.
allowRemotefalseUngesichertes, routbares Binden bewusst bestätigen (attach).
allowUngatedMountfalseEinen von DevTools ungesicherten Routenbaum bewusst bestätigen (mount).
backendFramework-DefaultHTTP-Backend für den DevTools-Server.
serveUitruefalse lässt den Tap ohne UI laufen.
allowedOriginsSame-OriginOrigins, die den WebSocket öffnen dürfen.
panelsalle anSchalter pro Panel — siehe unten.
uiDevelopmentRoot—UI von der Platte servieren; nur für Panel-Entwicklung.

Jedes Panel lässt sich einzeln abschalten, und ein abgeschaltetes Panel bleibt abgeschaltet — seine Daten verlassen den Prozess nicht, egal was ein Client anfragt:

const devtoolsOptions = DevToolsOptions.create()
.withPanels({ timeTravel: false, deadLetters: false });

Über Time Travel solltest du zuerst nachdenken: Das ist das Panel, das rohe persistierte Events sichtbar macht — dicht gefolgt von Dead Letters und dem Event Stream, die beide Nachrichten-Payloads zeigen.

DevTools ist ein Debugger, und die Defaults behandeln es auch so.

  • Standardmäßig Loopback. host ist 127.0.0.1, nichts außerhalb der Maschine kommt heran.

  • Ein routbares Binden muss Absicht sein. Setzt du bei attach host auf etwas, das nicht Loopback ist, wirft es — es sei denn, du übergibst zusätzlich auth, ipAllowlist oder allowRemote: true. Die Fehlermeldung nennt alle drei, damit ein Tippfehler im Host-String nicht stillschweigend deinen Actor-Zustand veröffentlicht.

  • Ein Mount muss es genauso sein. mount gibt seine Routen an einen Server, den DevTools nie zu sehen bekommt, und kann den Host deshalb nicht prüfen wie attach — host wird auf diesem Weg nicht einmal gelesen. Also fragt es vorab: auth, ipAllowlist oder allowUngatedMount: true, womit du bestätigst, dass die Absicherung dieses Baums außerhalb von DevTools liegt (und eine Zeile im Log bekommst, die das festhält). Der Loopback-Default zählt hier nicht als Absicherung, denn er ist nicht das, was der Aufrufer bindet.

  • Absicherungen gelten für alles. auth und ipAllowlist umschließen die UI, die JSON-Endpunkte und das WebSocket-Upgrade — eine Absicherung nur auf dem JSON würde den eigentlichen Datenkanal offen lassen.

  • Same-Origin-Sockets. Der Tap akzeptiert ein WebSocket-Upgrade, dessen Origin auf den Tap selbst zeigt, und lehnt jedes andere ab — eine besuchte Seite kann deine lokalen DevTools also nicht anwählen. Das ist standardmäßig aktiv und braucht keine Konfiguration: die UI wird vom Tap selbst ausgeliefert, ihre Origin passt also immer. allowedOrigins erweitert die Regel für eine anderswo ausgelieferte UI, es ersetzt sie nicht. Eine Anfrage ganz ohne Origin ist erlaubt, denn CSWSH braucht einen Browser — und ein Browser schickt immer eine.

    Die Bindung an Loopback ersetzt das nicht. Ein WebSocket-Handshake unterliegt nicht der Same-Origin-Policy, jede Seite im Browser der entwickelnden Person erreicht also 127.0.0.1.

  • Ein verbundener Client kann den Tap nicht fluten. Requests werden außerhalb der Mailbox des Hubs beantwortet, damit ein langsamer Journal-Read nicht die übrigen Tabs blockiert — womit die Mailbox die Arbeit auch nicht mehr begrenzt. Stattdessen zählt der Hub sie: 32 offene Requests pro Verbindung und 256 über den ganzen Tap. Ein Request darüber hinaus wird mit einem unavailable-Fehler abgelehnt statt eingereiht. Auch die Sockets sind gedeckelt, bei 32 gleichzeitigen Verbindungen; ein Upgrade darüber hinaus wird mit 1013 geschlossen, bevor es verdrahtet wird.

    An diese Zahlen kommt ein Panel nie heran. Ein Client, der Tausende gleichzeitiger Journal-Replays anfordert, schon — und der Tap läuft im selben Prozess wie die Actors, die du untersuchst. Was er aushungern könnte, ist also deine Anwendung, nicht nur der Debugger. Was ein Client bei einer Ablehnung tun sollte, steht in der Protokoll-Referenz.

  • Die Cluster-Seite richtet sich nach der Verbindung. Übergibst du einen Cluster, beantwortet jeder Knoten über den Cluster-Transport die Frage „Wie geht es deinem Knoten?“, und der ausliefernde Knoten sammelt die Antworten ein. Beide Enden vertrauen der Verbindung, nicht der Nachricht: Ein Knoten antwortet über die Verbindung, auf der die Frage ankam, nie an eine Adresse, die die Frage genannt hat — und der Sammler legt eine Messung unter der Adresse ab, die der Transport geliefert hat, nie unter der, die die Messung für sich selbst behauptet. Eine Zeile bekommt nur ein Knoten, den der Cluster gerade als Mitglied führt, und sowohl die Anzahl der Zeilen als auch die Größe eines gemeldeten Actor-Baums sind gedeckelt. Ein lügender Peer kann nur über seine eigene Zeile lügen.

    Das begrenzt, was ein kompromittiertes oder fehlerhaftes Mitglied dem Dashboard antun kann. Eine Authentifizierung des Cluster-Ports ist es nicht — das ist mTLS, und das bleibt das, was zu konfigurieren ist, sobald der Port für irgendetwas erreichbar ist, das du nicht selbst betreibst.

Wenn du sie doch exponierst, hänge sie hinter dieselbe Auth wie deine Management-Endpunkte und behandle die Credentials als Produktivgeheimnis:

import { BearerTokenAuth } from 'actor-ts/http';
const devtoolsOptions = DevToolsOptions.create()
.withHost('0.0.0.0')
.withAuth(BearerTokenAuth({ tokens: [process.env.DEVTOOLS_TOKEN!] }));

Für DevTools verdrahtet sind die Examples, die laufen, bis du sie stoppst — die HTTP- und Cache-Services, die Sharding-Demo, die Chat- und Voice-Backends —, und keines zahlt dafür, solange du nicht fragst:

Terminal-Fenster
bun run examples/http/rest-service.ts --devtools

--devtools funktioniert in jeder Shell. DEVTOOLS=1 bun run … tut dasselbe in einer POSIX-Shell, aber VAR=wert befehl ist in PowerShell ein Parser-Fehler — dort setzt du die Variable separat:

Terminal-Fenster
$env:DEVTOOLS = '1'; bun run examples/http/rest-service.ts

Diese Grenze ist die ganze Regel: Ein Example, das von selbst fertig wird, enthält keinerlei Bezug auf DevTools mehr, du kannst also eines unverändert in ein Projekt kopieren und laufen lassen. Bis dahin waren es zwei Durchläufe — wissenswert, falls dir die ältere Formulierung noch im Kopf ist. Der Durchlauf, der die Verdrahtung entfernt hat (#552), ging danach, welche Examples sich vor dem Herunterfahren ausdrücklich geparkt hatten, damit DevTools überhaupt zu öffnen war — hello-world, die patterns/- und persistence/-Durchläufe und der Rest der kurzen Skripte. Sieben blieben dabei zurück, die sich nie geparkt, sondern nur angebunden hatten: cluster/singleton-hello.ts, cluster/singleton-cron.ts, cluster/sharded-daemon-hello.ts, cluster/sharded-daemon-fixed-workers.ts, discovery/service-locator-cluster.ts, pubsub/event-bus-across-nodes.ts und management/opentelemetry-tracing.ts. Sie banden einen Port und protokollierten eine URL, die tot war, bevor ein Browser sie öffnen konnte — singleton-hello gab drei solcher URLs aus und beendete sich nach rund 1,3 s. Auch sie sind jetzt entkoppelt.

Diesen sieben beizubringen, oben zu bleiben, wäre der andere Weg gewesen, die Lücke zu schließen — und er ist es nicht geworden: Jedes von ihnen endet damit, den Cluster zu verlassen und seine Systeme zu terminieren. Danach zu parken hieße, dem Browser ein bereits verschwundenes System hinzuhalten — und statt des Teardowns zu parken hieße, die Demonstration zu löschen, die in dreien von ihnen genau dieser Teardown ist. Wenn es wirklich etwas zu sehen geben soll, nimm einen der Services oben oder einen Cluster-Knoten unten.

Jede Anbindung nimmt sich ihren eigenen Port, aufsteigend ab --devtools-port / DEVTOOLS_PORT (Default 9333). Dieser Zähler gilt pro Prozess — ein Cluster aus getrennten Terminals, in dem jeder Knoten bei 9333 anfinge, nimmt deshalb den ersten freien Port im Bereich, genauso wie der Cluster-Transport seinen eigenen sucht:

Terminal-Fenster
bun run examples/cluster/counter-node.ts --devtools --port 9001
bun run examples/cluster/counter-node.ts --devtools --port 9002 --seeds 127.0.0.1:9001
bun run examples/cluster/counter-node.ts --devtools --port 9003 --seeds 127.0.0.1:9001

Drei Terminals, ein Cluster aus drei Knoten und DevTools auf 9333, 9334 und 9335, ganz ohne DevTools-Port-Flag — --port ist dort der Cluster-Port des Knotens. Ist keiner frei — oder scheitert an DevTools sonst etwas —, protokolliert das Example eine Warnung und startet trotzdem. Ein Debugger, der nicht binden kann, ist kein Grund, das zu debuggende Programm sterben zu lassen.

Gebunden wird auf 127.0.0.1. --devtools-host / DEVTOOLS_HOST ändert das — das Flag für den Fall, dass der Browser nicht auf der Maschine läuft, auf der das Example läuft: ein Container, eine VM, eine WSL- oder Remote-Dev-Box.

Terminal-Fenster
bun run examples/http/rest-service.ts --devtools --devtools-host 0.0.0.0

Einen Nicht-Loopback-Host dort hinzuschreiben ist genau die bewusste Entscheidung, nach der Sicherheit fragt. Das Example ergänzt deshalb allowRemote, und DevTools bindet — mit der Warnung, dass es ohne Auth erreichbar ist — statt sich zu verweigern und dich rätseln zu lassen, warum. Dieser Handel taugt für ein Example und sonst nichts: Alles mit echtem State gehört hinter auth oder eine ipAllowlist.

Die Verdrahtung liegt in examples/devtools.ts, falls du das Muster in deine eigene App übernehmen willst.

Jedes Panel zeigt weiter, was ihm zuletzt gesagt wurde — die letzte Messung vor dem Ausfall eines Knotens ist meist die interessante —, und genau deshalb sähe eine abgerissene Verbindung sonst aus wie ein gesundes System. Nach ein paar Sekunden ohne Antwort geht ein Dialog auf: No node reachable, mit der Angabe, wie lange das schon so ist — und er schließt sich von selbst, sobald wieder etwas antwortet. Wegklicken kannst du ihn, um die letzten Zahlen trotzdem zu lesen; das Panel bleibt dabei gedimmt, damit sie nicht mit lebenden verwechselt werden, und die Uptime friert auf ihrem letzten Stand ein, statt über den Tod des Systems hinaus weiterzuzählen, das sie misst.

Jeder Knoten liefert seine eigenen DevTools aus — der Port eines anderen Knotens kann also noch antworten, während der geöffnete es nicht tut.

Ungenutzt: keine. Die Extension zu erzeugen tut nichts: kein Port, keine Taps, keine Instrumentierung. Alles beginnt bei attach(), und die Datenerfassung eines Panels läuft nur, solange ein Browser sie tatsächlich abonniert hat — eine offene Übersicht bringt das System nicht dazu, Spans aufzuzeichnen.

Solange DevTools hängt, schaltet es die Metrics-Registry ein, falls du das nicht schon getan hast: Nachrichtendurchsatz, Mailbox-Drops und Handler-Latenz zählt das Framework selbst, und gegen die Noop-Registry stehen sie alle auf 0. Damit laufen alle Framework-Metriken scharf, Cluster-Zähler eingeschlossen, solange DevTools attached ist; detach() setzt die Noop-Registry zurück. Eine Registry, die du selbst eingeschaltet hast, bleibt in beide Richtungen unangetastet — DevTools liest sie und schaltet sie nie ab.

Die UI ist eine Angular-Anwendung, deren Charts von Apache ECharts gezeichnet werden, gebaut von ihrer eigenen Toolchain und als gzip-Bundle in das veröffentlichte Paket eingebettet. Beide sind reine Build-Zeit-Abhängigkeiten: Keines taucht in dependencies oder peerDependencies von actor-ts auf, niemand installiert sie beim Konsumieren des Pakets, und die ausgelieferte Seite lädt nichts über das Netz. Es gibt also weiterhin kein UI-Framework zur Laufzeit und keinen Netzwerkzugriff — geändert hat sich, wie das Bundle entsteht, nicht was ausgeliefert wird.

ECharts wird lazy in einem eigenen Chunk geladen: Wer ein Panel öffnet, das nichts zeichnet, zahlt nichts davon.

Diese Toolchain ist eine eigene Installation und bewusst kein Workspace. Genau deshalb kann ein frischer Clone ohne sie typechecken, testen und smoken — das gebaute Bundle ist eingecheckt. Einmalig installieren, bevor du an der UI arbeitest:

Terminal-Fenster
bun run ui:install

Um an einem Panel zu arbeiten:

Terminal-Fenster
bun run build:ui -- --dev --watch
Terminal-Fenster
bun run dev:devtools

Das erste baut bei jedem Speichern nach devtools-ui/.dev, das zweite startet ein Demo-System, das dieses Verzeichnis via uiDevelopmentRoot ausliefert. Die Schleife ist Speichern → Neu laden, ohne TypeScript-Build und ohne Server-Neustart.