index.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  1. import {Component} from 'react';
  2. import styled from '@emotion/styled';
  3. import type {LocationDescriptorObject} from 'history';
  4. import omit from 'lodash/omit';
  5. import pick from 'lodash/pick';
  6. import moment from 'moment-timezone';
  7. import type {DateTimeObject} from 'sentry/components/charts/utils';
  8. import {CompactSelect} from 'sentry/components/compactSelect';
  9. import ErrorBoundary from 'sentry/components/errorBoundary';
  10. import HookOrDefault from 'sentry/components/hookOrDefault';
  11. import * as Layout from 'sentry/components/layouts/thirds';
  12. import ExternalLink from 'sentry/components/links/externalLink';
  13. import {DatePageFilter} from 'sentry/components/organizations/datePageFilter';
  14. import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
  15. import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
  16. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  17. import {ProjectPageFilter} from 'sentry/components/organizations/projectPageFilter';
  18. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  19. import {DATA_CATEGORY_INFO, DEFAULT_STATS_PERIOD} from 'sentry/constants';
  20. import {ALL_ACCESS_PROJECTS} from 'sentry/constants/pageFilters';
  21. import {t, tct} from 'sentry/locale';
  22. import ConfigStore from 'sentry/stores/configStore';
  23. import {space} from 'sentry/styles/space';
  24. import {
  25. DataCategoryExact,
  26. type DataCategoryInfo,
  27. type PageFilters,
  28. } from 'sentry/types/core';
  29. import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
  30. import type {Organization} from 'sentry/types/organization';
  31. import type {Project} from 'sentry/types/project';
  32. import {hasDynamicSamplingCustomFeature} from 'sentry/utils/dynamicSampling/features';
  33. import withOrganization from 'sentry/utils/withOrganization';
  34. import withPageFilters from 'sentry/utils/withPageFilters';
  35. import HeaderTabs from 'sentry/views/organizationStats/header';
  36. import {getPerformanceBaseUrl} from 'sentry/views/performance/utils';
  37. import type {ChartDataTransform} from './usageChart';
  38. import {CHART_OPTIONS_DATACATEGORY} from './usageChart';
  39. import UsageStatsOrg from './usageStatsOrg';
  40. import UsageStatsProjects from './usageStatsProjects';
  41. const HookHeader = HookOrDefault({hookName: 'component:org-stats-banner'});
  42. export const PAGE_QUERY_PARAMS = [
  43. // From DatePageFilter
  44. 'statsPeriod',
  45. 'start',
  46. 'end',
  47. 'utc',
  48. // From data category selector
  49. 'dataCategory',
  50. // From UsageOrganizationStats
  51. 'transform',
  52. // From UsageProjectStats
  53. 'sort',
  54. 'query',
  55. 'cursor',
  56. 'spikeCursor',
  57. // From show data discarded on client toggle
  58. 'clientDiscard',
  59. ];
  60. export type OrganizationStatsProps = {
  61. organization: Organization;
  62. selection: PageFilters;
  63. } & RouteComponentProps<{}, {}>;
  64. export class OrganizationStats extends Component<OrganizationStatsProps> {
  65. get dataCategoryInfo(): DataCategoryInfo {
  66. const dataCategoryPlural = this.props.location?.query?.dataCategory;
  67. const categories = Object.values(DATA_CATEGORY_INFO);
  68. const info = categories.find(c => c.plural === dataCategoryPlural);
  69. if (
  70. info?.name === DataCategoryExact.SPAN &&
  71. this.props.organization.features.includes('spans-usage-tracking') &&
  72. !hasDynamicSamplingCustomFeature(this.props.organization)
  73. ) {
  74. return {
  75. ...info,
  76. apiName: 'span_indexed',
  77. };
  78. }
  79. // Default to errors
  80. return info ?? DATA_CATEGORY_INFO.error;
  81. }
  82. get dataCategory() {
  83. return this.dataCategoryInfo.plural;
  84. }
  85. get dataCategoryName() {
  86. return this.dataCategoryInfo.titleName;
  87. }
  88. get dataDatetime(): DateTimeObject {
  89. const params = this.props.selection.datetime;
  90. const {
  91. start,
  92. end,
  93. statsPeriod,
  94. utc: utcString,
  95. } = normalizeDateTimeParams(params, {
  96. allowEmptyPeriod: true,
  97. allowAbsoluteDatetime: true,
  98. allowAbsolutePageDatetime: true,
  99. });
  100. if (!statsPeriod && !start && !end) {
  101. return {period: DEFAULT_STATS_PERIOD};
  102. }
  103. // Following getParams, statsPeriod will take priority over start/end
  104. if (statsPeriod) {
  105. return {period: statsPeriod};
  106. }
  107. const utc = utcString === 'true';
  108. if (start && end) {
  109. return utc
  110. ? {
  111. start: moment.utc(start).format(),
  112. end: moment.utc(end).format(),
  113. utc,
  114. }
  115. : {
  116. start: moment(start).utc().format(),
  117. end: moment(end).utc().format(),
  118. utc,
  119. };
  120. }
  121. return {period: DEFAULT_STATS_PERIOD};
  122. }
  123. get clientDiscard(): boolean {
  124. return this.props.location?.query?.clientDiscard === 'true';
  125. }
  126. // Validation and type-casting should be handled by chart
  127. get chartTransform(): string | undefined {
  128. return this.props.location?.query?.transform;
  129. }
  130. // Validation and type-casting should be handled by table
  131. get tableSort(): string | undefined {
  132. return this.props.location?.query?.sort;
  133. }
  134. get tableQuery(): string | undefined {
  135. return this.props.location?.query?.query;
  136. }
  137. get tableCursor(): string | undefined {
  138. return this.props.location?.query?.cursor;
  139. }
  140. // Project selection from GlobalSelectionHeader
  141. get projectIds(): number[] {
  142. const selection_projects = this.props.selection.projects.length
  143. ? this.props.selection.projects
  144. : [ALL_ACCESS_PROJECTS];
  145. return selection_projects;
  146. }
  147. get isSingleProject(): boolean {
  148. return this.projectIds.length === 1 && !this.projectIds.includes(-1);
  149. }
  150. getNextLocations = (project: Project): Record<string, LocationDescriptorObject> => {
  151. const {location, organization} = this.props;
  152. const nextLocation: LocationDescriptorObject = {
  153. ...location,
  154. query: {
  155. ...location.query,
  156. project: project.id,
  157. },
  158. };
  159. // Do not leak out page-specific keys
  160. nextLocation.query = omit(nextLocation.query, PAGE_QUERY_PARAMS);
  161. return {
  162. performance: {
  163. ...nextLocation,
  164. pathname: getPerformanceBaseUrl(organization.slug),
  165. },
  166. projectDetail: {
  167. ...nextLocation,
  168. pathname: `/organizations/${organization.slug}/projects/${project.slug}/`,
  169. },
  170. issueList: {
  171. ...nextLocation,
  172. pathname: `/organizations/${organization.slug}/issues/`,
  173. },
  174. settings: {
  175. pathname: `/settings/${organization.slug}/projects/${project.slug}/`,
  176. },
  177. };
  178. };
  179. /**
  180. * See PAGE_QUERY_PARAMS for list of accepted keys on nextState
  181. */
  182. setStateOnUrl = (
  183. nextState: {
  184. clientDiscard?: boolean;
  185. cursor?: string;
  186. dataCategory?: DataCategoryInfo['plural'];
  187. query?: string;
  188. sort?: string;
  189. transform?: ChartDataTransform;
  190. },
  191. options: {
  192. willUpdateRouter?: boolean;
  193. } = {
  194. willUpdateRouter: true,
  195. }
  196. ): LocationDescriptorObject => {
  197. const {location, router} = this.props;
  198. const nextQueryParams = pick(nextState, PAGE_QUERY_PARAMS);
  199. const nextLocation = {
  200. ...location,
  201. query: {
  202. ...location?.query,
  203. ...nextQueryParams,
  204. },
  205. };
  206. if (options.willUpdateRouter) {
  207. router.push(nextLocation);
  208. }
  209. return nextLocation;
  210. };
  211. renderProjectPageControl = () => {
  212. const {organization} = this.props;
  213. const isSelfHostedErrorsOnly = ConfigStore.get('isSelfHostedErrorsOnly');
  214. const options = CHART_OPTIONS_DATACATEGORY.filter(opt => {
  215. if (isSelfHostedErrorsOnly) {
  216. return opt.value === DATA_CATEGORY_INFO.error.plural;
  217. }
  218. if (opt.value === DATA_CATEGORY_INFO.replay.plural) {
  219. return organization.features.includes('session-replay');
  220. }
  221. if (DATA_CATEGORY_INFO.span.plural === opt.value) {
  222. return organization.features.includes('span-stats');
  223. }
  224. if (DATA_CATEGORY_INFO.transaction.plural === opt.value) {
  225. return !organization.features.includes('spans-usage-tracking');
  226. }
  227. if (DATA_CATEGORY_INFO.profileDuration.plural === opt.value) {
  228. return (
  229. organization.features.includes('continuous-profiling-stats') ||
  230. organization.features.includes('continuous-profiling')
  231. );
  232. }
  233. if (DATA_CATEGORY_INFO.profile.plural === opt.value) {
  234. return !organization.features.includes('continuous-profiling-stats');
  235. }
  236. return true;
  237. });
  238. return (
  239. <PageControl>
  240. <PageFilterBar>
  241. <ProjectPageFilter />
  242. <DropdownDataCategory
  243. triggerProps={{prefix: t('Category')}}
  244. value={this.dataCategory}
  245. options={options}
  246. onChange={opt => this.setStateOnUrl({dataCategory: String(opt.value)})}
  247. />
  248. <DatePageFilter />
  249. </PageFilterBar>
  250. </PageControl>
  251. );
  252. };
  253. /**
  254. * This method is replaced by the hook "component:enhanced-org-stats"
  255. */
  256. renderUsageStatsOrg() {
  257. const {organization, router, location, params, routes} = this.props;
  258. return (
  259. <UsageStatsOrg
  260. isSingleProject={this.isSingleProject}
  261. projectIds={this.projectIds}
  262. organization={organization}
  263. dataCategory={this.dataCategory}
  264. dataCategoryName={this.dataCategoryInfo.titleName}
  265. dataCategoryApiName={this.dataCategoryInfo.apiName}
  266. dataDatetime={this.dataDatetime}
  267. chartTransform={this.chartTransform}
  268. clientDiscard={this.clientDiscard}
  269. handleChangeState={this.setStateOnUrl}
  270. router={router}
  271. location={location}
  272. params={params}
  273. routes={routes}
  274. />
  275. );
  276. }
  277. render() {
  278. const {organization} = this.props;
  279. const hasTeamInsights = organization.features.includes('team-insights');
  280. return (
  281. <SentryDocumentTitle title="Usage Stats">
  282. <PageFiltersContainer>
  283. {hasTeamInsights ? (
  284. <HeaderTabs organization={organization} activeTab="stats" />
  285. ) : (
  286. <Layout.Header>
  287. <Layout.HeaderContent>
  288. <Layout.Title>{t('Organization Usage Stats')}</Layout.Title>
  289. <HeadingSubtitle>
  290. {tct(
  291. 'A view of the usage data that Sentry has received across your entire organization. [link: Read the docs].',
  292. {
  293. link: <ExternalLink href="https://docs.sentry.io/product/stats/" />,
  294. }
  295. )}
  296. </HeadingSubtitle>
  297. </Layout.HeaderContent>
  298. </Layout.Header>
  299. )}
  300. <Body>
  301. <Layout.Main fullWidth>
  302. <HookHeader organization={organization} />
  303. {this.renderProjectPageControl()}
  304. <div>
  305. <ErrorBoundary mini>{this.renderUsageStatsOrg()}</ErrorBoundary>
  306. </div>
  307. <ErrorBoundary mini>
  308. <UsageStatsProjects
  309. organization={organization}
  310. dataCategory={this.dataCategoryInfo}
  311. dataCategoryName={this.dataCategoryInfo.titleName}
  312. isSingleProject={this.isSingleProject}
  313. projectIds={this.projectIds}
  314. dataDatetime={this.dataDatetime}
  315. tableSort={this.tableSort}
  316. tableQuery={this.tableQuery}
  317. tableCursor={this.tableCursor}
  318. handleChangeState={this.setStateOnUrl}
  319. getNextLocations={this.getNextLocations}
  320. />
  321. </ErrorBoundary>
  322. </Layout.Main>
  323. </Body>
  324. </PageFiltersContainer>
  325. </SentryDocumentTitle>
  326. );
  327. }
  328. }
  329. const HookOrgStats = HookOrDefault({
  330. hookName: 'component:enhanced-org-stats',
  331. defaultComponent: OrganizationStats,
  332. });
  333. export default withPageFilters(withOrganization(HookOrgStats));
  334. const DropdownDataCategory = styled(CompactSelect)`
  335. width: auto;
  336. position: relative;
  337. grid-column: auto / span 1;
  338. button[aria-haspopup='listbox'] {
  339. width: 100%;
  340. height: 100%;
  341. }
  342. @media (min-width: ${p => p.theme.breakpoints.small}) {
  343. grid-column: auto / span 2;
  344. }
  345. @media (min-width: ${p => p.theme.breakpoints.large}) {
  346. grid-column: auto / span 1;
  347. }
  348. &::after {
  349. content: '';
  350. position: absolute;
  351. top: 0;
  352. bottom: 0;
  353. left: 0;
  354. right: 0;
  355. pointer-events: none;
  356. box-shadow: inset 0 0 0 1px ${p => p.theme.border};
  357. border-radius: ${p => p.theme.borderRadius};
  358. }
  359. `;
  360. const Body = styled(Layout.Body)`
  361. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  362. display: block;
  363. }
  364. `;
  365. const HeadingSubtitle = styled('p')`
  366. margin-top: ${space(0.5)};
  367. margin-bottom: 0;
  368. `;
  369. const PageControl = styled('div')`
  370. display: grid;
  371. width: 100%;
  372. margin-bottom: ${space(2)};
  373. grid-template-columns: minmax(0, max-content);
  374. @media (max-width: ${p => p.theme.breakpoints.small}) {
  375. grid-template-columns: minmax(0, 1fr);
  376. }
  377. `;