feat(web): record HTTP request duration metrics - #1571

Merged
brendan-kellam merged 8 commits into
mainfrom
brendan/web-http-metrics
Aug 12, 2026
Merged

feat(web): record HTTP request duration metrics#1571
brendan-kellam merged 8 commits into
mainfrom
brendan/web-http-metrics

Conversation

@brendan-kellam

@brendan-kellambrendan-kellam commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Follows #1570.

Problem

#1570 gave the web process runtime metrics (heap, GC, event loop lag) but nothing about how long requests take, so per-endpoint latency is still invisible. That gap is concrete: an attempt to build a Better Stack chart of /api/health response time found the data does not exist anywhere in telemetry — not as a span (traces go to Sentry), not as a metric, and Uptime monitor data isn't queryable from a Telemetry dashboard.

/api/health is the useful case. It does almost no work, so its duration is essentially event loop queueing delay — which makes it a direct read on whether the process is stalling, and it is the exact endpoint the liveness probe hits with a 1 second timeout.

Change

Adds an http_request_duration_seconds histogram (labels: method, route, status) populated from Node's built-in HTTP diagnostics channels — http.server.request.start and http.server.response.finish.

Why diagnostics channels. Next.js owns the http.Server instance in a standalone build, so there is no request pipeline to wrap. proxy.ts middleware runs in the edge runtime, where prom-client doesn't work. The channels are a supported, in-process observation point that sees every request without patching or monkey-wrapping anything.

Two details that are load-bearing rather than incidental:

Route labels come from Next's own route table. The raw path can't be a label — repository/file paths are unbounded, the first segment is client-supplied, and /api/[...slug] is a catch-all, so full paths or naive truncation both let scanner traffic grow the series count without limit. Instead, the build's .next/routes-manifest.json (78 static + 18 dynamic routes, each with a matching regex, ordered by the router's own resolution priority) is loaded at startup, and each request path is matched to its route pattern:

/browse/github.com/org/repo/-/blob/src/index.ts -> /browse/[...path]
/api/auth/callback/github -> /api/auth/[...nextauth]
/settings/connections/42 -> /settings/connections/[id]
/wp-admin -> /[...slug] (the route that actually serves it)
/api/not-a-real-route -> /api/[...slug]

Cardinality is bounded by the number of defined routes plus /_next and other, and the label set tracks the app automatically at build time — no hand-maintained list to rot. If the manifest is missing or unreadable (e.g. next dev), everything is labelled other: granularity lost, bound kept — it fails closed.

Metrics scrapes are excluded. The channels are process-wide, so they also fire for the metrics server itself — without a filter on the metrics port, every scrape would record itself and the histogram would measure the observer. Verified in the integration test.

Recording is wrapped in try/catch and logs at debug: a metrics failure must never affect request handling.

Test plan

  • yarn workspace @sourcebot/web build — exit 0, and both http_request_duration_seconds and the module's log line are present in the standalone server chunks
  • 17 tests pass across 3 files
  • normalizeRoute unit tests against a manifest-shaped fixture: static exact-match, dynamic patterns, manifest ordering (specific routes beat catch-alls), duplicate/trailing slashes, missing-table fallback
  • Cardinality tests: 3,000 distinct scanner-style paths collapse to exactly the two catch-all labels; a 1,000-path hostile corpus never exceeds the table-derived bound; with no root catch-all in the table, unmatched paths report other
  • Integration test drives real HTTP requests through the channels and asserts /api/health is recorded, two distinct file paths collapse to a single /browse series with count 2, and status="200" is labelled
  • Metrics-port exclusion asserted on the total observation count, and verified by mutation: removing the port filter fails the test with expected 4 to be 3
  • The integration test injects its route table and takes ephemeral ports, so it depends on neither a prior next build nor a fixed port
  • Verified against the real production build: the manifest loads via the default cwd-relative path, and the running pod's web process cwd (/app/packages/web) contains routes-manifest.json — the standalone server chdirs to the app dir on boot
  • eslint clean; tsc --noEmit reports no errors in the new files and the non-test error count is unchanged at 0
  • Post-deploy: confirm http_request_duration_seconds appears on :3070/metrics and build the /api/health latency chart

Follow-up

Once this is deployed and scraped, the /api/health p50/p95/p99 chart is a single histogramQuantile query, and the "does health latency spike during GC" correlation becomes one dashboard rather than an inference across two systems. The dashboard is already created and its charts group by source, so the web process joins them automatically.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added HTTP request duration metrics with route, method, and response status details.
    • Metrics are collected automatically using configurable duration buckets.
    • Routes are normalized and grouped into bounded categories for consistent reporting.
    • Metrics-server scrapes are excluded from request measurements.
    • Unmatched or unavailable routes are grouped under a fallback category.
  • Documentation

    • Documented the new HTTP request duration metric in the changelog.

Note

Cursor Bugbot is generating a summary for commit d839501. Configure here.

The web process now reports runtime metrics, but nothing about how long
requests actually take, so per-endpoint latency is still invisible. That is the
signal needed to see a stall from the outside: /api/health does almost no work,
so its duration is essentially event loop queueing delay.
Add an http_request_duration_seconds histogram labelled by method, route, and
status, populated by subscribing to Node's built-in http.server.request.start
and http.server.response.finish diagnostics channels. Next.js owns the server
instance in a standalone build, so there is no request pipeline to wrap; the
channels observe every request without patching anything.
Paths are collapsed to a bounded route label. Repository and file paths are
unbounded, so labelling by full path would mint a time series per file viewed.
Requests to the metrics port are skipped, since the channels are process-wide
and every scrape would otherwise record itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

This comment has been minimized.

@coderabbitai

coderabbitaiBot 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: a717e7a1-5bc9-4482-8553-b83841f0ecaa

📥 Commits

Reviewing files that changed from the base of the PR and between d839501 and 0867a0e.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • packages/web/src/httpMetrics.integration.test.ts
  • packages/web/src/httpMetrics.test.ts
  • packages/web/src/httpMetrics.ts

Walkthrough

The web server now exports http_request_duration_seconds metrics. Diagnostic channels record completed requests with bounded route labels, method, status, and duration. Runtime registration starts collection, and unit and integration tests validate normalization and aggregation.

Changes

HTTP metrics instrumentation

Layer / File(s)Summary
HTTP duration metric contract
packages/web/src/promClient.ts
Defines and registers the httpRequestDuration histogram with method, route, and status labels.
Request tracking and route normalization
packages/web/src/httpMetrics.ts, packages/web/src/instrumentation.ts
Loads route manifests, normalizes routes, bounds route labels, excludes metrics-server traffic, records completed request durations, prevents duplicate subscriptions, and starts collection during runtime registration.
Metric behavior validation and release notes
packages/web/src/httpMetrics.test.ts, packages/web/src/httpMetrics.integration.test.ts, CHANGELOG.md
Tests route normalization, cardinality limits, request aggregation, status labels, observation counts, histogram bounds, and metrics-port exclusion. Documents the new metric.

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

Sequence Diagram(s)

sequenceDiagram
participant NodeRegistration
participant NodeDiagnosticChannels
participant httpMetrics
participant httpRequestDuration
NodeRegistration->>httpMetrics: Start HTTP metrics collection
NodeDiagnosticChannels->>httpMetrics: Publish request start and completion messages
httpMetrics->>httpMetrics: Normalize route and exclude metrics-server traffic
httpMetrics->>httpRequestDuration: Observe method, route, status, and duration
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly and concisely describes the main change: adding HTTP request duration metrics for the web server.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch brendan/web-http-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>
Comment threadpackages/web/src/httpMetrics.ts
Truncating path depth bounded depth, not breadth. The first path segment is
client-supplied and /api/[...slug] is a catch-all, so /wp-admin, /.env, and
/api/<anything> each minted a new time series. Scanner traffic could grow the
series count without limit, which is exactly what the normalization was
supposed to prevent.
Match the truncated path against a known-route set and report anything else as
`other`, bounding distinct route labels to that set plus one regardless of what
is requested. A route missing from the set loses granularity rather than
breaking, so it fails closed.
Also strengthens the metrics-port exclusion assertion. It checked for the
absence of a `/metrics` label, which became vacuous once unknown paths collapse
to `other` — it now asserts the total observation count, and fails if the port
filter is removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot 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/httpMetrics.integration.test.ts`:
- Around line 38-42: Update the integration test setup around startHttpMetrics
so process.env.WEB_METRICS_PORT is assigned before the httpMetrics module is
imported, ensuring createEnv(options) reads the ephemeral metrics port during
module initialization. Replace the static import with a deferred import after
the environment assignment and invoke startHttpMetrics from that loaded module.
🪄 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: 7cb3db6d-2e81-48e4-a016-a97834000cd9

📥 Commits

Reviewing files that changed from the base of the PR and between 84a75e4 and 9e595eb.

📒 Files selected for processing (6)
  • CHANGELOG.md
  • packages/web/src/httpMetrics.integration.test.ts
  • packages/web/src/httpMetrics.test.ts
  • packages/web/src/httpMetrics.ts
  • packages/web/src/instrumentation.ts
  • packages/web/src/promClient.ts

Comment threadpackages/web/src/httpMetrics.integration.test.ts
…hardcoded list
The hardcoded KNOWN_ROUTES set bounded cardinality but rots: new routes
silently degrade to `other` until someone updates the file, and two-segment
truncation collapses distinct routes (everything under /api/ee/* became one
label).
Next's build already emits .next/routes-manifest.json with every defined
route and a matching regex, ordered by the router's own resolution priority.
Load that at startup: exact-match static routes, then first dynamic regex
wins, mirroring how the server actually routes the request. Labels become the
route pattern itself (/browse/[...path], /settings/connections/[id]), so
cardinality is bounded by the number of defined routes plus /_next and
`other`, and the label set tracks the app automatically at build time.
Scanner traffic now lands on the catch-all routes that genuinely serve it
(/[...slug], /api/[...slug]) rather than a synthetic bucket. If the manifest
is missing or unreadable, everything is labelled `other` — granularity lost,
bound kept.
Verified against the production build's manifest (78 static + 18 dynamic
routes) and the running pod: the standalone server chdirs to the app dir, so
the cwd-relative manifest path resolves.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot 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/httpMetrics.ts`:
- Around line 59-63: Update the catch block in initRouteTable to assign
routeTable = undefined before returning false, ensuring initialization failures
discard any previously loaded route table and subsequent requests use the
OTHER_ROUTE fallback.
🪄 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: c5daa8f0-6a9f-4a10-b470-2185ac676f2c

📥 Commits

Reviewing files that changed from the base of the PR and between 9e595eb and 829ecb3.

📒 Files selected for processing (3)
  • packages/web/src/httpMetrics.integration.test.ts
  • packages/web/src/httpMetrics.test.ts
  • packages/web/src/httpMetrics.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/web/src/httpMetrics.test.ts

Comment threadpackages/web/src/httpMetrics.ts
Comment threadpackages/web/src/httpMetrics.ts
@brendan-kellam
brendan-kellam merged commit 5e13dbb into mainAug 12, 2026
11 of 12 checks passed
@brendan-kellam
brendan-kellam deleted the brendan/web-http-metrics branch August 12, 2026 23:27
@github-actionsgithub-actionsBot mentioned this pull request Aug 12, 2026

@cursorcursorBot 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 0867a0e. Configure here.

method: request.method ?? 'UNKNOWN',
route: normalizeRoute(pathname),
status: response.statusCode,
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unbounded HTTP method label cardinality

Medium Severity

The method label uses the raw client-supplied request.method with no allowlist. Route labels were carefully bounded via the manifest to stop scanner traffic from exploding series count, but arbitrary methods remain unbounded and multiply every route×status series under hostile or unusual clients.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit 0867a0e. Configure here.

Sign up for freeto 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

@brendan-kellam
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(web): record HTTP request duration metrics - #1571

Merged
brendan-kellam merged 8 commits into
mainfrom
brendan/web-http-metrics
Aug 12, 2026
Merged

feat(web): record HTTP request duration metrics#1571
brendan-kellam merged 8 commits into
mainfrom
brendan/web-http-metrics

Conversation

@brendan-kellam

@brendan-kellambrendan-kellam commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Follows #1570.

Problem

#1570 gave the web process runtime metrics (heap, GC, event loop lag) but nothing about how long requests take, so per-endpoint latency is still invisible. That gap is concrete: an attempt to build a Better Stack chart of /api/health response time found the data does not exist anywhere in telemetry — not as a span (traces go to Sentry), not as a metric, and Uptime monitor data isn't queryable from a Telemetry dashboard.

/api/health is the useful case. It does almost no work, so its duration is essentially event loop queueing delay — which makes it a direct read on whether the process is stalling, and it is the exact endpoint the liveness probe hits with a 1 second timeout.

Change

Adds an http_request_duration_seconds histogram (labels: method, route, status) populated from Node's built-in HTTP diagnostics channels — http.server.request.start and http.server.response.finish.

Why diagnostics channels. Next.js owns the http.Server instance in a standalone build, so there is no request pipeline to wrap. proxy.ts middleware runs in the edge runtime, where prom-client doesn't work. The channels are a supported, in-process observation point that sees every request without patching or monkey-wrapping anything.

Two details that are load-bearing rather than incidental:

Route labels come from Next's own route table. The raw path can't be a label — repository/file paths are unbounded, the first segment is client-supplied, and /api/[...slug] is a catch-all, so full paths or naive truncation both let scanner traffic grow the series count without limit. Instead, the build's .next/routes-manifest.json (78 static + 18 dynamic routes, each with a matching regex, ordered by the router's own resolution priority) is loaded at startup, and each request path is matched to its route pattern:

/browse/github.com/org/repo/-/blob/src/index.ts -> /browse/[...path]
/api/auth/callback/github -> /api/auth/[...nextauth]
/settings/connections/42 -> /settings/connections/[id]
/wp-admin -> /[...slug] (the route that actually serves it)
/api/not-a-real-route -> /api/[...slug]

Cardinality is bounded by the number of defined routes plus /_next and other, and the label set tracks the app automatically at build time — no hand-maintained list to rot. If the manifest is missing or unreadable (e.g. next dev), everything is labelled other: granularity lost, bound kept — it fails closed.

Metrics scrapes are excluded. The channels are process-wide, so they also fire for the metrics server itself — without a filter on the metrics port, every scrape would record itself and the histogram would measure the observer. Verified in the integration test.

Recording is wrapped in try/catch and logs at debug: a metrics failure must never affect request handling.

Test plan

  • yarn workspace @sourcebot/web build — exit 0, and both http_request_duration_seconds and the module's log line are present in the standalone server chunks
  • 17 tests pass across 3 files
  • normalizeRoute unit tests against a manifest-shaped fixture: static exact-match, dynamic patterns, manifest ordering (specific routes beat catch-alls), duplicate/trailing slashes, missing-table fallback
  • Cardinality tests: 3,000 distinct scanner-style paths collapse to exactly the two catch-all labels; a 1,000-path hostile corpus never exceeds the table-derived bound; with no root catch-all in the table, unmatched paths report other
  • Integration test drives real HTTP requests through the channels and asserts /api/health is recorded, two distinct file paths collapse to a single /browse series with count 2, and status="200" is labelled
  • Metrics-port exclusion asserted on the total observation count, and verified by mutation: removing the port filter fails the test with expected 4 to be 3
  • The integration test injects its route table and takes ephemeral ports, so it depends on neither a prior next build nor a fixed port
  • Verified against the real production build: the manifest loads via the default cwd-relative path, and the running pod's web process cwd (/app/packages/web) contains routes-manifest.json — the standalone server chdirs to the app dir on boot
  • eslint clean; tsc --noEmit reports no errors in the new files and the non-test error count is unchanged at 0
  • Post-deploy: confirm http_request_duration_seconds appears on :3070/metrics and build the /api/health latency chart

Follow-up

Once this is deployed and scraped, the /api/health p50/p95/p99 chart is a single histogramQuantile query, and the "does health latency spike during GC" correlation becomes one dashboard rather than an inference across two systems. The dashboard is already created and its charts group by source, so the web process joins them automatically.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added HTTP request duration metrics with route, method, and response status details.
    • Metrics are collected automatically using configurable duration buckets.
    • Routes are normalized and grouped into bounded categories for consistent reporting.
    • Metrics-server scrapes are excluded from request measurements.
    • Unmatched or unavailable routes are grouped under a fallback category.
  • Documentation

    • Documented the new HTTP request duration metric in the changelog.

Note

Cursor Bugbot is generating a summary for commit d839501. Configure here.

The web process now reports runtime metrics, but nothing about how long
requests actually take, so per-endpoint latency is still invisible. That is the
signal needed to see a stall from the outside: /api/health does almost no work,
so its duration is essentially event loop queueing delay.
Add an http_request_duration_seconds histogram labelled by method, route, and
status, populated by subscribing to Node's built-in http.server.request.start
and http.server.response.finish diagnostics channels. Next.js owns the server
instance in a standalone build, so there is no request pipeline to wrap; the
channels observe every request without patching anything.
Paths are collapsed to a bounded route label. Repository and file paths are
unbounded, so labelling by full path would mint a time series per file viewed.
Requests to the metrics port are skipped, since the channels are process-wide
and every scrape would otherwise record itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

This comment has been minimized.

@coderabbitai

coderabbitaiBot 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: a717e7a1-5bc9-4482-8553-b83841f0ecaa

📥 Commits

Reviewing files that changed from the base of the PR and between d839501 and 0867a0e.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • packages/web/src/httpMetrics.integration.test.ts
  • packages/web/src/httpMetrics.test.ts
  • packages/web/src/httpMetrics.ts

Walkthrough

The web server now exports http_request_duration_seconds metrics. Diagnostic channels record completed requests with bounded route labels, method, status, and duration. Runtime registration starts collection, and unit and integration tests validate normalization and aggregation.

Changes

HTTP metrics instrumentation

Layer / File(s)Summary
HTTP duration metric contract
packages/web/src/promClient.ts
Defines and registers the httpRequestDuration histogram with method, route, and status labels.
Request tracking and route normalization
packages/web/src/httpMetrics.ts, packages/web/src/instrumentation.ts
Loads route manifests, normalizes routes, bounds route labels, excludes metrics-server traffic, records completed request durations, prevents duplicate subscriptions, and starts collection during runtime registration.
Metric behavior validation and release notes
packages/web/src/httpMetrics.test.ts, packages/web/src/httpMetrics.integration.test.ts, CHANGELOG.md
Tests route normalization, cardinality limits, request aggregation, status labels, observation counts, histogram bounds, and metrics-port exclusion. Documents the new metric.

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

Sequence Diagram(s)

sequenceDiagram
participant NodeRegistration
participant NodeDiagnosticChannels
participant httpMetrics
participant httpRequestDuration
NodeRegistration->>httpMetrics: Start HTTP metrics collection
NodeDiagnosticChannels->>httpMetrics: Publish request start and completion messages
httpMetrics->>httpMetrics: Normalize route and exclude metrics-server traffic
httpMetrics->>httpRequestDuration: Observe method, route, status, and duration
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly and concisely describes the main change: adding HTTP request duration metrics for the web server.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch brendan/web-http-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>
Comment threadpackages/web/src/httpMetrics.ts
Truncating path depth bounded depth, not breadth. The first path segment is
client-supplied and /api/[...slug] is a catch-all, so /wp-admin, /.env, and
/api/<anything> each minted a new time series. Scanner traffic could grow the
series count without limit, which is exactly what the normalization was
supposed to prevent.
Match the truncated path against a known-route set and report anything else as
`other`, bounding distinct route labels to that set plus one regardless of what
is requested. A route missing from the set loses granularity rather than
breaking, so it fails closed.
Also strengthens the metrics-port exclusion assertion. It checked for the
absence of a `/metrics` label, which became vacuous once unknown paths collapse
to `other` — it now asserts the total observation count, and fails if the port
filter is removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot 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/httpMetrics.integration.test.ts`:
- Around line 38-42: Update the integration test setup around startHttpMetrics
so process.env.WEB_METRICS_PORT is assigned before the httpMetrics module is
imported, ensuring createEnv(options) reads the ephemeral metrics port during
module initialization. Replace the static import with a deferred import after
the environment assignment and invoke startHttpMetrics from that loaded module.
🪄 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: 7cb3db6d-2e81-48e4-a016-a97834000cd9

📥 Commits

Reviewing files that changed from the base of the PR and between 84a75e4 and 9e595eb.

📒 Files selected for processing (6)
  • CHANGELOG.md
  • packages/web/src/httpMetrics.integration.test.ts
  • packages/web/src/httpMetrics.test.ts
  • packages/web/src/httpMetrics.ts
  • packages/web/src/instrumentation.ts
  • packages/web/src/promClient.ts

Comment threadpackages/web/src/httpMetrics.integration.test.ts
…hardcoded list
The hardcoded KNOWN_ROUTES set bounded cardinality but rots: new routes
silently degrade to `other` until someone updates the file, and two-segment
truncation collapses distinct routes (everything under /api/ee/* became one
label).
Next's build already emits .next/routes-manifest.json with every defined
route and a matching regex, ordered by the router's own resolution priority.
Load that at startup: exact-match static routes, then first dynamic regex
wins, mirroring how the server actually routes the request. Labels become the
route pattern itself (/browse/[...path], /settings/connections/[id]), so
cardinality is bounded by the number of defined routes plus /_next and
`other`, and the label set tracks the app automatically at build time.
Scanner traffic now lands on the catch-all routes that genuinely serve it
(/[...slug], /api/[...slug]) rather than a synthetic bucket. If the manifest
is missing or unreadable, everything is labelled `other` — granularity lost,
bound kept.
Verified against the production build's manifest (78 static + 18 dynamic
routes) and the running pod: the standalone server chdirs to the app dir, so
the cwd-relative manifest path resolves.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot 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/httpMetrics.ts`:
- Around line 59-63: Update the catch block in initRouteTable to assign
routeTable = undefined before returning false, ensuring initialization failures
discard any previously loaded route table and subsequent requests use the
OTHER_ROUTE fallback.
🪄 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: c5daa8f0-6a9f-4a10-b470-2185ac676f2c

📥 Commits

Reviewing files that changed from the base of the PR and between 9e595eb and 829ecb3.

📒 Files selected for processing (3)
  • packages/web/src/httpMetrics.integration.test.ts
  • packages/web/src/httpMetrics.test.ts
  • packages/web/src/httpMetrics.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/web/src/httpMetrics.test.ts

Comment threadpackages/web/src/httpMetrics.ts
Comment threadpackages/web/src/httpMetrics.ts
@brendan-kellam
brendan-kellam merged commit 5e13dbb into mainAug 12, 2026
11 of 12 checks passed
@brendan-kellam
brendan-kellam deleted the brendan/web-http-metrics branch August 12, 2026 23:27
@github-actionsgithub-actionsBot mentioned this pull request Aug 12, 2026

@cursorcursorBot 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 0867a0e. Configure here.

method: request.method ?? 'UNKNOWN',
route: normalizeRoute(pathname),
status: response.statusCode,
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unbounded HTTP method label cardinality

Medium Severity

The method label uses the raw client-supplied request.method with no allowlist. Route labels were carefully bounded via the manifest to stop scanner traffic from exploding series count, but arbitrary methods remain unbounded and multiply every route×status series under hostile or unusual clients.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit 0867a0e. Configure here.

Sign up for freeto 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

@brendan-kellam
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(web): record HTTP request duration metrics - #1571

Merged
brendan-kellam merged 8 commits into
mainfrom
brendan/web-http-metrics
Aug 12, 2026
Merged

feat(web): record HTTP request duration metrics#1571
brendan-kellam merged 8 commits into
mainfrom
brendan/web-http-metrics

Conversation

@brendan-kellam

@brendan-kellambrendan-kellam commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Follows #1570.

Problem

#1570 gave the web process runtime metrics (heap, GC, event loop lag) but nothing about how long requests take, so per-endpoint latency is still invisible. That gap is concrete: an attempt to build a Better Stack chart of /api/health response time found the data does not exist anywhere in telemetry — not as a span (traces go to Sentry), not as a metric, and Uptime monitor data isn't queryable from a Telemetry dashboard.

/api/health is the useful case. It does almost no work, so its duration is essentially event loop queueing delay — which makes it a direct read on whether the process is stalling, and it is the exact endpoint the liveness probe hits with a 1 second timeout.

Change

Adds an http_request_duration_seconds histogram (labels: method, route, status) populated from Node's built-in HTTP diagnostics channels — http.server.request.start and http.server.response.finish.

Why diagnostics channels. Next.js owns the http.Server instance in a standalone build, so there is no request pipeline to wrap. proxy.ts middleware runs in the edge runtime, where prom-client doesn't work. The channels are a supported, in-process observation point that sees every request without patching or monkey-wrapping anything.

Two details that are load-bearing rather than incidental:

Route labels come from Next's own route table. The raw path can't be a label — repository/file paths are unbounded, the first segment is client-supplied, and /api/[...slug] is a catch-all, so full paths or naive truncation both let scanner traffic grow the series count without limit. Instead, the build's .next/routes-manifest.json (78 static + 18 dynamic routes, each with a matching regex, ordered by the router's own resolution priority) is loaded at startup, and each request path is matched to its route pattern:

/browse/github.com/org/repo/-/blob/src/index.ts -> /browse/[...path]
/api/auth/callback/github -> /api/auth/[...nextauth]
/settings/connections/42 -> /settings/connections/[id]
/wp-admin -> /[...slug] (the route that actually serves it)
/api/not-a-real-route -> /api/[...slug]

Cardinality is bounded by the number of defined routes plus /_next and other, and the label set tracks the app automatically at build time — no hand-maintained list to rot. If the manifest is missing or unreadable (e.g. next dev), everything is labelled other: granularity lost, bound kept — it fails closed.

Metrics scrapes are excluded. The channels are process-wide, so they also fire for the metrics server itself — without a filter on the metrics port, every scrape would record itself and the histogram would measure the observer. Verified in the integration test.

Recording is wrapped in try/catch and logs at debug: a metrics failure must never affect request handling.

Test plan

  • yarn workspace @sourcebot/web build — exit 0, and both http_request_duration_seconds and the module's log line are present in the standalone server chunks
  • 17 tests pass across 3 files
  • normalizeRoute unit tests against a manifest-shaped fixture: static exact-match, dynamic patterns, manifest ordering (specific routes beat catch-alls), duplicate/trailing slashes, missing-table fallback
  • Cardinality tests: 3,000 distinct scanner-style paths collapse to exactly the two catch-all labels; a 1,000-path hostile corpus never exceeds the table-derived bound; with no root catch-all in the table, unmatched paths report other
  • Integration test drives real HTTP requests through the channels and asserts /api/health is recorded, two distinct file paths collapse to a single /browse series with count 2, and status="200" is labelled
  • Metrics-port exclusion asserted on the total observation count, and verified by mutation: removing the port filter fails the test with expected 4 to be 3
  • The integration test injects its route table and takes ephemeral ports, so it depends on neither a prior next build nor a fixed port
  • Verified against the real production build: the manifest loads via the default cwd-relative path, and the running pod's web process cwd (/app/packages/web) contains routes-manifest.json — the standalone server chdirs to the app dir on boot
  • eslint clean; tsc --noEmit reports no errors in the new files and the non-test error count is unchanged at 0
  • Post-deploy: confirm http_request_duration_seconds appears on :3070/metrics and build the /api/health latency chart

Follow-up

Once this is deployed and scraped, the /api/health p50/p95/p99 chart is a single histogramQuantile query, and the "does health latency spike during GC" correlation becomes one dashboard rather than an inference across two systems. The dashboard is already created and its charts group by source, so the web process joins them automatically.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added HTTP request duration metrics with route, method, and response status details.
    • Metrics are collected automatically using configurable duration buckets.
    • Routes are normalized and grouped into bounded categories for consistent reporting.
    • Metrics-server scrapes are excluded from request measurements.
    • Unmatched or unavailable routes are grouped under a fallback category.
  • Documentation

    • Documented the new HTTP request duration metric in the changelog.

Note

Cursor Bugbot is generating a summary for commit d839501. Configure here.

The web process now reports runtime metrics, but nothing about how long
requests actually take, so per-endpoint latency is still invisible. That is the
signal needed to see a stall from the outside: /api/health does almost no work,
so its duration is essentially event loop queueing delay.
Add an http_request_duration_seconds histogram labelled by method, route, and
status, populated by subscribing to Node's built-in http.server.request.start
and http.server.response.finish diagnostics channels. Next.js owns the server
instance in a standalone build, so there is no request pipeline to wrap; the
channels observe every request without patching anything.
Paths are collapsed to a bounded route label. Repository and file paths are
unbounded, so labelling by full path would mint a time series per file viewed.
Requests to the metrics port are skipped, since the channels are process-wide
and every scrape would otherwise record itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

This comment has been minimized.

@coderabbitai

coderabbitaiBot 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: a717e7a1-5bc9-4482-8553-b83841f0ecaa

📥 Commits

Reviewing files that changed from the base of the PR and between d839501 and 0867a0e.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • packages/web/src/httpMetrics.integration.test.ts
  • packages/web/src/httpMetrics.test.ts
  • packages/web/src/httpMetrics.ts

Walkthrough

The web server now exports http_request_duration_seconds metrics. Diagnostic channels record completed requests with bounded route labels, method, status, and duration. Runtime registration starts collection, and unit and integration tests validate normalization and aggregation.

Changes

HTTP metrics instrumentation

Layer / File(s)Summary
HTTP duration metric contract
packages/web/src/promClient.ts
Defines and registers the httpRequestDuration histogram with method, route, and status labels.
Request tracking and route normalization
packages/web/src/httpMetrics.ts, packages/web/src/instrumentation.ts
Loads route manifests, normalizes routes, bounds route labels, excludes metrics-server traffic, records completed request durations, prevents duplicate subscriptions, and starts collection during runtime registration.
Metric behavior validation and release notes
packages/web/src/httpMetrics.test.ts, packages/web/src/httpMetrics.integration.test.ts, CHANGELOG.md
Tests route normalization, cardinality limits, request aggregation, status labels, observation counts, histogram bounds, and metrics-port exclusion. Documents the new metric.

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

Sequence Diagram(s)

sequenceDiagram
participant NodeRegistration
participant NodeDiagnosticChannels
participant httpMetrics
participant httpRequestDuration
NodeRegistration->>httpMetrics: Start HTTP metrics collection
NodeDiagnosticChannels->>httpMetrics: Publish request start and completion messages
httpMetrics->>httpMetrics: Normalize route and exclude metrics-server traffic
httpMetrics->>httpRequestDuration: Observe method, route, status, and duration
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly and concisely describes the main change: adding HTTP request duration metrics for the web server.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch brendan/web-http-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>
Comment threadpackages/web/src/httpMetrics.ts
Truncating path depth bounded depth, not breadth. The first path segment is
client-supplied and /api/[...slug] is a catch-all, so /wp-admin, /.env, and
/api/<anything> each minted a new time series. Scanner traffic could grow the
series count without limit, which is exactly what the normalization was
supposed to prevent.
Match the truncated path against a known-route set and report anything else as
`other`, bounding distinct route labels to that set plus one regardless of what
is requested. A route missing from the set loses granularity rather than
breaking, so it fails closed.
Also strengthens the metrics-port exclusion assertion. It checked for the
absence of a `/metrics` label, which became vacuous once unknown paths collapse
to `other` — it now asserts the total observation count, and fails if the port
filter is removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot 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/httpMetrics.integration.test.ts`:
- Around line 38-42: Update the integration test setup around startHttpMetrics
so process.env.WEB_METRICS_PORT is assigned before the httpMetrics module is
imported, ensuring createEnv(options) reads the ephemeral metrics port during
module initialization. Replace the static import with a deferred import after
the environment assignment and invoke startHttpMetrics from that loaded module.
🪄 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: 7cb3db6d-2e81-48e4-a016-a97834000cd9

📥 Commits

Reviewing files that changed from the base of the PR and between 84a75e4 and 9e595eb.

📒 Files selected for processing (6)
  • CHANGELOG.md
  • packages/web/src/httpMetrics.integration.test.ts
  • packages/web/src/httpMetrics.test.ts
  • packages/web/src/httpMetrics.ts
  • packages/web/src/instrumentation.ts
  • packages/web/src/promClient.ts

Comment threadpackages/web/src/httpMetrics.integration.test.ts
…hardcoded list
The hardcoded KNOWN_ROUTES set bounded cardinality but rots: new routes
silently degrade to `other` until someone updates the file, and two-segment
truncation collapses distinct routes (everything under /api/ee/* became one
label).
Next's build already emits .next/routes-manifest.json with every defined
route and a matching regex, ordered by the router's own resolution priority.
Load that at startup: exact-match static routes, then first dynamic regex
wins, mirroring how the server actually routes the request. Labels become the
route pattern itself (/browse/[...path], /settings/connections/[id]), so
cardinality is bounded by the number of defined routes plus /_next and
`other`, and the label set tracks the app automatically at build time.
Scanner traffic now lands on the catch-all routes that genuinely serve it
(/[...slug], /api/[...slug]) rather than a synthetic bucket. If the manifest
is missing or unreadable, everything is labelled `other` — granularity lost,
bound kept.
Verified against the production build's manifest (78 static + 18 dynamic
routes) and the running pod: the standalone server chdirs to the app dir, so
the cwd-relative manifest path resolves.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot 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/httpMetrics.ts`:
- Around line 59-63: Update the catch block in initRouteTable to assign
routeTable = undefined before returning false, ensuring initialization failures
discard any previously loaded route table and subsequent requests use the
OTHER_ROUTE fallback.
🪄 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: c5daa8f0-6a9f-4a10-b470-2185ac676f2c

📥 Commits

Reviewing files that changed from the base of the PR and between 9e595eb and 829ecb3.

📒 Files selected for processing (3)
  • packages/web/src/httpMetrics.integration.test.ts
  • packages/web/src/httpMetrics.test.ts
  • packages/web/src/httpMetrics.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/web/src/httpMetrics.test.ts

Comment threadpackages/web/src/httpMetrics.ts
Comment threadpackages/web/src/httpMetrics.ts
@brendan-kellam
brendan-kellam merged commit 5e13dbb into mainAug 12, 2026
11 of 12 checks passed
@brendan-kellam
brendan-kellam deleted the brendan/web-http-metrics branch August 12, 2026 23:27
@github-actionsgithub-actionsBot mentioned this pull request Aug 12, 2026

@cursorcursorBot 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 0867a0e. Configure here.

method: request.method ?? 'UNKNOWN',
route: normalizeRoute(pathname),
status: response.statusCode,
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unbounded HTTP method label cardinality

Medium Severity

The method label uses the raw client-supplied request.method with no allowlist. Route labels were carefully bounded via the manifest to stop scanner traffic from exploding series count, but arbitrary methods remain unbounded and multiply every route×status series under hostile or unusual clients.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit 0867a0e. Configure here.

Sign up for freeto 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

@brendan-kellam
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(web): record HTTP request duration metrics - #1571

Merged
brendan-kellam merged 8 commits into
mainfrom
brendan/web-http-metrics
Aug 12, 2026
Merged

feat(web): record HTTP request duration metrics#1571
brendan-kellam merged 8 commits into
mainfrom
brendan/web-http-metrics

Conversation

@brendan-kellam

@brendan-kellambrendan-kellam commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Follows #1570.

Problem

#1570 gave the web process runtime metrics (heap, GC, event loop lag) but nothing about how long requests take, so per-endpoint latency is still invisible. That gap is concrete: an attempt to build a Better Stack chart of /api/health response time found the data does not exist anywhere in telemetry — not as a span (traces go to Sentry), not as a metric, and Uptime monitor data isn't queryable from a Telemetry dashboard.

/api/health is the useful case. It does almost no work, so its duration is essentially event loop queueing delay — which makes it a direct read on whether the process is stalling, and it is the exact endpoint the liveness probe hits with a 1 second timeout.

Change

Adds an http_request_duration_seconds histogram (labels: method, route, status) populated from Node's built-in HTTP diagnostics channels — http.server.request.start and http.server.response.finish.

Why diagnostics channels. Next.js owns the http.Server instance in a standalone build, so there is no request pipeline to wrap. proxy.ts middleware runs in the edge runtime, where prom-client doesn't work. The channels are a supported, in-process observation point that sees every request without patching or monkey-wrapping anything.

Two details that are load-bearing rather than incidental:

Route labels come from Next's own route table. The raw path can't be a label — repository/file paths are unbounded, the first segment is client-supplied, and /api/[...slug] is a catch-all, so full paths or naive truncation both let scanner traffic grow the series count without limit. Instead, the build's .next/routes-manifest.json (78 static + 18 dynamic routes, each with a matching regex, ordered by the router's own resolution priority) is loaded at startup, and each request path is matched to its route pattern:

/browse/github.com/org/repo/-/blob/src/index.ts -> /browse/[...path]
/api/auth/callback/github -> /api/auth/[...nextauth]
/settings/connections/42 -> /settings/connections/[id]
/wp-admin -> /[...slug] (the route that actually serves it)
/api/not-a-real-route -> /api/[...slug]

Cardinality is bounded by the number of defined routes plus /_next and other, and the label set tracks the app automatically at build time — no hand-maintained list to rot. If the manifest is missing or unreadable (e.g. next dev), everything is labelled other: granularity lost, bound kept — it fails closed.

Metrics scrapes are excluded. The channels are process-wide, so they also fire for the metrics server itself — without a filter on the metrics port, every scrape would record itself and the histogram would measure the observer. Verified in the integration test.

Recording is wrapped in try/catch and logs at debug: a metrics failure must never affect request handling.

Test plan

  • yarn workspace @sourcebot/web build — exit 0, and both http_request_duration_seconds and the module's log line are present in the standalone server chunks
  • 17 tests pass across 3 files
  • normalizeRoute unit tests against a manifest-shaped fixture: static exact-match, dynamic patterns, manifest ordering (specific routes beat catch-alls), duplicate/trailing slashes, missing-table fallback
  • Cardinality tests: 3,000 distinct scanner-style paths collapse to exactly the two catch-all labels; a 1,000-path hostile corpus never exceeds the table-derived bound; with no root catch-all in the table, unmatched paths report other
  • Integration test drives real HTTP requests through the channels and asserts /api/health is recorded, two distinct file paths collapse to a single /browse series with count 2, and status="200" is labelled
  • Metrics-port exclusion asserted on the total observation count, and verified by mutation: removing the port filter fails the test with expected 4 to be 3
  • The integration test injects its route table and takes ephemeral ports, so it depends on neither a prior next build nor a fixed port
  • Verified against the real production build: the manifest loads via the default cwd-relative path, and the running pod's web process cwd (/app/packages/web) contains routes-manifest.json — the standalone server chdirs to the app dir on boot
  • eslint clean; tsc --noEmit reports no errors in the new files and the non-test error count is unchanged at 0
  • Post-deploy: confirm http_request_duration_seconds appears on :3070/metrics and build the /api/health latency chart

Follow-up

Once this is deployed and scraped, the /api/health p50/p95/p99 chart is a single histogramQuantile query, and the "does health latency spike during GC" correlation becomes one dashboard rather than an inference across two systems. The dashboard is already created and its charts group by source, so the web process joins them automatically.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added HTTP request duration metrics with route, method, and response status details.
    • Metrics are collected automatically using configurable duration buckets.
    • Routes are normalized and grouped into bounded categories for consistent reporting.
    • Metrics-server scrapes are excluded from request measurements.
    • Unmatched or unavailable routes are grouped under a fallback category.
  • Documentation

    • Documented the new HTTP request duration metric in the changelog.

Note

Cursor Bugbot is generating a summary for commit d839501. Configure here.

The web process now reports runtime metrics, but nothing about how long
requests actually take, so per-endpoint latency is still invisible. That is the
signal needed to see a stall from the outside: /api/health does almost no work,
so its duration is essentially event loop queueing delay.
Add an http_request_duration_seconds histogram labelled by method, route, and
status, populated by subscribing to Node's built-in http.server.request.start
and http.server.response.finish diagnostics channels. Next.js owns the server
instance in a standalone build, so there is no request pipeline to wrap; the
channels observe every request without patching anything.
Paths are collapsed to a bounded route label. Repository and file paths are
unbounded, so labelling by full path would mint a time series per file viewed.
Requests to the metrics port are skipped, since the channels are process-wide
and every scrape would otherwise record itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

This comment has been minimized.

@coderabbitai

coderabbitaiBot 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: a717e7a1-5bc9-4482-8553-b83841f0ecaa

📥 Commits

Reviewing files that changed from the base of the PR and between d839501 and 0867a0e.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • packages/web/src/httpMetrics.integration.test.ts
  • packages/web/src/httpMetrics.test.ts
  • packages/web/src/httpMetrics.ts

Walkthrough

The web server now exports http_request_duration_seconds metrics. Diagnostic channels record completed requests with bounded route labels, method, status, and duration. Runtime registration starts collection, and unit and integration tests validate normalization and aggregation.

Changes

HTTP metrics instrumentation

Layer / File(s)Summary
HTTP duration metric contract
packages/web/src/promClient.ts
Defines and registers the httpRequestDuration histogram with method, route, and status labels.
Request tracking and route normalization
packages/web/src/httpMetrics.ts, packages/web/src/instrumentation.ts
Loads route manifests, normalizes routes, bounds route labels, excludes metrics-server traffic, records completed request durations, prevents duplicate subscriptions, and starts collection during runtime registration.
Metric behavior validation and release notes
packages/web/src/httpMetrics.test.ts, packages/web/src/httpMetrics.integration.test.ts, CHANGELOG.md
Tests route normalization, cardinality limits, request aggregation, status labels, observation counts, histogram bounds, and metrics-port exclusion. Documents the new metric.

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

Sequence Diagram(s)

sequenceDiagram
participant NodeRegistration
participant NodeDiagnosticChannels
participant httpMetrics
participant httpRequestDuration
NodeRegistration->>httpMetrics: Start HTTP metrics collection
NodeDiagnosticChannels->>httpMetrics: Publish request start and completion messages
httpMetrics->>httpMetrics: Normalize route and exclude metrics-server traffic
httpMetrics->>httpRequestDuration: Observe method, route, status, and duration
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly and concisely describes the main change: adding HTTP request duration metrics for the web server.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch brendan/web-http-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>
Comment threadpackages/web/src/httpMetrics.ts
Truncating path depth bounded depth, not breadth. The first path segment is
client-supplied and /api/[...slug] is a catch-all, so /wp-admin, /.env, and
/api/<anything> each minted a new time series. Scanner traffic could grow the
series count without limit, which is exactly what the normalization was
supposed to prevent.
Match the truncated path against a known-route set and report anything else as
`other`, bounding distinct route labels to that set plus one regardless of what
is requested. A route missing from the set loses granularity rather than
breaking, so it fails closed.
Also strengthens the metrics-port exclusion assertion. It checked for the
absence of a `/metrics` label, which became vacuous once unknown paths collapse
to `other` — it now asserts the total observation count, and fails if the port
filter is removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot 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/httpMetrics.integration.test.ts`:
- Around line 38-42: Update the integration test setup around startHttpMetrics
so process.env.WEB_METRICS_PORT is assigned before the httpMetrics module is
imported, ensuring createEnv(options) reads the ephemeral metrics port during
module initialization. Replace the static import with a deferred import after
the environment assignment and invoke startHttpMetrics from that loaded module.
🪄 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: 7cb3db6d-2e81-48e4-a016-a97834000cd9

📥 Commits

Reviewing files that changed from the base of the PR and between 84a75e4 and 9e595eb.

📒 Files selected for processing (6)
  • CHANGELOG.md
  • packages/web/src/httpMetrics.integration.test.ts
  • packages/web/src/httpMetrics.test.ts
  • packages/web/src/httpMetrics.ts
  • packages/web/src/instrumentation.ts
  • packages/web/src/promClient.ts

Comment threadpackages/web/src/httpMetrics.integration.test.ts
…hardcoded list
The hardcoded KNOWN_ROUTES set bounded cardinality but rots: new routes
silently degrade to `other` until someone updates the file, and two-segment
truncation collapses distinct routes (everything under /api/ee/* became one
label).
Next's build already emits .next/routes-manifest.json with every defined
route and a matching regex, ordered by the router's own resolution priority.
Load that at startup: exact-match static routes, then first dynamic regex
wins, mirroring how the server actually routes the request. Labels become the
route pattern itself (/browse/[...path], /settings/connections/[id]), so
cardinality is bounded by the number of defined routes plus /_next and
`other`, and the label set tracks the app automatically at build time.
Scanner traffic now lands on the catch-all routes that genuinely serve it
(/[...slug], /api/[...slug]) rather than a synthetic bucket. If the manifest
is missing or unreadable, everything is labelled `other` — granularity lost,
bound kept.
Verified against the production build's manifest (78 static + 18 dynamic
routes) and the running pod: the standalone server chdirs to the app dir, so
the cwd-relative manifest path resolves.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot 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/httpMetrics.ts`:
- Around line 59-63: Update the catch block in initRouteTable to assign
routeTable = undefined before returning false, ensuring initialization failures
discard any previously loaded route table and subsequent requests use the
OTHER_ROUTE fallback.
🪄 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: c5daa8f0-6a9f-4a10-b470-2185ac676f2c

📥 Commits

Reviewing files that changed from the base of the PR and between 9e595eb and 829ecb3.

📒 Files selected for processing (3)
  • packages/web/src/httpMetrics.integration.test.ts
  • packages/web/src/httpMetrics.test.ts
  • packages/web/src/httpMetrics.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/web/src/httpMetrics.test.ts

Comment threadpackages/web/src/httpMetrics.ts
Comment threadpackages/web/src/httpMetrics.ts
@brendan-kellam
brendan-kellam merged commit 5e13dbb into mainAug 12, 2026
11 of 12 checks passed
@brendan-kellam
brendan-kellam deleted the brendan/web-http-metrics branch August 12, 2026 23:27
@github-actionsgithub-actionsBot mentioned this pull request Aug 12, 2026

@cursorcursorBot 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 0867a0e. Configure here.

method: request.method ?? 'UNKNOWN',
route: normalizeRoute(pathname),
status: response.statusCode,
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unbounded HTTP method label cardinality

Medium Severity

The method label uses the raw client-supplied request.method with no allowlist. Route labels were carefully bounded via the manifest to stop scanner traffic from exploding series count, but arbitrary methods remain unbounded and multiply every route×status series under hostile or unusual clients.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit 0867a0e. Configure here.

Sign up for freeto 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

@brendan-kellam
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(web): record HTTP request duration metrics - #1571

Merged
brendan-kellam merged 8 commits into
mainfrom
brendan/web-http-metrics
Aug 12, 2026
Merged

feat(web): record HTTP request duration metrics#1571
brendan-kellam merged 8 commits into
mainfrom
brendan/web-http-metrics

Conversation

@brendan-kellam

@brendan-kellambrendan-kellam commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Follows #1570.

Problem

#1570 gave the web process runtime metrics (heap, GC, event loop lag) but nothing about how long requests take, so per-endpoint latency is still invisible. That gap is concrete: an attempt to build a Better Stack chart of /api/health response time found the data does not exist anywhere in telemetry — not as a span (traces go to Sentry), not as a metric, and Uptime monitor data isn't queryable from a Telemetry dashboard.

/api/health is the useful case. It does almost no work, so its duration is essentially event loop queueing delay — which makes it a direct read on whether the process is stalling, and it is the exact endpoint the liveness probe hits with a 1 second timeout.

Change

Adds an http_request_duration_seconds histogram (labels: method, route, status) populated from Node's built-in HTTP diagnostics channels — http.server.request.start and http.server.response.finish.

Why diagnostics channels. Next.js owns the http.Server instance in a standalone build, so there is no request pipeline to wrap. proxy.ts middleware runs in the edge runtime, where prom-client doesn't work. The channels are a supported, in-process observation point that sees every request without patching or monkey-wrapping anything.

Two details that are load-bearing rather than incidental:

Route labels come from Next's own route table. The raw path can't be a label — repository/file paths are unbounded, the first segment is client-supplied, and /api/[...slug] is a catch-all, so full paths or naive truncation both let scanner traffic grow the series count without limit. Instead, the build's .next/routes-manifest.json (78 static + 18 dynamic routes, each with a matching regex, ordered by the router's own resolution priority) is loaded at startup, and each request path is matched to its route pattern:

/browse/github.com/org/repo/-/blob/src/index.ts -> /browse/[...path]
/api/auth/callback/github -> /api/auth/[...nextauth]
/settings/connections/42 -> /settings/connections/[id]
/wp-admin -> /[...slug] (the route that actually serves it)
/api/not-a-real-route -> /api/[...slug]

Cardinality is bounded by the number of defined routes plus /_next and other, and the label set tracks the app automatically at build time — no hand-maintained list to rot. If the manifest is missing or unreadable (e.g. next dev), everything is labelled other: granularity lost, bound kept — it fails closed.

Metrics scrapes are excluded. The channels are process-wide, so they also fire for the metrics server itself — without a filter on the metrics port, every scrape would record itself and the histogram would measure the observer. Verified in the integration test.

Recording is wrapped in try/catch and logs at debug: a metrics failure must never affect request handling.

Test plan

  • yarn workspace @sourcebot/web build — exit 0, and both http_request_duration_seconds and the module's log line are present in the standalone server chunks
  • 17 tests pass across 3 files
  • normalizeRoute unit tests against a manifest-shaped fixture: static exact-match, dynamic patterns, manifest ordering (specific routes beat catch-alls), duplicate/trailing slashes, missing-table fallback
  • Cardinality tests: 3,000 distinct scanner-style paths collapse to exactly the two catch-all labels; a 1,000-path hostile corpus never exceeds the table-derived bound; with no root catch-all in the table, unmatched paths report other
  • Integration test drives real HTTP requests through the channels and asserts /api/health is recorded, two distinct file paths collapse to a single /browse series with count 2, and status="200" is labelled
  • Metrics-port exclusion asserted on the total observation count, and verified by mutation: removing the port filter fails the test with expected 4 to be 3
  • The integration test injects its route table and takes ephemeral ports, so it depends on neither a prior next build nor a fixed port
  • Verified against the real production build: the manifest loads via the default cwd-relative path, and the running pod's web process cwd (/app/packages/web) contains routes-manifest.json — the standalone server chdirs to the app dir on boot
  • eslint clean; tsc --noEmit reports no errors in the new files and the non-test error count is unchanged at 0
  • Post-deploy: confirm http_request_duration_seconds appears on :3070/metrics and build the /api/health latency chart

Follow-up

Once this is deployed and scraped, the /api/health p50/p95/p99 chart is a single histogramQuantile query, and the "does health latency spike during GC" correlation becomes one dashboard rather than an inference across two systems. The dashboard is already created and its charts group by source, so the web process joins them automatically.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added HTTP request duration metrics with route, method, and response status details.
    • Metrics are collected automatically using configurable duration buckets.
    • Routes are normalized and grouped into bounded categories for consistent reporting.
    • Metrics-server scrapes are excluded from request measurements.
    • Unmatched or unavailable routes are grouped under a fallback category.
  • Documentation

    • Documented the new HTTP request duration metric in the changelog.

Note

Cursor Bugbot is generating a summary for commit d839501. Configure here.

The web process now reports runtime metrics, but nothing about how long
requests actually take, so per-endpoint latency is still invisible. That is the
signal needed to see a stall from the outside: /api/health does almost no work,
so its duration is essentially event loop queueing delay.
Add an http_request_duration_seconds histogram labelled by method, route, and
status, populated by subscribing to Node's built-in http.server.request.start
and http.server.response.finish diagnostics channels. Next.js owns the server
instance in a standalone build, so there is no request pipeline to wrap; the
channels observe every request without patching anything.
Paths are collapsed to a bounded route label. Repository and file paths are
unbounded, so labelling by full path would mint a time series per file viewed.
Requests to the metrics port are skipped, since the channels are process-wide
and every scrape would otherwise record itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

This comment has been minimized.

@coderabbitai

coderabbitaiBot 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: a717e7a1-5bc9-4482-8553-b83841f0ecaa

📥 Commits

Reviewing files that changed from the base of the PR and between d839501 and 0867a0e.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • packages/web/src/httpMetrics.integration.test.ts
  • packages/web/src/httpMetrics.test.ts
  • packages/web/src/httpMetrics.ts

Walkthrough

The web server now exports http_request_duration_seconds metrics. Diagnostic channels record completed requests with bounded route labels, method, status, and duration. Runtime registration starts collection, and unit and integration tests validate normalization and aggregation.

Changes

HTTP metrics instrumentation

Layer / File(s)Summary
HTTP duration metric contract
packages/web/src/promClient.ts
Defines and registers the httpRequestDuration histogram with method, route, and status labels.
Request tracking and route normalization
packages/web/src/httpMetrics.ts, packages/web/src/instrumentation.ts
Loads route manifests, normalizes routes, bounds route labels, excludes metrics-server traffic, records completed request durations, prevents duplicate subscriptions, and starts collection during runtime registration.
Metric behavior validation and release notes
packages/web/src/httpMetrics.test.ts, packages/web/src/httpMetrics.integration.test.ts, CHANGELOG.md
Tests route normalization, cardinality limits, request aggregation, status labels, observation counts, histogram bounds, and metrics-port exclusion. Documents the new metric.

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

Sequence Diagram(s)

sequenceDiagram
participant NodeRegistration
participant NodeDiagnosticChannels
participant httpMetrics
participant httpRequestDuration
NodeRegistration->>httpMetrics: Start HTTP metrics collection
NodeDiagnosticChannels->>httpMetrics: Publish request start and completion messages
httpMetrics->>httpMetrics: Normalize route and exclude metrics-server traffic
httpMetrics->>httpRequestDuration: Observe method, route, status, and duration
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly and concisely describes the main change: adding HTTP request duration metrics for the web server.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch brendan/web-http-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>
Comment threadpackages/web/src/httpMetrics.ts
Truncating path depth bounded depth, not breadth. The first path segment is
client-supplied and /api/[...slug] is a catch-all, so /wp-admin, /.env, and
/api/<anything> each minted a new time series. Scanner traffic could grow the
series count without limit, which is exactly what the normalization was
supposed to prevent.
Match the truncated path against a known-route set and report anything else as
`other`, bounding distinct route labels to that set plus one regardless of what
is requested. A route missing from the set loses granularity rather than
breaking, so it fails closed.
Also strengthens the metrics-port exclusion assertion. It checked for the
absence of a `/metrics` label, which became vacuous once unknown paths collapse
to `other` — it now asserts the total observation count, and fails if the port
filter is removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot 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/httpMetrics.integration.test.ts`:
- Around line 38-42: Update the integration test setup around startHttpMetrics
so process.env.WEB_METRICS_PORT is assigned before the httpMetrics module is
imported, ensuring createEnv(options) reads the ephemeral metrics port during
module initialization. Replace the static import with a deferred import after
the environment assignment and invoke startHttpMetrics from that loaded module.
🪄 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: 7cb3db6d-2e81-48e4-a016-a97834000cd9

📥 Commits

Reviewing files that changed from the base of the PR and between 84a75e4 and 9e595eb.

📒 Files selected for processing (6)
  • CHANGELOG.md
  • packages/web/src/httpMetrics.integration.test.ts
  • packages/web/src/httpMetrics.test.ts
  • packages/web/src/httpMetrics.ts
  • packages/web/src/instrumentation.ts
  • packages/web/src/promClient.ts

Comment threadpackages/web/src/httpMetrics.integration.test.ts
…hardcoded list
The hardcoded KNOWN_ROUTES set bounded cardinality but rots: new routes
silently degrade to `other` until someone updates the file, and two-segment
truncation collapses distinct routes (everything under /api/ee/* became one
label).
Next's build already emits .next/routes-manifest.json with every defined
route and a matching regex, ordered by the router's own resolution priority.
Load that at startup: exact-match static routes, then first dynamic regex
wins, mirroring how the server actually routes the request. Labels become the
route pattern itself (/browse/[...path], /settings/connections/[id]), so
cardinality is bounded by the number of defined routes plus /_next and
`other`, and the label set tracks the app automatically at build time.
Scanner traffic now lands on the catch-all routes that genuinely serve it
(/[...slug], /api/[...slug]) rather than a synthetic bucket. If the manifest
is missing or unreadable, everything is labelled `other` — granularity lost,
bound kept.
Verified against the production build's manifest (78 static + 18 dynamic
routes) and the running pod: the standalone server chdirs to the app dir, so
the cwd-relative manifest path resolves.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot 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/httpMetrics.ts`:
- Around line 59-63: Update the catch block in initRouteTable to assign
routeTable = undefined before returning false, ensuring initialization failures
discard any previously loaded route table and subsequent requests use the
OTHER_ROUTE fallback.
🪄 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: c5daa8f0-6a9f-4a10-b470-2185ac676f2c

📥 Commits

Reviewing files that changed from the base of the PR and between 9e595eb and 829ecb3.

📒 Files selected for processing (3)
  • packages/web/src/httpMetrics.integration.test.ts
  • packages/web/src/httpMetrics.test.ts
  • packages/web/src/httpMetrics.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/web/src/httpMetrics.test.ts

Comment threadpackages/web/src/httpMetrics.ts
Comment threadpackages/web/src/httpMetrics.ts
@brendan-kellam
brendan-kellam merged commit 5e13dbb into mainAug 12, 2026
11 of 12 checks passed
@brendan-kellam
brendan-kellam deleted the brendan/web-http-metrics branch August 12, 2026 23:27
@github-actionsgithub-actionsBot mentioned this pull request Aug 12, 2026

@cursorcursorBot 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 0867a0e. Configure here.

method: request.method ?? 'UNKNOWN',
route: normalizeRoute(pathname),
status: response.statusCode,
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unbounded HTTP method label cardinality

Medium Severity

The method label uses the raw client-supplied request.method with no allowlist. Route labels were carefully bounded via the manifest to stop scanner traffic from exploding series count, but arbitrary methods remain unbounded and multiply every route×status series under hostile or unusual clients.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit 0867a0e. Configure here.

Sign up for freeto 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

@brendan-kellam
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(web): record HTTP request duration metrics - #1571

Merged
brendan-kellam merged 8 commits into
mainfrom
brendan/web-http-metrics
Aug 12, 2026
Merged

feat(web): record HTTP request duration metrics#1571
brendan-kellam merged 8 commits into
mainfrom
brendan/web-http-metrics

Conversation

@brendan-kellam

@brendan-kellambrendan-kellam commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Follows #1570.

Problem

#1570 gave the web process runtime metrics (heap, GC, event loop lag) but nothing about how long requests take, so per-endpoint latency is still invisible. That gap is concrete: an attempt to build a Better Stack chart of /api/health response time found the data does not exist anywhere in telemetry — not as a span (traces go to Sentry), not as a metric, and Uptime monitor data isn't queryable from a Telemetry dashboard.

/api/health is the useful case. It does almost no work, so its duration is essentially event loop queueing delay — which makes it a direct read on whether the process is stalling, and it is the exact endpoint the liveness probe hits with a 1 second timeout.

Change

Adds an http_request_duration_seconds histogram (labels: method, route, status) populated from Node's built-in HTTP diagnostics channels — http.server.request.start and http.server.response.finish.

Why diagnostics channels. Next.js owns the http.Server instance in a standalone build, so there is no request pipeline to wrap. proxy.ts middleware runs in the edge runtime, where prom-client doesn't work. The channels are a supported, in-process observation point that sees every request without patching or monkey-wrapping anything.

Two details that are load-bearing rather than incidental:

Route labels come from Next's own route table. The raw path can't be a label — repository/file paths are unbounded, the first segment is client-supplied, and /api/[...slug] is a catch-all, so full paths or naive truncation both let scanner traffic grow the series count without limit. Instead, the build's .next/routes-manifest.json (78 static + 18 dynamic routes, each with a matching regex, ordered by the router's own resolution priority) is loaded at startup, and each request path is matched to its route pattern:

/browse/github.com/org/repo/-/blob/src/index.ts -> /browse/[...path]
/api/auth/callback/github -> /api/auth/[...nextauth]
/settings/connections/42 -> /settings/connections/[id]
/wp-admin -> /[...slug] (the route that actually serves it)
/api/not-a-real-route -> /api/[...slug]

Cardinality is bounded by the number of defined routes plus /_next and other, and the label set tracks the app automatically at build time — no hand-maintained list to rot. If the manifest is missing or unreadable (e.g. next dev), everything is labelled other: granularity lost, bound kept — it fails closed.

Metrics scrapes are excluded. The channels are process-wide, so they also fire for the metrics server itself — without a filter on the metrics port, every scrape would record itself and the histogram would measure the observer. Verified in the integration test.

Recording is wrapped in try/catch and logs at debug: a metrics failure must never affect request handling.

Test plan

  • yarn workspace @sourcebot/web build — exit 0, and both http_request_duration_seconds and the module's log line are present in the standalone server chunks
  • 17 tests pass across 3 files
  • normalizeRoute unit tests against a manifest-shaped fixture: static exact-match, dynamic patterns, manifest ordering (specific routes beat catch-alls), duplicate/trailing slashes, missing-table fallback
  • Cardinality tests: 3,000 distinct scanner-style paths collapse to exactly the two catch-all labels; a 1,000-path hostile corpus never exceeds the table-derived bound; with no root catch-all in the table, unmatched paths report other
  • Integration test drives real HTTP requests through the channels and asserts /api/health is recorded, two distinct file paths collapse to a single /browse series with count 2, and status="200" is labelled
  • Metrics-port exclusion asserted on the total observation count, and verified by mutation: removing the port filter fails the test with expected 4 to be 3
  • The integration test injects its route table and takes ephemeral ports, so it depends on neither a prior next build nor a fixed port
  • Verified against the real production build: the manifest loads via the default cwd-relative path, and the running pod's web process cwd (/app/packages/web) contains routes-manifest.json — the standalone server chdirs to the app dir on boot
  • eslint clean; tsc --noEmit reports no errors in the new files and the non-test error count is unchanged at 0
  • Post-deploy: confirm http_request_duration_seconds appears on :3070/metrics and build the /api/health latency chart

Follow-up

Once this is deployed and scraped, the /api/health p50/p95/p99 chart is a single histogramQuantile query, and the "does health latency spike during GC" correlation becomes one dashboard rather than an inference across two systems. The dashboard is already created and its charts group by source, so the web process joins them automatically.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added HTTP request duration metrics with route, method, and response status details.
    • Metrics are collected automatically using configurable duration buckets.
    • Routes are normalized and grouped into bounded categories for consistent reporting.
    • Metrics-server scrapes are excluded from request measurements.
    • Unmatched or unavailable routes are grouped under a fallback category.
  • Documentation

    • Documented the new HTTP request duration metric in the changelog.

Note

Cursor Bugbot is generating a summary for commit d839501. Configure here.

The web process now reports runtime metrics, but nothing about how long
requests actually take, so per-endpoint latency is still invisible. That is the
signal needed to see a stall from the outside: /api/health does almost no work,
so its duration is essentially event loop queueing delay.
Add an http_request_duration_seconds histogram labelled by method, route, and
status, populated by subscribing to Node's built-in http.server.request.start
and http.server.response.finish diagnostics channels. Next.js owns the server
instance in a standalone build, so there is no request pipeline to wrap; the
channels observe every request without patching anything.
Paths are collapsed to a bounded route label. Repository and file paths are
unbounded, so labelling by full path would mint a time series per file viewed.
Requests to the metrics port are skipped, since the channels are process-wide
and every scrape would otherwise record itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

This comment has been minimized.

@coderabbitai

coderabbitaiBot 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: a717e7a1-5bc9-4482-8553-b83841f0ecaa

📥 Commits

Reviewing files that changed from the base of the PR and between d839501 and 0867a0e.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • packages/web/src/httpMetrics.integration.test.ts
  • packages/web/src/httpMetrics.test.ts
  • packages/web/src/httpMetrics.ts

Walkthrough

The web server now exports http_request_duration_seconds metrics. Diagnostic channels record completed requests with bounded route labels, method, status, and duration. Runtime registration starts collection, and unit and integration tests validate normalization and aggregation.

Changes

HTTP metrics instrumentation

Layer / File(s)Summary
HTTP duration metric contract
packages/web/src/promClient.ts
Defines and registers the httpRequestDuration histogram with method, route, and status labels.
Request tracking and route normalization
packages/web/src/httpMetrics.ts, packages/web/src/instrumentation.ts
Loads route manifests, normalizes routes, bounds route labels, excludes metrics-server traffic, records completed request durations, prevents duplicate subscriptions, and starts collection during runtime registration.
Metric behavior validation and release notes
packages/web/src/httpMetrics.test.ts, packages/web/src/httpMetrics.integration.test.ts, CHANGELOG.md
Tests route normalization, cardinality limits, request aggregation, status labels, observation counts, histogram bounds, and metrics-port exclusion. Documents the new metric.

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

Sequence Diagram(s)

sequenceDiagram
participant NodeRegistration
participant NodeDiagnosticChannels
participant httpMetrics
participant httpRequestDuration
NodeRegistration->>httpMetrics: Start HTTP metrics collection
NodeDiagnosticChannels->>httpMetrics: Publish request start and completion messages
httpMetrics->>httpMetrics: Normalize route and exclude metrics-server traffic
httpMetrics->>httpRequestDuration: Observe method, route, status, and duration
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly and concisely describes the main change: adding HTTP request duration metrics for the web server.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch brendan/web-http-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>
Comment threadpackages/web/src/httpMetrics.ts
Truncating path depth bounded depth, not breadth. The first path segment is
client-supplied and /api/[...slug] is a catch-all, so /wp-admin, /.env, and
/api/<anything> each minted a new time series. Scanner traffic could grow the
series count without limit, which is exactly what the normalization was
supposed to prevent.
Match the truncated path against a known-route set and report anything else as
`other`, bounding distinct route labels to that set plus one regardless of what
is requested. A route missing from the set loses granularity rather than
breaking, so it fails closed.
Also strengthens the metrics-port exclusion assertion. It checked for the
absence of a `/metrics` label, which became vacuous once unknown paths collapse
to `other` — it now asserts the total observation count, and fails if the port
filter is removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot 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/httpMetrics.integration.test.ts`:
- Around line 38-42: Update the integration test setup around startHttpMetrics
so process.env.WEB_METRICS_PORT is assigned before the httpMetrics module is
imported, ensuring createEnv(options) reads the ephemeral metrics port during
module initialization. Replace the static import with a deferred import after
the environment assignment and invoke startHttpMetrics from that loaded module.
🪄 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: 7cb3db6d-2e81-48e4-a016-a97834000cd9

📥 Commits

Reviewing files that changed from the base of the PR and between 84a75e4 and 9e595eb.

📒 Files selected for processing (6)
  • CHANGELOG.md
  • packages/web/src/httpMetrics.integration.test.ts
  • packages/web/src/httpMetrics.test.ts
  • packages/web/src/httpMetrics.ts
  • packages/web/src/instrumentation.ts
  • packages/web/src/promClient.ts

Comment threadpackages/web/src/httpMetrics.integration.test.ts
…hardcoded list
The hardcoded KNOWN_ROUTES set bounded cardinality but rots: new routes
silently degrade to `other` until someone updates the file, and two-segment
truncation collapses distinct routes (everything under /api/ee/* became one
label).
Next's build already emits .next/routes-manifest.json with every defined
route and a matching regex, ordered by the router's own resolution priority.
Load that at startup: exact-match static routes, then first dynamic regex
wins, mirroring how the server actually routes the request. Labels become the
route pattern itself (/browse/[...path], /settings/connections/[id]), so
cardinality is bounded by the number of defined routes plus /_next and
`other`, and the label set tracks the app automatically at build time.
Scanner traffic now lands on the catch-all routes that genuinely serve it
(/[...slug], /api/[...slug]) rather than a synthetic bucket. If the manifest
is missing or unreadable, everything is labelled `other` — granularity lost,
bound kept.
Verified against the production build's manifest (78 static + 18 dynamic
routes) and the running pod: the standalone server chdirs to the app dir, so
the cwd-relative manifest path resolves.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot 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/httpMetrics.ts`:
- Around line 59-63: Update the catch block in initRouteTable to assign
routeTable = undefined before returning false, ensuring initialization failures
discard any previously loaded route table and subsequent requests use the
OTHER_ROUTE fallback.
🪄 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: c5daa8f0-6a9f-4a10-b470-2185ac676f2c

📥 Commits

Reviewing files that changed from the base of the PR and between 9e595eb and 829ecb3.

📒 Files selected for processing (3)
  • packages/web/src/httpMetrics.integration.test.ts
  • packages/web/src/httpMetrics.test.ts
  • packages/web/src/httpMetrics.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/web/src/httpMetrics.test.ts

Comment threadpackages/web/src/httpMetrics.ts
Comment threadpackages/web/src/httpMetrics.ts
@brendan-kellam
brendan-kellam merged commit 5e13dbb into mainAug 12, 2026
11 of 12 checks passed
@brendan-kellam
brendan-kellam deleted the brendan/web-http-metrics branch August 12, 2026 23:27
@github-actionsgithub-actionsBot mentioned this pull request Aug 12, 2026

@cursorcursorBot 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 0867a0e. Configure here.

method: request.method ?? 'UNKNOWN',
route: normalizeRoute(pathname),
status: response.statusCode,
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unbounded HTTP method label cardinality

Medium Severity

The method label uses the raw client-supplied request.method with no allowlist. Route labels were carefully bounded via the manifest to stop scanner traffic from exploding series count, but arbitrary methods remain unbounded and multiply every route×status series under hostile or unusual clients.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit 0867a0e. Configure here.

Sign up for freeto 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

@brendan-kellam
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(web): record HTTP request duration metrics - #1571

Merged
brendan-kellam merged 8 commits into
mainfrom
brendan/web-http-metrics
Aug 12, 2026
Merged

feat(web): record HTTP request duration metrics#1571
brendan-kellam merged 8 commits into
mainfrom
brendan/web-http-metrics

Conversation

@brendan-kellam

@brendan-kellambrendan-kellam commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Follows #1570.

Problem

#1570 gave the web process runtime metrics (heap, GC, event loop lag) but nothing about how long requests take, so per-endpoint latency is still invisible. That gap is concrete: an attempt to build a Better Stack chart of /api/health response time found the data does not exist anywhere in telemetry — not as a span (traces go to Sentry), not as a metric, and Uptime monitor data isn't queryable from a Telemetry dashboard.

/api/health is the useful case. It does almost no work, so its duration is essentially event loop queueing delay — which makes it a direct read on whether the process is stalling, and it is the exact endpoint the liveness probe hits with a 1 second timeout.

Change

Adds an http_request_duration_seconds histogram (labels: method, route, status) populated from Node's built-in HTTP diagnostics channels — http.server.request.start and http.server.response.finish.

Why diagnostics channels. Next.js owns the http.Server instance in a standalone build, so there is no request pipeline to wrap. proxy.ts middleware runs in the edge runtime, where prom-client doesn't work. The channels are a supported, in-process observation point that sees every request without patching or monkey-wrapping anything.

Two details that are load-bearing rather than incidental:

Route labels come from Next's own route table. The raw path can't be a label — repository/file paths are unbounded, the first segment is client-supplied, and /api/[...slug] is a catch-all, so full paths or naive truncation both let scanner traffic grow the series count without limit. Instead, the build's .next/routes-manifest.json (78 static + 18 dynamic routes, each with a matching regex, ordered by the router's own resolution priority) is loaded at startup, and each request path is matched to its route pattern:

/browse/github.com/org/repo/-/blob/src/index.ts -> /browse/[...path]
/api/auth/callback/github -> /api/auth/[...nextauth]
/settings/connections/42 -> /settings/connections/[id]
/wp-admin -> /[...slug] (the route that actually serves it)
/api/not-a-real-route -> /api/[...slug]

Cardinality is bounded by the number of defined routes plus /_next and other, and the label set tracks the app automatically at build time — no hand-maintained list to rot. If the manifest is missing or unreadable (e.g. next dev), everything is labelled other: granularity lost, bound kept — it fails closed.

Metrics scrapes are excluded. The channels are process-wide, so they also fire for the metrics server itself — without a filter on the metrics port, every scrape would record itself and the histogram would measure the observer. Verified in the integration test.

Recording is wrapped in try/catch and logs at debug: a metrics failure must never affect request handling.

Test plan

  • yarn workspace @sourcebot/web build — exit 0, and both http_request_duration_seconds and the module's log line are present in the standalone server chunks
  • 17 tests pass across 3 files
  • normalizeRoute unit tests against a manifest-shaped fixture: static exact-match, dynamic patterns, manifest ordering (specific routes beat catch-alls), duplicate/trailing slashes, missing-table fallback
  • Cardinality tests: 3,000 distinct scanner-style paths collapse to exactly the two catch-all labels; a 1,000-path hostile corpus never exceeds the table-derived bound; with no root catch-all in the table, unmatched paths report other
  • Integration test drives real HTTP requests through the channels and asserts /api/health is recorded, two distinct file paths collapse to a single /browse series with count 2, and status="200" is labelled
  • Metrics-port exclusion asserted on the total observation count, and verified by mutation: removing the port filter fails the test with expected 4 to be 3
  • The integration test injects its route table and takes ephemeral ports, so it depends on neither a prior next build nor a fixed port
  • Verified against the real production build: the manifest loads via the default cwd-relative path, and the running pod's web process cwd (/app/packages/web) contains routes-manifest.json — the standalone server chdirs to the app dir on boot
  • eslint clean; tsc --noEmit reports no errors in the new files and the non-test error count is unchanged at 0
  • Post-deploy: confirm http_request_duration_seconds appears on :3070/metrics and build the /api/health latency chart

Follow-up

Once this is deployed and scraped, the /api/health p50/p95/p99 chart is a single histogramQuantile query, and the "does health latency spike during GC" correlation becomes one dashboard rather than an inference across two systems. The dashboard is already created and its charts group by source, so the web process joins them automatically.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added HTTP request duration metrics with route, method, and response status details.
    • Metrics are collected automatically using configurable duration buckets.
    • Routes are normalized and grouped into bounded categories for consistent reporting.
    • Metrics-server scrapes are excluded from request measurements.
    • Unmatched or unavailable routes are grouped under a fallback category.
  • Documentation

    • Documented the new HTTP request duration metric in the changelog.

Note

Cursor Bugbot is generating a summary for commit d839501. Configure here.

The web process now reports runtime metrics, but nothing about how long
requests actually take, so per-endpoint latency is still invisible. That is the
signal needed to see a stall from the outside: /api/health does almost no work,
so its duration is essentially event loop queueing delay.
Add an http_request_duration_seconds histogram labelled by method, route, and
status, populated by subscribing to Node's built-in http.server.request.start
and http.server.response.finish diagnostics channels. Next.js owns the server
instance in a standalone build, so there is no request pipeline to wrap; the
channels observe every request without patching anything.
Paths are collapsed to a bounded route label. Repository and file paths are
unbounded, so labelling by full path would mint a time series per file viewed.
Requests to the metrics port are skipped, since the channels are process-wide
and every scrape would otherwise record itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

This comment has been minimized.

@coderabbitai

coderabbitaiBot 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: a717e7a1-5bc9-4482-8553-b83841f0ecaa

📥 Commits

Reviewing files that changed from the base of the PR and between d839501 and 0867a0e.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • packages/web/src/httpMetrics.integration.test.ts
  • packages/web/src/httpMetrics.test.ts
  • packages/web/src/httpMetrics.ts

Walkthrough

The web server now exports http_request_duration_seconds metrics. Diagnostic channels record completed requests with bounded route labels, method, status, and duration. Runtime registration starts collection, and unit and integration tests validate normalization and aggregation.

Changes

HTTP metrics instrumentation

Layer / File(s)Summary
HTTP duration metric contract
packages/web/src/promClient.ts
Defines and registers the httpRequestDuration histogram with method, route, and status labels.
Request tracking and route normalization
packages/web/src/httpMetrics.ts, packages/web/src/instrumentation.ts
Loads route manifests, normalizes routes, bounds route labels, excludes metrics-server traffic, records completed request durations, prevents duplicate subscriptions, and starts collection during runtime registration.
Metric behavior validation and release notes
packages/web/src/httpMetrics.test.ts, packages/web/src/httpMetrics.integration.test.ts, CHANGELOG.md
Tests route normalization, cardinality limits, request aggregation, status labels, observation counts, histogram bounds, and metrics-port exclusion. Documents the new metric.

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

Sequence Diagram(s)

sequenceDiagram
participant NodeRegistration
participant NodeDiagnosticChannels
participant httpMetrics
participant httpRequestDuration
NodeRegistration->>httpMetrics: Start HTTP metrics collection
NodeDiagnosticChannels->>httpMetrics: Publish request start and completion messages
httpMetrics->>httpMetrics: Normalize route and exclude metrics-server traffic
httpMetrics->>httpRequestDuration: Observe method, route, status, and duration
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly and concisely describes the main change: adding HTTP request duration metrics for the web server.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch brendan/web-http-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>
Comment threadpackages/web/src/httpMetrics.ts
Truncating path depth bounded depth, not breadth. The first path segment is
client-supplied and /api/[...slug] is a catch-all, so /wp-admin, /.env, and
/api/<anything> each minted a new time series. Scanner traffic could grow the
series count without limit, which is exactly what the normalization was
supposed to prevent.
Match the truncated path against a known-route set and report anything else as
`other`, bounding distinct route labels to that set plus one regardless of what
is requested. A route missing from the set loses granularity rather than
breaking, so it fails closed.
Also strengthens the metrics-port exclusion assertion. It checked for the
absence of a `/metrics` label, which became vacuous once unknown paths collapse
to `other` — it now asserts the total observation count, and fails if the port
filter is removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot 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/httpMetrics.integration.test.ts`:
- Around line 38-42: Update the integration test setup around startHttpMetrics
so process.env.WEB_METRICS_PORT is assigned before the httpMetrics module is
imported, ensuring createEnv(options) reads the ephemeral metrics port during
module initialization. Replace the static import with a deferred import after
the environment assignment and invoke startHttpMetrics from that loaded module.
🪄 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: 7cb3db6d-2e81-48e4-a016-a97834000cd9

📥 Commits

Reviewing files that changed from the base of the PR and between 84a75e4 and 9e595eb.

📒 Files selected for processing (6)
  • CHANGELOG.md
  • packages/web/src/httpMetrics.integration.test.ts
  • packages/web/src/httpMetrics.test.ts
  • packages/web/src/httpMetrics.ts
  • packages/web/src/instrumentation.ts
  • packages/web/src/promClient.ts

Comment threadpackages/web/src/httpMetrics.integration.test.ts
…hardcoded list
The hardcoded KNOWN_ROUTES set bounded cardinality but rots: new routes
silently degrade to `other` until someone updates the file, and two-segment
truncation collapses distinct routes (everything under /api/ee/* became one
label).
Next's build already emits .next/routes-manifest.json with every defined
route and a matching regex, ordered by the router's own resolution priority.
Load that at startup: exact-match static routes, then first dynamic regex
wins, mirroring how the server actually routes the request. Labels become the
route pattern itself (/browse/[...path], /settings/connections/[id]), so
cardinality is bounded by the number of defined routes plus /_next and
`other`, and the label set tracks the app automatically at build time.
Scanner traffic now lands on the catch-all routes that genuinely serve it
(/[...slug], /api/[...slug]) rather than a synthetic bucket. If the manifest
is missing or unreadable, everything is labelled `other` — granularity lost,
bound kept.
Verified against the production build's manifest (78 static + 18 dynamic
routes) and the running pod: the standalone server chdirs to the app dir, so
the cwd-relative manifest path resolves.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot 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/httpMetrics.ts`:
- Around line 59-63: Update the catch block in initRouteTable to assign
routeTable = undefined before returning false, ensuring initialization failures
discard any previously loaded route table and subsequent requests use the
OTHER_ROUTE fallback.
🪄 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: c5daa8f0-6a9f-4a10-b470-2185ac676f2c

📥 Commits

Reviewing files that changed from the base of the PR and between 9e595eb and 829ecb3.

📒 Files selected for processing (3)
  • packages/web/src/httpMetrics.integration.test.ts
  • packages/web/src/httpMetrics.test.ts
  • packages/web/src/httpMetrics.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/web/src/httpMetrics.test.ts

Comment threadpackages/web/src/httpMetrics.ts
Comment threadpackages/web/src/httpMetrics.ts
@brendan-kellam
brendan-kellam merged commit 5e13dbb into mainAug 12, 2026
11 of 12 checks passed
@brendan-kellam
brendan-kellam deleted the brendan/web-http-metrics branch August 12, 2026 23:27
@github-actionsgithub-actionsBot mentioned this pull request Aug 12, 2026

@cursorcursorBot 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 0867a0e. Configure here.

method: request.method ?? 'UNKNOWN',
route: normalizeRoute(pathname),
status: response.statusCode,
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unbounded HTTP method label cardinality

Medium Severity

The method label uses the raw client-supplied request.method with no allowlist. Route labels were carefully bounded via the manifest to stop scanner traffic from exploding series count, but arbitrary methods remain unbounded and multiply every route×status series under hostile or unusual clients.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit 0867a0e. Configure here.

Sign up for freeto 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

@brendan-kellam
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(web): record HTTP request duration metrics - #1571

Merged
brendan-kellam merged 8 commits into
mainfrom
brendan/web-http-metrics
Aug 12, 2026
Merged

feat(web): record HTTP request duration metrics#1571
brendan-kellam merged 8 commits into
mainfrom
brendan/web-http-metrics

Conversation

@brendan-kellam

@brendan-kellambrendan-kellam commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Follows #1570.

Problem

#1570 gave the web process runtime metrics (heap, GC, event loop lag) but nothing about how long requests take, so per-endpoint latency is still invisible. That gap is concrete: an attempt to build a Better Stack chart of /api/health response time found the data does not exist anywhere in telemetry — not as a span (traces go to Sentry), not as a metric, and Uptime monitor data isn't queryable from a Telemetry dashboard.

/api/health is the useful case. It does almost no work, so its duration is essentially event loop queueing delay — which makes it a direct read on whether the process is stalling, and it is the exact endpoint the liveness probe hits with a 1 second timeout.

Change

Adds an http_request_duration_seconds histogram (labels: method, route, status) populated from Node's built-in HTTP diagnostics channels — http.server.request.start and http.server.response.finish.

Why diagnostics channels. Next.js owns the http.Server instance in a standalone build, so there is no request pipeline to wrap. proxy.ts middleware runs in the edge runtime, where prom-client doesn't work. The channels are a supported, in-process observation point that sees every request without patching or monkey-wrapping anything.

Two details that are load-bearing rather than incidental:

Route labels come from Next's own route table. The raw path can't be a label — repository/file paths are unbounded, the first segment is client-supplied, and /api/[...slug] is a catch-all, so full paths or naive truncation both let scanner traffic grow the series count without limit. Instead, the build's .next/routes-manifest.json (78 static + 18 dynamic routes, each with a matching regex, ordered by the router's own resolution priority) is loaded at startup, and each request path is matched to its route pattern:

/browse/github.com/org/repo/-/blob/src/index.ts -> /browse/[...path]
/api/auth/callback/github -> /api/auth/[...nextauth]
/settings/connections/42 -> /settings/connections/[id]
/wp-admin -> /[...slug] (the route that actually serves it)
/api/not-a-real-route -> /api/[...slug]

Cardinality is bounded by the number of defined routes plus /_next and other, and the label set tracks the app automatically at build time — no hand-maintained list to rot. If the manifest is missing or unreadable (e.g. next dev), everything is labelled other: granularity lost, bound kept — it fails closed.

Metrics scrapes are excluded. The channels are process-wide, so they also fire for the metrics server itself — without a filter on the metrics port, every scrape would record itself and the histogram would measure the observer. Verified in the integration test.

Recording is wrapped in try/catch and logs at debug: a metrics failure must never affect request handling.

Test plan

  • yarn workspace @sourcebot/web build — exit 0, and both http_request_duration_seconds and the module's log line are present in the standalone server chunks
  • 17 tests pass across 3 files
  • normalizeRoute unit tests against a manifest-shaped fixture: static exact-match, dynamic patterns, manifest ordering (specific routes beat catch-alls), duplicate/trailing slashes, missing-table fallback
  • Cardinality tests: 3,000 distinct scanner-style paths collapse to exactly the two catch-all labels; a 1,000-path hostile corpus never exceeds the table-derived bound; with no root catch-all in the table, unmatched paths report other
  • Integration test drives real HTTP requests through the channels and asserts /api/health is recorded, two distinct file paths collapse to a single /browse series with count 2, and status="200" is labelled
  • Metrics-port exclusion asserted on the total observation count, and verified by mutation: removing the port filter fails the test with expected 4 to be 3
  • The integration test injects its route table and takes ephemeral ports, so it depends on neither a prior next build nor a fixed port
  • Verified against the real production build: the manifest loads via the default cwd-relative path, and the running pod's web process cwd (/app/packages/web) contains routes-manifest.json — the standalone server chdirs to the app dir on boot
  • eslint clean; tsc --noEmit reports no errors in the new files and the non-test error count is unchanged at 0
  • Post-deploy: confirm http_request_duration_seconds appears on :3070/metrics and build the /api/health latency chart

Follow-up

Once this is deployed and scraped, the /api/health p50/p95/p99 chart is a single histogramQuantile query, and the "does health latency spike during GC" correlation becomes one dashboard rather than an inference across two systems. The dashboard is already created and its charts group by source, so the web process joins them automatically.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added HTTP request duration metrics with route, method, and response status details.
    • Metrics are collected automatically using configurable duration buckets.
    • Routes are normalized and grouped into bounded categories for consistent reporting.
    • Metrics-server scrapes are excluded from request measurements.
    • Unmatched or unavailable routes are grouped under a fallback category.
  • Documentation

    • Documented the new HTTP request duration metric in the changelog.

Note

Cursor Bugbot is generating a summary for commit d839501. Configure here.

The web process now reports runtime metrics, but nothing about how long
requests actually take, so per-endpoint latency is still invisible. That is the
signal needed to see a stall from the outside: /api/health does almost no work,
so its duration is essentially event loop queueing delay.
Add an http_request_duration_seconds histogram labelled by method, route, and
status, populated by subscribing to Node's built-in http.server.request.start
and http.server.response.finish diagnostics channels. Next.js owns the server
instance in a standalone build, so there is no request pipeline to wrap; the
channels observe every request without patching anything.
Paths are collapsed to a bounded route label. Repository and file paths are
unbounded, so labelling by full path would mint a time series per file viewed.
Requests to the metrics port are skipped, since the channels are process-wide
and every scrape would otherwise record itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

This comment has been minimized.

@coderabbitai

coderabbitaiBot 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: a717e7a1-5bc9-4482-8553-b83841f0ecaa

📥 Commits

Reviewing files that changed from the base of the PR and between d839501 and 0867a0e.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • packages/web/src/httpMetrics.integration.test.ts
  • packages/web/src/httpMetrics.test.ts
  • packages/web/src/httpMetrics.ts

Walkthrough

The web server now exports http_request_duration_seconds metrics. Diagnostic channels record completed requests with bounded route labels, method, status, and duration. Runtime registration starts collection, and unit and integration tests validate normalization and aggregation.

Changes

HTTP metrics instrumentation

Layer / File(s)Summary
HTTP duration metric contract
packages/web/src/promClient.ts
Defines and registers the httpRequestDuration histogram with method, route, and status labels.
Request tracking and route normalization
packages/web/src/httpMetrics.ts, packages/web/src/instrumentation.ts
Loads route manifests, normalizes routes, bounds route labels, excludes metrics-server traffic, records completed request durations, prevents duplicate subscriptions, and starts collection during runtime registration.
Metric behavior validation and release notes
packages/web/src/httpMetrics.test.ts, packages/web/src/httpMetrics.integration.test.ts, CHANGELOG.md
Tests route normalization, cardinality limits, request aggregation, status labels, observation counts, histogram bounds, and metrics-port exclusion. Documents the new metric.

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

Sequence Diagram(s)

sequenceDiagram
participant NodeRegistration
participant NodeDiagnosticChannels
participant httpMetrics
participant httpRequestDuration
NodeRegistration->>httpMetrics: Start HTTP metrics collection
NodeDiagnosticChannels->>httpMetrics: Publish request start and completion messages
httpMetrics->>httpMetrics: Normalize route and exclude metrics-server traffic
httpMetrics->>httpRequestDuration: Observe method, route, status, and duration
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly and concisely describes the main change: adding HTTP request duration metrics for the web server.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch brendan/web-http-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>
Comment threadpackages/web/src/httpMetrics.ts
Truncating path depth bounded depth, not breadth. The first path segment is
client-supplied and /api/[...slug] is a catch-all, so /wp-admin, /.env, and
/api/<anything> each minted a new time series. Scanner traffic could grow the
series count without limit, which is exactly what the normalization was
supposed to prevent.
Match the truncated path against a known-route set and report anything else as
`other`, bounding distinct route labels to that set plus one regardless of what
is requested. A route missing from the set loses granularity rather than
breaking, so it fails closed.
Also strengthens the metrics-port exclusion assertion. It checked for the
absence of a `/metrics` label, which became vacuous once unknown paths collapse
to `other` — it now asserts the total observation count, and fails if the port
filter is removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot 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/httpMetrics.integration.test.ts`:
- Around line 38-42: Update the integration test setup around startHttpMetrics
so process.env.WEB_METRICS_PORT is assigned before the httpMetrics module is
imported, ensuring createEnv(options) reads the ephemeral metrics port during
module initialization. Replace the static import with a deferred import after
the environment assignment and invoke startHttpMetrics from that loaded module.
🪄 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: 7cb3db6d-2e81-48e4-a016-a97834000cd9

📥 Commits

Reviewing files that changed from the base of the PR and between 84a75e4 and 9e595eb.

📒 Files selected for processing (6)
  • CHANGELOG.md
  • packages/web/src/httpMetrics.integration.test.ts
  • packages/web/src/httpMetrics.test.ts
  • packages/web/src/httpMetrics.ts
  • packages/web/src/instrumentation.ts
  • packages/web/src/promClient.ts

Comment threadpackages/web/src/httpMetrics.integration.test.ts
…hardcoded list
The hardcoded KNOWN_ROUTES set bounded cardinality but rots: new routes
silently degrade to `other` until someone updates the file, and two-segment
truncation collapses distinct routes (everything under /api/ee/* became one
label).
Next's build already emits .next/routes-manifest.json with every defined
route and a matching regex, ordered by the router's own resolution priority.
Load that at startup: exact-match static routes, then first dynamic regex
wins, mirroring how the server actually routes the request. Labels become the
route pattern itself (/browse/[...path], /settings/connections/[id]), so
cardinality is bounded by the number of defined routes plus /_next and
`other`, and the label set tracks the app automatically at build time.
Scanner traffic now lands on the catch-all routes that genuinely serve it
(/[...slug], /api/[...slug]) rather than a synthetic bucket. If the manifest
is missing or unreadable, everything is labelled `other` — granularity lost,
bound kept.
Verified against the production build's manifest (78 static + 18 dynamic
routes) and the running pod: the standalone server chdirs to the app dir, so
the cwd-relative manifest path resolves.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitaicoderabbitaiBot 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/httpMetrics.ts`:
- Around line 59-63: Update the catch block in initRouteTable to assign
routeTable = undefined before returning false, ensuring initialization failures
discard any previously loaded route table and subsequent requests use the
OTHER_ROUTE fallback.
🪄 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: c5daa8f0-6a9f-4a10-b470-2185ac676f2c

📥 Commits

Reviewing files that changed from the base of the PR and between 9e595eb and 829ecb3.

📒 Files selected for processing (3)
  • packages/web/src/httpMetrics.integration.test.ts
  • packages/web/src/httpMetrics.test.ts
  • packages/web/src/httpMetrics.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/web/src/httpMetrics.test.ts

Comment threadpackages/web/src/httpMetrics.ts
Comment threadpackages/web/src/httpMetrics.ts
@brendan-kellam
brendan-kellam merged commit 5e13dbb into mainAug 12, 2026
11 of 12 checks passed
@brendan-kellam
brendan-kellam deleted the brendan/web-http-metrics branch August 12, 2026 23:27
@github-actionsgithub-actionsBot mentioned this pull request Aug 12, 2026

@cursorcursorBot 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 0867a0e. Configure here.

method: request.method ?? 'UNKNOWN',
route: normalizeRoute(pathname),
status: response.statusCode,
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unbounded HTTP method label cardinality

Medium Severity

The method label uses the raw client-supplied request.method with no allowlist. Route labels were carefully bounded via the manifest to stop scanner traffic from exploding series count, but arbitrary methods remain unbounded and multiply every route×status series under hostile or unusual clients.

Fix in CursorFix in Web

Reviewed by Cursor Bugbot for commit 0867a0e. Configure here.

Sign up for freeto 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

@brendan-kellam