Skip to content

Commit ea8872d

Browse files
committed
fix: normalize label values once, when a label combination is first stored
Non-string label values bypassed escapeLabelValue() and could render malformed exposition (#791). Escaping during metrics() costs linear in total cardinality (#792/#793 were declined for that), so values are coerced at the storage boundary instead, once per new combination. - LabelMap gains one insertion point, #insert, used by set, setDelta, getOrAdd and merge. The entry gets a copy the store owns, coerced with the same ToString the exposition applies, so a caller mutating its object afterwards cannot change what a stored series reports. - normalizeLabels() walks with for...in, because keyFrom() reads inherited enumerable labels too. Labels that no enumeration reaches, or whose prototype chain intercepts writes, are unsupported. The copy keeps the source prototype, so keyFrom() answers absent declared names the way the source did. Nullish values are copied as-is, and __proto__ needs Object.defineProperty rather than assignment. - merge() keeps the stored labels instead of overwriting them with the caller's object. getOrAdd() hands the stored labels to init(), so Summary's value holds that same object rather than the caller's, and its export helpers are unchanged from main. LabelGrouper does not normalize; the store-backed labels it receives are normalized already. Benchmarks and the observable output changes are in the PR description. Fixes #791 Signed-off-by: Changhyun Kim <milcho0604@gmail.com>
1 parent 9366ad0 commit ea8872d

8 files changed

Lines changed: 390 additions & 38 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ project adheres to [Semantic Versioning](http://semver.org/).
1616
- perf: Histogram rendering builds its export list straight from the store iterator instead of an intermediate array. Faster at high series counts on Node 24 and 26, can be slightly slower on Node 22
1717
- fix: Correct content type exported for cluster and worker mode.
1818
- perf: Remove truthy conditionals from default metric collectors
19+
- fix: Non-nullish, non-string label values are coerced to strings when a combination is first stored, so exposition escapes them; the store also keeps its own copy, so mutating the caller's object after recording no longer changes the stored series
20+
- fix: Label-less summaries report `labels: {}` in `getMetricsAsJSON()`, like other metrics
1921

2022
### Added
2123

lib/summary.js

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,14 @@ class Summary extends Metric {
3939
this.store = new LabelMap(this.labelNames);
4040

4141
if (this.labelNames.length === 0) {
42-
this.store.set(
43-
{},
44-
{
42+
this.store.getOrAdd({}, storedLabels => {
43+
return {
44+
labels: storedLabels,
4545
td: new timeWindowQuantiles(this.maxAgeSeconds, this.ageBuckets),
4646
count: 0,
4747
sum: 0,
48-
},
49-
);
48+
};
49+
});
5050
}
5151
}
5252

@@ -177,14 +177,17 @@ function observe(labels) {
177177
);
178178
}
179179

180-
const summaryOfLabel = this.store.getOrAdd(labelValuePair.labels, () => {
181-
return {
182-
labels: labelValuePair.labels,
183-
td: new timeWindowQuantiles(this.maxAgeSeconds, this.ageBuckets),
184-
count: 0,
185-
sum: 0,
186-
};
187-
});
180+
const summaryOfLabel = this.store.getOrAdd(
181+
labelValuePair.labels,
182+
storedLabels => {
183+
return {
184+
labels: storedLabels,
185+
td: new timeWindowQuantiles(this.maxAgeSeconds, this.ageBuckets),
186+
count: 0,
187+
sum: 0,
188+
};
189+
},
190+
);
188191

189192
summaryOfLabel.td.push(labelValuePair.value);
190193
summaryOfLabel.count++;

lib/util.js

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,40 @@ exports.waitFor = async function waitFor(promise, limit = 5_000) {
202202
* @property labels {object}
203203
*/
204204

205+
/**
206+
* Copy the labels the store owns, coercing values for exposition (#791).
207+
* @param {object} labels
208+
* @returns {object} a copy owned by the store
209+
*/
210+
function normalizeLabels(labels) {
211+
// Keep the source prototype: keyFrom() reads absent declared names off it.
212+
const proto = Object.getPrototypeOf(labels);
213+
const copy =
214+
proto === Object.prototype ? { ...labels } : Object.create(proto);
215+
216+
for (const name in labels) {
217+
const value = labels[name];
218+
const stored =
219+
typeof value === 'string' || value === null || value === undefined
220+
? value
221+
: `${value}`;
222+
223+
if (name === '__proto__') {
224+
// Assigning would hit the prototype setter and drop the label.
225+
Object.defineProperty(copy, name, {
226+
value: stored,
227+
writable: true,
228+
enumerable: true,
229+
configurable: true,
230+
});
231+
} else {
232+
copy[name] = stored;
233+
}
234+
}
235+
236+
return copy;
237+
}
238+
205239
/**
206240
* Lookup table for stats by labels.
207241
*/
@@ -216,6 +250,22 @@ class LabelMap {
216250
this.#labelNames = new Set(labelNames.slice().sort());
217251
}
218252

253+
/**
254+
* The single insertion point for new label combinations.
255+
* @param {string} key precomputed `keyFrom(entry.labels)`
256+
* @param {StatsEntry} entry
257+
* @param {[Function]} init optional factory, receives the stored labels
258+
* @returns {StatsEntry}
259+
*/
260+
#insert(key, entry, init) {
261+
entry.labels = normalizeLabels(entry.labels);
262+
// init() runs before the entry lands, so a throw leaves the map untouched.
263+
if (init) entry.value = init(entry.labels);
264+
this.#map.set(key, entry);
265+
266+
return entry;
267+
}
268+
219269
/**
220270
* @function setValue
221271
* @param {object} labels
@@ -229,7 +279,7 @@ class LabelMap {
229279
if (entry !== undefined) {
230280
entry.value = value;
231281
} else {
232-
this.#map.set(key, { value, labels });
282+
this.#insert(key, { value, labels });
233283
}
234284

235285
return this;
@@ -248,7 +298,7 @@ class LabelMap {
248298
if (entry !== undefined) {
249299
entry.value += value;
250300
} else {
251-
this.#map.set(key, { value, labels });
301+
this.#insert(key, { value, labels });
252302
}
253303

254304
return this;
@@ -270,16 +320,15 @@ class LabelMap {
270320
* called to create an object to put there. This allows for nested structures.
271321
*
272322
* @param {object} labels labels for the new entry
273-
* @param {[Function]} init function to generate an empty record
323+
* @param {[Function]} init receives the stored labels, returns an empty record
274324
* @returns {*} the existing value or the result of init()
275325
*/
276326
getOrAdd(labels, init) {
277327
const key = this.keyFrom(labels);
278328
let entry = this.#map.get(key);
279329

280330
if (entry === undefined) {
281-
entry = { value: init(), labels };
282-
this.#map.set(key, entry);
331+
entry = this.#insert(key, { labels }, init);
283332
}
284333

285334
return entry.value;
@@ -307,10 +356,9 @@ class LabelMap {
307356

308357
let entry = this.#map.get(key);
309358
if (entry !== undefined) {
310-
Object.assign(entry, values, { labels });
359+
Object.assign(entry, values, { labels: entry.labels });
311360
} else {
312-
entry = { ...values, labels };
313-
this.#map.set(key, entry);
361+
entry = this.#insert(key, { ...values, labels });
314362
}
315363

316364
return entry;
@@ -434,6 +482,8 @@ class LabelGrouper {
434482

435483
/**
436484
* Adds the `value` to the `key`'s array of values.
485+
*
486+
* NB: no normalization here. Store-backed labels arrive normalized.
437487
* @param {StatsEntry} value Value to add to `key`'s array.
438488
* @returns {LabelGrouper} undefined.
439489
*/

test/defaultMetricsTest.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,8 @@ describe.each([
101101
expect(allMetricValues.length).toBeGreaterThan(0);
102102

103103
allMetricValues.forEach(metricValue => {
104-
expect(metricValue.labels).toMatchObject(labels);
104+
// Label values are normalized to strings at the storage boundary.
105+
expect(metricValue.labels).toMatchObject({ NODE_APP_INSTANCE: '0' });
105106
});
106107
});
107108

test/metrics/versionTest.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ function expectVersionMetrics(metrics) {
2525
expect(metrics[0].type).toEqual('gauge');
2626
expect(metrics[0].name).toEqual('nodejs_version_info');
2727
expect(metrics[0].values[0].labels.version).toEqual(nodeVersion);
28-
expect(metrics[0].values[0].labels.major).toEqual(versionSegments[0]);
29-
expect(metrics[0].values[0].labels.minor).toEqual(versionSegments[1]);
30-
expect(metrics[0].values[0].labels.patch).toEqual(versionSegments[2]);
28+
// Label values are normalized to strings at the storage boundary.
29+
expect(metrics[0].values[0].labels.major).toEqual(`${versionSegments[0]}`);
30+
expect(metrics[0].values[0].labels.minor).toEqual(`${versionSegments[1]}`);
31+
expect(metrics[0].values[0].labels.patch).toEqual(`${versionSegments[2]}`);
3132
}
3233

3334
describe.each([

test/registerTest.js

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,47 @@ describe('Register', () => {
340340
expect(escapedResult).toMatch(/\\"/);
341341
});
342342

343+
it('should escape non-string label values recorded through a metric', async () => {
344+
const gauge = new Gauge({
345+
name: 'test_metric',
346+
help: 'A test metric',
347+
labelNames: ['label', 'code', 'count'],
348+
});
349+
gauge.set({ label: ['say "hi"'], code: ['a\nb'], count: 3 }, 12);
350+
351+
const escapedResult = await register.metrics();
352+
expect(escapedResult).toMatch(/label="say \\"hi\\""/);
353+
expect(escapedResult).toMatch(/code="a\\nb"/);
354+
expect(escapedResult).toMatch(/count="3"/);
355+
});
356+
357+
it('should escape summary labels stored inside the summary value', async () => {
358+
const summary = new Summary({
359+
name: 'test_summary',
360+
help: 'A test summary',
361+
labelNames: ['x'],
362+
percentiles: [0.5],
363+
});
364+
summary.observe({ x: ['say "hi"'] }, 1);
365+
366+
const escapedResult = await register.metrics();
367+
expect(escapedResult).toMatch(/x="say \\"hi\\""/);
368+
});
369+
370+
it('should render inherited enumerable labels recorded through a metric', async () => {
371+
const gauge = new Gauge({
372+
name: 'test_metric',
373+
help: 'A test metric',
374+
labelNames: ['region', 'method'],
375+
});
376+
const labels = Object.create({ region: 'eu' });
377+
labels.method = 'GET';
378+
gauge.set(labels, 1);
379+
380+
const result = await register.metrics();
381+
expect(result).toContain('test_metric{method="GET",region="eu"} 1');
382+
});
383+
343384
describe('getMetricsAsArray()', () => {
344385
it('should return metrics', async () => {
345386
register.registerMetric(getMetric());
@@ -831,7 +872,9 @@ describe('Register', () => {
831872
});
832873

833874
describe('AggregatorRegistry.aggregate()', () => {
834-
// These mimic the output of `getMetricsAsJSON`.
875+
// Direct aggregate inputs exercising label pass-through. aggregate()
876+
// does not normalize, so raw numeric labels here stay raw. (Store-backed
877+
// labels in real `getMetricsAsJSON` output arrive already normalized.)
835878
const metrics1 = [
836879
{
837880
name: 'test_histogram',

test/summaryTest.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@ describe.each([
5858
expect((await instance.get()).values[8].value).toEqual(1);
5959
});
6060

61+
it('should report empty labels for sum and count', async () => {
62+
instance.observe(100);
63+
// Through the registry, because that is the documented shape.
64+
const [{ values }] = await globalRegistry.getMetricsAsJSON();
65+
expect(values[7].metricName).toEqual('summary_test_sum');
66+
expect(values[7].labels).toEqual({});
67+
expect(values[8].metricName).toEqual('summary_test_count');
68+
expect(values[8].labels).toEqual({});
69+
});
70+
6171
it('should validate labels when observing', async () => {
6272
const summary = new Summary({
6373
name: 'foobar',
@@ -184,6 +194,18 @@ describe.each([
184194
});
185195
});
186196

197+
it("should report the stored labels, not the caller's object", async () => {
198+
const labels = { method: 3, endpoint: '/test' };
199+
instance.observe(labels, 50);
200+
labels.method = 'mutated afterwards';
201+
202+
const { values } = await instance.get();
203+
expect(values).toHaveLength(3);
204+
for (const value of values) {
205+
expect(value.labels.method).toEqual('3');
206+
}
207+
});
208+
187209
it('should record and calculate the correct values per label', async () => {
188210
instance.labels('GET', '/test').observe(50);
189211
instance.labels('POST', '/test').observe(100);

0 commit comments

Comments
 (0)