github.com/grafana/pyroscope@v1.18.0/pkg/ingester/pyroscope/ingest_handler_test.go (about)

     1  package pyroscope
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/json"
     7  	"mime/multipart"
     8  	"net/http/httptest"
     9  	"os"
    10  	"slices"
    11  	"sort"
    12  	"testing"
    13  
    14  	"connectrpc.com/connect"
    15  
    16  	"github.com/go-kit/log"
    17  	"github.com/prometheus/prometheus/model/labels"
    18  	"github.com/stretchr/testify/assert"
    19  	"github.com/stretchr/testify/require"
    20  
    21  	profilev1 "github.com/grafana/pyroscope/api/gen/proto/go/google/v1"
    22  	pushv1 "github.com/grafana/pyroscope/api/gen/proto/go/push/v1"
    23  	v1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
    24  	"github.com/grafana/pyroscope/pkg/distributor/model"
    25  	phlaremodel "github.com/grafana/pyroscope/pkg/model"
    26  	pprof2 "github.com/grafana/pyroscope/pkg/og/convert/pprof"
    27  	"github.com/grafana/pyroscope/pkg/og/convert/pprof/bench"
    28  	"github.com/grafana/pyroscope/pkg/pprof"
    29  	"github.com/grafana/pyroscope/pkg/tenant"
    30  	"github.com/grafana/pyroscope/pkg/util/body"
    31  	"github.com/grafana/pyroscope/pkg/validation"
    32  )
    33  
    34  type flatProfileSeries struct {
    35  	Labels     []*v1.LabelPair
    36  	Profile    *profilev1.Profile
    37  	RawProfile []byte
    38  }
    39  
    40  type MockPushService struct {
    41  	Keep     bool
    42  	reqPprof []*flatProfileSeries
    43  	T        testing.TB
    44  }
    45  
    46  func (m *MockPushService) PushBatch(ctx context.Context, req *model.PushRequest) error {
    47  	if m.Keep {
    48  		for _, series := range req.Series {
    49  			rawProfileCopy := make([]byte, len(series.RawProfile))
    50  			copy(rawProfileCopy, series.RawProfile)
    51  			m.reqPprof = append(m.reqPprof, &flatProfileSeries{
    52  				Labels:     series.Labels,
    53  				Profile:    series.Profile.CloneVT(),
    54  				RawProfile: rawProfileCopy,
    55  			})
    56  		}
    57  	}
    58  	return nil
    59  }
    60  
    61  type DumpProfile struct {
    62  	Collapsed  []string
    63  	Labels     string
    64  	SampleType string
    65  }
    66  type Dump struct {
    67  	Profiles []DumpProfile
    68  }
    69  
    70  func (m *MockPushService) Push(_ context.Context, req *connect.Request[pushv1.PushRequest]) (*connect.Response[pushv1.PushResponse], error) {
    71  	for _, series := range req.Msg.Series {
    72  		for _, sample := range series.Samples {
    73  			p, err := pprof.RawFromBytes(sample.RawProfile)
    74  			if err != nil {
    75  				return nil, err
    76  			}
    77  			m.reqPprof = append(m.reqPprof, &flatProfileSeries{
    78  				Labels:  series.Labels,
    79  				Profile: p.CloneVT(),
    80  			})
    81  		}
    82  	}
    83  	return nil, nil
    84  }
    85  
    86  func (m *MockPushService) selectActualProfile(lsUnsorted []labels.Label, st string) DumpProfile {
    87  	ls := labels.New(lsUnsorted...)
    88  	lss := ls.String()
    89  	for _, p := range m.reqPprof {
    90  		promLabels := phlaremodel.Labels(p.Labels).ToPrometheusLabels()
    91  		actualLabels := labels.NewBuilder(promLabels).Del("jfr_event").Labels()
    92  		als := actualLabels.String()
    93  		if als == lss {
    94  			for sti := range p.Profile.SampleType {
    95  				actualST := p.Profile.StringTable[p.Profile.SampleType[sti].Type]
    96  				if actualST == st {
    97  					dp := DumpProfile{}
    98  					dp.Labels = ls.String()
    99  					dp.SampleType = actualST
   100  					dp.Collapsed = bench.StackCollapseProto(p.Profile, sti, 1.0)
   101  					slices.Sort(dp.Collapsed)
   102  					return dp
   103  				}
   104  			}
   105  		}
   106  	}
   107  	m.T.Fatalf("no profile found for %s %s", ls.String(), st)
   108  	return DumpProfile{}
   109  }
   110  
   111  func (m *MockPushService) CompareDump(file string) {
   112  	bs, err := bench.ReadGzipFile(file)
   113  	require.NoError(m.T, err)
   114  
   115  	expected := Dump{}
   116  	err = json.Unmarshal(bs, &expected)
   117  	require.NoError(m.T, err)
   118  
   119  	var req []*flatProfileSeries
   120  	for _, x := range m.reqPprof {
   121  		iterateProfileSeries(x.Profile.CloneVT(), x.Labels, func(p *profilev1.Profile, ls phlaremodel.Labels) {
   122  			req = append(req, &flatProfileSeries{
   123  				Labels:  ls,
   124  				Profile: p,
   125  			})
   126  		})
   127  	}
   128  	m.reqPprof = req
   129  
   130  	for i := range expected.Profiles {
   131  		expectedLabels := []labels.Label{}
   132  		err := json.Unmarshal([]byte(expected.Profiles[i].Labels), &expectedLabels)
   133  		require.NoError(m.T, err)
   134  
   135  		actual := m.selectActualProfile(expectedLabels, expected.Profiles[i].SampleType)
   136  		require.Equal(m.T, expected.Profiles[i].Collapsed, actual.Collapsed)
   137  	}
   138  }
   139  
   140  const (
   141  	repoRoot       = "../../../"
   142  	testdataDirJFR = repoRoot + "pkg/og/convert/jfr/testdata"
   143  )
   144  
   145  func TestCorruptedJFR422(t *testing.T) {
   146  	l := log.NewSyncLogger(log.NewLogfmtLogger(os.Stderr))
   147  
   148  	src := testdataDirJFR + "/" + "cortex-dev-01__kafka-0__cpu__0.jfr.gz"
   149  	jfr, err := bench.ReadGzipFile(src)
   150  	require.NoError(t, err)
   151  
   152  	jfr[0] = 0 // corrupt jfr
   153  
   154  	svc := &MockPushService{Keep: true, T: t}
   155  	h := NewPyroscopeIngestHandler(svc, validation.MockLimits{}, l)
   156  
   157  	res := httptest.NewRecorder()
   158  	body, ct := createJFRRequestBody(t, jfr, nil)
   159  
   160  	req := httptest.NewRequest("POST", "/ingest?name=javaapp&format=jfr", bytes.NewReader(body))
   161  	req.Header.Set("Content-Type", ct)
   162  	h.ServeHTTP(res, req)
   163  
   164  	require.Equal(t, 422, res.Code)
   165  }
   166  
   167  func createJFRRequestBody(t *testing.T, jfr, labels []byte) ([]byte, string) {
   168  	var b bytes.Buffer
   169  	w := multipart.NewWriter(&b)
   170  	jfrw, err := w.CreateFormFile("jfr", "jfr")
   171  	require.NoError(t, err)
   172  	_, err = jfrw.Write(jfr)
   173  	require.NoError(t, err)
   174  	if labels != nil {
   175  		labelsw, err := w.CreateFormFile("labels", "labels")
   176  		require.NoError(t, err)
   177  		_, err = labelsw.Write(labels)
   178  		require.NoError(t, err)
   179  	}
   180  	err = w.Close()
   181  	require.NoError(t, err)
   182  	return b.Bytes(), w.FormDataContentType()
   183  }
   184  
   185  func BenchmarkIngestJFR(b *testing.B) {
   186  	jfrs := []string{
   187  		"cortex-dev-01__kafka-0__cpu__0.jfr.gz",
   188  		"cortex-dev-01__kafka-0__cpu__1.jfr.gz",
   189  		"cortex-dev-01__kafka-0__cpu__2.jfr.gz",
   190  		"cortex-dev-01__kafka-0__cpu__3.jfr.gz",
   191  		"cortex-dev-01__kafka-0__cpu_lock0_alloc0__0.jfr.gz",
   192  		"cortex-dev-01__kafka-0__cpu_lock_alloc__0.jfr.gz",
   193  		"cortex-dev-01__kafka-0__cpu_lock_alloc__1.jfr.gz",
   194  		"cortex-dev-01__kafka-0__cpu_lock_alloc__2.jfr.gz",
   195  		"cortex-dev-01__kafka-0__cpu_lock_alloc__3.jfr.gz",
   196  	}
   197  	l := log.NewSyncLogger(log.NewLogfmtLogger(os.Stderr))
   198  	h := NewPyroscopeIngestHandler(&MockPushService{}, validation.MockLimits{}, l)
   199  
   200  	for _, jfr := range jfrs {
   201  		b.Run(jfr, func(b *testing.B) {
   202  			jfr, err := bench.ReadGzipFile(testdataDirJFR + "/" + jfr)
   203  			require.NoError(b, err)
   204  			for i := 0; i < b.N; i++ {
   205  				res := httptest.NewRecorder()
   206  				req := httptest.NewRequest("POST", "/ingest?name=javaapp&format=jfr", bytes.NewReader(jfr))
   207  				req.Header.Set("Content-Type", "application/octet-stream")
   208  				h.ServeHTTP(res, req)
   209  			}
   210  		})
   211  	}
   212  }
   213  
   214  func TestIngestPPROFFixtures(t *testing.T) {
   215  	testdata := []struct {
   216  		profile          string
   217  		prevProfile      string
   218  		sampleTypeConfig string
   219  		spyName          string
   220  
   221  		expectStatus int
   222  		expectMetric string
   223  	}{
   224  		{
   225  			profile:      repoRoot + "pkg/pprof/testdata/heap",
   226  			expectStatus: 200,
   227  			expectMetric: "memory",
   228  		},
   229  		{
   230  			profile:      repoRoot + "pkg/pprof/testdata/profile_java",
   231  			expectStatus: 200,
   232  			expectMetric: "process_cpu",
   233  		},
   234  		{
   235  			profile:      repoRoot + "pkg/og/convert/testdata/cpu.pprof",
   236  			expectStatus: 200,
   237  			expectMetric: "process_cpu",
   238  		},
   239  		{
   240  			profile:      repoRoot + "pkg/og/convert/testdata/cpu.pprof",
   241  			prevProfile:  repoRoot + "pkg/og/convert/testdata/cpu.pprof",
   242  			expectStatus: 422,
   243  		},
   244  
   245  		{
   246  			profile:      repoRoot + "pkg/og/convert/pprof/testdata/cpu.pb.gz",
   247  			prevProfile:  "",
   248  			expectStatus: 200,
   249  			expectMetric: "process_cpu",
   250  		},
   251  		{
   252  			profile:      repoRoot + "pkg/og/convert/pprof/testdata/cpu-exemplars.pb.gz",
   253  			expectStatus: 200,
   254  			expectMetric: "process_cpu",
   255  		},
   256  		{
   257  			profile:      repoRoot + "pkg/og/convert/pprof/testdata/cpu-js.pb.gz",
   258  			expectStatus: 200,
   259  			expectMetric: "wall",
   260  		},
   261  		{
   262  			profile:      repoRoot + "pkg/og/convert/pprof/testdata/heap.pb",
   263  			expectStatus: 200,
   264  			expectMetric: "memory",
   265  		},
   266  		{
   267  			profile:      repoRoot + "pkg/og/convert/pprof/testdata/heap.pb.gz",
   268  			expectStatus: 200,
   269  			expectMetric: "memory",
   270  		},
   271  		{
   272  			profile:      repoRoot + "pkg/og/convert/pprof/testdata/heap-js.pprof",
   273  			expectStatus: 200,
   274  			expectMetric: "memory",
   275  		},
   276  		{
   277  			profile:      repoRoot + "pkg/og/convert/pprof/testdata/nodejs-heap.pb.gz",
   278  			expectStatus: 200,
   279  			expectMetric: "memory",
   280  		},
   281  		{
   282  			profile:      repoRoot + "pkg/og/convert/pprof/testdata/nodejs-wall.pb.gz",
   283  			expectStatus: 200,
   284  			expectMetric: "wall",
   285  		},
   286  		{
   287  			profile:          repoRoot + "pkg/og/convert/pprof/testdata/req_2.pprof",
   288  			sampleTypeConfig: repoRoot + "pkg/og/convert/pprof/testdata/req_2.st.json",
   289  			expectStatus:     200,
   290  			expectMetric:     "goroutines",
   291  		},
   292  		{
   293  			profile:          repoRoot + "pkg/og/convert/pprof/testdata/req_3.pprof",
   294  			sampleTypeConfig: repoRoot + "pkg/og/convert/pprof/testdata/req_3.st.json",
   295  			expectStatus:     200,
   296  			expectMetric:     "block",
   297  		},
   298  		{
   299  			profile:          repoRoot + "pkg/og/convert/pprof/testdata/req_4.pprof",
   300  			sampleTypeConfig: repoRoot + "pkg/og/convert/pprof/testdata/req_4.st.json",
   301  			expectStatus:     200,
   302  			expectMetric:     "mutex",
   303  		},
   304  		{
   305  			profile:          repoRoot + "pkg/og/convert/pprof/testdata/req_5.pprof",
   306  			sampleTypeConfig: repoRoot + "pkg/og/convert/pprof/testdata/req_5.st.json",
   307  			expectStatus:     200,
   308  			expectMetric:     "memory",
   309  		},
   310  		{
   311  			// this one have milliseconds in Profile.TimeNanos
   312  			// https://github.com/grafana/pyroscope/pull/2376/files
   313  			profile:      repoRoot + "pkg/og/convert/pprof/testdata/pyspy-1.pb.gz",
   314  			expectStatus: 200,
   315  			expectMetric: "process_cpu",
   316  			spyName:      pprof2.SpyNameForFunctionNameRewrite(),
   317  		},
   318  
   319  		// todo add pprof from dotnet
   320  
   321  	}
   322  	for _, testdatum := range testdata {
   323  		t.Run(testdatum.profile, func(t *testing.T) {
   324  			var (
   325  				profile, prevProfile, sampleTypeConfig []byte
   326  				err                                    error
   327  			)
   328  			profile, err = os.ReadFile(testdatum.profile)
   329  			require.NoError(t, err)
   330  			if testdatum.prevProfile != "" {
   331  				prevProfile, err = os.ReadFile(testdatum.prevProfile)
   332  				require.NoError(t, err)
   333  			}
   334  			if testdatum.sampleTypeConfig != "" {
   335  				sampleTypeConfig, err = os.ReadFile(testdatum.sampleTypeConfig)
   336  				require.NoError(t, err)
   337  			}
   338  
   339  			bs, ct := createPProfRequest(t, profile, prevProfile, sampleTypeConfig)
   340  
   341  			svc := &MockPushService{Keep: true, T: t}
   342  			h := NewPyroscopeIngestHandler(svc, validation.MockLimits{}, log.NewSyncLogger(log.NewLogfmtLogger(os.Stderr)))
   343  
   344  			res := httptest.NewRecorder()
   345  			spyName := "foo239"
   346  			if testdatum.spyName != "" {
   347  				spyName = testdatum.spyName
   348  			}
   349  			ctx := context.Background()
   350  			ctx = tenant.InjectTenantID(ctx, "tenant-a")
   351  			req := httptest.NewRequestWithContext(ctx, "POST", "/ingest?name=pprof.test{qwe=asd}&spyName="+spyName, bytes.NewReader(bs))
   352  			req.Header.Set("Content-Type", ct)
   353  			h.ServeHTTP(res, req)
   354  			assert.Equal(t, testdatum.expectStatus, res.Code)
   355  
   356  			if testdatum.expectStatus == 200 {
   357  				require.Equal(t, 1, len(svc.reqPprof))
   358  				actualReq := svc.reqPprof[0]
   359  				ls := phlaremodel.Labels(actualReq.Labels)
   360  				require.Equal(t, testdatum.expectMetric, ls.Get(labels.MetricName))
   361  				require.Equal(t, "asd", ls.Get("qwe"))
   362  				require.Equal(t, spyName, ls.Get(phlaremodel.LabelNamePyroscopeSpy))
   363  				require.Equal(t, "pprof.test", ls.Get("service_name"))
   364  				require.Equal(t, "false", ls.Get("__delta__"))
   365  				require.Equal(t, profile, actualReq.RawProfile)
   366  
   367  				if testdatum.spyName != pprof2.SpyNameForFunctionNameRewrite() {
   368  					comparePPROF(t, actualReq.Profile, actualReq.RawProfile)
   369  				}
   370  			} else {
   371  				assert.Equal(t, 0, len(svc.reqPprof))
   372  			}
   373  		})
   374  	}
   375  }
   376  
   377  func comparePPROF(t *testing.T, actual *profilev1.Profile, profile2 []byte) {
   378  	expected, err := pprof.RawFromBytes(profile2)
   379  	require.NoError(t, err)
   380  
   381  	require.Equal(t, len(expected.SampleType), len(actual.SampleType))
   382  	for i := range actual.SampleType {
   383  		require.Equal(t, expected.StringTable[expected.SampleType[i].Type], actual.StringTable[actual.SampleType[i].Type])
   384  		require.Equal(t, expected.StringTable[expected.SampleType[i].Unit], actual.StringTable[actual.SampleType[i].Unit])
   385  
   386  		actualCollapsed := bench.StackCollapseProto(actual, i, 1.0)
   387  		expectedCollapsed := bench.StackCollapseProto(expected.Profile, i, 1.0)
   388  		require.Equal(t, expectedCollapsed, actualCollapsed)
   389  	}
   390  }
   391  
   392  func createPProfRequest(t *testing.T, profile, prevProfile, sampleTypeConfig []byte) ([]byte, string) {
   393  	const (
   394  		formFieldProfile          = "profile"
   395  		formFieldPreviousProfile  = "prev_profile"
   396  		formFieldSampleTypeConfig = "sample_type_config"
   397  	)
   398  
   399  	var b bytes.Buffer
   400  	w := multipart.NewWriter(&b)
   401  
   402  	profileW, err := w.CreateFormFile(formFieldProfile, "not used")
   403  	require.NoError(t, err)
   404  	_, err = profileW.Write(profile)
   405  	require.NoError(t, err)
   406  
   407  	if sampleTypeConfig != nil {
   408  
   409  		sampleTypeConfigW, err := w.CreateFormFile(formFieldSampleTypeConfig, "not used")
   410  		require.NoError(t, err)
   411  		_, err = sampleTypeConfigW.Write(sampleTypeConfig)
   412  		require.NoError(t, err)
   413  	}
   414  
   415  	if prevProfile != nil {
   416  		prevProfileW, err := w.CreateFormFile(formFieldPreviousProfile, "not used")
   417  		require.NoError(t, err)
   418  		_, err = prevProfileW.Write(prevProfile)
   419  		require.NoError(t, err)
   420  	}
   421  	err = w.Close()
   422  	require.NoError(t, err)
   423  
   424  	return b.Bytes(), w.FormDataContentType()
   425  }
   426  
   427  func iterateProfileSeries(p *profilev1.Profile, seriesLabels phlaremodel.Labels, fn func(*profilev1.Profile, phlaremodel.Labels)) {
   428  	for _, x := range p.Sample {
   429  		sort.Sort(pprof.LabelsByKeyValue(x.Label))
   430  	}
   431  	sort.Sort(pprof.SamplesByLabels(p.Sample))
   432  	groups := pprof.GroupSamplesWithoutLabels(p, "profile_id")
   433  	e := pprof.NewSampleExporter(p)
   434  	for _, g := range groups {
   435  		ls := mergeSeriesAndSampleLabels(p, seriesLabels, g.Labels)
   436  		ps := e.ExportSamples(new(profilev1.Profile), g.Samples)
   437  		fn(ps, ls)
   438  	}
   439  }
   440  
   441  func mergeSeriesAndSampleLabels(p *profilev1.Profile, sl []*v1.LabelPair, pl []*profilev1.Label) []*v1.LabelPair {
   442  	m := phlaremodel.Labels(sl).Clone()
   443  	for _, l := range pl {
   444  		m = append(m, &v1.LabelPair{
   445  			Name:  p.StringTable[l.Key],
   446  			Value: p.StringTable[l.Str],
   447  		})
   448  	}
   449  	sort.Stable(m)
   450  	return m.Unique()
   451  }
   452  
   453  func TestBodySizeLimit(t *testing.T) {
   454  	l := log.NewSyncLogger(log.NewLogfmtLogger(os.Stderr))
   455  	svc := &MockPushService{Keep: true, T: t}
   456  
   457  	const sizeLimit = 64 << 20 // 64 MiB
   458  
   459  	bodySizeLimiter := body.NewSizeLimitHandler(validation.MockLimits{
   460  		IngestionBodyLimitBytesValue: sizeLimit,
   461  	})
   462  
   463  	h := bodySizeLimiter(NewPyroscopeIngestHandler(svc, validation.MockLimits{}, l))
   464  
   465  	// Create a body larger than the 64 MiB limit
   466  	largeBody := make([]byte, sizeLimit+1) // 1 byte over the limit
   467  	for i := range largeBody {
   468  		largeBody[i] = byte(i % 256)
   469  	}
   470  
   471  	res := httptest.NewRecorder()
   472  	ctx := tenant.InjectTenantID(context.Background(), "any-tenant")
   473  	req := httptest.NewRequestWithContext(ctx, "POST", "/ingest?name=testapp&format=pprof", bytes.NewReader(largeBody))
   474  	req.Header.Set("Content-Type", "application/octet-stream")
   475  
   476  	h.ServeHTTP(res, req)
   477  
   478  	// Should return 413 Request Entity Too Large status when body size limit is exceeded
   479  	require.Equal(t, 413, res.Code)
   480  
   481  	// Verify the error message contains information about the body size limit
   482  	responseBody := res.Body.String()
   483  	assert.Contains(t, responseBody, "request body too large")
   484  
   485  	// Verify no profiles were ingested
   486  	assert.Equal(t, 0, len(svc.reqPprof))
   487  }
   488  
   489  func TestBodySizeWithinLimit(t *testing.T) {
   490  	l := log.NewSyncLogger(log.NewLogfmtLogger(os.Stderr))
   491  	svc := &MockPushService{Keep: true, T: t}
   492  	const sizeLimit = 64 << 20 // 64 MiB
   493  
   494  	bodySizeLimiter := body.NewSizeLimitHandler(validation.MockLimits{
   495  		IngestionBodyLimitBytesValue: sizeLimit,
   496  	})
   497  
   498  	h := bodySizeLimiter(NewPyroscopeIngestHandler(svc, validation.MockLimits{}, l))
   499  
   500  	// Use a valid small pprof profile for the test
   501  	profile, err := os.ReadFile(repoRoot + "pkg/og/convert/testdata/cpu.pprof")
   502  	require.NoError(t, err)
   503  
   504  	// Create a request with the actual profile (much smaller than limit)
   505  	res := httptest.NewRecorder()
   506  	ctx := tenant.InjectTenantID(context.Background(), "any-tenant")
   507  	req := httptest.NewRequestWithContext(ctx, "POST", "/ingest?name=testapp&format=pprof", bytes.NewReader(profile))
   508  	req.Header.Set("Content-Type", "application/octet-stream")
   509  
   510  	h.ServeHTTP(res, req)
   511  
   512  	// Should succeed with a valid profile within size limit
   513  	require.Equal(t, 200, res.Code)
   514  }