usageStatsOrg.tsx 18 KB

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