customerStats.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
  2. import type {Theme} from '@emotion/react';
  3. import {useTheme} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import cloneDeep from 'lodash/cloneDeep';
  6. import startCase from 'lodash/startCase';
  7. import moment from 'moment-timezone';
  8. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  9. import {BarChart} from 'sentry/components/charts/barChart';
  10. import ChartZoom from 'sentry/components/charts/chartZoom';
  11. import Legend from 'sentry/components/charts/components/legend';
  12. import type {TooltipSubLabel} from 'sentry/components/charts/components/tooltip';
  13. import {type DateTimeObject, getInterval} from 'sentry/components/charts/utils';
  14. import LoadingError from 'sentry/components/loadingError';
  15. import LoadingIndicator from 'sentry/components/loadingIndicator';
  16. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  17. import {space} from 'sentry/styles/space';
  18. import type {DataPoint} from 'sentry/types/echarts';
  19. import type {Organization} from 'sentry/types/organization';
  20. import type {Project} from 'sentry/types/project';
  21. import {defined} from 'sentry/utils';
  22. import getDynamicText from 'sentry/utils/getDynamicText';
  23. import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse';
  24. import useApi from 'sentry/utils/useApi';
  25. import useRouter from 'sentry/utils/useRouter';
  26. import {
  27. categoryFromDataType,
  28. type DataType,
  29. } from 'admin/components/customers/customerStatsFilters';
  30. enum SeriesName {
  31. ACCEPTED = 'Accepted',
  32. FILTERED = 'Filtered (Server)',
  33. OVER_QUOTA = 'Over Quota',
  34. DISCARDED = 'Discarded (Client)',
  35. DROPPED = 'Dropped (Server)',
  36. }
  37. type SubSeries = {
  38. data: DataPoint[];
  39. seriesName: string;
  40. };
  41. type SeriesItem = {
  42. data: DataPoint[];
  43. seriesName: string;
  44. color?: string;
  45. subSeries?: SubSeries[];
  46. };
  47. /** @internal exported for tests only */
  48. export type StatsGroup = {
  49. by: {
  50. outcome: string;
  51. reason: string;
  52. };
  53. series: Record<string, number[]>;
  54. totals: Record<string, number>;
  55. };
  56. type Stats = {
  57. groups: StatsGroup[];
  58. intervals: Array<string | number>;
  59. };
  60. type LegendProps = {
  61. points: Stats;
  62. };
  63. export const useSeries = (): Record<string, SeriesItem> => {
  64. const theme = useTheme();
  65. return {
  66. accepted: {
  67. seriesName: SeriesName.ACCEPTED,
  68. data: [],
  69. color: theme.purple300,
  70. },
  71. overQuota: {
  72. seriesName: SeriesName.OVER_QUOTA,
  73. data: [],
  74. color: theme.pink200,
  75. },
  76. totalFiltered: {
  77. seriesName: SeriesName.FILTERED,
  78. data: [],
  79. color: theme.purple200,
  80. },
  81. totalDiscarded: {
  82. seriesName: SeriesName.DISCARDED,
  83. data: [],
  84. color: theme.yellow300,
  85. },
  86. totalDropped: {
  87. seriesName: SeriesName.DROPPED,
  88. data: [],
  89. color: theme.red300,
  90. },
  91. };
  92. };
  93. function zeroFillDates(start: number, end: number, {color}: {color: string}) {
  94. const zero: SeriesItem = {
  95. seriesName: SeriesName.ACCEPTED,
  96. data: [],
  97. color,
  98. };
  99. const numberOfIntervals = Math.ceil((end - start) / 86400);
  100. if (numberOfIntervals >= 0) {
  101. zero.data = [...new Array(numberOfIntervals).keys()].map(i => ({
  102. name: new Date((start + (i + 1) * 86400) * 1000).toString(),
  103. value: 0,
  104. }));
  105. }
  106. return zero;
  107. }
  108. /** @internal exported for tests only */
  109. export function populateChartData(
  110. intervals: Array<string | number>,
  111. groups: StatsGroup[],
  112. series: Record<string, SeriesItem>
  113. ): SeriesItem[] {
  114. const {accepted, totalFiltered, totalDiscarded, totalDropped, overQuota} =
  115. cloneDeep(series);
  116. const outcomeMapping = {accepted, totalDiscarded};
  117. const filteredData: Record<string, SeriesItem> = {};
  118. const discardedData: Record<string, SeriesItem> = {};
  119. const droppedData: Record<string, SeriesItem> = {};
  120. intervals.forEach((timestamp, dateIndex) => {
  121. groups.forEach(point => {
  122. const dataObject = {
  123. name: timestamp.toString(),
  124. value: point.series['sum(quantity)']![dateIndex]!,
  125. };
  126. if (point.by.outcome === 'filtered') {
  127. if (point.by.reason?.startsWith('Sampled:')) {
  128. if (filteredData['dynamic-sampling'] === undefined) {
  129. filteredData['dynamic-sampling'] = {
  130. seriesName: 'Dynamic Sampling',
  131. data: [],
  132. };
  133. }
  134. if (dateIndex >= filteredData['dynamic-sampling'].data.length) {
  135. filteredData['dynamic-sampling'].data.push(dataObject);
  136. } else {
  137. filteredData['dynamic-sampling']!.data[dateIndex]!.value += dataObject.value;
  138. }
  139. } else {
  140. // dynamically adding filtered reasons into graph
  141. if (filteredData[point.by.reason] === undefined) {
  142. filteredData[point.by.reason] = {
  143. seriesName: startCase(point.by.reason?.replace(/-|_/g, ' ')),
  144. data: [],
  145. };
  146. }
  147. filteredData[point.by.reason]!.data.push(dataObject);
  148. }
  149. if (dateIndex >= totalFiltered!.data.length) {
  150. totalFiltered!.data.push({...dataObject, value: 0});
  151. }
  152. return;
  153. }
  154. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  155. if (outcomeMapping[point.by.outcome]) {
  156. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  157. if (dateIndex >= outcomeMapping[point.by.outcome].data.length) {
  158. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  159. outcomeMapping[point.by.outcome].data.push(dataObject);
  160. return;
  161. }
  162. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  163. outcomeMapping[point.by.outcome].data[dateIndex].value += dataObject.value;
  164. return;
  165. }
  166. // below are the dropped outcome cases
  167. if (['usage_exceeded', 'grace_period'].includes(point.by.reason)) {
  168. // combined usage_exceeded and grace_period into over quota
  169. if (dateIndex >= overQuota!.data.length) {
  170. overQuota!.data.push(dataObject);
  171. return;
  172. }
  173. overQuota!.data[dateIndex]!.value += dataObject.value;
  174. return;
  175. }
  176. if (point.by.outcome === 'client_discard') {
  177. // dynamically adding discarded reasons into graph
  178. if (discardedData[point.by.reason] === undefined) {
  179. discardedData[point.by.reason] = {
  180. seriesName: startCase(point.by.reason?.replace(/-|_/g, ' ')),
  181. data: [],
  182. };
  183. }
  184. discardedData[point.by.reason]!.data.push(dataObject);
  185. if (dateIndex >= totalDiscarded!.data.length) {
  186. totalDiscarded!.data.push({...dataObject, value: 0});
  187. }
  188. return;
  189. }
  190. if (point.by.outcome === 'abuse' && point.by.reason === 'none') {
  191. if (droppedData.abuse === undefined) {
  192. droppedData.abuse = {
  193. seriesName: 'Abuse',
  194. data: [],
  195. };
  196. }
  197. droppedData.abuse.data.push(dataObject);
  198. if (dateIndex >= totalDropped!.data.length) {
  199. totalDropped!.data.push({...dataObject, value: 0});
  200. }
  201. return;
  202. }
  203. // dynamically adding dropped reasons into graph
  204. if (droppedData[point.by.reason] === undefined) {
  205. droppedData[point.by.reason] = {
  206. seriesName: startCase(point.by.reason?.replace(/-|_/g, ' ')),
  207. data: [],
  208. };
  209. }
  210. droppedData[point.by.reason]!.data.push(dataObject);
  211. if (dateIndex >= totalDropped!.data.length) {
  212. totalDropped!.data.push({...dataObject, value: 0});
  213. }
  214. });
  215. });
  216. for (const data of Object.values(filteredData)) {
  217. totalFiltered!.subSeries = totalFiltered!.subSeries ?? [];
  218. totalFiltered!.subSeries.push({seriesName: data.seriesName, data: data.data});
  219. for (const dataIndex in data.data) {
  220. totalFiltered!.data[dataIndex]!.value += data.data[dataIndex]!.value;
  221. }
  222. }
  223. for (const data of Object.values(discardedData)) {
  224. totalDiscarded!.subSeries = totalDiscarded!.subSeries ?? [];
  225. totalDiscarded!.subSeries.push({seriesName: data.seriesName, data: data.data});
  226. for (const dataIndex in data.data) {
  227. totalDiscarded!.data[dataIndex]!.value += data.data[dataIndex]!.value;
  228. }
  229. }
  230. for (const data of Object.values(droppedData)) {
  231. totalDropped!.subSeries = totalDropped!.subSeries ?? [];
  232. totalDropped!.subSeries.push({seriesName: data.seriesName, data: data.data});
  233. for (const dataIndex in data.data) {
  234. totalDropped!.data[dataIndex]!.value += data.data[dataIndex]!.value;
  235. }
  236. }
  237. return [accepted!, totalFiltered!, overQuota!, totalDiscarded!, totalDropped!];
  238. }
  239. function FooterLegend({points}: LegendProps) {
  240. let accepted = 0;
  241. let filtered = 0;
  242. let total = 0;
  243. let discarded = 0;
  244. let dropped = 0;
  245. points.groups.forEach(point => {
  246. switch (point.by.outcome) {
  247. case 'filtered':
  248. filtered += point.totals['sum(quantity)']!;
  249. break;
  250. case 'accepted':
  251. accepted += point.totals['sum(quantity)']!;
  252. break;
  253. case 'client_discard':
  254. discarded += point.totals['sum(quantity)']!;
  255. break;
  256. default:
  257. dropped += point.totals['sum(quantity)']!;
  258. break;
  259. }
  260. total += point.totals['sum(quantity)']!;
  261. });
  262. return (
  263. <LegendContainer>
  264. <div>
  265. <strong>Total</strong>
  266. {total.toLocaleString()}
  267. </div>
  268. <div>
  269. <strong>{SeriesName.ACCEPTED}</strong>
  270. {accepted.toLocaleString()}
  271. </div>
  272. <div>
  273. <strong>{SeriesName.FILTERED}</strong>
  274. {filtered.toLocaleString()}
  275. </div>
  276. <div>
  277. <strong>{SeriesName.DISCARDED}</strong>
  278. {discarded.toLocaleString()}
  279. </div>
  280. <div>
  281. <strong>{SeriesName.DROPPED}</strong>
  282. {dropped.toLocaleString()}
  283. </div>
  284. </LegendContainer>
  285. );
  286. }
  287. type Props = {
  288. dataType: DataType;
  289. orgSlug: Organization['slug'];
  290. onDemandPeriodEnd?: string;
  291. onDemandPeriodStart?: string;
  292. projectId?: Project['id'];
  293. };
  294. export function CustomerStats({
  295. orgSlug,
  296. projectId,
  297. dataType,
  298. onDemandPeriodStart,
  299. onDemandPeriodEnd,
  300. }: Props) {
  301. const api = useApi();
  302. const [stats, setStats] = useState<Stats | null>(null);
  303. const [loading, setLoading] = useState<boolean>(false);
  304. const [error, setError] = useState<Error | null>(null);
  305. const router = useRouter();
  306. const dataDatetime = useMemo((): DateTimeObject => {
  307. const {
  308. start,
  309. end,
  310. utc: utcString,
  311. statsPeriod,
  312. } = normalizeDateTimeParams(router.location.query, {
  313. allowEmptyPeriod: true,
  314. allowAbsoluteDatetime: true,
  315. allowAbsolutePageDatetime: true,
  316. });
  317. const utc = utcString === 'true';
  318. if (!start && !end && !statsPeriod && onDemandPeriodStart && onDemandPeriodEnd) {
  319. return {
  320. start: onDemandPeriodStart,
  321. end: onDemandPeriodEnd,
  322. };
  323. }
  324. if (start && end) {
  325. return utc
  326. ? {
  327. start: moment.utc(start).format(),
  328. end: moment.utc(end).format(),
  329. utc,
  330. }
  331. : {
  332. start: moment(start).utc().format(),
  333. end: moment(end).utc().format(),
  334. utc,
  335. };
  336. }
  337. return {
  338. period: statsPeriod ?? '90d',
  339. };
  340. }, [router.location.query, onDemandPeriodStart, onDemandPeriodEnd]);
  341. const fetchStatsRequest = useCallback(() => {
  342. return api.requestPromise(`/organizations/${orgSlug}/stats_v2/`, {
  343. query: {
  344. start: dataDatetime.start,
  345. end: dataDatetime.end,
  346. utc: dataDatetime.utc,
  347. statsPeriod: dataDatetime.period,
  348. interval: getInterval(dataDatetime),
  349. groupBy: ['outcome', 'reason'],
  350. field: 'sum(quantity)',
  351. category: categoryFromDataType(dataType),
  352. ...(projectId ? {project: projectId} : {}),
  353. },
  354. });
  355. }, [api, dataType, dataDatetime, orgSlug, projectId]);
  356. const fetchStats = useCallback(async () => {
  357. setLoading(true);
  358. try {
  359. const response = await fetchStatsRequest();
  360. setStats(response);
  361. } catch (err) {
  362. const message = 'Unable to load stats data';
  363. handleXhrErrorResponse(message, err);
  364. addErrorMessage(message);
  365. setError(err);
  366. } finally {
  367. setLoading(false);
  368. }
  369. }, [fetchStatsRequest]);
  370. useEffect(() => {
  371. fetchStats();
  372. }, [dataType, dataDatetime, fetchStats]);
  373. const theme = useTheme();
  374. const series = useSeries();
  375. if (loading) {
  376. return <LoadingIndicator />;
  377. }
  378. if (error) {
  379. return <LoadingError onRetry={() => fetchStats()} />;
  380. }
  381. if (stats === null) {
  382. return null;
  383. }
  384. const {intervals, groups} = stats;
  385. const zeroFillStart = Number(new Date(intervals[intervals.length - 1]!)) / 1000 + 86400;
  386. const chartSeries = [
  387. ...populateChartData(intervals, groups, series),
  388. zeroFillDates(
  389. zeroFillStart,
  390. new Date(dataDatetime.end ?? moment().format()).valueOf() / 1000,
  391. {color: theme.purple200}
  392. ),
  393. ];
  394. const {legend, subLabels} = chartSeries.reduce(
  395. (acc, serie) => {
  396. if (!acc.legend.includes(serie!.seriesName) && serie!.data.length > 0) {
  397. acc.legend.push(serie!.seriesName);
  398. }
  399. if (!serie!.subSeries) {
  400. return acc;
  401. }
  402. for (const subSerie of serie!.subSeries) {
  403. acc.subLabels.push({
  404. parentLabel: serie!.seriesName,
  405. label: subSerie.seriesName,
  406. data: subSerie.data,
  407. });
  408. }
  409. return acc;
  410. },
  411. {
  412. legend: [] as string[],
  413. subLabels: [] as TooltipSubLabel[],
  414. }
  415. );
  416. return (
  417. <Fragment>
  418. {getDynamicText({
  419. value: (
  420. <ChartZoom
  421. period={dataDatetime.period}
  422. start={dataDatetime.start}
  423. end={dataDatetime.end}
  424. utc={dataDatetime.utc}
  425. >
  426. {zoomRenderProps => (
  427. <Fragment>
  428. <BarChart
  429. isGroupedByDate
  430. stacked
  431. animation={false}
  432. series={chartSeries}
  433. colors={Object.values(series)
  434. .map(serie => serie.color)
  435. .filter(defined)}
  436. tooltip={{subLabels}}
  437. legend={Legend({
  438. right: 10,
  439. top: 0,
  440. data: legend,
  441. theme: theme as Theme,
  442. })}
  443. grid={{top: 30, bottom: 0, left: 0, right: 0}}
  444. {...zoomRenderProps}
  445. />
  446. <Footer>
  447. <FooterLegend points={stats} />
  448. </Footer>
  449. </Fragment>
  450. )}
  451. </ChartZoom>
  452. ),
  453. fixed: 'Customer Stats Chart',
  454. })}
  455. </Fragment>
  456. );
  457. }
  458. const Footer = styled('div')`
  459. display: flex;
  460. justify-content: space-between;
  461. border-top: 1px solid ${p => p.theme.border};
  462. margin: ${space(3)} -${space(2)} -${space(2)} -${space(2)};
  463. padding: ${space(2)};
  464. color: ${p => p.theme.subText};
  465. `;
  466. const LegendContainer = styled('div')`
  467. &,
  468. > div {
  469. display: flex;
  470. align-items: center;
  471. flex-wrap: wrap;
  472. gap: ${space(4)};
  473. }
  474. > div {
  475. gap: ${space(0.5)};
  476. }
  477. `;