Skip to content

Commit 7613c95

Browse files
authored
fix(billing): Fixes usage percentage in usage history for Continuous Profiling (#97626)
Closes: https://linear.app/getsentry/issue/BIL-1245/inconsistent-usage-history-calculation-for-ui-profile-hours Fixes the usagePercentage calculation for Continuous Profiling data categories. The fix handles both normal usage and overage scenarios (properly showing ">100%" when usage exceeds limits). This branch fixes a usage calculation bug for Continuous Profiling categories (`PROFILE_DURATION` and `PROFILE_DURATION_UI`) in the subscription page. The issue was that usage data is stored in milliseconds but prepaid limits are in hours, causing incorrect percentage calculations. The fix adds proper unit conversion by dividing usage by `MILLISECONDS_IN_HOUR` for Continuous Profiling categories. See https://sentry.zendesk.com/agent/tickets/159695
1 parent 642d48f commit 7613c95

File tree

6 files changed

+212
-24
lines changed

6 files changed

+212
-24
lines changed

static/gsApp/utils/billing.spec.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {DataCategory} from 'sentry/types/core';
1010
import {BILLION, GIGABYTE, MILLION, UNLIMITED} from 'getsentry/constants';
1111
import {OnDemandBudgetMode, type EventBucket, type ProductTrial} from 'getsentry/types';
1212
import {
13+
convertUsageToReservedUnit,
1314
formatReservedWithUnits,
1415
formatUsageWithUnits,
1516
getActiveProductTrial,
@@ -390,6 +391,47 @@ describe('formatUsageWithUnits', function () {
390391
});
391392
});
392393

394+
describe('convertUsageToReservedUnit', function () {
395+
it('converts attachments from bytes to GB', function () {
396+
expect(convertUsageToReservedUnit(GIGABYTE, DataCategory.ATTACHMENTS)).toBe(1);
397+
expect(convertUsageToReservedUnit(5 * GIGABYTE, DataCategory.ATTACHMENTS)).toBe(5);
398+
expect(convertUsageToReservedUnit(0.5 * GIGABYTE, DataCategory.ATTACHMENTS)).toBe(
399+
0.5
400+
);
401+
});
402+
403+
it('converts continuous profiling from milliseconds to hours', function () {
404+
expect(
405+
convertUsageToReservedUnit(MILLISECONDS_IN_HOUR, DataCategory.PROFILE_DURATION)
406+
).toBe(1);
407+
expect(
408+
convertUsageToReservedUnit(2 * MILLISECONDS_IN_HOUR, DataCategory.PROFILE_DURATION)
409+
).toBe(2);
410+
expect(
411+
convertUsageToReservedUnit(
412+
0.5 * MILLISECONDS_IN_HOUR,
413+
DataCategory.PROFILE_DURATION
414+
)
415+
).toBe(0.5);
416+
expect(
417+
convertUsageToReservedUnit(MILLISECONDS_IN_HOUR, DataCategory.PROFILE_DURATION_UI)
418+
).toBe(1);
419+
expect(
420+
convertUsageToReservedUnit(
421+
3.5 * MILLISECONDS_IN_HOUR,
422+
DataCategory.PROFILE_DURATION_UI
423+
)
424+
).toBe(3.5);
425+
});
426+
427+
it('returns usage unchanged for other categories', function () {
428+
expect(convertUsageToReservedUnit(1000, DataCategory.ERRORS)).toBe(1000);
429+
expect(convertUsageToReservedUnit(500, DataCategory.TRANSACTIONS)).toBe(500);
430+
expect(convertUsageToReservedUnit(250, DataCategory.REPLAYS)).toBe(250);
431+
expect(convertUsageToReservedUnit(0, DataCategory.SPANS)).toBe(0);
432+
});
433+
});
434+
393435
describe('getSlot', () => {
394436
function makeBucket(props: {events?: number; price?: number}) {
395437
return {

static/gsApp/utils/billing.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,19 @@ export function formatUsageWithUnits(
189189
: usageQuantity.toLocaleString();
190190
}
191191

192+
export function convertUsageToReservedUnit(
193+
usage: number,
194+
category: DataCategory | string
195+
): number {
196+
if (isByteCategory(category)) {
197+
return usage / GIGABYTE;
198+
}
199+
if (isContinuousProfiling(category)) {
200+
return usage / MILLISECONDS_IN_HOUR;
201+
}
202+
return usage;
203+
}
204+
192205
/**
193206
* Do not export.
194207
* Helper method for formatReservedWithUnits

static/gsApp/views/subscriptionPage/usageAlert.spec.tsx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {DataCategory} from 'sentry/types/core';
1212

1313
import {GIGABYTE} from 'getsentry/constants';
1414
import SubscriptionStore from 'getsentry/stores/subscriptionStore';
15+
import {MILLISECONDS_IN_HOUR} from 'getsentry/utils/billing';
1516
import UsageAlert from 'getsentry/views/subscriptionPage/usageAlert';
1617

1718
describe('Subscription > UsageAlert', function () {
@@ -704,5 +705,75 @@ describe('Subscription > UsageAlert', function () {
704705

705706
expect(screen.queryByTestId('usage-alert')).not.toBeInTheDocument();
706707
});
708+
709+
it('renders am3 with projected profile duration overage', function () {
710+
const organization = OrganizationFixture({access: ['org:billing']});
711+
const subscription = SubscriptionFixture({organization, canTrial: false});
712+
713+
render(
714+
<UsageAlert
715+
subscription={{
716+
...subscription,
717+
plan: 'am3_f',
718+
categories: {
719+
[DataCategory.PROFILE_DURATION]: MetricHistoryFixture({
720+
prepaid: 100,
721+
reserved: 100,
722+
category: DataCategory.PROFILE_DURATION,
723+
}),
724+
},
725+
}}
726+
usage={CustomerUsageFixture({
727+
totals: {
728+
[DataCategory.PROFILE_DURATION]: UsageTotalFixture({
729+
accepted: 50 * MILLISECONDS_IN_HOUR,
730+
projected: 200 * MILLISECONDS_IN_HOUR,
731+
}),
732+
},
733+
})}
734+
/>,
735+
{organization}
736+
);
737+
738+
expect(screen.getByTestId('projected-overage-alert')).toBeInTheDocument();
739+
expect(screen.getByText('Projected Overage')).toBeInTheDocument();
740+
expect(screen.getByText(/will need at least 200/)).toBeInTheDocument();
741+
expect(screen.getByLabelText('Upgrade Plan')).toBeInTheDocument();
742+
743+
expect(screen.queryByTestId('usage-exceeded-alert')).not.toBeInTheDocument();
744+
expect(screen.queryByTestId('grace-period-alert')).not.toBeInTheDocument();
745+
});
746+
747+
it('does not render without projected profile duration overage', function () {
748+
const organization = OrganizationFixture({access: ['org:billing']});
749+
const subscription = SubscriptionFixture({organization, canTrial: false});
750+
751+
render(
752+
<UsageAlert
753+
subscription={{
754+
...subscription,
755+
plan: 'am3_f',
756+
categories: {
757+
[DataCategory.PROFILE_DURATION]: MetricHistoryFixture({
758+
prepaid: 500,
759+
reserved: 500,
760+
category: DataCategory.PROFILE_DURATION,
761+
}),
762+
},
763+
}}
764+
usage={CustomerUsageFixture({
765+
totals: {
766+
[DataCategory.PROFILE_DURATION]: UsageTotalFixture({
767+
accepted: 50 * MILLISECONDS_IN_HOUR,
768+
projected: 200 * MILLISECONDS_IN_HOUR,
769+
}),
770+
},
771+
})}
772+
/>,
773+
{organization}
774+
);
775+
776+
expect(screen.queryByTestId('usage-alert')).not.toBeInTheDocument();
777+
});
707778
});
708779
});

static/gsApp/views/subscriptionPage/usageAlert.tsx

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,19 @@ import useOrganization from 'sentry/utils/useOrganization';
1111
import TextBlock from 'sentry/views/settings/components/text/textBlock';
1212

1313
import AddEventsCTA from 'getsentry/components/addEventsCTA';
14-
import {GIGABYTE, RESERVED_BUDGET_QUOTA} from 'getsentry/constants';
14+
import {RESERVED_BUDGET_QUOTA} from 'getsentry/constants';
1515
import OrgStatsBanner from 'getsentry/hooks/orgStatsBanner';
1616
import type {CustomerUsage, Subscription} from 'getsentry/types';
1717
import {
18+
convertUsageToReservedUnit,
1819
formatReservedWithUnits,
19-
formatUsageWithUnits,
2020
getBestActionToIncreaseEventLimits,
2121
hasPerformance,
2222
isBizPlanFamily,
2323
isUnlimitedReserved,
2424
UsageAction,
2525
} from 'getsentry/utils/billing';
26-
import {
27-
getPlanCategoryName,
28-
isByteCategory,
29-
sortCategoriesWithKeys,
30-
} from 'getsentry/utils/dataCategory';
26+
import {getPlanCategoryName, sortCategoriesWithKeys} from 'getsentry/utils/dataCategory';
3127

3228
import {ButtonWrapper, SubscriptionBody} from './styles';
3329

@@ -67,11 +63,13 @@ function UsageAlert({subscription, usage}: Props) {
6763
hadCustomDynamicSampling: subscription.hadCustomDynamicSampling,
6864
});
6965

66+
const formattedAmount = formatReservedWithUnits(projected, category, {
67+
isAbbreviated: category !== DataCategory.ATTACHMENTS,
68+
});
69+
7070
return category === DataCategory.ATTACHMENTS
71-
? `${formatUsageWithUnits(projected, category)} of attachments`
72-
: `${formatReservedWithUnits(projected, category, {
73-
isAbbreviated: true,
74-
})} ${displayName}`;
71+
? `${formattedAmount} of attachments`
72+
: `${formattedAmount} ${displayName}`;
7573
}
7674

7775
function projectedCategoryOverages() {
@@ -90,16 +88,14 @@ function UsageAlert({subscription, usage}: Props) {
9088
return acc;
9189
}
9290
const projected = usage.totals[category]?.projected || 0;
93-
const projectedWithReservedUnit = isByteCategory(category)
94-
? projected / GIGABYTE
95-
: projected;
91+
const projectedWithReservedUnit = convertUsageToReservedUnit(projected, category);
9692

9793
const hasOverage =
9894
!!currentHistory.reserved &&
9995
projectedWithReservedUnit > (currentHistory.prepaid ?? 0);
10096

10197
if (hasOverage) {
102-
acc.push(formatProjected(projected, category as DataCategory));
98+
acc.push(formatProjected(projectedWithReservedUnit, category as DataCategory));
10399
}
104100
return acc;
105101
},

static/gsApp/views/subscriptionPage/usageHistory.spec.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,4 +800,73 @@ describe('Subscription > UsageHistory', () => {
800800

801801
expect(mockCall).toHaveBeenCalled();
802802
});
803+
804+
it('converts prepaid limit to hours for UI profile duration category', async function () {
805+
const MILLISECONDS_IN_HOUR = 60 * 60 * 1000;
806+
MockApiClient.addMockResponse({
807+
url: `/customers/${organization.slug}/history/`,
808+
method: 'GET',
809+
body: [
810+
BillingHistoryFixture({
811+
plan: 'am3_business_ent_auf',
812+
isCurrent: true,
813+
categories: {
814+
[DataCategory.PROFILE_DURATION_UI]: MetricHistoryFixture({
815+
category: DataCategory.PROFILE_DURATION_UI,
816+
usage: 100 * MILLISECONDS_IN_HOUR,
817+
reserved: 6000,
818+
prepaid: 6000,
819+
}),
820+
},
821+
}),
822+
],
823+
});
824+
825+
const subscription = SubscriptionFixture({
826+
organization,
827+
plan: 'am3_business_ent_auf',
828+
});
829+
SubscriptionStore.set(organization.slug, subscription);
830+
831+
render(<UsageHistory {...RouteComponentPropsFixture()} />, {organization});
832+
833+
// Should show 2% (100/6000 * 100)
834+
expect(await screen.findByText(/UI Profile Hours/i)).toBeInTheDocument();
835+
expect(await screen.findByText('2%')).toBeInTheDocument();
836+
expect(screen.queryByText('>100%')).not.toBeInTheDocument();
837+
});
838+
839+
it('shows >100% for UI profile duration overage', async function () {
840+
const MILLISECONDS_IN_HOUR = 60 * 60 * 1000;
841+
MockApiClient.addMockResponse({
842+
url: `/customers/${organization.slug}/history/`,
843+
method: 'GET',
844+
body: [
845+
BillingHistoryFixture({
846+
plan: 'am3_business_ent_auf',
847+
isCurrent: true,
848+
categories: {
849+
[DataCategory.PROFILE_DURATION_UI]: MetricHistoryFixture({
850+
category: DataCategory.PROFILE_DURATION_UI,
851+
usage: 7000 * MILLISECONDS_IN_HOUR,
852+
reserved: 6000,
853+
prepaid: 6000,
854+
}),
855+
},
856+
}),
857+
],
858+
});
859+
860+
const subscription = SubscriptionFixture({
861+
organization,
862+
plan: 'am3_business_ent_auf',
863+
});
864+
SubscriptionStore.set(organization.slug, subscription);
865+
866+
render(<UsageHistory {...RouteComponentPropsFixture()} />, {organization});
867+
868+
// Should show >100% when usage exceeds prepaid limit
869+
expect(await screen.findByText(/UI Profile Hours/i)).toBeInTheDocument();
870+
expect(await screen.findByText('>100%')).toBeInTheDocument();
871+
});
803872
});

static/gsApp/views/subscriptionPage/usageHistory.tsx

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,7 @@ import {useLocation} from 'sentry/utils/useLocation';
2323
import useOrganization from 'sentry/utils/useOrganization';
2424

2525
import withSubscription from 'getsentry/components/withSubscription';
26-
import {
27-
GIGABYTE,
28-
RESERVED_BUDGET_QUOTA,
29-
UNLIMITED,
30-
UNLIMITED_ONDEMAND,
31-
} from 'getsentry/constants';
26+
import {RESERVED_BUDGET_QUOTA, UNLIMITED, UNLIMITED_ONDEMAND} from 'getsentry/constants';
3227
import type {
3328
BillingHistory,
3429
BillingMetricHistory,
@@ -37,6 +32,7 @@ import type {
3732
} from 'getsentry/types';
3833
import {OnDemandBudgetMode} from 'getsentry/types';
3934
import {
35+
convertUsageToReservedUnit,
4036
formatReservedWithUnits,
4137
formatUsageWithUnits,
4238
getSoftCapType,
@@ -339,9 +335,10 @@ function UsageHistoryRow({history, subscription}: RowProps) {
339335
{metricHistory.reserved === RESERVED_BUDGET_QUOTA
340336
? 'N/A'
341337
: usagePercentage(
342-
metricHistory.category === DataCategory.ATTACHMENTS
343-
? metricHistory.usage / GIGABYTE
344-
: metricHistory.usage,
338+
convertUsageToReservedUnit(
339+
metricHistory.usage,
340+
metricHistory.category
341+
),
345342
metricHistory.prepaid
346343
)}
347344
</td>

0 commit comments

Comments
 (0)