usageStatsOrg.tsx 14 KB

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