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
5 changes: 5 additions & 0 deletions docs/configuration/config-file-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -4344,6 +4344,11 @@ query_rejection:

# list of rule groups to disable
[disabled_rule_groups: <list of DisabledRuleGroup> | default = []]

# Name validation scheme for metric names and label names, Support values are:
# legacy, utf8.
# CLI flag: -validation.name-validation-scheme
[name_validation_scheme: <int> | default = legacy]
```
### `memberlist_config`
Expand Down
27 changes: 14 additions & 13 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,15 @@ require (
github.com/opentracing/opentracing-go v1.2.0
github.com/pkg/errors v0.9.1
github.com/prometheus/alertmanager v0.28.1
github.com/prometheus/client_golang v1.23.0-rc.1
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2
github.com/prometheus/common v0.65.1-0.20250703115700-7f8b2a0d32d3
github.com/prometheus/common v0.66.1
// Prometheus maps version 2.x.y to tags v0.x.y.
github.com/prometheus/prometheus v0.305.1-0.20250808023455-1e4144a496fb
github.com/prometheus/prometheus v0.306.0
github.com/segmentio/fasthash v1.0.3
github.com/sony/gobreaker v1.0.0
github.com/spf13/afero v1.11.0
github.com/stretchr/testify v1.10.0
github.com/stretchr/testify v1.11.1
github.com/thanos-io/objstore v0.0.0-20250722142242-922b22272ee3
github.com/thanos-io/promql-engine v0.0.0-20250924193140-e9123dc11264
github.com/thanos-io/thanos v0.39.3-0.20250729120336-88d0ae8071cb
Expand All @@ -66,8 +66,8 @@ require (
go.opentelemetry.io/otel/sdk v1.36.0
go.opentelemetry.io/otel/trace v1.36.0
go.uber.org/atomic v1.11.0
golang.org/x/net v0.41.0
golang.org/x/sync v0.15.0
golang.org/x/net v0.43.0
golang.org/x/sync v0.16.0
golang.org/x/time v0.12.0
google.golang.org/grpc v1.73.0
gopkg.in/yaml.v2 v2.4.0
Expand All @@ -94,7 +94,7 @@ require (
github.com/tjhop/slog-gokit v0.1.4
go.opentelemetry.io/collector/pdata v1.35.0
go.uber.org/automaxprocs v1.6.0
google.golang.org/protobuf v1.36.6
google.golang.org/protobuf v1.36.8
)

require (
Expand Down Expand Up @@ -230,7 +230,7 @@ require (
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus-community/prom-label-proxy v0.11.1 // indirect
github.com/prometheus/exporter-toolkit v0.14.0 // indirect
github.com/prometheus/otlptranslator v0.0.0-20250731173911-a9673827589a // indirect
github.com/prometheus/otlptranslator v0.0.0-20250620074007-94f535e0c588 // indirect
github.com/prometheus/sigv4 v0.2.0 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/rantav/go-grpc-channelz v0.0.4 // indirect
Expand Down Expand Up @@ -279,15 +279,16 @@ require (
go.uber.org/goleak v1.3.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go4.org/intern v0.0.0-20230525184215-6c62f75575cb // indirect
go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect
golang.org/x/crypto v0.39.0 // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/mod v0.26.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.26.0 // indirect
golang.org/x/tools v0.34.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/tools v0.35.0 // indirect
gonum.org/v1/gonum v0.16.0 // indirect
google.golang.org/api v0.239.0 // indirect
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect
Expand Down
61 changes: 30 additions & 31 deletions go.sum

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions integration/e2e/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/go-kit/log"
"github.com/pkg/errors"
"github.com/prometheus/common/expfmt"
"github.com/prometheus/common/model"
"github.com/thanos-io/thanos/pkg/runutil"

"github.com/cortexproject/cortex/pkg/util/backoff"
Expand Down Expand Up @@ -623,7 +624,7 @@ func (s *HTTPService) SumMetrics(metricNames []string, opts ...MetricsOption) ([
return nil, err
}

var tp expfmt.TextParser
tp := expfmt.NewTextParser(model.LegacyValidation)
families, err := tp.TextToMetricFamilies(strings.NewReader(metrics))
if err != nil {
return nil, err
Expand Down Expand Up @@ -670,7 +671,7 @@ func (s *HTTPService) WaitRemovedMetric(metricName string, opts ...MetricsOption
}

// Parse metrics.
var tp expfmt.TextParser
tp := expfmt.NewTextParser(model.LegacyValidation)
families, err := tp.TextToMetricFamilies(strings.NewReader(metrics))
if err != nil {
return err
Expand Down
137 changes: 137 additions & 0 deletions integration/utf8_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
//go:build requires_docker
// +build requires_docker

package integration

import (
"fmt"
"path/filepath"
"testing"
"time"

"github.com/prometheus/prometheus/prompb"
"github.com/stretchr/testify/require"

"github.com/cortexproject/cortex/integration/e2e"
e2edb "github.com/cortexproject/cortex/integration/e2e/db"
"github.com/cortexproject/cortex/integration/e2ecortex"
)

func Test_RulerExternalLabels_UTF8Validation(t *testing.T) {
s, err := e2e.NewScenario(networkName)
require.NoError(t, err)
defer s.Close()

// Start dependencies.
minio := e2edb.NewMinio(9000, bucketName)
require.NoError(t, s.StartAndWaitReady(minio))

runtimeConfigYamlFile := `
overrides:
'user-2':
name_validation_scheme: utf8
ruler_external_labels:
test.utf8.metric: 😄
`
require.NoError(t, writeFileToSharedDir(s, runtimeConfigFile, []byte(runtimeConfigYamlFile)))
filePath := filepath.Join(e2e.ContainerSharedDir, runtimeConfigFile)

// Start Cortex components.
require.NoError(t, copyFileToSharedDir(s, "docs/configuration/single-process-config-blocks.yaml", cortexConfigFile))

flags := map[string]string{
"-auth.enabled": "true",
"-runtime-config.file": filePath,
"-runtime-config.backend": "filesystem",
// ingester
"-blocks-storage.s3.access-key-id": e2edb.MinioAccessKey,
"-blocks-storage.s3.secret-access-key": e2edb.MinioSecretKey,
"-blocks-storage.s3.bucket-name": bucketName,
"-blocks-storage.s3.endpoint": fmt.Sprintf("%s-minio-9000:9000", networkName),
"-blocks-storage.s3.insecure": "true",
// alert manager
"-alertmanager.web.external-url": "http://localhost/alertmanager",
"-alertmanager-storage.backend": "local",
"-alertmanager-storage.local.path": filepath.Join(e2e.ContainerSharedDir, "alertmanager_configs"),
}
// make alert manager config dir
require.NoError(t, writeFileToSharedDir(s, "alertmanager_configs", []byte{}))

// The external labels validation should be success
cortex := e2ecortex.NewSingleBinaryWithConfigFile("cortex-1", cortexConfigFile, flags, "", 9009, 9095)
require.NoError(t, s.StartAndWaitReady(cortex))
}

func Test_Distributor_UTF8ValidationPerTenant(t *testing.T) {
s, err := e2e.NewScenario(networkName)
require.NoError(t, err)
defer s.Close()

// Start dependencies.
minio := e2edb.NewMinio(9000, bucketName)
require.NoError(t, s.StartAndWaitReady(minio))

runtimeConfigYamlFile := `
overrides:
'user-2':
name_validation_scheme: utf8
`

require.NoError(t, writeFileToSharedDir(s, runtimeConfigFile, []byte(runtimeConfigYamlFile)))
filePath := filepath.Join(e2e.ContainerSharedDir, runtimeConfigFile)

// Start Cortex components.
require.NoError(t, copyFileToSharedDir(s, "docs/configuration/single-process-config-blocks.yaml", cortexConfigFile))

flags := map[string]string{
"-auth.enabled": "true",
"-runtime-config.file": filePath,
"-runtime-config.backend": "filesystem",
// ingester
"-blocks-storage.s3.access-key-id": e2edb.MinioAccessKey,
"-blocks-storage.s3.secret-access-key": e2edb.MinioSecretKey,
"-blocks-storage.s3.bucket-name": bucketName,
"-blocks-storage.s3.endpoint": fmt.Sprintf("%s-minio-9000:9000", networkName),
"-blocks-storage.s3.insecure": "true",
// alert manager
"-alertmanager.web.external-url": "http://localhost/alertmanager",
"-alertmanager-storage.backend": "local",
"-alertmanager-storage.local.path": filepath.Join(e2e.ContainerSharedDir, "alertmanager_configs"),
}
// make alert manager config dir
require.NoError(t, writeFileToSharedDir(s, "alertmanager_configs", []byte{}))

cortex := e2ecortex.NewSingleBinaryWithConfigFile("cortex-1", cortexConfigFile, flags, "", 9009, 9095)
require.NoError(t, s.StartAndWaitReady(cortex))

// user-1 uses legacy validation
user1Client, err := e2ecortex.NewClient(cortex.HTTPEndpoint(), "", "", "", "user-1")
require.NoError(t, err)

// user-2 uses utf8 validation
user2Client, err := e2ecortex.NewClient(cortex.HTTPEndpoint(), "", "", "", "user-2")
require.NoError(t, err)

now := time.Now()

utf8Series, _ := generateSeries("series_1", now, prompb.Label{Name: "test.utf8.metric", Value: "😄"})
legacySeries, _ := generateSeries("series_2", now, prompb.Label{Name: "job", Value: "test"})

res, err := user1Client.Push(legacySeries)
require.NoError(t, err)
require.Equal(t, 200, res.StatusCode)

// utf8Series push should be fail for user-1
res, err = user1Client.Push(utf8Series)
require.NoError(t, err)
require.Equal(t, 400, res.StatusCode)

res, err = user2Client.Push(legacySeries)
require.NoError(t, err)
require.Equal(t, 200, res.StatusCode)

// utf8Series push should be success for user-2
res, err = user2Client.Push(utf8Series)
require.NoError(t, err)
require.Equal(t, 200, res.StatusCode)
}
7 changes: 7 additions & 0 deletions pkg/ruler/external_labels_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ func TestUserExternalLabels(t *testing.T) {
userExternalLabels: labels.FromStrings("from", "cloud", "tag", "local"),
expectedExternalLabels: labels.FromStrings("from", "cloud", "tag", "local"),
},
{
name: "utf8",
removeBeforeTest: true,
exists: false,
userExternalLabels: labels.FromStrings("test.utf8.metric", "😄"),
expectedExternalLabels: labels.FromStrings("from", "cortex", "test.utf8.metric", "😄"),
},
}

const userID = "test-user"
Expand Down
7 changes: 5 additions & 2 deletions pkg/util/validation/exporter.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,13 @@ func ExtractNumericalValues(l *Limits) map[string]float64 {

switch field.Kind() {
case reflect.Int, reflect.Int64:
if field.Type().String() == "model.Duration" {
switch fieldType.Type.String() {
case "model.Duration":
// we export the model.Duration in seconds
metrics[tag] = time.Duration(field.Int()).Seconds()
} else {
case "model.ValidationScheme":
// skip
default:
metrics[tag] = float64(field.Int())
}
case reflect.Uint, reflect.Uint64:
Expand Down
17 changes: 16 additions & 1 deletion pkg/util/validation/limits.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,8 @@ type Limits struct {
AlertmanagerMaxSilencesCount int `yaml:"alertmanager_max_silences_count" json:"alertmanager_max_silences_count"`
AlertmanagerMaxSilencesSizeBytes int `yaml:"alertmanager_max_silences_size_bytes" json:"alertmanager_max_silences_size_bytes"`
DisabledRuleGroups DisabledRuleGroups `yaml:"disabled_rule_groups" json:"disabled_rule_groups" doc:"nocli|description=list of rule groups to disable"`

NameValidationScheme model.ValidationScheme `yaml:"name_validation_scheme" json:"name_validation_scheme"`
}

// RegisterFlags adds the flags required to config this to the given FlagSet
Expand Down Expand Up @@ -354,6 +356,9 @@ func (l *Limits) RegisterFlags(f *flag.FlagSet) {
f.IntVar(&l.AlertmanagerMaxAlertsSizeBytes, "alertmanager.max-alerts-size-bytes", 0, "Maximum total size of alerts that a single user can have, alert size is the sum of the bytes of its labels, annotations and generatorURL. Inserting more alerts will fail with a log message and metric increment. 0 = no limit.")
f.IntVar(&l.AlertmanagerMaxSilencesCount, "alertmanager.max-silences-count", 0, "Maximum number of silences that a single user can have, including expired silences. 0 = no limit.")
f.IntVar(&l.AlertmanagerMaxSilencesSizeBytes, "alertmanager.max-silences-size-bytes", 0, "Maximum size of individual silences that a single user can have. 0 = no limit.")

_ = l.NameValidationScheme.Set(model.LegacyValidation.String())
f.Var(&l.NameValidationScheme, "validation.name-validation-scheme", fmt.Sprintf("Name validation scheme for metric names and label names, Support values are: %s.", strings.Join([]string{model.LegacyValidation.String(), model.UTF8Validation.String()}, ", ")))
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder why this is a runtime limit. Is it intended to be configurable per tenant? Where do we actually set this value

Copy link
Member Author

Choose a reason for hiding this comment

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

um.. I'd like to hear what other people think about whether it should be configurable per tenant or not. WDYT?

Copy link
Contributor

Choose a reason for hiding this comment

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

I think it is ok to be a per tenant runtime config. But I don't see the code where we actually set this value.

Copy link
Member Author

Choose a reason for hiding this comment

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

https://github.com/cortexproject/cortex/blob/v1.19.0/pkg/distributor/distributor.go#L721
The distributor is already using per-user config. I added some e2e tests.

}

// Validate the limits config and returns an error if the validation
Expand All @@ -376,8 +381,18 @@ func (l *Limits) Validate(shardByAllLabels bool, activeSeriesMetricsEnabled bool
return errMaxLocalNativeHistogramSeriesPerUserValidation
}

var nameValidationScheme model.ValidationScheme
switch l.NameValidationScheme {
case model.LegacyValidation, model.UTF8Validation:
nameValidationScheme = l.NameValidationScheme
case model.UnsetValidation:
nameValidationScheme = model.LegacyValidation
default:
return fmt.Errorf("unsupported name validation scheme: %s", l.NameValidationScheme)
}

if err := l.RulerExternalLabels.Validate(func(l labels.Label) error {
if !model.LabelName(l.Name).IsValid() {
if !nameValidationScheme.IsValidLabelName(l.Name) {
return fmt.Errorf("%w: %q", errInvalidLabelName, l.Name)
}
if !model.LabelValue(l.Value).IsValid() {
Expand Down
14 changes: 13 additions & 1 deletion pkg/util/validation/limits_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,18 @@ func TestLimits_Validate(t *testing.T) {
limits: Limits{RulerExternalLabels: labels.FromStrings("good", string([]byte{0xff, 0xfe, 0xfd}))},
expected: errInvalidLabelValue,
},
"utf8: external-labels utf8 label name and value": {
limits: Limits{NameValidationScheme: model.UTF8Validation, RulerExternalLabels: labels.FromStrings("test.utf8.metric", "😄")},
expected: nil,
},
"utf8: external-labels invalid label name": {
limits: Limits{NameValidationScheme: model.UTF8Validation, RulerExternalLabels: labels.FromStrings("test.\xc5.metric", "😄")},
expected: errInvalidLabelName,
},
"utf8: external-labels invalid label value": {
limits: Limits{NameValidationScheme: model.UTF8Validation, RulerExternalLabels: labels.FromStrings("test.utf8.metric", "test.\xc5.value")},
expected: errInvalidLabelValue,
},
}

for testName, testData := range tests {
Expand Down Expand Up @@ -245,7 +257,7 @@ limits_per_label_set:
err := yaml.Unmarshal([]byte(inputYAML), &limitsYAML)
require.NoError(t, err)
require.Len(t, limitsYAML.LimitsPerLabelSet, 1)
require.Len(t, limitsYAML.LimitsPerLabelSet[0].LabelSet, 1)
require.Equal(t, 1, limitsYAML.LimitsPerLabelSet[0].LabelSet.Len())
require.Equal(t, limitsYAML.LimitsPerLabelSet[0].Limits.MaxSeries, 10)

duplicatedInputYAML := `
Expand Down
4 changes: 2 additions & 2 deletions pkg/util/validation/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ func ValidateLabels(validateMetrics *ValidateMetrics, limits *Limits, userID str
return newNoMetricNameError()
}

if !model.IsValidMetricName(model.LabelValue(unsafeMetricName)) {
if !limits.NameValidationScheme.IsValidLabelName(unsafeMetricName) {
validateMetrics.DiscardedSamples.WithLabelValues(invalidMetricName, userID).Inc()
return newInvalidMetricNameError(unsafeMetricName)
}
Expand All @@ -304,7 +304,7 @@ func ValidateLabels(validateMetrics *ValidateMetrics, limits *Limits, userID str
labelsSizeBytes := 0

for _, l := range ls {
if !skipLabelNameValidation && !model.LabelName(l.Name).IsValid() {
if !skipLabelNameValidation && !limits.NameValidationScheme.IsValidLabelName(l.Name) {
validateMetrics.DiscardedSamples.WithLabelValues(invalidLabel, userID).Inc()
return newInvalidLabelError(ls, l.Name)
} else if len(l.Name) > maxLabelNameLength {
Expand Down
Loading
Loading