diff --git a/static/app/components/scoreCard.tsx b/static/app/components/scoreCard.tsx index 542a4781fce1dd..b39b3653f482ad 100644 --- a/static/app/components/scoreCard.tsx +++ b/static/app/components/scoreCard.tsx @@ -11,6 +11,7 @@ export type ScoreCardProps = { title: React.ReactNode; className?: string; help?: React.ReactNode; + isEstimate?: boolean; isTooltipHoverable?: boolean; renderOpenButton?: () => React.ReactNode; score?: React.ReactNode; @@ -27,7 +28,11 @@ function ScoreCard({ className, renderOpenButton, isTooltipHoverable, + isEstimate, }: ScoreCardProps) { + const value = score ?? '\u2014'; + const displayScore = isEstimate ? `~${value}` : value; + return ( @@ -46,7 +51,8 @@ function ScoreCard({ - {score ?? '\u2014'} + {displayScore} + {isEstimate && *} {defined(trend) && ( {trend} @@ -122,4 +128,15 @@ export const Trend = styled('div')` overflow: hidden; `; +const Asterisk = styled('div')` + color: grey; + font-size: ${p => p.theme.fontSizeRelativeSmall}; + display: inline-block; + width: 10pt; + height: 10pt; + position: relative; + top: -10px; + margin-left: ${space(0.25)}; +`; + export default ScoreCard; diff --git a/static/app/views/organizationStats/index.spec.tsx b/static/app/views/organizationStats/index.spec.tsx index d409f396856706..7024ae1d576f33 100644 --- a/static/app/views/organizationStats/index.spec.tsx +++ b/static/app/views/organizationStats/index.spec.tsx @@ -117,6 +117,10 @@ describe('OrganizationStats', function () { expect(screen.getAllByText('Invalid')[0]).toBeInTheDocument(); expect(screen.getAllByText('15')[0]).toBeInTheDocument(); + expect( + screen.queryByText('*This is an estimation, and may not be 100% accurate.') + ).not.toBeInTheDocument(); + // Correct API Calls const mockExpectations = { UsageStatsOrg: { @@ -519,6 +523,33 @@ describe('OrganizationStats', function () { ).toBeInTheDocument(); }); + it('shows estimation text when profile duration category is selected', async () => { + const newOrg = initializeOrg({ + organization: { + features: [ + 'global-views', + 'team-insights', + 'continuous-profiling-stats', + 'continuous-profiling', + ], + }, + }); + + render( + , + { + router: newOrg.router, + } + ); + expect( + await screen.findByText('*This is an estimation, and may not be 100% accurate.') + ).toBeInTheDocument(); + }); + it('denies access without project membership', async function () { const newOrg = initializeOrg({ organization: { diff --git a/static/app/views/organizationStats/index.tsx b/static/app/views/organizationStats/index.tsx index 51337c5ba61dcd..ac65ccce39e7a6 100644 --- a/static/app/views/organizationStats/index.tsx +++ b/static/app/views/organizationStats/index.tsx @@ -37,6 +37,7 @@ import withPageFilters from 'sentry/utils/withPageFilters'; import HeaderTabs from 'sentry/views/organizationStats/header'; import {getPerformanceBaseUrl} from 'sentry/views/performance/utils'; import {makeProjectsPathname} from 'sentry/views/projects/pathname'; +import {getDocsLinkForEventType} from 'sentry/views/settings/account/notifications/utils'; import type {ChartDataTransform} from './usageChart'; import {CHART_OPTIONS_DATACATEGORY} from './usageChart'; @@ -319,6 +320,29 @@ export class OrganizationStats extends Component { ); } + renderEstimationDisclaimer() { + if ( + this.dataCategory === DATA_CATEGORY_INFO.profileDuration.plural || + this.dataCategory === DATA_CATEGORY_INFO.profileDurationUI.plural + ) { + return ( + + {tct( + '*This is an estimation, and may not be 100% accurate. [estimateLink: How we calculate estimated usage]', + { + estimateLink: ( + + ), + } + )} + + ); + } + return null; + } + render() { const {organization} = this.props; const hasTeamInsights = organization.features.includes('team-insights'); @@ -349,7 +373,10 @@ export class OrganizationStats extends Component { - {this.renderProjectPageControl()} + + {this.renderProjectPageControl()} + {this.renderEstimationDisclaimer()} +
{this.renderUsageStatsOrg()}
@@ -425,12 +452,26 @@ const HeadingSubtitle = styled('p')` margin-bottom: 0; `; +const ControlsWrapper = styled('div')` + display: flex; + align-items: center; + gap: ${space(0.5)}; + margin-bottom: ${space(2)}; + justify-content: space-between; +`; + const PageControl = styled('div')` display: grid; - width: 100%; - margin-bottom: ${space(2)}; + + margin-bottom: 0; grid-template-columns: minmax(0, max-content); @media (max-width: ${p => p.theme.breakpoints.small}) { grid-template-columns: minmax(0, 1fr); } `; + +const EstimationText = styled('div')` + color: ${p => p.theme.subText}; + font-size: ${p => p.theme.fontSizeSmall}; + line-height: ${p => p.theme.text.lineHeightBody}; +`; diff --git a/static/app/views/organizationStats/mapSeriesToChart.spec.ts b/static/app/views/organizationStats/mapSeriesToChart.spec.ts index e3a8f74e6694a9..901f0798d72196 100644 --- a/static/app/views/organizationStats/mapSeriesToChart.spec.ts +++ b/static/app/views/organizationStats/mapSeriesToChart.spec.ts @@ -217,4 +217,71 @@ describe('mapSeriesToChart func', function () { // sums up rate limited, abuse, and cardinality limited expect(mappedSeries.cardStats.rateLimited).toBe('11'); }); + + it('should correctly sum up the profile chunks', function () { + const mappedSeries = mapSeriesToChart({ + orgStats: { + start: '2021-01-01T00:00:00Z', + end: '2021-01-07T00:00:00Z', + intervals: ['2021-01-01T00:00:00Z', '2021-01-02T00:00:00Z'], + groups: [ + { + by: { + outcome: 'invalid', + reason: 'bad', + category: 'profile_chunk', + }, + totals: { + 'sum(quantity)': 10, + }, + series: { + 'sum(quantity)': [1, 2], + }, + }, + { + by: { + outcome: 'accepted', + reason: 'good', + category: 'profile_chunk', + }, + totals: { + 'sum(quantity)': 10, + }, + series: { + 'sum(quantity)': [3, 4], + }, + }, + { + by: { + outcome: 'accepted', + reason: 'good', + category: 'profile_duration', + }, + totals: { + 'sum(quantity)': 10, + }, + series: { + 'sum(quantity)': [1, 2], + }, + }, + ], + }, + chartDateInterval: '1h', + chartDateUtc: true, + dataCategory: 'profile_duration', + endpointQuery: {}, + }); + + // multiplies dropped profile chunks by 9000 + expect(mappedSeries.chartStats.invalid).toEqual([ + {value: ['Jan 1 12:00 AM - 1:00 AM (+00:00)', 9000]}, + {value: ['Jan 2 12:00 AM - 1:00 AM (+00:00)', 18000]}, + ]); + + // does not add accepted profile chunks to accepted profile duration + expect(mappedSeries.chartStats.accepted).toEqual([ + {value: ['Jan 1 12:00 AM - 1:00 AM (+00:00)', 1]}, + {value: ['Jan 2 12:00 AM - 1:00 AM (+00:00)', 2]}, + ]); + }); }); diff --git a/static/app/views/organizationStats/mapSeriesToChart.ts b/static/app/views/organizationStats/mapSeriesToChart.ts index d9c3e7ecc59918..e8e234931c0885 100644 --- a/static/app/views/organizationStats/mapSeriesToChart.ts +++ b/static/app/views/organizationStats/mapSeriesToChart.ts @@ -13,6 +13,20 @@ import type {ChartStats} from './usageChart'; import {SeriesTypes} from './usageChart'; import {formatUsageWithUnits, getFormatUsageOptions} from './utils'; +// used for estimated dropped continuous profile hours and ui profile hours from profile chunks and profile chunks ui +export function droppedProfileChunkMultiplier( + category: number | string | undefined, + outcome: number | string | undefined +) { + if (category === 'profile_chunk' || category === 'profile_chunk_ui') { + if (outcome === Outcome.ACCEPTED) { + return 0; + } + return 9000; + } + return 1; +} + export function mapSeriesToChart({ orgStats, dataCategory, @@ -101,10 +115,13 @@ export function mapSeriesToChart({ countAcceptedStored += group.totals['sum(quantity)']!; } } else { + const value = + group.totals['sum(quantity)']! * + droppedProfileChunkMultiplier(category, outcome); if (outcome !== Outcome.CLIENT_DISCARD) { - count.total += group.totals['sum(quantity)']!; + count.total += value; } - (count as any)[outcome!] += group.totals['sum(quantity)']!; + (count as any)[outcome!] += value; } if (category === 'span_indexed' && outcome !== Outcome.ACCEPTED) { @@ -113,6 +130,7 @@ export function mapSeriesToChart({ } group.series['sum(quantity)']!.forEach((stat, i) => { + stat = stat * droppedProfileChunkMultiplier(category, outcome); const dataObject = {name: orgStats.intervals[i]!, value: stat}; const strigfiedReason = String(group.by.reason ?? ''); diff --git a/static/app/views/organizationStats/usageStatsOrg.tsx b/static/app/views/organizationStats/usageStatsOrg.tsx index 72d7ab7036416a..8e5cb14a1259cf 100644 --- a/static/app/views/organizationStats/usageStatsOrg.tsx +++ b/static/app/views/organizationStats/usageStatsOrg.tsx @@ -47,7 +47,7 @@ import UsageChart, { SeriesTypes, } from './usageChart'; import UsageStatsPerMin from './usageStatsPerMin'; -import {isDisplayUtc} from './utils'; +import {isContinuousProfiling, isDisplayUtc} from './utils'; export interface UsageStatsOrganizationProps extends WithRouterProps { dataCategory: DataCategoryInfo['plural']; @@ -150,6 +150,12 @@ class UsageStatsOrganization< groupBy.push('category'); category.push('span_indexed'); } + if (['profile_duration', 'profile_duration_ui'].includes(dataCategoryApiName)) { + groupBy.push('category'); + category.push( + dataCategoryApiName === 'profile_duration' ? 'profile_chunk' : 'profile_chunk_ui' + ); + } return { ...queryDatetime, @@ -370,6 +376,7 @@ class UsageStatsOrganization< const {total, accepted, accepted_stored, invalid, rateLimited, filtered} = this.chartData.cardStats; const dataCategoryNameLower = dataCategoryName.toLowerCase(); + const shouldShowEstimate = isContinuousProfiling(dataCategory); const navigateToInboundFilterSettings = (event: ReactMouseEvent) => { event.preventDefault(); @@ -429,6 +436,7 @@ class UsageStatsOrganization< } ), score: filtered, + isEstimate: shouldShowEstimate, }, rateLimited: { title: tct('Rate Limited [dataCategory]', {dataCategory: dataCategoryName}), @@ -445,6 +453,7 @@ class UsageStatsOrganization< } ), score: rateLimited, + isEstimate: shouldShowEstimate, }, invalid: { title: tct('Invalid [dataCategory]', {dataCategory: dataCategoryName}), @@ -461,6 +470,7 @@ class UsageStatsOrganization< } ), score: invalid, + isEstimate: shouldShowEstimate, }, }; return cardMetadata; @@ -478,6 +488,7 @@ class UsageStatsOrganization< score={loading ? undefined : card.score} help={card.help} trend={card.trend} + isEstimate={card.isEstimate} isTooltipHoverable /> )); diff --git a/static/app/views/organizationStats/usageStatsProjects.tsx b/static/app/views/organizationStats/usageStatsProjects.tsx index 1b757c626a36d0..b6f023e3916c32 100644 --- a/static/app/views/organizationStats/usageStatsProjects.tsx +++ b/static/app/views/organizationStats/usageStatsProjects.tsx @@ -21,6 +21,7 @@ import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import {hasDynamicSamplingCustomFeature} from 'sentry/utils/dynamicSampling/features'; import withProjects from 'sentry/utils/withProjects'; +import {droppedProfileChunkMultiplier} from 'sentry/views/organizationStats/mapSeriesToChart'; import type {UsageSeries} from './types'; import type {TableStat} from './usageTable'; @@ -124,6 +125,15 @@ class UsageStatsProjects extends DeprecatedAsyncComponent { groupBy.push('category'); category.push('span_indexed'); } + if ( + dataCategory.apiName === 'profile_duration' || + dataCategory.apiName === 'profile_duration_ui' + ) { + groupBy.push('category'); + category.push( + dataCategory.apiName === 'profile_duration' ? 'profile_chunk' : 'profile_chunk_ui' + ); + } // We do not need more granularity in the data so interval is '1d' return { @@ -388,6 +398,9 @@ class UsageStatsProjects extends DeprecatedAsyncComponent { const {outcome, category, project: projectId} = group.by; // Backend enum is singlar. Frontend enum is plural. + const multiplier = droppedProfileChunkMultiplier(category, outcome); + const value = group.totals['sum(quantity)']! * multiplier; + if (category === 'span_indexed' && outcome !== Outcome.ACCEPTED) { // we need `span_indexed` data for `accepted_stored` only return; @@ -402,11 +415,11 @@ class UsageStatsProjects extends DeprecatedAsyncComponent { } if (outcome !== Outcome.CLIENT_DISCARD && category !== 'span_indexed') { - stats[projectId!]!.total += group.totals['sum(quantity)']!; + stats[projectId!]!.total += value; } if (category === 'span_indexed' && outcome === Outcome.ACCEPTED) { - stats[projectId!]!.accepted_stored += group.totals['sum(quantity)']!; + stats[projectId!]!.accepted_stored += value; return; } @@ -415,7 +428,7 @@ class UsageStatsProjects extends DeprecatedAsyncComponent { outcome === Outcome.FILTERED || outcome === Outcome.INVALID ) { - stats[projectId!]![outcome] += group.totals['sum(quantity)']!; + stats[projectId!]![outcome] += value; } if ( @@ -423,7 +436,7 @@ class UsageStatsProjects extends DeprecatedAsyncComponent { outcome === Outcome.CARDINALITY_LIMITED || outcome === Outcome.ABUSE ) { - stats[projectId!]![SortBy.RATE_LIMITED] += group.totals['sum(quantity)']!; + stats[projectId!]![SortBy.RATE_LIMITED] += value; } }); diff --git a/static/app/views/organizationStats/utils.tsx b/static/app/views/organizationStats/utils.tsx index e5e68c23386566..387a46c1f2ffea 100644 --- a/static/app/views/organizationStats/utils.tsx +++ b/static/app/views/organizationStats/utils.tsx @@ -2,6 +2,7 @@ import type {DateTimeObject} from 'sentry/components/charts/utils'; import {getSeriesApiInterval} from 'sentry/components/charts/utils'; import {DATA_CATEGORY_INFO} from 'sentry/constants'; import type {DataCategoryInfo} from 'sentry/types/core'; +import {DataCategory} from 'sentry/types/core'; import {formatBytesBase10} from 'sentry/utils/bytes/formatBytesBase10'; import {parsePeriodToHours} from 'sentry/utils/duration/parsePeriodToHours'; @@ -148,3 +149,10 @@ export function getPaginationPageLink({ nextOffset < numRows }"; cursor="0:${nextOffset}:0"`; } + +export function isContinuousProfiling(dataCategory: DataCategory | string) { + return ( + dataCategory === DataCategory.PROFILE_DURATION || + dataCategory === DataCategory.PROFILE_DURATION_UI + ); +}