From ecf46947da0ec6f4e52ed4a3cd76ab28e05305a9 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Fri, 28 Nov 2025 12:32:11 -0500 Subject: [PATCH 1/9] feat(usage overview): Introduce util functions and hook --- .../gsApp/hooks/useProductBillingMetadata.tsx | 116 +++++++++++++++ static/gsApp/types/index.tsx | 2 +- static/gsApp/utils/billing.spec.tsx | 140 +++++++++++++++++- static/gsApp/utils/billing.tsx | 91 +++++++++++- 4 files changed, 344 insertions(+), 5 deletions(-) create mode 100644 static/gsApp/hooks/useProductBillingMetadata.tsx diff --git a/static/gsApp/hooks/useProductBillingMetadata.tsx b/static/gsApp/hooks/useProductBillingMetadata.tsx new file mode 100644 index 00000000000000..a7ebf64b465913 --- /dev/null +++ b/static/gsApp/hooks/useProductBillingMetadata.tsx @@ -0,0 +1,116 @@ +import type {DataCategory} from 'sentry/types/core'; +import {toTitleCase} from 'sentry/utils/string/toTitleCase'; + +import type {AddOn, AddOnCategory, ProductTrial, Subscription} from 'getsentry/types'; +import { + checkIsAddOn, + getActiveProductTrial, + getBilledCategory, + getPotentialProductTrial, + productIsEnabled, +} from 'getsentry/utils/billing'; +import {getPlanCategoryName} from 'getsentry/utils/dataCategory'; + +interface ProductBillingMetadata { + /** + * The active product trial for the given product, if any. + * Always null when excludeProductTrials is true. + */ + activeProductTrial: ProductTrial | null; + /** + * The billed category for the given product. + * When product is a DataCategory, we just return product. + * When product is an AddOnCategory, we return the billed category for the + * add-on. + */ + billedCategory: DataCategory | null; + /** + * The display name for the given product in title case. + */ + displayName: string; + /** + * Whether the product is an add-on. + */ + isAddOn: boolean; + /** + * Whether the product is enabled for the subscription. + */ + isEnabled: boolean; + /** + * The longest available product trial for the given product, if any. + * Always null when excludeProductTrials is true. + */ + potentialProductTrial: ProductTrial | null; + /** + * Whether the usage for the given product has exceeded the limit. + */ + usageExceeded: boolean; + /** + * The add-on information for the given product from the subscription, + * if any. + */ + addOnInfo?: AddOn; +} + +const EMPTY_PRODUCT_BILLING_METADATA: ProductBillingMetadata = { + billedCategory: null, + displayName: '', + isAddOn: false, + isEnabled: false, + usageExceeded: false, + activeProductTrial: null, + potentialProductTrial: null, +}; + +export function useProductBillingMetadata( + subscription: Subscription, + product: DataCategory | AddOnCategory, + parentProduct?: DataCategory | AddOnCategory, + excludeProductTrials?: boolean +): ProductBillingMetadata { + const isAddOn = checkIsAddOn(parentProduct ?? product); + const billedCategory = getBilledCategory(subscription, product); + + if (!billedCategory) { + return EMPTY_PRODUCT_BILLING_METADATA; + } + + let displayName = ''; + let addOnInfo = undefined; + + if (isAddOn) { + const category = (parentProduct ?? product) as AddOnCategory; + addOnInfo = subscription.addOns?.[category]; + if (!addOnInfo) { + return EMPTY_PRODUCT_BILLING_METADATA; + } + displayName = parentProduct + ? getPlanCategoryName({ + plan: subscription.planDetails, + category: billedCategory, + title: true, + }) + : toTitleCase(addOnInfo.productName, {allowInnerUpperCase: true}); + } else { + displayName = getPlanCategoryName({ + plan: subscription.planDetails, + category: billedCategory, + title: true, + }); + } + + return { + displayName, + billedCategory, + isAddOn, + isEnabled: productIsEnabled(subscription, parentProduct ?? product), + addOnInfo, + usageExceeded: subscription.categories[billedCategory]?.usageExceeded ?? false, + activeProductTrial: excludeProductTrials + ? null + : getActiveProductTrial(subscription.productTrials ?? null, billedCategory), + potentialProductTrial: excludeProductTrials + ? null + : getPotentialProductTrial(subscription.productTrials ?? null, billedCategory), + }; +} diff --git a/static/gsApp/types/index.tsx b/static/gsApp/types/index.tsx index 31429dc54e30a5..a0e6b35625b560 100644 --- a/static/gsApp/types/index.tsx +++ b/static/gsApp/types/index.tsx @@ -149,7 +149,7 @@ export type AddOnCategoryInfo = { productName: string; }; -type AddOn = AddOnCategoryInfo & { +export type AddOn = AddOnCategoryInfo & { enabled: boolean; }; diff --git a/static/gsApp/utils/billing.spec.tsx b/static/gsApp/utils/billing.spec.tsx index 0fcf27e501d82c..880cd0b2dd0492 100644 --- a/static/gsApp/utils/billing.spec.tsx +++ b/static/gsApp/utils/billing.spec.tsx @@ -8,14 +8,16 @@ import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; import {DataCategory} from 'sentry/types/core'; import {BILLION, GIGABYTE, MILLION, UNLIMITED} from 'getsentry/constants'; -import {OnDemandBudgetMode} from 'getsentry/types'; -import type {ProductTrial} from 'getsentry/types'; +import {AddOnCategory, OnDemandBudgetMode} from 'getsentry/types'; +import type {ProductTrial, Subscription} from 'getsentry/types'; import { + checkIsAddOn, convertUsageToReservedUnit, formatReservedWithUnits, formatUsageWithUnits, getActiveProductTrial, getBestActionToIncreaseEventLimits, + getBilledCategory, getCreditApplied, getOnDemandCategories, getProductTrial, @@ -27,6 +29,7 @@ import { isNewPayingCustomer, isTeamPlanFamily, MILLISECONDS_IN_HOUR, + productIsEnabled, trialPromptIsDismissed, UsageAction, } from 'getsentry/utils/billing'; @@ -185,6 +188,11 @@ describe('formatReservedWithUnits', () => { useUnitScaling: true, }) ).toBe(UNLIMITED); + expect( + formatReservedWithUnits(-1, DataCategory.ATTACHMENTS, { + useUnitScaling: true, + }) + ).toBe(UNLIMITED); }); it('returns correct string for Profile Duration', () => { @@ -253,6 +261,11 @@ describe('formatReservedWithUnits', () => { useUnitScaling: true, }) ).toBe(UNLIMITED); + expect( + formatReservedWithUnits(-1, DataCategory.LOG_BYTE, { + useUnitScaling: true, + }) + ).toBe(UNLIMITED); expect( formatReservedWithUnits(1234, DataCategory.LOG_BYTE, { @@ -1129,3 +1142,126 @@ describe('getCreditApplied', () => { ).toBe(0); }); }); + +describe('checkIsAddOn', () => { + it('returns true for add-on', () => { + expect(checkIsAddOn(AddOnCategory.LEGACY_SEER)).toBe(true); + expect(checkIsAddOn(AddOnCategory.SEER)).toBe(true); + }); + + it('returns false for data category', () => { + expect(checkIsAddOn(DataCategory.ERRORS)).toBe(false); + expect(checkIsAddOn(DataCategory.SEER_AUTOFIX)).toBe(false); + }); +}); + +describe('getBilledCategory', () => { + const organization = OrganizationFixture(); + const subscription = SubscriptionFixture({organization, plan: 'am3_team'}); + + it('returns correct billed category for data category', () => { + subscription.planDetails.categories.forEach(category => { + expect(getBilledCategory(subscription, category)).toBe(category); + }); + }); + + it('returns correct billed category for add-on', () => { + expect(getBilledCategory(subscription, AddOnCategory.LEGACY_SEER)).toBe( + DataCategory.SEER_AUTOFIX + ); + expect(getBilledCategory(subscription, AddOnCategory.SEER)).toBe( + DataCategory.SEER_USER + ); + }); +}); + +describe('productIsEnabled', () => { + const organization = OrganizationFixture(); + let subscription: Subscription; + + beforeEach(() => { + subscription = SubscriptionFixture({organization, plan: 'am3_team'}); + }); + + it('returns true for active product trial', () => { + subscription.productTrials = [ + { + // not started + category: DataCategory.PROFILE_DURATION, + isStarted: false, + reasonCode: 1001, + startDate: undefined, + endDate: moment().utc().add(20, 'years').format(), + }, + { + // started + category: DataCategory.REPLAYS, + isStarted: true, + reasonCode: 1001, + startDate: moment().utc().subtract(10, 'days').format(), + endDate: moment().utc().add(20, 'days').format(), + }, + { + // started + category: DataCategory.SEER_AUTOFIX, + isStarted: true, + reasonCode: 1001, + startDate: moment().utc().subtract(10, 'days').format(), + endDate: moment().utc().add(20, 'days').format(), + }, + ]; + + expect(productIsEnabled(subscription, DataCategory.PROFILE_DURATION)).toBe(false); + expect(productIsEnabled(subscription, DataCategory.REPLAYS)).toBe(true); + expect(productIsEnabled(subscription, DataCategory.SEER_AUTOFIX)).toBe(true); + expect(productIsEnabled(subscription, AddOnCategory.LEGACY_SEER)).toBe(true); // because there is a product trial for the billed category + }); + + it('uses subscription add-on info for add-on', () => { + subscription.addOns!.seer = { + ...subscription.addOns?.seer!, + enabled: true, + }; + + expect(productIsEnabled(subscription, AddOnCategory.SEER)).toBe(true); + expect(productIsEnabled(subscription, AddOnCategory.LEGACY_SEER)).toBe(false); + }); + + it('returns true for non-PAYG-only data categories', () => { + expect(productIsEnabled(subscription, DataCategory.ERRORS)).toBe(true); + }); + + it('uses PAYG budgets for PAYG-only data categories', () => { + expect(productIsEnabled(subscription, DataCategory.PROFILE_DURATION)).toBe(false); + + // shared PAYG + subscription.onDemandBudgets = { + budgetMode: OnDemandBudgetMode.SHARED, + sharedMaxBudget: 1000, + enabled: true, + onDemandSpendUsed: 0, + }; + expect(productIsEnabled(subscription, DataCategory.PROFILE_DURATION)).toBe(true); + + // per-category PAYG + subscription.onDemandBudgets = { + budgetMode: OnDemandBudgetMode.PER_CATEGORY, + enabled: true, + budgets: { + errors: 1000, + }, + usedSpends: {}, + }; + expect(productIsEnabled(subscription, DataCategory.PROFILE_DURATION)).toBe(false); + + subscription.onDemandBudgets.budgets = { + ...subscription.onDemandBudgets.budgets, + profileDuration: 1000, + }; + subscription.categories.profileDuration = { + ...subscription.categories.profileDuration!, + onDemandBudget: 1000, + }; + expect(productIsEnabled(subscription, DataCategory.PROFILE_DURATION)).toBe(true); + }); +}); diff --git a/static/gsApp/utils/billing.tsx b/static/gsApp/utils/billing.tsx index 48da32b511e1f8..332ae53c3ed757 100644 --- a/static/gsApp/utils/billing.tsx +++ b/static/gsApp/utils/billing.tsx @@ -6,6 +6,7 @@ import {DataCategory} from 'sentry/types/core'; import type {Organization} from 'sentry/types/organization'; import {defined} from 'sentry/utils'; import getDaysSinceDate from 'sentry/utils/getDaysSinceDate'; +import {toTitleCase} from 'sentry/utils/string/toTitleCase'; import type {IconSize} from 'sentry/utils/theme'; import { @@ -158,11 +159,17 @@ export function formatReservedWithUnits( if (!isByteCategory(dataCategory)) { return formatReservedNumberToString(reservedQuantity, options); } + // convert reservedQuantity to BYTES to check for unlimited - const usageGb = reservedQuantity ? reservedQuantity * GIGABYTE : reservedQuantity; + // unless it's already unlimited + const usageGb = + reservedQuantity && !isUnlimitedReserved(reservedQuantity) + ? reservedQuantity * GIGABYTE + : reservedQuantity; if (isUnlimitedReserved(usageGb)) { return options.isGifted ? '0 GB' : UNLIMITED; } + if (!options.useUnitScaling) { const byteOptions = dataCategory === DataCategory.LOG_BYTE @@ -622,7 +629,9 @@ export function hasAccessToSubscriptionOverview( */ export function getSoftCapType(metricHistory: BillingMetricHistory): string | null { if (metricHistory.softCapType) { - return titleCase(metricHistory.softCapType.replace(/_/g, ' ')); + return toTitleCase(metricHistory.softCapType.replace(/_/g, ' ').toLowerCase(), { + allowInnerUpperCase: true, + }).replace(' ', metricHistory.softCapType === 'ON_DEMAND' ? '-' : ' '); } if (metricHistory.trueForward) { return 'True Forward'; @@ -861,3 +870,81 @@ export function formatOnDemandDescription( const budgetTerm = displayBudgetName(plan, {title: false}).toLowerCase(); return description.replace(new RegExp(`\\s*${budgetTerm}\\s*`, 'gi'), ' ').trim(); } + +/** + * Given a DataCategory or AddOnCategory, returns true if it is an add-on, false otherwise. + */ +export function checkIsAddOn( + selectedProduct: DataCategory | AddOnCategory | string +): boolean { + return Object.values(AddOnCategory).includes(selectedProduct as AddOnCategory); +} + +/** + * Get the billed DataCategory for an add-on or DataCategory. + */ +export function getBilledCategory( + subscription: Subscription, + selectedProduct: DataCategory | AddOnCategory +): DataCategory | null { + if (checkIsAddOn(selectedProduct)) { + const category = selectedProduct as AddOnCategory; + const addOnInfo = subscription.addOns?.[category]; + if (!addOnInfo) { + return null; + } + + const {dataCategories, apiName} = addOnInfo; + const reservedBudgetCategory = getReservedBudgetCategoryForAddOn(apiName); + const reservedBudget = subscription.reservedBudgets?.find( + budget => budget.apiName === reservedBudgetCategory + ); + return reservedBudget + ? (dataCategories.find(dataCategory => + subscription.planDetails.planCategories[dataCategory]?.find( + bucket => bucket.events === RESERVED_BUDGET_QUOTA + ) + ) ?? dataCategories[0]!) + : dataCategories[0]!; + } + + return selectedProduct as DataCategory; +} + +export function productIsEnabled( + subscription: Subscription, + selectedProduct: DataCategory | AddOnCategory +): boolean { + const billedCategory = getBilledCategory(subscription, selectedProduct); + if (!billedCategory) { + return false; + } + + const activeProductTrial = getActiveProductTrial( + subscription.productTrials ?? null, + billedCategory + ); + if (activeProductTrial) { + return true; + } + + if (checkIsAddOn(selectedProduct)) { + const addOnInfo = subscription.addOns?.[selectedProduct as AddOnCategory]; + if (!addOnInfo) { + return false; + } + return addOnInfo.enabled; + } + + const metricHistory = subscription.categories[billedCategory]; + if (!metricHistory) { + return false; + } + const isPaygOnly = metricHistory.reserved === 0; + return ( + !isPaygOnly || + metricHistory.onDemandBudget > 0 || + (subscription.onDemandBudgets?.budgetMode === OnDemandBudgetMode.SHARED && + subscription.onDemandBudgets.sharedMaxBudget > 0) + ); +} From a9fdc2d02824770b3f0a2247b719d0b7ff556323 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Fri, 28 Nov 2025 13:48:50 -0500 Subject: [PATCH 2/9] fix test + knip --- .../components/categoryUsageDrawer.tsx | 10 +++------- .../gsApp/views/subscriptionPage/usageHistory.spec.tsx | 6 +++--- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/static/gsApp/views/subscriptionPage/components/categoryUsageDrawer.tsx b/static/gsApp/views/subscriptionPage/components/categoryUsageDrawer.tsx index 3557f9eb538b58..c240cd0d961f0b 100644 --- a/static/gsApp/views/subscriptionPage/components/categoryUsageDrawer.tsx +++ b/static/gsApp/views/subscriptionPage/components/categoryUsageDrawer.tsx @@ -9,6 +9,7 @@ import {useNavigate} from 'sentry/utils/useNavigate'; import useOrganization from 'sentry/utils/useOrganization'; import {CHART_OPTIONS_DATA_TRANSFORM} from 'sentry/views/organizationStats/usageChart'; +import {useProductBillingMetadata} from 'getsentry/hooks/useProductBillingMetadata'; import { PlanTier, type BillingMetricHistory, @@ -19,7 +20,6 @@ import { import {addBillingStatTotals, isAm2Plan} from 'getsentry/utils/billing'; import { getChunkCategoryFromDuration, - getPlanCategoryName, isContinuousProfiling, } from 'getsentry/utils/dataCategory'; import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics'; @@ -55,12 +55,8 @@ function CategoryUsageDrawer({ const transform = selectedTransform(location); const {category, usage: billedUsage} = categoryInfo; - const displayName = getPlanCategoryName({ - plan: subscription.planDetails, - category, - title: true, - }); - + // XXX(isabella): using this to make knip happy til the hook is used in other places + const {displayName} = useProductBillingMetadata(subscription, category); const usageStats = { [category]: stats, }; diff --git a/static/gsApp/views/subscriptionPage/usageHistory.spec.tsx b/static/gsApp/views/subscriptionPage/usageHistory.spec.tsx index 49aa93100105a7..379a21dc050c02 100644 --- a/static/gsApp/views/subscriptionPage/usageHistory.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageHistory.spec.tsx @@ -435,9 +435,9 @@ describe('Subscription > UsageHistory', () => { render(, {organization}); // Per-category Soft Cap On-demand should show up - expect(await screen.findAllByText('Errors (On Demand)')).toHaveLength(2); - expect(screen.getAllByText('Transactions (On Demand)')).toHaveLength(2); - expect(screen.getAllByText('Attachments (On Demand)')).toHaveLength(2); + expect(await screen.findAllByText('Errors (On-Demand)')).toHaveLength(2); + expect(screen.getAllByText('Transactions (On-Demand)')).toHaveLength(2); + expect(screen.getAllByText('Attachments (On-Demand)')).toHaveLength(2); expect(mockCall).toHaveBeenCalled(); }); From 28f2d3e1a4eb7b4ad4388d459497ce7928194d2d Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Fri, 28 Nov 2025 14:01:03 -0500 Subject: [PATCH 3/9] add this too --- static/gsApp/utils/trackGetsentryAnalytics.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/static/gsApp/utils/trackGetsentryAnalytics.tsx b/static/gsApp/utils/trackGetsentryAnalytics.tsx index 2fc444f9fda891..719607205494ef 100644 --- a/static/gsApp/utils/trackGetsentryAnalytics.tsx +++ b/static/gsApp/utils/trackGetsentryAnalytics.tsx @@ -203,9 +203,13 @@ type GetsentryEventParameters = { addOnCategory: AddOnCategory; isOpen: boolean; } & HasSub; - 'subscription_page.usage_overview.row_clicked': { - dataCategory: DataCategory; - } & HasSub; + 'subscription_page.usage_overview.row_clicked': ( + | { + dataCategory: DataCategory; + } + | {addOnCategory: AddOnCategory} + ) & + HasSub; 'subscription_page.usage_overview.transform_changed': { transform: string; } & HasSub; From 6b0f5aac989160c4de62778f7149f2514daa3a20 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Fri, 28 Nov 2025 14:04:48 -0500 Subject: [PATCH 4/9] feat(usage overview): Update buttons --- .../usageOverview/actions.tsx | 100 ++++++++++++++++++ .../index.spec.tsx} | 0 .../index.tsx} | 41 +------ 3 files changed, 103 insertions(+), 38 deletions(-) create mode 100644 static/gsApp/views/subscriptionPage/usageOverview/actions.tsx rename static/gsApp/views/subscriptionPage/{usageOverview.spec.tsx => usageOverview/index.spec.tsx} (100%) rename static/gsApp/views/subscriptionPage/{usageOverview.tsx => usageOverview/index.tsx} (96%) diff --git a/static/gsApp/views/subscriptionPage/usageOverview/actions.tsx b/static/gsApp/views/subscriptionPage/usageOverview/actions.tsx new file mode 100644 index 00000000000000..7fde4639f3e0b6 --- /dev/null +++ b/static/gsApp/views/subscriptionPage/usageOverview/actions.tsx @@ -0,0 +1,100 @@ +import {Button} from 'sentry/components/core/button'; +import {LinkButton} from 'sentry/components/core/button/linkButton'; +import {Flex} from 'sentry/components/core/layout'; +import {DropdownMenu} from 'sentry/components/dropdownMenu'; +import {IconDownload, IconEllipsis, IconTable} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import type {Organization} from 'sentry/types/organization'; +import {useNavContext} from 'sentry/views/nav/context'; +import {NavLayout} from 'sentry/views/nav/types'; + +import {useCurrentBillingHistory} from 'getsentry/hooks/useCurrentBillingHistory'; +import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics'; + +function UsageOverviewActions({organization}: {organization: Organization}) { + const {layout: navLayout} = useNavContext(); + const isMobile = navLayout === NavLayout.MOBILE; + + const {currentHistory, isPending, isError} = useCurrentBillingHistory(); + const hasBillingPerms = organization.access.includes('org:billing'); + if (!hasBillingPerms) { + return null; + } + + const buttons: Array<{ + icon: React.ReactNode; + label: string; + disabled?: boolean; + onClick?: () => void; + to?: string; + }> = [ + { + label: t('View all usage'), + to: '/settings/billing/usage/', + icon: , + }, + { + label: t('Download as CSV'), + icon: , + onClick: () => { + trackGetsentryAnalytics('subscription_page.download_reports.clicked', { + organization, + reportType: 'summary', + }); + if (currentHistory) { + window.open(currentHistory.links.csv, '_blank'); + } + }, + disabled: isPending || isError, + }, + ]; + + if (isMobile) { + return ( + , + showChevron: false, + size: 'sm', + }} + items={buttons.map(buttonInfo => ({ + key: buttonInfo.label, + label: buttonInfo.label, + onAction: buttonInfo.onClick, + to: buttonInfo.to, + disabled: buttonInfo.disabled, + }))} + /> + ); + } + + return ( + + {buttons.map(buttonInfo => + buttonInfo.to ? ( + + {buttonInfo.label} + + ) : ( + + ) + )} + + ); +} + +export default UsageOverviewActions; diff --git a/static/gsApp/views/subscriptionPage/usageOverview.spec.tsx b/static/gsApp/views/subscriptionPage/usageOverview/index.spec.tsx similarity index 100% rename from static/gsApp/views/subscriptionPage/usageOverview.spec.tsx rename to static/gsApp/views/subscriptionPage/usageOverview/index.spec.tsx diff --git a/static/gsApp/views/subscriptionPage/usageOverview.tsx b/static/gsApp/views/subscriptionPage/usageOverview/index.tsx similarity index 96% rename from static/gsApp/views/subscriptionPage/usageOverview.tsx rename to static/gsApp/views/subscriptionPage/usageOverview/index.tsx index b89298e1d4d598..225ae56dd5fc47 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/index.tsx @@ -5,7 +5,6 @@ import moment from 'moment-timezone'; import {Tag} from 'sentry/components/core/badge/tag'; import {Button} from 'sentry/components/core/button'; -import {LinkButton} from 'sentry/components/core/button/linkButton'; import {Container, Flex} from 'sentry/components/core/layout'; import {Heading, Text} from 'sentry/components/core/text'; import type {TextProps} from 'sentry/components/core/text/text'; @@ -13,13 +12,7 @@ import useDrawer from 'sentry/components/globalDrawer'; import QuestionTooltip from 'sentry/components/questionTooltip'; import type {GridColumnOrder} from 'sentry/components/tables/gridEditable'; import GridEditable from 'sentry/components/tables/gridEditable'; -import { - IconChevron, - IconDownload, - IconGraph, - IconLightning, - IconLock, -} from 'sentry/icons'; +import {IconChevron, IconLightning, IconLock} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import type {DataCategory} from 'sentry/types/core'; import type {Organization} from 'sentry/types/organization'; @@ -40,7 +33,6 @@ import { UNLIMITED, UNLIMITED_RESERVED, } from 'getsentry/constants'; -import {useCurrentBillingHistory} from 'getsentry/hooks/useCurrentBillingHistory'; import { AddOnCategory, OnDemandBudgetMode, @@ -69,6 +61,7 @@ import { import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics'; import {displayPriceWithCents, getBucket} from 'getsentry/views/amCheckout/utils'; import CategoryUsageDrawer from 'getsentry/views/subscriptionPage/components/categoryUsageDrawer'; +import UsageOverviewActions from 'getsentry/views/subscriptionPage/usageOverview/actions'; import {EMPTY_STAT_TOTAL} from 'getsentry/views/subscriptionPage/usageTotals'; interface UsageOverviewProps { @@ -798,7 +791,6 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi function UsageOverview({subscription, organization, usageData}: UsageOverviewProps) { const hasBillingPerms = organization.access.includes('org:billing'); const {isCollapsed: navIsCollapsed, layout} = useNavContext(); - const {currentHistory, isPending, isError} = useCurrentBillingHistory(); const startDate = moment(subscription.onDemandPeriodStart); const endDate = moment(subscription.onDemandPeriodEnd); const startsAndEndsSameYear = startDate.year() === endDate.year(); @@ -833,34 +825,7 @@ function UsageOverview({subscription, organization, usageData}: UsageOverviewPro {`${startDate.format(startsAndEndsSameYear ? 'MMM D' : 'MMM D, YYYY')} - ${endDate.format('MMM D, YYYY')}`} - {hasBillingPerms && ( - - } - aria-label={t('View usage history')} - priority="link" - to="/settings/billing/usage/" - > - {t('View usage history')} - - - - )} + {hasBillingPerms && } Date: Fri, 28 Nov 2025 14:11:05 -0500 Subject: [PATCH 5/9] update tests + tweak style --- .../gsApp/views/subscriptionPage/usageOverview/index.spec.tsx | 4 ++-- static/gsApp/views/subscriptionPage/usageOverview/index.tsx | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/static/gsApp/views/subscriptionPage/usageOverview/index.spec.tsx b/static/gsApp/views/subscriptionPage/usageOverview/index.spec.tsx index bc97d9759fdefe..7a1e2e0d4e1ad1 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/index.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/index.spec.tsx @@ -58,7 +58,7 @@ describe('UsageOverview', () => { expect( screen.getByRole('columnheader', {name: 'Pay-as-you-go spend'}) ).toBeInTheDocument(); - expect(screen.getByRole('button', {name: 'View usage history'})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'View all usage'})).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Download as CSV'})).toBeInTheDocument(); expect(screen.getAllByRole('row', {name: /^View .+ usage$/i}).length).toBeGreaterThan( 0 @@ -85,7 +85,7 @@ describe('UsageOverview', () => { screen.getByRole('columnheader', {name: 'Pay-as-you-go spend'}) ).toBeInTheDocument(); expect( - screen.queryByRole('button', {name: 'View usage history'}) + screen.queryByRole('button', {name: 'View all usage'}) ).not.toBeInTheDocument(); expect( screen.queryByRole('button', {name: 'Download as CSV'}) diff --git a/static/gsApp/views/subscriptionPage/usageOverview/index.tsx b/static/gsApp/views/subscriptionPage/usageOverview/index.tsx index 225ae56dd5fc47..a8ef6bb5c705ae 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/index.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/index.tsx @@ -815,7 +815,6 @@ function UsageOverview({subscription, organization, usageData}: UsageOverviewPro align={{xs: 'start', sm: 'center'}} padding="lg xl" gap="xl" - direction={{xs: 'column', sm: 'row'}} > From f7623fe103257d4b7ae29757582ee6bc1d5e97ce Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Fri, 28 Nov 2025 14:18:12 -0500 Subject: [PATCH 6/9] also add this --- static/gsApp/views/subscriptionPage/overview.tsx | 11 +++++------ .../views/subscriptionPage/subscriptionHeader.tsx | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/static/gsApp/views/subscriptionPage/overview.tsx b/static/gsApp/views/subscriptionPage/overview.tsx index 79789a1fd6a7c2..afd9658f9b1efe 100644 --- a/static/gsApp/views/subscriptionPage/overview.tsx +++ b/static/gsApp/views/subscriptionPage/overview.tsx @@ -146,7 +146,7 @@ function Overview({location, subscription, promotionData}: Props) { // Whilst self-serve accounts do. if (!hasBillingPerms && !subscription.canSelfServe) { return ( - + ); @@ -311,10 +311,9 @@ function Overview({location, subscription, promotionData}: Props) { {t('Having trouble?')} @@ -410,7 +409,7 @@ function Overview({location, subscription, promotionData}: Props) { return ( ) : ( - + {hasBillingPerms ? contentWithBillingPerms(usage, subscription.planDetails) : contentWithoutBillingPerms(usage)} diff --git a/static/gsApp/views/subscriptionPage/subscriptionHeader.tsx b/static/gsApp/views/subscriptionPage/subscriptionHeader.tsx index 3fecb67e9b2d39..197b5b944414cc 100644 --- a/static/gsApp/views/subscriptionPage/subscriptionHeader.tsx +++ b/static/gsApp/views/subscriptionPage/subscriptionHeader.tsx @@ -229,7 +229,7 @@ function SubscriptionHeader(props: Props) { - + Date: Fri, 28 Nov 2025 14:35:18 -0500 Subject: [PATCH 7/9] feat(usage overview): Extract drawer charts into component --- .../components/categoryUsageDrawer.tsx | 124 ++------------- .../subscriptionPage/usageOverview/charts.tsx | 146 ++++++++++++++++++ .../subscriptionPage/usageOverview/index.tsx | 7 +- .../subscriptionPage/usageOverview/types.tsx | 15 ++ .../subscriptionPage/usageTotalsTable.tsx | 8 +- 5 files changed, 173 insertions(+), 127 deletions(-) create mode 100644 static/gsApp/views/subscriptionPage/usageOverview/charts.tsx create mode 100644 static/gsApp/views/subscriptionPage/usageOverview/types.tsx diff --git a/static/gsApp/views/subscriptionPage/components/categoryUsageDrawer.tsx b/static/gsApp/views/subscriptionPage/components/categoryUsageDrawer.tsx index c240cd0d961f0b..5ebd579ca36644 100644 --- a/static/gsApp/views/subscriptionPage/components/categoryUsageDrawer.tsx +++ b/static/gsApp/views/subscriptionPage/components/categoryUsageDrawer.tsx @@ -1,110 +1,31 @@ -import OptionSelector from 'sentry/components/charts/optionSelector'; -import {ChartControls, InlineContainer} from 'sentry/components/charts/styles'; import {Container, Flex} from 'sentry/components/core/layout'; import {DrawerBody, DrawerHeader} from 'sentry/components/globalDrawer/components'; -import {t} from 'sentry/locale'; -import {DataCategory} from 'sentry/types/core'; -import {useLocation} from 'sentry/utils/useLocation'; -import {useNavigate} from 'sentry/utils/useNavigate'; import useOrganization from 'sentry/utils/useOrganization'; -import {CHART_OPTIONS_DATA_TRANSFORM} from 'sentry/views/organizationStats/usageChart'; import {useProductBillingMetadata} from 'getsentry/hooks/useProductBillingMetadata'; import { - PlanTier, type BillingMetricHistory, - type BillingStats, - type BillingStatTotal, + type CustomerUsage, type Subscription, } from 'getsentry/types'; -import {addBillingStatTotals, isAm2Plan} from 'getsentry/utils/billing'; -import { - getChunkCategoryFromDuration, - isContinuousProfiling, -} from 'getsentry/utils/dataCategory'; -import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics'; -import { - ProductUsageChart, - selectedTransform, -} from 'getsentry/views/subscriptionPage/reservedUsageChart'; -import {EMPTY_STAT_TOTAL} from 'getsentry/views/subscriptionPage/usageTotals'; -import UsageTotalsTable from 'getsentry/views/subscriptionPage/usageTotalsTable'; +import UsageCharts from 'getsentry/views/subscriptionPage/usageOverview/charts'; interface CategoryUsageDrawerProps { categoryInfo: BillingMetricHistory; - eventTotals: Record; - periodEnd: string; - periodStart: string; - stats: BillingStats; subscription: Subscription; - totals: BillingStatTotal; + usageData: CustomerUsage; } function CategoryUsageDrawer({ categoryInfo, - stats, - totals, - eventTotals, subscription, - periodStart, - periodEnd, + usageData, }: CategoryUsageDrawerProps) { const organization = useOrganization(); - const navigate = useNavigate(); - const location = useLocation(); - const transform = selectedTransform(location); - const {category, usage: billedUsage} = categoryInfo; + const {category} = categoryInfo; // XXX(isabella): using this to make knip happy til the hook is used in other places const {displayName} = useProductBillingMetadata(subscription, category); - const usageStats = { - [category]: stats, - }; - - const adjustedTotals = isContinuousProfiling(category) - ? { - ...addBillingStatTotals(totals, [ - eventTotals[getChunkCategoryFromDuration(category)] ?? EMPTY_STAT_TOTAL, - !isAm2Plan(subscription.plan) && category === DataCategory.PROFILE_DURATION - ? (eventTotals[DataCategory.PROFILES] ?? EMPTY_STAT_TOTAL) - : EMPTY_STAT_TOTAL, - ]), - accepted: billedUsage, - } - : {...totals, accepted: billedUsage}; - - const renderFooter = () => { - return ( - - - { - trackGetsentryAnalytics( - 'subscription_page.usage_overview.transform_changed', - { - organization, - subscription, - transform: val, - } - ); - navigate({ - pathname: location.pathname, - query: {...location.query, transform: val}, - }); - }} - /> - - - ); - }; - - const showEventBreakdown = - organization.features.includes('profiling-billing') && - subscription.planTier === PlanTier.AM2 && - category === DataCategory.TRANSACTIONS; return ( @@ -112,39 +33,12 @@ function CategoryUsageDrawer({ {displayName} - - - - {showEventBreakdown && - Object.entries(eventTotals).map(([key, eventTotal]) => { - return ( - - ); - })} - ); diff --git a/static/gsApp/views/subscriptionPage/usageOverview/charts.tsx b/static/gsApp/views/subscriptionPage/usageOverview/charts.tsx new file mode 100644 index 00000000000000..05315e70fc0f8f --- /dev/null +++ b/static/gsApp/views/subscriptionPage/usageOverview/charts.tsx @@ -0,0 +1,146 @@ +import {Container, Flex} from '@sentry/scraps/layout'; + +import OptionSelector from 'sentry/components/charts/optionSelector'; +import {ChartControls, InlineContainer} from 'sentry/components/charts/styles'; +import {t} from 'sentry/locale'; +import {DataCategory} from 'sentry/types/core'; +import {useLocation} from 'sentry/utils/useLocation'; +import {useNavigate} from 'sentry/utils/useNavigate'; +import {CHART_OPTIONS_DATA_TRANSFORM} from 'sentry/views/organizationStats/usageChart'; + +import {PlanTier} from 'getsentry/types'; +import {addBillingStatTotals, checkIsAddOn, isAm2Plan} from 'getsentry/utils/billing'; +import { + getCategoryInfoFromPlural, + getChunkCategoryFromDuration, + isContinuousProfiling, +} from 'getsentry/utils/dataCategory'; +import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics'; +import { + ProductUsageChart, + selectedTransform, +} from 'getsentry/views/subscriptionPage/reservedUsageChart'; +import type {BreakdownPanelProps} from 'getsentry/views/subscriptionPage/usageOverview/types'; +import {EMPTY_STAT_TOTAL} from 'getsentry/views/subscriptionPage/usageTotals'; +import UsageTotalsTable from 'getsentry/views/subscriptionPage/usageTotalsTable'; + +function UsageCharts({ + selectedProduct, + usageData, + subscription, + organization, +}: BreakdownPanelProps) { + const navigate = useNavigate(); + const location = useLocation(); + const transform = selectedTransform(location); + if (checkIsAddOn(selectedProduct)) { + return null; + } + + const category = selectedProduct as DataCategory; + const metricHistory = subscription.categories[category]; + const categoryInfo = getCategoryInfoFromPlural(category); + + if (!metricHistory || !categoryInfo) { + return null; + } + + const {tallyType} = categoryInfo; + if (tallyType === 'seat') { + return null; + } + + const {usage: billedUsage} = metricHistory; + const stats = usageData.stats[category] ?? []; + const eventTotals = usageData.eventTotals?.[category] ?? {}; + const totals = usageData.totals[category] ?? EMPTY_STAT_TOTAL; + const usageStats = { + [category]: stats, + }; + + const adjustedTotals = isContinuousProfiling(category) + ? { + ...addBillingStatTotals(totals, [ + eventTotals[getChunkCategoryFromDuration(category)] ?? EMPTY_STAT_TOTAL, + !isAm2Plan(subscription.plan) && + selectedProduct === DataCategory.PROFILE_DURATION + ? (eventTotals[DataCategory.PROFILES] ?? EMPTY_STAT_TOTAL) + : EMPTY_STAT_TOTAL, + ]), + accepted: billedUsage, + } + : {...totals, accepted: billedUsage}; + + const showEventBreakdown = + organization.features.includes('profiling-billing') && + subscription.planTier === PlanTier.AM2 && + category === DataCategory.TRANSACTIONS; + + const renderFooter = () => { + return ( + + + { + trackGetsentryAnalytics( + 'subscription_page.usage_overview.transform_changed', + { + organization, + subscription, + transform: val, + } + ); + navigate({ + pathname: location.pathname, + query: {...location.query, transform: val}, + }); + }} + /> + + + ); + }; + + return ( + + + + + {showEventBreakdown && + Object.entries(eventTotals).map(([key, eventTotal]) => { + return ( + + ); + })} + + + ); +} + +export default UsageCharts; diff --git a/static/gsApp/views/subscriptionPage/usageOverview/index.tsx b/static/gsApp/views/subscriptionPage/usageOverview/index.tsx index a8ef6bb5c705ae..edaf1ef732b1c5 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/index.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/index.tsx @@ -62,7 +62,6 @@ import trackGetsentryAnalytics from 'getsentry/utils/trackGetsentryAnalytics'; import {displayPriceWithCents, getBucket} from 'getsentry/views/amCheckout/utils'; import CategoryUsageDrawer from 'getsentry/views/subscriptionPage/components/categoryUsageDrawer'; import UsageOverviewActions from 'getsentry/views/subscriptionPage/usageOverview/actions'; -import {EMPTY_STAT_TOTAL} from 'getsentry/views/subscriptionPage/usageTotals'; interface UsageOverviewProps { organization: Organization; @@ -182,12 +181,8 @@ function UsageOverviewTable({subscription, organization, usageData}: UsageOvervi () => ( ), { diff --git a/static/gsApp/views/subscriptionPage/usageOverview/types.tsx b/static/gsApp/views/subscriptionPage/usageOverview/types.tsx new file mode 100644 index 00000000000000..eb6ba2776cf849 --- /dev/null +++ b/static/gsApp/views/subscriptionPage/usageOverview/types.tsx @@ -0,0 +1,15 @@ +import type {DataCategory} from 'sentry/types/core'; +import type {Organization} from 'sentry/types/organization'; + +import type {AddOnCategory, CustomerUsage, Subscription} from 'getsentry/types'; + +export interface UsageOverviewProps { + organization: Organization; + subscription: Subscription; + usageData: CustomerUsage; +} + +export interface BreakdownPanelProps extends UsageOverviewProps { + selectedProduct: DataCategory | AddOnCategory; + isInline?: boolean; +} diff --git a/static/gsApp/views/subscriptionPage/usageTotalsTable.tsx b/static/gsApp/views/subscriptionPage/usageTotalsTable.tsx index 04d08c2ab1d7cf..2fcb7ab6c62427 100644 --- a/static/gsApp/views/subscriptionPage/usageTotalsTable.tsx +++ b/static/gsApp/views/subscriptionPage/usageTotalsTable.tsx @@ -310,12 +310,7 @@ function UsageTotalsTable({category, isEventBreakdown, totals, subscription}: Pr const hasSpikeProtection = categoryInfo?.hasSpikeProtection ?? false; return ( - + {isNewBillingUI && ( Date: Fri, 28 Nov 2025 14:40:42 -0500 Subject: [PATCH 8/9] rm unused exported type --- static/gsApp/views/subscriptionPage/usageOverview/types.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/gsApp/views/subscriptionPage/usageOverview/types.tsx b/static/gsApp/views/subscriptionPage/usageOverview/types.tsx index eb6ba2776cf849..57ff4cc91b2dc3 100644 --- a/static/gsApp/views/subscriptionPage/usageOverview/types.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/types.tsx @@ -3,7 +3,7 @@ import type {Organization} from 'sentry/types/organization'; import type {AddOnCategory, CustomerUsage, Subscription} from 'getsentry/types'; -export interface UsageOverviewProps { +interface UsageOverviewProps { organization: Organization; subscription: Subscription; usageData: CustomerUsage; From ab127d34dc18d59938fa52d0197788fe01f98af6 Mon Sep 17 00:00:00 2001 From: isabellaenriquez Date: Fri, 28 Nov 2025 15:13:19 -0500 Subject: [PATCH 9/9] update tests --- .../charts.spec.tsx} | 54 ++++++++++++------- 1 file changed, 34 insertions(+), 20 deletions(-) rename static/gsApp/views/subscriptionPage/{components/categoryUsageDrawer.spec.tsx => usageOverview/charts.spec.tsx} (76%) diff --git a/static/gsApp/views/subscriptionPage/components/categoryUsageDrawer.spec.tsx b/static/gsApp/views/subscriptionPage/usageOverview/charts.spec.tsx similarity index 76% rename from static/gsApp/views/subscriptionPage/components/categoryUsageDrawer.spec.tsx rename to static/gsApp/views/subscriptionPage/usageOverview/charts.spec.tsx index b7e0754669f0d4..0a61eb83f632a6 100644 --- a/static/gsApp/views/subscriptionPage/components/categoryUsageDrawer.spec.tsx +++ b/static/gsApp/views/subscriptionPage/usageOverview/charts.spec.tsx @@ -1,6 +1,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {BillingStatFixture} from 'getsentry-test/fixtures/billingStat'; +import {CustomerUsageFixture} from 'getsentry-test/fixtures/customerUsage'; import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription'; import {UsageTotalFixture} from 'getsentry-test/fixtures/usageTotal'; import {act, render, screen} from 'sentry-test/reactTestingLibrary'; @@ -10,10 +11,10 @@ import {OrganizationContext} from 'sentry/views/organizationContext'; import SubscriptionStore from 'getsentry/stores/subscriptionStore'; import {PlanTier} from 'getsentry/types'; +import UsageCharts from 'getsentry/views/subscriptionPage/usageOverview/charts'; +import type {BreakdownPanelProps} from 'getsentry/views/subscriptionPage/usageOverview/types'; -import CategoryUsageDrawer from './categoryUsageDrawer'; - -describe('CategoryUsageDrawer', () => { +describe('UsageCharts', () => { const organization = OrganizationFixture(); const totals = UsageTotalFixture({ accepted: 50, @@ -28,10 +29,10 @@ describe('CategoryUsageDrawer', () => { organization.features.push('subscriptions-v3'); }); - function renderComponent(props: any) { + function renderComponent(props: Omit) { return render( - + ); } @@ -45,15 +46,19 @@ describe('CategoryUsageDrawer', () => { usage: 50, }; SubscriptionStore.set(organization.slug, subscription); + const usageData = CustomerUsageFixture({ + totals: { + [DataCategory.ERRORS]: totals, + }, + stats: { + [DataCategory.ERRORS]: stats, + }, + }); await act(async () => { renderComponent({ subscription, - categoryInfo: subscription.categories.errors, - eventTotals: {[DataCategory.ERRORS]: totals}, - totals, - stats, - periodEnd: '2021-02-01', - periodStart: '2021-01-01', + usageData, + selectedProduct: DataCategory.ERRORS, }); // filter values are asynchronously persisted @@ -81,18 +86,27 @@ describe('CategoryUsageDrawer', () => { usage: 50, }; SubscriptionStore.set(organization.slug, subscription); - await act(async () => { - renderComponent({ - subscription, - categoryInfo: subscription.categories.transactions, - eventTotals: { + const usageData = CustomerUsageFixture({ + totals: { + [DataCategory.TRANSACTIONS]: totals, + [DataCategory.PROFILES]: totals, + }, + stats: { + [DataCategory.TRANSACTIONS]: stats, + [DataCategory.PROFILES]: stats, + }, + eventTotals: { + [DataCategory.TRANSACTIONS]: { [DataCategory.TRANSACTIONS]: totals, [DataCategory.PROFILES]: totals, }, - totals, - stats, - periodEnd: '2021-02-01', - periodStart: '2021-01-01', + }, + }); + await act(async () => { + renderComponent({ + subscription, + selectedProduct: DataCategory.TRANSACTIONS, + usageData, }); await tick(); });