Skip to content

Commit 3a1f028

Browse files
authored
feat(ratelimit): Add metrics rate limit (#11538)
closes #11336 on v7: #11505 #11506
1 parent 80c0ee6 commit 3a1f028

File tree

6 files changed

+67
-17
lines changed

6 files changed

+67
-17
lines changed

packages/core/src/transports/base.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,10 @@ export function createTransport(
4949

5050
// Drop rate limited items from envelope
5151
forEachEnvelopeItem(envelope, (item, type) => {
52-
const envelopeItemDataCategory = envelopeItemTypeToDataCategory(type);
53-
if (isRateLimited(rateLimits, envelopeItemDataCategory)) {
52+
const dataCategory = envelopeItemTypeToDataCategory(type);
53+
if (isRateLimited(rateLimits, dataCategory)) {
5454
const event: Event | undefined = getEventForEnvelopeItem(item, type);
55-
options.recordDroppedEvent('ratelimit_backoff', envelopeItemDataCategory, event);
55+
options.recordDroppedEvent('ratelimit_backoff', dataCategory, event);
5656
} else {
5757
filteredEnvelopeItems.push(item);
5858
}

packages/types/src/datacategory.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// This type is used in various places like Client Reports and Rate Limit Categories
22
// See:
33
// - https://develop.sentry.dev/sdk/rate-limiting/#definitions
4-
// - https://github.com/getsentry/relay/blob/c3b339e151c1e548ede489a01c65db82472c8751/relay-common/src/constants.rs#L139-L152
4+
// - https://github.com/getsentry/relay/blob/ec791fed9c2260688f25ea6a6d53ab913927e9a5/relay-base-schema/src/data_category.rs#L91
55
// - https://develop.sentry.dev/sdk/client-reports/#envelope-item-payload under `discarded_events`
66
export type DataCategory =
77
// Reserved and only used in edgecases, unlikely to be ever actually used
@@ -26,8 +26,8 @@ export type DataCategory =
2626
| 'monitor'
2727
// Feedback type event (v2)
2828
| 'feedback'
29-
// Statsd type event for metrics
30-
| 'statsd'
29+
// Metrics sent via the statsd or metrics envelope items
30+
| 'metric_bucket'
3131
// Span
3232
| 'span'
3333
// Unknown data category

packages/types/src/envelope.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export type DynamicSamplingContext = {
2424
sampled?: string;
2525
};
2626

27+
// https://github.com/getsentry/relay/blob/311b237cd4471042352fa45e7a0824b8995f216f/relay-server/src/envelope.rs#L154
28+
// https://develop.sentry.dev/sdk/envelopes/#data-model
2729
export type EnvelopeItemType =
2830
| 'client_report'
2931
| 'user_report'

packages/utils/src/envelope.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,8 +209,7 @@ const ITEM_TYPE_TO_DATA_CATEGORY_MAP: Record<EnvelopeItemType, DataCategory> = {
209209
check_in: 'monitor',
210210
feedback: 'feedback',
211211
span: 'span',
212-
// TODO: This is a temporary workaround until we have a proper data category for metrics
213-
statsd: 'unknown',
212+
statsd: 'metric_bucket',
214213
};
215214

216215
/**
@@ -220,7 +219,7 @@ export function envelopeItemTypeToDataCategory(type: EnvelopeItemType): DataCate
220219
return ITEM_TYPE_TO_DATA_CATEGORY_MAP[type];
221220
}
222221

223-
/** Extracts the minimal SDK info from from the metadata or an events */
222+
/** Extracts the minimal SDK info from the metadata or an events */
224223
export function getSdkMetadataForEnvelopeHeader(metadataOrEvent?: SdkMetadata | Event): SdkInfo | undefined {
225224
if (!metadataOrEvent || !metadataOrEvent.sdk) {
226225
return;

packages/utils/src/ratelimit.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { TransportMakeRequestResponse } from '@sentry/types';
1+
import type { DataCategory, TransportMakeRequestResponse } from '@sentry/types';
22

33
// Intentionally keeping the key broad, as we don't know for sure what rate limit headers get returned from backend
44
export type RateLimits = Record<string, number>;
@@ -32,15 +32,15 @@ export function parseRetryAfterHeader(header: string, now: number = Date.now()):
3232
*
3333
* @return the time in ms that the category is disabled until or 0 if there's no active rate limit.
3434
*/
35-
export function disabledUntil(limits: RateLimits, category: string): number {
36-
return limits[category] || limits.all || 0;
35+
export function disabledUntil(limits: RateLimits, dataCategory: DataCategory): number {
36+
return limits[dataCategory] || limits.all || 0;
3737
}
3838

3939
/**
4040
* Checks if a category is rate limited
4141
*/
42-
export function isRateLimited(limits: RateLimits, category: string, now: number = Date.now()): boolean {
43-
return disabledUntil(limits, category) > now;
42+
export function isRateLimited(limits: RateLimits, dataCategory: DataCategory, now: number = Date.now()): boolean {
43+
return disabledUntil(limits, dataCategory) > now;
4444
}
4545

4646
/**
@@ -67,23 +67,32 @@ export function updateRateLimits(
6767
* rate limit headers are of the form
6868
* <header>,<header>,..
6969
* where each <header> is of the form
70-
* <retry_after>: <categories>: <scope>: <reason_code>
70+
* <retry_after>: <categories>: <scope>: <reason_code>: <namespaces>
7171
* where
7272
* <retry_after> is a delay in seconds
7373
* <categories> is the event type(s) (error, transaction, etc) being rate limited and is of the form
7474
* <category>;<category>;...
7575
* <scope> is what's being limited (org, project, or key) - ignored by SDK
7676
* <reason_code> is an arbitrary string like "org_quota" - ignored by SDK
77+
* <namespaces> Semicolon-separated list of metric namespace identifiers. Defines which namespace(s) will be affected.
78+
* Only present if rate limit applies to the metric_bucket data category.
7779
*/
7880
for (const limit of rateLimitHeader.trim().split(',')) {
79-
const [retryAfter, categories] = limit.split(':', 2);
81+
const [retryAfter, categories, , , namespaces] = limit.split(':', 5);
8082
const headerDelay = parseInt(retryAfter, 10);
8183
const delay = (!isNaN(headerDelay) ? headerDelay : 60) * 1000; // 60sec default
8284
if (!categories) {
8385
updatedRateLimits.all = now + delay;
8486
} else {
8587
for (const category of categories.split(';')) {
86-
updatedRateLimits[category] = now + delay;
88+
if (category === 'metric_bucket') {
89+
// namespaces will be present when category === 'metric_bucket'
90+
if (!namespaces || namespaces.split(';').includes('custom')) {
91+
updatedRateLimits[category] = now + delay;
92+
}
93+
} else {
94+
updatedRateLimits[category] = now + delay;
95+
}
8796
}
8897
}
8998
}

packages/utils/test/ratelimit.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,43 @@ describe('updateRateLimits()', () => {
197197
expect(updatedRateLimits.all).toEqual(60_000);
198198
});
199199
});
200+
201+
describe('data category "metric_bucket"', () => {
202+
test('should add limit for `metric_bucket` category when namespaces contain "custom"', () => {
203+
const rateLimits: RateLimits = {};
204+
const headers = {
205+
'retry-after': null,
206+
'x-sentry-rate-limits': '42:metric_bucket:::custom',
207+
};
208+
const updatedRateLimits = updateRateLimits(rateLimits, { headers }, 0);
209+
expect(updatedRateLimits.metric_bucket).toEqual(42 * 1000);
210+
});
211+
212+
test('should not add limit for `metric_bucket` category when namespaces do not contain "custom"', () => {
213+
const rateLimits: RateLimits = {};
214+
const headers = {
215+
'retry-after': null,
216+
'x-sentry-rate-limits': '42:metric_bucket:::namespace1;namespace2',
217+
};
218+
const updatedRateLimits = updateRateLimits(rateLimits, { headers }, 0);
219+
expect(updatedRateLimits.metric_bucket).toBeUndefined();
220+
});
221+
222+
test('should add limit for `metric_bucket` category when namespaces are empty', () => {
223+
const rateLimits: RateLimits = {};
224+
225+
const headers1 = {
226+
'retry-after': null,
227+
'x-sentry-rate-limits': '42:metric_bucket', // without semicolon at the end
228+
};
229+
const updatedRateLimits1 = updateRateLimits(rateLimits, { headers: headers1 }, 0);
230+
expect(updatedRateLimits1.metric_bucket).toEqual(42 * 1000);
231+
232+
const headers2 = {
233+
'retry-after': null,
234+
'x-sentry-rate-limits': '42:metric_bucket:organization:quota_exceeded:', // with semicolon at the end
235+
};
236+
const updatedRateLimits2 = updateRateLimits(rateLimits, { headers: headers2 }, 0);
237+
expect(updatedRateLimits2.metric_bucket).toEqual(42 * 1000);
238+
});
239+
});

0 commit comments

Comments
 (0)