Monitoring
What the server counts about itself and every app, how to see it in a terminal with
melee stats, and how to get it into a Grafana Cloud dashboard with one agent on the box. What is not measured is listed too.
melee-server keeps a small set of counters in memory: requests and how they ended, how long they took, how
long they waited for a slot, process starts and stops and why, what is running right now, and what the kernel
says each app is using. It serves them on the control API in two shapes: Prometheus text on
GET /v1/metrics for a scraper, and JSON on GET /v1/stats for melee stats. Nothing runs inside the app
binary: it is sealed by the sandbox and cannot load a metrics gem, so what the server sees from outside is
what is measured. Two mechanisms outside Rails appear below: Prometheus text is a plain-text format
(name{label="value"} 123, one line per number) that every metrics scraper reads, and a cgroup is a
directory under /sys/fs/cgroup in which the Linux kernel enforces limits and keeps accounts (memory, CPU,
process count) for every process placed in it; melee puts each app in /sys/fs/cgroup/melee/<app>, which is
where the memory and CPU figures come from.
melee stats
From any app directory whose melee.toml names the server:
melee stats
server 0.1.0 up 3d, 1 app(s) deployed, 4 deploy(s) ok / 1 failed, 12 unrouted request(s)
APP WARM WORKER BUSY REQUESTS FAILED MEAN STATIC MEMORY PROCS
volleyball up - 0+0 1834 3 1.2ms 611 14.2M 2
BUSY is requests holding a slot + requests waiting for one; MEMORY and PROCS come from the app's cgroup (Linux, root).
| column | meaning |
|---|---|
WARM, WORKER | whether the app’s warm process and its durable-object worker are running right now |
BUSY | requests holding a slot + requests waiting for one, right now |
REQUESTS | requests that reached the app path since the server started (files from public/ are STATIC) |
FAILED | of those, everything that did not end in a 2xx or 3xx: the app’s own 4xx/5xx and the server’s timeout, busy, gone, spawn-failed and cooling-down answers |
MEAN | mean time from the request arriving to the answer leaving, queueing and cold starts included |
MEMORY, PROCS | memory.current and pids.current from the app’s cgroup; - on macOS or when the server is not root |
Counts are since the server started; a restart zeroes them. The figures above are illustrative, not from a real run.
Reading /v1/metrics by hand
The two routes take the control token like every other route. They also take a second, optional token,
configured with --metrics-token-file, which opens exactly these two routes and gets a 403 anywhere else.
That is the token a scraper should hold, so that a leaked agent credential can read numbers but cannot deploy.
The server refuses to start if the two files hold the same value. Both files are 0600 root; the server
refuses a secret file that anyone else can read.
curl -s -H "Authorization: Bearer $(cat /etc/melee/metrics-token)" http://127.0.0.1:7070/v1/metrics
# HELP melee_apps_deployed Apps with a current release.
# TYPE melee_apps_deployed gauge
melee_apps_deployed 1
# HELP melee_requests_total Requests that reached the app path, by outcome.
# TYPE melee_requests_total counter
melee_requests_total{app="volleyball",outcome="2xx"} 1790
melee_requests_total{app="volleyball",outcome="3xx"} 41
...
A scrape reads counters, asks each pool what is running and reads five files from each app’s cgroup. It never starts a process and never writes anything, so polling it every fifteen seconds does not keep an idle app warm and does not touch the app’s directories. It also never waits for a process that is starting: a scrape that lands mid-start reports that app as not up. This is also why the number of durable objects per app is not in it: reading that would start the worker.
Setting up Grafana Cloud
The production recipe (deploy/install.sh) installs Grafana Alloy as the
one agent on the box. It scrapes /v1/metrics with the metrics token, exports host metrics (CPU, memory,
disk, network) and ships the systemd journals of melee-server, caddy and alloy to Loki, and sends all of
it to your Grafana Cloud stack. Alloy runs as its own user and never reads /etc/melee/metrics-token
itself: install.sh copies the value into /etc/melee/alloy-generated.env as MELEE_METRICS_TOKEN, a
root-only file systemd hands to the service, and rewrites it on every run so the two cannot drift. The free tier (10,000 active series) is plenty: melee-server produces about 84
series per app and six for the server, plus a few hundred from the host exporter.
-
Create a Grafana Cloud account and a stack (any region). On the stack’s page at grafana.com, the Prometheus tile’s Details shows a remote-write URL and a numeric user id; the Loki tile’s Details shows a push URL (ending
/loki/api/v1/push) and a user id of its own. Two URLs, two ids. -
Make an API key: Security > Access Policies, a policy scoped to the stack with
metrics:writeandlogs:write, then a token on it (older UI text calls this aMetricsPublisherAPI key). Copy it once; it startsglc_. One token serves both remote writes, and it is write-only: it can push samples and cannot read anything back. -
Fill in
/etc/melee/alloy.envon the box.install.shwrote it with empty values, mode 0600 root:GRAFANA_CLOUD_PROM_URL=https://prometheus-xxx.grafana.net/api/prom/push GRAFANA_CLOUD_PROM_USER=123456 GRAFANA_CLOUD_LOKI_URL=https://logs-xxx.grafana.net/loki/api/v1/push GRAFANA_CLOUD_LOKI_USER=654321 GRAFANA_CLOUD_API_KEY=glc_...The two
LOKIlines are optional:install.shrenders the journal-to-Loki half of the Alloy config only whenGRAFANA_CLOUD_LOKI_URLis set, so you can start with metrics and add logs later by filling them in and rerunninginstall.sh(mise run prod:upgradedoes that).Plain
KEY=value, no quotes, nothing after the value on the line. -
Start Alloy:
systemctl enable --now alloy, or reruninstall.sh, which enables it once it seesGRAFANA_CLOUD_PROM_URLfilled in and leaves it disabled otherwise.journalctl -u alloy -n 50should show it scraping without401s; a401from127.0.0.1:7070meansMELEE_METRICS_TOKENin/etc/melee/alloy-generated.envno longer matches/etc/melee/metrics-token(reruninstall.sh, which regenerates it), and a401from Grafana Cloud means the user id or key is wrong. -
Import the dashboard: in Grafana, Dashboards > New > Import, upload
deploy/grafana/melee.jsonfrom the checkout, and pick your stack’s Prometheus data source when asked. It has one variable,app, which defaults to every app.
Nothing above has been run against the production box yet (O-04 in TASKS.md); the configuration was
written from Alloy’s documentation and unit-tested against the server, not against a live stack.
What each dashboard row shows
| row | panels | what to look for |
|---|---|---|
| Overview | apps deployed; warm processes up; requests/s; failed fraction over 5 min; p95 request time; server uptime | the failed fraction: anything above a few percent that is not a bot probing 4xxs |
| Requests | requests/s by app (static files as a second line); requests/s by outcome; p50/p95/p99 request time; slots in flight, queued, and the p95 queue wait | outcome lines other than 2xx/3xx/4xx; a queue wait that is not near zero means --slots is too low for that app |
| Processes | warm process and worker up, as a timeline; starts and stops per hour by reason; cold start p95; seconds until the next durable-object timer | exited stops are crashes; timeout stops are requests that hit --request-timeout-secs; frequent idle stops and starts mean --idle-secs is short for the traffic pattern |
| Resources per app | memory and peak memory; CPU rate; process count and OOM kills | memory near --memory-max (128 MB by default), or any OOM kill at all |
| Host | CPU busy; memory used; disk used on / | disk, since nothing prunes releases or logs |
Stop reasons in full: idle (no request for --idle-secs), timeout (a request ran past
--request-timeout-secs and the process group was killed), slot_error (a broken exchange on a slot, killed;
a process that dies mid-request lands here or in exited depending on which the server noticed first, so read
the two together as “died on its own”), replaced (the server stopped it: a deploy, melee env,
melee restart or shutdown), exited (it ended on its own). Request outcomes: 2xx, 3xx, 4xx, 5xx from the app; timeout (504), busy (503), gone
(502, the process died before answering), spawn_failed (502), cooling_down (503 after two failed starts)
from the server.
What is not measured
- Durable-object calls. A request child calls the worker over a UNIX socket inside the sandbox that the
server never sees, so there is no count of object calls and no per-object or per-method timing. The only
object figure is
melee_worker_admin_calls_total, which countsmelee objects. - Object count per app, because reading it would start the worker.
- Anything inside the app: individual routes, queries, template time. Outcomes are status classes, so a
404and a422are both4xx. - The app’s own log lines.
<home>/logs/<app>.ndjsonis root-only and Alloy runs asalloy; only the three units’ journals reach Loki.melee logsreads the file as before. - Alerting. Nothing pages you. The metrics to alert on are
melee_warm_process_upat 0 whilemelee_requests_totalgrows under a server outcome,melee_app_oom_kills_totalincreasing, and disk from the host exporter (O-07). - On macOS, or as a non-root user, the five cgroup metrics are absent and
melee statsprints-.
The design, the full metric table and the series budget are in the repository’s
docs/design/observability.md; the decision and the alternatives (AppSignal, self-hosted Prometheus, Netdata)
are ADR 0018.