Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Monitoring

What a node tells you about itself: two health endpoints on the main port, and Prometheus metrics on a port of their own.

Health and readiness

PathAnswers
GET /_wasmkeep/health200 ok while the process accepts requests.
GET /_wasmkeep/ready200 ready when the node should get traffic; 503 not ready otherwise.

A node is not ready once it starts shutting down; in a cluster, until it has checked that the bucket enforces conditional writes and that no other process runs with its node id (for good if either fails: serve exits), while it has not loaded the cluster secret or its node lease (the heartbeat, ADR 0020) is not valid, which takes the bucket refusing or not answering its writes for the lease’s ttl minus its safety margin (8 s by default; calls and durable writes would fail); and when the disk holding --data-dir has less than 1 GiB free (or 5% of a smaller disk), sampled every 5 s off the request path. The public answer gives no reason (the port faces clients); wasmkeep_not_ready{reason} on the metrics page does.

A node that stops for good says why in the same words in that reason and at the start of serve’s exit line, followed there by the details:

ReasonWhat it found
another process runs with this node idanother process writes this node id
this node was fenceda peer fenced its lease after it stopped renewing, or an operator ran wasmkeep cluster fence
this node's lease was released by another processanother process stopped cleanly under this id
this node's lease object was deletedits nodes/<id> object was removed
an older version of WasmKeep runs with this node ida heartbeat in the format from before node leases under this id
a node of an older WasmKeep version runs in this clusteranother node of a version from before node leases (stop every older node first)
this node's lease object holds something this version cannot readan unreadable object under this id
the bucket does not enforce conditional writesthe bucket check at start failed

Point load balancers at /_wasmkeep/ready, and use /_wasmkeep/health for “restart it if it stops answering”.

Open files

Every resident actor holds three open files (its database, write-ahead log and shared-memory index), every connection one. At start, wasmkeep serve raises its soft open-files limit to the hard limit, then caps connections and resident actors to what fits, logging a warning when that lowers them. With a cluster listener (--cluster-listen), its connections (up to 1,024) are counted first. Give the process a generous limit (LimitNOFILE=65536 in the systemd unit, or ulimit -n) to serve many actors at once.

The resident cap includes actors still closing their databases and blocking database work left by a cancelled event. An address without a mailbox (including a hibernated or failed-over actor) waits up to 250 ms for an idle actor’s resources to close. If capacity remains full, its request receives 503 Service Unavailable with Retry-After: 1. Registered mailboxes can still receive events, subject to their own queue limit (429). Relaying to another owner does not take a local actor slot when the owner is known (learned in the last second, or read from the actor’s ownership record within the admission wait); otherwise the request goes through local admission. Clients should honor the retry delay and bound their retries.

Alarms refused by admission use exponential backoff with jitter: a delay between half and all of a ceiling that starts at 100 ms and reaches 5 s. The retry counter below makes sustained admission pressure observable. WebSocket messages retain their frame while waiting up to the event wall limit; close callbacks remain pending through capacity or queue pressure. Closing sockets keep counting against socket limits until cleanup ends.

This admission rule uses the startup budget; it is not a live count of all process descriptors. Unrelated files and outbound connections still need the budget’s reserved headroom. The resident metric below counts registered mailboxes, so it can briefly fall before retiring resources release capacity.

Memory

Each resident actor costs about 250 KB for Rust and 740 KB for TypeScript on QuickJS (benchmarks); wasmkeep_actors_resident times that, plus the compiled classes, is roughly the process’s resident memory.

On Linux, wasmkeep serve and wasmkeep dev turn transparent huge pages off for their own process. Where the kernel sets them to always (some desktop and tuned distributions do; most servers use madvise), each actor’s memory would otherwise sit on 2 MiB pages it mostly does not use: on one such machine, a Rust actor measured 577 KB instead of 250 KB, with no gain in CPU or latency. To check a running node:

cat /sys/kernel/mm/transparent_hugepage/enabled   # the kernel's setting
pid=$(pidof wasmkeep)
grep THP_enabled /proc/$pid/status                 # 0: off for WasmKeep

--transparent-huge-pages system keeps the kernel’s setting instead. If the process cannot turn them off, it logs a warning and carries on.

Metrics

wasmkeep serve ... --metrics-listen 127.0.0.1:9100
curl 127.0.0.1:9100/metrics

Off by default. The page names classes and counts events, so bind it to loopback or a private address that only your Prometheus reaches. Labels are class names, event kinds and fixed reasons, never actor names.

MetricTypeWhat it tells you
wasmkeep_actors_residentgaugeActors in memory now: capacity planning.
wasmkeep_websockets_opengaugeRegistered WebSockets, including pending close cleanup.
wasmkeep_classesgaugeClasses served.
wasmkeep_ready, wasmkeep_not_ready{reason}gauges1 when /_wasmkeep/ready answers 200; otherwise one wasmkeep_not_ready series saying why.
wasmkeep_events_total{class,kind,outcome}counterEvents run; kind is request, alarm, websocket or init, outcome is ok or failed (trapped, cut, or a handler that returned an error).
wasmkeep_event_duration_seconds{class}histogramSlow handlers and hot classes.
wasmkeep_event_cuts_total{reason}counterEvents the host stopped: wall (past the wall-clock limit) or lease (this node lost the actor mid-event).
wasmkeep_cpu_budget_exceeded_totalcounterGuest calls stopped for using their CPU budget.
wasmkeep_instantiate_secondshistogramCreating an actor’s instance: the wake cost.
wasmkeep_wakes_total, wasmkeep_hibernations_totalcountersActor instances created (first request or wake) and dropped (hibernated, evicted, discarded after a trap, replaced by a new version, shut down): is the idle timeout right?
wasmkeep_forwards_total{kind}counterRequests (request), sockets (websocket) and calls (call) sent to the node owning the actor: placement efficiency.
wasmkeep_failover_waits_total{kind,outcome}counterRequests (request) and WebSocket upgrades (websocket) for an actor whose node was dead or stopping that this node kept until the actor could be taken over, instead of answering 503 at once (what a caller sees when a node dies), by how the wait ended: served (this node took the actor and answered), forwarded (another node took it first; sent there once), owner_alive (the owner wrote its lease again or restarted; sent to it as usual), expired (the wait ran out: 503), shutdown and lease_lost (this node began to stop, or its own lease ran out: 503), left (the client closed its connection: never run), answered_other (asked for the actor here and answered otherwise: the node full, the takeover failed, an access rule; or the client left during that try). over_cap counts requests that would have been kept and got the 503 at once because the node already kept a quarter of its connection limit, or 64 for that actor. expired rising when a node dies: the wait is shorter than the takeover (--failover-wait-secs), or no node could fence the dead one.
wasmkeep_durable_write_secondshistogramexec-durable until durable: the bucket’s health as actors feel it.
wasmkeep_requests_abandoned_totalcounterRequests and actor calls not run because their sender had gone while they waited in the actor’s queue (behind a long event, or a takeover’s restore): a client that closed its connection, or a caller whose call timed out. Nobody would get the answer, and nothing they would have written was acknowledged. A request that has started runs to its end. Seen only for a request received whole (no body, or one that arrived in the server’s first read, about 8 KiB): one whose body is still streaming (larger, chunked, or waiting for 100 Continue) runs as before.
wasmkeep_restore_read_secondshistogramOne object (a segment or a snapshot) a restore read from the bucket, from its request to its last byte, resends included. A restore keeps up to 8 reads, and 32 MiB by their listed sizes, under way while it applies the objects in order (always at least one, however large); a node’s restores together read at most 32 objects and 256 MiB at once, one read counting for at most 32 MiB. The time starts once a read has its share, so waiting for it is not counted, and reads overlap, so their sum can exceed the restore’s time. A read is resent when the bucket leaves it unanswered for 5 s, when it takes longer than 5 s plus a second per megabyte read alongside it on the node (its own included), or when the bucket refuses it with an error a resend can get past (after a pause of half a second, then a second, without its share, then the wait for a share again: both are in this time); three tries at most.
wasmkeep_restore_failures_totalcounterRestores of an actor from the bucket that did not complete, whatever the reason: a read that failed three times, the bucket refusing, the deadline of its reads (30 s, plus a second per megabyte and a second per eight objects of what it reads, counting the time it waits for the bucket to give it the next object: not the listing, local disk work, waiting for a turn among the node’s other restores, or the seconds those restores’ bytes in flight add to a read’s limit), a history that cannot be restored, or a local file error. A restore that fails before its database is complete leaves the node’s files as they were; one that fails putting the complete database in place (a local file error) has set the previous file aside, and the next restore reads the bucket again either way. For the bucket’s reasons the request that woke the actor got a 503 with Retry-After and the next one restores again from a fresh listing; the log line restore failed names the actor and the reason. A few during a bucket incident are expected; steadily rising with a healthy bucket means actors that cannot be restored in their time (see wasmkeep_restore_read_seconds) or a damaged history.
wasmkeep_listing_secondshistogramOne listing of the cluster’s heartbeats, with the view and the peers’ pins it refreshes (every heartbeat period, and early when an unknown pin knocks). Near the heartbeat horizon (10 s by default), peers’ pins expire before the next listing renews them and handshakes between nodes are refused; fences run on a task of their own and are not counted.
wasmkeep_guest_log_lines_total, wasmkeep_guest_log_dropped_totalcountersLines actors printed that reached the log, and that were dropped over the rate (what actors print).
wasmkeep_admission_refused_total{reason}counterRequests, WebSocket upgrades and actor calls this node refused before their event ran, by reason: access (the class’s access rule: an internal class reached from outside, 403, or a name an existing class does not let an outside request create, 404), capacity (no resident capacity, 503 with Retry-After, or a WebSocket over the socket limits), overloaded (the actor’s queue was full: 429 for requests and calls, 503 for a WebSocket upgrade) and unavailable (the node cannot run the actor now, 503: a required value not set, the deployed version or the app’s secrets not applied, the bucket not answering for the actor’s ownership; on a WebSocket upgrade, an ownership record that cannot be read also answers 503 and is counted here, where a request gets a 500 and is not counted). A 503 while an actor’s ownership changes hands between nodes is not a refusal and is not counted. All four series are always on the page. Each refusal is counted once, by the node that answered it: one forwarded to the actor’s node is counted there. Alarms are not counted here (wasmkeep_alarm_admission_retries_total). capacity rising: more actors awake at once than the node holds (see open files and memory); access rising: clients or links aimed at actors they may not reach.
wasmkeep_alarm_admission_retries_totalcounterAlarm deliveries rescheduled because resident capacity or an actor queue was full; no actor labels.
wasmkeep_node_lease_writes_total, wasmkeep_node_lease_failures_totalcountersWrites of this node’s lease (its heartbeat, ADR 0020) that reached the bucket, and that failed or timed out. With the defaults (a write every 3 s, valid for 8 s), two failing in a row and the lease runs out: the node reports not ready. A write that hangs is counted when it times out (2.7 s), so alert on the first increment.
wasmkeep_node_fences_totalcounterFences this node wrote over a node that stopped renewing its lease (crashed, or cut off from the bucket).
wasmkeep_bucket_requests_total{operation,status}counterRequests this node made to the bucket: cost and errors. See below.
wasmkeep_failovers_totalcounterActors this node took over whose ownership record still named another node, freed because that node was fenced, released or replaced by a new process. See below.
wasmkeep_history_collected_totalcounterObjects of actors’ history deleted from the bucket by garbage collection (ADR 0012).
wasmkeep_history_collect_failures_totalcounterGarbage-collection passes that failed (a bucket error); the next snapshot tries again. Steadily rising: the bucket refuses deletes.
wasmkeep_snapshot_upload_failures_totalcounterSnapshot uploads that were not acknowledged: the node gave the upload up, which does not say the bucket lacks it (an attempt given up can still land later, and is then an ordinary snapshot). A periodic snapshot has one attempt (one call to the bucket client, which may send the request again within it after an error answer), given up after 5 s plus a second per megabyte of the database, 10 s at most. One not acknowledged loses nothing and fails no event: the writes already uploaded are the history, and it is tried again once the actor has made one more write and 30 s have passed, then 8 writes later, then every 64 while it keeps failing (the node keeps these waits while an idle actor is out of memory; a node that takes the actor over, or one that restarted, tries at the actor’s first event there). Until one succeeds, a restore of that actor replays more writes; a database the bucket cannot take within 10 s counts here at every try. An actor’s first snapshot and the snapshot after a delete are sent again as a write is (an attempt is given up after 5 s plus a second per megabyte, the next one after twice that, three at most, the whole upload within 20 s plus that second per megabyte; the bucket client ends any single request after 30 s by default and does not send it again after that): the snapshot after a delete counts here and in wasmkeep_history_collect_failures_total, and does not delay the periodic one; an actor’s first snapshot counts here, and its event fails. Steadily rising: the bucket refuses or stalls large uploads.
wasmkeep_signatures_refused_full_total{record}counterCalls from other nodes refused because this node’s record of used signatures (replay protection: 500,000 at once for calls, ordinary node calls; 50,000 for control, take and alarm hand-off calls, kept apart so a flood of other calls cannot block a drain or a move). The node logs such a refusal as a full record, apart from replays. Any rise: far more node calls a minute than expected.
wasmkeep_websocket_owner_closes_total{reason}counterWebSockets closed with 1012 because this node no longer owned their actor, by how it found out: event (a message or close on one of them found another node owning the actor, or this draining node sending it to a peer), sweep (the owner sweep found another node holding a room that hibernated with sockets open here), lease_lapse (this node’s lease ran out) and drain (a draining node closing the sockets of hibernated rooms). Clients reconnect. A steady event or sweep rate means rooms often wake on another node than the one holding their sockets: hash the load balancer on the room’s path (front door).
wasmkeep_websocket_sweep_reads_totalcounterOwnership records the owner sweep read: once a minute, one per room that hibernated with WebSockets open here, 5,000 a pass at most (the next pass goes on with the rest). A pass stops at its first failed read. Rising with the number of such rooms; each read is a bucket GET.

Not there yet: per-tenant labels (one tenant per process until multi-tenancy), traps by reason (wasmkeep_events_total{outcome="failed"} and wasmkeep_cpu_budget_exceeded_total cover them for now) and parked WebSockets (sockets are not parked yet; wasmkeep_websockets_open counts them).

Bucket requests

Every bucket request a node makes is counted: leases, heartbeats, the alarm index, replication, restores and following a deployed app. operation is get, head, put (conditional writes included), delete, list, copy, rename or multipart. status is:

StatusMeans
okThe request succeeded.
not_foundThe object did not exist. Routine: a lease or an app not created yet.
preconditionA conditional write or read lost its condition (the object exists, or changed since it was read). Routine for leases: another node won the race.
errorAnything else: unreachable bucket, refused credentials, throttling, a server error.

Watch status="error"; the other two are part of normal operation. A listing counts once per call, however many pages it took: S3 and R2 return 1,000 keys a page, so a restore of an actor with 10,000 objects in the bucket counts 2 list requests here but bills about 22.

Failovers

wasmkeep_failovers_total counts, on the node that took them, actors taken from another node whose node lease was fenced (or released with the actor still recorded as its own, ttl plus grace earlier): that node died, was cut off from the bucket, or stopped without releasing the actor (ADR 0020). An actor that moves because its owner hibernated it, shut down, or gave it up is a hand-off (the owner released it) and is not counted, nor is a node taking back its own actors after a restart. Sum it over nodes for the cluster; outside rolling restarts of crashed nodes it should stay near zero.

Grafana dashboard

deploy/grafana/wasmkeep.json shows every metric above except wasmkeep_classes, the guest log counters (wasmkeep_guest_log_lines_total, wasmkeep_guest_log_dropped_total), the history collection counters (wasmkeep_history_collected_total, wasmkeep_history_collect_failures_total), wasmkeep_snapshot_upload_failures_total, wasmkeep_signatures_refused_full_total, wasmkeep_websocket_owner_closes_total, wasmkeep_websocket_sweep_reads_total, wasmkeep_restore_failures_total, the listing and restore-read histograms (wasmkeep_listing_seconds, wasmkeep_restore_read_seconds), the node lease write and fence counters (wasmkeep_node_lease_writes_total, wasmkeep_node_fences_total) and wasmkeep_requests_abandoned_total. To see it on a running cluster, the Compose example starts three nodes with Prometheus and Grafana, the dashboard loaded. Otherwise import it in Grafana (Dashboards, New, Import) and pick your Prometheus data source; it needs Grafana 10 or later. It assumes Prometheus adds an instance label to each node’s series, as it does by default, and filters by node and class.

RowPanels
OverviewNodes ready and not ready (with the reason), resident actors, open WebSockets, failovers over the time range.
EventsEvents per second by class and outcome, the failed share by class, duration p50 and p99, events stopped by the host (wall clock, lost lease, CPU budget).
Actors and placementWakes and hibernations, instantiation p50 and p99, forwards by kind, alarm admission retries.
Bucket and leasesBucket requests by operation and by status, durable write p50 and p99, bucket errors and node lease failures per node, failovers per node.

A test (the_grafana_dashboard_uses_only_metrics_that_exist) fails if the dashboard queries a metric the page does not have.

Alerts worth having

These are in deploy/prometheus/alerts.yml as Prometheus rules, with one more for a node Prometheus cannot scrape (up == 0 for a minute): a node that is down exports nothing, so none of the rules below would say so. Load the file with rule_files: in prometheus.yml and check it with promtool check rules after editing. It assumes a scrape job named wasmkeep, and the rules on durable write latency and on failed events wait 5 minutes before they fire. Two of the rules below hold “when no node was lost”: a rule cannot know that, so they fire on every fence and failover, a deliberate kill included, and their text says so. The Compose example loads the file. A test (the_alert_rules_use_only_metrics_that_exist) fails if a rule names a metric the page does not have.

  • wasmkeep_node_lease_failures_total increasing on any node: two failures in a row and the node drops every actor it holds.
  • increase(wasmkeep_node_fences_total[15m]) above 0 when no node was lost.
  • histogram_quantile(0.99, rate(wasmkeep_durable_write_seconds_bucket[5m])) above 1 s.
  • rate(wasmkeep_events_total{outcome="failed"}[5m]) above 1% of all events for a class.
  • /_wasmkeep/ready failing on any node for 2 minutes.
  • rate(wasmkeep_bucket_requests_total{status="error"}[5m]) above 0 for 5 minutes.
  • increase(wasmkeep_failovers_total[15m]) above 0 when no node was lost.

What actors print

What an actor writes to stdout or stderr (Rust’s println! and eprintln!, console.log in TypeScript, print in Python, printf in C) reaches the node’s log, one entry per line, as target wasmkeep::guest with the actor’s address and the stream; a stateless component’s says actor=stateless:

INFO wasmkeep::guest: order 42 shipped actor=acme/orders/42 stream="stdout"
  • Limits: --guest-log-rate lines a second per actor instance (50 by default, with bursts of twice that) and 1,000 a second for the whole node. Past them, lines are dropped and counted (wasmkeep_guest_log_dropped_total), and the actor’s next logged line is preceded by a warning saying how many were. --guest-log-rate 0 discards everything.
  • Lines: a line is logged when its newline arrives; one left without it is logged when the instance ends (hibernation, a trap). Lines are cut at 4 KB, and control characters are escaped (\u{1b}), so a guest cannot forge entries or colour a terminal. Writes never block or fail the guest.
  • Filtering: the usual RUST_LOG, e.g. RUST_LOG=info,wasmkeep::guest=warn to keep only the drop warnings, or wasmkeep::guest=off.
  • Actors log what they choose to: whatever they print (a secret included) lands in the node’s log, and wherever that is shipped.