Skip to content

Fix escaping for non-string label values - #792

Open
Hashim1999164 wants to merge 2 commits into
prometheus:mainfrom
Hashim1999164:fix/escape-non-string-label-values
Open

Fix escaping for non-string label values#792
Hashim1999164 wants to merge 2 commits into
prometheus:mainfrom
Hashim1999164:fix/escape-non-string-label-values

Conversation

@Hashim1999164

@Hashim1999164 Hashim1999164 commented Jul 31, 2026

Copy link
Copy Markdown

Summary

  • Non-string label values (for example arrays) skipped escapeLabelValue and were stringified only during template interpolation, so quotes and newlines could break Prometheus text format.
  • Coerce with `${str}` before escapeString / quote escaping, matching how string labels are already handled.
  • Adds a regression test for quote and newline cases from Non-string label values bypass escaping and can produce malformed exposition #791.

Fixes #791

Test plan

  • npx jest test/registerTest.js -t "should escape"
  • Confirm exposition with array label values passes promtool check metrics

Comment thread lib/registry.js Outdated
return str;
}
return escapeString(str).replace(/"/g, '\\"');
return escapeString(`${str}`).replace(/"/g, '\\"');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this will still throw for Symbol values. I'm not sure if it's worth trying to handle those or not.

@Hashim1999164

Copy link
Copy Markdown
Author

Updated escapeLabelValue to use String(value) so Symbol values no longer throw, and added a regression test. Also rewrote the commit with a DCO sign-off.

@Hashim1999164
Hashim1999164 force-pushed the fix/escape-non-string-label-values branch from ab68655 to dcd68f6 Compare July 31, 2026 19:11
Coerce label values with String() before escape so quotes and newlines
in array (and other non-string) values cannot break Prometheus text
format, and Symbol values do not throw.

Signed-off-by: Hashim Khan <sardarhashim30@gmail.com>
@Hashim1999164
Hashim1999164 force-pushed the fix/escape-non-string-label-values branch from dcd68f6 to e9a077c Compare July 31, 2026 19:11
@Hashim1999164

Copy link
Copy Markdown
Author

Updated to use String(value) so Symbol label values no longer throw, and added a regression test for that case. DCO sign-off is on the rewritten commit as well.

@jdmarshall jdmarshall left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Here the bill comes due.

If you want a sanity check done here in the code, you're paying for it by a 1/6 to 1/4 increase in metrics processing time. Every collection, every minute, of every hour of every day, for a problem that should have been corrected at PR time.

For some users, especially power users, this function is on the critical path in a prometheus-enabled app, because we do the most damage to the p95 response times of the host application every time the metrics collection kicks in. Heavy math being done periodically in a language not meant for heavy math.

Performance Regressions:
------------------------

registry ⇒ metrics() 1 x 64

 ⇒ @prometheus/client@latest   ▏████████████████████▕ 1,670 op/s | 41 samples | (baseline)
 ⇒ @prometheus/client@trunk    ▏███████████████████▌▕ 1,643 op/s | 41 samples | (0.98x slower)
 ⇒ @prometheus/client@current  ▏████████████████────▕ 1,351 op/s | 41 samples | (0.81x slower)

registry ⇒ metrics() 1 x 64 and openMetrics

 ⇒ @prometheus/client@latest   ▏███████████████████▌▕ 1,638 op/s | 41 samples | (baseline)
 ⇒ @prometheus/client@trunk    ▏████████████████████▕ 1,651 op/s | 40 samples | (1.01x faster)
 ⇒ @prometheus/client@current  ▏████████████████────▕ 1,342 op/s | 41 samples | (0.82x slower)

registry ⇒ metrics() 2 x 4

 ⇒ @prometheus/client@latest   ▏████████████████████▕ 6,216 op/s | 41 samples | (baseline)
 ⇒ @prometheus/client@trunk    ▏███████████████████▌▕ 6,125 op/s | 41 samples | (0.99x slower)
 ⇒ @prometheus/client@current  ▏███████████████─────▕ 4,738 op/s | 40 samples | (0.76x slower)

registry ⇒ metrics() 2 x 4 and openMetrics

 ⇒ @prometheus/client@latest   ▏███████████████████▌▕ 6,192 op/s | 41 samples | (baseline)
 ⇒ @prometheus/client@trunk    ▏████████████████████▕ 6,257 op/s | 41 samples | (1.01x faster)
 ⇒ @prometheus/client@current  ▏███████████████─────▕ 4,765 op/s | 41 samples | (0.77x slower)

registry ⇒ metrics() 2 x 8

 ⇒ @prometheus/client@latest   ▏████████████████████▕ 1,788 op/s | 41 samples | (baseline)
 ⇒ @prometheus/client@trunk    ▏███████████████████▌▕ 1,779 op/s | 41 samples | (0.99x slower)
 ⇒ @prometheus/client@current  ▏████████████████────▕ 1,433 op/s | 41 samples | (0.80x slower)

registry ⇒ metrics() 6 x 2

 ⇒ @prometheus/client@latest   ▏███████████████████▌▕ 1,400 op/s | 41 samples | (baseline)
 ⇒ @prometheus/client@trunk    ▏████████████████████▕ 1,415 op/s | 41 samples | (1.01x faster)
 ⇒ @prometheus/client@current  ▏████████████████────▕ 1,163 op/s | 41 samples | (0.83x slower)

registry ⇒ metrics() 6 x 2 and openMetrics

 ⇒ @prometheus/client@latest   ▏███████████████████▌▕ 1,391 op/s | 41 samples | (baseline)
 ⇒ @prometheus/client@trunk    ▏████████████████████▕ 1,402 op/s | 41 samples | (1.01x faster)
 ⇒ @prometheus/client@current  ▏████████████████────▕ 1,144 op/s | 41 samples | (0.82x slower)

So, I will not be landing this PR. This is a problem to be solved during bootstrapping, not during the hot, periodic path in the code.

That probably means fixing the problem in the Metrics constructor, or perhaps the LabelMap. Not here. Never here.

@jdmarshall

jdmarshall commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

It is bugs like the one this PR attempts to fix that are the reason why I made the benchmarks an active part of the PR process and reproduced the library we used to use to run them.

There are obvious solutions to many bugs that have a cost that everyone pays every time, for a problem 5% of users encounter. That tax is better discussed before merge rather than after.

Coerce non-string label values with String() only when needed, and document the fix in CHANGELOG.

Signed-off-by: Hashim Khan <sardarhashim30@gmail.com>
@Hashim1999164

Copy link
Copy Markdown
Author

Thanks for the benchmark notes. I updated escapeLabelValue to keep the string fast path and only call String() for non-string values (for example Symbols), and added a CHANGELOG entry.

@jdmarshall

Copy link
Copy Markdown
Contributor

I'm certain that String(str) already has this exact same guard. The benchmarks did not improve. The author of the original ticket is making noises about tackling the deeper problem. You might want to coordinate with him?

milcho0604 added a commit to milcho0604/client_js that referenced this pull request Aug 2, 2026
…tored

Non-string label values bypassed escapeLabelValue() and could render
malformed exposition (prometheus#791): a value whose string form contains a quote
or a newline produced invalid output. Escaping them during metrics() was
rejected in prometheus#792/prometheus#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 prometheus#791

Signed-off-by: Changhyun Kim <milcho0604@gmail.com>
@Hashim1999164

Copy link
Copy Markdown
Author

Thanks for the follow-up. Agreed that a typeof guard before String() does not buy much once the engine already specializes String on strings, and the benchmarks make that clear.

I am fine closing this PR if you would rather wait for the deeper fix from the original issue author. Happy to coordinate or reshape this if there is a narrower change you still want here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Non-string label values bypass escaping and can produce malformed exposition

3 participants