github.com/grafana/pyroscope@v1.18.0/pkg/validation/usage_groups_test.go (about)

     1  package validation
     2  
     3  import (
     4  	"encoding/json"
     5  	"fmt"
     6  	"slices"
     7  	"testing"
     8  
     9  	"github.com/prometheus/client_golang/prometheus/testutil"
    10  	"github.com/prometheus/prometheus/model/labels"
    11  	"github.com/prometheus/prometheus/promql/parser"
    12  	"github.com/stretchr/testify/require"
    13  	"gopkg.in/yaml.v3"
    14  
    15  	phlaremodel "github.com/grafana/pyroscope/pkg/model"
    16  	"github.com/grafana/pyroscope/pkg/util"
    17  )
    18  
    19  func TestUsageGroupConfig_GetUsageGroups(t *testing.T) {
    20  	tests := []struct {
    21  		Name        string
    22  		TenantID    string
    23  		Config      map[string]string
    24  		Labels      phlaremodel.Labels
    25  		WantedNames []string
    26  	}{
    27  		{
    28  			Name:     "single_usage_group_match",
    29  			TenantID: "tenant1",
    30  			Config: map[string]string{
    31  				"app/foo": `{service_name="foo"}`,
    32  			},
    33  			Labels: phlaremodel.Labels{
    34  				{Name: "service_name", Value: "foo"},
    35  			},
    36  			WantedNames: []string{"app/foo"},
    37  		},
    38  		{
    39  			Name:     "multiple_usage_group_matches",
    40  			TenantID: "tenant1",
    41  			Config: map[string]string{
    42  				"app/foo":  `{service_name="foo"}`,
    43  				"app/foo2": `{service_name="foo", namespace=~"bar.*"}`,
    44  			},
    45  			Labels: phlaremodel.Labels{
    46  				{Name: "service_name", Value: "foo"},
    47  				{Name: "namespace", Value: "barbaz"},
    48  			},
    49  			WantedNames: []string{
    50  				"app/foo",
    51  				"app/foo2",
    52  			},
    53  		},
    54  		{
    55  			Name:     "no_usage_group_matches",
    56  			TenantID: "tenant1",
    57  			Config: map[string]string{
    58  				"app/foo": `{service_name="notfound"}`,
    59  			},
    60  			Labels: phlaremodel.Labels{
    61  				{Name: "service_name", Value: "foo"},
    62  			},
    63  			WantedNames: []string{},
    64  		},
    65  		{
    66  			Name:     "wildcard_matcher",
    67  			TenantID: "tenant1",
    68  			Config: map[string]string{
    69  				"app/foo": `{}`,
    70  			},
    71  			Labels: phlaremodel.Labels{
    72  				{Name: "service_name", Value: "foo"},
    73  			},
    74  			WantedNames: []string{"app/foo"},
    75  		},
    76  		{
    77  			Name:     "no_labels",
    78  			TenantID: "tenant1",
    79  			Config: map[string]string{
    80  				"app/foo": `{service_name="foo"}`,
    81  			},
    82  			Labels:      phlaremodel.Labels{},
    83  			WantedNames: []string{},
    84  		},
    85  		{
    86  			Name:     "disjoint_labels_do_not_match",
    87  			TenantID: "tenant1",
    88  			Config: map[string]string{
    89  				"app/foo": `{namespace="foo", container="bar"}`,
    90  			},
    91  			Labels: phlaremodel.Labels{
    92  				{Name: "service_name", Value: "foo"},
    93  			},
    94  			WantedNames: []string{},
    95  		},
    96  		{
    97  			Name:     "dynamic_usage_group_names",
    98  			TenantID: "tenant1",
    99  			Config: map[string]string{
   100  				"app/${labels.service_name}": `{service_name=~"(.*)"}`,
   101  			},
   102  			Labels: phlaremodel.Labels{
   103  				{Name: "service_name", Value: "foo"},
   104  			},
   105  			WantedNames: []string{
   106  				"app/foo",
   107  			},
   108  		},
   109  		{
   110  			Name:     "dynamic_usage_group_names_missing_label",
   111  			TenantID: "tenant1",
   112  			Config: map[string]string{
   113  				"app/${labels.service_name}/${labels.env}": `{service_name=~"(.*)"}`,
   114  			},
   115  			Labels: phlaremodel.Labels{
   116  				{Name: "service_name", Value: "foo"},
   117  			},
   118  			WantedNames: []string{},
   119  		},
   120  		{
   121  			Name:     "dynamic_usage_group_names_empty_label",
   122  			TenantID: "tenant1",
   123  			Config: map[string]string{
   124  				"app/${labels.service_name}": `{service_name=~"(.*)"}`,
   125  			},
   126  			Labels: phlaremodel.Labels{
   127  				{Name: "service_name", Value: ""},
   128  			},
   129  			WantedNames: []string{},
   130  		},
   131  	}
   132  
   133  	for _, tt := range tests {
   134  		t.Run(tt.Name, func(t *testing.T) {
   135  			config, err := NewUsageGroupConfig(tt.Config)
   136  			require.NoError(t, err)
   137  
   138  			evaluator := NewUsageGroupEvaluator(util.Logger)
   139  			got := evaluator.GetMatch(tt.TenantID, config, tt.Labels)
   140  
   141  			gotNames := make([]string, len(got.names))
   142  			for i, name := range got.names {
   143  				gotNames[i] = name.ResolvedName
   144  			}
   145  			slices.Sort(gotNames)
   146  			slices.Sort(tt.WantedNames)
   147  			require.Equal(t, tt.WantedNames, gotNames)
   148  		})
   149  	}
   150  }
   151  
   152  func TestUsageGroupMatch_CountReceivedBytes(t *testing.T) {
   153  	tests := []struct {
   154  		Name       string
   155  		Match      UsageGroupMatch
   156  		Count      int64
   157  		WantCounts map[string]float64
   158  	}{
   159  		{
   160  			Name: "single_usage_group_match",
   161  			Match: UsageGroupMatch{
   162  				tenantID: "tenant1",
   163  				names:    []UsageGroupMatchName{{ResolvedName: "app/foo"}},
   164  			},
   165  			Count: 100,
   166  			WantCounts: map[string]float64{
   167  				"app/foo":  100,
   168  				"app/foo2": 0,
   169  				"other":    0,
   170  			},
   171  		},
   172  		{
   173  			Name: "multiple_usage_group_matches",
   174  			Match: UsageGroupMatch{
   175  				tenantID: "tenant1",
   176  				names: []UsageGroupMatchName{
   177  					{ResolvedName: "app/foo"},
   178  					{ResolvedName: "app/foo2"},
   179  				},
   180  			},
   181  			Count: 100,
   182  			WantCounts: map[string]float64{
   183  				"app/foo":  100,
   184  				"app/foo2": 100,
   185  				"other":    0,
   186  			},
   187  		},
   188  		{
   189  			Name: "no_usage_group_matches",
   190  			Match: UsageGroupMatch{
   191  				tenantID: "tenant1",
   192  				names:    []UsageGroupMatchName{},
   193  			},
   194  			Count: 100,
   195  			WantCounts: map[string]float64{
   196  				"app/foo":  0,
   197  				"app/foo2": 0,
   198  				"other":    100,
   199  			},
   200  		},
   201  	}
   202  
   203  	for _, tt := range tests {
   204  		t.Run(tt.Name, func(t *testing.T) {
   205  			const profileType = "cpu"
   206  			usageGroupReceivedDecompressedBytes.Reset()
   207  
   208  			tt.Match.CountReceivedBytes(profileType, tt.Count)
   209  
   210  			for name, want := range tt.WantCounts {
   211  				collector := usageGroupReceivedDecompressedBytes.WithLabelValues(
   212  					profileType,
   213  					tt.Match.tenantID,
   214  					name,
   215  				)
   216  
   217  				got := testutil.ToFloat64(collector)
   218  				require.Equal(t, got, want, "usage group %s has incorrect metric value", name)
   219  			}
   220  		})
   221  	}
   222  }
   223  
   224  func TestUsageGroupMatch_CountDiscardedBytes(t *testing.T) {
   225  	tests := []struct {
   226  		Name       string
   227  		Match      UsageGroupMatch
   228  		Count      int64
   229  		WantCounts map[string]float64
   230  	}{
   231  		{
   232  			Name: "single_usage_group_match",
   233  			Match: UsageGroupMatch{
   234  				tenantID: "tenant1",
   235  				names:    []UsageGroupMatchName{{ResolvedName: "app/foo"}},
   236  			},
   237  			Count: 100,
   238  			WantCounts: map[string]float64{
   239  				"app/foo":  100,
   240  				"app/foo2": 0,
   241  				"other":    0,
   242  			},
   243  		},
   244  		{
   245  			Name: "multiple_usage_group_matches",
   246  			Match: UsageGroupMatch{
   247  				tenantID: "tenant1",
   248  				names: []UsageGroupMatchName{
   249  					{ResolvedName: "app/foo"},
   250  					{ResolvedName: "app/foo2"},
   251  				},
   252  			},
   253  			Count: 100,
   254  			WantCounts: map[string]float64{
   255  				"app/foo":  100,
   256  				"app/foo2": 100,
   257  				"other":    0,
   258  			},
   259  		},
   260  		{
   261  			Name: "no_usage_group_matches",
   262  			Match: UsageGroupMatch{
   263  				tenantID: "tenant1",
   264  				names:    []UsageGroupMatchName{},
   265  			},
   266  			Count: 100,
   267  			WantCounts: map[string]float64{
   268  				"app/foo":  0,
   269  				"app/foo2": 0,
   270  				"other":    100,
   271  			},
   272  		},
   273  	}
   274  
   275  	for _, tt := range tests {
   276  		t.Run(tt.Name, func(t *testing.T) {
   277  			const reason = "no_reason"
   278  			usageGroupDiscardedBytes.Reset()
   279  
   280  			tt.Match.CountDiscardedBytes(reason, tt.Count)
   281  
   282  			for name, want := range tt.WantCounts {
   283  				collector := usageGroupDiscardedBytes.WithLabelValues(
   284  					reason,
   285  					tt.Match.tenantID,
   286  					name,
   287  				)
   288  
   289  				got := testutil.ToFloat64(collector)
   290  				require.Equal(t, got, want, "usage group %q has incorrect metric value", name)
   291  			}
   292  		})
   293  	}
   294  }
   295  
   296  func (c *UsageGroupConfig) valuesMap() map[string][]string {
   297  	m := make(map[string][]string)
   298  	for k, v := range c.config {
   299  		for _, matcher := range v {
   300  			m[k] = append(m[k], matcher.String())
   301  		}
   302  	}
   303  	return m
   304  }
   305  
   306  func TestNewUsageGroupConfig(t *testing.T) {
   307  	tests := []struct {
   308  		Name      string
   309  		ConfigMap map[string]string
   310  		Want      *UsageGroupConfig
   311  		WantErr   string
   312  	}{
   313  		{
   314  			Name: "single_usage_group",
   315  			ConfigMap: map[string]string{
   316  				"app/foo": `{service_name="foo"}`,
   317  			},
   318  			Want: &UsageGroupConfig{
   319  				config: map[string][]*labels.Matcher{
   320  					"app/foo": testMustParseMatcher(t, `{service_name="foo"}`),
   321  				},
   322  			},
   323  		},
   324  		{
   325  			Name: "multiple_usage_groups",
   326  			ConfigMap: map[string]string{
   327  				"app/foo":  `{service_name="foo"}`,
   328  				"app/foo2": `{service_name="foo", namespace=~"bar.*"}`,
   329  			},
   330  			Want: &UsageGroupConfig{
   331  				config: map[string][]*labels.Matcher{
   332  					"app/foo":  testMustParseMatcher(t, `{service_name="foo"}`),
   333  					"app/foo2": testMustParseMatcher(t, `{service_name="foo", namespace=~"bar.*"}`),
   334  				},
   335  			},
   336  		},
   337  		{
   338  			Name:      "no_usage_groups",
   339  			ConfigMap: map[string]string{},
   340  			Want: &UsageGroupConfig{
   341  				config: map[string][]*labels.Matcher{},
   342  			},
   343  		},
   344  		{
   345  			Name: "wildcard_matcher",
   346  			ConfigMap: map[string]string{
   347  				"app/foo": `{}`,
   348  			},
   349  			Want: &UsageGroupConfig{
   350  				config: map[string][]*labels.Matcher{
   351  					"app/foo": testMustParseMatcher(t, `{}`),
   352  				},
   353  			},
   354  		},
   355  		{
   356  			Name: "too_many_usage_groups",
   357  			ConfigMap: func() map[string]string {
   358  				m := make(map[string]string)
   359  				for i := 0; i < maxUsageGroups+1; i++ {
   360  					m[fmt.Sprintf("app/foo%d", i)] = `{service_name="foo"}`
   361  				}
   362  				return m
   363  			}(),
   364  			WantErr: fmt.Sprintf("maximum number of usage groups is %d, got %d", maxUsageGroups, maxUsageGroups+1),
   365  		},
   366  		{
   367  			Name: "invalid_matcher",
   368  			ConfigMap: map[string]string{
   369  				"app/foo": `????`,
   370  			},
   371  			WantErr: `failed to parse matchers for usage group "app/foo": 1:1: parse error: unexpected character: '?'`,
   372  		},
   373  		{
   374  			Name: "empty_matcher",
   375  			ConfigMap: map[string]string{
   376  				"app/foo": ``,
   377  			},
   378  			WantErr: `failed to parse matchers for usage group "app/foo": unknown position: parse error: unexpected end of input`,
   379  		},
   380  		{
   381  			Name: "empty_name",
   382  			ConfigMap: map[string]string{
   383  				"": `{service_name="foo"}`,
   384  			},
   385  			WantErr: "usage group name cannot be empty",
   386  		},
   387  		{
   388  			Name: "whitespace_name",
   389  			ConfigMap: map[string]string{
   390  				"   app/foo   ": `{service_name="foo"}`,
   391  			},
   392  			Want: &UsageGroupConfig{
   393  				config: map[string][]*labels.Matcher{
   394  					"app/foo": testMustParseMatcher(t, `{service_name="foo"}`),
   395  				},
   396  			},
   397  		},
   398  		{
   399  			Name: "reserved_name",
   400  			ConfigMap: map[string]string{
   401  				noMatchName: `{service_name="foo"}`,
   402  			},
   403  			WantErr: fmt.Sprintf("usage group name %q is reserved", noMatchName),
   404  		},
   405  		{
   406  			Name: "invalid_utf8_name",
   407  			ConfigMap: map[string]string{
   408  				"app/\x80foo": `{service_name="foo"}`,
   409  			},
   410  			WantErr: `usage group name "app/\x80foo" is not valid UTF-8`,
   411  		},
   412  	}
   413  
   414  	for _, tt := range tests {
   415  		t.Run(tt.Name, func(t *testing.T) {
   416  			got, err := NewUsageGroupConfig(tt.ConfigMap)
   417  			if tt.WantErr != "" {
   418  				require.EqualError(t, err, tt.WantErr)
   419  			} else {
   420  				require.NoError(t, err)
   421  				require.Equal(t, tt.Want.valuesMap(), got.valuesMap())
   422  			}
   423  		})
   424  	}
   425  }
   426  
   427  func TestUsageGroupConfig_UnmarshalYAML(t *testing.T) {
   428  	type Object struct {
   429  		UsageGroups UsageGroupConfig `yaml:"usage_groups"`
   430  	}
   431  
   432  	tests := []struct {
   433  		Name    string
   434  		YAML    string
   435  		Want    *UsageGroupConfig
   436  		WantErr string
   437  	}{
   438  		{
   439  			Name: "single_usage_group",
   440  			YAML: `
   441  usage_groups:
   442    app/foo: '{service_name="foo"}'`,
   443  			Want: &UsageGroupConfig{
   444  				config: map[string][]*labels.Matcher{
   445  					"app/foo": testMustParseMatcher(t, `{service_name="foo"}`),
   446  				},
   447  			},
   448  		},
   449  		{
   450  			Name: "multiple_usage_groups",
   451  			YAML: `
   452  usage_groups:
   453    app/foo: '{service_name="foo"}'
   454    app/foo2: '{service_name="foo", namespace=~"bar.*"}'`,
   455  			Want: &UsageGroupConfig{
   456  				config: map[string][]*labels.Matcher{
   457  					"app/foo":  testMustParseMatcher(t, `{service_name="foo"}`),
   458  					"app/foo2": testMustParseMatcher(t, `{service_name="foo", namespace=~"bar.*"}`),
   459  				},
   460  			},
   461  		},
   462  		{
   463  			Name: "empty_usage_groups",
   464  			YAML: `
   465  usage_groups: {}`,
   466  			Want: &UsageGroupConfig{
   467  				config: map[string][]*labels.Matcher{},
   468  			},
   469  		},
   470  		{
   471  			Name:    "invalid_yaml",
   472  			YAML:    `usage_groups: ?????`,
   473  			WantErr: "malformed usage group config: yaml: unmarshal errors:\n  line 1: cannot unmarshal !!str `?????` into map[string]string",
   474  		},
   475  		{
   476  			Name: "invalid_matcher",
   477  			YAML: `
   478  usage_groups:
   479    app/foo: ?????`,
   480  			WantErr: `failed to parse matchers for usage group "app/foo": 1:1: parse error: unexpected character: '?'`,
   481  		},
   482  		{
   483  			Name: "missing_usage_groups_key_in_config",
   484  			YAML: `
   485  some_other_config:
   486    foo: bar`,
   487  			Want: &UsageGroupConfig{},
   488  		},
   489  	}
   490  
   491  	for _, tt := range tests {
   492  		t.Run(tt.Name, func(t *testing.T) {
   493  			got := Object{}
   494  			err := yaml.Unmarshal([]byte(tt.YAML), &got)
   495  			if tt.WantErr != "" {
   496  				require.EqualError(t, err, tt.WantErr)
   497  			} else {
   498  				require.NoError(t, err)
   499  				require.Equal(t, tt.Want.valuesMap(), got.UsageGroups.valuesMap())
   500  			}
   501  		})
   502  	}
   503  }
   504  
   505  func TestUsageGroupConfig_UnmarshalJSON(t *testing.T) {
   506  	type Object struct {
   507  		UsageGroups UsageGroupConfig `json:"usage_groups"`
   508  	}
   509  
   510  	tests := []struct {
   511  		Name    string
   512  		JSON    string
   513  		Want    *UsageGroupConfig
   514  		WantErr string
   515  	}{
   516  		{
   517  			Name: "single_usage_group",
   518  			JSON: `{
   519  				"usage_groups": {
   520  					"app/foo": "{service_name=\"foo\"}"
   521  				}
   522  			}`,
   523  			Want: &UsageGroupConfig{
   524  				config: map[string][]*labels.Matcher{
   525  					"app/foo": testMustParseMatcher(t, `{service_name="foo"}`),
   526  				},
   527  			},
   528  		},
   529  		{
   530  			Name: "multiple_usage_groups",
   531  			JSON: `{
   532  				"usage_groups": {
   533  					"app/foo": "{service_name=\"foo\"}",
   534  					"app/foo2": "{service_name=\"foo\", namespace=~\"bar.*\"}"
   535  				}
   536  			}`,
   537  			Want: &UsageGroupConfig{
   538  				config: map[string][]*labels.Matcher{
   539  					"app/foo":  testMustParseMatcher(t, `{service_name="foo"}`),
   540  					"app/foo2": testMustParseMatcher(t, `{service_name="foo", namespace=~"bar.*"}`),
   541  				},
   542  			},
   543  		},
   544  		{
   545  			Name: "empty_usage_groups",
   546  			JSON: `{"usage_groups": {}}`,
   547  			Want: &UsageGroupConfig{
   548  				config: map[string][]*labels.Matcher{},
   549  			},
   550  		},
   551  		{
   552  			Name:    "invalid_json",
   553  			JSON:    `{"usage_groups": "?????"}`,
   554  			WantErr: "malformed usage group config: json: cannot unmarshal string into Go value of type map[string]string",
   555  		},
   556  		{
   557  			Name:    "invalid_matcher",
   558  			JSON:    `{"usage_groups": {"app/foo": "?????"}}`,
   559  			WantErr: `failed to parse matchers for usage group "app/foo": 1:1: parse error: unexpected character: '?'`,
   560  		},
   561  		{
   562  			Name: "missing_usage_groups_key_in_config",
   563  			JSON: `{"some_other_key": {"foo": "bar"}}`,
   564  			Want: &UsageGroupConfig{},
   565  		},
   566  	}
   567  
   568  	for _, tt := range tests {
   569  		t.Run(tt.Name, func(t *testing.T) {
   570  			got := Object{}
   571  			err := json.Unmarshal([]byte(tt.JSON), &got)
   572  			if tt.WantErr != "" {
   573  				require.EqualError(t, err, tt.WantErr)
   574  			} else {
   575  				require.NoError(t, err)
   576  				require.Equal(t, tt.Want.valuesMap(), got.UsageGroups.valuesMap())
   577  			}
   578  		})
   579  	}
   580  }
   581  
   582  func testMustParseMatcher(t *testing.T, s string) []*labels.Matcher {
   583  	m, err := parser.ParseMetricSelector(s)
   584  	require.NoError(t, err)
   585  	return m
   586  }
   587  
   588  func TestUsageGroupMatchName_IsMoreSpecificThan(t *testing.T) {
   589  	tests := []struct {
   590  		Name  string
   591  		Match UsageGroupMatchName
   592  		Other UsageGroupMatchName
   593  		Want  bool
   594  	}{
   595  		{
   596  			Name:  "same name",
   597  			Match: UsageGroupMatchName{ConfiguredName: "app/foo"},
   598  			Other: UsageGroupMatchName{ConfiguredName: "app/foo"},
   599  			Want:  false,
   600  		},
   601  		{
   602  			Name:  "less specific name",
   603  			Match: UsageGroupMatchName{ConfiguredName: "${labels.service_name}"},
   604  			Other: UsageGroupMatchName{ConfiguredName: "test-service"},
   605  			Want:  false,
   606  		},
   607  		{
   608  			Name:  "more specific name",
   609  			Match: UsageGroupMatchName{ConfiguredName: "test-service"},
   610  			Other: UsageGroupMatchName{ConfiguredName: "${labels.service_name}"},
   611  			Want:  true,
   612  		},
   613  		{
   614  			Name:  "more specific name with prefix",
   615  			Match: UsageGroupMatchName{ConfiguredName: "test-service"},
   616  			Other: UsageGroupMatchName{ConfiguredName: "service/${labels.service_name}"},
   617  			Want:  true,
   618  		},
   619  	}
   620  	for _, tt := range tests {
   621  		t.Run(tt.Name, func(t *testing.T) {
   622  			require.Equal(t, tt.Want, tt.Match.IsMoreSpecificThan(&tt.Other))
   623  		})
   624  	}
   625  }