usageStatsOrg.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import * as Sentry from '@sentry/react';
  4. import isEqual from 'lodash/isEqual';
  5. import moment from 'moment';
  6. import AsyncComponent from 'sentry/components/asyncComponent';
  7. import OptionSelector from 'sentry/components/charts/optionSelector';
  8. import {InlineContainer, SectionHeading} from 'sentry/components/charts/styles';
  9. import {DateTimeObject, getSeriesApiInterval} from 'sentry/components/charts/utils';
  10. import ErrorBoundary from 'sentry/components/errorBoundary';
  11. import NotAvailable from 'sentry/components/notAvailable';
  12. import ScoreCard, {ScoreCardProps} from 'sentry/components/scoreCard';
  13. import {DEFAULT_STATS_PERIOD} from 'sentry/constants';
  14. import {t, tct} from 'sentry/locale';
  15. import {space} from 'sentry/styles/space';
  16. import {DataCategoryInfo, IntervalPeriod, Organization, Outcome} from 'sentry/types';
  17. import {parsePeriodToHours} from 'sentry/utils/dates';
  18. import {
  19. FORMAT_DATETIME_DAILY,
  20. FORMAT_DATETIME_HOURLY,
  21. getDateFromMoment,
  22. } from './usageChart/utils';
  23. import {UsageSeries, UsageStat} from './types';
  24. import UsageChart, {
  25. CHART_OPTIONS_DATA_TRANSFORM,
  26. ChartDataTransform,
  27. ChartStats,
  28. UsageChartProps,
  29. } from './usageChart';
  30. import UsageStatsPerMin from './usageStatsPerMin';
  31. import {formatUsageWithUnits, getFormatUsageOptions, isDisplayUtc} from './utils';
  32. export type UsageStatsOrganizationProps = {
  33. dataCategory: DataCategoryInfo['plural'];
  34. dataCategoryName: string;
  35. dataDatetime: DateTimeObject;
  36. handleChangeState: (state: {
  37. dataCategory?: DataCategoryInfo['plural'];
  38. pagePeriod?: string | null;
  39. transform?: ChartDataTransform;
  40. }) => void;
  41. isSingleProject: boolean;
  42. organization: Organization;
  43. projectIds: number[];
  44. chartTransform?: string;
  45. } & AsyncComponent['props'];
  46. type UsageStatsOrganizationState = {
  47. orgStats: UsageSeries | undefined;
  48. } & AsyncComponent['state'];
  49. /**
  50. * This component is replaced by EnhancedUsageStatsOrganization in getsentry, which inherits
  51. * heavily from this one. Take care if changing any existing function signatures to ensure backwards
  52. * compatibility.
  53. */
  54. class UsageStatsOrganization<
  55. P extends UsageStatsOrganizationProps = UsageStatsOrganizationProps,
  56. S extends UsageStatsOrganizationState = UsageStatsOrganizationState
  57. > extends AsyncComponent<P, S> {
  58. componentDidUpdate(prevProps: UsageStatsOrganizationProps) {
  59. const {dataDatetime: prevDateTime, projectIds: prevProjectIds} = prevProps;
  60. const {dataDatetime: currDateTime, projectIds: currProjectIds} = this.props;
  61. if (
  62. prevDateTime.start !== currDateTime.start ||
  63. prevDateTime.end !== currDateTime.end ||
  64. prevDateTime.period !== currDateTime.period ||
  65. prevDateTime.utc !== currDateTime.utc ||
  66. !isEqual(prevProjectIds, currProjectIds)
  67. ) {
  68. this.reloadData();
  69. }
  70. }
  71. getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
  72. return [['orgStats', this.endpointPath, {query: this.endpointQuery}]];
  73. }
  74. /** List of components to render on single-project view */
  75. get projectDetails(): JSX.Element[] {
  76. return [];
  77. }
  78. get endpointPath() {
  79. const {organization} = this.props;
  80. return `/organizations/${organization.slug}/stats_v2/`;
  81. }
  82. get endpointQueryDatetime() {
  83. const {dataDatetime} = this.props;
  84. const queryDatetime =
  85. dataDatetime.start && dataDatetime.end
  86. ? {
  87. start: dataDatetime.start,
  88. end: dataDatetime.end,
  89. utc: dataDatetime.utc,
  90. }
  91. : {
  92. statsPeriod: dataDatetime.period || DEFAULT_STATS_PERIOD,
  93. };
  94. return queryDatetime;
  95. }
  96. get endpointQuery() {
  97. const {dataDatetime, projectIds} = this.props;
  98. const queryDatetime = this.endpointQueryDatetime;
  99. return {
  100. ...queryDatetime,
  101. interval: getSeriesApiInterval(dataDatetime),
  102. groupBy: ['category', 'outcome'],
  103. project: projectIds,
  104. field: ['sum(quantity)'],
  105. };
  106. }
  107. get chartData(): {
  108. cardStats: {
  109. accepted?: string;
  110. dropped?: string;
  111. filtered?: string;
  112. total?: string;
  113. };
  114. chartDateEnd: string;
  115. chartDateEndDisplay: string;
  116. chartDateInterval: IntervalPeriod;
  117. chartDateStart: string;
  118. chartDateStartDisplay: string;
  119. chartDateTimezoneDisplay: string;
  120. chartDateUtc: boolean;
  121. chartStats: ChartStats;
  122. chartTransform: ChartDataTransform;
  123. dataError?: Error;
  124. } {
  125. const {orgStats} = this.state;
  126. return {
  127. ...this.mapSeriesToChart(orgStats),
  128. ...this.chartDateRange,
  129. ...this.chartTransform,
  130. };
  131. }
  132. get chartTransform(): {chartTransform: ChartDataTransform} {
  133. const {chartTransform} = this.props;
  134. switch (chartTransform) {
  135. case ChartDataTransform.CUMULATIVE:
  136. case ChartDataTransform.PERIODIC:
  137. return {chartTransform};
  138. default:
  139. return {chartTransform: ChartDataTransform.PERIODIC};
  140. }
  141. }
  142. get chartDateRange(): {
  143. chartDateEnd: string;
  144. chartDateEndDisplay: string;
  145. chartDateInterval: IntervalPeriod;
  146. chartDateStart: string;
  147. chartDateStartDisplay: string;
  148. chartDateTimezoneDisplay: string;
  149. chartDateUtc: boolean;
  150. } {
  151. const {orgStats} = this.state;
  152. const {dataDatetime} = this.props;
  153. const interval = getSeriesApiInterval(dataDatetime);
  154. // Use fillers as loading/error states will not display datetime at all
  155. if (!orgStats || !orgStats.intervals) {
  156. return {
  157. chartDateInterval: interval,
  158. chartDateStart: '',
  159. chartDateEnd: '',
  160. chartDateUtc: true,
  161. chartDateStartDisplay: '',
  162. chartDateEndDisplay: '',
  163. chartDateTimezoneDisplay: '',
  164. };
  165. }
  166. const {intervals} = orgStats;
  167. const intervalHours = parsePeriodToHours(interval);
  168. // Keep datetime in UTC until we want to display it to users
  169. const startTime = moment(intervals[0]).utc();
  170. const endTime =
  171. intervals.length < 2
  172. ? moment(startTime) // when statsPeriod and interval is the same value
  173. : moment(intervals[intervals.length - 1]).utc();
  174. const useUtc = isDisplayUtc(dataDatetime);
  175. // If interval is a day or more, use UTC to format date. Otherwise, the date
  176. // may shift ahead/behind when converting to the user's local time.
  177. const FORMAT_DATETIME =
  178. intervalHours >= 24 ? FORMAT_DATETIME_DAILY : FORMAT_DATETIME_HOURLY;
  179. const xAxisStart = moment(startTime);
  180. const xAxisEnd = moment(endTime);
  181. const displayStart = useUtc ? moment(startTime).utc() : moment(startTime).local();
  182. const displayEnd = useUtc ? moment(endTime).utc() : moment(endTime).local();
  183. if (intervalHours < 24) {
  184. displayEnd.add(intervalHours, 'h');
  185. }
  186. return {
  187. chartDateInterval: interval,
  188. chartDateStart: xAxisStart.format(),
  189. chartDateEnd: xAxisEnd.format(),
  190. chartDateUtc: useUtc,
  191. chartDateStartDisplay: displayStart.format(FORMAT_DATETIME),
  192. chartDateEndDisplay: displayEnd.format(FORMAT_DATETIME),
  193. chartDateTimezoneDisplay: displayStart.format('Z'),
  194. };
  195. }
  196. get chartProps(): UsageChartProps {
  197. const {dataCategory} = this.props;
  198. const {error, errors, loading} = this.state;
  199. const {
  200. chartStats,
  201. dataError,
  202. chartDateInterval,
  203. chartDateStart,
  204. chartDateEnd,
  205. chartDateUtc,
  206. chartTransform,
  207. } = this.chartData;
  208. const hasError = error || !!dataError;
  209. const chartErrors: any = dataError ? {...errors, data: dataError} : errors; // TODO(ts): AsyncComponent
  210. const chartProps = {
  211. isLoading: loading,
  212. isError: hasError,
  213. errors: chartErrors,
  214. title: ' ', // Force the title to be blank
  215. footer: this.renderChartFooter(),
  216. dataCategory,
  217. dataTransform: chartTransform,
  218. usageDateStart: chartDateStart,
  219. usageDateEnd: chartDateEnd,
  220. usageDateShowUtc: chartDateUtc,
  221. usageDateInterval: chartDateInterval,
  222. usageStats: chartStats,
  223. } as UsageChartProps;
  224. return chartProps;
  225. }
  226. get cardMetadata() {
  227. const {dataCategory, dataCategoryName, organization, projectIds} = this.props;
  228. const {total, accepted, dropped, filtered} = this.chartData.cardStats;
  229. const cardMetadata: Record<string, ScoreCardProps> = {
  230. total: {
  231. title: tct('Total [dataCategory]', {dataCategory: dataCategoryName}),
  232. score: total,
  233. },
  234. accepted: {
  235. title: tct('Accepted [dataCategory]', {dataCategory: dataCategoryName}),
  236. help: tct('Accepted [dataCategory] were successfully processed by Sentry', {
  237. dataCategory,
  238. }),
  239. score: accepted,
  240. trend: (
  241. <UsageStatsPerMin
  242. dataCategory={dataCategory}
  243. organization={organization}
  244. projectIds={projectIds}
  245. />
  246. ),
  247. },
  248. filtered: {
  249. title: tct('Filtered [dataCategory]', {dataCategory: dataCategoryName}),
  250. help: tct(
  251. 'Filtered [dataCategory] were blocked due to your inbound data filter rules',
  252. {dataCategory}
  253. ),
  254. score: filtered,
  255. },
  256. dropped: {
  257. title: tct('Dropped [dataCategory]', {dataCategory: dataCategoryName}),
  258. help: tct(
  259. 'Dropped [dataCategory] were discarded due to invalid data, rate-limits, quota limits, or spike protection',
  260. {dataCategory}
  261. ),
  262. score: dropped,
  263. },
  264. };
  265. return cardMetadata;
  266. }
  267. mapSeriesToChart(orgStats?: UsageSeries): {
  268. cardStats: {
  269. accepted?: string;
  270. dropped?: string;
  271. filtered?: string;
  272. total?: string;
  273. };
  274. chartStats: ChartStats;
  275. dataError?: Error;
  276. } {
  277. const cardStats = {
  278. total: undefined,
  279. accepted: undefined,
  280. dropped: undefined,
  281. filtered: undefined,
  282. };
  283. const chartStats: ChartStats = {
  284. accepted: [],
  285. dropped: [],
  286. projected: [],
  287. filtered: [],
  288. };
  289. if (!orgStats) {
  290. return {cardStats, chartStats};
  291. }
  292. try {
  293. const {dataCategory} = this.props;
  294. const {chartDateInterval, chartDateUtc} = this.chartDateRange;
  295. const usageStats: UsageStat[] = orgStats.intervals.map(interval => {
  296. const dateTime = moment(interval);
  297. return {
  298. date: getDateFromMoment(dateTime, chartDateInterval, chartDateUtc),
  299. total: 0,
  300. accepted: 0,
  301. filtered: 0,
  302. dropped: {total: 0},
  303. };
  304. });
  305. // Tally totals for card data
  306. const count: Record<'total' | Outcome, number> = {
  307. total: 0,
  308. [Outcome.ACCEPTED]: 0,
  309. [Outcome.FILTERED]: 0,
  310. [Outcome.DROPPED]: 0,
  311. [Outcome.INVALID]: 0, // Combined with dropped later
  312. [Outcome.RATE_LIMITED]: 0, // Combined with dropped later
  313. [Outcome.CLIENT_DISCARD]: 0, // Not exposed yet
  314. };
  315. orgStats.groups.forEach(group => {
  316. const {outcome, category} = group.by;
  317. // HACK: The backend enum are singular, but the frontend enums are plural
  318. if (!dataCategory.includes(`${category}`)) {
  319. return;
  320. }
  321. if (outcome !== Outcome.CLIENT_DISCARD) {
  322. count.total += group.totals['sum(quantity)'];
  323. }
  324. count[outcome] += group.totals['sum(quantity)'];
  325. group.series['sum(quantity)'].forEach((stat, i) => {
  326. switch (outcome) {
  327. case Outcome.ACCEPTED:
  328. case Outcome.FILTERED:
  329. usageStats[i][outcome] += stat;
  330. return;
  331. case Outcome.DROPPED:
  332. case Outcome.RATE_LIMITED:
  333. case Outcome.INVALID:
  334. usageStats[i].dropped.total += stat;
  335. // TODO: add client discards to dropped?
  336. return;
  337. default:
  338. return;
  339. }
  340. });
  341. });
  342. // Invalid and rate_limited data is combined with dropped
  343. count[Outcome.DROPPED] += count[Outcome.INVALID];
  344. count[Outcome.DROPPED] += count[Outcome.RATE_LIMITED];
  345. usageStats.forEach(stat => {
  346. stat.total = stat.accepted + stat.filtered + stat.dropped.total;
  347. // Chart Data
  348. (chartStats.accepted as any[]).push({value: [stat.date, stat.accepted]});
  349. (chartStats.dropped as any[]).push({
  350. value: [stat.date, stat.dropped.total],
  351. } as any);
  352. (chartStats.filtered as any[])?.push({value: [stat.date, stat.filtered]});
  353. });
  354. return {
  355. cardStats: {
  356. total: formatUsageWithUnits(
  357. count.total,
  358. dataCategory,
  359. getFormatUsageOptions(dataCategory)
  360. ),
  361. accepted: formatUsageWithUnits(
  362. count[Outcome.ACCEPTED],
  363. dataCategory,
  364. getFormatUsageOptions(dataCategory)
  365. ),
  366. filtered: formatUsageWithUnits(
  367. count[Outcome.FILTERED],
  368. dataCategory,
  369. getFormatUsageOptions(dataCategory)
  370. ),
  371. dropped: formatUsageWithUnits(
  372. count[Outcome.DROPPED],
  373. dataCategory,
  374. getFormatUsageOptions(dataCategory)
  375. ),
  376. },
  377. chartStats,
  378. };
  379. } catch (err) {
  380. Sentry.withScope(scope => {
  381. scope.setContext('query', this.endpointQuery);
  382. scope.setContext('body', {...orgStats});
  383. Sentry.captureException(err);
  384. });
  385. return {
  386. cardStats,
  387. chartStats,
  388. dataError: new Error('Failed to parse stats data'),
  389. };
  390. }
  391. }
  392. renderCards() {
  393. const {loading} = this.state;
  394. const cardMetadata = Object.values(this.cardMetadata);
  395. return cardMetadata.map((card, i) => (
  396. <StyledScoreCard
  397. key={i}
  398. title={card.title}
  399. score={loading ? undefined : card.score}
  400. help={card.help}
  401. trend={card.trend}
  402. />
  403. ));
  404. }
  405. renderChart() {
  406. const {loading} = this.state;
  407. return loading ? null : <UsageChart {...this.chartProps} />;
  408. }
  409. renderChartFooter = () => {
  410. const {handleChangeState} = this.props;
  411. const {loading, error} = this.state;
  412. const {
  413. chartDateInterval,
  414. chartTransform,
  415. chartDateStartDisplay,
  416. chartDateEndDisplay,
  417. chartDateTimezoneDisplay,
  418. } = this.chartData;
  419. return (
  420. <Footer>
  421. <InlineContainer>
  422. <FooterDate>
  423. <SectionHeading>{t('Date Range:')}</SectionHeading>
  424. <span>
  425. {loading || error ? (
  426. <NotAvailable />
  427. ) : (
  428. tct('[start] — [end] ([timezone] UTC, [interval] interval)', {
  429. start: chartDateStartDisplay,
  430. end: chartDateEndDisplay,
  431. timezone: chartDateTimezoneDisplay,
  432. interval: chartDateInterval,
  433. })
  434. )}
  435. </span>
  436. </FooterDate>
  437. </InlineContainer>
  438. <InlineContainer>
  439. <OptionSelector
  440. title={t('Type')}
  441. selected={chartTransform}
  442. options={CHART_OPTIONS_DATA_TRANSFORM}
  443. onChange={(val: string) =>
  444. handleChangeState({transform: val as ChartDataTransform})
  445. }
  446. />
  447. </InlineContainer>
  448. </Footer>
  449. );
  450. };
  451. renderProjectDetails() {
  452. const {isSingleProject} = this.props;
  453. const projectDetails = this.projectDetails.map((projectDetailComponent, i) => (
  454. <ErrorBoundary mini key={i}>
  455. {projectDetailComponent}
  456. </ErrorBoundary>
  457. ));
  458. return isSingleProject ? projectDetails : null;
  459. }
  460. renderComponent() {
  461. return (
  462. <Fragment>
  463. <PageGrid>
  464. {this.renderCards()}
  465. <ChartWrapper data-test-id="usage-stats-chart">
  466. {this.renderChart()}
  467. </ChartWrapper>
  468. </PageGrid>
  469. {this.renderProjectDetails()}
  470. </Fragment>
  471. );
  472. }
  473. }
  474. export default UsageStatsOrganization;
  475. const PageGrid = styled('div')`
  476. display: grid;
  477. grid-template-columns: 1fr;
  478. gap: ${space(2)};
  479. @media (min-width: ${p => p.theme.breakpoints.small}) {
  480. grid-template-columns: repeat(2, 1fr);
  481. }
  482. @media (min-width: ${p => p.theme.breakpoints.large}) {
  483. grid-template-columns: repeat(4, 1fr);
  484. }
  485. `;
  486. const StyledScoreCard = styled(ScoreCard)`
  487. grid-column: auto / span 1;
  488. margin: 0;
  489. `;
  490. const ChartWrapper = styled('div')`
  491. grid-column: 1 / -1;
  492. `;
  493. const Footer = styled('div')`
  494. display: flex;
  495. flex-direction: row;
  496. justify-content: space-between;
  497. padding: ${space(1)} ${space(3)};
  498. border-top: 1px solid ${p => p.theme.border};
  499. `;
  500. const FooterDate = styled('div')`
  501. display: flex;
  502. flex-direction: row;
  503. align-items: center;
  504. > ${SectionHeading} {
  505. margin-right: ${space(1.5)};
  506. }
  507. > span:last-child {
  508. font-weight: 400;
  509. font-size: ${p => p.theme.fontSizeMedium};
  510. }
  511. `;