import type {MouseEvent as ReactMouseEvent} from 'react'; import {Fragment} from 'react'; import type {WithRouterProps} from 'react-router'; import styled from '@emotion/styled'; import * as Sentry from '@sentry/react'; import isEqual from 'lodash/isEqual'; import moment from 'moment'; import {navigateTo} from 'sentry/actionCreators/navigation'; import OptionSelector from 'sentry/components/charts/optionSelector'; import {InlineContainer, SectionHeading} from 'sentry/components/charts/styles'; import type {DateTimeObject} from 'sentry/components/charts/utils'; import {getSeriesApiInterval} from 'sentry/components/charts/utils'; import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent'; import ErrorBoundary from 'sentry/components/errorBoundary'; import NotAvailable from 'sentry/components/notAvailable'; import type {ScoreCardProps} from 'sentry/components/scoreCard'; import ScoreCard from 'sentry/components/scoreCard'; import {DATA_CATEGORY_INFO, DEFAULT_STATS_PERIOD} from 'sentry/constants'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {DataCategoryInfo, IntervalPeriod, Organization} from 'sentry/types'; import {Outcome} from 'sentry/types'; import {parsePeriodToHours} from 'sentry/utils/duration/parsePeriodToHours'; import { FORMAT_DATETIME_DAILY, FORMAT_DATETIME_HOURLY, getDateFromMoment, } from './usageChart/utils'; import type {UsageSeries, UsageStat} from './types'; import type {ChartStats, UsageChartProps} from './usageChart'; import UsageChart, {CHART_OPTIONS_DATA_TRANSFORM, ChartDataTransform} from './usageChart'; import UsageStatsPerMin from './usageStatsPerMin'; import {formatUsageWithUnits, getFormatUsageOptions, isDisplayUtc} from './utils'; export interface UsageStatsOrganizationProps extends WithRouterProps { dataCategory: DataCategoryInfo['plural']; dataCategoryName: string; dataDatetime: DateTimeObject; handleChangeState: (state: { dataCategory?: DataCategoryInfo['plural']; pagePeriod?: string | null; transform?: ChartDataTransform; }) => void; isSingleProject: boolean; organization: Organization; projectIds: number[]; chartTransform?: string; } type UsageStatsOrganizationState = { orgStats: UsageSeries | undefined; metricOrgStats?: UsageSeries | undefined; } & DeprecatedAsyncComponent['state']; /** * This component is replaced by EnhancedUsageStatsOrganization in getsentry, which inherits * heavily from this one. Take care if changing any existing function signatures to ensure backwards * compatibility. */ class UsageStatsOrganization< P extends UsageStatsOrganizationProps = UsageStatsOrganizationProps, S extends UsageStatsOrganizationState = UsageStatsOrganizationState, > extends DeprecatedAsyncComponent { componentDidUpdate(prevProps: UsageStatsOrganizationProps) { const {dataDatetime: prevDateTime, projectIds: prevProjectIds} = prevProps; const {dataDatetime: currDateTime, projectIds: currProjectIds} = this.props; if ( prevDateTime.start !== currDateTime.start || prevDateTime.end !== currDateTime.end || prevDateTime.period !== currDateTime.period || prevDateTime.utc !== currDateTime.utc || !isEqual(prevProjectIds, currProjectIds) ) { this.reloadData(); } } getEndpoints(): ReturnType { return [['orgStats', this.endpointPath, {query: this.endpointQuery}]]; } /** List of components to render on single-project view */ get projectDetails(): JSX.Element[] { return []; } get endpointPath() { const {organization} = this.props; return `/organizations/${organization.slug}/stats_v2/`; } get endpointQueryDatetime() { const {dataDatetime} = this.props; const queryDatetime = dataDatetime.start && dataDatetime.end ? { start: dataDatetime.start, end: dataDatetime.end, utc: dataDatetime.utc, } : { statsPeriod: dataDatetime.period || DEFAULT_STATS_PERIOD, }; return queryDatetime; } get endpointQuery() { const {dataDatetime, projectIds} = this.props; const queryDatetime = this.endpointQueryDatetime; return { ...queryDatetime, interval: getSeriesApiInterval(dataDatetime), groupBy: ['category', 'outcome'], project: projectIds, field: ['sum(quantity)'], }; } get chartData(): { cardStats: { accepted?: string; dropped?: string; filtered?: string; total?: string; }; chartDateEnd: string; chartDateEndDisplay: string; chartDateInterval: IntervalPeriod; chartDateStart: string; chartDateStartDisplay: string; chartDateTimezoneDisplay: string; chartDateUtc: boolean; chartStats: ChartStats; chartTransform: ChartDataTransform; dataError?: Error; } { return { ...this.mapSeriesToChart(this.state.orgStats), ...this.chartDateRange, ...this.chartTransform, }; } get chartTransform(): {chartTransform: ChartDataTransform} { const {chartTransform} = this.props; switch (chartTransform) { case ChartDataTransform.CUMULATIVE: case ChartDataTransform.PERIODIC: return {chartTransform}; default: return {chartTransform: ChartDataTransform.PERIODIC}; } } get chartDateRange(): { chartDateEnd: string; chartDateEndDisplay: string; chartDateInterval: IntervalPeriod; chartDateStart: string; chartDateStartDisplay: string; chartDateTimezoneDisplay: string; chartDateUtc: boolean; } { const {orgStats} = this.state; const {dataDatetime} = this.props; const interval = getSeriesApiInterval(dataDatetime); // Use fillers as loading/error states will not display datetime at all if (!orgStats || !orgStats.intervals) { return { chartDateInterval: interval, chartDateStart: '', chartDateEnd: '', chartDateUtc: true, chartDateStartDisplay: '', chartDateEndDisplay: '', chartDateTimezoneDisplay: '', }; } const {intervals} = orgStats; const intervalHours = parsePeriodToHours(interval); // Keep datetime in UTC until we want to display it to users const startTime = moment(intervals[0]).utc(); const endTime = intervals.length < 2 ? moment(startTime) // when statsPeriod and interval is the same value : moment(intervals[intervals.length - 1]).utc(); const useUtc = isDisplayUtc(dataDatetime); // If interval is a day or more, use UTC to format date. Otherwise, the date // may shift ahead/behind when converting to the user's local time. const FORMAT_DATETIME = intervalHours >= 24 ? FORMAT_DATETIME_DAILY : FORMAT_DATETIME_HOURLY; const xAxisStart = moment(startTime); const xAxisEnd = moment(endTime); const displayStart = useUtc ? moment(startTime).utc() : moment(startTime).local(); const displayEnd = useUtc ? moment(endTime).utc() : moment(endTime).local(); if (intervalHours < 24) { displayEnd.add(intervalHours, 'h'); } return { chartDateInterval: interval, chartDateStart: xAxisStart.format(), chartDateEnd: xAxisEnd.format(), chartDateUtc: useUtc, chartDateStartDisplay: displayStart.format(FORMAT_DATETIME), chartDateEndDisplay: displayEnd.format(FORMAT_DATETIME), chartDateTimezoneDisplay: displayStart.format('Z'), }; } get chartProps(): UsageChartProps { const {dataCategory} = this.props; const {error, errors, loading} = this.state; const { chartStats, dataError, chartDateInterval, chartDateStart, chartDateEnd, chartDateUtc, chartTransform, } = this.chartData; const hasError = error || !!dataError; const chartErrors: any = dataError ? {...errors, data: dataError} : errors; // TODO(ts): AsyncComponent const chartProps = { isLoading: loading, isError: hasError, errors: chartErrors, title: ' ', // Force the title to be blank footer: this.renderChartFooter(), dataCategory, dataTransform: chartTransform, usageDateStart: chartDateStart, usageDateEnd: chartDateEnd, usageDateShowUtc: chartDateUtc, usageDateInterval: chartDateInterval, usageStats: chartStats, } as UsageChartProps; return chartProps; } get cardMetadata() { const {dataCategory, dataCategoryName, organization, projectIds, router} = this.props; const {total, accepted, dropped, filtered} = this.chartData.cardStats; const navigateToInboundFilterSettings = (event: ReactMouseEvent) => { event.preventDefault(); const url = `/settings/${organization.slug}/projects/:projectId/filters/data-filters/`; if (router) { navigateTo(url, router); } }; const cardMetadata: Record = { total: { title: tct('Total [dataCategory]', {dataCategory: dataCategoryName}), score: total, }, accepted: { title: tct('Accepted [dataCategory]', {dataCategory: dataCategoryName}), help: tct('Accepted [dataCategory] were successfully processed by Sentry', { dataCategory, }), score: accepted, trend: ( ), }, filtered: { title: tct('Filtered [dataCategory]', {dataCategory: dataCategoryName}), help: tct( 'Filtered [dataCategory] were blocked due to your [filterSettings: inbound data filter] rules', { dataCategory, filterSettings: ( navigateToInboundFilterSettings(event)} /> ), } ), score: filtered, }, dropped: { title: tct('Dropped [dataCategory]', {dataCategory: dataCategoryName}), help: tct( 'Dropped [dataCategory] were discarded due to invalid data, rate-limits, quota limits, or spike protection', {dataCategory} ), score: dropped, }, }; return cardMetadata; } mapSeriesToChart(orgStats?: UsageSeries): { cardStats: { accepted?: string; dropped?: string; filtered?: string; total?: string; }; chartStats: ChartStats; dataError?: Error; } { const cardStats = { total: undefined, accepted: undefined, dropped: undefined, filtered: undefined, }; const chartStats: ChartStats = { accepted: [], dropped: [], projected: [], filtered: [], }; if (!orgStats) { return {cardStats, chartStats}; } try { const {dataCategory} = this.props; const {chartDateInterval, chartDateUtc} = this.chartDateRange; const usageStats: UsageStat[] = orgStats.intervals.map(interval => { const dateTime = moment(interval); return { date: getDateFromMoment(dateTime, chartDateInterval, chartDateUtc), total: 0, accepted: 0, filtered: 0, dropped: {total: 0}, }; }); // Tally totals for card data const count: Record<'total' | Outcome, number> = { total: 0, [Outcome.ACCEPTED]: 0, [Outcome.FILTERED]: 0, [Outcome.DROPPED]: 0, [Outcome.INVALID]: 0, // Combined with dropped later [Outcome.RATE_LIMITED]: 0, // Combined with dropped later [Outcome.CLIENT_DISCARD]: 0, // Not exposed yet [Outcome.CARDINALITY_LIMITED]: 0, // Combined with dropped later }; orgStats.groups.forEach(group => { const {outcome, category} = group.by; // HACK: The backend enum are singular, but the frontend enums are plural const fullDataCategory = Object.values(DATA_CATEGORY_INFO).find( data => data.plural === dataCategory ); if (fullDataCategory?.apiName !== category) { return; } if (outcome !== Outcome.CLIENT_DISCARD) { count.total += group.totals['sum(quantity)']; } count[outcome] += group.totals['sum(quantity)']; group.series['sum(quantity)'].forEach((stat, i) => { switch (outcome) { case Outcome.ACCEPTED: case Outcome.FILTERED: usageStats[i][outcome] += stat; return; case Outcome.DROPPED: case Outcome.RATE_LIMITED: case Outcome.CARDINALITY_LIMITED: case Outcome.INVALID: usageStats[i].dropped.total += stat; // TODO: add client discards to dropped? return; default: return; } }); }); // Invalid and rate_limited data is combined with dropped count[Outcome.DROPPED] += count[Outcome.INVALID]; count[Outcome.DROPPED] += count[Outcome.RATE_LIMITED]; count[Outcome.DROPPED] += count[Outcome.CARDINALITY_LIMITED]; usageStats.forEach(stat => { stat.total = stat.accepted + stat.filtered + stat.dropped.total; // Chart Data (chartStats.accepted as any[]).push({value: [stat.date, stat.accepted]}); (chartStats.dropped as any[]).push({ value: [stat.date, stat.dropped.total], } as any); (chartStats.filtered as any[])?.push({value: [stat.date, stat.filtered]}); }); return { cardStats: { total: formatUsageWithUnits( count.total, dataCategory, getFormatUsageOptions(dataCategory) ), accepted: formatUsageWithUnits( count[Outcome.ACCEPTED], dataCategory, getFormatUsageOptions(dataCategory) ), filtered: formatUsageWithUnits( count[Outcome.FILTERED], dataCategory, getFormatUsageOptions(dataCategory) ), dropped: formatUsageWithUnits( count[Outcome.DROPPED], dataCategory, getFormatUsageOptions(dataCategory) ), }, chartStats, }; } catch (err) { Sentry.withScope(scope => { scope.setContext('query', this.endpointQuery); scope.setContext('body', {...orgStats}); Sentry.captureException(err); }); return { cardStats, chartStats, dataError: new Error('Failed to parse stats data'), }; } } renderCards() { const {loading} = this.state; const cardMetadata = Object.values(this.cardMetadata); return cardMetadata.map((card, i) => ( )); } renderChart() { const {loading} = this.state; return ; } renderChartFooter = () => { const {handleChangeState} = this.props; const {loading, error} = this.state; const { chartDateInterval, chartTransform, chartDateStartDisplay, chartDateEndDisplay, chartDateTimezoneDisplay, } = this.chartData; return (
{t('Date Range:')} {loading || error ? ( ) : ( tct('[start] — [end] ([timezone] UTC, [interval] interval)', { start: chartDateStartDisplay, end: chartDateEndDisplay, timezone: chartDateTimezoneDisplay, interval: chartDateInterval, }) )} handleChangeState({transform: val as ChartDataTransform}) } />
); }; renderProjectDetails() { const {isSingleProject} = this.props; const projectDetails = this.projectDetails.map((projectDetailComponent, i) => ( {projectDetailComponent} )); return isSingleProject ? projectDetails : null; } renderComponent() { return ( {this.renderCards()} {this.renderChart()} {this.renderProjectDetails()} ); } } export default UsageStatsOrganization; const PageGrid = styled('div')` display: grid; grid-template-columns: 1fr; gap: ${space(2)}; @media (min-width: ${p => p.theme.breakpoints.small}) { grid-template-columns: repeat(2, 1fr); } @media (min-width: ${p => p.theme.breakpoints.large}) { grid-template-columns: repeat(4, 1fr); } `; const StyledScoreCard = styled(ScoreCard)` grid-column: auto / span 1; margin: 0; `; const ChartWrapper = styled('div')` grid-column: 1 / -1; `; const Footer = styled('div')` display: flex; flex-direction: row; justify-content: space-between; padding: ${space(1)} ${space(3)}; border-top: 1px solid ${p => p.theme.border}; `; const FooterDate = styled('div')` display: flex; flex-direction: row; align-items: center; > ${SectionHeading} { margin-right: ${space(1.5)}; } > span:last-child { font-weight: ${p => p.theme.fontWeightNormal}; font-size: ${p => p.theme.fontSizeMedium}; } `;