From 85891b5d9749282118605ba3667b4b1dfa807a75 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 11 Aug 2026 15:28:51 -0700 Subject: [PATCH 1/7] feat(web): record HTTP request duration metrics 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) --- .../web/src/httpMetrics.integration.test.ts | 65 ++++++++++++ packages/web/src/httpMetrics.test.ts | 51 ++++++++++ packages/web/src/httpMetrics.ts | 98 +++++++++++++++++++ packages/web/src/instrumentation.ts | 3 + packages/web/src/promClient.ts | 10 +- 5 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/httpMetrics.integration.test.ts create mode 100644 packages/web/src/httpMetrics.test.ts create mode 100644 packages/web/src/httpMetrics.ts diff --git a/packages/web/src/httpMetrics.integration.test.ts b/packages/web/src/httpMetrics.integration.test.ts new file mode 100644 index 000000000..ee8b5e422 --- /dev/null +++ b/packages/web/src/httpMetrics.integration.test.ts @@ -0,0 +1,65 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import { createServer, Server } from 'node:http'; +import { startHttpMetrics } from './httpMetrics'; +import { registry } from './promClient'; + +const app = createServer((_req, res) => { + res.writeHead(200); + res.end('ok'); +}); + +// Stands in for the real metrics server: requests to it must not be recorded, +// since the diagnostics channels are process-wide and would otherwise make +// every scrape observe itself. +const metricsServer = createServer((_req, res) => { + res.writeHead(200); + res.end('# metrics'); +}); + +const listen = (server: Server): Promise => { + return new Promise(resolve => { + server.listen(0, () => resolve((server.address() as { port: number }).port)); + }); +}; + +afterAll(() => { + app.close(); + metricsServer.close(); +}); + +const countLines = (output: string): string[] => { + return output.split('\n').filter(line => line.startsWith('http_request_duration_seconds_count')); +}; + +describe('httpMetrics', () => { + it('records durations per normalized route and ignores the metrics port', async () => { + // Both servers take an ephemeral port, then the metrics port is published + // to env before subscribing, so the test never depends on a fixed port. + const metricsPort = await listen(metricsServer); + const appPort = await listen(app); + process.env.WEB_METRICS_PORT = String(metricsPort); + + startHttpMetrics(); + + await fetch(`http://127.0.0.1:${appPort}/api/health`); + await fetch(`http://127.0.0.1:${appPort}/browse/github.com/a/b/-/blob/x.ts`); + await fetch(`http://127.0.0.1:${appPort}/browse/github.com/c/d/-/blob/y.ts`); + await fetch(`http://127.0.0.1:${metricsPort}/metrics`); + + // The finish channel fires after the response is flushed to the client. + await new Promise(resolve => setTimeout(resolve, 100)); + + const counts = countLines(await registry.metrics()); + + expect(counts.some(line => line.includes('route="/api/health"'))).toBe(true); + expect(counts.some(line => line.includes('status="200"'))).toBe(true); + + // Two distinct file paths must collapse to a single /browse series. + const browse = counts.filter(line => line.includes('route="/browse"')); + expect(browse).toHaveLength(1); + expect(browse[0].trim().endsWith('2')).toBe(true); + + // The scrape of the metrics port must not appear at all. + expect(counts.some(line => line.includes('route="/metrics"'))).toBe(false); + }); +}); diff --git a/packages/web/src/httpMetrics.test.ts b/packages/web/src/httpMetrics.test.ts new file mode 100644 index 000000000..1555c3123 --- /dev/null +++ b/packages/web/src/httpMetrics.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeRoute } from './httpMetrics'; + +describe('normalizeRoute', () => { + it('maps the root path', () => { + expect(normalizeRoute('/')).toBe('/'); + expect(normalizeRoute('')).toBe('/'); + }); + + it('keeps two segments for API routes', () => { + expect(normalizeRoute('/api/health')).toBe('/api/health'); + expect(normalizeRoute('/api/commits')).toBe('/api/commits'); + expect(normalizeRoute('/api/auth/callback/github')).toBe('/api/auth'); + }); + + it('keeps one segment for page routes', () => { + expect(normalizeRoute('/search')).toBe('/search'); + expect(normalizeRoute('/settings/connections/42')).toBe('/settings'); + }); + + it('bounds unbounded repository and file paths', () => { + const a = normalizeRoute('/browse/github.com/org/repo/-/blob/src/index.ts'); + const b = normalizeRoute('/browse/github.com/other/repo/-/blob/lib/other.ts'); + + expect(a).toBe('/browse'); + expect(b).toBe('/browse'); + // The point of normalizing: distinct files must not mint distinct labels. + expect(a).toBe(b); + }); + + it('is unaffected by trailing or duplicate slashes', () => { + expect(normalizeRoute('/search/')).toBe('/search'); + expect(normalizeRoute('//search//')).toBe('/search'); + }); + + it('produces a bounded label set for a realistic path mix', () => { + const paths = [ + '/', '/search', '/search?q=foo'.split('?')[0], '/repos', '/settings/general', + '/browse/github.com/a/b/-/blob/x.ts', '/browse/github.com/c/d/-/blob/y.ts', + '/api/health', '/api/health', '/api/commits', '/api/auth/session', + '/_next/static/chunks/main.js', '/_next/static/css/app.css', + ]; + + const labels = new Set(paths.map(normalizeRoute)); + + expect(labels).toEqual(new Set([ + '/', '/search', '/repos', '/settings', '/browse', + '/api/health', '/api/commits', '/api/auth', '/_next', + ])); + }); +}); diff --git a/packages/web/src/httpMetrics.ts b/packages/web/src/httpMetrics.ts new file mode 100644 index 000000000..b0f104c6c --- /dev/null +++ b/packages/web/src/httpMetrics.ts @@ -0,0 +1,98 @@ +import { createLogger, env } from '@sourcebot/shared'; +import { subscribe } from 'node:diagnostics_channel'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { httpRequestDuration } from './promClient'; + +const logger = createLogger('web-http-metrics'); + +/** + * Collapses a request path into a bounded label. + * + * The full path can't be used: repository and file paths are unbounded, so + * `/browse/github.com/org/repo/-/blob/src/index.ts` would mint a new time + * series for every file anyone views. API paths keep two segments so + * `/api/health` stays distinct from `/api/commits`; everything else keeps one. + * That bounds the label to roughly the number of API routes plus top-level + * pages. + */ +export const normalizeRoute = (pathname: string): string => { + const segments = pathname.split('/').filter(segment => segment.length > 0); + if (segments.length === 0) { + return '/'; + } + + const depth = segments[0] === 'api' ? 2 : 1; + return `/${segments.slice(0, depth).join('/')}`; +}; + +interface RequestStartMessage { + response?: ServerResponse; + socket?: { localPort?: number }; +} + +interface ResponseFinishMessage { + request?: IncomingMessage; + response?: ServerResponse; +} + +const startTimes = new WeakMap(); +let subscribed = false; + +/** + * Records request durations by subscribing to Node's built-in HTTP diagnostics + * channels. Next.js owns the server instance in a standalone build, so there's + * no request pipeline to wrap; these channels observe every request without + * patching anything. + * + * Requests to the metrics port are skipped — the channels are process-wide, so + * without that filter every scrape would record itself. + */ +export const startHttpMetrics = (): void => { + if (subscribed) { + return; + } + subscribed = true; + + const metricsPort = Number(env.WEB_METRICS_PORT); + + subscribe('http.server.request.start', (message) => { + try { + const { response, socket } = message as RequestStartMessage; + if (!response || socket?.localPort === metricsPort) { + return; + } + startTimes.set(response, performance.now()); + } catch (error) { + logger.debug(`Failed to record request start: ${error}`); + } + }); + + subscribe('http.server.response.finish', (message) => { + try { + const { request, response } = message as ResponseFinishMessage; + if (!request || !response) { + return; + } + + const startedAt = startTimes.get(response); + if (startedAt === undefined) { + return; + } + startTimes.delete(response); + + const pathname = (request.url ?? '/').split('?')[0]; + httpRequestDuration.observe( + { + method: request.method ?? 'UNKNOWN', + route: normalizeRoute(pathname), + status: response.statusCode, + }, + (performance.now() - startedAt) / 1000, + ); + } catch (error) { + logger.debug(`Failed to record request duration: ${error}`); + } + }); + + logger.info('HTTP request duration metrics enabled.'); +}; diff --git a/packages/web/src/instrumentation.ts b/packages/web/src/instrumentation.ts index e64926611..2af3ddcef 100644 --- a/packages/web/src/instrumentation.ts +++ b/packages/web/src/instrumentation.ts @@ -12,6 +12,9 @@ export async function register() { if (process.env.NEXT_RUNTIME === 'nodejs') { const { startMetricsServer } = await import('./metricsServer'); startMetricsServer(); + + const { startHttpMetrics } = await import('./httpMetrics'); + startHttpMetrics(); } if (process.env.NEXT_RUNTIME === 'nodejs') { diff --git a/packages/web/src/promClient.ts b/packages/web/src/promClient.ts index 294159d31..81d63cbe4 100644 --- a/packages/web/src/promClient.ts +++ b/packages/web/src/promClient.ts @@ -1,8 +1,16 @@ -import client, { Gauge, Registry } from 'prom-client'; +import client, { Gauge, Histogram, Registry } from 'prom-client'; import { getHeapStatistics } from 'node:v8'; export const registry = new Registry(); +export const httpRequestDuration = new Histogram({ + name: 'http_request_duration_seconds', + help: 'Duration of HTTP requests handled by the web server, in seconds', + labelNames: ['method', 'route', 'status'], + buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], +}); +registry.registerMetric(httpRequestDuration); + // `collectDefaultMetrics` reports heap usage but not the ceiling it's measured // against, and usage alone can't distinguish "busy" from "out of room". Without // the limit there's no way to tell whether V8 is doing cheap incremental From a0e55b2e2a9fad13637385f4086371c32f988b1c Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 11 Aug 2026 15:29:43 -0700 Subject: [PATCH 2/7] docs: add changelog entry for HTTP request duration metrics Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7b8a4186..b969ca883 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added a manually triggered cloud image release workflow for isolated internal deployments. [#1566](https://github.com/sourcebot-dev/sourcebot/pull/1566) - Added Prometheus metrics for the web process, served on `WEB_METRICS_PORT` (default `3070`). [#1570](https://github.com/sourcebot-dev/sourcebot/pull/1570) +- Added an `http_request_duration_seconds` metric recording web request latency by route, method, and status. [#1571](https://github.com/sourcebot-dev/sourcebot/pull/1571) ### Fixed - Fixed the web process being capped at a ~4GiB heap regardless of how much memory the container has, which caused multi-second garbage collection pauses on larger deployments. [#1569](https://github.com/sourcebot-dev/sourcebot/pull/1569) From 9e595eb1a018772e7291c1807eec1caa09467396 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 11 Aug 2026 16:21:44 -0700 Subject: [PATCH 3/7] fix(web): bound route label cardinality with an allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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) --- .../web/src/httpMetrics.integration.test.ts | 9 ++- packages/web/src/httpMetrics.test.ts | 66 +++++++++++----- packages/web/src/httpMetrics.ts | 78 +++++++++++++++++-- 3 files changed, 125 insertions(+), 28 deletions(-) diff --git a/packages/web/src/httpMetrics.integration.test.ts b/packages/web/src/httpMetrics.integration.test.ts index ee8b5e422..fb906c5d7 100644 --- a/packages/web/src/httpMetrics.integration.test.ts +++ b/packages/web/src/httpMetrics.integration.test.ts @@ -59,7 +59,12 @@ describe('httpMetrics', () => { expect(browse).toHaveLength(1); expect(browse[0].trim().endsWith('2')).toBe(true); - // The scrape of the metrics port must not appear at all. - expect(counts.some(line => line.includes('route="/metrics"'))).toBe(false); + // The scrape of the metrics port must not be recorded. Asserted on the + // total observation count rather than on the absence of a `/metrics` + // label: `/metrics` is not a known route, so it would land in `other` + // and an absent-label check would pass even with the filter removed. + const total = counts.reduce((sum, line) => sum + Number(line.trim().split(' ').pop()), 0); + expect(total).toBe(3); + expect(counts.some(line => line.includes('route="other"'))).toBe(false); }); }); diff --git a/packages/web/src/httpMetrics.test.ts b/packages/web/src/httpMetrics.test.ts index 1555c3123..1b4e814f9 100644 --- a/packages/web/src/httpMetrics.test.ts +++ b/packages/web/src/httpMetrics.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { normalizeRoute } from './httpMetrics'; +import { MAX_ROUTE_LABELS, normalizeRoute } from './httpMetrics'; describe('normalizeRoute', () => { it('maps the root path', () => { @@ -7,25 +7,23 @@ describe('normalizeRoute', () => { expect(normalizeRoute('')).toBe('/'); }); - it('keeps two segments for API routes', () => { + it('keeps two segments for known API routes', () => { expect(normalizeRoute('/api/health')).toBe('/api/health'); expect(normalizeRoute('/api/commits')).toBe('/api/commits'); expect(normalizeRoute('/api/auth/callback/github')).toBe('/api/auth'); }); - it('keeps one segment for page routes', () => { + it('keeps one segment for known page routes', () => { expect(normalizeRoute('/search')).toBe('/search'); expect(normalizeRoute('/settings/connections/42')).toBe('/settings'); }); - it('bounds unbounded repository and file paths', () => { + it('collapses unbounded repository and file paths', () => { const a = normalizeRoute('/browse/github.com/org/repo/-/blob/src/index.ts'); const b = normalizeRoute('/browse/github.com/other/repo/-/blob/lib/other.ts'); expect(a).toBe('/browse'); - expect(b).toBe('/browse'); - // The point of normalizing: distinct files must not mint distinct labels. - expect(a).toBe(b); + expect(b).toBe(a); }); it('is unaffected by trailing or duplicate slashes', () => { @@ -33,19 +31,49 @@ describe('normalizeRoute', () => { expect(normalizeRoute('//search//')).toBe('/search'); }); - it('produces a bounded label set for a realistic path mix', () => { - const paths = [ - '/', '/search', '/search?q=foo'.split('?')[0], '/repos', '/settings/general', - '/browse/github.com/a/b/-/blob/x.ts', '/browse/github.com/c/d/-/blob/y.ts', - '/api/health', '/api/health', '/api/commits', '/api/auth/session', - '/_next/static/chunks/main.js', '/_next/static/css/app.css', - ]; + describe('cardinality bounding', () => { + it('reports unknown top-level paths as other', () => { + expect(normalizeRoute('/wp-admin')).toBe('other'); + expect(normalizeRoute('/.env')).toBe('other'); + expect(normalizeRoute('/phpmyadmin/index.php')).toBe('other'); + }); - const labels = new Set(paths.map(normalizeRoute)); + it('reports unknown API paths as other, despite the [...slug] catch-all', () => { + expect(normalizeRoute('/api/not-a-real-route')).toBe('other'); + expect(normalizeRoute('/api/12345')).toBe('other'); + expect(normalizeRoute('/api/health-check')).toBe('other'); + }); - expect(labels).toEqual(new Set([ - '/', '/search', '/repos', '/settings', '/browse', - '/api/health', '/api/commits', '/api/auth', '/_next', - ])); + it('stays bounded under scanner traffic', () => { + const hostile: string[] = []; + for (let i = 0; i < 1000; i++) { + hostile.push(`/scan-${i}`); + hostile.push(`/api/scan-${i}`); + hostile.push(`/${i}/${i}/${i}`); + } + + const labels = new Set(hostile.map(normalizeRoute)); + + // 3000 distinct hostile paths must produce exactly one label. + expect(labels).toEqual(new Set(['other'])); + }); + + it('never exceeds the documented label bound for any input', () => { + const paths = [ + '/', '/search', '/repos', '/settings/general', '/browse/a/b/c', + '/api/health', '/api/commits', '/api/auth/session', '/_next/static/x.js', + '/wp-admin', '/api/bogus', '/random', '/api/9', '/..%2f', '/a/b/c/d/e', + ]; + for (let i = 0; i < 500; i++) { + paths.push(`/junk${i}`, `/api/junk${i}`); + } + + const labels = new Set(paths.map(normalizeRoute)); + + expect(labels.size).toBeLessThanOrEqual(MAX_ROUTE_LABELS); + // Known routes still resolve; only the unknown ones collapse. + expect(labels).toContain('/api/health'); + expect(labels).toContain('other'); + }); }); }); diff --git a/packages/web/src/httpMetrics.ts b/packages/web/src/httpMetrics.ts index b0f104c6c..7534d2849 100644 --- a/packages/web/src/httpMetrics.ts +++ b/packages/web/src/httpMetrics.ts @@ -5,15 +5,74 @@ import { httpRequestDuration } from './promClient'; const logger = createLogger('web-http-metrics'); +/** + * Every path that isn't in this set is reported as `other`. + * + * Truncating path depth alone does not bound cardinality: the first segment is + * client-supplied, and `/api/[...slug]` is a catch-all, so `/wp-admin`, + * `/.env`, and `/api/` would each mint a new time series. Scanner or + * bot traffic would then grow the series count without limit. Matching against + * a known set instead bounds it to this size plus one, whatever gets requested. + * + * Adding a route here is deliberate. A missing one is reported as `other`, so + * new routes lose granularity rather than breaking, and cardinality holds. + */ +const KNOWN_ROUTES = new Set([ + '/', + '/_next', + '/askgh', + '/browse', + '/chat', + '/chats', + '/invite', + '/login', + '/oauth', + '/onboard', + '/redeem', + '/repos', + '/search', + '/settings', + '/signup', + '/slow', + '/api/auth', + '/api/avatar', + '/api/blame', + '/api/changelog', + '/api/chat', + '/api/commit', + '/api/commits', + '/api/diff', + '/api/ee', + '/api/files', + '/api/find_definitions', + '/api/find_references', + '/api/folder_contents', + '/api/health', + '/api/minidenticon', + '/api/models', + '/api/offers', + '/api/openapi.json', + '/api/repo-status', + '/api/repos', + '/api/search', + '/api/source', + '/api/stream_search', + '/api/symbols', + '/api/tree', + '/api/version', + '/api/webhook', +]); + +const OTHER_ROUTE = 'other'; + /** * Collapses a request path into a bounded label. * - * The full path can't be used: repository and file paths are unbounded, so - * `/browse/github.com/org/repo/-/blob/src/index.ts` would mint a new time - * series for every file anyone views. API paths keep two segments so - * `/api/health` stays distinct from `/api/commits`; everything else keeps one. - * That bounds the label to roughly the number of API routes plus top-level - * pages. + * Depth is truncated first, because repository and file paths are unbounded and + * `/browse/github.com/org/repo/-/blob/src/index.ts` must not mint a series per + * file viewed. API paths keep two segments so `/api/health` stays distinct from + * `/api/commits`; everything else keeps one. The result is then matched against + * `KNOWN_ROUTES`, which is what actually bounds the label set. */ export const normalizeRoute = (pathname: string): string => { const segments = pathname.split('/').filter(segment => segment.length > 0); @@ -22,9 +81,14 @@ export const normalizeRoute = (pathname: string): string => { } const depth = segments[0] === 'api' ? 2 : 1; - return `/${segments.slice(0, depth).join('/')}`; + const candidate = `/${segments.slice(0, depth).join('/')}`; + + return KNOWN_ROUTES.has(candidate) ? candidate : OTHER_ROUTE; }; +/** Upper bound on distinct `route` label values, for tests and review. */ +export const MAX_ROUTE_LABELS = KNOWN_ROUTES.size + 1; + interface RequestStartMessage { response?: ServerResponse; socket?: { localPort?: number }; From 829ecb3b2463043f727dc7d349924fb28c535f05 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 12 Aug 2026 12:30:38 -0700 Subject: [PATCH 4/7] refactor(web): derive route labels from routes-manifest instead of a hardcoded list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../web/src/httpMetrics.integration.test.ts | 15 +- packages/web/src/httpMetrics.test.ts | 113 +++++++++---- packages/web/src/httpMetrics.ts | 155 ++++++++++-------- 3 files changed, 175 insertions(+), 108 deletions(-) diff --git a/packages/web/src/httpMetrics.integration.test.ts b/packages/web/src/httpMetrics.integration.test.ts index fb906c5d7..ae5517f18 100644 --- a/packages/web/src/httpMetrics.integration.test.ts +++ b/packages/web/src/httpMetrics.integration.test.ts @@ -1,6 +1,6 @@ import { afterAll, describe, expect, it } from 'vitest'; import { createServer, Server } from 'node:http'; -import { startHttpMetrics } from './httpMetrics'; +import { initRouteTable, startHttpMetrics } from './httpMetrics'; import { registry } from './promClient'; const app = createServer((_req, res) => { @@ -32,7 +32,14 @@ const countLines = (output: string): string[] => { }; describe('httpMetrics', () => { - it('records durations per normalized route and ignores the metrics port', async () => { + it('records durations per route pattern and ignores the metrics port', async () => { + // The table is injected rather than read from disk so the test doesn't + // depend on a prior `next build` having produced routes-manifest.json. + initRouteTable({ + staticRoutes: [{ page: '/api/health' }], + dynamicRoutes: [{ page: '/browse/[...path]', regex: '^/browse/(.+?)(?:/)?$' }], + }); + // Both servers take an ephemeral port, then the metrics port is published // to env before subscribing, so the test never depends on a fixed port. const metricsPort = await listen(metricsServer); @@ -54,8 +61,8 @@ describe('httpMetrics', () => { expect(counts.some(line => line.includes('route="/api/health"'))).toBe(true); expect(counts.some(line => line.includes('status="200"'))).toBe(true); - // Two distinct file paths must collapse to a single /browse series. - const browse = counts.filter(line => line.includes('route="/browse"')); + // Two distinct file paths must collapse to the single route-pattern series. + const browse = counts.filter(line => line.includes('route="/browse/[...path]"')); expect(browse).toHaveLength(1); expect(browse[0].trim().endsWith('2')).toBe(true); diff --git a/packages/web/src/httpMetrics.test.ts b/packages/web/src/httpMetrics.test.ts index 1b4e814f9..6d15abec4 100644 --- a/packages/web/src/httpMetrics.test.ts +++ b/packages/web/src/httpMetrics.test.ts @@ -1,47 +1,92 @@ import { describe, expect, it } from 'vitest'; -import { MAX_ROUTE_LABELS, normalizeRoute } from './httpMetrics'; +import { buildRouteTable, normalizeRoute } from './httpMetrics'; + +// Mirrors the shape and ordering of the real .next/routes-manifest.json: +// dynamic routes are listed in Next's resolution priority, with catch-alls +// after specific routes and the root catch-all last. +const table = buildRouteTable({ + staticRoutes: [ + { page: '/' }, + { page: '/search' }, + { page: '/repos' }, + { page: '/api/health' }, + { page: '/api/commits' }, + ], + dynamicRoutes: [ + { page: '/api/auth/[...nextauth]', regex: '^/api/auth/(.+?)(?:/)?$' }, + { page: '/api/repos/[repoId]/image', regex: '^/api/repos/([^/]+?)/image(?:/)?$' }, + { page: '/api/[...slug]', regex: '^/api/(.+?)(?:/)?$' }, + { page: '/browse/[...path]', regex: '^/browse/(.+?)(?:/)?$' }, + { page: '/settings/[...slug]', regex: '^/settings/(.+?)(?:/)?$' }, + { page: '/[...slug]', regex: '^/(.+?)(?:/)?$' }, + ], +}); + +const maxLabels = table.staticPages.size + table.dynamicRoutes.length + 2; // + '/_next', 'other' describe('normalizeRoute', () => { it('maps the root path', () => { - expect(normalizeRoute('/')).toBe('/'); - expect(normalizeRoute('')).toBe('/'); + expect(normalizeRoute('/', table)).toBe('/'); + expect(normalizeRoute('', table)).toBe('/'); + }); + + it('matches static routes exactly', () => { + expect(normalizeRoute('/api/health', table)).toBe('/api/health'); + expect(normalizeRoute('/search', table)).toBe('/search'); }); - it('keeps two segments for known API routes', () => { - expect(normalizeRoute('/api/health')).toBe('/api/health'); - expect(normalizeRoute('/api/commits')).toBe('/api/commits'); - expect(normalizeRoute('/api/auth/callback/github')).toBe('/api/auth'); + it('labels dynamic routes with their route pattern', () => { + expect(normalizeRoute('/api/auth/callback/github', table)).toBe('/api/auth/[...nextauth]'); + expect(normalizeRoute('/api/repos/42/image', table)).toBe('/api/repos/[repoId]/image'); + expect(normalizeRoute('/settings/connections/42', table)).toBe('/settings/[...slug]'); }); - it('keeps one segment for known page routes', () => { - expect(normalizeRoute('/search')).toBe('/search'); - expect(normalizeRoute('/settings/connections/42')).toBe('/settings'); + it('respects manifest ordering: specific routes win over catch-alls', () => { + // /api/auth/... must hit [...nextauth], not the /api/[...slug] catch-all. + expect(normalizeRoute('/api/auth/session', table)).toBe('/api/auth/[...nextauth]'); + // Unknown API paths fall through to the catch-all that actually serves them. + expect(normalizeRoute('/api/not-a-real-route', table)).toBe('/api/[...slug]'); }); - it('collapses unbounded repository and file paths', () => { - const a = normalizeRoute('/browse/github.com/org/repo/-/blob/src/index.ts'); - const b = normalizeRoute('/browse/github.com/other/repo/-/blob/lib/other.ts'); + it('collapses unbounded repository and file paths to one label', () => { + const a = normalizeRoute('/browse/github.com/org/repo/-/blob/src/index.ts', table); + const b = normalizeRoute('/browse/github.com/other/repo/-/blob/lib/other.ts', table); - expect(a).toBe('/browse'); + expect(a).toBe('/browse/[...path]'); expect(b).toBe(a); }); it('is unaffected by trailing or duplicate slashes', () => { - expect(normalizeRoute('/search/')).toBe('/search'); - expect(normalizeRoute('//search//')).toBe('/search'); + expect(normalizeRoute('/search/', table)).toBe('/search'); + expect(normalizeRoute('//search//', table)).toBe('/search'); + expect(normalizeRoute('/api/health/', table)).toBe('/api/health'); + }); + + it('labels asset requests /_next without consulting the table', () => { + expect(normalizeRoute('/_next/static/chunks/main.js', table)).toBe('/_next'); + expect(normalizeRoute('/_next/image', undefined)).toBe('/_next'); + }); + + it('reports everything as other when no table is loaded', () => { + expect(normalizeRoute('/api/health', undefined)).toBe('other'); + expect(normalizeRoute('/search', undefined)).toBe('other'); }); describe('cardinality bounding', () => { - it('reports unknown top-level paths as other', () => { - expect(normalizeRoute('/wp-admin')).toBe('other'); - expect(normalizeRoute('/.env')).toBe('other'); - expect(normalizeRoute('/phpmyadmin/index.php')).toBe('other'); + it('routes scanner traffic to catch-alls, not new labels', () => { + expect(normalizeRoute('/wp-admin', table)).toBe('/[...slug]'); + expect(normalizeRoute('/.env', table)).toBe('/[...slug]'); + expect(normalizeRoute('/api/12345', table)).toBe('/api/[...slug]'); }); - it('reports unknown API paths as other, despite the [...slug] catch-all', () => { - expect(normalizeRoute('/api/not-a-real-route')).toBe('other'); - expect(normalizeRoute('/api/12345')).toBe('other'); - expect(normalizeRoute('/api/health-check')).toBe('other'); + it('reports unmatched paths as other when there is no root catch-all', () => { + const noCatchAll = buildRouteTable({ + staticRoutes: [{ page: '/search' }], + dynamicRoutes: [{ page: '/api/[...slug]', regex: '^/api/(.+?)(?:/)?$' }], + }); + + expect(normalizeRoute('/wp-admin', noCatchAll)).toBe('other'); + expect(normalizeRoute('/api/anything', noCatchAll)).toBe('/api/[...slug]'); }); it('stays bounded under scanner traffic', () => { @@ -52,28 +97,26 @@ describe('normalizeRoute', () => { hostile.push(`/${i}/${i}/${i}`); } - const labels = new Set(hostile.map(normalizeRoute)); + const labels = new Set(hostile.map(p => normalizeRoute(p, table))); - // 3000 distinct hostile paths must produce exactly one label. - expect(labels).toEqual(new Set(['other'])); + // 3,000 distinct hostile paths produce exactly the two catch-all labels. + expect(labels).toEqual(new Set(['/[...slug]', '/api/[...slug]'])); }); - it('never exceeds the documented label bound for any input', () => { + it('never exceeds the table-derived bound for any input', () => { const paths = [ - '/', '/search', '/repos', '/settings/general', '/browse/a/b/c', - '/api/health', '/api/commits', '/api/auth/session', '/_next/static/x.js', - '/wp-admin', '/api/bogus', '/random', '/api/9', '/..%2f', '/a/b/c/d/e', + '/', '/search', '/repos', '/browse/a/b/c', '/api/health', + '/api/commits', '/api/auth/session', '/_next/static/x.js', + '/wp-admin', '/api/bogus', '/random', '/..%2f', '/a/b/c/d/e', ]; for (let i = 0; i < 500; i++) { paths.push(`/junk${i}`, `/api/junk${i}`); } - const labels = new Set(paths.map(normalizeRoute)); + const labels = new Set(paths.map(p => normalizeRoute(p, table))); - expect(labels.size).toBeLessThanOrEqual(MAX_ROUTE_LABELS); - // Known routes still resolve; only the unknown ones collapse. + expect(labels.size).toBeLessThanOrEqual(maxLabels); expect(labels).toContain('/api/health'); - expect(labels).toContain('other'); }); }); }); diff --git a/packages/web/src/httpMetrics.ts b/packages/web/src/httpMetrics.ts index 7534d2849..6051480d1 100644 --- a/packages/web/src/httpMetrics.ts +++ b/packages/web/src/httpMetrics.ts @@ -1,93 +1,106 @@ import { createLogger, env } from '@sourcebot/shared'; import { subscribe } from 'node:diagnostics_channel'; +import { readFileSync } from 'node:fs'; import type { IncomingMessage, ServerResponse } from 'node:http'; +import path from 'node:path'; import { httpRequestDuration } from './promClient'; const logger = createLogger('web-http-metrics'); +interface RoutesManifest { + staticRoutes: { page: string }[]; + dynamicRoutes: { page: string; regex: string }[]; +} + +interface RouteTable { + staticPages: Set; + dynamicRoutes: { page: string; regex: RegExp }[]; +} + +const OTHER_ROUTE = 'other'; + /** - * Every path that isn't in this set is reported as `other`. - * - * Truncating path depth alone does not bound cardinality: the first segment is - * client-supplied, and `/api/[...slug]` is a catch-all, so `/wp-admin`, - * `/.env`, and `/api/` would each mint a new time series. Scanner or - * bot traffic would then grow the series count without limit. Matching against - * a known set instead bounds it to this size plus one, whatever gets requested. - * - * Adding a route here is deliberate. A missing one is reported as `other`, so - * new routes lose granularity rather than breaking, and cardinality holds. + * Builds a route matcher from Next's routes-manifest. The manifest lists every + * defined route with a matching regex, ordered by Next's own resolution + * priority (specific routes before catch-alls), so first-match-wins here + * agrees with how the server actually routes the request. */ -const KNOWN_ROUTES = new Set([ - '/', - '/_next', - '/askgh', - '/browse', - '/chat', - '/chats', - '/invite', - '/login', - '/oauth', - '/onboard', - '/redeem', - '/repos', - '/search', - '/settings', - '/signup', - '/slow', - '/api/auth', - '/api/avatar', - '/api/blame', - '/api/changelog', - '/api/chat', - '/api/commit', - '/api/commits', - '/api/diff', - '/api/ee', - '/api/files', - '/api/find_definitions', - '/api/find_references', - '/api/folder_contents', - '/api/health', - '/api/minidenticon', - '/api/models', - '/api/offers', - '/api/openapi.json', - '/api/repo-status', - '/api/repos', - '/api/search', - '/api/source', - '/api/stream_search', - '/api/symbols', - '/api/tree', - '/api/version', - '/api/webhook', -]); +export const buildRouteTable = (manifest: RoutesManifest): RouteTable => { + return { + staticPages: new Set(manifest.staticRoutes.map(route => route.page)), + dynamicRoutes: manifest.dynamicRoutes.map(route => ({ + page: route.page, + regex: new RegExp(route.regex), + })), + }; +}; -const OTHER_ROUTE = 'other'; +let routeTable: RouteTable | undefined; /** - * Collapses a request path into a bounded label. + * Loads the route table from the build's routes-manifest. Next's standalone + * server chdirs to the app directory on boot, so the manifest sits at + * `.next/routes-manifest.json` relative to cwd. * - * Depth is truncated first, because repository and file paths are unbounded and - * `/browse/github.com/org/repo/-/blob/src/index.ts` must not mint a series per - * file viewed. API paths keep two segments so `/api/health` stays distinct from - * `/api/commits`; everything else keeps one. The result is then matched against - * `KNOWN_ROUTES`, which is what actually bounds the label set. + * Deriving routes from the manifest (rather than a hardcoded list) keeps the + * label set in sync with the app automatically: new routes appear at build + * time, and the label is the route pattern itself (`/browse/[...path]`), so + * cardinality is bounded by the number of defined routes no matter what gets + * requested. */ -export const normalizeRoute = (pathname: string): string => { +export const initRouteTable = (manifest?: RoutesManifest): boolean => { + try { + const resolved = manifest ?? (JSON.parse( + readFileSync(path.join(process.cwd(), '.next', 'routes-manifest.json'), 'utf-8'), + ) as RoutesManifest); + routeTable = buildRouteTable(resolved); + logger.info(`Route table loaded: ${routeTable.staticPages.size} static, ${routeTable.dynamicRoutes.length} dynamic routes.`); + return true; + } catch (error) { + // Fail closed: without a table every request is labelled `other`, which + // loses granularity but can never grow the label set. + logger.error(`Failed to load routes-manifest; all routes will be reported as '${OTHER_ROUTE}': ${error}`); + return false; + } +}; + +/** + * Maps a request path to its route pattern. The raw path can't be used as a + * label: repository and file paths are unbounded, so `/browse/...` would mint + * a new time series for every file anyone views, and unknown paths (scanners, + * bots) would grow the set without limit. Matching against the app's own + * routes bounds the label set to the number of defined routes plus `/_next` + * and `other`. + */ +export const normalizeRoute = (pathname: string, table: RouteTable | undefined = routeTable): string => { const segments = pathname.split('/').filter(segment => segment.length > 0); if (segments.length === 0) { return '/'; } - const depth = segments[0] === 'api' ? 2 : 1; - const candidate = `/${segments.slice(0, depth).join('/')}`; + // Asset requests are real traffic but not manifest routes. + if (segments[0] === '_next') { + return '/_next'; + } - return KNOWN_ROUTES.has(candidate) ? candidate : OTHER_ROUTE; -}; + if (!table) { + return OTHER_ROUTE; + } -/** Upper bound on distinct `route` label values, for tests and review. */ -export const MAX_ROUTE_LABELS = KNOWN_ROUTES.size + 1; + const canonical = `/${segments.join('/')}`; + + if (table.staticPages.has(canonical)) { + return canonical; + } + + for (const route of table.dynamicRoutes) { + if (route.regex.test(canonical)) { + return route.page; + } + } + + return OTHER_ROUTE; +}; interface RequestStartMessage { response?: ServerResponse; @@ -117,6 +130,10 @@ export const startHttpMetrics = (): void => { } subscribed = true; + if (!routeTable) { + initRouteTable(); + } + const metricsPort = Number(env.WEB_METRICS_PORT); subscribe('http.server.request.start', (message) => { From d839501e107b01654079bf0edc45cfee1aa43e0d Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 12 Aug 2026 13:36:10 -0700 Subject: [PATCH 5/7] fix(web): extend HTTP latency histogram buckets --- packages/web/src/httpMetrics.integration.test.ts | 9 ++++++++- packages/web/src/promClient.ts | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/web/src/httpMetrics.integration.test.ts b/packages/web/src/httpMetrics.integration.test.ts index ae5517f18..2642b69bd 100644 --- a/packages/web/src/httpMetrics.integration.test.ts +++ b/packages/web/src/httpMetrics.integration.test.ts @@ -56,7 +56,8 @@ describe('httpMetrics', () => { // The finish channel fires after the response is flushed to the client. await new Promise(resolve => setTimeout(resolve, 100)); - const counts = countLines(await registry.metrics()); + const metrics = await registry.metrics(); + const counts = countLines(metrics); expect(counts.some(line => line.includes('route="/api/health"'))).toBe(true); expect(counts.some(line => line.includes('status="200"'))).toBe(true); @@ -66,6 +67,12 @@ describe('httpMetrics', () => { expect(browse).toHaveLength(1); expect(browse[0].trim().endsWith('2')).toBe(true); + // Keep enough resolution to distinguish the long-tail stalls this + // metric is intended to expose rather than collapsing them into +Inf. + for (const upperBound of [15, 20, 30, 60]) { + expect(metrics).toContain(`le="${upperBound}"`); + } + // The scrape of the metrics port must not be recorded. Asserted on the // total observation count rather than on the absence of a `/metrics` // label: `/metrics` is not a known route, so it would land in `other` diff --git a/packages/web/src/promClient.ts b/packages/web/src/promClient.ts index 81d63cbe4..a651a2a22 100644 --- a/packages/web/src/promClient.ts +++ b/packages/web/src/promClient.ts @@ -7,7 +7,7 @@ export const httpRequestDuration = new Histogram({ name: 'http_request_duration_seconds', help: 'Duration of HTTP requests handled by the web server, in seconds', labelNames: ['method', 'route', 'status'], - buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], + buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 15, 20, 30, 60], }); registry.registerMetric(httpRequestDuration); From 2a944650dd7104bc3082c9f262ed3017d2412875 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 12 Aug 2026 16:24:58 -0700 Subject: [PATCH 6/7] fix(web): label rewritten routes in HTTP metrics --- .../web/src/httpMetrics.integration.test.ts | 7 ++- packages/web/src/httpMetrics.test.ts | 53 +++++++++++++++- packages/web/src/httpMetrics.ts | 62 +++++++++++++++++-- 3 files changed, 114 insertions(+), 8 deletions(-) diff --git a/packages/web/src/httpMetrics.integration.test.ts b/packages/web/src/httpMetrics.integration.test.ts index 2642b69bd..10c7029ad 100644 --- a/packages/web/src/httpMetrics.integration.test.ts +++ b/packages/web/src/httpMetrics.integration.test.ts @@ -38,6 +38,9 @@ describe('httpMetrics', () => { initRouteTable({ staticRoutes: [{ page: '/api/health' }], dynamicRoutes: [{ page: '/browse/[...path]', regex: '^/browse/(.+?)(?:/)?$' }], + rewrites: { + afterFiles: [{ source: '/api/mcp', regex: '^/api/mcp(?:/)?$' }], + }, }); // Both servers take an ephemeral port, then the metrics port is published @@ -51,6 +54,7 @@ describe('httpMetrics', () => { await fetch(`http://127.0.0.1:${appPort}/api/health`); await fetch(`http://127.0.0.1:${appPort}/browse/github.com/a/b/-/blob/x.ts`); await fetch(`http://127.0.0.1:${appPort}/browse/github.com/c/d/-/blob/y.ts`); + await fetch(`http://127.0.0.1:${appPort}/api/mcp`); await fetch(`http://127.0.0.1:${metricsPort}/metrics`); // The finish channel fires after the response is flushed to the client. @@ -60,6 +64,7 @@ describe('httpMetrics', () => { const counts = countLines(metrics); expect(counts.some(line => line.includes('route="/api/health"'))).toBe(true); + expect(counts.some(line => line.includes('route="/api/mcp"'))).toBe(true); expect(counts.some(line => line.includes('status="200"'))).toBe(true); // Two distinct file paths must collapse to the single route-pattern series. @@ -78,7 +83,7 @@ describe('httpMetrics', () => { // label: `/metrics` is not a known route, so it would land in `other` // and an absent-label check would pass even with the filter removed. const total = counts.reduce((sum, line) => sum + Number(line.trim().split(' ').pop()), 0); - expect(total).toBe(3); + expect(total).toBe(4); expect(counts.some(line => line.includes('route="other"'))).toBe(false); }); }); diff --git a/packages/web/src/httpMetrics.test.ts b/packages/web/src/httpMetrics.test.ts index 6d15abec4..440a23b28 100644 --- a/packages/web/src/httpMetrics.test.ts +++ b/packages/web/src/httpMetrics.test.ts @@ -20,9 +20,24 @@ const table = buildRouteTable({ { page: '/settings/[...slug]', regex: '^/settings/(.+?)(?:/)?$' }, { page: '/[...slug]', regex: '^/(.+?)(?:/)?$' }, ], + rewrites: { + afterFiles: [ + { source: '/ingest/:path*', regex: '^/ingest(?:/(.+?))?(?:/)?$' }, + { source: '/.well-known/oauth-authorization-server', regex: '^/\\.well-known/oauth-authorization-server(?:/)?$' }, + { source: '/.well-known/oauth-protected-resource/:path*', regex: '^/\\.well-known/oauth-protected-resource(?:/(.+?))?(?:/)?$' }, + { source: '/register', regex: '^/register(?:/)?$' }, + { source: '/api/mcp', regex: '^/api/mcp(?:/)?$' }, + { source: '/scim/v2/:path*', regex: '^/scim/v2(?:/(.+?))?(?:/)?$' }, + ], + }, }); -const maxLabels = table.staticPages.size + table.dynamicRoutes.length + 2; // + '/_next', 'other' +const maxLabels = table.staticPages.size + + table.dynamicRoutes.length + + table.beforeFilesRewrites.length + + table.afterFilesRewrites.length + + table.fallbackRewrites.length + + 2; // + '/_next', 'other' describe('normalizeRoute', () => { it('maps the root path', () => { @@ -48,6 +63,41 @@ describe('normalizeRoute', () => { expect(normalizeRoute('/api/not-a-real-route', table)).toBe('/api/[...slug]'); }); + it('labels rewritten paths with their public source pattern', () => { + expect(normalizeRoute('/api/mcp', table)).toBe('/api/mcp'); + expect(normalizeRoute('/scim/v2/Users/42', table)).toBe('/scim/v2/:path*'); + expect(normalizeRoute('/.well-known/oauth-authorization-server', table)) + .toBe('/.well-known/oauth-authorization-server'); + expect(normalizeRoute('/.well-known/oauth-protected-resource/api/mcp', table)) + .toBe('/.well-known/oauth-protected-resource/:path*'); + expect(normalizeRoute('/register', table)).toBe('/register'); + expect(normalizeRoute('/ingest/events', table)).toBe('/ingest/:path*'); + }); + + it('matches rewrites in Next routing order', () => { + const precedenceTable = buildRouteTable({ + staticRoutes: [ + { page: '/docs' }, + { page: '/api/health' }, + ], + dynamicRoutes: [ + { page: '/api/[...slug]', regex: '^/api/(.+?)(?:/)?$' }, + { page: '/browse/[...path]', regex: '^/browse/(.+?)(?:/)?$' }, + ], + rewrites: { + beforeFiles: [{ source: '/docs/:path*', regex: '^/docs(?:/(.+?))?(?:/)?$' }], + afterFiles: [{ source: '/api/:path*', regex: '^/api/(.+?)(?:/)?$' }], + fallback: [{ source: '/:path*', regex: '^/(.+?)(?:/)?$' }], + }, + }); + + expect(normalizeRoute('/docs', precedenceTable)).toBe('/docs/:path*'); + expect(normalizeRoute('/api/health', precedenceTable)).toBe('/api/health'); + expect(normalizeRoute('/api/mcp', precedenceTable)).toBe('/api/:path*'); + expect(normalizeRoute('/browse/org/repo', precedenceTable)).toBe('/browse/[...path]'); + expect(normalizeRoute('/unmatched', precedenceTable)).toBe('/:path*'); + }); + it('collapses unbounded repository and file paths to one label', () => { const a = normalizeRoute('/browse/github.com/org/repo/-/blob/src/index.ts', table); const b = normalizeRoute('/browse/github.com/other/repo/-/blob/lib/other.ts', table); @@ -107,6 +157,7 @@ describe('normalizeRoute', () => { const paths = [ '/', '/search', '/repos', '/browse/a/b/c', '/api/health', '/api/commits', '/api/auth/session', '/_next/static/x.js', + '/api/mcp', '/scim/v2/Users/42', '/ingest/events', '/wp-admin', '/api/bogus', '/random', '/..%2f', '/a/b/c/d/e', ]; for (let i = 0; i < 500; i++) { diff --git a/packages/web/src/httpMetrics.ts b/packages/web/src/httpMetrics.ts index 6051480d1..17a7a4aa3 100644 --- a/packages/web/src/httpMetrics.ts +++ b/packages/web/src/httpMetrics.ts @@ -10,15 +10,32 @@ const logger = createLogger('web-http-metrics'); interface RoutesManifest { staticRoutes: { page: string }[]; dynamicRoutes: { page: string; regex: string }[]; + rewrites?: { + beforeFiles?: { source: string; regex: string }[]; + afterFiles?: { source: string; regex: string }[]; + fallback?: { source: string; regex: string }[]; + }; +} + +interface RewriteRoute { + source: string; + regex: RegExp; } interface RouteTable { staticPages: Set; dynamicRoutes: { page: string; regex: RegExp }[]; + beforeFilesRewrites: RewriteRoute[]; + afterFilesRewrites: RewriteRoute[]; + fallbackRewrites: RewriteRoute[]; } const OTHER_ROUTE = 'other'; +const matchRewrite = (pathname: string, rewrites: RewriteRoute[]): string | undefined => { + return rewrites.find(rewrite => rewrite.regex.test(pathname))?.source; +}; + /** * Builds a route matcher from Next's routes-manifest. The manifest lists every * defined route with a matching regex, ordered by Next's own resolution @@ -26,12 +43,22 @@ const OTHER_ROUTE = 'other'; * agrees with how the server actually routes the request. */ export const buildRouteTable = (manifest: RoutesManifest): RouteTable => { + const buildRewrites = (rewrites: { source: string; regex: string }[] | undefined): RewriteRoute[] => { + return (rewrites ?? []).map(rewrite => ({ + source: rewrite.source, + regex: new RegExp(rewrite.regex), + })); + }; + return { staticPages: new Set(manifest.staticRoutes.map(route => route.page)), dynamicRoutes: manifest.dynamicRoutes.map(route => ({ page: route.page, regex: new RegExp(route.regex), })), + beforeFilesRewrites: buildRewrites(manifest.rewrites?.beforeFiles), + afterFilesRewrites: buildRewrites(manifest.rewrites?.afterFiles), + fallbackRewrites: buildRewrites(manifest.rewrites?.fallback), }; }; @@ -44,9 +71,9 @@ let routeTable: RouteTable | undefined; * * Deriving routes from the manifest (rather than a hardcoded list) keeps the * label set in sync with the app automatically: new routes appear at build - * time, and the label is the route pattern itself (`/browse/[...path]`), so - * cardinality is bounded by the number of defined routes no matter what gets - * requested. + * time, and the label is the route or rewrite pattern itself + * (`/browse/[...path]`), so cardinality is bounded by the number of defined + * routes and rewrites no matter what gets requested. */ export const initRouteTable = (manifest?: RoutesManifest): boolean => { try { @@ -54,7 +81,12 @@ export const initRouteTable = (manifest?: RoutesManifest): boolean => { readFileSync(path.join(process.cwd(), '.next', 'routes-manifest.json'), 'utf-8'), ) as RoutesManifest); routeTable = buildRouteTable(resolved); - logger.info(`Route table loaded: ${routeTable.staticPages.size} static, ${routeTable.dynamicRoutes.length} dynamic routes.`); + const rewriteCount = routeTable.beforeFilesRewrites.length + + routeTable.afterFilesRewrites.length + + routeTable.fallbackRewrites.length; + logger.info( + `Route table loaded: ${routeTable.staticPages.size} static, ${routeTable.dynamicRoutes.length} dynamic, ${rewriteCount} rewrite routes.`, + ); return true; } catch (error) { // Fail closed: without a table every request is labelled `other`, which @@ -69,8 +101,8 @@ export const initRouteTable = (manifest?: RoutesManifest): boolean => { * label: repository and file paths are unbounded, so `/browse/...` would mint * a new time series for every file anyone views, and unknown paths (scanners, * bots) would grow the set without limit. Matching against the app's own - * routes bounds the label set to the number of defined routes plus `/_next` - * and `other`. + * routes bounds the label set to the number of defined routes and rewrite + * sources plus `/_next` and `other`. */ export const normalizeRoute = (pathname: string, table: RouteTable | undefined = routeTable): string => { const segments = pathname.split('/').filter(segment => segment.length > 0); @@ -89,16 +121,34 @@ export const normalizeRoute = (pathname: string, table: RouteTable | undefined = const canonical = `/${segments.join('/')}`; + // Preserve Next's routing order. Rewrite source patterns are the stable, + // public-facing route labels; resolving destinations here would require + // duplicating Next's parameter interpolation and rewrite chaining. + const beforeFilesRewrite = matchRewrite(canonical, table.beforeFilesRewrites); + if (beforeFilesRewrite) { + return beforeFilesRewrite; + } + if (table.staticPages.has(canonical)) { return canonical; } + const afterFilesRewrite = matchRewrite(canonical, table.afterFilesRewrites); + if (afterFilesRewrite) { + return afterFilesRewrite; + } + for (const route of table.dynamicRoutes) { if (route.regex.test(canonical)) { return route.page; } } + const fallbackRewrite = matchRewrite(canonical, table.fallbackRewrites); + if (fallbackRewrite) { + return fallbackRewrite; + } + return OTHER_ROUTE; }; From 3bfa7a3135ad97168db7392c2a3aa77fd9b6f8df Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 12 Aug 2026 16:26:48 -0700 Subject: [PATCH 7/7] fix(web): clear stale HTTP metrics route table --- packages/web/src/httpMetrics.test.ts | 16 +++++++++++++++- packages/web/src/httpMetrics.ts | 1 + 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/web/src/httpMetrics.test.ts b/packages/web/src/httpMetrics.test.ts index 440a23b28..a594a0c42 100644 --- a/packages/web/src/httpMetrics.test.ts +++ b/packages/web/src/httpMetrics.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildRouteTable, normalizeRoute } from './httpMetrics'; +import { buildRouteTable, initRouteTable, normalizeRoute } from './httpMetrics'; // Mirrors the shape and ordering of the real .next/routes-manifest.json: // dynamic routes are listed in Next's resolution priority, with catch-alls @@ -40,6 +40,20 @@ const maxLabels = table.staticPages.size + 2; // + '/_next', 'other' describe('normalizeRoute', () => { + it('discards a previously loaded table when initialization fails', () => { + expect(initRouteTable({ + staticRoutes: [{ page: '/api/health' }], + dynamicRoutes: [], + })).toBe(true); + expect(normalizeRoute('/api/health')).toBe('/api/health'); + + expect(initRouteTable({ + staticRoutes: [], + dynamicRoutes: [{ page: '/broken', regex: '[' }], + })).toBe(false); + expect(normalizeRoute('/api/health')).toBe('other'); + }); + it('maps the root path', () => { expect(normalizeRoute('/', table)).toBe('/'); expect(normalizeRoute('', table)).toBe('/'); diff --git a/packages/web/src/httpMetrics.ts b/packages/web/src/httpMetrics.ts index 17a7a4aa3..6d8adc0ba 100644 --- a/packages/web/src/httpMetrics.ts +++ b/packages/web/src/httpMetrics.ts @@ -91,6 +91,7 @@ export const initRouteTable = (manifest?: RoutesManifest): boolean => { } catch (error) { // Fail closed: without a table every request is labelled `other`, which // loses granularity but can never grow the label set. + routeTable = undefined; logger.error(`Failed to load routes-manifest; all routes will be reported as '${OTHER_ROUTE}': ${error}`); return false; }