Skip to content

Commit 0e97dc4

Browse files
fix(usage overview): Display add-on categories with reserved volume (#104465)
Fixes a bug where we didn't display add-on sub-categories that had a non-reserved budget / non-unlimited volume: <img width="2195" height="763" alt="Screenshot 2025-12-05 at 11 06 05 AM" src="https://github.com/user-attachments/assets/d02f8d59-99a7-4132-ab36-957042f799dc" />
1 parent f418d2a commit 0e97dc4

File tree

16 files changed

+364
-169
lines changed

16 files changed

+364
-169
lines changed

static/gsApp/utils/billing.spec.tsx

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,19 @@ import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription';
77

88
import {DataCategory} from 'sentry/types/core';
99

10-
import {BILLION, GIGABYTE, MILLION, UNLIMITED} from 'getsentry/constants';
10+
import {
11+
BILLION,
12+
GIGABYTE,
13+
MILLION,
14+
RESERVED_BUDGET_QUOTA,
15+
UNLIMITED,
16+
UNLIMITED_RESERVED,
17+
} from 'getsentry/constants';
1118
import {AddOnCategory, OnDemandBudgetMode} from 'getsentry/types';
1219
import type {ProductTrial, Subscription} from 'getsentry/types';
1320
import {
1421
checkIsAddOn,
22+
checkIsAddOnChildCategory,
1523
convertUsageToReservedUnit,
1624
formatReservedWithUnits,
1725
formatUsageWithUnits,
@@ -1155,6 +1163,65 @@ describe('checkIsAddOn', () => {
11551163
});
11561164
});
11571165

1166+
describe('checkIsAddOnChildCategory', () => {
1167+
const organization = OrganizationFixture();
1168+
let subscription: Subscription;
1169+
1170+
beforeEach(() => {
1171+
subscription = SubscriptionFixture({organization, plan: 'am3_team'});
1172+
});
1173+
1174+
it('returns false when parent add-on is unavailable', () => {
1175+
subscription.addOns!.seer = {
1176+
...subscription.addOns?.seer!,
1177+
isAvailable: false,
1178+
};
1179+
expect(checkIsAddOnChildCategory(subscription, DataCategory.SEER_USER, true)).toBe(
1180+
false
1181+
);
1182+
});
1183+
1184+
it('returns true for zero reserved volume', () => {
1185+
subscription.categories.seerUsers = {
1186+
...subscription.categories.seerUsers!,
1187+
reserved: 0,
1188+
};
1189+
expect(checkIsAddOnChildCategory(subscription, DataCategory.SEER_USER, true)).toBe(
1190+
true
1191+
);
1192+
});
1193+
1194+
it('returns true for RESERVED_BUDGET_QUOTA reserved volume', () => {
1195+
subscription.categories.seerAutofix = {
1196+
...subscription.categories.seerAutofix!,
1197+
reserved: RESERVED_BUDGET_QUOTA,
1198+
};
1199+
expect(checkIsAddOnChildCategory(subscription, DataCategory.SEER_AUTOFIX, true)).toBe(
1200+
true
1201+
);
1202+
});
1203+
1204+
it('returns true for sub-categories regardless of reserved volume if not checking', () => {
1205+
subscription.categories.seerAutofix = {
1206+
...subscription.categories.seerAutofix!,
1207+
reserved: UNLIMITED_RESERVED,
1208+
};
1209+
expect(
1210+
checkIsAddOnChildCategory(subscription, DataCategory.SEER_AUTOFIX, false)
1211+
).toBe(true);
1212+
});
1213+
1214+
it('returns false for sub-categories with non-zero reserved volume if checking', () => {
1215+
subscription.categories.seerAutofix = {
1216+
...subscription.categories.seerAutofix!,
1217+
reserved: UNLIMITED_RESERVED,
1218+
};
1219+
expect(checkIsAddOnChildCategory(subscription, DataCategory.SEER_AUTOFIX, true)).toBe(
1220+
false
1221+
);
1222+
});
1223+
});
1224+
11581225
describe('getBilledCategory', () => {
11591226
const organization = OrganizationFixture();
11601227
const subscription = SubscriptionFixture({organization, plan: 'am3_team'});

static/gsApp/utils/billing.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -897,6 +897,34 @@ export function checkIsAddOn(
897897
return Object.values(AddOnCategory).includes(selectedProduct as AddOnCategory);
898898
}
899899

900+
/**
901+
* Check if a data category is a child category of an add-on.
902+
* If `checkReserved` is true, the category is only considered a child if it has a reserved volume of 0 or RESERVED_BUDGET_QUOTA.
903+
* If `checkReserved` is false, the category is considered a child if it is included in `dataCategories` for any available add-on.
904+
*/
905+
export function checkIsAddOnChildCategory(
906+
subscription: Subscription,
907+
category: DataCategory,
908+
checkReserved: boolean
909+
) {
910+
const parentAddOn = Object.values(subscription.addOns ?? {})
911+
.filter(addOn => addOn.isAvailable)
912+
.find(addOn => addOn.dataCategories.includes(category));
913+
if (!parentAddOn) {
914+
return false;
915+
}
916+
917+
if (checkReserved) {
918+
const metricHistory = subscription.categories[category];
919+
if (!metricHistory) {
920+
return false;
921+
}
922+
return [RESERVED_BUDGET_QUOTA, 0].includes(metricHistory.reserved ?? 0);
923+
}
924+
925+
return true;
926+
}
927+
900928
/**
901929
* Get the billed DataCategory for an add-on or DataCategory.
902930
*/

static/gsApp/utils/dataCategory.spec.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {DataCategory} from 'sentry/types/core';
1616
import {
1717
getPlanCategoryName,
1818
getReservedBudgetDisplayName,
19+
getSingularCategoryName,
1920
hasCategoryFeature,
2021
isByteCategory,
2122
listDisplayNames,
@@ -259,6 +260,50 @@ describe('getPlanCategoryName', () => {
259260
);
260261
});
261262

263+
it('should title case category if specified', () => {
264+
expect(
265+
getPlanCategoryName({plan, category: DataCategory.MONITOR_SEATS, title: true})
266+
).toBe('Cron Monitors');
267+
expect(getPlanCategoryName({plan, category: DataCategory.ERRORS, title: true})).toBe(
268+
'Errors'
269+
);
270+
});
271+
272+
it('should display spans as accepted spans for DS', () => {
273+
expect(
274+
getPlanCategoryName({
275+
plan,
276+
category: DataCategory.SPANS,
277+
hadCustomDynamicSampling: true,
278+
})
279+
).toBe('Accepted spans');
280+
});
281+
});
282+
283+
describe('getSingularCategoryName', () => {
284+
const plan = PlanDetailsLookupFixture('am3_team');
285+
286+
it('should capitalize category', () => {
287+
expect(getSingularCategoryName({plan, category: DataCategory.TRANSACTIONS})).toBe(
288+
'Transaction'
289+
);
290+
expect(getSingularCategoryName({plan, category: DataCategory.PROFILE_DURATION})).toBe(
291+
'Continuous profile hour'
292+
);
293+
expect(getSingularCategoryName({plan, category: DataCategory.MONITOR_SEATS})).toBe(
294+
'Cron monitor'
295+
);
296+
});
297+
298+
it('should title case category if specified', () => {
299+
expect(
300+
getSingularCategoryName({plan, category: DataCategory.MONITOR_SEATS, title: true})
301+
).toBe('Cron Monitor');
302+
expect(
303+
getSingularCategoryName({plan, category: DataCategory.ERRORS, title: true})
304+
).toBe('Error');
305+
});
306+
262307
it('should display spans as accepted spans for DS', () => {
263308
expect(
264309
getPlanCategoryName({

static/gsApp/utils/dataCategory.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import upperFirst from 'lodash/upperFirst';
22

33
import {DATA_CATEGORY_INFO} from 'sentry/constants';
4+
import {t} from 'sentry/locale';
45
import {DataCategory, DataCategoryExact} from 'sentry/types/core';
56
import type {Organization} from 'sentry/types/organization';
67
import oxfordizeArray from 'sentry/utils/oxfordizeArray';
@@ -68,13 +69,11 @@ export function getPlanCategoryName({
6869
}: CategoryNameProps) {
6970
const displayNames = plan?.categoryDisplayNames?.[category];
7071
const categoryName =
71-
category === DataCategory.LOG_BYTE
72-
? 'logs'
73-
: category === DataCategory.SPANS && hadCustomDynamicSampling
74-
? 'accepted spans'
75-
: displayNames
76-
? displayNames.plural
77-
: category;
72+
category === DataCategory.SPANS && hadCustomDynamicSampling
73+
? t('accepted spans')
74+
: displayNames
75+
? displayNames.plural
76+
: category;
7877
return title
7978
? toTitleCase(categoryName, {allowInnerUpperCase: true})
8079
: capitalize
@@ -95,7 +94,7 @@ export function getSingularCategoryName({
9594
const displayNames = plan?.categoryDisplayNames?.[category];
9695
const categoryName =
9796
category === DataCategory.SPANS && hadCustomDynamicSampling
98-
? 'accepted span'
97+
? t('accepted span')
9998
: displayNames
10099
? displayNames.singular
101100
: category.substring(0, category.length - 1);

0 commit comments

Comments
 (0)