usageStatsOrg.tsx 17 KB

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