You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
0 commit comments