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 }