Public: anyone can read this; the crew can change it.
/metrics adds the app series: apps up or asleep, and each cell's CPU and memory #4052
Check · Review · Land · Live done — Landed and live in production.
`GET /metrics` now serves the three app families. The type check and the new tests pass, along with `test/metrics-route.test.ts` and `test/openmetrics.test.ts`. I did not run the whole suite, and I did not scrape a running forge.
shipped production is serving it deploy 1f09d72
GET /metrics now serves the three app families. The type check and the new tests pass, along with test/metrics-route.test.ts and test/openmetrics.test.ts. I did not run the whole suite, and I did not scrape a running forge.
forge_app_up: a gauge per app and environment. It is 1 when the environment isrunningand 0 when it is asleep, stopped, deploying or failed. It reads the states the runtime already holds.forge_cell_cpu_seconds_totalandforge_cell_memory_bytes: one series per app and environment, for each cell the cell watch has already read. The watch keeps the CPU and memory counters in memory, so a cell that isn't running has no series. Nothing is started and nothing is shelled out.- Private apps: the
applabel is the app's id, never its slug. A test checks that the slug appears nowhere in the body. - Code: the families are in
src/metrics/forge.ts, and a smallsrc/metrics/app_sources.tsgathers the runtime states and cell counters for/metrics. - Docs:
docs/METRICS.mdlists all three families. - Tests:
test/metrics-cells.test.tschecks thatforge_app_upgoes from 1 to 0 when an app sleeps, that cell figures appear only once the watch has read the cell, that private apps use their id, and thatforgeMetricsserves the new families.
The CPU family is documented as forge_cell_cpu_seconds with samples named …_total. The existing route test requires every # TYPE family name to appear in the docs, and OpenMetrics drops _total from a counter's family name.
The issue said to read from kernel/vitals, but that module keeps no per-cell figures. The per-cell counters come from the cell watch in apps/contain.ts instead, which is the source behind the machine samples.
I left out per-environment up for apps the runtime hasn't loaded, which have no series. I also did not add a persisted history: the figures are only what the watch holds right now.
To try it, scrape /metrics with an admin ro token and look for forge_app_up.
The families are sound. /metrics stays behind accountAdmin(ctx, "ro"): on the preview, a guest gets 404 and nothing leaks. Label values go through escapeLabelValue. Nothing spawns or shells out, and the cost is one pass over the apps and the in-memory counters. The privacy rule and the up/asleep gauge are tested in appFamilies.
One miss against the bar: the code that decides privacy and feeds real data into the route has no test. src/metrics/app_sources.ts is new and nothing runs it. Every test passes a hand-built AppReading with private: true. If a.access === "private" were dropped, or api.ts went back to forgeMetrics(ctx.app.db), the suite would stay green. In the first case private slugs would be in the /metrics body; in the second the new series would be gone. metrics-route.test.ts would not catch either, because it only checks that # TYPE names are documented, and empty families still emit those lines.
Fix first: add a test that builds appSources from a real db with one public app and one private app, and checks that the private one comes out as private: true (or that its slug is absent from forgeMetrics(db, now, appSources(app))). Ideally also add a route-level test that GET /metrics with an admin ro token carries forge_app_up for a seeded running app.
Minor, not blocking: the cell regex matches on a.slug, but runtime labels are fixed when a cell starts (app:${record.slug}/${env}). After a slug rename a still-running cell loses its CPU and memory series until it restarts. Worth a line in docs/METRICS.md, or match on the app's id instead.
I added the test sentinel asked for, and the new tests, the existing metrics tests and tsc pass. The new code is wired into /metrics and the tests cover that wiring.
New test file: test/metrics-apps-route.test.ts uses a real forge database and a real HTTP server. It has one public app (notes) and one private app (secret-plans). It seeds an unstarted AppsRuntime with notes/production running, notes/pull-7 asleep, and secret-plans/production running. The runtime spawns no cell.
appSourcesreportsnotesas public andsecret-plansas private. Dropping thea.access === "private"check fails this test and the next one.- The
forgeMetrics(db, now, appSources(app))body names the private app by its id (forge_app_up{app="<id>",env="production"} 1). The slugsecret-plansis not in it. GET /metricswith an adminrotoken returns 200 and containsforge_app_up{app="notes",env="production"} 1,…env="pull-7"} 0, and the private app under its id. The slugsecret-plansdoes not appear. Revertingapi.tstoforgeMetrics(ctx.app.db)makes the app series disappear and fails this test.
Slug rename (non-blocking point): I kept matching cells on the slug. The runtime fixes a cell's label as app:<slug>/<env> when the cell starts, and CellCounter carries only that label. Matching on id would need a runtime change that is out of scope. docs/METRICS.md now says a renamed app's cell figures return when the cell restarts.
The wiring, the admin gate (accountAdmin(ctx, "ro")), label escaping and the private-id naming are all fine. The problem is that the cell families can make /metrics fail outright during every deploy, and nothing tests that case.
High: duplicate series throws, so the whole scrape returns 500 during a deploy. openMetrics throws duplicate series in … when two samples in one family have the same label set (src/metrics/openmetrics.ts:105-106). A cell's watch label is fixed at spawn as app:<slug>/<env> (src/apps/runtime.ts:1416). The runtime starts and health-checks the new deployment while the previous cell is still running, and stops the old one only after the swap (src/apps/runtime.ts:1135-1166). For that whole window, cellCounters() returns two entries with the same label. appFamilies pushes both as {app, env}, so openMetrics throws and the scrape loses everything: the egress families, the duration histogram and forge_app_up as well as the cell figures. The same thing happens after a rename: if app A leaves slug x and app B takes it, A's still-running cell and B's cell collide. A's figures are also reported under B's name, and if A is private and B is public, a private cell's figures go out under a public name.
Fix first: never emit two samples with the same {app, env}. Sum the counters of every cell whose label matches (CPU and RSS of the old and new trees together is a true total for the environment), or keep only the newest key. Then add a test in test/metrics-cells.test.ts that passes two CellCounters with the same label app:notes/production and asserts that forgeMetrics returns a body rather than throwing. That test fails today. If you can, match cells to apps by something that does not move on a rename. If that needs a runtime change, open an issue for it rather than widening this pull.
Minor: the /^app:(.+)\/([^/]+)$/ regex runs once per cell for every app (apps × cells). Parsing each cell label once into a map keyed by slug would be simpler and linear.
Cells that share a watch label are now summed into one series per {app, env}, so a deploy no longer makes /metrics throw. The metrics tests and tsc pass; I didn't run the whole suite.
- The fix:
appFamiliesinsrc/metrics/forge.tsparses each cell label once into a map keyedslug/env, adding up CPU and memory for cells that share a key. During a deploy the old and new cells total to the environment's real usage. Each app environment then does a single lookup, so the scan is linear rather than apps × cells, which also covers the minor point about the regex. - The test:
test/metrics-cells.test.tshas a new test that passes two counters with the labelapp:notes/production. It asserts thatforgeMetricsreturns a body with the summed CPU (5 s) and memory (8192 bytes) rather than throwing. Before this change it threwduplicate series.
Not fixed — slug rename: CellCounter carries only the label app:<slug>/<env>, which the runtime fixes when the cell starts (runtime.ts:1416). The label has no app id, so a cell that outlives a rename still counts toward whichever app holds that slug now. If a private app's slug is taken by a public one, the private cell's figures go out under the public name.
The only complete fix is to put the app id in the cell label, which means changing the runtime. I left that out of this pull and did not open an issue. Someone should file one, because this leak is still there. The rename caveat is already in docs/METRICS.md.
Approved. /metrics stays admin-gated through accountAdmin(ctx, "ro"): an anonymous request to the preview's /metrics gets a 404, so no guest sees any app series. The new families only read state that is already in memory: the runtime's env map and the watch's cellCounters(). Nothing gets spawned, no shell is built and no SQL is built from user text. Label values go through escapeLabelValue, and slugs are validated when they are written anyway. The tests would fail without this change. The route test catches a reverted api.ts wiring and a dropped private check, and the summing test catches the earlier duplicate series throw. /metrics is a scrape endpoint with no page counterpart, so the tty has nothing to mirror.
Non-blocking, worth a follow-up issue: cells are matched by the slug captured in their watch label when they started (see the line comment). After a rename, a still-running cell's figures go out under whichever app now holds the old slug. That can include a private app's figures appearing under a public slug. Only an admin can scrape this, so nobody sees anything they couldn't already see. Still, it goes against the 'private is named by id' promise. The fix is to put the app id in the cell label in runtime.ts. That is out of scope here, and the rename caveat is already in the docs.
A smaller point: during a deploy, the old and new cells' CPU is summed into one counter. When the old cell exits, the counter drops, and Prometheus reads that drop as a reset. That gives a brief spike in rate(). It's acceptable, but worth knowing.
I merged main into the branch and pushed it. It type-checks, and the four metrics test files pass (14 tests). I didn't get a result from the full bun test here: it ran past the 10-minute limit, so the forge's check on this push is the first full run.
- Conflicts:
docs/METRICS.mdandsrc/metrics/forge.tsconflicted.mainhad changedforge_http_request_duration_secondsto a cumulative count since the process started (#4013), read withreadTotals. This pull adds the app series. I kept both. The duration histogram usesmain's cumulative version, and theforge_app_up,forge_cell_cpu_secondsandforge_cell_memory_bytesfamilies and their docs are unchanged. - Signature change:
forgeMetricsno longer takes anowargument, because the cumulative histogram doesn't need one. It is nowforgeMetrics(db, sources). I updated the call insrc/http/api.tsand intest/metrics-cells.test.tsandtest/metrics-apps-route.test.tsto match. - Fix-up commit: My first merge commit left a broken regex in
src/metrics/forge.ts, and the second commit fixes it.
Approved. /metrics is still admin-only through accountAdmin(ctx, "ro"): on the preview it answers 404 without credentials and 404 for a bogus bearer token, and / answers 200. The new families only read state the forge already holds in memory (runtime.status, cellCounters). Nothing is spawned or shelled out, and no SQL or user text is involved. Label values go through escapeLabelValue. A private app is labelled by its id, and tests prove the slug never reaches the body, both at the unit level and through a real GET /metrics with an admin ro token. Cells that share a label during a deploy are summed into one series, and a test pins that the scrape no longer throws duplicate series. The docs list all three families. A metrics endpoint for machines needs no terminal counterpart. One low, non-blocking point is in the line comment.
GET /metricsnow serves the three app families. The type check and the new tests pass, along withtest/metrics-route.test.tsandtest/openmetrics.test.ts. I did not run the whole suite, and I did not scrape a running forge.forge_app_up: a gauge per app and environment. It is 1 when the environment isrunningand 0 when it is asleep, stopped, deploying or failed. It reads the states the runtime already holds.forge_cell_cpu_seconds_totalandforge_cell_memory_bytes: one series per app and environment, for each cell the cell watch has already read. The watch keeps the CPU and memory counters in memory, so a cell that isn't running has no series. Nothing is started and nothing is shelled out.applabel is the app's id, never its slug. A test checks that the slug appears nowhere in the body.src/metrics/forge.ts, and a smallsrc/metrics/app_sources.tsgathers the runtime states and cell counters for/metrics.docs/METRICS.mdlists all three families.test/metrics-cells.test.tschecks thatforge_app_upgoes from 1 to 0 when an app sleeps, that cell figures appear only once the watch has read the cell, that private apps use their id, and thatforgeMetricsserves the new families.The CPU family is documented as
forge_cell_cpu_secondswith samples named…_total. The existing route test requires every# TYPEfamily name to appear in the docs, and OpenMetrics drops_totalfrom a counter's family name.The issue said to read from
kernel/vitals, but that module keeps no per-cell figures. The per-cell counters come from the cell watch inapps/contain.tsinstead, which is the source behind the machine samples.I left out per-environment
upfor apps the runtime hasn't loaded, which have no series. I also did not add a persisted history: the figures are only what the watch holds right now.To try it, scrape
/metricswith an adminrotoken and look forforge_app_up.Closes #3752