Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions static/app/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,12 @@ function buildRoutes(): RouteObject[] {
},
],
},
{
path: 'rate-limits/',
name: t('Rate Limits'),
component: make(() => import('sentry/views/settings/organizationRateLimits')),
deprecatedRouteProps: true,
},
{
path: 'relay/',
name: t('Relay'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ export function getOrganizationNavigationConfiguration({
description: t('View the audit log for an organization'),
id: 'audit-log',
},
{
path: `${organizationSettingsPathPrefix}/rate-limits/`,
title: t('Rate Limits'),
show: ({features}) => features!.has('legacy-rate-limits'),
description: t('Configure rate limits for all projects in the organization'),
id: 'rate-limits',
},
{
path: `${organizationSettingsPathPrefix}/relay/`,
title: t('Relay'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ export function getUserOrgNavigationConfiguration(): NavigationSection[] {
description: t('View the audit log for an organization'),
id: 'audit-log',
},
{
path: `${organizationSettingsPathPrefix}/rate-limits/`,
title: t('Rate Limits'),
show: ({features}) => features?.has('legacy-rate-limits') ?? false,
description: t('Configure rate limits for all projects in the organization'),
id: 'rate-limits',
},
{
path: `${organizationSettingsPathPrefix}/relay/`,
title: t('Relay'),
Expand Down
20 changes: 20 additions & 0 deletions static/app/views/settings/organizationRateLimits/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import withOrganization from 'sentry/utils/withOrganization';
import {OrganizationPermissionAlert} from 'sentry/views/settings/organization/organizationPermissionAlert';

import OrganizationRateLimits from './organizationRateLimits';

function OrganizationRateLimitsContainer(
props: React.ComponentProps<typeof OrganizationRateLimits>
) {
if (!props.organization) {
return null;
}

return props.organization.access.includes('org:write') ? (
<OrganizationRateLimits {...props} />
) : (
<OrganizationPermissionAlert />
);
}

export default withOrganization(OrganizationRateLimitsContainer);
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {OrganizationFixture} from 'sentry-fixture/organization';
import {RouteComponentPropsFixture} from 'sentry-fixture/routeComponentPropsFixture';

import {act, render, screen, userEvent} from 'sentry-test/reactTestingLibrary';

import type {OrganizationRateLimitProps} from 'sentry/views/settings/organizationRateLimits/organizationRateLimits';
import OrganizationRateLimits from 'sentry/views/settings/organizationRateLimits/organizationRateLimits';

const ENDPOINT = '/organizations/org-slug/';

describe('Organization Rate Limits', () => {
const organization = OrganizationFixture({
quota: {
projectLimit: 75,
accountLimit: 70000,
maxRate: null,
maxRateInterval: null,
},
});

const renderComponent = (props?: Partial<OrganizationRateLimitProps>) =>
render(
<OrganizationRateLimits
{...RouteComponentPropsFixture()}
organization={organization}
{...props}
/>
);

beforeEach(() => {
MockApiClient.clearMockResponses();
});

it('renders with initialData', () => {
renderComponent();

// XXX: Slider input values are associated to their step value
// Step 16 is 70000
expect(screen.getByRole('slider', {name: 'Account Limit'})).toHaveValue('16');
expect(screen.getByRole('slider', {name: 'Per-Project Limit'})).toHaveValue('75');
});

it('renders with maxRate and maxRateInterval set', () => {
const org = OrganizationFixture({
...organization,
quota: {
maxRate: 100,
maxRateInterval: 60,
projectLimit: null,
accountLimit: null,
},
});

renderComponent({organization: org});

expect(screen.getByRole('slider')).toBeInTheDocument();
});

it('can change Account Rate Limit', async () => {
const mock = MockApiClient.addMockResponse({
url: ENDPOINT,
method: 'PUT',
statusCode: 200,
});

renderComponent();

expect(mock).not.toHaveBeenCalled();

// Change Account Limit
act(() => screen.getByRole('slider', {name: 'Account Limit'}).focus());
await userEvent.keyboard('{ArrowLeft>5}');
await userEvent.tab();

expect(mock).toHaveBeenCalledWith(
ENDPOINT,
expect.objectContaining({
method: 'PUT',
data: {
accountRateLimit: 20000,
},
})
);
});

it('can change Project Rate Limit', async () => {
const mock = MockApiClient.addMockResponse({
url: ENDPOINT,
method: 'PUT',
statusCode: 200,
});

renderComponent();

expect(mock).not.toHaveBeenCalled();

// Change Project Rate Limit
act(() => screen.getByRole('slider', {name: 'Per-Project Limit'}).focus());
await userEvent.keyboard('{ArrowRight>5}');
await userEvent.tab();

expect(mock).toHaveBeenCalledWith(
ENDPOINT,
expect.objectContaining({
method: 'PUT',
data: {
projectRateLimit: 100,
},
})
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import {css} from '@emotion/react';

import FieldGroup from 'sentry/components/forms/fieldGroup';
import RangeField from 'sentry/components/forms/fields/rangeField';
import Form from 'sentry/components/forms/form';
import Panel from 'sentry/components/panels/panel';
import PanelAlert from 'sentry/components/panels/panelAlert';
import PanelBody from 'sentry/components/panels/panelBody';
import PanelHeader from 'sentry/components/panels/panelHeader';
import {t, tct} from 'sentry/locale';
import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
import type {Organization} from 'sentry/types/organization';
import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
import TextBlock from 'sentry/views/settings/components/text/textBlock';

export type OrganizationRateLimitProps = RouteComponentProps & {
organization: Organization;
};

const getRateLimitValues = () => {
const steps: number[] = [];
let i = 0;
while (i <= 1_000_000) {
steps.push(i);
if (i < 10_000) {
i += 1_000;
} else if (i < 100_000) {
i += 10_000;
} else {
i += 100_000;
}
}
return steps;
};

// We can just generate this once
const ACCOUNT_RATE_LIMIT_VALUES = getRateLimitValues();

function OrganizationRateLimit({organization}: OrganizationRateLimitProps) {
// TODO(billy): Update organization.quota in organizationStore with new values

const {quota} = organization;
const {maxRate, maxRateInterval, projectLimit, accountLimit} = quota;
const initialData = {
projectRateLimit: projectLimit || 100,
accountRateLimit: accountLimit,
};

return (
<div>
<SettingsPageHeader title={t('Rate Limits')} />

<Panel>
<PanelHeader>{t('Adjust Limits')}</PanelHeader>
<PanelBody>
<PanelAlert type="info">
{t(`Rate limits allow you to control how much data is stored for this
organization. When a rate is exceeded the system will begin discarding
data until the next interval.`)}
</PanelAlert>

<Form
data-test-id="rate-limit-editor"
saveOnBlur
allowUndo
apiMethod="PUT"
apiEndpoint={`/organizations/${organization.slug}/`}
initialData={initialData}
>
{maxRate ? (
<FieldGroup
label={t('Account Limit')}
help={t(
'The maximum number of events to accept across this entire organization.'
)}
>
<TextBlock
css={css`
margin-bottom: 0;
`}
>
{tct(
'Your account is limited to a maximum of [maxRate] events per [maxRateInterval] seconds.',
{
maxRate,
maxRateInterval,
}
)}
</TextBlock>
</FieldGroup>
) : (
<RangeField
name="accountRateLimit"
label={t('Account Limit')}
min={0}
max={1000000}
allowedValues={ACCOUNT_RATE_LIMIT_VALUES}
help={t(
'The maximum number of events to accept across this entire organization.'
)}
placeholder="e.g. 500"
formatLabel={value =>
value
? tct('[number] per hour', {
number: value.toLocaleString(),
})
: t('No Limit')
}
/>
)}
<RangeField
name="projectRateLimit"
label={t('Per-Project Limit')}
help={t(
'The maximum percentage of the account limit (set above) that an individual project can consume.'
)}
step={5}
min={50}
max={100}
formatLabel={value =>
value === 100 ? t('No Limit \u2014 100%') : `${value}%`
}
/>
</Form>
</PanelBody>
</Panel>
</div>
);
}

export default OrganizationRateLimit;
35 changes: 35 additions & 0 deletions tests/acceptance/test_organization_rate_limits.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from unittest.mock import Mock, patch

from django.utils import timezone

from sentry.testutils.cases import AcceptanceTestCase
from sentry.testutils.silo import no_silo_test


@no_silo_test
class OrganizationRateLimitsTest(AcceptanceTestCase):
def setUp(self) -> None:
super().setUp()
self.user = self.create_user("foo@example.com")
self.org = self.create_organization(name="Rowdy Tiger", owner=None)
self.team = self.create_team(organization=self.org, name="Mariachi Band")
self.project = self.create_project(organization=self.org, teams=[self.team], name="Bengal")
self.create_member(user=self.user, organization=self.org, role="owner", teams=[self.team])
self.login_as(self.user)
self.path = f"/organizations/{self.org.slug}/rate-limits/"

@patch("sentry.quotas.get_maximum_quota", Mock(return_value=(100, 60)))
def test_with_rate_limits(self) -> None:
self.project.update(first_event=timezone.now())
self.browser.get(self.path)
self.browser.wait_until_not('[data-test-id="loading-indicator"]')
self.browser.wait_until_test_id("rate-limit-editor")
assert self.browser.element_exists_by_test_id("rate-limit-editor")

@patch("sentry.quotas.get_maximum_quota", Mock(return_value=(0, 60)))
def test_without_rate_limits(self) -> None:
self.project.update(first_event=timezone.now())
self.browser.get(self.path)
self.browser.wait_until_not('[data-test-id="loading-indicator"]')
self.browser.wait_until_test_id("rate-limit-editor")
assert self.browser.element_exists_by_test_id("rate-limit-editor")
Loading