Skip to content
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ This release marks our first release under the Prometheus umbrella.
runtime object. Value-style uses such as `MetricType.Counter` (which threw at runtime)
no longer compile; compare against the string literals instead. Under
`verbatimModuleSyntax`, import it with `import type`.
- The cluster primary now reports metrics

### Changed

Expand All @@ -46,6 +47,8 @@ This release marks our first release under the Prometheus umbrella.
- chore: Add copyright license headers and test
- Make cluster and worker-thread metric aggregation order deterministic
- Export `MetricObject`, `MetricObjectWithValues`, `MetricValue` and `MetricValueWithName` from the TypeScript definitions
- Improve cluster support to allow workers to opt out
- Abort cluster metric responses during process termination

### Added

Expand Down
5 changes: 5 additions & 0 deletions example/cluster.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ const metricsServer = express();
const clusterRegistry = new ClusterRegistry();

if (cluster.isPrimary) {
require('../').collectDefaultMetrics({
gcDurationBuckets: [0.001, 0.01, 0.1, 1, 2, 5], // These are the default buckets.
});

for (let i = 1; i <= 4; i++) {
cluster.fork({ ...process.env, PORT: 3000 + i });
}
Expand All @@ -32,6 +36,7 @@ if (cluster.isPrimary) {
res.set('Content-Type', clusterRegistry.contentType);
res.send(metrics);
} catch (ex) {
console.error(ex);
res.statusCode = 500;
res.send(ex.message);
}
Expand Down
1 change: 0 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,6 @@ export class WorkerRegistry<T extends RegistryContentType> extends Registry<T> {
*/
workerMetrics(): Promise<string>;

addWorker(worker: Worker): void;
/**
* Sets the registry or registries to be aggregated. Call from workers to
* use a registry/registries other than the default global registry.
Expand Down
227 changes: 167 additions & 60 deletions lib/cluster.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
* cluster master.
*/

const { debuglog } = require('node:util');
const Registry = require('./registry');
// We need to lazy-load the 'cluster' module as some application servers -
// namely Passenger - crash when it is imported.
Expand All @@ -31,17 +32,25 @@ let cluster = () => {
return data;
};

const debug = debuglog('prom:metrics:cluster');
const ANNOUNCEMENT = '@prometheus-io/client:announcement';
const GET_METRICS_REQ = '@prometheus-io/client:getMetricsReq';
const GET_METRICS_RES = '@prometheus-io/client:getMetricsRes';

let registries = [Registry.globalRegistry];
let requestCtr = 0; // Concurrency control
let listenersAdded = false;
const requests = new Map(); // Pending requests for workers' local metrics.
const workers = new Map();

class AggregatorRegistry extends Registry {
/**
* Create a Registry.
* @param regContentType
*/
constructor(regContentType = Registry.PROMETHEUS_CONTENT_TYPE) {
super(regContentType);

addListeners();
}

Expand All @@ -53,9 +62,9 @@ class AggregatorRegistry extends Registry {
*/
clusterMetrics() {
const requestId = requestCtr++;
const workers = Object.values(cluster().workers)
.filter(worker => worker.isConnected())
.sort((left, right) => left.id - right.id);
const orderedWorkers = [...workers.values()].sort(
(left, right) => left.id - right.id,
);

return new Promise((resolve, reject) => {
let settled = false;
Expand All @@ -78,36 +87,43 @@ class AggregatorRegistry extends Registry {
responseHandlers,
done,
errorTimeout: setTimeout(() => {
const err = new Error('Operation timed out.');
const err = new Error(
`Operation timed out. ${request.responseHandlers.size} outstanding responses.`,
);
request.done(err);
}, 5000),
}, 5_000),
};
requests.set(requestId, request);

const message = {
type: GET_METRICS_REQ,
requestId,
};

if (workers.length === 0) {
// No workers were up
process.nextTick(() => done(undefined, ''));
return;
}

const responsePromises = workers.map(
const workerMetrics = orderedWorkers.map(
worker =>
new Promise((resolveResponse, rejectResponse) => {
responseHandlers.set(worker.id, {
resolve: resolveResponse,
reject: rejectResponse,
});
worker.send(message);

worker.send({
type: GET_METRICS_REQ,
requestId,
});
}),
);

Promise.all(responsePromises)
.then(metrics => Registry.aggregate(metrics.flat()).metrics())
const myMetrics = Promise.all(
registries.map(r => r.getMetricsAsJSON()),
).then(metrics => {
return { metrics };
});

if (workerMetrics.length === 0) {
debug('No workers found for requestId', requestId);
}

const allMetrics = [myMetrics, ...workerMetrics];

Promise.all(allMetrics)
.then(responses => responses.flatMap(response => response.metrics))
.then(metrics => Registry.aggregate(metrics).metrics())
.then(result => done(undefined, result), done);
});
}
Expand Down Expand Up @@ -158,54 +174,145 @@ class AggregatorRegistry extends Registry {
* @returns {void}
*/
function addListeners() {
if (listenersAdded) return;
if (listenersAdded) {
return;
}

listenersAdded = true;

if (cluster().isPrimary) {
// Listen for worker responses to requests for local metrics
cluster().on('message', (worker, message) => {
if (message.type === GET_METRICS_RES) {
const request = requests.get(message.requestId);
replaceListener('message', cluster(), primaryListener);
replaceListener('disconnect', cluster(), disconnect);

if (request === undefined) {
return;
}
announce();
} else {
replaceListener('message', process, workerListener);
Comment on lines +184 to +189

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I've been trying to understand an issue flagged by LLM. "replaceListener" was removing listeners from the "old" registry, but that means you can lose metrics as they won't be called anymore. So reverting to simply doing on() without replaceListener fixes that, see diff and regression test. Is this what you also flagged here ?

It does reintroduce #155 warning in "listeners don't accumulate" in test/clusterTest.js, but there is a way to fix it apparently (in a separate PR):

Removing replaceListener does re-expose #155's original symptom — 11+ reloads of the module in one process will emit MaxListenersExceededWarning again. That's the honest trade, and it's the right one: a warning in a test harness that clears require.cache is strictly better than silent metric loss in production. If the author wants both, the fix is a shared registration marker (a symbol on the emitter, or a globalThis key) that skips installing a second listener rather than removing the first — skipping leaves the earlier instance's workers map intact, so no instance goes blind.

Suggested change
replaceListener('message', cluster(), primaryListener);
replaceListener('disconnect', cluster(), disconnect);
if (request === undefined) {
return;
}
announce();
} else {
replaceListener('message', process, workerListener);
cluster().on('message', primaryListener);
cluster().on('disconnect', disconnect);
announce();
} else {
process.on('message', workerListener);

Regression test for this:

		it('keeps polling workers after a duplicate module instance is loaded', async () => {
			const originalWorkers = cluster.workers;

			jest.resetModules();
			const FirstInstance = require('../lib/cluster');
			const registry = new FirstInstance(regType);

			// A duplicated copy of the package - a nested dependency, say - loads
			// its own module instance in the same primary process.
			jest.resetModules();
			const SecondInstance = require('../lib/cluster');
			new SecondInstance(regType);

			const worker = { id: 1, isConnected: () => true, send: jest.fn() };
			cluster.workers = { 1: worker };
			cluster.emit('message', worker, { type: ANNOUNCEMENT });

			let result;
			try {
				result = registry.clusterMetrics();

				const requests = worker.send.mock.calls
					.map(([message]) => message)
					.filter(message => message.type === GET_METRICS_REQ);

				// The first instance must still know about the worker. Otherwise it
				// silently reports primary-only metrics, with no timeout or error.
				expect(requests).toHaveLength(1);
			} finally {
				cluster.emit('message', worker, {
					type: GET_METRICS_RES,
					requestId: 0,
					metrics: [[metric(1)]],
				});
				await result?.catch(() => {});
				cluster.emit('disconnect', worker);
				cluster.workers = originalWorkers;
			}
		});

@jdmarshall jdmarshall Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Major edit:

Because the responses are being aggregated through a promise, only the first result was ever being seen anyway. In fact what you were probably always seeing before was the oldest or second oldest metrics per process, based on when the event was delivered and processing time to gather the metrics. Which is exactly the wrong data for functional and integration tests.

To the best of my knowledge prom-client has never worked with hot reload. Let alone well. And anyone would see that it doesn't within a few minutes of trying, especially if they used older versions that were especially crabby about this.

We have a bigger problem with what to do about dead workers. Because their metrics disappear when they do, and since we are gathering them, we are getting the wrong answers for counts and gauges. #803 which is a problem since the general wisdom is 'let the process crash' when unhandledException or unhandledRejection fires.

What I think that suggests is an update to the README, suggesting you let a Prometheus sidecar handle the aggregation in Serious Projects rather than using cluster.js or worker.js

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm sorry I'm having a hard time understanding this :( I'll let this go after this.

Because the responses are being aggregated through a promise, only the first result was ever being seen anyway.

In the proposed test, there's no first/second result, it's one result per instance. Also using on() doesn't change that we have a promise which still keeps only one result if there were multiple. Shouldn't some test break if using on() changed something about handling multiple messages? E.g. does not error out on unexpected (or late) responses or aggregates worker responses in worker id order test?

The proposed test is about multiple instance at the same time sending just one message and in that case replaceListener makes us completely lose one set of results.

I've asked the LLM to show what's the worse that can happen if we keep replaceListener and under what circumstances, I'm ok with approving this PR and filing an issue instead based on this:

PR #789 — risk of keeping replaceListener

Assessment of the worst-case outcome if prometheus/client_js#789
is approved with replaceListener retained in lib/cluster.js.

Tested against commit 1adc9d5 ("Simplify process.send sanity checks."), Node v20.9.0,
compared against the same tree with replaceListener reverted to plain on().


The worst case is worse than "missing metrics" — it's a crash of the cluster primary,
and it's deterministic.

The failure chain

replaceListener strips both listeners from the earlier instance — message and
disconnect. That second one is what escalates this. Reproduced 3/3, app registry
constructed first, a nested copy loading later:

before 2nd copy loads: wc_counter=2                      ← healthy
--- second module instance constructed ---
after  2nd copy loads: REJECTED: Operation timed out. 2 outstanding responses.
after worker death:    Error [ERR_IPC_CHANNEL_CLOSED]: Channel closed
                       Emitted 'error' event on Worker instance
                       Node.js v20.9.0                   ← process exits

Three stages:

  1. Every scrape times out at 5 s. The earlier instance still has both workers in its
    workers map, but responses no longer route to it. Worse than the empty-map case —
    that returned fast and wrong; this hangs the metrics endpoint for 5 s and then 500s.
  2. Dead workers are never pruned, because the disconnect listener went with the
    message one.
  3. worker.send() on a dead worker emits ERR_IPC_CHANNEL_CLOSED as an 'error'
    event on the Worker object. Nothing listens for it, so Node throws and the primary
    exits — taking the whole cluster down, since the primary is the process manager.

Same scenario with plain on(): wc_counter=1 in 2 ms, correctly reflecting the
surviving worker.

There's an uncomfortable detail here — the PR body says "Fixes #563", and
#563 is "[BUG] ERR_IPC_CHANNEL_CLOSED
showing up on new deployments"
. replaceListener can produce precisely that error, fatally.

How likely is it

All of these must hold:

  • Two copies of the package as separate module instances, with byte-identical
    primaryListener source — different versions won't dedup, so this needs same-version
    duplication.
  • Both construct a ClusterRegistry in the primary. Requiring twice is harmless;
    addListeners() only runs from the constructor. This is the narrow link — most apps
    construct once. It opens up when a framework plugin or APM wrapper instruments
    alongside app code.
  • The app scrapes via the earlier-constructed instance (the later one wins the listener),
    so it's roughly a coin flip on init order.
  • For the crash specifically, a worker then has to die — which for long-running clusters
    is a matter of time, and is the normal response to unhandledRejection.

So: unlikely to hit most users, near-certain for anyone who does hit it, and it recurs on
every boot with that dependency tree. Detectability is poor — the only signal is a
debug() line behind NODE_DEBUG=prom:metrics:cluster, and the symptoms point nowhere
near prom-client.

If you approve anyway

Reasonable position — the PR is a large net improvement, and it targets unreleased v0.16,
so there's runway. Two things worth asking for as a condition, both cheap:

Restore the isConnected() filter in clusterMetrics() — the
.filter(worker => worker.isConnected()) this PR dropped. Tested: it doesn't fix the
metric loss, but it does downgrade the crash to a timeout:

after worker death, scrape#1: 5002ms  REJECTED: Operation timed out.
after worker death, scrape#2: 5006ms  REJECTED: Operation timed out.

A broken metrics endpoint is survivable; a dead primary isn't. This is worth having
regardless of how the replaceListener argument lands, since the same
send()-to-a-dead-worker race exists in a narrower window even with plain on().

File a follow-up issue with the reproduction, so the decision is recorded rather than
lost in a review thread.

For what it's worth, the actual fix stays small — −26/+3, no interaction with anything
else in the PR — so "approve with on()" costs the author very little compared to
shipping a known primary-crash path.


Appendix: reproduction

Not part of the original assessment — included because the scratch copies used above are
session-temporary. Requires two trees: one as the PR stands, one with
replaceListener('message', cluster(), primaryListener) /
replaceListener('disconnect', cluster(), disconnect) /
replaceListener('message', process, workerListener) reverted to the equivalent
.on(...) calls.

// worstcase.js — run as: VARIANT=/path/to/tree node worstcase.js
// App constructs its registry first; a library loads its own copy LATER.
// By then the app's instance already knows about the workers.
const cluster = require('cluster');
const P = process.env.VARIANT;
const path = P + '/lib/cluster.js';

if (cluster.isPrimary) {
	const A = require(path);
	const regA = new A(); // app's registry
	const workers = [cluster.fork(), cluster.fork()];

	(async () => {
		await new Promise(r => setTimeout(r, 700)); // workers announce to A
		let m = await regA.clusterMetrics().catch(e => `REJECTED: ${e.message}`);
		console.log(
			`  before 2nd copy loads: wc_counter=${(String(m).match(/^wc_counter (\S+)/m) || [])[1] ?? 'ABSENT'}`,
		);

		// A nested dependency initialises its own copy now.
		delete require.cache[require.resolve(path)];
		const B = require(path);
		new B();
		console.log('  --- second module instance constructed ---');

		m = await regA.clusterMetrics().catch(e => `REJECTED: ${e.message}`);
		console.log(
			`  after  2nd copy loads: wc_counter=${(String(m).match(/^wc_counter (\S+)/m) || [])[1] ?? String(m).slice(0, 60)}`,
		);

		// Now one worker dies. A never pruned it, because A lost its disconnect listener.
		process.kill(workers[0].process.pid, 'SIGKILL');
		await new Promise(r => setTimeout(r, 600));
		for (const scrape of [1, 2]) {
			const t = Date.now();
			m = await regA.clusterMetrics().catch(e => `REJECTED: ${e.message}`);
			const v = (String(m).match(/^wc_counter (\S+)/m) || [])[1];
			console.log(
				`  after worker death, scrape#${scrape}: ${String(Date.now() - t).padStart(4)}ms  ${v ? `wc_counter=${v}` : String(m).slice(0, 55)}`,
			);
		}
		for (const w of workers)
			try {
				w.kill();
			} catch {}
		process.exit(0);
	})();
} else {
	const { Counter } = require(P + '/index.js');
	new Counter({ name: 'wc_counter', help: 'h' }).inc(1);
	const AR = require(path);
	new AR();
	setInterval(() => {}, 1000);
}


const response = request.responseHandlers.get(worker.id);
if (response === undefined) {
return;
}
request.responseHandlers.delete(worker.id);
if (typeof process.send !== 'function') {
debug('worker has no process.send()');
} else if (!process.connected) {
debug('worker is not connected to parent process');
} else {
process.send({ type: ANNOUNCEMENT });
}
}
}

if (message.error) {
response.reject(new Error(message.error));
} else {
response.resolve(message.metrics);
}
/**
* Watch for metrics events and aggregator announcements
*
* Whereas clusters are a top-level activity, multiple modules may start their
* own workers and require telemetry collection.
* @param message {MessageEvent}
*/
async function workerListener(message) {
if (message.type === ANNOUNCEMENT) {
process.send({ type: ANNOUNCEMENT });
} else if (message.type === GET_METRICS_REQ) {
try {
const metrics = await Promise.all(
registries.map(r => r.getMetricsAsJSON()),
);

if (!process.connected) {
debug('Connection to primary lost.');
} else {
process.send({
type: GET_METRICS_RES,
requestId: message.requestId,
metrics,
});
}
});
} else {
// Respond to master's requests for worker's local metrics.
process.on('message', message => {
if (message.type === GET_METRICS_REQ) {
Promise.all(registries.map(r => r.getMetricsAsJSON()))
.then(metrics => {
process.send({
type: GET_METRICS_RES,
requestId: message.requestId,
metrics,
});
})
.catch(error => {
process.send({
type: GET_METRICS_RES,
requestId: message.requestId,
error: error.message,
});
});
} catch (error) {
debug('Error sending to primary', error);
if (!process.connected) {
debug('Connection to primary lost.');
} else {
process.send({
type: GET_METRICS_RES,
requestId: message.requestId,
error: error.message,
});
}
});
}
}
}

/**
* Add workers to the aggregation list when they are announced.
*
* Whereas clusters are a top-level activity, multiple modules may start their
* own workers and require telemetry collection.
* @param event {MessageEvent}
*/

async function primaryListener(worker, event) {
if (event.type === ANNOUNCEMENT) {
if (workers.has(worker.id)) {
debug('duplicate worker announcement', worker.id);
return;
}

workers.set(worker.id, worker);
} else if (event.type === GET_METRICS_RES) {
const request = requests.get(event.requestId);

if (request === undefined) {
debug('unexpected results from worker', worker.id);
return;
}

const response = request.responseHandlers.get(worker.id);
if (response === undefined) {
return;
}
request.responseHandlers.delete(worker.id);

if (event.error) {
response.reject(new Error(event.error));
} else {
response.resolve({
threadId: worker.id,
metrics: event.metrics,
});
}
}
}

function disconnect(event) {
debug('worker disconnected', event.id);
workers.delete(event.id);
}

function announce() {
for (const worker of Object.values(cluster().workers)) {
if (worker.isConnected()) {
worker.send({ type: ANNOUNCEMENT });
}
}
}

/**
* Replace any listeners with new ones.
*
* @param messageType
* @param emitter {EventEmitter}
* @param fn
*/
function replaceListener(messageType, emitter, fn) {
// Reloading a module creates a unique instance of each function, so the
// identity checks is cluster.off() will fail.
const functionString = fn.toString();

for (const listener of emitter.listeners(messageType)) {
// eslint-disable-next-line eqeqeq
if (functionString == listener) {
debug('removing duplicate listener', messageType);
emitter.off(messageType, listener);
}
}

emitter.on(messageType, fn);
}

Comment on lines +295 to 317

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

For above.

Suggested change
/**
* Replace any listeners with new ones.
*
* @param messageType
* @param emitter {EventEmitter}
* @param fn
*/
function replaceListener(messageType, emitter, fn) {
// Reloading a module creates a unique instance of each function, so the
// identity checks is cluster.off() will fail.
const functionString = fn.toString();
for (const listener of emitter.listeners(messageType)) {
// eslint-disable-next-line eqeqeq
if (functionString == listener) {
debug('removing duplicate listener', messageType);
emitter.off(messageType, listener);
}
}
emitter.on(messageType, fn);
}

module.exports = AggregatorRegistry;
Loading
Loading