Skip to content

Commit b326c21

Browse files
authored
feat(aci): useMetricDetectorLimit (#97969)
Used by #97953
1 parent 17d5369 commit b326c21

File tree

9 files changed

+380
-9
lines changed

9 files changed

+380
-9
lines changed

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: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
import {OrganizationFixture} from 'sentry-fixture/organization';
2+
3+
import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription';
4+
import {renderHook, waitFor} from 'sentry-test/reactTestingLibrary';
5+
6+
import type {Organization} from 'sentry/types/organization';
7+
import {QueryClient, QueryClientProvider} from 'sentry/utils/queryClient';
8+
import {OrganizationContext} from 'sentry/views/organizationContext';
9+
10+
import SubscriptionStore from 'getsentry/stores/subscriptionStore';
11+
12+
import {useMetricDetectorLimit} from './useMetricDetectorLimit';
13+
14+
const queryClient = new QueryClient({
15+
defaultOptions: {
16+
queries: {
17+
retry: false,
18+
},
19+
},
20+
});
21+
22+
function createWrapper(organization: Organization) {
23+
return function Wrapper({children}: {children?: React.ReactNode}) {
24+
return (
25+
<QueryClientProvider client={queryClient}>
26+
<OrganizationContext value={organization}>{children}</OrganizationContext>
27+
</QueryClientProvider>
28+
);
29+
};
30+
}
31+
32+
const mockOrganization = OrganizationFixture({
33+
features: ['workflow-engine-metric-detector-limit'],
34+
});
35+
36+
const mockOrganizationWithoutFeature = OrganizationFixture({
37+
features: [],
38+
});
39+
40+
describe('useMetricDetectorLimit', () => {
41+
beforeEach(() => {
42+
MockApiClient.clearMockResponses();
43+
queryClient.clear();
44+
SubscriptionStore.init();
45+
});
46+
47+
it('handles feature flag is disabled', () => {
48+
const wrapper = createWrapper(mockOrganizationWithoutFeature);
49+
const detectorsRequest = MockApiClient.addMockResponse({
50+
url: '/organizations/org-slug/detectors/',
51+
headers: {'X-Hits': '5'},
52+
body: [],
53+
});
54+
55+
const {result} = renderHook(() => useMetricDetectorLimit(), {wrapper});
56+
57+
expect(result.current).toEqual({
58+
hasReachedLimit: false,
59+
detectorLimit: -1,
60+
detectorCount: -1,
61+
isLoading: false,
62+
isError: false,
63+
});
64+
65+
expect(detectorsRequest).not.toHaveBeenCalled();
66+
});
67+
68+
it('handles no subscription data', async () => {
69+
const wrapper = createWrapper(mockOrganization);
70+
71+
SubscriptionStore.set(mockOrganization.slug, null as any);
72+
const detectorsRequest = MockApiClient.addMockResponse({
73+
url: '/organizations/org-slug/detectors/',
74+
headers: {'X-Hits': '2'},
75+
body: [],
76+
});
77+
78+
const {result} = renderHook(() => useMetricDetectorLimit(), {wrapper});
79+
80+
await waitFor(() => {
81+
expect(result.current?.isLoading).toBe(false);
82+
});
83+
84+
expect(result.current).toEqual({
85+
hasReachedLimit: false,
86+
detectorLimit: -1,
87+
detectorCount: 2,
88+
isLoading: false,
89+
isError: false,
90+
});
91+
92+
expect(detectorsRequest).toHaveBeenCalledWith(
93+
'/organizations/org-slug/detectors/',
94+
expect.objectContaining({
95+
query: expect.objectContaining({
96+
query: 'type:metric',
97+
per_page: 0,
98+
}),
99+
})
100+
);
101+
});
102+
103+
it('handles detectors count is below limit', async () => {
104+
const wrapper = createWrapper(mockOrganization);
105+
106+
const subscription = SubscriptionFixture({
107+
organization: mockOrganization,
108+
planDetails: {
109+
...SubscriptionFixture({
110+
organization: mockOrganization,
111+
}).planDetails,
112+
metricDetectorLimit: 4,
113+
},
114+
});
115+
116+
SubscriptionStore.set(mockOrganization.slug, subscription);
117+
118+
const detectorsRequest = MockApiClient.addMockResponse({
119+
url: '/organizations/org-slug/detectors/',
120+
headers: {'X-Hits': '3'},
121+
body: [],
122+
});
123+
124+
const {result} = renderHook(() => useMetricDetectorLimit(), {wrapper});
125+
126+
await waitFor(() => {
127+
expect(result.current.isLoading).toBe(false);
128+
});
129+
130+
expect(result.current).toEqual({
131+
hasReachedLimit: false,
132+
detectorLimit: 4,
133+
detectorCount: 3,
134+
isLoading: false,
135+
isError: false,
136+
});
137+
138+
expect(detectorsRequest).toHaveBeenCalledWith(
139+
'/organizations/org-slug/detectors/',
140+
expect.objectContaining({
141+
query: expect.objectContaining({
142+
query: 'type:metric',
143+
per_page: 0,
144+
}),
145+
})
146+
);
147+
});
148+
149+
it('handles detectors count equals limit', async () => {
150+
const wrapper = createWrapper(mockOrganization);
151+
152+
const subscription = SubscriptionFixture({
153+
organization: mockOrganization,
154+
planDetails: {
155+
...SubscriptionFixture({
156+
organization: mockOrganization,
157+
}).planDetails,
158+
metricDetectorLimit: 3,
159+
},
160+
});
161+
162+
SubscriptionStore.set(mockOrganization.slug, subscription);
163+
164+
MockApiClient.addMockResponse({
165+
url: '/organizations/org-slug/detectors/',
166+
headers: {'X-Hits': '3'},
167+
body: [],
168+
});
169+
170+
const {result} = renderHook(() => useMetricDetectorLimit(), {wrapper});
171+
172+
await waitFor(() => {
173+
expect(result.current.isLoading).toBe(false);
174+
});
175+
176+
expect(result.current).toEqual({
177+
hasReachedLimit: true,
178+
detectorLimit: 3,
179+
detectorCount: 3,
180+
isLoading: false,
181+
isError: false,
182+
});
183+
});
184+
185+
it('handles detector limit is -1', async () => {
186+
const wrapper = createWrapper(mockOrganization);
187+
188+
const subscription = SubscriptionFixture({
189+
organization: mockOrganization,
190+
planDetails: {
191+
...SubscriptionFixture({
192+
organization: mockOrganization,
193+
}).planDetails,
194+
metricDetectorLimit: -1,
195+
},
196+
});
197+
198+
SubscriptionStore.set(mockOrganization.slug, subscription);
199+
200+
const detectorsRequest = MockApiClient.addMockResponse({
201+
url: '/organizations/org-slug/detectors/',
202+
headers: {'X-Hits': '10'},
203+
body: [],
204+
});
205+
206+
const {result} = renderHook(() => useMetricDetectorLimit(), {wrapper});
207+
208+
await waitFor(() => {
209+
expect(result.current.isLoading).toBe(false);
210+
});
211+
212+
expect(result.current).toEqual({
213+
hasReachedLimit: false,
214+
detectorLimit: -1,
215+
detectorCount: 10,
216+
isLoading: false,
217+
isError: false,
218+
});
219+
220+
expect(detectorsRequest).toHaveBeenCalled();
221+
});
222+
223+
it('handles detectors API error gracefully', async () => {
224+
const wrapper = createWrapper(mockOrganization);
225+
226+
const subscription = SubscriptionFixture({
227+
organization: mockOrganization,
228+
planDetails: {
229+
...SubscriptionFixture({
230+
organization: mockOrganization,
231+
}).planDetails,
232+
metricDetectorLimit: 20,
233+
},
234+
});
235+
236+
SubscriptionStore.set(mockOrganization.slug, subscription);
237+
238+
MockApiClient.addMockResponse({
239+
url: '/organizations/org-slug/detectors/',
240+
statusCode: 500,
241+
});
242+
243+
const {result} = renderHook(() => useMetricDetectorLimit(), {wrapper});
244+
245+
await waitFor(() => {
246+
expect(result.current.isLoading).toBe(false);
247+
});
248+
249+
expect(result.current).toEqual({
250+
hasReachedLimit: false,
251+
detectorLimit: 20,
252+
detectorCount: -1,
253+
isLoading: false,
254+
isError: true,
255+
});
256+
});
257+
258+
it('handles missing X-Hits header as error', async () => {
259+
const wrapper = createWrapper(mockOrganization);
260+
261+
const subscription = SubscriptionFixture({
262+
organization: mockOrganization,
263+
planDetails: {
264+
...SubscriptionFixture({
265+
organization: mockOrganization,
266+
}).planDetails,
267+
metricDetectorLimit: 5,
268+
},
269+
});
270+
271+
SubscriptionStore.set(mockOrganization.slug, subscription);
272+
273+
MockApiClient.addMockResponse({
274+
url: '/organizations/org-slug/detectors/',
275+
body: [],
276+
// No X-Hits header
277+
});
278+
279+
const {result} = renderHook(() => useMetricDetectorLimit(), {wrapper});
280+
281+
await waitFor(() => {
282+
expect(result.current.isLoading).toBe(false);
283+
});
284+
285+
expect(result.current).toEqual({
286+
hasReachedLimit: false,
287+
detectorLimit: 5,
288+
detectorCount: -1,
289+
isLoading: false,
290+
isError: true,
291+
});
292+
});
293+
});
Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import useOrganization from 'sentry/utils/useOrganization';
2+
import {useDetectorsQuery} from 'sentry/views/detectors/hooks/index';
3+
4+
import useSubscription from 'getsentry/hooks/useSubscription';
25

36
type MetricDetectorLimitResponse = {
47
detectorCount: number;
@@ -8,25 +11,41 @@ type MetricDetectorLimitResponse = {
811
isLoading: boolean;
912
};
1013

11-
// TODO: Replace with actual hook
14+
const UNLIMITED_QUOTA = -1;
15+
const ERROR_COUNT = -1;
16+
1217
export function useMetricDetectorLimit(): MetricDetectorLimitResponse {
1318
const organization = useOrganization();
19+
const subscription = useSubscription();
20+
const hasFlag = organization.features.includes('workflow-engine-metric-detector-limit');
21+
22+
const {isLoading, isError, getResponseHeader} = useDetectorsQuery(
23+
{
24+
query: 'type:metric',
25+
limit: 0,
26+
},
27+
{enabled: hasFlag}
28+
);
29+
30+
const hits = getResponseHeader?.('X-Hits');
31+
const detectorCount = hits ? parseInt(hits, 10) : ERROR_COUNT;
32+
const detectorLimit = subscription?.planDetails?.metricDetectorLimit ?? UNLIMITED_QUOTA;
1433

15-
if (!organization.features.includes('workflow-engine-metric-detector-limit')) {
34+
if (!hasFlag || detectorLimit === UNLIMITED_QUOTA) {
1635
return {
1736
hasReachedLimit: false,
18-
detectorLimit: -1,
19-
detectorCount: -1,
37+
detectorLimit: UNLIMITED_QUOTA,
38+
detectorCount,
2039
isLoading: false,
2140
isError: false,
2241
};
2342
}
2443

2544
return {
26-
hasReachedLimit: true,
27-
detectorCount: 20,
28-
detectorLimit: 20,
29-
isError: false,
30-
isLoading: false,
45+
detectorCount,
46+
detectorLimit,
47+
hasReachedLimit: detectorCount >= detectorLimit,
48+
isLoading,
49+
isError: isError || detectorCount === ERROR_COUNT,
3150
};
3251
}

static/gsApp/types/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ export type Plan = {
154154
id: string;
155155
isTestPlan: boolean;
156156
maxMembers: number | null;
157+
metricDetectorLimit: number;
157158
name: string;
158159
onDemandCategories: DataCategory[];
159160
onDemandEventPrice: number;

0 commit comments

Comments
 (0)