Skip to content

feat(web): expose Prometheus metrics for the web process - #1570

Merged
brendan-kellam merged 4 commits into
mainfrom
brendan/web-metrics
Aug 11, 2026
Merged

feat(web): expose Prometheus metrics for the web process#1570
brendan-kellam merged 4 commits into
mainfrom
brendan/web-metrics

Conversation

@brendan-kellam

@brendan-kellam brendan-kellam commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Problem

The web process has no instrumentation of any kind. Heap usage, GC duration, and event loop lag are invisible for the process that serves every application request.

:3060 is the backend worker and :6070 is zoekt; both report nodejs_* / go_* runtime metrics. The Next.js process reports nothing. Diagnosing a recent incident — a 6 s homepage render caused by V8 running back-to-back full mark-compacts at its heap ceiling — required reading /sys/fs/cgroup files inside the container and inferring the rest from trace span arithmetic, because the relevant numbers simply were not collected.

Change

Mirror the backend's prom-client setup for the web process:

File Purpose
packages/web/src/promClient.ts Registry + collectDefaultMetrics + a custom heap-limit gauge
packages/web/src/metricsServer.ts Serves /metrics on its own port
packages/web/src/instrumentation.ts Starts it in the nodejs runtime
packages/shared/src/env.server.ts WEB_METRICS_PORT, default 3070

This yields nodejs_heap_size_used_bytes, nodejs_gc_duration_seconds, nodejs_eventloop_lag_p99_seconds, process_resident_memory_bytes, and the per-heap-space gauges.

Two deliberate decisions:

A separate port, not a Next.js route. A /api/metrics route would pass through app middleware and be reachable through the ingress. A dedicated port keeps scraping off the request path and unexposed publicly, matching how the backend and zoekt already work.

nodejs_heap_size_limit_bytes is added by hand, because collectDefaultMetrics omits it. This is the metric the recent investigation actually needed: without the ceiling, heap usage alone cannot distinguish a merely busy process from one pinned at its limit and thrashing. nodejs_heap_space_size_* can approximate it, but not legibly in an alert threshold.

The metrics server is designed never to take down the web process — the error event is handled rather than left to throw, and an unusable port is logged and skipped rather than passed to listen(undefined), which would silently bind a random port and leave the scrape target quietly broken.

Note for reviewers

prom-client does not appear in .next/standalone/node_modules, which would normally mean a MODULE_NOT_FOUND at startup. It is fully bundled instead: its own internal metric names are inlined into the server chunk and no external require("prom-client") survives the build. Verified, but worth knowing if the bundling behaviour ever changes.

Test plan

  • yarn workspace @sourcebot/web build — exit 0
  • packages/web/src/promClient.test.ts — 4/4 pass
  • eslint clean on all new/changed files
  • tsc --noEmit reports no errors in the new files (the 30 pre-existing errors are all in .test.ts files and unrelated)
  • End-to-end via a throwaway integration test: /metrics returns 200 with text/plain, contains nodejs_heap_size_limit_bytes and nodejs_heap_size_used_bytes, and other paths 404
  • Port guard verified: with WEB_METRICS_PORT unset it logs Invalid WEB_METRICS_PORT 'undefined'; metrics server not started. and returns without binding
  • Confirmed prom-client is bundled into the standalone output rather than externalised
  • Post-deploy: confirm :3070/metrics responds in the cluster and the scrape lands in Better Stack

Requires

sourcebot-dev/sourcebot-infra#24 exposes port 3070 and adds the scrape. That PR also repairs the existing backend and zoekt scrapes, which turned out to have been failing silently since prod moved namespaces — no app metric has reached Better Stack in that window. Without it, these metrics are collected but never read.

🤖 Generated with Claude Code


Note

Low Risk
Additive observability only: a separate metrics server that is designed not to crash the web process, with no changes to auth, request handling, or data paths.

Overview
Adds Prometheus runtime metrics for the Next.js web process (heap, GC, event-loop lag), previously only available for the worker and Zoekt.

Metrics are served on a dedicated port (WEB_METRICS_PORT, default 3070) via a standalone HTTP server started from instrumentation.ts, so scrapes bypass app middleware and stay off the public ingress. Includes a custom nodejs_heap_size_limit_bytes gauge (omitted by collectDefaultMetrics) so heap usage can be compared against V8's ceiling. Failures (bad port, listen errors) are logged without taking down the web process.

Reviewed by Cursor Bugbot for commit cf8db22. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • New Features

    • Added Prometheus metrics for the web process, including memory usage, garbage collection, event-loop lag, and V8 heap limits.
    • Metrics are available at the /metrics endpoint on a configurable port, defaulting to 3070.
    • Added clear handling for unavailable metrics ports, unsupported paths, and collection failures.
  • Documentation

    • Documented the new metrics availability and configuration under Unreleased changes.

The web process had no instrumentation of any kind, so heap usage, GC
duration, and event loop lag were invisible for the process that serves all
application traffic. Diagnosing a recent GC-thrash incident required reading
cgroup files inside the container and inferring the rest from trace spans.

Mirror the backend's prom-client setup and serve it on its own port
(WEB_METRICS_PORT, default 3070) rather than as a Next.js route, so scraping
bypasses app middleware and is not reachable through the ingress.

Also adds nodejs_heap_size_limit_bytes, which prom-client's default metrics
omit. Without the ceiling, heap usage alone cannot distinguish a busy process
from one pinned at its limit running back-to-back full mark-compacts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

This comment has been minimized.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3c99fe0d-6755-4109-9f08-9748854acaa1

📥 Commits

Reviewing files that changed from the base of the PR and between b444139 and cf8db22.

📒 Files selected for processing (1)
  • CHANGELOG.md

Walkthrough

The web package adds a Prometheus registry with Node.js and V8 heap metrics. A Node.js HTTP server exposes these metrics at /metrics on WEB_METRICS_PORT, and startup wiring enables the server during instrumentation.

Changes

Prometheus metrics

Layer / File(s) Summary
Prometheus registry and metric validation
packages/web/src/promClient.ts, packages/web/src/promClient.test.ts, packages/web/package.json
The registry collects default Node.js metrics and a V8 heap-size-limit gauge. Tests validate metric presence, heap limits, GC metrics, and repeatable collection.
Metrics endpoint and port configuration
packages/shared/src/env.server.ts, packages/web/src/metricsServer.ts, CHANGELOG.md
WEB_METRICS_PORT defaults to 3070. The HTTP server serves registry output at /metrics, returns 404 for other paths, returns 500 on collection failures, and records the configuration in the changelog.
Node.js runtime startup
packages/web/src/instrumentation.ts
Node.js instrumentation dynamically imports and starts the metrics server.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant HTTPClient
  participant MetricsServer
  participant PrometheusRegistry
  HTTPClient->>MetricsServer: GET /metrics
  MetricsServer->>PrometheusRegistry: collect metrics
  PrometheusRegistry-->>MetricsServer: Prometheus metric text
  MetricsServer-->>HTTPClient: 200 response with metrics
Loading

Possibly related PRs

  • sourcebot-dev/sourcebot#1431: The PR changes shared web instrumentation and dependencies for Sentry profiling, while this PR adds Prometheus metrics through the same areas.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: exposing Prometheus metrics for the web process.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch brendan/web-metrics

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/web/src/metricsServer.ts`:
- Around line 15-18: Update the WEB_METRICS_PORT validation in the metrics
server startup flow to require an integer within the valid TCP port range,
including 65535 and excluding nonpositive values. Keep invalid values on the
existing logger.error and undefined-return path so server.listen is never called
with an out-of-range port.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bd948e54-d265-43df-ba69-5ca840b4d8ad

📥 Commits

Reviewing files that changed from the base of the PR and between eb46976 and b444139.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (7)
  • CHANGELOG.md
  • packages/shared/src/env.server.ts
  • packages/web/package.json
  • packages/web/src/instrumentation.ts
  • packages/web/src/metricsServer.ts
  • packages/web/src/promClient.test.ts
  • packages/web/src/promClient.ts

Comment thread packages/web/src/metricsServer.ts
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

License Audit

⚠️ Status: PASS

Metric Count
Total packages 2196
Resolved (non-standard) 8
Unresolved 0
Strong copyleft 0
Weak copyleft 28

Weak Copyleft Packages (informational)

Package Version License
@img/sharp-libvips-darwin-arm64 1.3.2 LGPL-3.0-or-later
@img/sharp-libvips-darwin-x64 1.3.2 LGPL-3.0-or-later
@img/sharp-libvips-linux-arm 1.3.2 LGPL-3.0-or-later
@img/sharp-libvips-linux-arm64 1.3.2 LGPL-3.0-or-later
@img/sharp-libvips-linux-ppc64 1.3.2 LGPL-3.0-or-later
@img/sharp-libvips-linux-riscv64 1.3.2 LGPL-3.0-or-later
@img/sharp-libvips-linux-s390x 1.3.2 LGPL-3.0-or-later
@img/sharp-libvips-linux-x64 1.3.2 LGPL-3.0-or-later
@img/sharp-libvips-linuxmusl-arm64 1.3.2 LGPL-3.0-or-later
@img/sharp-libvips-linuxmusl-x64 1.3.2 LGPL-3.0-or-later
@img/sharp-wasm32 0.35.3 Apache-2.0 AND LGPL-3.0-or-later AND MIT
@img/sharp-win32-arm64 0.35.3 Apache-2.0 AND LGPL-3.0-or-later
@img/sharp-win32-ia32 0.35.3 Apache-2.0 AND LGPL-3.0-or-later
@img/sharp-win32-x64 0.35.3 Apache-2.0 AND LGPL-3.0-or-later
axe-core 4.10.3 MPL-2.0
dompurify 3.4.13 (MPL-2.0 OR Apache-2.0)
lightningcss 1.32.0 MPL-2.0
lightningcss-android-arm64 1.32.0 MPL-2.0
lightningcss-darwin-arm64 1.32.0 MPL-2.0
lightningcss-darwin-x64 1.32.0 MPL-2.0
lightningcss-freebsd-x64 1.32.0 MPL-2.0
lightningcss-linux-arm-gnueabihf 1.32.0 MPL-2.0
lightningcss-linux-arm64-gnu 1.32.0 MPL-2.0
lightningcss-linux-arm64-musl 1.32.0 MPL-2.0
lightningcss-linux-x64-gnu 1.32.0 MPL-2.0
lightningcss-linux-x64-musl 1.32.0 MPL-2.0
lightningcss-win32-arm64-msvc 1.32.0 MPL-2.0
lightningcss-win32-x64-msvc 1.32.0 MPL-2.0
Resolved Packages (8)
Package Version Original Resolved Source
codemirror-lang-elixir 4.0.0 UNKNOWN Apache-2.0 npm registry metadata (Apache-2.0 declared on later versions) + verified against package LICENSE file shipped in node_modules (version-matched to manifest)
khroma 2.1.0 UNKNOWN MIT package LICENSE file shipped in node_modules (version-matched to manifest) (node_modules/khroma/license)
lezer-elixir 1.1.2 UNKNOWN Apache-2.0 npm registry metadata (Apache-2.0 declared on later versions) + verified against package LICENSE file shipped in node_modules (version-matched to manifest)
map-stream 0.1.0 UNKNOWN MIT npm registry metadata (MIT on later versions) + verified against package LICENSE file shipped in node_modules (version-matched to manifest) (LICENCE)
memorystream 0.3.1 UNKNOWN MIT extracted from object: npm registry legacy "licenses" field [{"type":"MIT","url":"..."}], confirmed by package LICENSE file shipped in node_modules (version-matched to manifest)
valid-url 1.0.9 UNKNOWN MIT package LICENSE file shipped in node_modules (version-matched to manifest)
pause-stream 0.0.11 ["MIT","Apache2"] (MIT OR Apache-2.0) extracted from array-valued license field; LICENSE file states "Dual Licensed MIT and Apache 2" (package LICENSE file shipped in node_modules (version-matched to manifest))
posthog-js 1.369.0 SEE LICENSE IN LICENSE (Apache-2.0 AND MIT) package LICENSE file shipped in node_modules (version-matched to manifest) (Apache-2.0 primary grant plus MIT-licensed third-party portions); matches npm registry SPDX on later versions

@brendan-kellam
brendan-kellam merged commit 84a75e4 into main Aug 11, 2026
13 of 14 checks passed
@brendan-kellam
brendan-kellam deleted the brendan/web-metrics branch August 11, 2026 22:12

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit cf8db22. Configure here.

if (!Number.isInteger(port) || port <= 0) {
logger.error(`Invalid WEB_METRICS_PORT '${env.WEB_METRICS_PORT}'; metrics server not started.`);
return undefined;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incomplete metrics port validation

Medium Severity

The port guard only rejects non-integers and values <= 0, so ports above 65535 still reach server.listen. Node throws ERR_SOCKET_BAD_PORT synchronously there, which the 'error' listener does not catch. That exception escapes startMetricsServer and aborts register before initialize runs, so a bad WEB_METRICS_PORT can take down web startup instead of only skipping metrics.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cf8db22. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant