Skip to content

Commit de2b362

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): a value whose string form contains a quote or a newline produced invalid output. Escaping them during metrics() was rejected in #792/#793 because metrics() cost is linear in total cardinality; the maintainer's counterproposal is to pay the cost at the storage boundary instead, once per new label combination. - LabelMap gains a single insertion point (#insert), used by set, setDelta, getOrAdd and merge. It replaces the entry's labels with a copy the store owns, coercing every value with template interpolation - the same ToString the exposition applies. - The copy is unconditional. The entry keeps those labels for its lifetime, so holding a reference the caller can still mutate would let a later mutation change what a stored series reports. Only a combination's first record reaches #insert, so recording an existing combination copies nothing. - normalizeLabels() walks the source with for...in, which picks up inherited enumerable labels; keyFrom() reads labels by name and sees those too, so a copy without them could not reproduce the key its entry is filed under, and remove(entry.labels) - which Summary's pruning uses - would quietly miss. - __proto__ is defined with Object.defineProperty: it passes the label name regexp, and plain assignment would invoke the prototype setter instead of defining a property, dropping the label. The spread that seeds the copy is for object shape - building it key by key instead costs about 24 bytes per stored series. - Nullish values are copied as-is: keyFrom() treats them as absent, so coercing them would make stored labels compute a different key than the one they are stored under, and would collapse {a: null} with {a: 'null'} after serialization. Their rendered form needs no escaping anyway. - merge() keeps the stored labels on update instead of overwriting them with the caller's raw object. - Summary's stored value no longer carries a second copy of the labels; the export helpers take entry.labels, so getOrAdd() is back to calling init() with no arguments. - LabelGrouper deliberately does NOT normalize: aggregation input comes from registry.getMetricsAsJSON(), whose store-backed labels were already normalized on first insertion, so re-checking every value would tax aggregate() for work the stores already did. Labels that never pass through the stores (custom collectors, registry default labels) flow through aggregation unchanged, as before. Measured on Node 24.11 (arm64), one implementation per process, median of 9 samples: recording an existing combination is unchanged (51.5ns -> 51.9ns); a new combination's first insertion costs 8-22% more depending on label count, and the per-record cost is back within noise by about a hundred records of that series. The repo's benchmark suite reports no significant regressions across its 46 cases. Retained heap at 250k unique series is unchanged (64.6MiB vs 64.7MiB). Observable changes: label values that pass through the built-in stores are reported as strings ('3' instead of 3) by getMetricsAsJSON(), metric get(), worker payloads and aggregation output; and a label-less summary reports labels: {} rather than labels: undefined, matching the other metric types. Both are noted in the changelog. Custom collector results, registry default labels and exemplar labels do not pass through the stores and are unchanged. Fixes #791 Signed-off-by: Changhyun Kim <milcho0604@gmail.com>
1 parent 9fcc3dc commit de2b362

8 files changed

Lines changed: 353 additions & 34 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ This release marks our first release under the Prometheus umbrella.
4646
- chore: Add copyright license headers and test
4747
- Make cluster and worker-thread metric aggregation order deterministic
4848
- Export `MetricObject`, `MetricObjectWithValues`, `MetricValue` and `MetricValueWithName` from the TypeScript definitions
49+
- fix: Non-string label values (except `null`/`undefined`) are coerced to strings when a
50+
combination is first stored, so exposition escapes them and `getMetricsAsJSON()` reports
51+
them as strings. The store now keeps its own copy of the labels: mutating the caller's
52+
object after recording no longer changes the stored series
53+
- fix: Label-less summaries report `labels: {}` in `getMetricsAsJSON()`, like other metrics
4954

5055
### Added
5156

lib/summary.js

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,10 @@ class Summary extends Metric {
7272
if (this.pruneAgedBuckets && s.td.size() === 0) {
7373
this.store.remove(entry.labels);
7474
} else {
75-
values.push(...extractSummariesForExport(s, this.percentiles));
76-
values.push(getSumForExport(s, this));
77-
values.push(getCountForExport(s, this));
75+
const labels = entry.labels;
76+
values.push(...extractSummariesForExport(s, labels, this.percentiles));
77+
values.push(getSumForExport(s, labels, this));
78+
values.push(getCountForExport(s, labels, this));
7879
}
7980
}
8081

@@ -126,30 +127,30 @@ class Summary extends Metric {
126127
}
127128
}
128129

129-
function extractSummariesForExport(summaryOfLabels, percentiles) {
130+
function extractSummariesForExport(summaryOfLabels, labels, percentiles) {
130131
summaryOfLabels.td.compress();
131132

132133
return percentiles.map(percentile => {
133134
const percentileValue = summaryOfLabels.td.percentile(percentile);
134135
return {
135-
labels: Object.assign({ quantile: percentile }, summaryOfLabels.labels),
136+
labels: Object.assign({ quantile: percentile }, labels),
136137
value: percentileValue ? percentileValue : 0,
137138
};
138139
});
139140
}
140141

141-
function getCountForExport(value, summary) {
142+
function getCountForExport(value, labels, summary) {
142143
return {
143144
metricName: `${summary.name}_count`,
144-
labels: value.labels,
145+
labels,
145146
value: value.count,
146147
};
147148
}
148149

149-
function getSumForExport(value, summary) {
150+
function getSumForExport(value, labels, summary) {
150151
return {
151152
metricName: `${summary.name}_sum`,
152-
labels: value.labels,
153+
labels,
153154
value: value.sum,
154155
};
155156
}
@@ -179,7 +180,6 @@ function observe(labels) {
179180

180181
const summaryOfLabel = this.store.getOrAdd(labelValuePair.labels, () => {
181182
return {
182-
labels: labelValuePair.labels,
183183
td: new timeWindowQuantiles(this.maxAgeSeconds, this.ageBuckets),
184184
count: 0,
185185
sum: 0,

lib/util.js

Lines changed: 96 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,72 @@ exports.nowTimestamp = function nowTimestamp() {
168168
* @property labels {object}
169169
*/
170170

171+
/**
172+
* Copy the labels the store is about to take ownership of, coercing
173+
* non-nullish values to the string Prometheus expects. Coercion uses
174+
* template interpolation — exactly what rendering would have done later —
175+
* so the rendered form is unchanged while escaping no longer gets
176+
* skipped (#791).
177+
*
178+
* The copy is unconditional: the entry keeps these labels for its lifetime,
179+
* so holding on to an object the caller can still mutate would let a later
180+
* mutation change what a stored series reports.
181+
*
182+
* Nullish values are copied as-is: `keyFrom()` treats them as absent, so
183+
* coercing them to `"null"`/`"undefined"` would make the stored labels
184+
* compute a different key than the one they are stored under — breaking
185+
* `remove(entry.labels)` round-trips (Summary's pruning does exactly that)
186+
* and collapsing `{a: null}` with `{a: 'null'}` after serialization. Their
187+
* rendered form (`"null"`) contains nothing that needs escaping anyway.
188+
*
189+
* Two corner cases are deliberately unsupported (each needs another
190+
* `for...in`-visible property present — alone, `isEmpty()` keeps original
191+
* and copy agreed on `''`):
192+
* - `Object.create(null)` labels missing a declared name that collides with
193+
* `Object.prototype` (`constructor`, …): the plain copy reads the
194+
* inherited member where the original read `undefined`.
195+
* - Non-enumerable label properties: no enumeration sees them, so the copy
196+
* drops what `keyFrom()` read by property access.
197+
* Symbols are not label data: `keyFrom()` iterates declared names,
198+
* exposition uses `Object.entries()`; neither reads them.
199+
* @param {object} labels
200+
* @returns {object} a copy owned by the store
201+
*/
202+
function normalizeLabels(labels) {
203+
// Spread first, for the packed object shape V8 gives a clone: building the
204+
// copy up key by key instead costs about 24 bytes per stored series.
205+
const copy = { ...labels };
206+
207+
// Then walk the source with `for...in`, which picks up inherited enumerable
208+
// labels too. `keyFrom()` reads labels by name, so it sees those as well, and
209+
// a copy that dropped them could not reproduce the key its entry is filed
210+
// under — `remove(entry.labels)`, which Summary's pruning uses, would quietly
211+
// miss. Non-enumerable labels stay out of reach of any enumeration; they only
212+
// survived before because the store kept the caller's object.
213+
for (const name in labels) {
214+
const value = labels[name];
215+
const stored =
216+
typeof value === 'string' || value === null || value === undefined
217+
? value
218+
: `${value}`;
219+
220+
if (name === '__proto__') {
221+
// A legal label name, but assigning it would invoke the prototype
222+
// setter instead of defining a property, dropping the label.
223+
Object.defineProperty(copy, name, {
224+
value: stored,
225+
writable: true,
226+
enumerable: true,
227+
configurable: true,
228+
});
229+
} else {
230+
copy[name] = stored;
231+
}
232+
}
233+
234+
return copy;
235+
}
236+
171237
/**
172238
* Lookup table for stats by labels.
173239
*/
@@ -182,6 +248,22 @@ class LabelMap {
182248
this.#labelNames = new Set(labelNames.slice().sort());
183249
}
184250

251+
/**
252+
* The single insertion point — every new label combination enters the map
253+
* here, and takes its own copy of the labels on the way in. Only a
254+
* combination's first record reaches this method, so the recording fast
255+
* path for existing combinations copies nothing.
256+
* @param {string} key precomputed `keyFrom(entry.labels)`
257+
* @param {StatsEntry} entry
258+
* @returns {StatsEntry}
259+
*/
260+
#insert(key, entry) {
261+
entry.labels = normalizeLabels(entry.labels);
262+
this.#map.set(key, entry);
263+
264+
return entry;
265+
}
266+
185267
/**
186268
* @function setValue
187269
* @param {object} labels
@@ -195,7 +277,7 @@ class LabelMap {
195277
if (entry !== undefined) {
196278
entry.value = value;
197279
} else {
198-
this.#map.set(key, { value, labels });
280+
this.#insert(key, { value, labels });
199281
}
200282

201283
return this;
@@ -214,7 +296,7 @@ class LabelMap {
214296
if (entry !== undefined) {
215297
entry.value += value;
216298
} else {
217-
this.#map.set(key, { value, labels });
299+
this.#insert(key, { value, labels });
218300
}
219301

220302
return this;
@@ -244,8 +326,7 @@ class LabelMap {
244326
let entry = this.#map.get(key);
245327

246328
if (entry === undefined) {
247-
entry = { value: init(), labels };
248-
this.#map.set(key, entry);
329+
entry = this.#insert(key, { value: init(), labels });
249330
}
250331

251332
return entry.value;
@@ -273,10 +354,11 @@ class LabelMap {
273354

274355
let entry = this.#map.get(key);
275356
if (entry !== undefined) {
276-
Object.assign(entry, values, { labels });
357+
// Keep the stored labels: they were copied on first insertion and
358+
// identify the same combination (same key).
359+
Object.assign(entry, values, { labels: entry.labels });
277360
} else {
278-
entry = { ...values, labels };
279-
this.#map.set(key, entry);
361+
entry = this.#insert(key, { ...values, labels });
280362
}
281363

282364
return entry;
@@ -400,6 +482,13 @@ class LabelGrouper {
400482

401483
/**
402484
* Adds the `value` to the `key`'s array of values.
485+
*
486+
* NB: no label normalization here, by design. Aggregation input comes from
487+
* `registry.getMetricsAsJSON()`, whose store-backed labels were already
488+
* normalized by LabelMap on first insertion — re-checking every value on
489+
* this path would tax `aggregate()` for work the stores already did.
490+
* Labels that never pass through the stores (custom collector results,
491+
* registry default labels) arrive here as-is, unchanged from before.
403492
* @param {StatsEntry} value Value to add to `key`'s array.
404493
* @returns {LabelGrouper} undefined.
405494
*/

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('should output metrics as JSON', () => {
344385
it('should output metrics as JSON', async () => {
345386
register.registerMetric(getMetric());
@@ -757,7 +798,9 @@ describe('Register', () => {
757798
});
758799

759800
describe('AggregatorRegistry.aggregate()', () => {
760-
// These mimic the output of `getMetricsAsJSON`.
801+
// Direct aggregate inputs exercising label pass-through — aggregate()
802+
// does not normalize, so raw numeric labels here stay raw. (Store-backed
803+
// labels in real `getMetricsAsJSON` output arrive already normalized.)
761804
const metrics1 = [
762805
{
763806
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)