import {Fragment, useState} from 'react'; import styled from '@emotion/styled'; import colorFn from 'color'; import {Button} from 'sentry/components/button'; import Card from 'sentry/components/card'; import {CHART_PALETTE} from 'sentry/constants/chartPalette'; import {IconChevron} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {DataCategory} from 'sentry/types/core'; import type {Organization} from 'sentry/types/organization'; import getDaysSinceDate from 'sentry/utils/getDaysSinceDate'; import {formatPercentage} from 'sentry/utils/number/formatPercentage'; import ProductTrialTag from 'getsentry/components/productTrial/productTrialTag'; import StartTrialButton from 'getsentry/components/startTrialButton'; import {GIGABYTE, RESERVED_BUDGET_QUOTA, UNLIMITED} from 'getsentry/constants'; import { type BillingMetricHistory, type BillingStatTotal, type EventBucket, PlanTier, type ProductTrial, type Subscription, } from 'getsentry/types'; import { formatReservedWithUnits, formatUsageWithUnits, getActiveProductTrial, getPotentialProductTrial, isUnlimitedReserved, MILLISECONDS_IN_HOUR, } from 'getsentry/utils/billing'; import {getPlanCategoryName} from 'getsentry/utils/dataCategory'; import formatCurrency from 'getsentry/utils/formatCurrency'; import {roundUpToNearestDollar} from 'getsentry/utils/roundUpToNearestDollar'; import titleCase from 'getsentry/utils/titleCase'; import {getBucket} from 'getsentry/views/amCheckout/utils'; import { getOnDemandBudget, hasOnDemandBudgetsFeature, parseOnDemandBudgetsFromSubscription, } from 'getsentry/views/onDemandBudgets/utils'; import UsageTotalsTable from 'getsentry/views/subscriptionPage/usageTotalsTable'; import { calculateCategorySpend, calculateTotalSpend, } from 'getsentry/views/subscriptionPage/utils'; const EMPTY_STAT_TOTAL = { accepted: 0, dropped: 0, droppedOther: 0, droppedOverQuota: 0, droppedSpikeProtection: 0, filtered: 0, projected: 0, }; const COLORS = { reserved: CHART_PALETTE[5]![0]!, ondemand: CHART_PALETTE[5]![1]!, } as const; function getPercentage(quantity: number, total: number | null) { if (typeof total === 'number' && total > 0) { return (Math.min(quantity, total) / total) * 100; } return 0; } export function displayPercentage(quantity: number, total: number | null) { const percentage = getPercentage(quantity, total); return percentage.toFixed(0) + '%'; } type UsageProps = { /** * The data category to display */ category: string; displayMode: 'usage' | 'cost'; organization: Organization; subscription: Subscription; /** * Do not allow the table to be expansded */ disableTable?: boolean; /** * Event breakdown totals */ eventTotals?: {[key: string]: BillingStatTotal}; /** * Gifted budget for the current billing period. */ freeBudget?: number | null; /** * Gifted events for the current billing period. */ freeUnits?: number; /** * The prepaid budget (reserved + gifted) if any */ prepaidBudget?: number | null; /** * Total events allowed for the current usage period including gifted */ prepaidUnits?: number; /** * The reserved budget if any */ reservedBudget?: number | null; /** * The reserved spend if any */ reservedSpend?: number | null; /** * The reserved amount or null if the account doesn't have this category. */ reservedUnits?: number | null; /** * Show event breakdown */ showEventBreakdown?: boolean; /** * If soft cap is enabled, the type of soft cap in use: true forward or on-demand */ softCapType?: 'ON_DEMAND' | 'TRUE_FORWARD' | null; /** * Usage totals. */ totals?: BillingStatTotal; /** * Whether this category has True Forward */ trueForward?: boolean; }; type State = {expanded: boolean; trialButtonBusy: boolean}; /** * Calculates usage metrics for a subscription category's prepaid (reserved) events. * * @param category - The data category to calculate usage for (e.g. 'errors', 'transactions') * @param subscription - The subscription object containing plan and usage details * @param totals - Object containing the accepted event count for this category * @param prepaid - The prepaid/reserved event limit (volume-based reserved) or commited spend (budget-based reserved) for this category * @param reservedCpe - The reserved cost-per-event for this category (for reserved budget categories), in cents * @param reservedSpend - The reserved spend for this category (for reserved budget categories). If provided, calculations with `totals` and `reservedCpe` are overriden to use the number provided for `prepaidSpend` * * @returns Object containing: * - onDemandUsage: Number of events that exceeded the prepaid limit and went to on-demand * - prepaidPercentUsed: Percentage of prepaid limit used (0-100) * - prepaidPrice: Monthly cost of the prepaid events (reserved budget if it is a reserved budget category) * - prepaidSpend: Cost of prepaid events used so far this period * - prepaidUsage: Number of events used within prepaid limit */ export function calculateCategoryPrepaidUsage( category: string, subscription: Subscription, totals: Pick, prepaid: number, reservedCpe?: number | null, reservedSpend?: number | null ): { onDemandUsage: number; prepaidPercentUsed: number; prepaidPrice: number; /** * Total category spend this period */ prepaidSpend: number; prepaidUsage: number; } { // Calculate the prepaid total let prepaidTotal: any; if (isUnlimitedReserved(prepaid)) { prepaidTotal = prepaid; } else { // Convert prepaid limits to the appropriate unit based on category switch (category) { case DataCategory.ATTACHMENTS: prepaidTotal = prepaid * GIGABYTE; break; case DataCategory.PROFILE_DURATION: prepaidTotal = prepaid * MILLISECONDS_IN_HOUR; break; default: prepaidTotal = prepaid; } } const hasReservedBudget = reservedCpe || typeof reservedSpend === 'number'; // reservedSpend can be 0 const prepaidUsed = hasReservedBudget ? (reservedSpend ?? totals.accepted * (reservedCpe ?? 0)) : totals.accepted; const prepaidPercentUsed = getPercentage(prepaidUsed, prepaidTotal); // Calculate the prepaid price // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message const categoryInfo: BillingMetricHistory = subscription.categories[category]; // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message const slots: EventBucket[] = subscription.planDetails.planCategories[category]; // If the category billing info is not in the subscription, return 0 for all values // This seems to happen sometimes on partner accounts if (!categoryInfo || !slots) { return { prepaidPrice: 0, prepaidSpend: 0, prepaidPercentUsed: 0, onDemandUsage: 0, prepaidUsage: 0, }; } // Get the price bucket for the reserved event amount const prepaidPriceBucket = getBucket({events: categoryInfo.reserved!, buckets: slots}); // Convert annual prices to monthly if needed const isMonthly = subscription.planDetails.billingInterval === 'monthly'; // This will be 0 when they are using the included amount const prepaidPrice = hasReservedBudget ? prepaid : (prepaidPriceBucket.price ?? 0) / (isMonthly ? 1 : 12); // Calculate spend based on percentage used const prepaidSpend = (prepaidPercentUsed / 100) * prepaidPrice; // Round the usage width to avoid half pixel artifacts const prepaidPercentUsedRounded = Math.round(prepaidPercentUsed); // Calculate on-demand usage if we've exceeded prepaid limit // No on-demand usage for unlimited reserved const onDemandUsage = (prepaidUsed > prepaidTotal && !isUnlimitedReserved(prepaidTotal)) || (hasReservedBudget && prepaidUsed >= prepaidTotal) ? categoryInfo.onDemandQuantity : 0; const prepaidUsage = totals.accepted - onDemandUsage; return { prepaidPrice, prepaidSpend, prepaidPercentUsed: prepaidPercentUsedRounded, onDemandUsage, prepaidUsage, }; } export function calculateCategoryOnDemandUsage( category: string, subscription: Subscription ): { /** * The maximum amount of on demand spend allowed for this category * This can be shared across all categories or specific to this category. * Other categories may have spent some of this budget making less avilable for this category. */ onDemandCategoryMax: number; onDemandCategorySpend: number; /** * Will be the total on demand spend available for all categories if shared * or the total available for this category if not shared. */ onDemandTotalAvailable: number; ondemandPercentUsed: number; } { const onDemandBudgets = parseOnDemandBudgetsFromSubscription(subscription); const isSharedOnDemand = 'sharedMaxBudget' in onDemandBudgets; const onDemandTotalAvailable = isSharedOnDemand ? onDemandBudgets.sharedMaxBudget : getOnDemandBudget(onDemandBudgets, category as DataCategory); const {onDemandTotalSpent} = calculateTotalSpend(subscription); const {onDemandSpent: onDemandCategorySpend} = calculateCategorySpend( subscription, category ); const onDemandCategoryMax = isSharedOnDemand ? // Subtract other category spend from shared on demand budget onDemandTotalAvailable - onDemandTotalSpent + onDemandCategorySpend : onDemandTotalAvailable; // Round the usage width to avoid half pixel artifacts const ondemandPercentUsed = Math.round( getPercentage(onDemandCategorySpend, onDemandCategoryMax) ); return { onDemandTotalAvailable, onDemandCategorySpend, onDemandCategoryMax, ondemandPercentUsed, }; } function ReservedUsage({ prepaidUsage, reserved, category, productTrial, }: { category: string; prepaidUsage: number; productTrial: ProductTrial | null; reserved: number | null; }) { const reservedOptions = { isAbbreviated: category !== DataCategory.ATTACHMENTS, }; return ( {formatUsageWithUnits(prepaidUsage, category, { isAbbreviated: true, })}{' '} of{' '} {productTrial?.isStarted && getDaysSinceDate(productTrial.endDate ?? '') <= 0 ? UNLIMITED : formatReservedWithUnits(reserved, category, reservedOptions)} ); } function UsageTotals({ category, subscription, organization, freeUnits = 0, prepaidUnits = 0, reservedUnits = null, freeBudget = null, prepaidBudget = null, reservedBudget = null, reservedSpend = null, softCapType = null, totals = EMPTY_STAT_TOTAL, eventTotals = {}, trueForward = false, showEventBreakdown = false, disableTable, displayMode, }: UsageProps) { const [state, setState] = useState({expanded: false, trialButtonBusy: false}); const usageOptions = {useUnitScaling: true}; const reservedOptions = { isAbbreviated: category !== DataCategory.ATTACHMENTS, }; const hasReservedBudget = reservedUnits === RESERVED_BUDGET_QUOTA; const free = hasReservedBudget ? freeBudget : freeUnits; const reserved = hasReservedBudget ? reservedBudget : reservedUnits; const prepaid = hasReservedBudget ? (prepaidBudget ?? 0) : prepaidUnits; const displayGifts = (free || freeBudget) && !isUnlimitedReserved(reservedUnits); const reservedTestId = displayGifts ? `gifted-${category}` : `reserved-${category}`; const hasOnDemand = hasOnDemandBudgetsFeature(organization, subscription) || subscription.planTier === PlanTier.AM3; const onDemandBudgets = parseOnDemandBudgetsFromSubscription(subscription); const totalMaxOndemandBudget = 'sharedMaxBudget' in onDemandBudgets ? onDemandBudgets.sharedMaxBudget : getOnDemandBudget(onDemandBudgets, category as DataCategory); const {onDemandSpent: categoryOnDemandSpent, onDemandUnitPrice} = calculateCategorySpend(subscription, category); function getReservedInfo() { let reservedInfo = tct('[reserved] Reserved', { reserved: formatReservedWithUnits( reserved, category, reservedOptions, hasReservedBudget ), }); if (softCapType) { const softCapName = titleCase(softCapType.replace(/_/g, ' ')); reservedInfo = tct('[reservedInfo] ([softCapName])', {reservedInfo, softCapName}); } // Fallback if softCapType was not set but True Forward is else if (trueForward) { reservedInfo = tct('[reservedInfo] (True Forward)', {reservedInfo}); } if (displayGifts) { reservedInfo = tct('[reservedInfo] + [giftedAmount] Gifted', { reservedInfo, giftedAmount: formatReservedWithUnits( free, category, reservedOptions, hasReservedBudget ), }); } return reservedInfo; } const productTrial = getActiveProductTrial(subscription.productTrials ?? null, category as DataCategory) ?? getPotentialProductTrial( subscription.productTrials ?? null, category as DataCategory ); const { ondemandPercentUsed, onDemandTotalAvailable, onDemandCategorySpend, onDemandCategoryMax, } = calculateCategoryOnDemandUsage(category, subscription); const unusedOnDemandWidth = 100 - ondemandPercentUsed; const {prepaidPrice, prepaidPercentUsed, prepaidUsage, onDemandUsage} = calculateCategoryPrepaidUsage( category, subscription, totals, prepaid, undefined, reservedSpend ); const unusedPrepaidWidth = reserved !== 0 || subscription.isTrial ? 100 - prepaidPercentUsed : 0; const totalCategorySpend = (hasReservedBudget ? (reservedSpend ?? 0) : prepaidPrice) + categoryOnDemandSpent; // Shared on demand spend is gone, another category has spent all of it // It is confusing to show on demand spend when the category did not spend any and the budget is gone const onDemandIsGoneAndCategorySpentNone = 'sharedMaxBudget' in onDemandBudgets && categoryOnDemandSpent === 0 && onDemandCategoryMax === 0; // Don't show on demand when: // - There is none left to spend and this category spent 0 // - There is no on demand budget for this category // - There is no on demand budget at all const showOnDemand = !onDemandIsGoneAndCategorySpentNone && hasOnDemand && totalMaxOndemandBudget !== 0; const isDisplayingSpend = displayMode === 'cost' || hasReservedBudget; // always display as spend for reserved budgets // Calculate the width of the reserved bar relative to on demand let reservedMaxWidth = showOnDemand ? (reserved !== 0 ? 50 : 0) : 100; if (showOnDemand && reserved && onDemandUnitPrice) { const onDemandTotalUnitsAvailable = onDemandCategoryMax / onDemandUnitPrice; reservedMaxWidth = showOnDemand && reserved ? (reserved / (reserved + onDemandTotalUnitsAvailable)) * 100 : 100; } function getTitle(): React.ReactNode { if (productTrial?.isStarted) { return t('trial usage this period'); } if (isDisplayingSpend) { return t('spend this period'); } return t('usage this period'); } const formattedUnitsUsed = formatUsageWithUnits( totals.accepted, category, usageOptions ); return (
{getPlanCategoryName({ plan: subscription.planDetails, category, hadCustomDynamicSampling: subscription.hadCustomDynamicSampling, })}{' '} {getTitle()} {productTrial && ( )} {productTrial?.isStarted && getDaysSinceDate(productTrial.endDate ?? '') <= 0 ? UNLIMITED : getReservedInfo()}
{productTrial && !productTrial.isStarted && ( { setState({...state, trialButtonBusy: true}); }} onTrialStarted={() => { setState({...state, trialButtonBusy: true}); }} onTrialFailed={() => { setState({...state, trialButtonBusy: false}); }} busy={state.trialButtonBusy} disabled={state.trialButtonBusy} /> )} {!disableTable && (