Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,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)

### 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)
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/env.server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,6 +174,9 @@ const options = {

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

// Port the web process serves its Prometheus metrics on.
WEB_METRICS_PORT: numberSchema.default(3070),

// Auth
AUTH_SECRET: z.string(),
AUTH_URL: z.string().url(),
Expand Down
1 change: 1 addition & 0 deletions packages/web/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -173,6 +173,7 @@
"posthog-js": "^1.369.0",
"posthog-node": "^5.24.15",
"pretty-bytes": "^6.1.1",
"prom-client": "^15.1.3",
"psl": "^1.15.0",
"react": "19.2.4",
"react-day-picker": "^9.14.0",
Expand Down
5 changes: 5 additions & 0 deletions packages/web/src/instrumentation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,11 @@ export async function register() {
await import('./sentry.edge.config');
}

if (process.env.NEXT_RUNTIME === 'nodejs') {
const { startMetricsServer } = await import('./metricsServer');
startMetricsServer();
}

if (process.env.NEXT_RUNTIME === 'nodejs') {
const { initialize } = await import('./initialize');
await initialize();
Expand Down
50 changes: 50 additions & 0 deletions packages/web/src/metricsServer.ts
Original file line numberDiff line numberDiff 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;
Comment thread
brendan-kellam marked this conversation as resolved.
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Incomplete metrics port validation

Medium Severity

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

Additional Locations (2)
Fix in CursorFix in Web

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;
};
47 changes: 47 additions & 0 deletions packages/web/src/promClient.test.ts
Original file line numberDiff line numberDiff 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));
});
});
21 changes: 21 additions & 0 deletions packages/web/src/promClient.ts
Original file line numberDiff line numberDiff 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,
});
1 change: 1 addition & 0 deletions yarn.lock
Original file line numberDiff line numberDiff line change
Expand Up@@ -9291,6 +9291,7 @@ __metadata:
posthog-js: "npm:^1.369.0"
posthog-node: "npm:^5.24.15"
pretty-bytes: "npm:^6.1.1"
prom-client: "npm:^15.1.3"
psl: "npm:^1.15.0"
raw-loader: "npm:^4.0.2"
react: "npm:19.2.4"
Expand Down
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,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)

### 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)
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/env.server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,6 +174,9 @@ const options = {

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

// Port the web process serves its Prometheus metrics on.
WEB_METRICS_PORT: numberSchema.default(3070),

// Auth
AUTH_SECRET: z.string(),
AUTH_URL: z.string().url(),
Expand Down
1 change: 1 addition & 0 deletions packages/web/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -173,6 +173,7 @@
"posthog-js": "^1.369.0",
"posthog-node": "^5.24.15",
"pretty-bytes": "^6.1.1",
"prom-client": "^15.1.3",
"psl": "^1.15.0",
"react": "19.2.4",
"react-day-picker": "^9.14.0",
Expand Down
5 changes: 5 additions & 0 deletions packages/web/src/instrumentation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,11 @@ export async function register() {
await import('./sentry.edge.config');
}

if (process.env.NEXT_RUNTIME === 'nodejs') {
const { startMetricsServer } = await import('./metricsServer');
startMetricsServer();
}

if (process.env.NEXT_RUNTIME === 'nodejs') {
const { initialize } = await import('./initialize');
await initialize();
Expand Down
50 changes: 50 additions & 0 deletions packages/web/src/metricsServer.ts
Original file line numberDiff line numberDiff 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;
Comment thread
brendan-kellam marked this conversation as resolved.
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Incomplete metrics port validation

Medium Severity

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

Additional Locations (2)
Fix in CursorFix in Web

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;
};
47 changes: 47 additions & 0 deletions packages/web/src/promClient.test.ts
Original file line numberDiff line numberDiff 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));
});
});
21 changes: 21 additions & 0 deletions packages/web/src/promClient.ts
Original file line numberDiff line numberDiff 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,
});
1 change: 1 addition & 0 deletions yarn.lock
Original file line numberDiff line numberDiff line change
Expand Up@@ -9291,6 +9291,7 @@ __metadata:
posthog-js: "npm:^1.369.0"
posthog-node: "npm:^5.24.15"
pretty-bytes: "npm:^6.1.1"
prom-client: "npm:^15.1.3"
psl: "npm:^1.15.0"
raw-loader: "npm:^4.0.2"
react: "npm:19.2.4"
Expand Down
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,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)

### 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)
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/env.server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,6 +174,9 @@ const options = {

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

// Port the web process serves its Prometheus metrics on.
WEB_METRICS_PORT: numberSchema.default(3070),

// Auth
AUTH_SECRET: z.string(),
AUTH_URL: z.string().url(),
Expand Down
1 change: 1 addition & 0 deletions packages/web/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -173,6 +173,7 @@
"posthog-js": "^1.369.0",
"posthog-node": "^5.24.15",
"pretty-bytes": "^6.1.1",
"prom-client": "^15.1.3",
"psl": "^1.15.0",
"react": "19.2.4",
"react-day-picker": "^9.14.0",
Expand Down
5 changes: 5 additions & 0 deletions packages/web/src/instrumentation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,11 @@ export async function register() {
await import('./sentry.edge.config');
}

if (process.env.NEXT_RUNTIME === 'nodejs') {
const { startMetricsServer } = await import('./metricsServer');
startMetricsServer();
}

if (process.env.NEXT_RUNTIME === 'nodejs') {
const { initialize } = await import('./initialize');
await initialize();
Expand Down
50 changes: 50 additions & 0 deletions packages/web/src/metricsServer.ts
Original file line numberDiff line numberDiff 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;
Comment thread
brendan-kellam marked this conversation as resolved.
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Incomplete metrics port validation

Medium Severity

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

Additional Locations (2)
Fix in CursorFix in Web

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;
};
47 changes: 47 additions & 0 deletions packages/web/src/promClient.test.ts
Original file line numberDiff line numberDiff 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));
});
});
21 changes: 21 additions & 0 deletions packages/web/src/promClient.ts
Original file line numberDiff line numberDiff 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,
});
1 change: 1 addition & 0 deletions yarn.lock
Original file line numberDiff line numberDiff line change
Expand Up@@ -9291,6 +9291,7 @@ __metadata:
posthog-js: "npm:^1.369.0"
posthog-node: "npm:^5.24.15"
pretty-bytes: "npm:^6.1.1"
prom-client: "npm:^15.1.3"
psl: "npm:^1.15.0"
raw-loader: "npm:^4.0.2"
react: "npm:19.2.4"
Expand Down
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,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)

### 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)
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/env.server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,6 +174,9 @@ const options = {

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

// Port the web process serves its Prometheus metrics on.
WEB_METRICS_PORT: numberSchema.default(3070),

// Auth
AUTH_SECRET: z.string(),
AUTH_URL: z.string().url(),
Expand Down
1 change: 1 addition & 0 deletions packages/web/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -173,6 +173,7 @@
"posthog-js": "^1.369.0",
"posthog-node": "^5.24.15",
"pretty-bytes": "^6.1.1",
"prom-client": "^15.1.3",
"psl": "^1.15.0",
"react": "19.2.4",
"react-day-picker": "^9.14.0",
Expand Down
5 changes: 5 additions & 0 deletions packages/web/src/instrumentation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,11 @@ export async function register() {
await import('./sentry.edge.config');
}

if (process.env.NEXT_RUNTIME === 'nodejs') {
const { startMetricsServer } = await import('./metricsServer');
startMetricsServer();
}

if (process.env.NEXT_RUNTIME === 'nodejs') {
const { initialize } = await import('./initialize');
await initialize();
Expand Down
50 changes: 50 additions & 0 deletions packages/web/src/metricsServer.ts
Original file line numberDiff line numberDiff 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;
Comment thread
brendan-kellam marked this conversation as resolved.
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Incomplete metrics port validation

Medium Severity

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

Additional Locations (2)
Fix in CursorFix in Web

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;
};
47 changes: 47 additions & 0 deletions packages/web/src/promClient.test.ts
Original file line numberDiff line numberDiff 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));
});
});
21 changes: 21 additions & 0 deletions packages/web/src/promClient.ts
Original file line numberDiff line numberDiff 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,
});
1 change: 1 addition & 0 deletions yarn.lock
Original file line numberDiff line numberDiff line change
Expand Up@@ -9291,6 +9291,7 @@ __metadata:
posthog-js: "npm:^1.369.0"
posthog-node: "npm:^5.24.15"
pretty-bytes: "npm:^6.1.1"
prom-client: "npm:^15.1.3"
psl: "npm:^1.15.0"
raw-loader: "npm:^4.0.2"
react: "npm:19.2.4"
Expand Down
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,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)

### 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)
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/env.server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,6 +174,9 @@ const options = {

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

// Port the web process serves its Prometheus metrics on.
WEB_METRICS_PORT: numberSchema.default(3070),

// Auth
AUTH_SECRET: z.string(),
AUTH_URL: z.string().url(),
Expand Down
1 change: 1 addition & 0 deletions packages/web/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -173,6 +173,7 @@
"posthog-js": "^1.369.0",
"posthog-node": "^5.24.15",
"pretty-bytes": "^6.1.1",
"prom-client": "^15.1.3",
"psl": "^1.15.0",
"react": "19.2.4",
"react-day-picker": "^9.14.0",
Expand Down
5 changes: 5 additions & 0 deletions packages/web/src/instrumentation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,11 @@ export async function register() {
await import('./sentry.edge.config');
}

if (process.env.NEXT_RUNTIME === 'nodejs') {
const { startMetricsServer } = await import('./metricsServer');
startMetricsServer();
}

if (process.env.NEXT_RUNTIME === 'nodejs') {
const { initialize } = await import('./initialize');
await initialize();
Expand Down
50 changes: 50 additions & 0 deletions packages/web/src/metricsServer.ts
Original file line numberDiff line numberDiff 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;
Comment thread
brendan-kellam marked this conversation as resolved.
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Incomplete metrics port validation

Medium Severity

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

Additional Locations (2)
Fix in CursorFix in Web

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;
};
47 changes: 47 additions & 0 deletions packages/web/src/promClient.test.ts
Original file line numberDiff line numberDiff 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));
});
});
21 changes: 21 additions & 0 deletions packages/web/src/promClient.ts
Original file line numberDiff line numberDiff 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,
});
1 change: 1 addition & 0 deletions yarn.lock
Original file line numberDiff line numberDiff line change
Expand Up@@ -9291,6 +9291,7 @@ __metadata:
posthog-js: "npm:^1.369.0"
posthog-node: "npm:^5.24.15"
pretty-bytes: "npm:^6.1.1"
prom-client: "npm:^15.1.3"
psl: "npm:^1.15.0"
raw-loader: "npm:^4.0.2"
react: "npm:19.2.4"
Expand Down
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,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)

### 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)
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/env.server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,6 +174,9 @@ const options = {

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

// Port the web process serves its Prometheus metrics on.
WEB_METRICS_PORT: numberSchema.default(3070),

// Auth
AUTH_SECRET: z.string(),
AUTH_URL: z.string().url(),
Expand Down
1 change: 1 addition & 0 deletions packages/web/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -173,6 +173,7 @@
"posthog-js": "^1.369.0",
"posthog-node": "^5.24.15",
"pretty-bytes": "^6.1.1",
"prom-client": "^15.1.3",
"psl": "^1.15.0",
"react": "19.2.4",
"react-day-picker": "^9.14.0",
Expand Down
5 changes: 5 additions & 0 deletions packages/web/src/instrumentation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,11 @@ export async function register() {
await import('./sentry.edge.config');
}

if (process.env.NEXT_RUNTIME === 'nodejs') {
const { startMetricsServer } = await import('./metricsServer');
startMetricsServer();
}

if (process.env.NEXT_RUNTIME === 'nodejs') {
const { initialize } = await import('./initialize');
await initialize();
Expand Down
50 changes: 50 additions & 0 deletions packages/web/src/metricsServer.ts
Original file line numberDiff line numberDiff 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;
Comment thread
brendan-kellam marked this conversation as resolved.
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Incomplete metrics port validation

Medium Severity

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

Additional Locations (2)
Fix in CursorFix in Web

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;
};
47 changes: 47 additions & 0 deletions packages/web/src/promClient.test.ts
Original file line numberDiff line numberDiff 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));
});
});
21 changes: 21 additions & 0 deletions packages/web/src/promClient.ts
Original file line numberDiff line numberDiff 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,
});
1 change: 1 addition & 0 deletions yarn.lock
Original file line numberDiff line numberDiff line change
Expand Up@@ -9291,6 +9291,7 @@ __metadata:
posthog-js: "npm:^1.369.0"
posthog-node: "npm:^5.24.15"
pretty-bytes: "npm:^6.1.1"
prom-client: "npm:^15.1.3"
psl: "npm:^1.15.0"
raw-loader: "npm:^4.0.2"
react: "npm:19.2.4"
Expand Down
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,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)

### 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)
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/env.server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,6 +174,9 @@ const options = {

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

// Port the web process serves its Prometheus metrics on.
WEB_METRICS_PORT: numberSchema.default(3070),

// Auth
AUTH_SECRET: z.string(),
AUTH_URL: z.string().url(),
Expand Down
1 change: 1 addition & 0 deletions packages/web/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -173,6 +173,7 @@
"posthog-js": "^1.369.0",
"posthog-node": "^5.24.15",
"pretty-bytes": "^6.1.1",
"prom-client": "^15.1.3",
"psl": "^1.15.0",
"react": "19.2.4",
"react-day-picker": "^9.14.0",
Expand Down
5 changes: 5 additions & 0 deletions packages/web/src/instrumentation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,11 @@ export async function register() {
await import('./sentry.edge.config');
}

if (process.env.NEXT_RUNTIME === 'nodejs') {
const { startMetricsServer } = await import('./metricsServer');
startMetricsServer();
}

if (process.env.NEXT_RUNTIME === 'nodejs') {
const { initialize } = await import('./initialize');
await initialize();
Expand Down
50 changes: 50 additions & 0 deletions packages/web/src/metricsServer.ts
Original file line numberDiff line numberDiff 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;
Comment thread
brendan-kellam marked this conversation as resolved.
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Incomplete metrics port validation

Medium Severity

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

Additional Locations (2)
Fix in CursorFix in Web

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;
};
47 changes: 47 additions & 0 deletions packages/web/src/promClient.test.ts
Original file line numberDiff line numberDiff 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));
});
});
21 changes: 21 additions & 0 deletions packages/web/src/promClient.ts
Original file line numberDiff line numberDiff 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,
});
1 change: 1 addition & 0 deletions yarn.lock
Original file line numberDiff line numberDiff line change
Expand Up@@ -9291,6 +9291,7 @@ __metadata:
posthog-js: "npm:^1.369.0"
posthog-node: "npm:^5.24.15"
pretty-bytes: "npm:^6.1.1"
prom-client: "npm:^15.1.3"
psl: "npm:^1.15.0"
raw-loader: "npm:^4.0.2"
react: "npm:19.2.4"
Expand Down
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,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)

### 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)
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/env.server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -174,6 +174,9 @@ const options = {

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

// Port the web process serves its Prometheus metrics on.
WEB_METRICS_PORT: numberSchema.default(3070),

// Auth
AUTH_SECRET: z.string(),
AUTH_URL: z.string().url(),
Expand Down
1 change: 1 addition & 0 deletions packages/web/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -173,6 +173,7 @@
"posthog-js": "^1.369.0",
"posthog-node": "^5.24.15",
"pretty-bytes": "^6.1.1",
"prom-client": "^15.1.3",
"psl": "^1.15.0",
"react": "19.2.4",
"react-day-picker": "^9.14.0",
Expand Down
5 changes: 5 additions & 0 deletions packages/web/src/instrumentation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,11 @@ export async function register() {
await import('./sentry.edge.config');
}

if (process.env.NEXT_RUNTIME === 'nodejs') {
const { startMetricsServer } = await import('./metricsServer');
startMetricsServer();
}

if (process.env.NEXT_RUNTIME === 'nodejs') {
const { initialize } = await import('./initialize');
await initialize();
Expand Down
50 changes: 50 additions & 0 deletions packages/web/src/metricsServer.ts
Original file line numberDiff line numberDiff 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;
Comment thread
brendan-kellam marked this conversation as resolved.
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Incomplete metrics port validation

Medium Severity

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

Additional Locations (2)
Fix in CursorFix in Web

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;
};
47 changes: 47 additions & 0 deletions packages/web/src/promClient.test.ts
Original file line numberDiff line numberDiff 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));
});
});
21 changes: 21 additions & 0 deletions packages/web/src/promClient.ts
Original file line numberDiff line numberDiff 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,
});
1 change: 1 addition & 0 deletions yarn.lock
Original file line numberDiff line numberDiff line change
Expand Up@@ -9291,6 +9291,7 @@ __metadata:
posthog-js: "npm:^1.369.0"
posthog-node: "npm:^5.24.15"
pretty-bytes: "npm:^6.1.1"
prom-client: "npm:^15.1.3"
psl: "npm:^1.15.0"
raw-loader: "npm:^4.0.2"
react: "npm:19.2.4"
Expand Down
Loading