-
Notifications
You must be signed in to change notification settings - Fork 355
feat(web): expose Prometheus metrics for the web process #1570
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0fc31ae
b444139
10d2047
cf8db22
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| import { createLogger, env } from '@sourcebot/shared'; | ||
| import { createServer, Server } from 'node:http'; | ||
| import { registry } from './promClient'; | ||
|
|
||
| const logger = createLogger('web-metrics-server'); | ||
|
|
||
| /** | ||
| * Serves the web process' Prometheus metrics on its own port, rather than as a | ||
| * Next.js route, so that scraping doesn't pass through the app's middleware or | ||
| * get exposed publicly through the ingress. | ||
| */ | ||
| export const startMetricsServer = (): Server | undefined => { | ||
| // Guard against a missing port: `listen(undefined)` binds a random one, which | ||
| // would leave the scrape target silently broken instead of loudly absent. | ||
| const port = Number(env.WEB_METRICS_PORT); | ||
| if (!Number.isInteger(port) || port <= 0) { | ||
| logger.error(`Invalid WEB_METRICS_PORT '${env.WEB_METRICS_PORT}'; metrics server not started.`); | ||
| return undefined; | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Incomplete metrics port validationMedium Severity The port guard only rejects non-integers and values Additional Locations (2)Reviewed by Cursor Bugbot for commit cf8db22. Configure here. |
||
|
|
||
| const server = createServer(async (req, res) => { | ||
| if (req.url !== '/metrics') { | ||
| res.writeHead(404); | ||
| res.end(); | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| const metrics = await registry.metrics(); | ||
| res.writeHead(200, { 'Content-Type': registry.contentType }); | ||
| res.end(metrics); | ||
| } catch (error) { | ||
| logger.error(`Failed to collect metrics: ${error}`); | ||
| res.writeHead(500); | ||
| res.end(); | ||
| } | ||
| }); | ||
|
|
||
| // Metrics must never take down the web server, so swallow listen failures | ||
| // (a port collision, most likely) instead of letting the 'error' event throw. | ||
| server.on('error', (error) => { | ||
| logger.error(`Metrics server error: ${error}`); | ||
| }); | ||
|
|
||
| server.listen(port, () => { | ||
| logger.info(`Web metrics server listening on port ${port}`); | ||
| }); | ||
|
|
||
| return server; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { registry } from './promClient'; | ||
|
|
||
| const metricNames = (output: string): Set<string> => { | ||
| return new Set( | ||
| output | ||
| .split('\n') | ||
| .filter(line => line.length > 0 && !line.startsWith('#')) | ||
| .map(line => line.split(/[ {]/)[0]) | ||
| ); | ||
| }; | ||
|
|
||
| describe('web promClient', () => { | ||
| it('exposes the metrics needed to diagnose heap pressure', async () => { | ||
| const names = metricNames(await registry.metrics()); | ||
|
|
||
| expect(names).toContain('nodejs_heap_size_limit_bytes'); | ||
| expect(names).toContain('nodejs_heap_size_used_bytes'); | ||
| expect(names).toContain('nodejs_eventloop_lag_p99_seconds'); | ||
| }); | ||
|
|
||
| it('registers the gc duration histogram', () => { | ||
| // Asserted via the registry rather than the rendered output: the histogram | ||
| // emits no series until a garbage collection has actually been observed. | ||
| expect(registry.getSingleMetric('nodejs_gc_duration_seconds')).toBeDefined(); | ||
| }); | ||
|
|
||
| it('reports a plausible heap size limit', async () => { | ||
| const output = await registry.metrics(); | ||
| const line = output.split('\n').find(l => l.startsWith('nodejs_heap_size_limit_bytes ')); | ||
|
|
||
| expect(line).toBeDefined(); | ||
|
|
||
| const limit = Number(line!.split(' ')[1]); | ||
| expect(Number.isFinite(limit)).toBe(true); | ||
| // Any real V8 heap limit is well above 100MB and well below 100GB. | ||
| expect(limit).toBeGreaterThan(100 * 1024 * 1024); | ||
| expect(limit).toBeLessThan(100 * 1024 * 1024 * 1024); | ||
| }); | ||
|
|
||
| it('can be collected repeatedly', async () => { | ||
| const first = await registry.metrics(); | ||
| const second = await registry.metrics(); | ||
|
|
||
| expect(metricNames(first)).toEqual(metricNames(second)); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import client, { Gauge, Registry } from 'prom-client'; | ||
| import { getHeapStatistics } from 'node:v8'; | ||
|
|
||
| export const registry = new Registry(); | ||
|
|
||
| // `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 | ||
| // collections or is pinned at its ceiling running full mark-compacts. | ||
| const heapSizeLimit = new Gauge({ | ||
| name: 'nodejs_heap_size_limit_bytes', | ||
| help: 'V8 heap size limit in bytes', | ||
| collect() { | ||
| this.set(getHeapStatistics().heap_size_limit); | ||
| }, | ||
| }); | ||
| registry.registerMetric(heapSizeLimit); | ||
|
|
||
| client.collectDefaultMetrics({ | ||
| register: registry, | ||
| }); |


Uh oh!
There was an error while loading. Please reload this page.