Skip to content
English

DevTools overview

Part of why the BEAM feels the way it does is that you can see the system: observer connects to a live node and shows you the process tree, the mailboxes, the load. DevTools is that idea for actor-ts — an embeddable web UI you attach to a running ActorSystem.

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

Open that URL and you land on the overview: what this system is, what it is doing, and how that is trending. The nav rail on the left is the way into every other tool.

DevTools is one product assembled from several tools. The nav rail always shows the full roster: a tool this system cannot offer stays greyed out with the reason, so nothing is hidden behind a click that fails.

PanelShows
OverviewSystem identity, uptime, live figures, trends
ActorsLive actor tree, cell states, mailbox depths, busiest actors
ClusterNode topology, shard distribution, membership history
TracingFlame graph and waterfall over recorded message spans
Explain planThe last messages one actor handled, with timings
Time travelBrowse a journal, reconstruct state at any point
ProfilerWhere the actor system spends its time
Dead lettersMessages the system could not deliver, and why
Event streamLive tail of the event bus, and the cluster topics
ConfigurationEvery resolved HOCON key, and which layer set it
Send messageSend JSON to an actor — off unless acknowledged

All of them speak one tap protocol over a single WebSocket, so they share a connection rather than each opening their own.

The overview itself is three sections, in the order you actually ask the questions:

  • Common — actor system, actor-ts version, uptime, runtime, cluster status. Uptime is the system’s, read from the server, so it survives a page reload and a dropped socket. The version is the running framework’s, straight from the handshake — hover it for the tap protocol version.
  • Numbers — actors, messages per second, processed messages, spawn and stop rates, restarts, mailbox backlog, stashed messages, suspended actors, dead letters, mailbox drops, and handler p99.
  • Charts — the same figures over the last few minutes, split across three plots so a level and a rate never share a y-axis, plus the busiest mailboxes right now.

Every panel here is a live view, which is exactly wrong at the moment it becomes interesting: the row you want to read is gone before you have read it. Pause in the header — or the P key, anywhere outside a text field — stops the whole view at once. Resume starts it again.

Two things stop, and the second one is the point:

  • The data. No panel folds anything new in while time is stopped.
  • The clock the panels are read against. Every “how long ago” reading in the UI holds still with the view it describes — uptime, a departed node’s last seen, and the stopped 12s ago badge on a terminated actor. This is what makes the pause useful rather than merely quiet: the actors panel keeps a stopped actor for 30 seconds and then sweeps it away, and 30 seconds is well inside the time it takes to read a supervision tree. Paused, it stays.

Nothing that happened during the pause is lost, but the two kinds of stream get there differently:

StreamsWhile pausedOn resume
Event stream, tracing spans, profilerheld, oldest dropped past a capdelivered in order
Overview figures, actors, cluster, mailboxesdiscardedfresh snapshot — the view jumps to now

The split is not a compromise, it is what each kind of data is. A tail is its frames and the server keeps no past to recover them from, so holding them is the only way not to lose them. A tree or a membership list only ever answers “what is true now”, and a fresh snapshot answers that exactly — and more cheaply than replaying every delta. The header says how many frames are being held, and names anything the cap had to throw away.

The charts do not end up with a hole. The server records its figures continuously whether or not anyone is watching, so resuming re-reads the window and the paused stretch fills in.

Two things deliberately do not stop:

  • The connection check. A node that dies while you are paused still raises the No node reachable dialog. A paused screen and a dead one look identical, so the one you did not ask for has to say so.
  • The server. The taps keep running; pausing is a property of your view, not of the system being watched. It is also why a pause costs the actor system nothing extra and can be held as long as you like.

DevTools.attach binds a server of its own — usually what you want, since the DevTools port should be firewalled separately from your application’s.

  1. Attach, ideally behind a flag so production never runs it:

    if (process.env.DEVTOOLS === '1') {
    await DevTools.attach(system); // http://127.0.0.1:9333
    }
  2. Open the URL the returned binding reports. With port: 0 the operating system picks a free port and the binding tells you which.

  3. Detach when you are done. This also happens automatically during CoordinatedShutdown, so a SIGTERM releases the port:

    await devtools.detach();

To put DevTools next to your management endpoints instead of on a port of its own, take the routes and bind them yourself:

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);

Whatever middleware wraps that subtree applies to DevTools too.

mount asks for the gate up front, and this is why: unlike attach it never learns where its routes end up. host is not read on this path — the caller binds — so DevTools cannot tell a private port from a public one, and it will not guess. Passing auth or ipAllowlist is the direct answer and the sturdier one: both wrap the tree that is returned, so the gate travels with it wherever it is mounted. When the surrounding server already gates the mount point, say so instead:

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

DevTools then logs one line recording that it is running without a gate of its own, and the rest is yours to get right.

Builder-first, as everywhere else in the framework; a plain object works just as well.

const devtoolsOptions = DevToolsOptions.create()
.withPort(9333)
.withHost('127.0.0.1')
.withPanels({ timeTravel: false });
OptionDefaultMeaning
host'127.0.0.1'Interface to bind. Anything routable needs a gate — see below.
port9333Port to bind; 0 picks a free one.
authMiddleware wrapping the whole tree (e.g. BearerTokenAuth).
ipAllowlistMiddleware wrapping the whole tree.
allowRemotefalseAcknowledge an ungated, routable bind (attach).
allowUngatedMountfalseAcknowledge a mounted tree DevTools does not gate (mount).
backendframework defaultHTTP backend for the DevTools server.
serveUitruefalse leaves the tap without the UI.
allowedOriginssame-originOrigins allowed to open the WebSocket.
panelsall enabledPer-panel switches — see below.
uiDevelopmentRootServe the UI from disk; panel development only.

Each panel can be disabled individually, and a disabled panel is disabled for good — its data never leaves the process, whatever a client asks for:

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

Time travel is the one to think about first: it is the panel that surfaces raw persisted events — closely followed by dead letters and the event stream, which both show message payloads.

DevTools is a debugger, and the defaults treat it as one.

  • Loopback by default. host is 127.0.0.1, so nothing outside the machine can reach it.

  • A routable bind must be deliberate. On attach, setting host to anything that is not loopback throws unless you also pass auth, ipAllowlist, or allowRemote: true. The error names all three, so a typo in a host string cannot quietly publish your actor state.

  • A mount must be deliberate too. mount hands its routes to a server DevTools never sees, so it cannot check the host the way attach does — host is not even read on that path. It therefore asks up front: auth, ipAllowlist, or allowUngatedMount: true, which acknowledges that whatever gates this tree is outside DevTools (and gets a line in the log saying so). The loopback default does not count as a gate here, because it is not what the caller will bind.

  • Gates cover everything. auth and ipAllowlist wrap the UI, the JSON endpoints and the WebSocket upgrade — a gate on only the JSON would leave the actual data channel open.

  • Same-origin sockets. The tap accepts a WebSocket upgrade whose Origin names the tap itself, and rejects any other — so a page you visit cannot dial your local DevTools. This is on by default and needs no configuration: the UI is served by the tap, so its origin always matches. allowedOrigins widens the rule for a UI served from somewhere else; it does not replace it. A request with no Origin at all is allowed, because CSWSH needs a browser and a browser always sends one.

    Binding to loopback is not a substitute for this. A WebSocket handshake is not subject to the same-origin policy, so any page in the developer’s browser can reach 127.0.0.1.

  • A connected client cannot flood the tap. Requests are answered off the hub’s mailbox, so one slow journal read cannot stall the other tabs — which also means the mailbox is not what bounds the work. The hub counts it instead: 32 requests in flight per connection and 256 across the tap. A request past either is refused with an unavailable error rather than queued. The sockets are capped too, at 32 concurrent connections; an upgrade past that is closed with 1013 before it is wired up.

    A panel never comes near those numbers. A client asking for thousands of concurrent journal replays does, and the tap runs in the same process as the actors you are debugging — so what it could starve is your application, not just the debugger. See the protocol reference for what a client should do when it is refused.

  • The cluster side goes by the connection. When you pass a Cluster, every node answers “how is your node doing?” over the cluster transport and the serving node collects the answers. Both ends trust the connection rather than the message: a node replies down the connection the question arrived on, never to an address the question named, and the collector files a reading under the address the transport supplied, never the one the reading claims for itself. Only a node the cluster currently holds as a member gets a row, and both the number of rows and the size of a reported actor tree are capped. A peer that lies can only lie about its own row.

    This bounds what one compromised or buggy member can do to the dashboard. It is not authentication of the cluster port — that is mTLS, and it stays the thing to configure when the port is reachable by anything you do not run.

If you do expose it, put it behind the same auth as your management endpoints and treat the credentials as production secrets:

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

The examples wired for DevTools are the ones that run until you stop them — the HTTP and cache services, the sharding demo, the chat and voice backends — and none of them pay for it unless you ask:

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

--devtools works in every shell. DEVTOOLS=1 bun run … does the same on a POSIX shell, but VAR=value command is a parser error in PowerShell — there, set it separately:

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

That dividing line is the whole rule: an example that finishes on its own carries no reference to DevTools at all, so you can copy one into a project and run it unchanged. It took two passes to get there, which is worth knowing if you remember the older wording. The sweep that removed the wiring (#552) keyed on which examples had explicitly parked themselves before shutdown so DevTools could be opened at all — hello-world, the patterns/ and persistence/ walkthroughs, and the rest of the short scripts. That left seven behind that had never parked themselves and only ever attached: 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 and management/opentelemetry-tracing.ts. They bound a port and logged a URL that was dead before a browser could open it — singleton-hello printed three such URLs and exited after about 1.3 s. They are unwired now too.

Teaching those seven to stay up was the other way to close that gap, and it is not what happened: each one ends by leaving the cluster and terminating its systems, so parking afterwards would hand the browser a system that is already gone — and parking instead of the teardown would delete the demonstration, which in three of them is exactly that teardown. For something to actually look at, reach for one of the services above or for a cluster node below.

Each attachment takes its own port, counting up from --devtools-port / DEVTOOLS_PORT (default 9333). That counter is per process, so a cluster built from separate terminals — where every node would start at 9333 — takes the first free port in the range instead, the same way the cluster transport scans for its own:

Terminal window
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

Three terminals, one three-node cluster, and DevTools on 9333, 9334 and 9335 without a single DevTools port flag — --port there is the node’s cluster port. If none is free — or anything else about DevTools fails — the example logs a warning and starts anyway. A debugger that cannot bind is not a reason for the program under debug to die.

The bind interface is 127.0.0.1. --devtools-host / DEVTOOLS_HOST changes it — the flag for when the browser is not on the machine running the example: a container, a VM, a WSL or remote dev box.

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

Typing a non-loopback host there is the deliberate act Security asks for, so the example pairs it with allowRemote and DevTools binds — and warns that it is reachable without auth — instead of refusing and leaving you to guess why. That trade is fine for an example and for nothing else: anything with real state in it wants auth or an ipAllowlist in front of the port.

The wiring lives in examples/devtools.ts if you want to copy the pattern into your own app.

Every panel keeps showing the last thing it was told — the final reading before a node died is usually the one worth having — so a lost connection would otherwise look exactly like a healthy system. After a couple of seconds without an answer a dialog opens saying No node reachable, counting how long that has been true, and it closes itself the moment something answers. Dismiss it to read the last figures anyway — the panel stays dimmed while the connection is down, so they cannot be mistaken for live ones, and the uptime counter freezes at its last reading rather than counting up past the death of the system it measures.

Each node serves its own DevTools, so another node’s port may still answer while the one you have open does not.

Unused, none. Creating the extension does nothing: no port, no taps, no instrumentation. Everything starts at attach(), and each panel’s data collection only runs while a browser is actually subscribed to it — an open overview does not make the system record spans.

While attached, DevTools switches the metrics registry on if you had not, because message throughput, mailbox drops and handler latency are counted by the framework itself and read 0 against the default noop registry. That makes every framework metric live, cluster counters included, for as long as DevTools is attached; detach() puts the noop back. A registry you enabled yourself is left alone in both directions — DevTools reads it and never disables it.

The UI is an Angular application whose charts are drawn by Apache ECharts, built by its own toolchain and embedded into the published package as a gzipped bundle. Both are build-time dependencies only: neither appears in actor-ts’s dependencies or peerDependencies, nothing a consumer installs carries them, and the served page loads nothing over the network. So the UI still needs no framework at runtime and no network access — what changed is how the bundle is produced, not what ships.

ECharts is loaded lazily, in a chunk of its own, so opening a panel that draws nothing costs none of it.

That toolchain is a separate install, deliberately not a workspace, which is why a fresh clone can typecheck, test and smoke without it — the built bundle is committed. Install it once before working on the UI:

Terminal window
bun run ui:install

To work on a panel:

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

The first rebuilds on save into devtools-ui/.dev; the second boots a demo system serving that directory via uiDevelopmentRoot. The loop is save → refresh, with no TypeScript build and no server restart.