usageStatsOrg.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  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 'sentry/components/asyncComponent';
  6. import OptionSelector from 'sentry/components/charts/optionSelector';
  7. import {InlineContainer, SectionHeading} from 'sentry/components/charts/styles';
  8. import {DateTimeObject, getSeriesApiInterval} from 'sentry/components/charts/utils';
  9. import NotAvailable from 'sentry/components/notAvailable';
  10. import ScoreCard from 'sentry/components/scoreCard';
  11. import {DEFAULT_STATS_PERIOD} from 'sentry/constants';
  12. import {t, tct} from 'sentry/locale';
  13. import space from 'sentry/styles/space';
  14. import {DataCategory, IntervalPeriod, Organization, Outcome} from 'sentry/types';
  15. import {parsePeriodToHours} from 'sentry/utils/dates';
  16. import {
  17. FORMAT_DATETIME_DAILY,
  18. FORMAT_DATETIME_HOURLY,
  19. getDateFromMoment,
  20. } from './usageChart/utils';
  21. import {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. dataCategory: DataCategory;
  31. dataCategoryName: string;
  32. dataDatetime: DateTimeObject;
  33. handleChangeState: (state: {
  34. dataCategory?: DataCategory;
  35. pagePeriod?: string | null;
  36. transform?: ChartDataTransform;
  37. }) => void;
  38. organization: Organization;
  39. chartTransform?: string;
  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. cardStats: {
  85. accepted?: string;
  86. dropped?: string;
  87. filtered?: string;
  88. total?: string;
  89. };
  90. chartDateEnd: string;
  91. chartDateEndDisplay: string;
  92. chartDateInterval: IntervalPeriod;
  93. chartDateStart: string;
  94. chartDateStartDisplay: string;
  95. chartDateTimezoneDisplay: string;
  96. chartDateUtc: boolean;
  97. chartStats: ChartStats;
  98. chartTransform: ChartDataTransform;
  99. dataError?: Error;
  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. chartDateEnd: string;
  120. chartDateEndDisplay: string;
  121. chartDateInterval: IntervalPeriod;
  122. chartDateStart: string;
  123. chartDateStartDisplay: string;
  124. chartDateTimezoneDisplay: string;
  125. chartDateUtc: boolean;
  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. cardStats: {
  174. accepted?: string;
  175. dropped?: string;
  176. filtered?: string;
  177. total?: string;
  178. };
  179. chartStats: ChartStats;
  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 as any[]).push({value: [stat.date, stat.accepted]});
  254. (chartStats.dropped as any[]).push({
  255. value: [stat.date, stat.dropped.total],
  256. } as any);
  257. (chartStats.filtered as any[])?.push({value: [stat.date, stat.filtered]});
  258. });
  259. return {
  260. cardStats: {
  261. total: formatUsageWithUnits(
  262. count.total,
  263. dataCategory,
  264. getFormatUsageOptions(dataCategory)
  265. ),
  266. accepted: formatUsageWithUnits(
  267. count[Outcome.ACCEPTED],
  268. dataCategory,
  269. getFormatUsageOptions(dataCategory)
  270. ),
  271. filtered: formatUsageWithUnits(
  272. count[Outcome.FILTERED],
  273. dataCategory,
  274. getFormatUsageOptions(dataCategory)
  275. ),
  276. dropped: formatUsageWithUnits(
  277. count[Outcome.DROPPED],
  278. dataCategory,
  279. getFormatUsageOptions(dataCategory)
  280. ),
  281. },
  282. chartStats,
  283. };
  284. } catch (err) {
  285. Sentry.withScope(scope => {
  286. scope.setContext('query', this.endpointQuery);
  287. scope.setContext('body', {...orgStats});
  288. Sentry.captureException(err);
  289. });
  290. return {
  291. cardStats,
  292. chartStats,
  293. dataError: new Error('Failed to parse stats data'),
  294. };
  295. }
  296. }
  297. renderCards() {
  298. const {dataCategory, dataCategoryName, organization} = this.props;
  299. const {loading} = this.state;
  300. const {total, accepted, dropped, filtered} = this.chartData.cardStats;
  301. const cardMetadata = [
  302. {
  303. title: tct('Total [dataCategory]', {dataCategory: dataCategoryName}),
  304. value: total,
  305. },
  306. {
  307. title: t('Accepted'),
  308. help: tct('Accepted [dataCategory] were successfully processed by Sentry', {
  309. dataCategory,
  310. }),
  311. value: accepted,
  312. secondaryValue: (
  313. <UsageStatsPerMin organization={organization} dataCategory={dataCategory} />
  314. ),
  315. },
  316. {
  317. title: t('Filtered'),
  318. help: tct(
  319. 'Filtered [dataCategory] were blocked due to your inbound data filter rules',
  320. {dataCategory}
  321. ),
  322. value: filtered,
  323. },
  324. {
  325. title: t('Dropped'),
  326. help: tct(
  327. 'Dropped [dataCategory] were discarded due to invalid data, rate-limits, quota limits, or spike protection',
  328. {dataCategory}
  329. ),
  330. value: dropped,
  331. },
  332. ];
  333. return cardMetadata.map((card, i) => (
  334. <StyledScoreCard
  335. key={i}
  336. title={card.title}
  337. score={loading ? undefined : card.value}
  338. help={card.help}
  339. trend={card.secondaryValue}
  340. />
  341. ));
  342. }
  343. renderChart() {
  344. const {dataCategory} = this.props;
  345. const {error, errors, loading} = this.state;
  346. const {
  347. chartStats,
  348. dataError,
  349. chartDateInterval,
  350. chartDateStart,
  351. chartDateEnd,
  352. chartDateUtc,
  353. chartTransform,
  354. } = this.chartData;
  355. const hasError = error || !!dataError;
  356. const chartErrors: any = dataError ? {...errors, data: dataError} : errors; // TODO(ts): AsyncComponent
  357. return (
  358. <UsageChart
  359. isLoading={loading}
  360. isError={hasError}
  361. errors={chartErrors}
  362. title=" " // Force the title to be blank
  363. footer={this.renderChartFooter()}
  364. dataCategory={dataCategory}
  365. dataTransform={chartTransform}
  366. usageDateStart={chartDateStart}
  367. usageDateEnd={chartDateEnd}
  368. usageDateShowUtc={chartDateUtc}
  369. usageDateInterval={chartDateInterval}
  370. usageStats={chartStats}
  371. />
  372. );
  373. }
  374. renderChartFooter = () => {
  375. const {handleChangeState} = this.props;
  376. const {loading, error} = this.state;
  377. const {
  378. chartDateInterval,
  379. chartTransform,
  380. chartDateStartDisplay,
  381. chartDateEndDisplay,
  382. chartDateTimezoneDisplay,
  383. } = this.chartData;
  384. return (
  385. <Footer>
  386. <InlineContainer>
  387. <FooterDate>
  388. <SectionHeading>{t('Date Range:')}</SectionHeading>
  389. <span>
  390. {loading || error ? (
  391. <NotAvailable />
  392. ) : (
  393. tct('[start] — [end] ([timezone] UTC, [interval] interval)', {
  394. start: chartDateStartDisplay,
  395. end: chartDateEndDisplay,
  396. timezone: chartDateTimezoneDisplay,
  397. interval: chartDateInterval,
  398. })
  399. )}
  400. </span>
  401. </FooterDate>
  402. </InlineContainer>
  403. <InlineContainer>
  404. <OptionSelector
  405. title={t('Type')}
  406. selected={chartTransform}
  407. options={CHART_OPTIONS_DATA_TRANSFORM}
  408. onChange={(val: string) =>
  409. handleChangeState({transform: val as ChartDataTransform})
  410. }
  411. />
  412. </InlineContainer>
  413. </Footer>
  414. );
  415. };
  416. renderComponent() {
  417. return (
  418. <Fragment>
  419. {this.renderCards()}
  420. <ChartWrapper data-test-id="usage-stats-chart">{this.renderChart()}</ChartWrapper>
  421. </Fragment>
  422. );
  423. }
  424. }
  425. export default UsageStatsOrganization;
  426. const StyledScoreCard = styled(ScoreCard)`
  427. grid-column: auto / span 1;
  428. margin: 0;
  429. `;
  430. const ChartWrapper = styled('div')`
  431. grid-column: 1 / -1;
  432. `;
  433. const Footer = styled('div')`
  434. display: flex;
  435. flex-direction: row;
  436. justify-content: space-between;
  437. padding: ${space(1)} ${space(3)};
  438. border-top: 1px solid ${p => p.theme.border};
  439. `;
  440. const FooterDate = styled('div')`
  441. display: flex;
  442. flex-direction: row;
  443. align-items: center;
  444. > ${SectionHeading} {
  445. margin-right: ${space(1.5)};
  446. }
  447. > span:last-child {
  448. font-weight: 400;
  449. font-size: ${p => p.theme.fontSizeMedium};
  450. }
  451. `;