Skip to content

Commit 3c02484

Browse files
authored
feat(alerts): Disable new metric alerts when over quota (#97953)
When the `workflow-engine-metric-detector-limit` flag is on and the subscription metric detector limit is exceeded, will disable creation of new ones.
1 parent 2b69de5 commit 3c02484

File tree

16 files changed

+652
-38
lines changed

16 files changed

+652
-38
lines changed

static/app/types/hooks.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,8 @@ type ComponentHooks = {
196196
'component:insights-date-range-query-limit-footer': () => React.ComponentType;
197197
'component:insights-upsell-page': () => React.ComponentType<InsightsUpsellHook>;
198198
'component:member-list-header': () => React.ComponentType<MemberListHeaderProps>;
199+
'component:metric-alert-quota-icon': React.ComponentType;
200+
'component:metric-alert-quota-message': React.ComponentType;
199201
'component:org-stats-banner': () => React.ComponentType<DashboardHeadersProps>;
200202
'component:org-stats-profiling-banner': () => React.ComponentType;
201203
'component:organization-header': () => React.ComponentType<OrganizationHeaderProps>;
@@ -327,6 +329,13 @@ type ReactHooks = {
327329
) => React.ContextType<typeof RouteAnalyticsContext>;
328330
'react-hook:use-button-tracking': (props: ButtonProps) => () => void;
329331
'react-hook:use-get-max-retention-days': () => number | undefined;
332+
'react-hook:use-metric-detector-limit': () => {
333+
detectorCount: number;
334+
detectorLimit: number;
335+
hasReachedLimit: boolean;
336+
isError: boolean;
337+
isLoading: boolean;
338+
};
330339
};
331340

332341
/**

static/app/views/alerts/wizard/index.tsx

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Feature from 'sentry/components/acl/feature';
55
import FeatureDisabled from 'sentry/components/acl/featureDisabled';
66
import {ExternalLink} from 'sentry/components/core/link';
77
import CreateAlertButton from 'sentry/components/createAlertButton';
8+
import Hook from 'sentry/components/hook';
89
import {Hovercard} from 'sentry/components/hovercard';
910
import * as Layout from 'sentry/components/layouts/thirds';
1011
import List from 'sentry/components/list';
@@ -14,6 +15,7 @@ import PanelBody from 'sentry/components/panels/panelBody';
1415
import PanelHeader from 'sentry/components/panels/panelHeader';
1516
import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
1617
import {t} from 'sentry/locale';
18+
import HookStore from 'sentry/stores/hookStore';
1719
import {space} from 'sentry/styles/space';
1820
import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
1921
import type {Organization} from 'sentry/types/organization';
@@ -23,7 +25,7 @@ import {makeAlertsPathname} from 'sentry/views/alerts/pathnames';
2325
import {Dataset} from 'sentry/views/alerts/rules/metric/types';
2426
import {AlertRuleType} from 'sentry/views/alerts/types';
2527

26-
import type {AlertType, WizardRuleTemplate} from './options';
28+
import type {AlertType, MetricAlertType, WizardRuleTemplate} from './options';
2729
import {
2830
AlertWizardAlertNames,
2931
AlertWizardExtraContent,
@@ -45,6 +47,11 @@ type AlertWizardProps = RouteComponentProps<RouteParams> & {
4547
const DEFAULT_ALERT_OPTION = 'issues';
4648

4749
function AlertWizard({organization, params, location, projectId}: AlertWizardProps) {
50+
const useMetricDetectorLimit =
51+
HookStore.get('react-hook:use-metric-detector-limit')[0] ?? (() => null);
52+
const quota = useMetricDetectorLimit();
53+
const canCreateMetricAlert = !quota?.hasReachedLimit;
54+
4855
const [alertOption, setAlertOption] = useState<AlertType>(
4956
location.query.alert_option in AlertWizardAlertNames
5057
? location.query.alert_option
@@ -56,11 +63,13 @@ function AlertWizard({organization, params, location, projectId}: AlertWizardPro
5663
setAlertOption(option);
5764
};
5865

66+
let metricRuleTemplate: Readonly<WizardRuleTemplate> | undefined =
67+
alertOption in AlertWizardRuleTemplates
68+
? AlertWizardRuleTemplates[alertOption as MetricAlertType]
69+
: undefined;
70+
const isMetricAlert = !!metricRuleTemplate;
71+
5972
function renderCreateAlertButton() {
60-
let metricRuleTemplate: Readonly<WizardRuleTemplate> | undefined =
61-
// @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
62-
AlertWizardRuleTemplates[alertOption];
63-
const isMetricAlert = !!metricRuleTemplate;
6473
const isTransactionDataset = metricRuleTemplate?.dataset === Dataset.TRANSACTIONS;
6574

6675
// If theres anything using the legacy sessions dataset, we need to convert it to metrics
@@ -114,7 +123,7 @@ function AlertWizard({organization, params, location, projectId}: AlertWizardPro
114123
<CreateAlertButton
115124
organization={organization}
116125
projectSlug={projectSlug}
117-
disabled={!hasFeature}
126+
disabled={!hasFeature || (isMetricAlert && !canCreateMetricAlert)}
118127
priority="primary"
119128
to={{
120129
pathname: makeAlertsPathname({
@@ -169,14 +178,17 @@ function AlertWizard({organization, params, location, projectId}: AlertWizardPro
169178
<div key={categoryHeading}>
170179
<CategoryTitle>{categoryHeading} </CategoryTitle>
171180
<WizardGroupedOptions
172-
choices={options.map((alertType: any) => {
173-
return [
174-
alertType,
175-
// @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
176-
AlertWizardAlertNames[alertType],
177-
// @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
178-
AlertWizardExtraContent[alertType],
179-
];
181+
choices={options.map((alertType: MetricAlertType) => {
182+
const optionIsMetricAlert = alertType in AlertWizardRuleTemplates;
183+
184+
return {
185+
id: alertType,
186+
name: AlertWizardAlertNames[alertType],
187+
badge: AlertWizardExtraContent[alertType],
188+
trailingContent: optionIsMetricAlert ? (
189+
<Hook name="component:metric-alert-quota-icon" />
190+
) : null,
191+
};
180192
})}
181193
onChange={option => handleChangeAlertOption(option as AlertType)}
182194
value={alertOption}
@@ -209,6 +221,7 @@ function AlertWizard({organization, params, location, projectId}: AlertWizardPro
209221
</PanelBody>
210222
</div>
211223
<WizardFooter>{renderCreateAlertButton()}</WizardFooter>
224+
{isMetricAlert && <Hook name="component:metric-alert-quota-message" />}
212225
</WizardPanelBody>
213226
</WizardPanel>
214227
</WizardBody>

static/app/views/alerts/wizard/radioPanelGroup.spec.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ describe('RadioGroupPanel', () => {
1111
label="test"
1212
value="choice_one"
1313
choices={[
14-
['choice_one', 'Choice One'],
15-
['choice_two', 'Choice Two'],
16-
['choice_three', 'Choice Three'],
14+
{id: 'choice_one', name: 'Choice One'},
15+
{id: 'choice_two', name: 'Choice Two'},
16+
{id: 'choice_three', name: 'Choice Three'},
1717
]}
1818
onChange={mock}
1919
/>
@@ -32,9 +32,9 @@ describe('RadioGroupPanel', () => {
3232
label="test"
3333
value="choice_one"
3434
choices={[
35-
['choice_one', 'Choice One'],
36-
['choice_two', 'Choice Two', 'extra content'],
37-
['choice_three', 'Choice Three'],
35+
{id: 'choice_one', name: 'Choice One'},
36+
{id: 'choice_two', name: 'Choice Two', trailingContent: 'extra content'},
37+
{id: 'choice_three', name: 'Choice Three'},
3838
]}
3939
onChange={mock}
4040
/>

static/app/views/alerts/wizard/radioPanelGroup.tsx

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import styled from '@emotion/styled';
22

3+
import {Flex} from 'sentry/components/core/layout';
34
import {Radio} from 'sentry/components/core/radio';
45
import {space} from 'sentry/styles/space';
56

67
type RadioPanelGroupProps<C extends string> = {
78
/**
89
* An array of [id, name]
910
*/
10-
choices: Array<[C, React.ReactNode, React.ReactNode?]>;
11+
choices: Array<{
12+
id: C;
13+
name: React.ReactNode;
14+
badge?: React.ReactNode;
15+
trailingContent?: React.ReactNode;
16+
}>;
1117
label: string;
1218
onChange: (id: C, e: React.FormEvent<HTMLInputElement>) => void;
1319
value: string | null;
@@ -25,17 +31,20 @@ function RadioPanelGroup<C extends string>({
2531
}: Props<C>) {
2632
return (
2733
<Container {...props} role="radiogroup" aria-labelledby={label}>
28-
{(choices || []).map(([id, name, extraContent], index) => (
34+
{(choices || []).map(({id, name, badge, trailingContent}, index) => (
2935
<RadioPanel key={index}>
3036
<RadioLineItem role="radio" index={index} aria-checked={value === id}>
31-
<Radio
32-
size="sm"
33-
aria-label={id}
34-
checked={value === id}
35-
onChange={(e: React.FormEvent<HTMLInputElement>) => onChange(id, e)}
36-
/>
37-
<div>{name}</div>
38-
{extraContent}
37+
<Flex align="center" gap="sm">
38+
<Radio
39+
size="sm"
40+
aria-label={id}
41+
checked={value === id}
42+
onChange={(e: React.FormEvent<HTMLInputElement>) => onChange(id, e)}
43+
/>
44+
{name}
45+
{badge}
46+
</Flex>
47+
{trailingContent && <div>{trailingContent}</div>}
3948
</RadioLineItem>
4049
</RadioPanel>
4150
))}
@@ -56,10 +65,10 @@ const Container = styled('div')`
5665
const RadioLineItem = styled('label')<{
5766
index: number;
5867
}>`
59-
display: grid;
60-
gap: ${space(0.25)} ${space(1)};
61-
grid-template-columns: max-content auto max-content;
68+
display: flex;
69+
gap: ${p => p.theme.space.sm};
6270
align-items: center;
71+
justify-content: space-between;
6372
cursor: pointer;
6473
outline: none;
6574
font-weight: ${p => p.theme.fontWeight.normal};
@@ -74,11 +83,6 @@ const RadioLineItem = styled('label')<{
7483
color: ${p => p.theme.textColor};
7584
}
7685
77-
svg {
78-
display: none;
79-
opacity: 0;
80-
}
81-
8286
&[aria-checked='true'] {
8387
color: ${p => p.theme.textColor};
8488
}

static/gsApp/__fixtures__/plan.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export function PlanFixture(fields: Partial<Plan>): Plan {
1212
availableReservedBudgetTypes: {},
1313
contractInterval: 'monthly',
1414
dashboardLimit: 10,
15+
metricDetectorLimit: 20,
1516
description: '',
1617
features: [],
1718
hasOnDemandModes: false,
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import {OrganizationFixture} from 'sentry-fixture/organization';
2+
3+
import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription';
4+
import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
5+
6+
import {openUpsellModal} from 'getsentry/actionCreators/modal';
7+
import SubscriptionStore from 'getsentry/stores/subscriptionStore';
8+
9+
import {MetricAlertQuotaMessage} from './metricAlertQuotaMessage';
10+
11+
jest.mock('getsentry/actionCreators/modal', () => ({
12+
openUpsellModal: jest.fn(),
13+
}));
14+
15+
describe('MetricAlertQuotaMessage', () => {
16+
const organization = OrganizationFixture({
17+
features: ['workflow-engine-metric-detector-limit'],
18+
});
19+
20+
beforeEach(() => {
21+
jest.clearAllMocks();
22+
});
23+
24+
it('renders nothing when subscription has unlimited quota', async () => {
25+
const subscription = SubscriptionFixture({
26+
organization,
27+
planDetails: {
28+
...SubscriptionFixture({
29+
organization,
30+
}).planDetails,
31+
metricDetectorLimit: -1,
32+
},
33+
});
34+
35+
SubscriptionStore.set(organization.slug, subscription);
36+
37+
const {container} = render(<MetricAlertQuotaMessage />, {
38+
organization,
39+
});
40+
41+
await waitFor(() => {
42+
expect(container).toBeEmptyDOMElement();
43+
});
44+
});
45+
46+
it('renders approaching detectorLimit message with upgrade action', async () => {
47+
const subscription = SubscriptionFixture({
48+
organization,
49+
planDetails: {
50+
...SubscriptionFixture({
51+
organization,
52+
}).planDetails,
53+
metricDetectorLimit: 10,
54+
},
55+
});
56+
57+
SubscriptionStore.set(organization.slug, subscription);
58+
59+
MockApiClient.addMockResponse({
60+
url: '/organizations/org-slug/detectors/',
61+
headers: {'X-Hits': '9'},
62+
body: [],
63+
});
64+
65+
render(<MetricAlertQuotaMessage />, {organization});
66+
67+
expect(await screen.findByText(/used 9 of 10 metric monitors/i)).toBeInTheDocument();
68+
69+
const upgrade = screen.getByRole('button', {name: /upgrade your plan/i});
70+
await userEvent.click(upgrade);
71+
72+
expect(openUpsellModal).toHaveBeenCalledWith({
73+
organization,
74+
source: 'metric-alert-quota',
75+
});
76+
});
77+
78+
it('renders reached detectorLimit message with remove and upgrade actions when at detectorLimit', async () => {
79+
const subscription = SubscriptionFixture({
80+
organization,
81+
planDetails: {
82+
...SubscriptionFixture({
83+
organization,
84+
}).planDetails,
85+
metricDetectorLimit: 10,
86+
},
87+
});
88+
89+
SubscriptionStore.set(organization.slug, subscription);
90+
91+
MockApiClient.addMockResponse({
92+
url: '/organizations/org-slug/detectors/',
93+
headers: {'X-Hits': '10'},
94+
body: [],
95+
});
96+
97+
render(<MetricAlertQuotaMessage />, {organization});
98+
99+
expect(
100+
await screen.findByText(/reached your plan's limit on metric monitors/i)
101+
).toBeInTheDocument();
102+
103+
// Remove link and upgrade button are present
104+
expect(
105+
screen.getByRole('link', {name: /remove existing monitors/i})
106+
).toBeInTheDocument();
107+
108+
const upgrade = screen.getByRole('button', {name: /upgrade your plan/i});
109+
await userEvent.click(upgrade);
110+
111+
expect(openUpsellModal).toHaveBeenCalledWith({
112+
organization,
113+
source: 'metric-alert-quota',
114+
});
115+
});
116+
});

0 commit comments

Comments
 (0)