usageStatsOrg.tsx 18 KB

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