Skip to content

Commit 84a75e4

Browse files
feat(web): expose Prometheus metrics for the web process (#1570)
* feat(web): expose Prometheus metrics for the web process The web process had no instrumentation of any kind, so heap usage, GC duration, and event loop lag were invisible for the process that serves all application traffic. Diagnosing a recent GC-thrash incident required reading cgroup files inside the container and inferring the rest from trace spans. Mirror the backend's prom-client setup and serve it on its own port (WEB_METRICS_PORT, default 3070) rather than as a Next.js route, so scraping bypasses app middleware and is not reachable through the ingress. Also adds nodejs_heap_size_limit_bytes, which prom-client's default metrics omit. Without the ceiling, heap usage alone cannot distinguish a busy process from one pinned at its limit running back-to-back full mark-compacts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: add changelog entry for web process metrics Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix changelog --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4fbf359 commit 84a75e4

8 files changed

Lines changed: 129 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111
- Added a manually triggered cloud image release workflow for isolated internal deployments. [#1566](https://github.com/sourcebot-dev/sourcebot/pull/1566)
12+
- Added Prometheus metrics for the web process, served on `WEB_METRICS_PORT` (default `3070`). [#1570](https://github.com/sourcebot-dev/sourcebot/pull/1570)
1213

1314
### Fixed
1415
- 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)

packages/shared/src/env.server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,9 @@ const options = {
174174

175175
WORKER_API_URL: z.string().url().default("http://localhost:3060"),
176176

177+
// Port the web process serves its Prometheus metrics on.
178+
WEB_METRICS_PORT: numberSchema.default(3070),
179+
177180
// Auth
178181
AUTH_SECRET: z.string(),
179182
AUTH_URL: z.string().url(),

packages/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@
173173
"posthog-js": "^1.369.0",
174174
"posthog-node": "^5.24.15",
175175
"pretty-bytes": "^6.1.1",
176+
"prom-client": "^15.1.3",
176177
"psl": "^1.15.0",
177178
"react": "19.2.4",
178179
"react-day-picker": "^9.14.0",

packages/web/src/instrumentation.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ export async function register() {
99
await import('./sentry.edge.config');
1010
}
1111

12+
if (process.env.NEXT_RUNTIME === 'nodejs') {
13+
const { startMetricsServer } = await import('./metricsServer');
14+
startMetricsServer();
15+
}
16+
1217
if (process.env.NEXT_RUNTIME === 'nodejs') {
1318
const { initialize } = await import('./initialize');
1419
await initialize();

packages/web/src/metricsServer.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { createLogger, env } from '@sourcebot/shared';
2+
import { createServer, Server } from 'node:http';
3+
import { registry } from './promClient';
4+
5+
const logger = createLogger('web-metrics-server');
6+
7+
/**
8+
* Serves the web process' Prometheus metrics on its own port, rather than as a
9+
* Next.js route, so that scraping doesn't pass through the app's middleware or
10+
* get exposed publicly through the ingress.
11+
*/
12+
export const startMetricsServer = (): Server | undefined => {
13+
// Guard against a missing port: `listen(undefined)` binds a random one, which
14+
// would leave the scrape target silently broken instead of loudly absent.
15+
const port = Number(env.WEB_METRICS_PORT);
16+
if (!Number.isInteger(port) || port <= 0) {
17+
logger.error(`Invalid WEB_METRICS_PORT '${env.WEB_METRICS_PORT}'; metrics server not started.`);
18+
return undefined;
19+
}
20+
21+
const server = createServer(async (req, res) => {
22+
if (req.url !== '/metrics') {
23+
res.writeHead(404);
24+
res.end();
25+
return;
26+
}
27+
28+
try {
29+
const metrics = await registry.metrics();
30+
res.writeHead(200, { 'Content-Type': registry.contentType });
31+
res.end(metrics);
32+
} catch (error) {
33+
logger.error(`Failed to collect metrics: ${error}`);
34+
res.writeHead(500);
35+
res.end();
36+
}
37+
});
38+
39+
// Metrics must never take down the web server, so swallow listen failures
40+
// (a port collision, most likely) instead of letting the 'error' event throw.
41+
server.on('error', (error) => {
42+
logger.error(`Metrics server error: ${error}`);
43+
});
44+
45+
server.listen(port, () => {
46+
logger.info(`Web metrics server listening on port ${port}`);
47+
});
48+
49+
return server;
50+
};
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { registry } from './promClient';
3+
4+
const metricNames = (output: string): Set<string> => {
5+
return new Set(
6+
output
7+
.split('\n')
8+
.filter(line => line.length > 0 && !line.startsWith('#'))
9+
.map(line => line.split(/[ {]/)[0])
10+
);
11+
};
12+
13+
describe('web promClient', () => {
14+
it('exposes the metrics needed to diagnose heap pressure', async () => {
15+
const names = metricNames(await registry.metrics());
16+
17+
expect(names).toContain('nodejs_heap_size_limit_bytes');
18+
expect(names).toContain('nodejs_heap_size_used_bytes');
19+
expect(names).toContain('nodejs_eventloop_lag_p99_seconds');
20+
});
21+
22+
it('registers the gc duration histogram', () => {
23+
// Asserted via the registry rather than the rendered output: the histogram
24+
// emits no series until a garbage collection has actually been observed.
25+
expect(registry.getSingleMetric('nodejs_gc_duration_seconds')).toBeDefined();
26+
});
27+
28+
it('reports a plausible heap size limit', async () => {
29+
const output = await registry.metrics();
30+
const line = output.split('\n').find(l => l.startsWith('nodejs_heap_size_limit_bytes '));
31+
32+
expect(line).toBeDefined();
33+
34+
const limit = Number(line!.split(' ')[1]);
35+
expect(Number.isFinite(limit)).toBe(true);
36+
// Any real V8 heap limit is well above 100MB and well below 100GB.
37+
expect(limit).toBeGreaterThan(100 * 1024 * 1024);
38+
expect(limit).toBeLessThan(100 * 1024 * 1024 * 1024);
39+
});
40+
41+
it('can be collected repeatedly', async () => {
42+
const first = await registry.metrics();
43+
const second = await registry.metrics();
44+
45+
expect(metricNames(first)).toEqual(metricNames(second));
46+
});
47+
});

packages/web/src/promClient.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import client, { Gauge, Registry } from 'prom-client';
2+
import { getHeapStatistics } from 'node:v8';
3+
4+
export const registry = new Registry();
5+
6+
// `collectDefaultMetrics` reports heap usage but not the ceiling it's measured
7+
// against, and usage alone can't distinguish "busy" from "out of room". Without
8+
// the limit there's no way to tell whether V8 is doing cheap incremental
9+
// collections or is pinned at its ceiling running full mark-compacts.
10+
const heapSizeLimit = new Gauge({
11+
name: 'nodejs_heap_size_limit_bytes',
12+
help: 'V8 heap size limit in bytes',
13+
collect() {
14+
this.set(getHeapStatistics().heap_size_limit);
15+
},
16+
});
17+
registry.registerMetric(heapSizeLimit);
18+
19+
client.collectDefaultMetrics({
20+
register: registry,
21+
});

yarn.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9291,6 +9291,7 @@ __metadata:
92919291
posthog-js: "npm:^1.369.0"
92929292
posthog-node: "npm:^5.24.15"
92939293
pretty-bytes: "npm:^6.1.1"
9294+
prom-client: "npm:^15.1.3"
92949295
psl: "npm:^1.15.0"
92959296
raw-loader: "npm:^4.0.2"
92969297
react: "npm:19.2.4"

0 commit comments

Comments
 (0)