usageStatsOrg.tsx 14 KB

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