123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740 |
- import type {Theme} from '@emotion/react';
- import {useTheme} from '@emotion/react';
- import styled from '@emotion/styled';
- import type {Location} from 'history';
- import moment from 'moment-timezone';
- import MarkLine from 'sentry/components/charts/components/markLine';
- import {ChartTooltip} from 'sentry/components/charts/components/tooltip';
- import OptionSelector from 'sentry/components/charts/optionSelector';
- import barSeries from 'sentry/components/charts/series/barSeries';
- import lineSeries from 'sentry/components/charts/series/lineSeries';
- import {
- ChartControls,
- InlineContainer,
- SectionValue,
- } from 'sentry/components/charts/styles';
- import {DATA_CATEGORY_INFO} from 'sentry/constants';
- import {CHART_PALETTE} from 'sentry/constants/chartPalette';
- import {IconCalendar} from 'sentry/icons';
- import {t} from 'sentry/locale';
- import {DataCategory} from 'sentry/types/core';
- import type {Organization} from 'sentry/types/organization';
- import {defined} from 'sentry/utils';
- import {browserHistory} from 'sentry/utils/browserHistory';
- import {decodeScalar} from 'sentry/utils/queryString';
- import {
- type CategoryOption,
- CHART_OPTIONS_DATACATEGORY,
- type ChartStats,
- } from 'sentry/views/organizationStats/usageChart';
- import UsageChart, {
- CHART_OPTIONS_DATA_TRANSFORM,
- ChartDataTransform,
- } from 'sentry/views/organizationStats/usageChart';
- import {
- getDateFromMoment,
- getTooltipFormatter,
- } from 'sentry/views/organizationStats/usageChart/utils';
- import {GIGABYTE} from 'getsentry/constants';
- import {
- type BillingMetricHistory,
- type BillingStat,
- type BillingStats,
- type CustomerUsage,
- type Plan,
- PlanTier,
- type ReservedBudgetForCategory,
- type Subscription,
- } from 'getsentry/types';
- import {formatReservedWithUnits, isUnlimitedReserved} from 'getsentry/utils/billing';
- import {getPlanCategoryName, hasCategoryFeature} from 'getsentry/utils/dataCategory';
- import formatCurrency from 'getsentry/utils/formatCurrency';
- import titleCase from 'getsentry/utils/titleCase';
- import {
- calculateCategoryOnDemandUsage,
- calculateCategoryPrepaidUsage,
- } from 'getsentry/views/subscriptionPage/usageTotals';
- const USAGE_CHART_OPTIONS_DATACATEGORY = [
- ...CHART_OPTIONS_DATACATEGORY,
- {
- label: DATA_CATEGORY_INFO.spanIndexed.titleName,
- value: DATA_CATEGORY_INFO.spanIndexed.plural,
- yAxisMinInterval: 100,
- },
- ];
- /** @internal exported for tests only */
- export function getCategoryOptions({
- plan,
- hadCustomDynamicSampling,
- }: {
- hadCustomDynamicSampling: boolean;
- plan: Plan;
- }): CategoryOption[] {
- return USAGE_CHART_OPTIONS_DATACATEGORY.filter(
- opt =>
- plan.categories.includes(opt.value as DataCategory) &&
- (opt.value === DataCategory.SPANS_INDEXED ? hadCustomDynamicSampling : true)
- );
- }
- type DroppedBreakdown = {
- other: number;
- overQuota: number;
- spikeProtection: number;
- };
- interface ReservedUsageChartProps {
- displayMode: 'usage' | 'cost';
- location: Location;
- organization: Organization;
- reservedBudgetCategoryInfo: Record<string, ReservedBudgetForCategory>;
- subscription: Subscription;
- usagePeriodEnd: string;
- usagePeriodStart: string;
- usageStats: CustomerUsage['stats'];
- }
- function getCategoryColors(theme: Theme) {
- return [
- theme.outcome.accepted!,
- theme.outcome.filtered!,
- theme.outcome.dropped!,
- theme.chartOther!, // Projected
- ];
- }
- function selectedCategory(location: Location, categoryOptions: CategoryOption[]) {
- const category = decodeScalar(location.query.category) as undefined | DataCategory;
- if (!category || !categoryOptions.some(cat => cat.value === category)) {
- return DataCategory.ERRORS;
- }
- return category;
- }
- function selectedTransform(location: Location) {
- const transform = decodeScalar(location.query.transform) as
- | undefined
- | ChartDataTransform;
- if (!transform || !Object.values(ChartDataTransform).includes(transform)) {
- return ChartDataTransform.CUMULATIVE;
- }
- return transform;
- }
- function chartTooltip(category: DataCategory, displayMode: 'usage' | 'cost') {
- const tooltipValueFormatter = getTooltipFormatter(category);
- return ChartTooltip({
- // Trigger to axis prevents tooltip from redrawing when hovering
- // over individual bars
- trigger: 'axis',
- // Custom tooltip implementation as we show a breakdown for dropped results.
- formatter(series) {
- const seriesList = Array.isArray(series) ? series : [series];
- // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
- const time = seriesList[0]?.value?.[0];
- return [
- '<div class="tooltip-series">',
- seriesList
- .map(s => {
- const label = s.seriesName ?? '';
- const value =
- displayMode === 'usage'
- ? // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
- tooltipValueFormatter(s.value?.[1])
- : // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
- formatCurrency(s.value?.[1] ?? 0);
- // @ts-expect-error TS(2339): Property 'dropped' does not exist on type 'OptionD... Remove this comment to see the full error message
- const dropped = s.data.dropped as DroppedBreakdown | undefined;
- if (typeof dropped === 'undefined' || value === '0') {
- return `<div><span class="tooltip-label">${s.marker} <strong>${label}</strong></span> ${value}</div>`;
- }
- const other = tooltipValueFormatter(dropped.other);
- const overQuota = tooltipValueFormatter(dropped.overQuota);
- const spikeProtection = tooltipValueFormatter(dropped.spikeProtection);
- // Used to shift breakdown over the same amount as series markers.
- const indent = '<span style="display: inline-block; width: 15px"></span>';
- const labels = [
- `<div><span class="tooltip-label">${s.marker} <strong>${t(
- 'Dropped'
- )}</strong></span> ${value}</div>`,
- `<div><span class="tooltip-label">${indent} <strong>${t(
- 'Over Quota'
- )}</strong></span> ${overQuota}</div>`,
- `<div><span class="tooltip-label">${indent} <strong>${t(
- 'Spike Protection'
- )}</strong></span> ${spikeProtection}</div>`,
- `<div><span class="tooltip-label">${indent} <strong>${t(
- 'Other'
- )}</strong></span> ${other}</div>`,
- ];
- return labels.join('');
- })
- .join(''),
- '</div>',
- `<div class="tooltip-footer tooltip-footer-centered">${time}</div>`,
- `<div class="tooltip-arrow"></div>`,
- ].join('');
- },
- });
- }
- function mapReservedToChart(reserved: number | null, category: string) {
- if (isUnlimitedReserved(reserved)) {
- return 0;
- }
- if (category === DataCategory.ATTACHMENTS) {
- return typeof reserved === 'number' ? reserved * GIGABYTE : 0;
- }
- return reserved || 0;
- }
- function defaultChartData(): ChartStats {
- return {
- accepted: [],
- dropped: [],
- projected: [],
- reserved: [],
- onDemand: [],
- };
- }
- /** @internal exported for tests only */
- export function mapStatsToChart({
- stats = [],
- transform,
- }: {
- stats: BillingStats;
- transform: ChartDataTransform;
- }) {
- const isCumulative = transform === ChartDataTransform.CUMULATIVE;
- let sumAccepted = 0;
- let sumDropped = 0;
- let sumOther = 0;
- let sumOverQuota = 0;
- let sumSpikeProtection = 0;
- const chartData = defaultChartData();
- stats.forEach(stat => {
- if (!stat) {
- return;
- }
- const date = getDateFromMoment(moment(stat.date));
- const isProjected = stat.isProjected ?? true;
- const accepted = stat.accepted ?? 0;
- const dropped = stat.dropped.total ?? 0;
- sumDropped = isCumulative ? sumDropped + dropped : dropped;
- sumAccepted = isCumulative ? sumAccepted + accepted : accepted;
- if (stat.dropped.overQuota) {
- sumOverQuota = isCumulative
- ? sumOverQuota + stat.dropped.overQuota
- : stat.dropped.overQuota;
- }
- if (stat.dropped.spikeProtection) {
- sumSpikeProtection = isCumulative
- ? sumSpikeProtection + stat.dropped.spikeProtection
- : stat.dropped.spikeProtection;
- }
- sumOther = Math.max(sumDropped - sumOverQuota - sumSpikeProtection, 0);
- if (isProjected) {
- chartData.projected.push({
- value: [date, sumAccepted],
- });
- } else {
- chartData.accepted.push({
- value: [date, sumAccepted],
- });
- // TODO(ts)
- (chartData.dropped as any[]).push({
- value: [date, sumDropped],
- dropped: {
- other: sumOther,
- overQuota: sumOverQuota,
- spikeProtection: sumSpikeProtection,
- } as DroppedBreakdown,
- });
- }
- });
- return chartData;
- }
- /** @internal exported for tests only */
- export function mapCostStatsToChart({
- stats = [],
- transform,
- subscription,
- category,
- }: {
- category: string;
- stats: BillingStats;
- subscription: Subscription;
- transform: ChartDataTransform;
- }) {
- const isCumulative = transform === ChartDataTransform.CUMULATIVE;
- /**
- * On demand is already a running total, so we'll need to subtract when not cumulative.
- */
- let previousOnDemandCostRunningTotal = 0;
- let sumReserved = 0;
- const chartData = defaultChartData();
- // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
- const metricHistory = subscription.categories[category];
- const prepaid = metricHistory.prepaid ?? 0;
- stats.forEach(stat => {
- if (!stat) {
- return;
- }
- const date = getDateFromMoment(moment(stat.date));
- const isProjected = stat.isProjected ?? true;
- const accepted = stat.accepted ?? 0;
- let onDemand = 0;
- if (defined(stat.onDemandCostRunningTotal)) {
- onDemand = isCumulative
- ? stat.onDemandCostRunningTotal
- : stat.onDemandCostRunningTotal - previousOnDemandCostRunningTotal;
- previousOnDemandCostRunningTotal = stat.onDemandCostRunningTotal;
- }
- const {prepaidSpend, prepaidPrice} = calculateCategoryPrepaidUsage(
- category,
- subscription,
- {accepted},
- prepaid
- );
- sumReserved = isCumulative ? sumReserved + prepaidSpend : prepaidSpend;
- // Ensure that the reserved amount does not exceed the prepaid amount.
- sumReserved = Math.min(sumReserved, prepaidPrice);
- if (!isProjected) {
- chartData.reserved!.push({
- value: [date, sumReserved],
- });
- chartData.onDemand!.push({
- value: [date, onDemand],
- });
- }
- });
- return chartData;
- }
- /** @internal exported for tests only */
- export function mapReservedBudgetStatsToChart({
- statsByDateAndCategory = {},
- transform,
- subscription,
- reservedBudgetCategoryInfo,
- }: {
- statsByDateAndCategory: Record<string, Record<string, BillingStats>>;
- subscription: Subscription;
- transform: ChartDataTransform;
- reservedBudgetCategoryInfo?: Record<string, ReservedBudgetForCategory>;
- }) {
- const isCumulative = transform === ChartDataTransform.CUMULATIVE;
- /**
- * On demand is already a running total, so we'll need to subtract when not cumulative.
- */
- let previousOnDemandCostRunningTotal = 0;
- let sumReserved = 0;
- const chartData = defaultChartData();
- if (!reservedBudgetCategoryInfo) {
- return chartData;
- }
- Object.entries(statsByDateAndCategory).forEach(([date, statsByCategory]) => {
- let reservedForDate = 0;
- let onDemandForDate = 0;
- Object.entries(statsByCategory).forEach(([category, stats]) => {
- const prepaid = reservedBudgetCategoryInfo[category]?.prepaidBudget ?? 0;
- const reservedCpe = reservedBudgetCategoryInfo[category]?.reservedCpe ?? 0;
- stats.forEach(stat => {
- if (!stat) {
- return;
- }
- const isProjected = stat.isProjected ?? true;
- const accepted = stat.accepted ?? 0;
- let onDemand = 0;
- if (defined(stat.onDemandCostRunningTotal)) {
- onDemand = isCumulative
- ? stat.onDemandCostRunningTotal
- : stat.onDemandCostRunningTotal - previousOnDemandCostRunningTotal;
- previousOnDemandCostRunningTotal = stat.onDemandCostRunningTotal;
- }
- const {prepaidSpend, prepaidPrice} = calculateCategoryPrepaidUsage(
- category,
- subscription,
- {accepted},
- prepaid,
- reservedCpe
- );
- sumReserved = isCumulative ? sumReserved + prepaidSpend : prepaidSpend;
- sumReserved = Math.min(sumReserved, prepaidPrice);
- if (!isProjected) {
- // if cumulative, sumReserved is the prepaid amount used so far, otherwise it's the amount used for this date
- if (isCumulative) {
- reservedForDate = sumReserved;
- } else {
- reservedForDate += sumReserved;
- }
- // when cumulative, onDemand is the running total for the category
- // otherwise, onDemand is the amount used for the category for this date
- // either way we need to add them together to get the on-demand amount across the categories
- onDemandForDate += onDemand;
- }
- });
- });
- const dateKey = getDateFromMoment(moment(date));
- chartData.reserved!.push({
- value: [dateKey, reservedForDate],
- });
- chartData.onDemand!.push({
- value: [dateKey, onDemandForDate],
- });
- });
- return chartData;
- }
- function ReservedUsageChart({
- location,
- organization,
- subscription,
- usagePeriodStart,
- usagePeriodEnd,
- usageStats,
- displayMode,
- reservedBudgetCategoryInfo,
- }: ReservedUsageChartProps) {
- const theme = useTheme();
- const categoryOptions = getCategoryOptions({
- plan: subscription.planDetails,
- hadCustomDynamicSampling: subscription.hadCustomDynamicSampling,
- });
- const category = selectedCategory(location, categoryOptions);
- const transform = selectedTransform(location);
- const currentHistory: BillingMetricHistory | undefined =
- // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
- subscription.categories[category];
- const categoryStats = usageStats[category];
- const isReservedBudgetCategory =
- subscription.reservedBudgetCategories?.includes(category) ?? false;
- if (isReservedBudgetCategory) {
- displayMode = 'cost';
- }
- function chartMetadata() {
- let dataCategoryMetadata: {
- chartData: ChartStats;
- isUnlimitedQuota: boolean;
- yAxisQuotaLine: number;
- yAxisQuotaLineLabel: string;
- } = {
- isUnlimitedQuota: false,
- chartData: {
- accepted: [],
- dropped: [],
- projected: [],
- reserved: [],
- onDemand: [],
- },
- yAxisQuotaLine: 0,
- yAxisQuotaLineLabel: '',
- };
- if (categoryStats) {
- if (isReservedBudgetCategory) {
- if ([DataCategory.SPANS, DataCategory.SPANS_INDEXED].includes(category)) {
- if (subscription.hadCustomDynamicSampling) {
- const statsByDateAndCategory = categoryStats.reduce(
- (acc, stat) => {
- if (stat) {
- acc[stat.date] = {[category]: [stat]};
- }
- return acc;
- },
- {} as Record<string, Record<string, BillingStats>>
- );
- dataCategoryMetadata.chartData = mapReservedBudgetStatsToChart({
- statsByDateAndCategory,
- transform,
- subscription,
- reservedBudgetCategoryInfo,
- });
- } else {
- const otherCategory =
- category === DataCategory.SPANS
- ? DataCategory.SPANS_INDEXED
- : DataCategory.SPANS;
- const otherCategoryStats = usageStats[otherCategory] ?? [];
- const statsByCategory = {
- [category]: categoryStats,
- [otherCategory]: otherCategoryStats,
- };
- const statsByDateAndCategory = Object.entries(statsByCategory).reduce(
- (acc, [budgetCategory, stats]) => {
- stats.forEach(stat => {
- if (stat) {
- acc[stat.date] = {...acc[stat.date], [budgetCategory]: [stat]};
- }
- });
- return acc;
- },
- {} as Record<string, Record<string, BillingStats>>
- );
- dataCategoryMetadata.chartData = mapReservedBudgetStatsToChart({
- statsByDateAndCategory,
- transform,
- subscription,
- reservedBudgetCategoryInfo,
- });
- }
- }
- } else if (displayMode === 'cost') {
- dataCategoryMetadata.chartData = mapCostStatsToChart({
- stats: categoryStats,
- transform,
- category,
- subscription,
- });
- } else {
- dataCategoryMetadata.chartData = mapStatsToChart({
- stats: categoryStats,
- transform,
- });
- }
- }
- if (currentHistory) {
- dataCategoryMetadata = {
- ...dataCategoryMetadata,
- isUnlimitedQuota: isUnlimitedReserved(currentHistory.reserved),
- yAxisQuotaLine: mapReservedToChart(currentHistory.reserved, category),
- yAxisQuotaLineLabel: formatReservedWithUnits(currentHistory.reserved, category, {
- isAbbreviated: true,
- }),
- };
- if (displayMode === 'cost') {
- const {prepaidPrice} = calculateCategoryPrepaidUsage(
- category,
- subscription,
- {accepted: 0},
- reservedBudgetCategoryInfo[category]?.prepaidBudget ?? currentHistory.prepaid
- );
- const {onDemandCategoryMax} = calculateCategoryOnDemandUsage(
- category,
- subscription
- );
- dataCategoryMetadata.yAxisQuotaLine = prepaidPrice + onDemandCategoryMax;
- }
- }
- return {
- isCumulative: transform === ChartDataTransform.CUMULATIVE,
- ...dataCategoryMetadata,
- };
- }
- function handleSelectDataCategory(value: ChartDataTransform) {
- browserHistory.push({
- pathname: location.pathname,
- query: {...location.query, transform: value},
- });
- }
- function handleSelectDataTransform(value: DataCategory) {
- browserHistory.push({
- pathname: location.pathname,
- query: {...location.query, category: value},
- });
- }
- /**
- * Whether the account has access to the data category
- * or tracked usage in the current billing period.
- */
- function hasOrUsedCategory(dataCategory: string) {
- return (
- hasCategoryFeature(dataCategory, subscription, organization) ||
- usageStats[dataCategory]?.some(
- (item: BillingStat) => item.total > 0 && !item.isProjected
- )
- );
- }
- function renderFooter() {
- const {planDetails} = subscription;
- const displayOptions = getCategoryOptions({
- plan: planDetails,
- hadCustomDynamicSampling: subscription.hadCustomDynamicSampling,
- }).reduce((acc, option) => {
- if (hasOrUsedCategory(option.value)) {
- if (
- option.value === DataCategory.SPANS &&
- subscription.hadCustomDynamicSampling
- ) {
- option.label = t('Accepted Spans');
- }
- acc.push(option);
- // Display upsell if the category is available
- } else if (planDetails.availableCategories?.includes(option.value)) {
- acc.push({
- ...option,
- tooltip: t(
- 'Your plan does not include %s. Migrate to our latest plans to access new features.',
- option.value
- ),
- disabled: true,
- });
- }
- return acc;
- }, [] as CategoryOption[]);
- return (
- <ChartControls>
- <InlineContainer>
- <SectionValue>
- <IconCalendar />
- </SectionValue>
- <SectionValue>
- {moment(usagePeriodStart).format('ll')}
- {' — '}
- {moment(usagePeriodEnd).format('ll')}
- </SectionValue>
- </InlineContainer>
- <InlineContainer>
- <OptionSelector
- title={t('Display')}
- selected={category}
- options={displayOptions}
- onChange={(val: string) => handleSelectDataTransform(val as DataCategory)}
- />
- <OptionSelector
- title={t('Type')}
- selected={transform}
- options={CHART_OPTIONS_DATA_TRANSFORM}
- onChange={(val: string) =>
- handleSelectDataCategory(val as ChartDataTransform)
- }
- />
- </InlineContainer>
- </ChartControls>
- );
- }
- const {isCumulative, isUnlimitedQuota, chartData, yAxisQuotaLine, yAxisQuotaLineLabel} =
- chartMetadata();
- return (
- <UsageChart
- footer={renderFooter()}
- dataCategory={category}
- dataTransform={transform}
- handleDataTransformation={s => s}
- usageDateStart={usagePeriodStart}
- usageDateEnd={usagePeriodEnd}
- usageStats={chartData}
- usageDateShowUtc={false}
- categoryOptions={categoryOptions}
- categoryColors={getCategoryColors(theme)}
- chartSeries={[
- ...(displayMode === 'cost' && chartData.reserved
- ? [
- barSeries({
- // Reserved spend
- name: 'Included in Subscription',
- data: chartData.reserved,
- barMinHeight: 1,
- stack: 'usage',
- legendHoverLink: false,
- color: CHART_PALETTE[5]![0]!,
- }),
- barSeries({
- name:
- subscription.planTier === PlanTier.AM3 ? 'Pay-as-you-go' : 'On-Demand',
- data: chartData.onDemand,
- barMinHeight: 1,
- stack: 'usage',
- legendHoverLink: false,
- color: CHART_PALETTE[5]![1]!,
- }),
- ]
- : []),
- lineSeries({
- markLine: MarkLine({
- silent: true,
- lineStyle: {
- color: !isCumulative || isUnlimitedQuota ? 'transparent' : theme.gray300,
- type: 'dashed',
- },
- data: [{yAxis: isCumulative ? yAxisQuotaLine : 0}],
- precision: 1,
- label: {
- show: isCumulative ? true : false,
- position: 'insideStartBottom',
- formatter:
- displayMode === 'usage'
- ? t(`Plan Quota (%s)`, yAxisQuotaLineLabel)
- : t('Max Spend'),
- color: theme.chartLabel,
- backgroundColor: theme.background,
- borderRadius: 2,
- padding: 2,
- fontSize: 10,
- },
- }),
- }),
- ]}
- yAxisFormatter={displayMode === 'usage' ? undefined : formatCurrency}
- chartTooltip={chartTooltip(category, displayMode)}
- title={
- <Title>
- {displayMode === 'usage'
- ? t('Current Usage Period')
- : t(
- 'Estimated %s Spend This Period',
- titleCase(
- getPlanCategoryName({
- plan: subscription.planDetails,
- category,
- hadCustomDynamicSampling: subscription.hadCustomDynamicSampling,
- })
- )
- )}
- </Title>
- }
- />
- );
- }
- export default ReservedUsageChart;
- const Title = styled('div')`
- font-size: ${p => p.theme.fontSizeExtraLarge};
- font-weight: normal;
- `;
|