import {Fragment, useCallback, useEffect, useMemo, useState} from 'react'; import type {Theme} from '@emotion/react'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import cloneDeep from 'lodash/cloneDeep'; import startCase from 'lodash/startCase'; import moment from 'moment-timezone'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {BarChart} from 'sentry/components/charts/barChart'; import ChartZoom from 'sentry/components/charts/chartZoom'; import Legend from 'sentry/components/charts/components/legend'; import type {TooltipSubLabel} from 'sentry/components/charts/components/tooltip'; import {type DateTimeObject, getInterval} from 'sentry/components/charts/utils'; import LoadingError from 'sentry/components/loadingError'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse'; import {space} from 'sentry/styles/space'; import type {DataPoint} from 'sentry/types/echarts'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import {defined} from 'sentry/utils'; import getDynamicText from 'sentry/utils/getDynamicText'; import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse'; import useApi from 'sentry/utils/useApi'; import useRouter from 'sentry/utils/useRouter'; import { categoryFromDataType, type DataType, } from 'admin/components/customers/customerStatsFilters'; enum SeriesName { ACCEPTED = 'Accepted', FILTERED = 'Filtered (Server)', OVER_QUOTA = 'Over Quota', DISCARDED = 'Discarded (Client)', DROPPED = 'Dropped (Server)', } type SubSeries = { data: DataPoint[]; seriesName: string; }; type SeriesItem = { data: DataPoint[]; seriesName: string; color?: string; subSeries?: SubSeries[]; }; /** @internal exported for tests only */ export type StatsGroup = { by: { outcome: string; reason: string; }; series: Record; totals: Record; }; type Stats = { groups: StatsGroup[]; intervals: Array; }; type LegendProps = { points: Stats; }; export const useSeries = (): Record => { const theme = useTheme(); return { accepted: { seriesName: SeriesName.ACCEPTED, data: [], color: theme.purple300, }, overQuota: { seriesName: SeriesName.OVER_QUOTA, data: [], color: theme.pink200, }, totalFiltered: { seriesName: SeriesName.FILTERED, data: [], color: theme.purple200, }, totalDiscarded: { seriesName: SeriesName.DISCARDED, data: [], color: theme.yellow300, }, totalDropped: { seriesName: SeriesName.DROPPED, data: [], color: theme.red300, }, }; }; function zeroFillDates(start: number, end: number, {color}: {color: string}) { const zero: SeriesItem = { seriesName: SeriesName.ACCEPTED, data: [], color, }; const numberOfIntervals = Math.ceil((end - start) / 86400); if (numberOfIntervals >= 0) { zero.data = [...new Array(numberOfIntervals).keys()].map(i => ({ name: new Date((start + (i + 1) * 86400) * 1000).toString(), value: 0, })); } return zero; } /** @internal exported for tests only */ export function populateChartData( intervals: Array, groups: StatsGroup[], series: Record ): SeriesItem[] { const {accepted, totalFiltered, totalDiscarded, totalDropped, overQuota} = cloneDeep(series); const outcomeMapping = {accepted, totalDiscarded}; const filteredData: Record = {}; const discardedData: Record = {}; const droppedData: Record = {}; intervals.forEach((timestamp, dateIndex) => { groups.forEach(point => { const dataObject = { name: timestamp.toString(), value: point.series['sum(quantity)']![dateIndex]!, }; if (point.by.outcome === 'filtered') { if (point.by.reason?.startsWith('Sampled:')) { if (filteredData['dynamic-sampling'] === undefined) { filteredData['dynamic-sampling'] = { seriesName: 'Dynamic Sampling', data: [], }; } if (dateIndex >= filteredData['dynamic-sampling'].data.length) { filteredData['dynamic-sampling'].data.push(dataObject); } else { filteredData['dynamic-sampling']!.data[dateIndex]!.value += dataObject.value; } } else { // dynamically adding filtered reasons into graph if (filteredData[point.by.reason] === undefined) { filteredData[point.by.reason] = { seriesName: startCase(point.by.reason?.replace(/-|_/g, ' ')), data: [], }; } filteredData[point.by.reason]!.data.push(dataObject); } if (dateIndex >= totalFiltered!.data.length) { totalFiltered!.data.push({...dataObject, value: 0}); } return; } // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message if (outcomeMapping[point.by.outcome]) { // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message if (dateIndex >= outcomeMapping[point.by.outcome].data.length) { // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message outcomeMapping[point.by.outcome].data.push(dataObject); return; } // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message outcomeMapping[point.by.outcome].data[dateIndex].value += dataObject.value; return; } // below are the dropped outcome cases if (['usage_exceeded', 'grace_period'].includes(point.by.reason)) { // combined usage_exceeded and grace_period into over quota if (dateIndex >= overQuota!.data.length) { overQuota!.data.push(dataObject); return; } overQuota!.data[dateIndex]!.value += dataObject.value; return; } if (point.by.outcome === 'client_discard') { // dynamically adding discarded reasons into graph if (discardedData[point.by.reason] === undefined) { discardedData[point.by.reason] = { seriesName: startCase(point.by.reason?.replace(/-|_/g, ' ')), data: [], }; } discardedData[point.by.reason]!.data.push(dataObject); if (dateIndex >= totalDiscarded!.data.length) { totalDiscarded!.data.push({...dataObject, value: 0}); } return; } if (point.by.outcome === 'abuse' && point.by.reason === 'none') { if (droppedData.abuse === undefined) { droppedData.abuse = { seriesName: 'Abuse', data: [], }; } droppedData.abuse.data.push(dataObject); if (dateIndex >= totalDropped!.data.length) { totalDropped!.data.push({...dataObject, value: 0}); } return; } // dynamically adding dropped reasons into graph if (droppedData[point.by.reason] === undefined) { droppedData[point.by.reason] = { seriesName: startCase(point.by.reason?.replace(/-|_/g, ' ')), data: [], }; } droppedData[point.by.reason]!.data.push(dataObject); if (dateIndex >= totalDropped!.data.length) { totalDropped!.data.push({...dataObject, value: 0}); } }); }); for (const data of Object.values(filteredData)) { totalFiltered!.subSeries = totalFiltered!.subSeries ?? []; totalFiltered!.subSeries.push({seriesName: data.seriesName, data: data.data}); for (const dataIndex in data.data) { totalFiltered!.data[dataIndex]!.value += data.data[dataIndex]!.value; } } for (const data of Object.values(discardedData)) { totalDiscarded!.subSeries = totalDiscarded!.subSeries ?? []; totalDiscarded!.subSeries.push({seriesName: data.seriesName, data: data.data}); for (const dataIndex in data.data) { totalDiscarded!.data[dataIndex]!.value += data.data[dataIndex]!.value; } } for (const data of Object.values(droppedData)) { totalDropped!.subSeries = totalDropped!.subSeries ?? []; totalDropped!.subSeries.push({seriesName: data.seriesName, data: data.data}); for (const dataIndex in data.data) { totalDropped!.data[dataIndex]!.value += data.data[dataIndex]!.value; } } return [accepted!, totalFiltered!, overQuota!, totalDiscarded!, totalDropped!]; } function FooterLegend({points}: LegendProps) { let accepted = 0; let filtered = 0; let total = 0; let discarded = 0; let dropped = 0; points.groups.forEach(point => { switch (point.by.outcome) { case 'filtered': filtered += point.totals['sum(quantity)']!; break; case 'accepted': accepted += point.totals['sum(quantity)']!; break; case 'client_discard': discarded += point.totals['sum(quantity)']!; break; default: dropped += point.totals['sum(quantity)']!; break; } total += point.totals['sum(quantity)']!; }); return (
Total {total.toLocaleString()}
{SeriesName.ACCEPTED} {accepted.toLocaleString()}
{SeriesName.FILTERED} {filtered.toLocaleString()}
{SeriesName.DISCARDED} {discarded.toLocaleString()}
{SeriesName.DROPPED} {dropped.toLocaleString()}
); } type Props = { dataType: DataType; orgSlug: Organization['slug']; onDemandPeriodEnd?: string; onDemandPeriodStart?: string; projectId?: Project['id']; }; export function CustomerStats({ orgSlug, projectId, dataType, onDemandPeriodStart, onDemandPeriodEnd, }: Props) { const api = useApi(); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const router = useRouter(); const dataDatetime = useMemo((): DateTimeObject => { const { start, end, utc: utcString, statsPeriod, } = normalizeDateTimeParams(router.location.query, { allowEmptyPeriod: true, allowAbsoluteDatetime: true, allowAbsolutePageDatetime: true, }); const utc = utcString === 'true'; if (!start && !end && !statsPeriod && onDemandPeriodStart && onDemandPeriodEnd) { return { start: onDemandPeriodStart, end: onDemandPeriodEnd, }; } if (start && end) { return utc ? { start: moment.utc(start).format(), end: moment.utc(end).format(), utc, } : { start: moment(start).utc().format(), end: moment(end).utc().format(), utc, }; } return { period: statsPeriod ?? '90d', }; }, [router.location.query, onDemandPeriodStart, onDemandPeriodEnd]); const fetchStatsRequest = useCallback(() => { return api.requestPromise(`/organizations/${orgSlug}/stats_v2/`, { query: { start: dataDatetime.start, end: dataDatetime.end, utc: dataDatetime.utc, statsPeriod: dataDatetime.period, interval: getInterval(dataDatetime), groupBy: ['outcome', 'reason'], field: 'sum(quantity)', category: categoryFromDataType(dataType), ...(projectId ? {project: projectId} : {}), }, }); }, [api, dataType, dataDatetime, orgSlug, projectId]); const fetchStats = useCallback(async () => { setLoading(true); try { const response = await fetchStatsRequest(); setStats(response); } catch (err) { const message = 'Unable to load stats data'; handleXhrErrorResponse(message, err); addErrorMessage(message); setError(err); } finally { setLoading(false); } }, [fetchStatsRequest]); useEffect(() => { fetchStats(); }, [dataType, dataDatetime, fetchStats]); const theme = useTheme(); const series = useSeries(); if (loading) { return ; } if (error) { return fetchStats()} />; } if (stats === null) { return null; } const {intervals, groups} = stats; const zeroFillStart = Number(new Date(intervals[intervals.length - 1]!)) / 1000 + 86400; const chartSeries = [ ...populateChartData(intervals, groups, series), zeroFillDates( zeroFillStart, new Date(dataDatetime.end ?? moment().format()).valueOf() / 1000, {color: theme.purple200} ), ]; const {legend, subLabels} = chartSeries.reduce( (acc, serie) => { if (!acc.legend.includes(serie!.seriesName) && serie!.data.length > 0) { acc.legend.push(serie!.seriesName); } if (!serie!.subSeries) { return acc; } for (const subSerie of serie!.subSeries) { acc.subLabels.push({ parentLabel: serie!.seriesName, label: subSerie.seriesName, data: subSerie.data, }); } return acc; }, { legend: [] as string[], subLabels: [] as TooltipSubLabel[], } ); return ( {getDynamicText({ value: ( {zoomRenderProps => ( serie.color) .filter(defined)} tooltip={{subLabels}} legend={Legend({ right: 10, top: 0, data: legend, theme: theme as Theme, })} grid={{top: 30, bottom: 0, left: 0, right: 0}} {...zoomRenderProps} /> )} ), fixed: 'Customer Stats Chart', })} ); } const Footer = styled('div')` display: flex; justify-content: space-between; border-top: 1px solid ${p => p.theme.border}; margin: ${space(3)} -${space(2)} -${space(2)} -${space(2)}; padding: ${space(2)}; color: ${p => p.theme.subText}; `; const LegendContainer = styled('div')` &, > div { display: flex; align-items: center; flex-wrap: wrap; gap: ${space(4)}; } > div { gap: ${space(0.5)}; } `;