Summary
When a label value is not a string, escapeLabelValue() returns it untouched, and it is converted to a string later during interpolation — after escaping would have happened. If that string form contains " or a newline, the rendered exposition is malformed and promtool check metrics rejects the whole response.
Reproduction
const client = require('prom-client');
const registry = new client.Registry();
const g = new client.Gauge({ name: 'g', help: 'h', labelNames: ['x'], registers: [registry] });
// Array label value containing a quote
g.set({ x: ['say "hi"'] }, 1);
console.log(JSON.stringify(await registry.metrics()));
Rendered output (whole body, JSON.stringify'd so the escaping is unambiguous):
"# HELP g h\n# TYPE g gauge\ng{x=\"say \"hi\"\"} 1\n"
The quotes inside the label value are not escaped. With a newline instead, the value breaks the line in two:
g.set({ x: ['a\nb'] }, 1);
// "# HELP g h\n# TYPE g gauge\ng{x=\"a\nb\"} 1\n"
A plain string with the same content is escaped correctly, which is the contrast:
g.set({ x: 'say "hi"' }, 1);
// "# HELP g h\n# TYPE g gauge\ng{x=\"say \\\"hi\\\"\"} 1\n"
Effect on scraping
Rendering a registry that holds one such series plus two ordinary ones and piping it to promtool check metrics:
healthy_metric 1
bad_metric{x="oops"quote"} 1
another_healthy 2
error while linting: text format parsing error in line 7: unexpected end of label value "oops"
promtool exits rc=1. The same body with a plain string label value (bad_metric{x="oops\"quote"} 1) exits rc=0. The whole response is rejected, not just the offending series. (I checked the exposition with promtool; I did not point a running Prometheus server at it.)
Where it comes from
lib/registry.js:
function escapeLabelValue(str) {
if (typeof str !== 'string') {
return str;
}
return escapeString(str).replace(/"/g, '\\"');
}
The value is interpolated afterwards as `${n}="${escapeLabelValue(v)}"`, so a non-string is stringified after the escaping step has already been skipped. Removing the early return locally (coercing before escaping) changes the first example's output to g{x="say \"hi\""} 1, which isolates the behaviour to those lines.
Note
#194 described the same scraping failure for string label values and was closed as fixed. This is the remaining path for values that are not strings.
Whether non-string label values should be coerced and escaped, or rejected when recorded, seems like a policy call rather than something to decide in a patch — arrays in particular are already used in the wild (#582), so rejecting them would be a behaviour change. Happy to open a PR for whichever direction you prefer.
Environment
- prom-client:
main @ 21c8c85
- Node.js: v25.9.0
- promtool 3.11.3 (
prom/prometheus@sha256:e4254400b856)
- Scope of what I tested:
Gauge#set() with a label value, rendered via registry.metrics().
Summary
When a label value is not a string,
escapeLabelValue()returns it untouched, and it is converted to a string later during interpolation — after escaping would have happened. If that string form contains"or a newline, the rendered exposition is malformed andpromtool check metricsrejects the whole response.Reproduction
Rendered output (whole body,
JSON.stringify'd so the escaping is unambiguous):The quotes inside the label value are not escaped. With a newline instead, the value breaks the line in two:
A plain string with the same content is escaped correctly, which is the contrast:
Effect on scraping
Rendering a registry that holds one such series plus two ordinary ones and piping it to
promtool check metrics:promtoolexits rc=1. The same body with a plain string label value (bad_metric{x="oops\"quote"} 1) exits rc=0. The whole response is rejected, not just the offending series. (I checked the exposition withpromtool; I did not point a running Prometheus server at it.)Where it comes from
lib/registry.js:The value is interpolated afterwards as
`${n}="${escapeLabelValue(v)}"`, so a non-string is stringified after the escaping step has already been skipped. Removing the early return locally (coercing before escaping) changes the first example's output tog{x="say \"hi\""} 1, which isolates the behaviour to those lines.Note
#194 described the same scraping failure for string label values and was closed as fixed. This is the remaining path for values that are not strings.
Whether non-string label values should be coerced and escaped, or rejected when recorded, seems like a policy call rather than something to decide in a patch — arrays in particular are already used in the wild (#582), so rejecting them would be a behaviour change. Happy to open a PR for whichever direction you prefer.
Environment
main@21c8c85prom/prometheus@sha256:e4254400b856)Gauge#set()with a label value, rendered viaregistry.metrics().