Skip to content

Commit ae64afa

Browse files
sjmiller609claude
andauthored
Add Prometheus metrics endpoint for browser VM observability (#309)
## Summary Adds a `GET /metrics` endpoint (Prometheus text format, dedicated listener on `METRICS_PORT`, default `10002`) so an external collector can scrape per-VM browser and system metrics and aggregate them across the fleet. Metrics carry no per-session or per-user labels — instance identity is attached (and aggregated away) by the scraper. ### Chrome metrics — `lib/metrics/chrome.go` Read browser-level over CDP (`Browser.getHistograms`, `Browser.getVersion`, `Target.getTargets`): no page attach, no script injection, no navigation — invisible to automation running in the browser. | Metric | Meaning | | --- | --- | | `kernel_chromium_uma{histogram=...}` | Prometheus histogram (`_bucket`/`_sum`/`_count`) per curated UMA histogram: FCP, LCP, DOMContentLoaded, load event, parse start (~TTFB), INP, FID, interactions per page, CLS, per-page CPU ms and browser-start-to-first-paint. Cumulative since browser start, so `rate()`/`increase()` work naturally. | | `kernel_chromium_up` | CDP endpoint reachable and responsive | | `kernel_chromium_info{product}` | Browser version (fleet version distribution) | | `kernel_chromium_pages` | Open page targets (tabs) | Chrome's sparse `[low, high)` UMA buckets map to cumulative `le` bounds; the overflow bucket folds into `+Inf`. The curated list lives in `DefaultUMAHistograms`; histograms with no samples yet are simply absent. ### VM / process metrics — `lib/metrics/system.go` `kernel_vm_cpu_seconds_total{mode}`, `kernel_vm_memory_{total,available}_bytes`, `kernel_vm_load1`, `kernel_vm_uptime_seconds` (monotonic — excludes suspended time), `kernel_vm_network_{receive,transmit}_bytes_total`, `kernel_vm_disk_{total,free}_bytes{mount="/"}`, `kernel_chromium_processes`, `kernel_chromium_memory_rss_bytes`. ## Design notes - **Separate listener, no scale-to-zero middleware**: periodic scrapes must not count as session activity or hold VMs awake. Also skips per-request logging so scrapes don't drown the logs. - **Not in the OpenAPI spec**: this is an internal scrape surface, following the `/internal/fork-identity` precedent. - **Browser-process histograms only**: `Browser.getHistograms` cannot see renderer/child-process histograms (`EventLatency.*`, `Graphics.Smoothness.*`, `Blink.*`) — those only merge into the browser's recorder when a UMA log is staged or `chrome://histograms` is opened, neither of which happens on these images. Verified empirically against a live browser; the curated set is restricted to histograms that populate without a sync. - **No new dependencies**: the exposition format is rendered directly; scrape cost is a few small CDP round-trips + `/proc` reads (~50ms). - New `cdpclient` methods `GetHistograms` and `CountPageTargets` follow the existing minimal-client patterns. ## Test plan - [x] Unit tests for exposition rendering/escaping, UMA→Prometheus bucket conversion (incl. exact-name filtering and overflow fold), /proc parsers, and the chrome collector against a fake CDP server - [x] Full unit suite (`go vet` + `go test -race`, non-e2e) passes - [x] Validated the real `cmd/api` binary inside two live browser VMs (both image variants) on alternate ports: all metric families present with sane values; page-load histogram sums matched values read independently via raw CDP - [ ] e2e suite not run (requires pre-built Docker images); the endpoint has no interaction with existing routes ## Sample output (from a live VM) ``` kernel_chromium_up 1 kernel_chromium_info{product="Chrome/145.0.7632.75"} 1 kernel_chromium_pages 1 kernel_chromium_uma_bucket{histogram="PageLoad.PaintTiming.NavigationToFirstContentfulPaint",le="885"} 1 kernel_chromium_uma_bucket{histogram="PageLoad.PaintTiming.NavigationToFirstContentfulPaint",le="1240"} 2 kernel_chromium_uma_bucket{histogram="PageLoad.PaintTiming.NavigationToFirstContentfulPaint",le="+Inf"} 2 kernel_chromium_uma_sum{histogram="PageLoad.PaintTiming.NavigationToFirstContentfulPaint"} 1977 kernel_chromium_uma_count{histogram="PageLoad.PaintTiming.NavigationToFirstContentfulPaint"} 2 kernel_vm_cpu_seconds_total{mode="user"} 75.1 kernel_chromium_processes 8 kernel_chromium_memory_rss_bytes 1.277575168e+09 ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > New exposed listener and CDP/nvidia-smi work on each scrape add operational surface and load; metrics path is intentionally isolated from scale-to-zero and the public API. > > **Overview** > Adds a **dedicated Prometheus scrape surface** on `METRICS_PORT` (default `10002`) with `GET /metrics`, wired in `cmd/api` on its own HTTP listener **without** scale-to-zero middleware or request logging so periodic scrapes do not keep VMs awake or flood logs. > > Introduces **`lib/metrics`** with a custom text exposition handler (serialized scrapes, per-collector timeouts, failed collectors dropped without partial families) and three collectors: **Chrome** (browser-level CDP: `kernel_chromium_up`, version, page count, curated UMA histograms re-bucketed for fleet aggregation), **system** (`/proc` CPU/memory/load/PSI/network/disk plus Chromium process count/RSS), and **GPU** (`nvidia-smi` when present, else `kernel_gpu_present 0`). > > Extends **`cdpclient`** with `GetHistograms` and `CountPageTargets`. Config and README document `METRICS_PORT`; config tests cover the new default and override. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit f993190. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent de7e9a4 commit ae64afa

13 files changed

Lines changed: 1285 additions & 0 deletions

File tree

server/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ Configure the server using environment variables:
5151
| Variable | Default | Description |
5252
| -------------- | --------- | ------------------------------------------- |
5353
| `PORT` | `10001` | HTTP server port |
54+
| `METRICS_PORT` | `10002` | Prometheus metrics port (`GET /metrics`) |
5455
| `FRAME_RATE` | `10` | Default recording framerate (fps) |
5556
| `DISPLAY_NUM` | `1` | Display/screen number to capture |
5657
| `MAX_SIZE_MB` | `500` | Default maximum file size (MB) |

server/cmd/api/main.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"github.com/kernel/kernel-images/server/lib/devtoolsproxy"
2828
"github.com/kernel/kernel-images/server/lib/events"
2929
"github.com/kernel/kernel-images/server/lib/logger"
30+
"github.com/kernel/kernel-images/server/lib/metrics"
3031
"github.com/kernel/kernel-images/server/lib/nekoclient"
3132
oapi "github.com/kernel/kernel-images/server/lib/oapi"
3233
"github.com/kernel/kernel-images/server/lib/recorder"
@@ -257,6 +258,22 @@ func main() {
257258
Handler: rChromeDriver,
258259
}
259260

261+
// Prometheus metrics for external collection. Served on its own
262+
// listener, deliberately without the scale-to-zero middleware (periodic
263+
// scrapes must not count as session activity) and without per-request
264+
// logging (scrapes would drown the logs).
265+
rMetrics := chi.NewRouter()
266+
rMetrics.Use(chiMiddleware.Recoverer)
267+
rMetrics.Method(http.MethodGet, "/metrics", metrics.Handler(slogger,
268+
metrics.NewChromeCollector(upstreamMgr),
269+
metrics.NewGPUCollector(),
270+
metrics.NewSystemCollector(),
271+
))
272+
srvMetrics := &http.Server{
273+
Addr: fmt.Sprintf("0.0.0.0:%d", config.MetricsPort),
274+
Handler: rMetrics,
275+
}
276+
260277
go func() {
261278
slogger.Info("http server starting", "addr", srv.Addr)
262279
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
@@ -281,6 +298,14 @@ func main() {
281298
}
282299
}()
283300

301+
go func() {
302+
slogger.Info("metrics server starting", "addr", srvMetrics.Addr)
303+
if err := srvMetrics.ListenAndServe(); err != nil && err != http.ErrServerClosed {
304+
slogger.Error("metrics server failed", "err", err)
305+
stop()
306+
}
307+
}()
308+
284309
// graceful shutdown
285310
<-ctx.Done()
286311
slogger.Info("shutdown signal received")
@@ -308,6 +333,9 @@ func main() {
308333
g.Go(func() error {
309334
return srvChromeDriver.Shutdown(shutdownCtx)
310335
})
336+
g.Go(func() error {
337+
return srvMetrics.Shutdown(shutdownCtx)
338+
})
311339

312340
if err := g.Wait(); err != nil {
313341
slogger.Error("server failed to shutdown", "err", err)

server/cmd/config/config.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ type Config struct {
1313
// Server configuration
1414
Port int `envconfig:"PORT" default:"10001"`
1515

16+
// Port for the Prometheus metrics endpoint. Served on a separate
17+
// listener so scrapes bypass the scale-to-zero middleware and the
18+
// external API surface.
19+
MetricsPort int `envconfig:"METRICS_PORT" default:"10002"`
20+
1621
// Recording configuration
1722
FrameRate int `envconfig:"FRAME_RATE" default:"10"`
1823
DisplayNum int `envconfig:"DISPLAY_NUM" default:"1"`
@@ -57,6 +62,7 @@ func (c *Config) LogValue() slog.Value {
5762
}
5863
return slog.GroupValue(
5964
slog.Int("port", c.Port),
65+
slog.Int("metrics_port", c.MetricsPort),
6066
slog.Int("frame_rate", c.FrameRate),
6167
slog.Int("display_num", c.DisplayNum),
6268
slog.Int("max_size_mb", c.MaxSizeInMB),

server/cmd/config/config_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ func TestLoad(t *testing.T) {
1919
env: map[string]string{},
2020
wantCfg: &Config{
2121
Port: 10001,
22+
MetricsPort: 10002,
2223
FrameRate: 10,
2324
DisplayNum: 1,
2425
MaxSizeInMB: 500,
@@ -37,6 +38,7 @@ func TestLoad(t *testing.T) {
3738
name: "custom valid env",
3839
env: map[string]string{
3940
"PORT": "12345",
41+
"METRICS_PORT": "12346",
4042
"FRAME_RATE": "20",
4143
"DISPLAY_NUM": "2",
4244
"MAX_SIZE_MB": "250",
@@ -51,6 +53,7 @@ func TestLoad(t *testing.T) {
5153
},
5254
wantCfg: &Config{
5355
Port: 12345,
56+
MetricsPort: 12346,
5457
FrameRate: 20,
5558
DisplayNum: 2,
5659
MaxSizeInMB: 250,
@@ -73,6 +76,7 @@ func TestLoad(t *testing.T) {
7376
},
7477
wantCfg: &Config{
7578
Port: 10001,
79+
MetricsPort: 10002,
7680
FrameRate: 10,
7781
DisplayNum: 1,
7882
MaxSizeInMB: 500,

server/lib/cdpclient/cdpclient.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,64 @@ func (c *Client) GetBrowserVersion(ctx context.Context) (*BrowserVersion, error)
133133
return &v, nil
134134
}
135135

136+
// Histogram is a snapshot of a Chrome UMA histogram as returned by
137+
// Browser.getHistograms. Values are cumulative since browser start and the
138+
// units follow the UMA definition of the histogram (PageLoad timings are
139+
// milliseconds). Only buckets with at least one sample are present.
140+
type Histogram struct {
141+
Name string `json:"name"`
142+
Sum int64 `json:"sum"`
143+
Count int64 `json:"count"`
144+
Buckets []HistogramBucket `json:"buckets"`
145+
}
146+
147+
// HistogramBucket is a [Low, High) bucket of a Histogram.
148+
type HistogramBucket struct {
149+
Low int64 `json:"low"`
150+
High int64 `json:"high"`
151+
Count int64 `json:"count"`
152+
}
153+
154+
// GetHistograms sends Browser.getHistograms, a browser-level command that
155+
// reads Chrome's in-memory UMA histograms without attaching to any page.
156+
// query is a substring filter on the histogram name; empty returns all.
157+
func (c *Client) GetHistograms(ctx context.Context, query string) ([]Histogram, error) {
158+
raw, err := c.send(ctx, "Browser.getHistograms", map[string]any{"query": query}, "")
159+
if err != nil {
160+
return nil, fmt.Errorf("Browser.getHistograms: %w", err)
161+
}
162+
var result struct {
163+
Histograms []Histogram `json:"histograms"`
164+
}
165+
if err := json.Unmarshal(raw, &result); err != nil {
166+
return nil, fmt.Errorf("unmarshal Browser.getHistograms: %w", err)
167+
}
168+
return result.Histograms, nil
169+
}
170+
171+
// CountPageTargets returns the number of open page targets.
172+
func (c *Client) CountPageTargets(ctx context.Context) (int, error) {
173+
targetsResult, err := c.send(ctx, "Target.getTargets", nil, "")
174+
if err != nil {
175+
return 0, fmt.Errorf("Target.getTargets: %w", err)
176+
}
177+
var targets struct {
178+
TargetInfos []struct {
179+
Type string `json:"type"`
180+
} `json:"targetInfos"`
181+
}
182+
if err := json.Unmarshal(targetsResult, &targets); err != nil {
183+
return 0, fmt.Errorf("unmarshal targets: %w", err)
184+
}
185+
n := 0
186+
for _, t := range targets.TargetInfos {
187+
if t.Type == "page" {
188+
n++
189+
}
190+
}
191+
return n, nil
192+
}
193+
136194
// DispatchStartURL closes extra page targets and dispatches a navigation on the
137195
// first page target. It does not wait for lifecycle events; Chrome owns the
138196
// eventual navigation result.

0 commit comments

Comments
 (0)