github.com/grafana/pyroscope@v1.18.0/pkg/distributor/distributor_api_test.go (about) 1 package distributor_test 2 3 import ( 4 "bytes" 5 "compress/gzip" 6 "context" 7 "fmt" 8 "io" 9 "net/http" 10 "net/http/httptest" 11 "strings" 12 "testing" 13 14 "connectrpc.com/connect" 15 "github.com/go-kit/log" 16 "github.com/gorilla/mux" 17 "github.com/grafana/dskit/server" 18 grpcgw "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" 19 "github.com/prometheus/client_golang/prometheus" 20 "github.com/prometheus/client_golang/prometheus/testutil" 21 "github.com/stretchr/testify/require" 22 otlpcolv1 "go.opentelemetry.io/proto/otlp/collector/profiles/v1development" 23 otlpv1 "go.opentelemetry.io/proto/otlp/profiles/v1development" 24 "google.golang.org/protobuf/encoding/protojson" 25 26 profilev1 "github.com/grafana/pyroscope/api/gen/proto/go/google/v1" 27 pushv1 "github.com/grafana/pyroscope/api/gen/proto/go/push/v1" 28 typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1" 29 "github.com/grafana/pyroscope/pkg/api" 30 "github.com/grafana/pyroscope/pkg/distributor" 31 "github.com/grafana/pyroscope/pkg/pprof" 32 "github.com/grafana/pyroscope/pkg/tenant" 33 "github.com/grafana/pyroscope/pkg/util" 34 "github.com/grafana/pyroscope/pkg/validation" 35 ) 36 37 type apiTest struct { 38 *api.API 39 HTTPMux *mux.Router 40 GRPCGWMux *grpcgw.ServeMux 41 } 42 43 func newAPITest(t testing.TB, cfg api.Config, logger log.Logger) *apiTest { 44 a := &apiTest{ 45 HTTPMux: mux.NewRouter(), 46 GRPCGWMux: grpcgw.NewServeMux(), 47 } 48 49 cfg.HTTPAuthMiddleware = util.AuthenticateUser(true) 50 cfg.GrpcAuthMiddleware = connect.WithInterceptors(tenant.NewAuthInterceptor(true)) 51 52 serv := &server.Server{ 53 HTTP: a.HTTPMux, 54 } 55 56 var err error 57 a.API, err = api.New( 58 cfg, 59 serv, 60 a.GRPCGWMux, 61 logger, 62 ) 63 if err != nil { 64 t.Error("failed to create api: ", err) 65 } 66 return a 67 } 68 69 func defaultLimits() *validation.Overrides { 70 return validation.MockOverrides(func(defaults *validation.Limits, tenantLimits map[string]*validation.Limits) { 71 l := validation.MockDefaultLimits() 72 l.IngestionBodyLimitMB = 1 // 1 MB 73 l.IngestionRateMB = 10000 // 100 MB 74 tenantLimits["1mb-body-limit"] = l 75 76 }) 77 } 78 79 // generateProfileOfSize creates a valid pprof profile with approximately the target size in bytes. 80 // It creates a minimal profile and pads the string table to reach the desired size. 81 func generateProfileOfSize(targetSize int, compress bool) ([]byte, error) { 82 // Create a minimal valid profile 83 p := &profilev1.Profile{ 84 SampleType: []*profilev1.ValueType{ 85 {Type: 1, Unit: 2}, 86 }, 87 Sample: []*profilev1.Sample{ 88 {LocationId: []uint64{1}, Value: []int64{100}}, 89 }, 90 Location: []*profilev1.Location{ 91 {Id: 1, MappingId: 1, Line: []*profilev1.Line{{FunctionId: 1, Line: 1}}}, 92 }, 93 Mapping: []*profilev1.Mapping{ 94 {Id: 1, Filename: 3}, 95 }, 96 Function: []*profilev1.Function{ 97 {Id: 1, Name: 4, SystemName: 4, Filename: 3}, 98 }, 99 StringTable: []string{ 100 "", 101 "cpu", "nanoseconds", 102 "main.go", 103 "foo", 104 }, 105 PeriodType: &profilev1.ValueType{Type: 1, Unit: 2}, 106 TimeNanos: 1, 107 Period: 1, 108 } 109 110 // Marshal to see the base size 111 baseData, err := pprof.Marshal(p, false) 112 if err != nil { 113 return nil, err 114 } 115 116 // If we need more size, pad the string table 117 if len(baseData) < targetSize { 118 // Add a large padding string to reach target size 119 // Each character in the string table adds roughly 1 byte 120 paddingSize := targetSize - len(baseData) 121 p.StringTable[4] = "f" + strings.Repeat("o", paddingSize) 122 } 123 124 return pprof.Marshal(p, compress) 125 } 126 127 func pprofWithSize(t testing.TB, target int) []byte { 128 t.Helper() 129 uncompressed, err := generateProfileOfSize(target, false) 130 require.NoError(t, err) 131 require.Equal(t, target, len(uncompressed)) 132 return uncompressed 133 } 134 135 func reqIngestGzPprof(data []byte) func(context.Context) *http.Request { 136 requestF := reqIngestPprof(data) 137 return func(ctx context.Context) *http.Request { 138 req := requestF(ctx) 139 req.Header.Set("Content-Encoding", "gzip") 140 return req 141 } 142 } 143 144 func reqIngestPprof(data []byte) func(context.Context) *http.Request { 145 return func(ctx context.Context) *http.Request { 146 req, err := http.NewRequestWithContext( 147 ctx, "POST", "/ingest?name=testapp&format=pprof", 148 bytes.NewReader(data), 149 ) 150 if err != nil { 151 panic(err) 152 } 153 154 return req 155 } 156 } 157 158 func reqPushPprofJson(profiles ...[][]byte) func(context.Context) *http.Request { 159 req := &pushv1.PushRequest{} 160 161 for pIdx, p := range profiles { 162 s := &pushv1.RawProfileSeries{ 163 Labels: []*typesv1.LabelPair{ 164 {Name: "__name__", Value: "fake_cpu"}, 165 {Name: "service_name", Value: "killer"}, 166 }, 167 } 168 for sIdx, sample := range p { 169 s.Samples = append(s.Samples, &pushv1.RawSample{ 170 ID: fmt.Sprintf("%d-%d", pIdx, sIdx), 171 RawProfile: sample, 172 }) 173 } 174 req.Series = append(req.Series, s) 175 } 176 177 jsonData, err := protojson.Marshal(req) 178 if err != nil { 179 panic(err) 180 } 181 182 return func(ctx context.Context) *http.Request { 183 req, err := http.NewRequestWithContext( 184 ctx, "POST", "/push.v1.PusherService/Push", 185 bytes.NewReader(jsonData), 186 ) 187 if err != nil { 188 panic(err) 189 } 190 req.Header.Set("Content-Type", "application/json") 191 return req 192 } 193 } 194 195 func gzipper(t testing.TB, fn func(testing.TB, int) []byte, targetSize int) []byte { 196 t.Helper() 197 data := fn(t, targetSize) 198 var buf bytes.Buffer 199 zw := gzip.NewWriter(&buf) 200 _, err := io.Copy(zw, bytes.NewReader(data)) 201 require.NoError(t, err) 202 require.NoError(t, zw.Close()) // Must close to flush and write gzip trailer 203 return buf.Bytes() 204 } 205 206 // otlpbuilder helps build OTLP profiles with controlled sizes 207 type otlpbuilder struct { 208 profile otlpv1.Profile 209 dictionary otlpv1.ProfilesDictionary 210 stringmap map[string]int32 211 } 212 213 func (o *otlpbuilder) addstr(s string) int32 { 214 if o.stringmap == nil { 215 o.stringmap = make(map[string]int32) 216 } 217 if idx, ok := o.stringmap[s]; ok { 218 return idx 219 } 220 idx := int32(len(o.stringmap)) 221 o.stringmap[s] = idx 222 o.dictionary.StringTable = append(o.dictionary.StringTable, s) 223 return idx 224 } 225 226 func otlpJSONWithSize(t testing.TB, targetSize int) []byte { 227 t.Helper() 228 229 b := new(otlpbuilder) 230 231 fileNameIdx := b.addstr("foo") 232 233 // Create minimal valid OTLP profile structure with cpu:nanoseconds type (maps to process_cpu) 234 b.dictionary.MappingTable = []*otlpv1.Mapping{{ 235 MemoryStart: 0x1000, 236 MemoryLimit: 0x2000, 237 FilenameStrindex: fileNameIdx, 238 }} 239 b.dictionary.LocationTable = []*otlpv1.Location{{ 240 MappingIndex: 0, 241 Address: 0x1100, 242 }} 243 b.dictionary.StackTable = []*otlpv1.Stack{{ 244 LocationIndices: []int32{0}, 245 }} 246 // Use cpu:nanoseconds which will be recognized as a valid profile type 247 cpuIdx := b.addstr("cpu") 248 nanosIdx := b.addstr("nanoseconds") 249 b.profile.SampleType = &otlpv1.ValueType{ 250 TypeStrindex: cpuIdx, 251 UnitStrindex: nanosIdx, 252 } 253 b.profile.PeriodType = &otlpv1.ValueType{ 254 TypeStrindex: cpuIdx, 255 UnitStrindex: nanosIdx, 256 } 257 b.profile.Period = 10000000 258 b.profile.Samples = []*otlpv1.Sample{{ 259 StackIndex: 0, 260 Values: []int64{100}, 261 }} 262 b.profile.TimeUnixNano = 1234567890 263 264 // Calculate current size 265 req := &otlpcolv1.ExportProfilesServiceRequest{ 266 ResourceProfiles: []*otlpv1.ResourceProfiles{{ 267 ScopeProfiles: []*otlpv1.ScopeProfiles{{ 268 Profiles: []*otlpv1.Profile{&b.profile}, 269 }}, 270 }}, 271 Dictionary: &b.dictionary, 272 } 273 jsonData, err := protojson.Marshal(req) 274 require.NoError(t, err) 275 baseSize := len(jsonData) 276 277 // Pad string table to reach target size 278 if baseSize < targetSize { 279 paddingSize := targetSize - baseSize - 1 280 b.dictionary.StringTable[fileNameIdx] += "f" + strings.Repeat("o", paddingSize) 281 } 282 283 jsonData, err = protojson.Marshal(req) 284 require.NoError(t, err) 285 if len(jsonData) != targetSize { 286 panic(fmt.Sprintf("json size=%d is not matching targetSize=%d", len(jsonData), targetSize)) 287 } 288 289 return jsonData 290 } 291 292 func reqOTLPJson(data []byte) func(context.Context) *http.Request { 293 return func(ctx context.Context) *http.Request { 294 httpReq, err := http.NewRequestWithContext( 295 ctx, "POST", "/v1development/profiles", 296 bytes.NewReader(data), 297 ) 298 if err != nil { 299 panic(err) 300 } 301 httpReq.Header.Set("Content-Type", "application/json") 302 return httpReq 303 } 304 } 305 306 func reqOTLPJsonGzip(data []byte) func(context.Context) *http.Request { 307 fn := reqOTLPJson(data) 308 return func(ctx context.Context) *http.Request { 309 req := fn(ctx) 310 req.Header.Set("Content-Encoding", "gzip") 311 return req 312 } 313 } 314 315 const ( 316 underOneMb = (1024 - 10) * 1024 317 oneMb = 1024 * 1024 318 overOneMb = (1024 + 10) * 1024 319 ) 320 321 func TestDistributorAPIBodySizeLimit(t *testing.T) { 322 const metricReason = validation.BodySizeLimit 323 324 logger := log.NewNopLogger() 325 326 limits := validation.MockOverrides(func(defaults *validation.Limits, tenantLimits map[string]*validation.Limits) { 327 l := validation.MockDefaultLimits() 328 l.IngestionBodyLimitMB = 1 // 1 MB 329 l.IngestionRateMB = 10000 // set this high enough to not interfere 330 tenantLimits["1mb-body-limit"] = l 331 332 }) 333 334 d, err := distributor.NewTestDistributor(t, 335 logger, 336 defaultLimits(), 337 ) 338 require.NoError(t, err) 339 340 a := newAPITest(t, api.Config{}, logger) 341 a.RegisterDistributor(d, limits, server.Config{}) 342 343 // generate sample payloads 344 var ( 345 pprofUnderOneMb = pprofWithSize(t, underOneMb) 346 pprofOneMb = pprofWithSize(t, oneMb) 347 pprofOverOneMb = pprofWithSize(t, overOneMb) 348 pprofGzipUnderOneMb = gzipper(t, pprofWithSize, underOneMb) 349 pprofGzipOneMb = gzipper(t, pprofWithSize, oneMb) 350 pprofGzipOverOneMb = gzipper(t, pprofWithSize, overOneMb) 351 otlpJSONUnderOneMb = otlpJSONWithSize(t, underOneMb) 352 otlpJSONnOneMb = otlpJSONWithSize(t, oneMb) 353 otlpJSONOverOneMb = otlpJSONWithSize(t, overOneMb) 354 otlpJSONGzipUnderOneMb = gzipper(t, otlpJSONWithSize, underOneMb) 355 otlpJSONGzipOneMb = gzipper(t, otlpJSONWithSize, oneMb) 356 otlpJSONGzipOverOneMb = gzipper(t, otlpJSONWithSize, overOneMb) 357 ) 358 359 testCases := []struct { 360 name string 361 skipMsg string 362 request func(context.Context) *http.Request 363 tenantID string 364 expectedStatus int 365 expectedErrorMsg string 366 expectDiscardedBytes float64 367 expectDiscardedProfiles float64 368 }{ 369 { 370 name: "ingest/uncompressed/within-limit", 371 request: reqIngestPprof(pprofUnderOneMb), 372 tenantID: "1mb-body-limit", 373 expectedStatus: 200, 374 }, 375 { 376 name: "ingest/uncompressed/exact-limit", 377 request: reqIngestPprof(pprofOneMb), 378 tenantID: "1mb-body-limit", 379 expectedStatus: 200, 380 }, 381 { 382 name: "ingest/uncompressed/exceeds-limit", 383 request: reqIngestPprof(pprofOverOneMb), 384 tenantID: "1mb-body-limit", 385 expectedStatus: 413, 386 expectedErrorMsg: "request body too large", 387 expectDiscardedBytes: float64(oneMb), 388 expectDiscardedProfiles: 1, 389 }, 390 { 391 name: "ingest/gzip/within-limit", 392 request: reqIngestGzPprof(pprofGzipUnderOneMb), 393 tenantID: "1mb-body-limit", 394 expectedStatus: 200, 395 }, 396 { 397 name: "ingest/gzip/exact-limit", 398 request: reqIngestGzPprof(pprofGzipOneMb), 399 tenantID: "1mb-body-limit", 400 expectedStatus: 200, 401 }, 402 // Note: /ingest endpoint does not support Content-Encoding: gzip header. 403 // Gzip decompression is handled at the pprof parsing layer, not HTTP layer. 404 { 405 name: "push-json/uncompressed/within-limit", 406 request: reqPushPprofJson([][]byte{pprofUnderOneMb}), 407 tenantID: "1mb-body-limit", 408 expectedStatus: 200, 409 }, 410 { 411 name: "push-json/uncompressed/exact-limit", 412 request: reqPushPprofJson([][]byte{pprofOneMb}), 413 tenantID: "1mb-body-limit", 414 expectedStatus: 200, 415 }, 416 { 417 name: "push-json/uncompressed/exceeds-limit", 418 request: reqPushPprofJson([][]byte{pprofOverOneMb}), 419 tenantID: "1mb-body-limit", 420 expectedStatus: 400, // grpc status codes used by connect have no mapping to 413 421 expectedErrorMsg: "uncompressed batched profile payload size exceeds limit of 1.0 MB", 422 expectDiscardedBytes: float64(overOneMb), 423 expectDiscardedProfiles: 1, 424 }, 425 { 426 name: "push-json/uncompressed/exceeds-limit-with-two-profiles", 427 request: reqPushPprofJson([][]byte{pprofUnderOneMb}, [][]byte{pprofUnderOneMb}), 428 tenantID: "1mb-body-limit", 429 expectedStatus: 400, // grpc status codes used by connect have no mapping to 413 430 expectedErrorMsg: "uncompressed batched profile payload size exceeds limit of 1.0 MB", 431 expectDiscardedBytes: float64(underOneMb * 2), 432 expectDiscardedProfiles: 2, 433 }, 434 { 435 name: "push-json/gzip/within-limit", 436 request: reqPushPprofJson([][]byte{pprofGzipUnderOneMb}), 437 tenantID: "1mb-body-limit", 438 expectedStatus: 200, 439 }, 440 { 441 name: "push-json/gzip/exact-limit", 442 request: reqPushPprofJson([][]byte{pprofGzipOneMb}), 443 tenantID: "1mb-body-limit", 444 expectedStatus: 200, 445 }, 446 { 447 name: "push-json/gzip/exceeds-limit", 448 request: reqPushPprofJson([][]byte{pprofGzipOverOneMb}), 449 tenantID: "1mb-body-limit", 450 expectedStatus: 400, // grpc status codes used by connect have no mapping to 413 451 expectedErrorMsg: "uncompressed batched profile payload size exceeds limit of 1.0 MB", 452 expectDiscardedBytes: float64(overOneMb), 453 expectDiscardedProfiles: 1, 454 }, 455 { 456 name: "push-json/gzip/exceeds-limit-with-two-profiles", 457 request: reqPushPprofJson([][]byte{pprofGzipUnderOneMb}, [][]byte{pprofGzipUnderOneMb}), 458 tenantID: "1mb-body-limit", 459 expectedStatus: 400, // grpc status codes used by connect have no mapping to 413 460 expectedErrorMsg: "uncompressed batched profile payload size exceeds limit of 1.0 MB", 461 expectDiscardedBytes: float64(underOneMb * 2), 462 expectDiscardedProfiles: 2, 463 }, 464 { 465 name: "otlp-json/uncompressed/within-limit", 466 request: reqOTLPJson(otlpJSONUnderOneMb), 467 tenantID: "1mb-body-limit", 468 expectedStatus: 200, 469 }, 470 { 471 name: "otlp-json/uncompressed/exact-limit", 472 request: reqOTLPJson(otlpJSONnOneMb), 473 tenantID: "1mb-body-limit", 474 expectedStatus: 200, 475 }, 476 { 477 name: "otlp-json/uncompressed/exceeds-limit", 478 request: reqOTLPJson(otlpJSONOverOneMb), 479 tenantID: "1mb-body-limit", 480 expectedStatus: 413, 481 expectedErrorMsg: "profile payload size exceeds limit of 1.0 MB", 482 expectDiscardedBytes: float64(oneMb), 483 expectDiscardedProfiles: 1, 484 }, 485 { 486 name: "otlp-json/gzip/within-limit", 487 request: reqOTLPJsonGzip(otlpJSONGzipUnderOneMb), 488 tenantID: "1mb-body-limit", 489 expectedStatus: 200, 490 }, 491 { 492 name: "otlp-json/gzip/exact-limit", 493 request: reqOTLPJsonGzip(otlpJSONGzipOneMb), 494 tenantID: "1mb-body-limit", 495 expectedStatus: 200, 496 }, 497 { 498 name: "otlp-json/gzip/exceeds-limit", 499 request: reqOTLPJsonGzip(otlpJSONGzipOverOneMb), 500 tenantID: "1mb-body-limit", 501 expectedStatus: 413, 502 expectedErrorMsg: "uncompressed profile payload size exceeds limit of 1.0 MB", 503 expectDiscardedBytes: float64(oneMb), 504 expectDiscardedProfiles: 1, 505 }, 506 } 507 508 for _, tc := range testCases { 509 t.Run(tc.name, func(t *testing.T) { 510 if tc.skipMsg != "" { 511 t.Skip(tc.skipMsg) 512 } 513 ctx, cancel := context.WithCancel(context.Background()) 514 defer cancel() 515 req := tc.request(ctx) 516 req.Header.Set("X-Scope-OrgID", tc.tenantID) 517 518 // Capture metrics before request if we expect them to change 519 var metricsBefore map[prometheus.Collector]float64 520 if tc.expectDiscardedBytes > 0 || tc.expectDiscardedProfiles > 0 { 521 metricsBefore = map[prometheus.Collector]float64{ 522 validation.DiscardedBytes.WithLabelValues(string(metricReason), tc.tenantID): testutil.ToFloat64(validation.DiscardedBytes.WithLabelValues(string(metricReason), tc.tenantID)), 523 validation.DiscardedProfiles.WithLabelValues(string(metricReason), tc.tenantID): testutil.ToFloat64(validation.DiscardedProfiles.WithLabelValues(string(metricReason), tc.tenantID)), 524 } 525 } 526 527 // Execute request through the mux 528 res := httptest.NewRecorder() 529 a.HTTPMux.ServeHTTP(res, req) 530 531 // Assertions 532 require.Equal(t, tc.expectedStatus, res.Code, 533 "expected status %d, got %d. Response body: %s", 534 tc.expectedStatus, res.Code, res.Body.String()) 535 536 if tc.expectedErrorMsg != "" { 537 require.Contains(t, res.Body.String(), tc.expectedErrorMsg) 538 } 539 540 // Check metrics if expected 541 if tc.expectDiscardedBytes > 0 || tc.expectDiscardedProfiles > 0 { 542 bytesMetric := validation.DiscardedBytes.WithLabelValues(string(metricReason), tc.tenantID) 543 profilesMetric := validation.DiscardedProfiles.WithLabelValues(string(metricReason), tc.tenantID) 544 545 bytesDelta := testutil.ToFloat64(bytesMetric) - metricsBefore[bytesMetric] 546 profilesDelta := testutil.ToFloat64(profilesMetric) - metricsBefore[profilesMetric] 547 548 if tc.expectDiscardedBytes > 0 { 549 require.Equal(t, tc.expectDiscardedBytes, bytesDelta, "expected %f discarded bytes, got %f", tc.expectDiscardedBytes, bytesDelta) 550 } 551 if tc.expectDiscardedProfiles > 0 { 552 require.Equal(t, tc.expectDiscardedProfiles, profilesDelta, "expected %f discarded profiles, got %f", tc.expectDiscardedProfiles, profilesDelta) 553 } 554 } 555 }) 556 } 557 558 } 559 560 func TestDistributorAPIMaxProfileSizeBytes(t *testing.T) { 561 const metricReason = validation.ProfileSizeLimit 562 563 logger := log.NewNopLogger() 564 565 limits := validation.MockOverrides(func(defaults *validation.Limits, tenantLimits map[string]*validation.Limits) { 566 l := validation.MockDefaultLimits() 567 l.MaxProfileSizeBytes = 1024 * 1024 // 1 MB 568 l.IngestionRateMB = 10000 // set this high enough to not interfere 569 tenantLimits["1mb-profile-limit"] = l 570 }) 571 572 d, err := distributor.NewTestDistributor(t, 573 logger, 574 limits, 575 ) 576 require.NoError(t, err) 577 578 a := newAPITest(t, api.Config{}, logger) 579 a.RegisterDistributor(d, limits, server.Config{}) 580 581 // generate sample payloads 582 var ( 583 pprofUnderOneMb = pprofWithSize(t, underOneMb) 584 pprofOverOneMb = pprofWithSize(t, overOneMb) 585 pprofGzipUnderOneMb = gzipper(t, pprofWithSize, underOneMb) 586 pprofGzipOverOneMb = gzipper(t, pprofWithSize, overOneMb) 587 otlpJSONUnderOneMb = otlpJSONWithSize(t, underOneMb) 588 otlpJSONOverOneMb = otlpJSONWithSize(t, overOneMb) 589 otlpJSONGzipUnderOneMb = gzipper(t, otlpJSONWithSize, underOneMb) 590 otlpJSONGzipOverOneMb = gzipper(t, otlpJSONWithSize, overOneMb) 591 ) 592 593 testCases := []struct { 594 name string 595 skipMsg string 596 request func(context.Context) *http.Request 597 tenantID string 598 expectedStatus int 599 expectedErrorMsg string 600 expectDiscardedBytes float64 601 expectDiscardedProfiles float64 602 }{ 603 { 604 name: "ingest/uncompressed/within-limit", 605 request: reqIngestPprof(pprofUnderOneMb), 606 tenantID: "1mb-profile-limit", 607 expectedStatus: 200, 608 }, 609 { 610 name: "ingest/uncompressed/exact-limit", 611 request: reqIngestPprof(pprofWithSize(t, 1024*1024-8)), // Note the extra 8 byte are used up by added metadata 612 tenantID: "1mb-profile-limit", 613 expectedStatus: 200, 614 }, 615 { 616 name: "ingest/uncompressed/exceeds-limit", 617 request: reqIngestPprof(pprofOverOneMb), 618 tenantID: "1mb-profile-limit", 619 expectedStatus: 422, 620 expectedErrorMsg: "exceeds maximum allowed size", 621 }, 622 { 623 name: "ingest/gzip/within-limit", 624 request: reqIngestGzPprof(pprofGzipUnderOneMb), 625 tenantID: "1mb-profile-limit", 626 expectedStatus: 200, 627 }, 628 { 629 name: "ingest/gzip/exact-limit", 630 request: reqIngestPprof(gzipper(t, pprofWithSize, 1024*1024-8)), // Note the extra 8 byte are used up by added metadata 631 tenantID: "1mb-profile-limit", 632 expectedStatus: 200, 633 }, 634 { 635 name: "ingest/gzip/exceeds-limit", 636 request: reqIngestGzPprof(pprofGzipOverOneMb), 637 tenantID: "1mb-profile-limit", 638 expectedStatus: 422, 639 expectedErrorMsg: "exceeds maximum allowed size", 640 }, 641 { 642 name: "push-json/uncompressed/within-limit", 643 request: reqPushPprofJson([][]byte{pprofUnderOneMb}), 644 tenantID: "1mb-profile-limit", 645 expectedStatus: 200, 646 }, 647 { 648 name: "push-json/uncompressed/exceeds-limit", 649 request: reqPushPprofJson([][]byte{pprofOverOneMb}), 650 tenantID: "1mb-profile-limit", 651 expectedStatus: 400, 652 expectedErrorMsg: "uncompressed profile payload size exceeds limit of 1.0 MB", 653 expectDiscardedBytes: float64(oneMb), 654 expectDiscardedProfiles: 1, 655 }, 656 { 657 name: "push-json/gzip/within-limit", 658 request: reqPushPprofJson([][]byte{pprofGzipUnderOneMb}), 659 tenantID: "1mb-profile-limit", 660 expectedStatus: 200, 661 }, 662 { 663 name: "push-json/gzip/exceeds-limit", 664 request: reqPushPprofJson([][]byte{pprofGzipOverOneMb}), 665 tenantID: "1mb-profile-limit", 666 expectedStatus: 400, 667 expectedErrorMsg: "uncompressed profile payload size exceeds limit of 1.0 MB", 668 expectDiscardedBytes: float64(oneMb), 669 expectDiscardedProfiles: 1, 670 }, 671 { 672 name: "otlp-json/uncompressed/within-limit", 673 request: reqOTLPJson(otlpJSONUnderOneMb), 674 tenantID: "1mb-profile-limit", 675 expectedStatus: 200, 676 }, 677 { 678 name: "otlp-json/uncompressed/exceeds-limit", 679 request: reqOTLPJson(otlpJSONOverOneMb), 680 tenantID: "1mb-profile-limit", 681 expectedStatus: 400, 682 expectedErrorMsg: "exceeds the size limit", 683 expectDiscardedProfiles: 1, 684 // Note: Bytes not checked for OTLP as they're based on converted pprof size 685 }, 686 { 687 name: "otlp-json/gzip/within-limit", 688 request: reqOTLPJsonGzip(otlpJSONGzipUnderOneMb), 689 tenantID: "1mb-profile-limit", 690 expectedStatus: 200, 691 }, 692 { 693 name: "otlp-json/gzip/exceeds-limit", 694 request: reqOTLPJsonGzip(otlpJSONGzipOverOneMb), 695 tenantID: "1mb-profile-limit", 696 expectedStatus: 400, 697 expectedErrorMsg: "exceeds the size limit", 698 expectDiscardedProfiles: 1, 699 // Note: Bytes not checked for OTLP as they're based on converted pprof size 700 }, 701 } 702 703 for _, tc := range testCases { 704 t.Run(tc.name, func(t *testing.T) { 705 if tc.skipMsg != "" { 706 t.Skip(tc.skipMsg) 707 } 708 ctx, cancel := context.WithCancel(context.Background()) 709 defer cancel() 710 req := tc.request(ctx) 711 req.Header.Set("X-Scope-OrgID", tc.tenantID) 712 713 // Capture metrics before request if we expect them to change 714 var metricsBefore map[prometheus.Collector]float64 715 if tc.expectDiscardedBytes > 0 || tc.expectDiscardedProfiles > 0 { 716 metricsBefore = map[prometheus.Collector]float64{ 717 validation.DiscardedBytes.WithLabelValues(string(metricReason), tc.tenantID): testutil.ToFloat64(validation.DiscardedBytes.WithLabelValues(string(metricReason), tc.tenantID)), 718 validation.DiscardedProfiles.WithLabelValues(string(metricReason), tc.tenantID): testutil.ToFloat64(validation.DiscardedProfiles.WithLabelValues(string(metricReason), tc.tenantID)), 719 } 720 } 721 722 // Execute request through the mux 723 res := httptest.NewRecorder() 724 a.HTTPMux.ServeHTTP(res, req) 725 726 // Assertions 727 require.Equal(t, tc.expectedStatus, res.Code, 728 "expected status %d, got %d. Response body: %s", 729 tc.expectedStatus, res.Code, res.Body.String()) 730 731 if tc.expectedErrorMsg != "" { 732 require.Contains(t, res.Body.String(), tc.expectedErrorMsg) 733 } 734 735 // Check metrics if expected 736 if tc.expectDiscardedBytes > 0 || tc.expectDiscardedProfiles > 0 { 737 bytesMetric := validation.DiscardedBytes.WithLabelValues(string(metricReason), tc.tenantID) 738 profilesMetric := validation.DiscardedProfiles.WithLabelValues(string(metricReason), tc.tenantID) 739 740 bytesDelta := testutil.ToFloat64(bytesMetric) - metricsBefore[bytesMetric] 741 profilesDelta := testutil.ToFloat64(profilesMetric) - metricsBefore[profilesMetric] 742 743 if tc.expectDiscardedBytes > 0 { 744 require.Equal(t, tc.expectDiscardedBytes, bytesDelta, "expected %f discarded bytes, got %f", tc.expectDiscardedBytes, bytesDelta) 745 } 746 if tc.expectDiscardedProfiles > 0 { 747 require.Equal(t, tc.expectDiscardedProfiles, profilesDelta, "expected %f discarded profiles, got %f", tc.expectDiscardedProfiles, profilesDelta) 748 } 749 } 750 }) 751 } 752 753 }