usageStatsOrg.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  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} = 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 organization={organization} dataCategory={dataCategory} />
  242. ),
  243. },
  244. filtered: {
  245. title: tct('Filtered [dataCategory]', {dataCategory: dataCategoryName}),
  246. help: tct(
  247. 'Filtered [dataCategory] were blocked due to your inbound data filter rules',
  248. {dataCategory}
  249. ),
  250. score: filtered,
  251. },
  252. dropped: {
  253. title: tct('Dropped [dataCategory]', {dataCategory: dataCategoryName}),
  254. help: tct(
  255. 'Dropped [dataCategory] were discarded due to invalid data, rate-limits, quota limits, or spike protection',
  256. {dataCategory}
  257. ),
  258. score: dropped,
  259. },
  260. };
  261. return cardMetadata;
  262. }
  263. mapSeriesToChart(orgStats?: UsageSeries): {
  264. cardStats: {
  265. accepted?: string;
  266. dropped?: string;
  267. filtered?: string;
  268. total?: string;
  269. };
  270. chartStats: ChartStats;
  271. dataError?: Error;
  272. } {
  273. const cardStats = {
  274. total: undefined,
  275. accepted: undefined,
  276. dropped: undefined,
  277. filtered: undefined,
  278. };
  279. const chartStats: ChartStats = {
  280. accepted: [],
  281. dropped: [],
  282. projected: [],
  283. filtered: [],
  284. };
  285. if (!orgStats) {
  286. return {cardStats, chartStats};
  287. }
  288. try {
  289. const {dataCategory} = this.props;
  290. const {chartDateInterval, chartDateUtc} = this.chartDateRange;
  291. const usageStats: UsageStat[] = orgStats.intervals.map(interval => {
  292. const dateTime = moment(interval);
  293. return {
  294. date: getDateFromMoment(dateTime, chartDateInterval, chartDateUtc),
  295. total: 0,
  296. accepted: 0,
  297. filtered: 0,
  298. dropped: {total: 0},
  299. };
  300. });
  301. // Tally totals for card data
  302. const count: Record<'total' | Outcome, number> = {
  303. total: 0,
  304. [Outcome.ACCEPTED]: 0,
  305. [Outcome.FILTERED]: 0,
  306. [Outcome.DROPPED]: 0,
  307. [Outcome.INVALID]: 0, // Combined with dropped later
  308. [Outcome.RATE_LIMITED]: 0, // Combined with dropped later
  309. [Outcome.CLIENT_DISCARD]: 0, // Not exposed yet
  310. };
  311. orgStats.groups.forEach(group => {
  312. const {outcome, category} = group.by;
  313. // HACK: The backend enum are singular, but the frontend enums are plural
  314. if (!dataCategory.includes(`${category}`)) {
  315. return;
  316. }
  317. if (outcome !== Outcome.CLIENT_DISCARD) {
  318. count.total += group.totals['sum(quantity)'];
  319. }
  320. count[outcome] += group.totals['sum(quantity)'];
  321. group.series['sum(quantity)'].forEach((stat, i) => {
  322. switch (outcome) {
  323. case Outcome.ACCEPTED:
  324. case Outcome.FILTERED:
  325. usageStats[i][outcome] += stat;
  326. return;
  327. case Outcome.DROPPED:
  328. case Outcome.RATE_LIMITED:
  329. case Outcome.INVALID:
  330. usageStats[i].dropped.total += stat;
  331. // TODO: add client discards to dropped?
  332. return;
  333. default:
  334. return;
  335. }
  336. });
  337. });
  338. // Invalid and rate_limited data is combined with dropped
  339. count[Outcome.DROPPED] += count[Outcome.INVALID];
  340. count[Outcome.DROPPED] += count[Outcome.RATE_LIMITED];
  341. usageStats.forEach(stat => {
  342. stat.total = stat.accepted + stat.filtered + stat.dropped.total;
  343. // Chart Data
  344. (chartStats.accepted as any[]).push({value: [stat.date, stat.accepted]});
  345. (chartStats.dropped as any[]).push({
  346. value: [stat.date, stat.dropped.total],
  347. } as any);
  348. (chartStats.filtered as any[])?.push({value: [stat.date, stat.filtered]});
  349. });
  350. return {
  351. cardStats: {
  352. total: formatUsageWithUnits(
  353. count.total,
  354. dataCategory,
  355. getFormatUsageOptions(dataCategory)
  356. ),
  357. accepted: formatUsageWithUnits(
  358. count[Outcome.ACCEPTED],
  359. dataCategory,
  360. getFormatUsageOptions(dataCategory)
  361. ),
  362. filtered: formatUsageWithUnits(
  363. count[Outcome.FILTERED],
  364. dataCategory,
  365. getFormatUsageOptions(dataCategory)
  366. ),
  367. dropped: formatUsageWithUnits(
  368. count[Outcome.DROPPED],
  369. dataCategory,
  370. getFormatUsageOptions(dataCategory)
  371. ),
  372. },
  373. chartStats,
  374. };
  375. } catch (err) {
  376. Sentry.withScope(scope => {
  377. scope.setContext('query', this.endpointQuery);
  378. scope.setContext('body', {...orgStats});
  379. Sentry.captureException(err);
  380. });
  381. return {
  382. cardStats,
  383. chartStats,
  384. dataError: new Error('Failed to parse stats data'),
  385. };
  386. }
  387. }
  388. renderCards() {
  389. const {loading} = this.state;
  390. const cardMetadata = Object.values(this.cardMetadata);
  391. return cardMetadata.map((card, i) => (
  392. <StyledScoreCard
  393. key={i}
  394. title={card.title}
  395. score={loading ? undefined : card.score}
  396. help={card.help}
  397. trend={card.trend}
  398. />
  399. ));
  400. }
  401. renderChart() {
  402. const {loading} = this.state;
  403. return loading ? null : <UsageChart {...this.chartProps} />;
  404. }
  405. renderChartFooter = () => {
  406. const {handleChangeState} = this.props;
  407. const {loading, error} = this.state;
  408. const {
  409. chartDateInterval,
  410. chartTransform,
  411. chartDateStartDisplay,
  412. chartDateEndDisplay,
  413. chartDateTimezoneDisplay,
  414. } = this.chartData;
  415. return (
  416. <Footer>
  417. <InlineContainer>
  418. <FooterDate>
  419. <SectionHeading>{t('Date Range:')}</SectionHeading>
  420. <span>
  421. {loading || error ? (
  422. <NotAvailable />
  423. ) : (
  424. tct('[start] — [end] ([timezone] UTC, [interval] interval)', {
  425. start: chartDateStartDisplay,
  426. end: chartDateEndDisplay,
  427. timezone: chartDateTimezoneDisplay,
  428. interval: chartDateInterval,
  429. })
  430. )}
  431. </span>
  432. </FooterDate>
  433. </InlineContainer>
  434. <InlineContainer>
  435. <OptionSelector
  436. title={t('Type')}
  437. selected={chartTransform}
  438. options={CHART_OPTIONS_DATA_TRANSFORM}
  439. onChange={(val: string) =>
  440. handleChangeState({transform: val as ChartDataTransform})
  441. }
  442. />
  443. </InlineContainer>
  444. </Footer>
  445. );
  446. };
  447. renderProjectDetails() {
  448. const {isSingleProject} = this.props;
  449. const projectDetails = this.projectDetails.map((projectDetailComponent, i) => (
  450. <ErrorBoundary mini key={i}>
  451. {projectDetailComponent}
  452. </ErrorBoundary>
  453. ));
  454. return isSingleProject ? projectDetails : null;
  455. }
  456. renderComponent() {
  457. return (
  458. <Fragment>
  459. <PageGrid>
  460. {this.renderCards()}
  461. <ChartWrapper data-test-id="usage-stats-chart">
  462. {this.renderChart()}
  463. </ChartWrapper>
  464. </PageGrid>
  465. {this.renderProjectDetails()}
  466. </Fragment>
  467. );
  468. }
  469. }
  470. export default UsageStatsOrganization;
  471. const PageGrid = styled('div')`
  472. display: grid;
  473. grid-template-columns: 1fr;
  474. gap: ${space(2)};
  475. @media (min-width: ${p => p.theme.breakpoints.small}) {
  476. grid-template-columns: repeat(2, 1fr);
  477. }
  478. @media (min-width: ${p => p.theme.breakpoints.large}) {
  479. grid-template-columns: repeat(4, 1fr);
  480. }
  481. `;
  482. const StyledScoreCard = styled(ScoreCard)`
  483. grid-column: auto / span 1;
  484. margin: 0;
  485. `;
  486. const ChartWrapper = styled('div')`
  487. grid-column: 1 / -1;
  488. `;
  489. const Footer = styled('div')`
  490. display: flex;
  491. flex-direction: row;
  492. justify-content: space-between;
  493. padding: ${space(1)} ${space(3)};
  494. border-top: 1px solid ${p => p.theme.border};
  495. `;
  496. const FooterDate = styled('div')`
  497. display: flex;
  498. flex-direction: row;
  499. align-items: center;
  500. > ${SectionHeading} {
  501. margin-right: ${space(1.5)};
  502. }
  503. > span:last-child {
  504. font-weight: 400;
  505. font-size: ${p => p.theme.fontSizeMedium};
  506. }
  507. `;