diff --git a/model/metric.go b/model/metric.go index 2fe46151..09a0bc7f 100644 --- a/model/metric.go +++ b/model/metric.go @@ -320,6 +320,26 @@ func IsValidLegacyMetricName(n string) bool { return LegacyValidation.IsValidMetricName(n) } +// IsValidLegacyLabelName reports whether n is a valid legacy label name. +// Unlike legacy metric names, legacy label names never permit ':' (colon), +// which is reserved for metric names only. This function does not use +// LabelNameRE for the check but a much faster hardcoded implementation. +// +// Use this, rather than IsValidLegacyMetricName, whenever the string in +// question is a label name and not a metric name (the value of the __name__ +// label is a metric name). +func IsValidLegacyLabelName(n string) bool { + if len(n) == 0 { + return false + } + for i, b := range n { + if !isValidLegacyLabelRune(b, i) { + return false + } + } + return true +} + // EscapeMetricFamily escapes the given metric names and labels with the given // escaping scheme. Returns a new object that uses the same pointers to fields // when possible and creates new escaped versions so as not to mutate the @@ -362,6 +382,8 @@ func EscapeMetricFamily(v *dto.MetricFamily, scheme EscapingScheme) *dto.MetricF for _, l := range m.Label { if l.GetName() == MetricNameLabel { + // The value of __name__ is a metric name, so it is validated + // and escaped with the metric-name rules (colons allowed). if l.Value == nil || IsValidLegacyMetricName(l.GetValue()) { escaped.Label = append(escaped.Label, l) continue @@ -372,12 +394,14 @@ func EscapeMetricFamily(v *dto.MetricFamily, scheme EscapingScheme) *dto.MetricF }) continue } - if l.Name == nil || IsValidLegacyMetricName(l.GetName()) { + // Label names never permit colons in the data model, so they are + // validated and escaped with the label-name rules. + if l.Name == nil || IsValidLegacyLabelName(l.GetName()) { escaped.Label = append(escaped.Label, l) continue } escaped.Label = append(escaped.Label, &dto.LabelPair{ - Name: proto.String(EscapeName(l.GetName(), scheme)), + Name: proto.String(EscapeLabelName(l.GetName(), scheme)), Value: l.Value, }) } @@ -391,7 +415,7 @@ func metricNeedsEscaping(m *dto.Metric) bool { if l.GetName() == MetricNameLabel && !IsValidLegacyMetricName(l.GetValue()) { return true } - if !IsValidLegacyMetricName(l.GetName()) { + if !IsValidLegacyLabelName(l.GetName()) { return true } } @@ -462,6 +486,74 @@ func EscapeName(name string, scheme EscapingScheme) string { } } +// EscapeLabelName escapes the incoming label name according to the provided +// escaping scheme. It is like [EscapeName] but applies the label-name validity +// rules, which never permit ':' (colon) even though colons are allowed in +// metric names. This is the correct entry point for label names; [EscapeName] +// should only be used for metric names. +// +// Use this for any name that is a label name rather than a metric name (the +// value of the __name__ label is a metric name). +func EscapeLabelName(name string, scheme EscapingScheme) string { + if len(name) == 0 { + return name + } + var escaped strings.Builder + switch scheme { + case NoEscaping: + return name + case UnderscoreEscaping: + if IsValidLegacyLabelName(name) { + return name + } + for i, b := range name { + if isValidLegacyLabelRune(b, i) { + escaped.WriteRune(b) + } else { + escaped.WriteRune('_') + } + } + return escaped.String() + case DotsEscaping: + // Do not early return for legacy valid names, we still escape underscores. + for i, b := range name { + switch { + case b == '_': + escaped.WriteString("__") + case b == '.': + escaped.WriteString("_dot_") + case isValidLegacyLabelRune(b, i): + escaped.WriteRune(b) + default: + escaped.WriteString("__") + } + } + return escaped.String() + case ValueEncodingEscaping: + if IsValidLegacyLabelName(name) { + return name + } + escaped.WriteString("U__") + for i, b := range name { + switch { + case b == '_': + escaped.WriteString("__") + case isValidLegacyLabelRune(b, i): + escaped.WriteRune(b) + case !utf8.ValidRune(b): + escaped.WriteString("_FFFD_") + default: + escaped.WriteRune('_') + escaped.WriteString(strconv.FormatInt(int64(b), 16)) + escaped.WriteRune('_') + } + } + return escaped.String() + default: + panic(fmt.Sprintf("invalid escaping scheme %d", scheme)) + } +} + // lower function taken from strconv.atoi. func lower(c byte) byte { return c | ('x' - 'X') @@ -549,6 +641,12 @@ func isValidLegacyRune(b rune, i int) bool { return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '_' || b == ':' || (b >= '0' && b <= '9' && i > 0) } +// isValidLegacyLabelRune is like isValidLegacyRune but does not permit ':', +// which is reserved for metric names and has never been valid in label names. +func isValidLegacyLabelRune(b rune, i int) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '_' || (b >= '0' && b <= '9' && i > 0) +} + func (e EscapingScheme) String() string { switch e { case NoEscaping: diff --git a/model/metric_test.go b/model/metric_test.go index 90f9f9bf..12b40812 100644 --- a/model/metric_test.go +++ b/model/metric_test.go @@ -571,6 +571,115 @@ func TestEscapeName(t *testing.T) { } } +func TestIsValidLegacyLabelName(t *testing.T) { + scenarios := []struct { + name string + input string + valid bool + }{ + {name: "empty", input: "", valid: false}, + {name: "plain label", input: "foo_bar", valid: true}, + {name: "label starting with digit", input: "1foo", valid: false}, + {name: "label with digit after first", input: "foo1", valid: true}, + {name: "label with colon", input: "app:instance", valid: false}, + {name: "label with hyphen", input: "app-instance", valid: false}, + {name: "label with dot", input: "app.instance", valid: false}, + {name: "underscore only", input: "_", valid: true}, + {name: "single letter", input: "a", valid: true}, + } + + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + got := IsValidLegacyLabelName(scenario.input) + if got != scenario.valid { + t.Errorf("IsValidLegacyLabelName(%q) = %v, want %v", scenario.input, got, scenario.valid) + } + }) + } +} + +func TestEscapeLabelName(t *testing.T) { + scenarios := []struct { + name string + input string + expectedUnderscores string + expectedDots string + expectedUnescapedDots string + expectedValue string + }{ + { + name: "empty string", + }, + { + // A colon is valid in metric names but NOT in label names; it must + // be escaped for label names. This is the regression for + // prometheus/prometheus#18380. + name: "label name with colon", + input: "app:instance-id", + expectedUnderscores: "app_instance_id", + expectedDots: "app__instance__id", + expectedUnescapedDots: "app_instance_id", + expectedValue: "U__app_3a_instance_2d_id", + }, + { + name: "legacy valid label name (no colon)", + input: "no_escaping_required", + expectedUnderscores: "no_escaping_required", + expectedDots: "no__escaping__required", + // Dots escaping will escape underscores even though it's not strictly + // necessary for compatibility. + expectedUnescapedDots: "no_escaping_required", + expectedValue: "no_escaping_required", + }, + { + name: "label name with dots", + input: "app.instance.id", + expectedUnderscores: "app_instance_id", + expectedDots: "app_dot_instance_dot_id", + expectedUnescapedDots: "app.instance.id", + expectedValue: "U__app_2e_instance_2e_id", + }, + { + // A label name that is legacy-valid for metric names (colon at + // position 0) must still be escaped for labels. + name: "colon at start", + input: ":label", + expectedUnderscores: "_label", + expectedDots: "__label", + expectedUnescapedDots: "_label", + expectedValue: "U___3a_label", + }, + } + + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + got := EscapeLabelName(scenario.input, UnderscoreEscaping) + if got != scenario.expectedUnderscores { + t.Errorf("underscores: expected %q but got %q", scenario.expectedUnderscores, got) + } + + got = EscapeLabelName(scenario.input, DotsEscaping) + if got != scenario.expectedDots { + t.Errorf("dots: expected %q but got %q", scenario.expectedDots, got) + } + got = UnescapeName(got, DotsEscaping) + if got != scenario.expectedUnescapedDots { + t.Errorf("dots unescaped: expected %q but got %q", scenario.expectedUnescapedDots, got) + } + + got = EscapeLabelName(scenario.input, ValueEncodingEscaping) + if got != scenario.expectedValue { + t.Errorf("values: expected %q but got %q", scenario.expectedValue, got) + } + // Value escaping is round-trippable. + got = UnescapeName(got, ValueEncodingEscaping) + if got != scenario.input { + t.Errorf("values unescaped: expected %q but got %q", scenario.input, got) + } + }) + } +} + func TestValueUnescapeErrors(t *testing.T) { scenarios := []struct { name string @@ -842,6 +951,107 @@ func TestEscapeMetricFamily(t *testing.T) { }, }, }, + { + // Regression for prometheus/prometheus#18380: a colon in a label + // name must be escaped, even though colons are valid in metric + // names. __name__ keeps its colon because its value is a metric name. + name: "label name with colon, underscores scheme", + scheme: UnderscoreEscaping, + input: &dto.MetricFamily{ + Name: proto.String("my:metric"), + Help: proto.String("some help text"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + { + Counter: &dto.Counter{ + Value: proto.Float64(1), + }, + Label: []*dto.LabelPair{ + { + Name: proto.String("__name__"), + Value: proto.String("my:metric"), + }, + { + Name: proto.String("app:instance-id"), + Value: proto.String("server-1"), + }, + }, + }, + }, + }, + expected: &dto.MetricFamily{ + Name: proto.String("my:metric"), + Help: proto.String("some help text"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + { + Counter: &dto.Counter{ + Value: proto.Float64(1), + }, + Label: []*dto.LabelPair{ + { + Name: proto.String("__name__"), + Value: proto.String("my:metric"), + }, + { + Name: proto.String("app_instance_id"), + Value: proto.String("server-1"), + }, + }, + }, + }, + }, + }, + { + // Same as above but with ValueEncodingEscaping, to verify the + // colon is encoded as _3a_ for label names while __name__ keeps it. + name: "label name with colon, values scheme", + scheme: ValueEncodingEscaping, + input: &dto.MetricFamily{ + Name: proto.String("my:metric"), + Help: proto.String("some help text"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + { + Counter: &dto.Counter{ + Value: proto.Float64(1), + }, + Label: []*dto.LabelPair{ + { + Name: proto.String("__name__"), + Value: proto.String("my:metric"), + }, + { + Name: proto.String("app:instance-id"), + Value: proto.String("server-1"), + }, + }, + }, + }, + }, + expected: &dto.MetricFamily{ + Name: proto.String("my:metric"), + Help: proto.String("some help text"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + { + Counter: &dto.Counter{ + Value: proto.Float64(1), + }, + Label: []*dto.LabelPair{ + { + Name: proto.String("__name__"), + Value: proto.String("my:metric"), + }, + { + Name: proto.String("U__app_3a_instance_2d_id"), + Value: proto.String("server-1"), + }, + }, + }, + }, + }, + }, } unexportList := []any{dto.MetricFamily{}, dto.Metric{}, dto.LabelPair{}, dto.Counter{}, dto.Gauge{}}