github.com/grafana/pyroscope@v1.18.0/pkg/frontend/readpath/queryfrontend/query_frontend_test.go (about)

     1  package queryfrontend
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"testing"
     7  	"time"
     8  
     9  	"connectrpc.com/connect"
    10  	"github.com/go-kit/log"
    11  	"github.com/grafana/dskit/user"
    12  	"github.com/stretchr/testify/assert"
    13  	"github.com/stretchr/testify/mock"
    14  	"github.com/stretchr/testify/require"
    15  
    16  	profilev1 "github.com/grafana/pyroscope/api/gen/proto/go/google/v1"
    17  	metastorev1 "github.com/grafana/pyroscope/api/gen/proto/go/metastore/v1"
    18  	querierv1 "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1"
    19  	queryv1 "github.com/grafana/pyroscope/api/gen/proto/go/query/v1"
    20  	typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
    21  	"github.com/grafana/pyroscope/pkg/block/metadata"
    22  	"github.com/grafana/pyroscope/pkg/featureflags"
    23  	"github.com/grafana/pyroscope/pkg/tenant"
    24  	"github.com/grafana/pyroscope/pkg/test/mocks/mockfrontend"
    25  	"github.com/grafana/pyroscope/pkg/test/mocks/mockmetastorev1"
    26  	"github.com/grafana/pyroscope/pkg/test/mocks/mockqueryfrontend"
    27  )
    28  
    29  func Test_QueryFrontend_QueryMetadata(t *testing.T) {
    30  	for _, test := range []struct {
    31  		query    *queryv1.QueryRequest
    32  		request  *metastorev1.QueryMetadataRequest
    33  		response *metastorev1.QueryMetadataResponse
    34  	}{
    35  		{
    36  			query: &queryv1.QueryRequest{LabelSelector: `{service_name="service-a"}`},
    37  			request: &metastorev1.QueryMetadataRequest{
    38  				TenantId: []string{"org"},
    39  				Query:    `{service_name="service-a"}`,
    40  				Labels:   []string{metadata.LabelNameUnsymbolized},
    41  			},
    42  			response: &metastorev1.QueryMetadataResponse{
    43  				Blocks: []*metastorev1.BlockMeta{{Id: "block_id_a"}},
    44  			},
    45  		},
    46  		{
    47  			query: &queryv1.QueryRequest{LabelSelector: `{service_name!="service-a"}`},
    48  			request: &metastorev1.QueryMetadataRequest{
    49  				TenantId: []string{"org"},
    50  				Query:    `{__tenant_dataset__="dataset_tsdb_index"}`,
    51  				Labels:   []string{metadata.LabelNameUnsymbolized, "__tenant_dataset__"},
    52  			},
    53  			response: &metastorev1.QueryMetadataResponse{
    54  				Blocks: []*metastorev1.BlockMeta{{Id: "block_id_a"}},
    55  			},
    56  		},
    57  		{
    58  			query: &queryv1.QueryRequest{LabelSelector: `{service_name=~".*"}`},
    59  			request: &metastorev1.QueryMetadataRequest{
    60  				TenantId: []string{"org"},
    61  				Query:    `{__tenant_dataset__="dataset_tsdb_index"}`,
    62  				Labels:   []string{metadata.LabelNameUnsymbolized, "__tenant_dataset__"},
    63  			},
    64  			response: &metastorev1.QueryMetadataResponse{
    65  				Blocks: []*metastorev1.BlockMeta{{Id: "block_id_c"}},
    66  			},
    67  		},
    68  		{
    69  			query: &queryv1.QueryRequest{LabelSelector: `{foo="bar"}`},
    70  			request: &metastorev1.QueryMetadataRequest{
    71  				TenantId: []string{"org"},
    72  				Query:    `{__tenant_dataset__="dataset_tsdb_index"}`,
    73  				Labels:   []string{metadata.LabelNameUnsymbolized, "__tenant_dataset__"},
    74  			},
    75  			response: &metastorev1.QueryMetadataResponse{
    76  				Blocks: []*metastorev1.BlockMeta{{Id: "block_id_b"}},
    77  			},
    78  		},
    79  		{
    80  			query: &queryv1.QueryRequest{LabelSelector: "{}"},
    81  			request: &metastorev1.QueryMetadataRequest{
    82  				TenantId: []string{"org"},
    83  				Query:    `{__tenant_dataset__="dataset_tsdb_index"}`,
    84  				Labels:   []string{metadata.LabelNameUnsymbolized, "__tenant_dataset__"},
    85  			},
    86  			response: &metastorev1.QueryMetadataResponse{
    87  				Blocks: []*metastorev1.BlockMeta{{Id: "block_id_d"}},
    88  			},
    89  		},
    90  	} {
    91  		mockMetadataClient := new(mockmetastorev1.MockMetadataQueryServiceClient)
    92  		ctx := user.InjectOrgID(context.Background(), "org")
    93  		f := &QueryFrontend{metadataQueryClient: mockMetadataClient}
    94  
    95  		mockMetadataClient.On("QueryMetadata", mock.Anything, test.request).
    96  			Return(test.response, nil).
    97  			Once()
    98  
    99  		blocks, err := f.QueryMetadata(ctx, test.query)
   100  		assert.NoError(t, err)
   101  		assert.Equal(t, test.response.Blocks, blocks)
   102  	}
   103  }
   104  
   105  func TestQueryFrontendSymbolization(t *testing.T) {
   106  	tests := []struct {
   107  		name              string
   108  		tenantID          string
   109  		symbolizerEnabled bool
   110  		hasUnsymbolized   bool
   111  		setupMocks        func(*mockfrontend.MockLimits, *mockqueryfrontend.MockSymbolizer)
   112  	}{
   113  		{
   114  			name:              "symbolization enabled for tenant with native profiles",
   115  			tenantID:          "tenant1",
   116  			symbolizerEnabled: true,
   117  			hasUnsymbolized:   true,
   118  			setupMocks: func(mockLimits *mockfrontend.MockLimits, mockSymbolizer *mockqueryfrontend.MockSymbolizer) {
   119  				mockLimits.On("SymbolizerEnabled", "tenant1").Return(true)
   120  				mockLimits.On("QuerySanitizeOnMerge", "tenant1").Return(true)
   121  				mockSymbolizer.On("SymbolizePprof", mock.Anything, mock.Anything).Return(nil).Once()
   122  			},
   123  		},
   124  		{
   125  			name:              "symbolization disabled for tenant",
   126  			tenantID:          "tenant2",
   127  			symbolizerEnabled: false,
   128  			hasUnsymbolized:   true,
   129  			setupMocks: func(mockLimits *mockfrontend.MockLimits, mockSymbolizer *mockqueryfrontend.MockSymbolizer) {
   130  				mockLimits.On("SymbolizerEnabled", "tenant2").Return(false)
   131  				mockLimits.On("QuerySanitizeOnMerge", "tenant2").Return(true)
   132  				mockSymbolizer.AssertNotCalled(t, "SymbolizePprof")
   133  			},
   134  		},
   135  		{
   136  			name:              "symbolization enabled but no native profiles",
   137  			tenantID:          "tenant3",
   138  			symbolizerEnabled: true,
   139  			hasUnsymbolized:   false,
   140  			setupMocks: func(mockLimits *mockfrontend.MockLimits, mockSymbolizer *mockqueryfrontend.MockSymbolizer) {
   141  				mockLimits.On("SymbolizerEnabled", "tenant3").Return(true)
   142  				mockLimits.On("QuerySanitizeOnMerge", "tenant3").Return(true)
   143  				mockSymbolizer.AssertNotCalled(t, "SymbolizePprof")
   144  			},
   145  		},
   146  	}
   147  
   148  	for _, tt := range tests {
   149  		t.Run(tt.name, func(t *testing.T) {
   150  			mockLimits := mockfrontend.NewMockLimits(t)
   151  			mockSymbolizer := mockqueryfrontend.NewMockSymbolizer(t)
   152  			tt.setupMocks(mockLimits, mockSymbolizer)
   153  
   154  			mockQueryBackend := mockqueryfrontend.NewMockQueryBackend(t)
   155  			mockQueryBackend.On("Invoke", mock.Anything, mock.Anything).Return(&queryv1.InvokeResponse{
   156  				Reports: []*queryv1.Report{
   157  					{
   158  						Pprof: &queryv1.PprofReport{Pprof: createProfile(t)},
   159  					},
   160  				},
   161  			}, nil)
   162  
   163  			mockMetadataClient := new(mockmetastorev1.MockMetadataQueryServiceClient)
   164  			mockMetadataClient.On("QueryMetadata", mock.Anything, mock.Anything).
   165  				Return(&metastorev1.QueryMetadataResponse{
   166  					Blocks: []*metastorev1.BlockMeta{{
   167  						Id: "block_id_d",
   168  						Datasets: []*metastorev1.Dataset{{
   169  							Labels: []int32{1, 1, 2},
   170  						}},
   171  						StringTable: []string{
   172  							"", // First string is always empty by convention
   173  							metadata.LabelNameUnsymbolized,
   174  							fmt.Sprintf("%v", tt.hasUnsymbolized),
   175  						},
   176  					}},
   177  				}, nil).
   178  				Once()
   179  
   180  			qf := NewQueryFrontend(
   181  				log.NewNopLogger(),
   182  				mockLimits,
   183  				mockMetadataClient,
   184  				nil,
   185  				mockQueryBackend,
   186  				mockSymbolizer,
   187  			)
   188  
   189  			ctx := tenant.InjectTenantID(context.Background(), tt.tenantID)
   190  			_, err := qf.Query(ctx, &queryv1.QueryRequest{
   191  				LabelSelector: `{service_name="test-service"}`,
   192  				Query: []*queryv1.Query{
   193  					{
   194  						QueryType: queryv1.QueryType_QUERY_PPROF,
   195  					},
   196  				},
   197  			})
   198  
   199  			require.NoError(t, err)
   200  
   201  			mockMetadataClient.AssertExpectations(t)
   202  			mockQueryBackend.AssertExpectations(t)
   203  		})
   204  	}
   205  }
   206  
   207  func createProfile(t *testing.T) []byte {
   208  	t.Helper()
   209  
   210  	stringTable := []string{
   211  		"",
   212  		"some_label",
   213  		"some_value",
   214  	}
   215  
   216  	labels := []*profilev1.Label{{
   217  		Key: 1,
   218  		Str: 2,
   219  	}}
   220  
   221  	profile := &profilev1.Profile{
   222  		StringTable: stringTable,
   223  		Sample: []*profilev1.Sample{{
   224  			Label: labels,
   225  		}},
   226  	}
   227  
   228  	bytes, err := profile.MarshalVT()
   229  	require.NoError(t, err)
   230  	return bytes
   231  }
   232  
   233  func Test_QueryFrontend_LabelNames_WithFiltering(t *testing.T) {
   234  	tests := []struct {
   235  		name                string
   236  		allowUtf8LabelNames bool
   237  		setCapabilities     bool
   238  		backendLabelNames   []string
   239  		expectedLabelNames  []string
   240  	}{
   241  		{
   242  			name:                "UTF8 labels allowed when enabled",
   243  			allowUtf8LabelNames: true,
   244  			setCapabilities:     true,
   245  			backendLabelNames:   []string{"foo", "bar", "世界"},
   246  			expectedLabelNames:  []string{"foo", "bar", "世界"},
   247  		},
   248  		{
   249  			name:                "UTF8 labels filtered when disabled",
   250  			allowUtf8LabelNames: false,
   251  			setCapabilities:     true,
   252  			backendLabelNames:   []string{"foo", "bar", "世界"},
   253  			expectedLabelNames:  []string{"foo", "bar"},
   254  		},
   255  		{
   256  			name:                "invalid labels pass through when UTF8 enabled",
   257  			allowUtf8LabelNames: true,
   258  			setCapabilities:     true,
   259  			backendLabelNames:   []string{"valid_name", "123invalid", "invalid-hyphen", "世界"},
   260  			expectedLabelNames:  []string{"valid_name", "123invalid", "invalid-hyphen", "世界"},
   261  		},
   262  		{
   263  			name:                "invalid labels filtered when UTF8 disabled",
   264  			allowUtf8LabelNames: false,
   265  			setCapabilities:     true,
   266  			backendLabelNames:   []string{"valid_name", "123invalid", "invalid-hyphen", "世界"},
   267  			expectedLabelNames:  []string{"valid_name"},
   268  		},
   269  		{
   270  			name:               "filtering enabled when no capabilities set",
   271  			setCapabilities:    false,
   272  			backendLabelNames:  []string{"valid_name", "123invalid", "世界"},
   273  			expectedLabelNames: []string{"valid_name"},
   274  		},
   275  		{
   276  			name:                "labels with dots pass through",
   277  			allowUtf8LabelNames: false,
   278  			setCapabilities:     true,
   279  			backendLabelNames:   []string{"service.name", "app.version"},
   280  			expectedLabelNames:  []string{"service.name", "app.version"},
   281  		},
   282  	}
   283  
   284  	for _, tc := range tests {
   285  		t.Run(tc.name, func(t *testing.T) {
   286  			mockQueryBackend := mockqueryfrontend.NewMockQueryBackend(t)
   287  			mockQueryBackend.On("Invoke", mock.Anything, mock.Anything).Return(&queryv1.InvokeResponse{
   288  				Reports: []*queryv1.Report{
   289  					{
   290  						ReportType: queryv1.ReportType_REPORT_LABEL_NAMES,
   291  						LabelNames: &queryv1.LabelNamesReport{
   292  							LabelNames: tc.backendLabelNames,
   293  						},
   294  					},
   295  				},
   296  			}, nil)
   297  
   298  			mockLimits := mockfrontend.NewMockLimits(t)
   299  			mockLimits.On("MaxQueryLookback", "test-tenant").Return(time.Duration(0))
   300  			mockLimits.On("MaxQueryLength", "test-tenant").Return(time.Duration(0))
   301  			mockLimits.On("QuerySanitizeOnMerge", "test-tenant").Return(true)
   302  			mockMetadataClient := new(mockmetastorev1.MockMetadataQueryServiceClient)
   303  			mockMetadataClient.On("QueryMetadata", mock.Anything, mock.Anything).Return(&metastorev1.QueryMetadataResponse{
   304  				Blocks: []*metastorev1.BlockMeta{{Id: "test-block"}},
   305  			}, nil)
   306  
   307  			qf := NewQueryFrontend(
   308  				log.NewNopLogger(),
   309  				mockLimits,
   310  				mockMetadataClient,
   311  				nil,
   312  				mockQueryBackend,
   313  				nil,
   314  			)
   315  
   316  			ctx := tenant.InjectTenantID(context.Background(), "test-tenant")
   317  			if tc.setCapabilities {
   318  				ctx = featureflags.WithClientCapabilities(ctx, featureflags.ClientCapabilities{
   319  					AllowUtf8LabelNames: tc.allowUtf8LabelNames,
   320  				})
   321  			}
   322  
   323  			req := connect.NewRequest(&typesv1.LabelNamesRequest{
   324  				Start: 1000,
   325  				End:   2000,
   326  			})
   327  
   328  			resp, err := qf.LabelNames(ctx, req)
   329  			require.NoError(t, err)
   330  			require.Equal(t, tc.expectedLabelNames, resp.Msg.Names)
   331  		})
   332  	}
   333  }
   334  
   335  func Test_QueryFrontend_Series_WithLabelNameFiltering(t *testing.T) {
   336  	tests := []struct {
   337  		name                 string
   338  		allowUtf8LabelNames  bool
   339  		setCapabilities      bool
   340  		requestLabelNames    []string
   341  		backendLabelNames    []string // For empty request case
   342  		expectedQueryRequest []string // What should be passed to backend
   343  	}{
   344  		{
   345  			name:                 "all label names pass through when UTF8 enabled",
   346  			allowUtf8LabelNames:  true,
   347  			setCapabilities:      true,
   348  			requestLabelNames:    []string{"valid_name", "123invalid", "invalid-hyphen", "世界"},
   349  			expectedQueryRequest: []string{"valid_name", "123invalid", "invalid-hyphen", "世界"},
   350  		},
   351  		{
   352  			name:                 "invalid label names filtered when UTF8 disabled",
   353  			allowUtf8LabelNames:  false,
   354  			setCapabilities:      true,
   355  			requestLabelNames:    []string{"valid_name", "123invalid", "invalid-hyphen", "世界"},
   356  			expectedQueryRequest: []string{"valid_name"},
   357  		},
   358  		{
   359  			name:                 "UTF8 labels filtered when UTF8 disabled",
   360  			allowUtf8LabelNames:  false,
   361  			setCapabilities:      true,
   362  			requestLabelNames:    []string{"foo", "bar", "世界", "日本語"},
   363  			expectedQueryRequest: []string{"foo", "bar"},
   364  		},
   365  		{
   366  			name:                 "filtering enabled when no capabilities set",
   367  			setCapabilities:      false,
   368  			requestLabelNames:    []string{"foo", "123invalid", "世界"},
   369  			expectedQueryRequest: []string{"foo"},
   370  		},
   371  		{
   372  			name:                 "all valid labels pass through",
   373  			allowUtf8LabelNames:  false,
   374  			setCapabilities:      true,
   375  			requestLabelNames:    []string{"foo", "bar", "service_name"},
   376  			expectedQueryRequest: []string{"foo", "bar", "service_name"},
   377  		},
   378  		{
   379  			name:                 "labels with dots pass through",
   380  			allowUtf8LabelNames:  false,
   381  			setCapabilities:      true,
   382  			requestLabelNames:    []string{"service.name", "app.version"},
   383  			expectedQueryRequest: []string{"service.name", "app.version"},
   384  		},
   385  		{
   386  			name:                 "empty label names with UTF8 disabled queries and filters all labels",
   387  			allowUtf8LabelNames:  false,
   388  			setCapabilities:      true,
   389  			requestLabelNames:    []string{},
   390  			backendLabelNames:    []string{"foo", "bar", "世界"},
   391  			expectedQueryRequest: []string{"foo", "bar"},
   392  		},
   393  	}
   394  
   395  	for _, tc := range tests {
   396  		t.Run(tc.name, func(t *testing.T) {
   397  			var capturedLabelNames []string
   398  
   399  			mockQueryBackend := mockqueryfrontend.NewMockQueryBackend(t)
   400  
   401  			// For empty label names case, we need to mock the LabelNames query first
   402  			if len(tc.requestLabelNames) == 0 {
   403  				mockQueryBackend.On("Invoke", mock.Anything, mock.MatchedBy(func(req *queryv1.InvokeRequest) bool {
   404  					return len(req.Query) > 0 && req.Query[0].QueryType == queryv1.QueryType_QUERY_LABEL_NAMES
   405  				})).Return(&queryv1.InvokeResponse{
   406  					Reports: []*queryv1.Report{
   407  						{
   408  							ReportType: queryv1.ReportType_REPORT_LABEL_NAMES,
   409  							LabelNames: &queryv1.LabelNamesReport{
   410  								LabelNames: tc.backendLabelNames,
   411  							},
   412  						},
   413  					},
   414  				}, nil).Once()
   415  			}
   416  
   417  			// Mock the Series query specifically
   418  			mockQueryBackend.On("Invoke", mock.Anything, mock.MatchedBy(func(req *queryv1.InvokeRequest) bool {
   419  				return len(req.Query) > 0 && req.Query[0].QueryType == queryv1.QueryType_QUERY_SERIES_LABELS
   420  			})).Run(func(args mock.Arguments) {
   421  				invReq := args.Get(1).(*queryv1.InvokeRequest)
   422  				if len(invReq.Query) > 0 && invReq.Query[0].SeriesLabels != nil {
   423  					capturedLabelNames = invReq.Query[0].SeriesLabels.LabelNames
   424  					if capturedLabelNames == nil {
   425  						capturedLabelNames = []string{}
   426  					}
   427  				}
   428  			}).Return(&queryv1.InvokeResponse{
   429  				Reports: []*queryv1.Report{
   430  					{
   431  						ReportType: queryv1.ReportType_REPORT_SERIES_LABELS,
   432  						SeriesLabels: &queryv1.SeriesLabelsReport{
   433  							SeriesLabels: []*typesv1.Labels{},
   434  						},
   435  					},
   436  				},
   437  			}, nil).Once()
   438  
   439  			mockLimits := mockfrontend.NewMockLimits(t)
   440  			mockLimits.On("MaxQueryLookback", "test-tenant").Return(time.Duration(0))
   441  			mockLimits.On("MaxQueryLength", "test-tenant").Return(time.Duration(0))
   442  			mockLimits.On("QuerySanitizeOnMerge", "test-tenant").Return(true)
   443  			mockMetadataClient := new(mockmetastorev1.MockMetadataQueryServiceClient)
   444  			mockMetadataClient.On("QueryMetadata", mock.Anything, mock.Anything).Return(&metastorev1.QueryMetadataResponse{
   445  				Blocks: []*metastorev1.BlockMeta{{Id: "test-block"}},
   446  			}, nil)
   447  
   448  			qf := NewQueryFrontend(
   449  				log.NewNopLogger(),
   450  				mockLimits,
   451  				mockMetadataClient,
   452  				nil,
   453  				mockQueryBackend,
   454  				nil,
   455  			)
   456  
   457  			ctx := tenant.InjectTenantID(context.Background(), "test-tenant")
   458  			if tc.setCapabilities {
   459  				ctx = featureflags.WithClientCapabilities(ctx, featureflags.ClientCapabilities{
   460  					AllowUtf8LabelNames: tc.allowUtf8LabelNames,
   461  				})
   462  			}
   463  
   464  			req := connect.NewRequest(&querierv1.SeriesRequest{
   465  				Matchers:   []string{`{service_name="test"}`},
   466  				LabelNames: tc.requestLabelNames,
   467  				Start:      1000,
   468  				End:        2000,
   469  			})
   470  
   471  			_, err := qf.Series(ctx, req)
   472  			require.NoError(t, err)
   473  
   474  			// Verify that the label names were filtered correctly before being sent to backend
   475  			require.Equal(t, tc.expectedQueryRequest, capturedLabelNames,
   476  				"Expected label names sent to backend to be %v, but got %v", tc.expectedQueryRequest, capturedLabelNames)
   477  		})
   478  	}
   479  }