usageStatsOrg.tsx 15 KB

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