github.com/grafana/pyroscope@v1.18.0/pkg/symbolizer/symbolizer_test.go (about)

     1  package symbolizer
     2  
     3  import (
     4  	"bytes"
     5  	"compress/gzip"
     6  	"context"
     7  	"fmt"
     8  	"io"
     9  	"os"
    10  	"strings"
    11  	"testing"
    12  
    13  	"github.com/go-kit/log"
    14  	"github.com/prometheus/client_golang/prometheus"
    15  	"github.com/prometheus/client_golang/prometheus/testutil"
    16  	"github.com/stretchr/testify/mock"
    17  	"github.com/stretchr/testify/require"
    18  
    19  	googlev1 "github.com/grafana/pyroscope/api/gen/proto/go/google/v1"
    20  	"github.com/grafana/pyroscope/lidia"
    21  	"github.com/grafana/pyroscope/pkg/model"
    22  	"github.com/grafana/pyroscope/pkg/tenant"
    23  	"github.com/grafana/pyroscope/pkg/test/mocks/mockobjstore"
    24  	"github.com/grafana/pyroscope/pkg/test/mocks/mocksymbolizer"
    25  	"github.com/grafana/pyroscope/pkg/validation"
    26  )
    27  
    28  type symbolizerInputs struct {
    29  	Registry *prometheus.Registry
    30  	Limits   Limits
    31  }
    32  
    33  func newSymbolizerTest(t *testing.T, inp *symbolizerInputs) (*Symbolizer, *mocksymbolizer.MockDebuginfodClient, *mockobjstore.MockBucket) {
    34  	t.Helper()
    35  	mockClient := mocksymbolizer.NewMockDebuginfodClient(t)
    36  	mockBucket := mockobjstore.NewMockBucket(t)
    37  
    38  	if inp == nil {
    39  		inp = &symbolizerInputs{}
    40  	}
    41  
    42  	if inp.Limits == nil {
    43  		inp.Limits = validation.MockDefaultOverrides()
    44  	}
    45  
    46  	if inp.Registry == nil {
    47  		inp.Registry = prometheus.NewRegistry()
    48  	}
    49  
    50  	s, err := New(
    51  		log.NewNopLogger(),
    52  		Config{MaxDebuginfodConcurrency: 1},
    53  		inp.Registry,
    54  		mockBucket,
    55  		inp.Limits,
    56  	)
    57  	require.NoError(t, err)
    58  	s.client = mockClient
    59  
    60  	return s, mockClient, mockBucket
    61  }
    62  
    63  // TestSymbolizePprof tests symbolization using testdata/symbols.debug which contains:
    64  //
    65  // 0x1500 -> (contains both functions)
    66  //   - main (/usr/src/stress-1.0.7-1/src/stress.c:87)
    67  //   - fprintf (/usr/include/x86_64-linux-gnu/bits/stdio2.h:77)
    68  //
    69  // 0x3c5a -> atoll_b (/usr/src/stress-1.0.7-1/src/stress.c:632)
    70  // 0x2745 -> main (/usr/src/stress-1.0.7-1/src/stress.c:87)
    71  func TestSymbolizePprof(t *testing.T) {
    72  	tests := []struct {
    73  		name      string
    74  		profile   *googlev1.Profile
    75  		setupMock func(*mocksymbolizer.MockDebuginfodClient, *mockobjstore.MockBucket)
    76  		wantErr   bool
    77  		validate  func(*testing.T, *googlev1.Profile)
    78  	}{
    79  		{
    80  			name: "already symbolized mapping",
    81  			profile: &googlev1.Profile{
    82  				Mapping: []*googlev1.Mapping{{
    83  					HasFunctions:   true,
    84  					HasFilenames:   true,
    85  					HasLineNumbers: true,
    86  				}},
    87  				Location: []*googlev1.Location{{
    88  					MappingId: 1,
    89  					Line: []*googlev1.Line{{
    90  						FunctionId: 0,
    91  						Line:       42,
    92  					}},
    93  				}},
    94  				Function: []*googlev1.Function{{
    95  					Name:     1,
    96  					Filename: 2,
    97  				}},
    98  				StringTable: []string{"", "main", "main.go"},
    99  			},
   100  			setupMock: func(mockClient *mocksymbolizer.MockDebuginfodClient, mockBucket *mockobjstore.MockBucket) {},
   101  			validate: func(t *testing.T, p *googlev1.Profile) {
   102  				require.True(t, p.Mapping[0].HasFunctions)
   103  				require.True(t, p.Mapping[0].HasFilenames)
   104  				require.True(t, p.Mapping[0].HasLineNumbers)
   105  			},
   106  		},
   107  		{
   108  			name: "needs symbolization single address",
   109  			profile: &googlev1.Profile{
   110  				Mapping: []*googlev1.Mapping{{
   111  					BuildId:     1,
   112  					MemoryStart: 0x0,
   113  					MemoryLimit: 0x1000000,
   114  					FileOffset:  0x0,
   115  				}},
   116  				Location: []*googlev1.Location{{
   117  					Id:        1,
   118  					MappingId: 1,
   119  					Address:   0x1500,
   120  				}},
   121  				StringTable: []string{"", "build-id"},
   122  			},
   123  			setupMock: func(mockClient *mocksymbolizer.MockDebuginfodClient, mockBucket *mockobjstore.MockBucket) {
   124  				mockClient.On("FetchDebuginfo", mock.Anything, "build-id").Return(openTestFile(t), nil).Once()
   125  				mockBucket.On("Get", mock.Anything, "build-id").Return(nil, fmt.Errorf("not found")).Once()
   126  				mockBucket.On("Upload", mock.Anything, "build-id", mock.Anything).Return(nil).Once()
   127  			},
   128  			validate: func(t *testing.T, p *googlev1.Profile) {
   129  				require.True(t, p.Mapping[0].HasFunctions)
   130  
   131  				require.Len(t, p.Location[0].Line, 1)
   132  
   133  				assertLocationHasFunction(t, p, p.Location[0], "main", "main")
   134  			},
   135  		},
   136  		{
   137  			name: "empty build ID creates fallback symbols",
   138  			profile: &googlev1.Profile{
   139  				Mapping: []*googlev1.Mapping{{
   140  					Id:       1,
   141  					Filename: 2,
   142  					BuildId:  1,
   143  				}},
   144  				Location: []*googlev1.Location{
   145  					{Id: 1, MappingId: 1, Address: 0xa4c},
   146  					{Id: 2, MappingId: 1, Address: 0x9f0},
   147  				},
   148  				StringTable: []string{"", "", "linux-vdso.1.so"},
   149  			},
   150  			setupMock: func(mockClient *mocksymbolizer.MockDebuginfodClient, mockBucket *mockobjstore.MockBucket) {},
   151  			validate: func(t *testing.T, p *googlev1.Profile) {
   152  				require.True(t, p.Mapping[0].HasFunctions)
   153  				require.Len(t, p.Location[0].Line, 1)
   154  				require.Len(t, p.Location[1].Line, 1)
   155  
   156  				fn1 := p.StringTable[p.Function[p.Location[0].Line[0].FunctionId-1].Name]
   157  				fn2 := p.StringTable[p.Function[p.Location[1].Line[0].FunctionId-1].Name]
   158  				require.Contains(t, fn1, "linux-vdso.1.so")
   159  				require.Contains(t, fn1, "0xa4c")
   160  				require.Contains(t, fn2, "linux-vdso.1.so")
   161  				require.Contains(t, fn2, "0x9f0")
   162  			},
   163  		},
   164  		{
   165  			name: "multiple locations per mapping",
   166  			profile: &googlev1.Profile{
   167  				Mapping: []*googlev1.Mapping{{
   168  					BuildId:     1,
   169  					MemoryStart: 0x0,
   170  					MemoryLimit: 0x1000000,
   171  					FileOffset:  0x0,
   172  				}},
   173  				Location: []*googlev1.Location{
   174  					{Id: 1, MappingId: 1, Address: 0x1500},
   175  					{Id: 2, MappingId: 1, Address: 0x3b60},
   176  					{Id: 3, MappingId: 1, Address: 0x1440},
   177  				},
   178  				StringTable: []string{"", "build-id"},
   179  			},
   180  			setupMock: func(mockClient *mocksymbolizer.MockDebuginfodClient, mockBucket *mockobjstore.MockBucket) {
   181  				mockClient.On("FetchDebuginfo", mock.Anything, "build-id").Return(openTestFile(t), nil).Once()
   182  				mockBucket.On("Get", mock.Anything, "build-id").Return(nil, fmt.Errorf("not found")).Once()
   183  				mockBucket.On("Upload", mock.Anything, "build-id", mock.Anything).Return(nil).Once()
   184  			},
   185  			validate: func(t *testing.T, p *googlev1.Profile) {
   186  				require.True(t, p.Mapping[0].HasFunctions)
   187  
   188  				// First location (0x1500) - main
   189  				require.Len(t, p.Location[0].Line, 1)
   190  				assertLocationHasFunction(t, p, p.Location[0], "main", "main")
   191  
   192  				// Second location (0x3b60) - atoll_b
   193  				require.Len(t, p.Location[1].Line, 1)
   194  				assertLocationHasFunction(t, p, p.Location[1], "atoll_b", "atoll_b")
   195  
   196  				// Third location (0x1440) - main
   197  				require.Len(t, p.Location[2].Line, 1)
   198  				assertLocationHasFunction(t, p, p.Location[2], "main", "main")
   199  			},
   200  		},
   201  		{
   202  			name: "preserve existing symbols when HasFunctions=false",
   203  			// This tests a defensive check against data inconsistency where a mapping has
   204  			// HasFunctions=false but contains locations with existing symbols.
   205  			// This scenario should be rare, but we maintain the check for robustness.
   206  			profile: &googlev1.Profile{
   207  				Mapping: []*googlev1.Mapping{{
   208  					Id:           1,
   209  					BuildId:      1,
   210  					Filename:     2,
   211  					MemoryStart:  0x0,
   212  					MemoryLimit:  0x1000000,
   213  					FileOffset:   0x0,
   214  					HasFunctions: false,
   215  				}},
   216  				Location: []*googlev1.Location{
   217  					{
   218  						Id:        1,
   219  						MappingId: 1,
   220  						Address:   0x1000,
   221  						Line: []*googlev1.Line{{
   222  							FunctionId: 1,
   223  							Line:       42,
   224  						}},
   225  					},
   226  					{
   227  						Id:        2,
   228  						MappingId: 1,
   229  						Address:   0x1500,
   230  						Line:      nil,
   231  					},
   232  				},
   233  				Function: []*googlev1.Function{{
   234  					Id:   1,
   235  					Name: 3,
   236  				}},
   237  				StringTable: []string{"", "build-id", "alloy", "existing_function"},
   238  			},
   239  			setupMock: func(mockClient *mocksymbolizer.MockDebuginfodClient, mockBucket *mockobjstore.MockBucket) {
   240  				mockClient.On("FetchDebuginfo", mock.Anything, "build-id").Return(openTestFile(t), nil).Once()
   241  				mockBucket.On("Get", mock.Anything, "build-id").Return(nil, fmt.Errorf("not found")).Once()
   242  				mockBucket.On("Upload", mock.Anything, "build-id", mock.Anything).Return(nil).Once()
   243  			},
   244  			validate: func(t *testing.T, p *googlev1.Profile) {
   245  				require.True(t, p.Mapping[0].HasFunctions)
   246  
   247  				require.Len(t, p.Location[0].Line, 1)
   248  				require.Equal(t, uint64(1), p.Location[0].Line[0].FunctionId)
   249  				require.Equal(t, "existing_function", p.StringTable[p.Function[0].Name])
   250  
   251  				require.Len(t, p.Location[1].Line, 1)
   252  				assertLocationHasFunction(t, p, p.Location[1], "main", "main")
   253  
   254  				existingFuncStillExists := false
   255  				for _, str := range p.StringTable {
   256  					if str == "existing_function" {
   257  						existingFuncStillExists = true
   258  						break
   259  					}
   260  				}
   261  				require.True(t, existingFuncStillExists)
   262  
   263  				placeholderFound := false
   264  				for _, str := range p.StringTable {
   265  					if strings.Contains(str, "!0x") {
   266  						placeholderFound = true
   267  						break
   268  					}
   269  				}
   270  				require.False(t, placeholderFound)
   271  			},
   272  		},
   273  	}
   274  
   275  	for _, tt := range tests {
   276  		t.Run(tt.name, func(t *testing.T) {
   277  			s, mockClient, mockBucket := newSymbolizerTest(t, nil)
   278  			tt.setupMock(mockClient, mockBucket)
   279  
   280  			ctx := tenant.InjectTenantID(context.Background(), "tenant")
   281  			err := s.SymbolizePprof(ctx, tt.profile)
   282  			if tt.wantErr {
   283  				require.Error(t, err)
   284  				return
   285  			}
   286  			require.NoError(t, err)
   287  
   288  			tt.validate(t, tt.profile)
   289  			mockClient.AssertExpectations(t)
   290  		})
   291  	}
   292  }
   293  
   294  func TestSymbolizationKeepsSequentialFunctionIDs(t *testing.T) {
   295  	s, mockClient, mockBucket := newSymbolizerTest(t, nil)
   296  
   297  	profile := &googlev1.Profile{
   298  		Mapping:     []*googlev1.Mapping{{BuildId: 1}},
   299  		Location:    []*googlev1.Location{{Id: 1, MappingId: 1, Address: 0x1500}},
   300  		Function:    []*googlev1.Function{{Id: 1, Name: 1}},
   301  		StringTable: []string{"", "build-id", "existing_func"},
   302  		Sample: []*googlev1.Sample{{
   303  			LocationId: []uint64{1},
   304  			Value:      []int64{100},
   305  		}},
   306  	}
   307  
   308  	mockBucket.On("Get", mock.Anything, "build-id").Return(nil, fmt.Errorf("not found"))
   309  	mockClient.On("FetchDebuginfo", mock.Anything, "build-id").Return(openTestFile(t), nil)
   310  	mockBucket.On("Upload", mock.Anything, "build-id", mock.Anything).Return(nil)
   311  
   312  	ctx := tenant.InjectTenantID(context.Background(), "tenant")
   313  	err := s.SymbolizePprof(ctx, profile)
   314  	require.NoError(t, err)
   315  
   316  	// Verify sequential function IDs
   317  	for i, fn := range profile.Function {
   318  		require.Equal(t, uint64(i+1), fn.Id)
   319  	}
   320  
   321  	_, err = model.TreeFromBackendProfile(profile, 1000)
   322  	require.NoError(t, err)
   323  }
   324  
   325  func TestSymbolizationWithLidiaData(t *testing.T) {
   326  
   327  	const testLidiaZip = "testdata/test_lidia_file.gz"
   328  	const buildID = "ffcf60c240417166980a43fbbfde486e0b3718e5"
   329  
   330  	lidiaData, err := extractGzipFile(t, testLidiaZip)
   331  	require.NoError(t, err)
   332  	require.NotEmpty(t, lidiaData)
   333  
   334  	// Configure the mock to return the same Lidia data for both Get operations
   335  	getLidiaData := func() io.ReadCloser {
   336  		return io.NopCloser(bytes.NewReader(lidiaData))
   337  	}
   338  
   339  	sym, _, mockBucket := newSymbolizerTest(t, nil)
   340  
   341  	mockBucket.On("Get", mock.Anything, buildID).Return(getLidiaData(), nil).Once()
   342  	mockBucket.On("Get", mock.Anything, buildID).Return(getLidiaData(), nil).Once()
   343  
   344  	req := &request{
   345  		buildID:    buildID,
   346  		binaryName: "test-binary",
   347  		locations: []*location{
   348  			{
   349  				address: 0x1b743d6,
   350  			},
   351  		},
   352  	}
   353  
   354  	ctx := tenant.InjectTenantID(context.Background(), "tenant")
   355  	sym.symbolize(ctx, req)
   356  	require.NotEmpty(t, req.locations[0].lines)
   357  
   358  	// Second request should also fetch from store
   359  	req2 := &request{
   360  		buildID:    buildID,
   361  		binaryName: "test-binary",
   362  		locations: []*location{
   363  			{
   364  				address: 0x1b743d6,
   365  			},
   366  		},
   367  	}
   368  
   369  	sym.symbolize(ctx, req2)
   370  	require.NotEmpty(t, req2.locations[0].lines)
   371  }
   372  
   373  // TestSymbolizeWithObjectStore validates the symbolizer's behavior with the object store
   374  func TestSymbolizeWithObjectStore(t *testing.T) {
   375  
   376  	elfTestFile := openTestFile(t)
   377  	elfData, err := io.ReadAll(elfTestFile)
   378  	elfTestFile.Close()
   379  	require.NoError(t, err)
   380  
   381  	var capturedLidiaData []byte
   382  
   383  	ctx := tenant.InjectTenantID(context.Background(), "tenant")
   384  
   385  	// 1. First request: Object store miss → fetch from debuginfod → store Lidia data in object store
   386  	t.Run("store-miss", func(t *testing.T) {
   387  		s, mockClient, mockBucket := newSymbolizerTest(t, nil)
   388  
   389  		mockBucket.On("Get", mock.Anything, "build-id").Return(nil, fmt.Errorf("not found")).Once()
   390  		mockClient.On("FetchDebuginfo", mock.Anything, "build-id").Return(io.NopCloser(bytes.NewReader(elfData)), nil).Once()
   391  		mockBucket.On("Upload", mock.Anything, "build-id", mock.Anything).Run(func(args mock.Arguments) {
   392  			reader := args.Get(2).(io.Reader)
   393  			var buf bytes.Buffer
   394  			teeReader := io.TeeReader(reader, &buf)
   395  			var err error
   396  			capturedLidiaData, err = io.ReadAll(teeReader)
   397  			require.NoError(t, err)
   398  		}).Return(nil).Once()
   399  
   400  		req1 := createRequest(t, "build-id", 0x1500)
   401  		s.symbolize(ctx, req1)
   402  		require.NotEmpty(t, req1.locations[0].lines)
   403  		require.NotEmpty(t, capturedLidiaData)
   404  
   405  		mockClient.AssertExpectations(t)
   406  		mockBucket.AssertExpectations(t)
   407  
   408  	})
   409  
   410  	// 2. Second request (same build-id, same address): Object store hit → use cached Lidia data
   411  	t.Run("store hit, same address", func(t *testing.T) {
   412  		s, mockClient, mockBucket := newSymbolizerTest(t, nil)
   413  
   414  		mockBucket.On("Get", mock.Anything, "build-id").Return(
   415  			io.NopCloser(bytes.NewReader(capturedLidiaData)), nil,
   416  		).Once()
   417  
   418  		req2 := createRequest(t, "build-id", 0x1500)
   419  		s.symbolize(ctx, req2)
   420  		require.NotEmpty(t, req2.locations[0].lines)
   421  
   422  		mockClient.AssertExpectations(t)
   423  		mockBucket.AssertExpectations(t)
   424  	})
   425  
   426  	// 3. Third request (same build-id, different address): Object store hit → use cached Lidia data
   427  	t.Run("store hit, different address", func(t *testing.T) {
   428  		s, mockClient, mockBucket := newSymbolizerTest(t, nil)
   429  		mockBucket.On("Get", mock.Anything, "build-id").Return(
   430  			io.NopCloser(bytes.NewReader(capturedLidiaData)), nil,
   431  		).Once()
   432  
   433  		req3 := createRequest(t, "build-id", 0x3c5a)
   434  		s.symbolize(ctx, req3)
   435  		require.NotEmpty(t, req3.locations[0].lines)
   436  
   437  		mockClient.AssertExpectations(t)
   438  		mockBucket.AssertExpectations(t)
   439  	})
   440  
   441  	// 4. Fourth request (different build-id): Object store miss → fetch from debuginfod → store Lidia data
   442  	t.Run("store miss, different build-id", func(t *testing.T) {
   443  		s, mockClient, mockBucket := newSymbolizerTest(t, nil)
   444  
   445  		var capturedLidiaData2 []byte
   446  		mockBucket.On("Get", mock.Anything, "different-build-id").Return(nil, fmt.Errorf("not found")).Once()
   447  		mockClient.On("FetchDebuginfo", mock.Anything, "different-build-id").Return(io.NopCloser(bytes.NewReader(elfData)), nil).Once()
   448  		mockBucket.On("Upload", mock.Anything, "different-build-id", mock.Anything).Run(func(args mock.Arguments) {
   449  			reader := args.Get(2).(io.Reader)
   450  			var buf bytes.Buffer
   451  			teeReader := io.TeeReader(reader, &buf)
   452  			var err error
   453  			capturedLidiaData2, err = io.ReadAll(teeReader)
   454  			require.NoError(t, err)
   455  		}).Return(nil).Once()
   456  
   457  		req4 := createRequest(t, "different-build-id", 0x1500)
   458  		s.symbolize(ctx, req4)
   459  		require.NotEmpty(t, req4.locations[0].lines)
   460  		require.NotEmpty(t, capturedLidiaData2)
   461  
   462  		mockClient.AssertExpectations(t)
   463  		mockBucket.AssertExpectations(t)
   464  	})
   465  
   466  }
   467  
   468  func TestSymbolizerMetrics(t *testing.T) {
   469  	tests := []struct {
   470  		name      string
   471  		setupMock func(*mocksymbolizer.MockDebuginfodClient, *mockobjstore.MockBucket)
   472  		setupTest func(*Symbolizer, context.Context)
   473  		expected  map[string]int
   474  	}{
   475  		{
   476  			name: "successful symbolization with cache",
   477  			setupMock: func(mockClient *mocksymbolizer.MockDebuginfodClient, mockBucket *mockobjstore.MockBucket) {
   478  				elfTestFile := openTestFile(t)
   479  				elfData, err := io.ReadAll(elfTestFile)
   480  				elfTestFile.Close()
   481  				require.NoError(t, err)
   482  
   483  				preProcessor := &Symbolizer{
   484  					logger:  log.NewNopLogger(),
   485  					metrics: newMetrics(nil),
   486  				}
   487  				lidiaData, err := preProcessor.processELFData(elfData, 0) // 0 means unlimited
   488  				require.NoError(t, err)
   489  				require.NotEmpty(t, lidiaData)
   490  
   491  				mockBucket.On("IsObjNotFoundErr", mock.Anything).Return(true).Maybe()
   492  				mockBucket.On("Name").Return("test-bucket").Maybe()
   493  
   494  				mockBucket.On("Get", mock.Anything, "build-id").Return(nil, fmt.Errorf("not found")).Once()
   495  
   496  				mockClient.On("FetchDebuginfo", mock.Anything, "build-id").Return(
   497  					io.NopCloser(bytes.NewReader(elfData)), nil,
   498  				).Once()
   499  				mockBucket.On("Upload", mock.Anything, "build-id", mock.Anything).Return(nil).Once()
   500  
   501  				mockBucket.On("Get", mock.Anything, "build-id").Return(
   502  					io.NopCloser(bytes.NewReader(lidiaData)), nil,
   503  				).Once()
   504  			},
   505  			setupTest: func(s *Symbolizer, ctx context.Context) {
   506  				req1 := createRequest(t, "build-id", 0x1500)
   507  				s.symbolize(ctx, req1)
   508  
   509  				req2 := createRequest(t, "build-id", 0x1500)
   510  				s.symbolize(ctx, req2)
   511  			},
   512  			expected: map[string]int{
   513  				"pyroscope_profile_symbolization_duration_seconds":   0,
   514  				"pyroscope_debug_symbol_resolution_duration_seconds": 1,
   515  				"pyroscope_debug_symbol_resolution_errors_total":     0,
   516  			},
   517  		},
   518  		{
   519  			name: "debuginfod error",
   520  			setupMock: func(mockClient *mocksymbolizer.MockDebuginfodClient, mockBucket *mockobjstore.MockBucket) {
   521  				mockBucket.On("Get", mock.Anything, "unknown-build-id").Return(nil, fmt.Errorf("not found")).Once()
   522  				mockClient.On("FetchDebuginfo", mock.Anything, "unknown-build-id").
   523  					Return(nil, buildIDNotFoundError{buildID: "unknown-build-id"}).Once()
   524  			},
   525  			setupTest: func(s *Symbolizer, ctx context.Context) {
   526  				req := createRequest(t, "unknown-build-id", 0x1500)
   527  				s.symbolize(ctx, req)
   528  			},
   529  			expected: map[string]int{
   530  				"pyroscope_profile_symbolization_duration_seconds":   0,
   531  				"pyroscope_debug_symbol_resolution_duration_seconds": 0,
   532  				"pyroscope_debug_symbol_resolution_errors_total":     0,
   533  			},
   534  		},
   535  		{
   536  			name: "elf_parsing_error",
   537  			setupMock: func(mockClient *mocksymbolizer.MockDebuginfodClient, mockBucket *mockobjstore.MockBucket) {
   538  				invalidData := []byte("invalid elf data")
   539  
   540  				mockBucket.On("Get", mock.Anything, "invalid-elf").Return(nil, fmt.Errorf("not found")).Once()
   541  				mockClient.On("FetchDebuginfo", mock.Anything, "invalid-elf").Return(
   542  					io.NopCloser(bytes.NewReader(invalidData)), nil,
   543  				).Once()
   544  			},
   545  			setupTest: func(s *Symbolizer, ctx context.Context) {
   546  				req := createRequest(t, "invalid-elf", 0x1500)
   547  				s.symbolize(ctx, req)
   548  			},
   549  			expected: map[string]int{
   550  				"pyroscope_profile_symbolization_duration_seconds": 0,
   551  				"pyroscope_debug_symbol_resolution_errors_total":   1,
   552  			},
   553  		},
   554  	}
   555  
   556  	for _, tt := range tests {
   557  		t.Run(tt.name, func(t *testing.T) {
   558  			reg := prometheus.NewRegistry()
   559  			s, mockClient, mockBucket := newSymbolizerTest(t, &symbolizerInputs{Registry: reg})
   560  			tt.setupMock(mockClient, mockBucket)
   561  
   562  			ctx := tenant.InjectTenantID(context.Background(), "tenant")
   563  			tt.setupTest(s, ctx)
   564  
   565  			for metricName, expectedCount := range tt.expected {
   566  				count, err := testutil.GatherAndCount(reg, metricName)
   567  				require.NoError(t, err, "Error gathering metric %s", metricName)
   568  				require.Equal(t, expectedCount, count, "Metric %s count mismatch", metricName)
   569  			}
   570  
   571  			mockClient.AssertExpectations(t)
   572  			mockBucket.AssertExpectations(t)
   573  		})
   574  	}
   575  }
   576  
   577  func assertLocationHasFunction(t *testing.T, profile *googlev1.Profile, loc *googlev1.Location,
   578  	functionName, fileName string) {
   579  	t.Helper()
   580  
   581  	found := false
   582  
   583  	for _, line := range loc.Line {
   584  		for _, fn := range profile.Function {
   585  			if fn.Id == line.FunctionId {
   586  				name := "<invalid>"
   587  				if fn.Name >= 0 && int(fn.Name) < len(profile.StringTable) {
   588  					name = profile.StringTable[fn.Name]
   589  				}
   590  				if name == functionName {
   591  					found = true
   592  				}
   593  			}
   594  		}
   595  	}
   596  
   597  	require.True(t, found, "Function %q not found in location", functionName)
   598  
   599  	if found {
   600  		fileNameFound := false
   601  		for _, str := range profile.StringTable {
   602  			if str == fileName {
   603  				fileNameFound = true
   604  				break
   605  			}
   606  		}
   607  		require.True(t, fileNameFound, "Filename %q not found in string table", fileName)
   608  	}
   609  
   610  }
   611  
   612  func openTestFile(t *testing.T) io.ReadCloser {
   613  	t.Helper()
   614  	f, err := os.Open("testdata/symbols.debug")
   615  	require.NoError(t, err)
   616  
   617  	data, err := io.ReadAll(f)
   618  	require.NoError(t, err)
   619  	f.Close()
   620  
   621  	return NewReaderAtCloser(data)
   622  }
   623  
   624  func extractGzipFile(t *testing.T, gzipPath string) ([]byte, error) {
   625  	t.Helper()
   626  	file, err := os.Open(gzipPath)
   627  	if err != nil {
   628  		return nil, err
   629  	}
   630  	defer file.Close()
   631  
   632  	gzipReader, err := gzip.NewReader(file)
   633  	if err != nil {
   634  		return nil, err
   635  	}
   636  	defer gzipReader.Close()
   637  
   638  	return io.ReadAll(gzipReader)
   639  }
   640  
   641  func createRequest(t *testing.T, buildID string, address uint64) *request {
   642  	t.Helper()
   643  	return &request{
   644  		buildID: buildID,
   645  		locations: []*location{
   646  			{
   647  				address: address,
   648  			},
   649  		},
   650  	}
   651  }
   652  
   653  func TestConfigValidate(t *testing.T) {
   654  	tests := []struct {
   655  		name    string
   656  		setup   func(cfg *Config)
   657  		wantErr bool
   658  	}{
   659  		{
   660  			name:    "valid config with positive concurrency",
   661  			setup:   func(cfg *Config) { cfg.MaxDebuginfodConcurrency = 10 },
   662  			wantErr: false,
   663  		},
   664  		{
   665  			name:    "invalid config with zero concurrency",
   666  			setup:   func(cfg *Config) { cfg.MaxDebuginfodConcurrency = 0 },
   667  			wantErr: true,
   668  		},
   669  		{
   670  			name:    "invalid config with negative concurrency",
   671  			setup:   func(cfg *Config) { cfg.MaxDebuginfodConcurrency = -1 },
   672  			wantErr: true,
   673  		},
   674  	}
   675  
   676  	for _, tt := range tests {
   677  		t.Run(tt.name, func(t *testing.T) {
   678  			cfg := Config{}
   679  			tt.setup(&cfg)
   680  			err := cfg.Validate()
   681  			if tt.wantErr {
   682  				require.Error(t, err)
   683  			} else {
   684  				require.NoError(t, err)
   685  			}
   686  		})
   687  	}
   688  }
   689  
   690  // TestUpdateAllSymbolsInProfile verifies that line numbers, file paths, and StartLine
   691  // are properly passed through from SourceInfoFrame to the profile.
   692  func TestUpdateAllSymbolsInProfile(t *testing.T) {
   693  	s := &Symbolizer{logger: log.NewNopLogger()}
   694  	stringMap := make(map[string]int64)
   695  
   696  	t.Run("basic symbolization", func(t *testing.T) {
   697  		profile := &googlev1.Profile{
   698  			Mapping:     []*googlev1.Mapping{{Id: 1, HasFunctions: false}},
   699  			Location:    []*googlev1.Location{{Id: 1, MappingId: 1, Address: 0x1500}},
   700  			StringTable: []string{""},
   701  			Function:    []*googlev1.Function{},
   702  		}
   703  
   704  		symbolizedLocs := []symbolizedLocation{{
   705  			loc: profile.Location[0],
   706  			symLoc: &location{
   707  				address: 0x1500,
   708  				lines: []lidia.SourceInfoFrame{{
   709  					LineNumber: 42, FunctionName: "testFunction", FilePath: "/path/to/test.go",
   710  				}},
   711  			},
   712  			mapping: profile.Mapping[0],
   713  		}}
   714  
   715  		s.updateAllSymbolsInProfile(profile, symbolizedLocs, stringMap)
   716  
   717  		require.True(t, profile.Mapping[0].HasFunctions)
   718  		require.Len(t, profile.Location[0].Line, 1)
   719  		require.Len(t, profile.Function, 1)
   720  
   721  		line := profile.Location[0].Line[0]
   722  		fn := profile.Function[0]
   723  
   724  		require.Equal(t, int64(42), line.Line)
   725  		require.Equal(t, int64(42), fn.StartLine)
   726  		require.Equal(t, "testFunction", profile.StringTable[fn.Name])
   727  		require.Equal(t, "/path/to/test.go", profile.StringTable[fn.Filename])
   728  	})
   729  
   730  	t.Run("minimum StartLine for same function", func(t *testing.T) {
   731  		profile := &googlev1.Profile{
   732  			Mapping: []*googlev1.Mapping{{Id: 1, HasFunctions: false}},
   733  			Location: []*googlev1.Location{
   734  				{Id: 1, MappingId: 1, Address: 0x1500},
   735  				{Id: 2, MappingId: 1, Address: 0x1600},
   736  			},
   737  			StringTable: []string{""},
   738  			Function:    []*googlev1.Function{},
   739  		}
   740  
   741  		symbolizedLocs := []symbolizedLocation{
   742  			{
   743  				loc: profile.Location[0],
   744  				symLoc: &location{address: 0x1500, lines: []lidia.SourceInfoFrame{{
   745  					LineNumber: 100, FunctionName: "testFunction", FilePath: "/path/to/test.go",
   746  				}}},
   747  				mapping: profile.Mapping[0],
   748  			},
   749  			{
   750  				loc: profile.Location[1],
   751  				symLoc: &location{address: 0x1600, lines: []lidia.SourceInfoFrame{{
   752  					LineNumber: 50, FunctionName: "testFunction", FilePath: "/path/to/test.go",
   753  				}}},
   754  				mapping: profile.Mapping[0],
   755  			},
   756  		}
   757  
   758  		s.updateAllSymbolsInProfile(profile, symbolizedLocs, stringMap)
   759  
   760  		require.Len(t, profile.Function, 1)
   761  		// StartLine properly updated
   762  		require.Equal(t, int64(50), profile.Function[0].StartLine)
   763  		require.Equal(t, int64(100), profile.Location[0].Line[0].Line)
   764  		require.Equal(t, int64(50), profile.Location[1].Line[0].Line)
   765  	})
   766  }