projectPerformance.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622
  1. import {Fragment} from 'react';
  2. import {RouteComponentProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import Access from 'sentry/components/acl/access';
  5. import Feature from 'sentry/components/acl/feature';
  6. import {Button} from 'sentry/components/button';
  7. import Confirm from 'sentry/components/confirm';
  8. import FieldWrapper from 'sentry/components/forms/fieldGroup/fieldWrapper';
  9. import Form from 'sentry/components/forms/form';
  10. import JsonForm from 'sentry/components/forms/jsonForm';
  11. import {Field, JsonFormObject} from 'sentry/components/forms/types';
  12. import ExternalLink from 'sentry/components/links/externalLink';
  13. import LoadingIndicator from 'sentry/components/loadingIndicator';
  14. import {Panel, PanelFooter, PanelHeader, PanelItem} from 'sentry/components/panels';
  15. import {t, tct} from 'sentry/locale';
  16. import ConfigStore from 'sentry/stores/configStore';
  17. import ProjectsStore from 'sentry/stores/projectsStore';
  18. import {space} from 'sentry/styles/space';
  19. import {Organization, Project, Scope} from 'sentry/types';
  20. import {DynamicSamplingBiasType} from 'sentry/types/sampling';
  21. import {trackAnalytics} from 'sentry/utils/analytics';
  22. import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser';
  23. import routeTitleGen from 'sentry/utils/routeTitle';
  24. import AsyncView from 'sentry/views/asyncView';
  25. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  26. import PermissionAlert from 'sentry/views/settings/project/permissionAlert';
  27. // These labels need to be exported so that they can be used in audit logs
  28. export const retentionPrioritiesLabels = {
  29. boostLatestRelease: t('Prioritize new releases'),
  30. boostEnvironments: t('Prioritize dev environments'),
  31. boostLowVolumeTransactions: t('Prioritize low-volume transactions'),
  32. ignoreHealthChecks: t('Deprioritize health checks'),
  33. };
  34. export const allowedDurationValues: number[] = [
  35. 50, 100, 200, 300, 400, 500, 1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000,
  36. 10000,
  37. ]; // In milliseconds
  38. type ProjectPerformanceSettings = {[key: string]: number | boolean};
  39. enum DetectorConfigAdmin {
  40. N_PLUS_DB_ENABLED = 'n_plus_one_db_queries_detection_enabled',
  41. SLOW_DB_ENABLED = 'slow_db_queries_detection_enabled',
  42. }
  43. enum DetectorConfigCustomer {
  44. SLOW_DB_DURATION = 'slow_db_query_duration_threshold',
  45. N_PLUS_DB_DURATION = 'n_plus_one_db_duration_threshold',
  46. }
  47. type RouteParams = {orgId: string; projectId: string};
  48. type Props = RouteComponentProps<{projectId: string}, {}> & {
  49. organization: Organization;
  50. project: Project;
  51. };
  52. type ProjectThreshold = {
  53. metric: string;
  54. threshold: string;
  55. editedBy?: string;
  56. id?: string;
  57. };
  58. type State = AsyncView['state'] & {
  59. threshold: ProjectThreshold;
  60. };
  61. class ProjectPerformance extends AsyncView<Props, State> {
  62. getTitle() {
  63. const {projectId} = this.props.params;
  64. return routeTitleGen(t('Performance'), projectId, false);
  65. }
  66. getProjectEndpoint({orgId, projectId}: RouteParams) {
  67. return `/projects/${orgId}/${projectId}/`;
  68. }
  69. getPerformanceIssuesEndpoint({orgId, projectId}: RouteParams) {
  70. return `/projects/${orgId}/${projectId}/performance-issues/configure/`;
  71. }
  72. getEndpoints(): ReturnType<AsyncView['getEndpoints']> {
  73. const {params, organization} = this.props;
  74. const {projectId} = params;
  75. const endpoints: ReturnType<AsyncView['getEndpoints']> = [
  76. [
  77. 'threshold',
  78. `/projects/${organization.slug}/${projectId}/transaction-threshold/configure/`,
  79. ],
  80. ['project', `/projects/${organization.slug}/${projectId}/`],
  81. ];
  82. if (organization.features.includes('project-performance-settings-admin')) {
  83. const performanceIssuesEndpoint = [
  84. 'performance_issue_settings',
  85. `/projects/${organization.slug}/${projectId}/performance-issues/configure/`,
  86. ] as [string, string];
  87. endpoints.push(performanceIssuesEndpoint);
  88. }
  89. return endpoints;
  90. }
  91. getRetentionPrioritiesData(...data) {
  92. return {
  93. dynamicSamplingBiases: Object.entries(data[1].form).map(([key, value]) => ({
  94. id: key,
  95. active: value,
  96. })),
  97. };
  98. }
  99. handleDelete = () => {
  100. const {projectId} = this.props.params;
  101. const {organization} = this.props;
  102. this.setState({
  103. loading: true,
  104. });
  105. this.api.request(
  106. `/projects/${organization.slug}/${projectId}/transaction-threshold/configure/`,
  107. {
  108. method: 'DELETE',
  109. success: () => {
  110. trackAnalytics('performance_views.project_transaction_threshold.clear', {
  111. organization,
  112. });
  113. },
  114. complete: () => this.fetchData(),
  115. }
  116. );
  117. };
  118. handleThresholdsReset = () => {
  119. const {projectId} = this.props.params;
  120. const {organization} = this.props;
  121. this.setState({
  122. loading: true,
  123. });
  124. this.api.request(
  125. `/projects/${organization.slug}/${projectId}/performance-issues/configure/`,
  126. {
  127. method: 'DELETE',
  128. complete: () => this.fetchData(),
  129. }
  130. );
  131. };
  132. getEmptyMessage() {
  133. return t('There is no threshold set for this project.');
  134. }
  135. renderLoading() {
  136. return (
  137. <LoadingIndicatorContainer>
  138. <LoadingIndicator />
  139. </LoadingIndicatorContainer>
  140. );
  141. }
  142. get formFields(): Field[] {
  143. const fields: Field[] = [
  144. {
  145. name: 'metric',
  146. type: 'select',
  147. label: t('Calculation Method'),
  148. options: [
  149. {value: 'duration', label: t('Transaction Duration')},
  150. {value: 'lcp', label: t('Largest Contentful Paint')},
  151. ],
  152. help: tct(
  153. 'This determines which duration is used to set your thresholds. By default, we use transaction duration which measures the entire length of the transaction. You can also set this to use a [link:Web Vital].',
  154. {
  155. link: (
  156. <ExternalLink href="https://docs.sentry.io/product/performance/web-vitals/" />
  157. ),
  158. }
  159. ),
  160. },
  161. {
  162. name: 'threshold',
  163. type: 'string',
  164. label: t('Response Time Threshold (ms)'),
  165. placeholder: t('300'),
  166. help: tct(
  167. 'Define what a satisfactory response time is based on the calculation method above. This will affect how your [link1:Apdex] and [link2:User Misery] thresholds are calculated. For example, misery will be 4x your satisfactory response time.',
  168. {
  169. link1: (
  170. <ExternalLink href="https://docs.sentry.io/performance-monitoring/performance/metrics/#apdex" />
  171. ),
  172. link2: (
  173. <ExternalLink href="https://docs.sentry.io/product/performance/metrics/#user-misery" />
  174. ),
  175. }
  176. ),
  177. },
  178. ];
  179. return fields;
  180. }
  181. get areAllConfigurationsDisabled(): boolean {
  182. let result = true;
  183. Object.values(DetectorConfigAdmin).forEach(threshold => {
  184. result = result && !this.state.performance_issue_settings[threshold];
  185. });
  186. return result;
  187. }
  188. get performanceIssueFormFields(): Field[] {
  189. return [
  190. {
  191. name: 'performanceIssueCreationRate',
  192. type: 'range',
  193. label: t('Performance Issue Creation Rate'),
  194. min: 0.0,
  195. max: 1.0,
  196. step: 0.01,
  197. defaultValue: 0,
  198. help: t(
  199. 'This determines the rate at which performance issues are created. A rate of 0.0 will disable performance issue creation.'
  200. ),
  201. },
  202. {
  203. name: 'performanceIssueSendToPlatform',
  204. type: 'boolean',
  205. label: t('Send Occurrences To Platform'),
  206. defaultValue: false,
  207. help: t(
  208. 'This determines whether performance issue occurrences are sent to the issues platform.'
  209. ),
  210. },
  211. {
  212. name: 'performanceIssueCreationThroughPlatform',
  213. type: 'boolean',
  214. label: t('Create Issues Through Issues Platform'),
  215. defaultValue: false,
  216. help: t(
  217. 'This determines whether performance issues are created through the issues platform.'
  218. ),
  219. },
  220. ];
  221. }
  222. get performanceIssueDetectorAdminFields(): Field[] {
  223. return [
  224. {
  225. name: DetectorConfigAdmin.N_PLUS_DB_ENABLED,
  226. type: 'boolean',
  227. label: t('N+1 DB Queries Detection Enabled'),
  228. defaultValue: true,
  229. onChange: value =>
  230. this.setState({
  231. performance_issue_settings: {
  232. ...this.state.performance_issue_settings,
  233. n_plus_one_db_queries_detection_enabled: value,
  234. },
  235. }),
  236. },
  237. {
  238. name: DetectorConfigAdmin.SLOW_DB_ENABLED,
  239. type: 'boolean',
  240. label: t('Slow DB Queries Detection Enabled'),
  241. defaultValue: true,
  242. onChange: value =>
  243. this.setState({
  244. performance_issue_settings: {
  245. ...this.state.performance_issue_settings,
  246. slow_db_queries_detection_enabled: value,
  247. },
  248. }),
  249. },
  250. ];
  251. }
  252. project_owner_detector_settings = (hasAccess: boolean): JsonFormObject[] => {
  253. const performanceSettings: ProjectPerformanceSettings =
  254. this.state.performance_issue_settings;
  255. const supportMail = ConfigStore.get('supportEmail');
  256. const disabledReason = hasAccess
  257. ? tct(
  258. 'Detection of this issue has been disabled. Contact our support team at [link:support@sentry.io].',
  259. {
  260. link: <ExternalLink href={'mailto:' + supportMail} />,
  261. }
  262. )
  263. : null;
  264. const formatDuration = (value: number | ''): string => {
  265. return value && value < 1000 ? `${value}ms` : `${(value as number) / 1000}s`;
  266. };
  267. return [
  268. {
  269. title: t('N+1 DB Queries'),
  270. fields: [
  271. {
  272. name: DetectorConfigCustomer.N_PLUS_DB_DURATION,
  273. type: 'range',
  274. label: t('Duration'),
  275. defaultValue: 100, // ms
  276. help: t(
  277. 'Setting the value to 200ms, means that an eligible event will be stored as a N+1 DB Query Issue only if the total duration of the involved spans exceeds 200ms'
  278. ),
  279. allowedValues: allowedDurationValues,
  280. disabled: !(
  281. hasAccess && performanceSettings[DetectorConfigAdmin.N_PLUS_DB_ENABLED]
  282. ),
  283. formatLabel: formatDuration,
  284. disabledReason,
  285. },
  286. ],
  287. },
  288. {
  289. title: t('Slow DB Queries'),
  290. fields: [
  291. {
  292. name: DetectorConfigCustomer.SLOW_DB_DURATION,
  293. type: 'range',
  294. label: t('Duration'),
  295. defaultValue: 1000, // ms
  296. help: t(
  297. 'Setting the value to 2s, means that an eligible event will be stored as a Slow DB Query Issue only if the duration of the involved span exceeds 2s.'
  298. ),
  299. allowedValues: allowedDurationValues.slice(1),
  300. disabled: !(
  301. hasAccess && performanceSettings[DetectorConfigAdmin.SLOW_DB_ENABLED]
  302. ),
  303. formatLabel: formatDuration,
  304. disabledReason,
  305. },
  306. ],
  307. },
  308. ];
  309. };
  310. get retentionPrioritiesFormFields(): Field[] {
  311. return [
  312. {
  313. name: 'boostLatestRelease',
  314. type: 'boolean',
  315. label: retentionPrioritiesLabels.boostLatestRelease,
  316. help: t(
  317. 'Captures more transactions for your new releases as they are being adopted'
  318. ),
  319. getData: this.getRetentionPrioritiesData,
  320. },
  321. {
  322. name: 'boostEnvironments',
  323. type: 'boolean',
  324. label: retentionPrioritiesLabels.boostEnvironments,
  325. help: t(
  326. 'Captures more traces from environments that contain "debug", "dev", "local", "qa", and "test"'
  327. ),
  328. getData: this.getRetentionPrioritiesData,
  329. },
  330. {
  331. name: 'boostLowVolumeTransactions',
  332. type: 'boolean',
  333. label: retentionPrioritiesLabels.boostLowVolumeTransactions,
  334. help: t("Balance high-volume endpoints so they don't drown out low-volume ones"),
  335. getData: this.getRetentionPrioritiesData,
  336. },
  337. {
  338. name: 'ignoreHealthChecks',
  339. type: 'boolean',
  340. label: retentionPrioritiesLabels.ignoreHealthChecks,
  341. help: t('Captures fewer of your health checks transactions'),
  342. getData: this.getRetentionPrioritiesData,
  343. },
  344. ];
  345. }
  346. get initialData() {
  347. const {threshold} = this.state;
  348. return {
  349. threshold: threshold.threshold,
  350. metric: threshold.metric,
  351. };
  352. }
  353. renderBody() {
  354. const {organization, project} = this.props;
  355. const endpoint = `/projects/${organization.slug}/${project.slug}/transaction-threshold/configure/`;
  356. const requiredScopes: Scope[] = ['project:write'];
  357. const params = {orgId: organization.slug, projectId: project.slug};
  358. const projectEndpoint = this.getProjectEndpoint(params);
  359. const performanceIssuesEndpoint = this.getPerformanceIssuesEndpoint(params);
  360. const isSuperUser = isActiveSuperuser();
  361. return (
  362. <Fragment>
  363. <SettingsPageHeader title={t('Performance')} />
  364. <PermissionAlert project={project} />
  365. <Form
  366. saveOnBlur
  367. allowUndo
  368. initialData={this.initialData}
  369. apiMethod="POST"
  370. apiEndpoint={endpoint}
  371. onSubmitSuccess={resp => {
  372. const initial = this.initialData;
  373. const changedThreshold = initial.metric === resp.metric;
  374. trackAnalytics('performance_views.project_transaction_threshold.change', {
  375. organization,
  376. from: changedThreshold ? initial.threshold : initial.metric,
  377. to: changedThreshold ? resp.threshold : resp.metric,
  378. key: changedThreshold ? 'threshold' : 'metric',
  379. });
  380. this.setState({threshold: resp});
  381. }}
  382. >
  383. <Access access={requiredScopes} project={project}>
  384. {({hasAccess}) => (
  385. <JsonForm
  386. title={t('General')}
  387. fields={this.formFields}
  388. disabled={!hasAccess}
  389. renderFooter={() => (
  390. <Actions>
  391. <Button onClick={() => this.handleDelete()}>{t('Reset All')}</Button>
  392. </Actions>
  393. )}
  394. />
  395. )}
  396. </Access>
  397. </Form>
  398. <Feature features={['organizations:dynamic-sampling']}>
  399. <Form
  400. saveOnBlur
  401. allowUndo
  402. initialData={
  403. project.dynamicSamplingBiases?.reduce((acc, bias) => {
  404. acc[bias.id] = bias.active;
  405. return acc;
  406. }, {}) ?? {}
  407. }
  408. onSubmitSuccess={(response, _instance, id, change) => {
  409. ProjectsStore.onUpdateSuccess(response);
  410. trackAnalytics(
  411. change?.new === true
  412. ? 'dynamic_sampling_settings.priority_enabled'
  413. : 'dynamic_sampling_settings.priority_disabled',
  414. {
  415. organization,
  416. project_id: project.id,
  417. id: id as DynamicSamplingBiasType,
  418. }
  419. );
  420. }}
  421. apiMethod="PUT"
  422. apiEndpoint={projectEndpoint}
  423. >
  424. <Access access={requiredScopes} project={project}>
  425. {({hasAccess}) => (
  426. <JsonForm
  427. title={t('Retention Priorities')}
  428. fields={this.retentionPrioritiesFormFields}
  429. disabled={!hasAccess}
  430. renderFooter={() => (
  431. <Actions>
  432. <Button
  433. external
  434. href="https://docs.sentry.io/product/performance/performance-at-scale/"
  435. >
  436. {t('Read docs')}
  437. </Button>
  438. </Actions>
  439. )}
  440. />
  441. )}
  442. </Access>
  443. </Form>
  444. </Feature>
  445. <Fragment>
  446. <Feature features={['organizations:performance-issues-dev']}>
  447. <Form
  448. saveOnBlur
  449. allowUndo
  450. initialData={{
  451. performanceIssueCreationRate:
  452. this.state.project.performanceIssueCreationRate,
  453. performanceIssueSendToPlatform:
  454. this.state.project.performanceIssueSendToPlatform,
  455. performanceIssueCreationThroughPlatform:
  456. this.state.project.performanceIssueCreationThroughPlatform,
  457. }}
  458. apiMethod="PUT"
  459. apiEndpoint={projectEndpoint}
  460. >
  461. <Access access={requiredScopes} project={project}>
  462. {({hasAccess}) => (
  463. <JsonForm
  464. title={t('Performance Issues - All')}
  465. fields={this.performanceIssueFormFields}
  466. disabled={!hasAccess}
  467. />
  468. )}
  469. </Access>
  470. </Form>
  471. </Feature>
  472. <Feature features={['organizations:project-performance-settings-admin']}>
  473. {isSuperUser && (
  474. <Form
  475. saveOnBlur
  476. allowUndo
  477. initialData={this.state.performance_issue_settings}
  478. apiMethod="PUT"
  479. apiEndpoint={performanceIssuesEndpoint}
  480. >
  481. <JsonForm
  482. title={t('Performance Issues - Admin Detector Settings')}
  483. fields={this.performanceIssueDetectorAdminFields}
  484. disabled={!isSuperUser}
  485. />
  486. </Form>
  487. )}
  488. <Form
  489. allowUndo
  490. initialData={this.state.performance_issue_settings}
  491. apiMethod="PUT"
  492. apiEndpoint={performanceIssuesEndpoint}
  493. saveOnBlur
  494. >
  495. <Access access={requiredScopes} project={project}>
  496. {({hasAccess}) => (
  497. <div>
  498. <StyledPanelHeader>
  499. {t('Performance Issues - Detector Threshold Settings')}
  500. </StyledPanelHeader>
  501. <StyledJsonForm
  502. forms={this.project_owner_detector_settings(hasAccess)}
  503. collapsible
  504. />
  505. <StyledPanelFooter>
  506. <Actions>
  507. <Confirm
  508. message={t(
  509. 'Are you sure you wish to reset all detector thresholds?'
  510. )}
  511. onConfirm={() => this.handleThresholdsReset()}
  512. disabled={!hasAccess || this.areAllConfigurationsDisabled}
  513. >
  514. <Button>{t('Reset All Thresholds')}</Button>
  515. </Confirm>
  516. </Actions>
  517. </StyledPanelFooter>
  518. </div>
  519. )}
  520. </Access>
  521. </Form>
  522. </Feature>
  523. </Fragment>
  524. </Fragment>
  525. );
  526. }
  527. }
  528. const Actions = styled(PanelItem)`
  529. justify-content: flex-end;
  530. `;
  531. const StyledPanelHeader = styled(PanelHeader)`
  532. border: 1px solid ${p => p.theme.border};
  533. border-bottom: none;
  534. `;
  535. const StyledJsonForm = styled(JsonForm)`
  536. ${Panel} {
  537. margin-bottom: 0;
  538. border-radius: 0;
  539. border-bottom: 0;
  540. }
  541. ${FieldWrapper} {
  542. border-top: 1px solid ${p => p.theme.border};
  543. }
  544. ${FieldWrapper} + ${FieldWrapper} {
  545. border-top: 0;
  546. }
  547. ${Panel} + ${Panel} {
  548. border-top: 1px solid ${p => p.theme.border};
  549. }
  550. ${PanelHeader} {
  551. border-bottom: 0;
  552. text-transform: none;
  553. margin-bottom: 0;
  554. background: none;
  555. padding: ${space(3)} ${space(2)};
  556. }
  557. `;
  558. const StyledPanelFooter = styled(PanelFooter)`
  559. background: ${p => p.theme.white};
  560. border: 1px solid ${p => p.theme.border};
  561. border-radius: 0 0 calc(${p => p.theme.panelBorderRadius} - 1px)
  562. calc(${p => p.theme.panelBorderRadius} - 1px);
  563. ${Actions} {
  564. padding: ${space(1.5)};
  565. }
  566. `;
  567. const LoadingIndicatorContainer = styled('div')`
  568. margin: 18px 18px 0;
  569. `;
  570. export default ProjectPerformance;