Add Prometheus metrics endpoint for browser VM observability#309
Merged
Conversation
Serve GET /metrics on a dedicated port (default 10002) exposing: - Chrome page-load performance (FCP, LCP, DOMContentLoaded, load event, parse start, INP, CLS, page CPU) read browser-level via CDP Browser.getHistograms — no page attach, no script injection. - GPU presence, vGPU license state and lease expiry, utilization, and framebuffer memory from nvidia-smi (kernel_gpu_present 0 on non-GPU VMs). - VM resource metrics (CPU, memory, load, disk, network, uptime) and Chromium process count/RSS. The listener deliberately skips the scale-to-zero middleware so periodic scrapes don't count as session activity, and stays out of the OpenAPI spec like the other internal endpoints. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add FID, interactions-per-page, browser-start-to-first-paint, and peak GPU memory during scroll. All four are recorded in the browser process, verified against a live browser after real usage; renderer/GPU-process histograms (EventLatency, Graphics.Smoothness, Blink.*) are not visible to Browser.getHistograms without a histogram sync, so they stay out. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
rgarcia
approved these changes
Jul 9, 2026
rgarcia
left a comment
Contributor
There was a problem hiding this comment.
reviewed the full diff — overall this is in good shape: right transport boundaries (separate listener, no scale-to-zero coupling), curated allowlist, no per-session labels, solid test coverage. two structural findings worth addressing before merge, one suggestion, and a handful of nits.
main findings
server/lib/metrics/chrome.go:119-146— sparse UMA buckets break cross-VM aggregation and inflate cardinality. chrome only returns buckets that have samples, andwriteUMAHistogrammaps them directly tolebounds — so two VMs emit differentlesets for the same histogram depending on what pages they loaded.sum by (le)at the collector then produces non-monotonic garbage (histogram aggregation requires identical bucket sets), and worst case is ~50-100levalues per histogram per VM. consider re-bucketing into a small fixed boundary set (10-15 hardcoded bounds per histogram) before exposition — chrome's[low, high)counts re-map cleanly onto wider fixed bounds, every VM emits identicallesets, and cardinality drops ~10x.server/lib/metrics/metrics.go:89-93— partial-family output on collector error. collectors write into the shared buffer as they go, and e.g. thereturn errin the UMA loop (chrome.go:91) exits after thekernel_chromium_umaheader (and possibly some histograms) are already written. strict parsers (prometheus, otel collector) reject the whole scrape body on a malformed family, so one flaky collector can zero out all metrics. consider gather-then-write per collector: collect into a localWriter, append only on success. one wrinkle: the chrome collector intentionally writeskernel_chromium_up 0then returns an error (chrome.go:70-73) — that sample is load-bearing for browser-down alerting, so with gather-then-write the collector should instead log the dial failure and return nil after writingup 0, so "returned error" can safely mean "discard the buffer".TestHandlerServesAllCollectorsDespiteErrorandTestChromeCollectorDownassert the current behavior and would need updating, and a test for aGetHistogramsfailure mid-loop would pin the fix.
suggestion
server/lib/metrics/system.go— add PSI gauges from/proc/pressure/{cpu,memory,io}(some avg10per resource, 3 gauges). it's the most workload-independent "guest is starved" signal — the thing steal time only approximates — and this collector already reads everything adjacent to it.
nits
server/lib/metrics/chrome.go:129-133— if chrome's snapshot ever yieldscountbelow the cumulative bucket sum, the+Infsample lands below the last bucket (non-monotonic histogram).count = max(h.Count, bucketSum)is strictly safer.server/lib/metrics/chrome.go:42—Memory.GPU.PeakMemoryUsage2.Scrollis MB while the rest of the family is ms; the shared HELP string is honest but dashboards will trip on it. maybe call out the exceptions in the HELP text.server/lib/metrics/system.go:174-203— summing per-process RSS double-counts shared pages, so the total scales with renderer count more than real memory pressure.smaps_rollupPSS is the accurate version; otherwise worth a comment that this is an upper bound for trends, not absolute usage.server/lib/metrics/system.go:63—/proc/uptimeis CLOCK_BOOTTIME-based on modern kernels, not CLOCK_MONOTONIC. the conclusion holds (external pause stops guest clocks entirely) but the stated mechanism is off.server/lib/metrics/gpu.go:154—time.Parsewith a named-zone abbreviation silently parses unknown zones with offset 0. fine while nvidia-smi always emits GMT, but asserting that (check the suffix + parse in UTC) or commenting the assumption keeps a driver change from silently skewing expiry.server/lib/metrics/gpu.go:137— thenowparam ofparseNvidiaSMIis unused;Collectcomputes the delta itself.server/lib/metrics/gpu.go:145— onlyGPUs[0]is read; a one-line comment that single-vGPU-per-VM is assumed would help.
…dler - UMA histograms are re-bucketed onto small fixed per-histogram boundary grids (~10 bounds) at exposition time. Chrome's native buckets are sparse and exponential, so every VM previously emitted a different le set, which breaks cross-VM histogram aggregation and inflates cardinality. All fixed bounds are emitted, including empty ones. - The handler now gathers each collector's output into its own buffer and appends it only on success, so a failure can never leave a partially written metric family in the response. Each collector also gets its own deadline under the scrape budget so a slow one cannot starve the others. An unreachable browser is no longer a collector error: chrome writes up 0 and returns nil so the signal survives the discard-on-error policy. - The curated histograms are fetched in ~5 CDP round trips instead of 14 by covering the PageLoad family with one substring query. - New PSI gauges from /proc/pressure (some avg10 per resource), skipped gracefully on kernels without CONFIG_PSI — current guest kernels lack it, so these stay dormant until images enable it. - Hardening and doc fixes: +Inf uses max(count, bucket sum); UMA HELP spells out the non-millisecond units; license expiry parsing requires the GMT zone instead of silently zero-offsetting unknown zones; RSS sum documented as a shared-page-inflated upper bound; uptime comment corrected to CLOCK_BOOTTIME; unused parseNvidiaSMI param removed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit f993190. Configure here.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Adds a
GET /metricsendpoint (Prometheus text format, dedicated listener onMETRICS_PORT, default10002) 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.goRead 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.kernel_chromium_uma{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, sorate()/increase()work naturally.kernel_chromium_upkernel_chromium_info{product}kernel_chromium_pagesChrome's sparse
[low, high)UMA buckets map to cumulativelebounds; the overflow bucket folds into+Inf. The curated list lives inDefaultUMAHistograms; histograms with no samples yet are simply absent.VM / process metrics —
lib/metrics/system.gokernel_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
/internal/fork-identityprecedent.Browser.getHistogramscannot see renderer/child-process histograms (EventLatency.*,Graphics.Smoothness.*,Blink.*) — those only merge into the browser's recorder when a UMA log is staged orchrome://histogramsis 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./procreads (~50ms).cdpclientmethodsGetHistogramsandCountPageTargetsfollow the existing minimal-client patterns.Test plan
go vet+go test -race, non-e2e) passescmd/apibinary 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 CDPSample output (from a live VM)
🤖 Generated with Claude Code
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(default10002) withGET /metrics, wired incmd/apion 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/metricswith 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 (/procCPU/memory/load/PSI/network/disk plus Chromium process count/RSS), and GPU (nvidia-smiwhen present, elsekernel_gpu_present 0).Extends
cdpclientwithGetHistogramsandCountPageTargets. Config and README documentMETRICS_PORT; config tests cover the new default and override.Reviewed by Cursor Bugbot for commit f993190. Bugbot is set up for automated code reviews on this repo. Configure here.