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  }