usageStatsOrg.tsx 20 KB

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