diff --git a/pkg/querier/querier.go b/pkg/querier/querier.go index ae5eae490e..adf261b490 100644 --- a/pkg/querier/querier.go +++ b/pkg/querier/querier.go @@ -56,6 +56,7 @@ func (cfg *Config) RegisterFlags(fs *flag.FlagSet) { type Limits interface { QueryAnalysisSeriesEnabled(string) bool + DisableLabelSanitization(string) bool } type Querier struct { @@ -274,6 +275,33 @@ func (q *Querier) LabelValues(ctx context.Context, req *connect.Request[typesv1. }), nil } +// keepUTF8LabelNames reports whether non-legacy (e.g. dotted) label names such +// as "nf.app" should be preserved in label-listing responses rather than +// filtered out by filterLabelNames. +// +// Upstream only preserves them when the request carries the per-request +// allow-utf8-labelnames client capability. In the v1 read path that capability +// never reaches the querier (it is parsed by the query-frontend HTTP middleware +// but querier.NewGRPCHandler does not re-apply it, and it is not propagated over +// the frontend->querier hop), so dotted label names are always filtered out of +// typeahead/label listings even though they are stored and matchable. +// +// We additionally preserve them whenever label sanitization is disabled for the +// tenant (validation.disable-label-sanitization). That is the same setting that +// keeps dotted label names verbatim on the write path, so honoring it on read +// makes read symmetric with write and works for every client (including the +// Grafana datasource query editor and dashboard variables, which do not send +// the capability). +func (q *Querier) keepUTF8LabelNames(ctx context.Context) bool { + if capabilities, ok := featureflags.GetClientCapabilities(ctx); ok && capabilities.AllowUtf8LabelNames { + return true + } + if tenantID, err := tenant.TenantID(ctx); err == nil && q.limits.DisableLabelSanitization(tenantID) { + return true + } + return false +} + func filterLabelNames(labelNames []string) []string { filtered := make([]string, 0, len(labelNames)) // Filter out label names not passing legacy validation if utf8 label names not enabled @@ -303,7 +331,7 @@ func (q *Querier) LabelNames(ctx context.Context, req *connect.Request[typesv1.L } labelNames := uniqueSortedStrings(responses) - if capabilities, ok := featureflags.GetClientCapabilities(ctx); !ok || !capabilities.AllowUtf8LabelNames { + if !q.keepUTF8LabelNames(ctx) { level.Debug(q.logger).Log("msg", "filtering out non-valid labels") labelNames = filterLabelNames(labelNames) } @@ -357,7 +385,7 @@ func (q *Querier) LabelNames(ctx context.Context, req *connect.Request[typesv1.L } labelNames := uniqueSortedStrings(responses) - if capabilities, ok := featureflags.GetClientCapabilities(ctx); !ok || !capabilities.AllowUtf8LabelNames { + if !q.keepUTF8LabelNames(ctx) { level.Debug(q.logger).Log("msg", "filtering out non-valid labels") labelNames = filterLabelNames(labelNames) } @@ -410,7 +438,7 @@ func (q *Querier) filterLabelNames( ctx context.Context, req *connect.Request[querierv1.SeriesRequest], ) ([]string, error) { - if capabilities, ok := featureflags.GetClientCapabilities(ctx); ok && capabilities.AllowUtf8LabelNames { + if q.keepUTF8LabelNames(ctx) { return req.Msg.LabelNames, nil } diff --git a/pkg/querier/querier_utf8_labels_test.go b/pkg/querier/querier_utf8_labels_test.go new file mode 100644 index 0000000000..cca24ae77d --- /dev/null +++ b/pkg/querier/querier_utf8_labels_test.go @@ -0,0 +1,65 @@ +package querier + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/pyroscope/v2/pkg/featureflags" + "github.com/grafana/pyroscope/v2/pkg/tenant" +) + +// stubLimits implements the querier Limits interface for tests. +type stubLimits struct { + disableLabelSanitization bool +} + +func (s stubLimits) QueryAnalysisSeriesEnabled(string) bool { return false } +func (s stubLimits) DisableLabelSanitization(string) bool { return s.disableLabelSanitization } + +func TestKeepUTF8LabelNames(t *testing.T) { + withTenant := func() context.Context { + return tenant.InjectTenantID(context.Background(), "tenant") + } + withCapability := func(ctx context.Context) context.Context { + return featureflags.WithClientCapabilities(ctx, featureflags.ClientCapabilities{AllowUtf8LabelNames: true}) + } + + for _, tc := range []struct { + name string + ctx context.Context + disableLabelSanitization bool + want bool + }{ + { + name: "sanitization disabled preserves dotted names without capability", + ctx: withTenant(), + disableLabelSanitization: true, + want: true, + }, + { + name: "sanitization enabled filters when no capability", + ctx: withTenant(), + disableLabelSanitization: false, + want: false, + }, + { + name: "client capability preserves dotted names even when sanitization enabled", + ctx: withCapability(withTenant()), + disableLabelSanitization: false, + want: true, + }, + { + name: "no tenant and no capability filters", + ctx: context.Background(), + disableLabelSanitization: true, // unreachable: tenant lookup fails, so this is ignored + want: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + q := &Querier{limits: stubLimits{disableLabelSanitization: tc.disableLabelSanitization}} + require.Equal(t, tc.want, q.keepUTF8LabelNames(tc.ctx)) + }) + } +}