Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 101 additions & 3 deletions model/metric.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
})
}
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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:
Expand Down
210 changes: 210 additions & 0 deletions model/metric_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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{}}
Expand Down
Loading