serverSideSampling.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643
  1. import {Fragment, useEffect, useState} from 'react';
  2. import {css} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import isEqual from 'lodash/isEqual';
  5. import {
  6. addErrorMessage,
  7. addLoadingMessage,
  8. addSuccessMessage,
  9. } from 'sentry/actionCreators/indicator';
  10. import {openModal} from 'sentry/actionCreators/modal';
  11. import {
  12. fetchProjectStats30d,
  13. fetchProjectStats48h,
  14. fetchSamplingDistribution,
  15. fetchSamplingSdkVersions,
  16. } from 'sentry/actionCreators/serverSideSampling';
  17. import GuideAnchor from 'sentry/components/assistant/guideAnchor';
  18. import Button from 'sentry/components/button';
  19. import ButtonBar from 'sentry/components/buttonBar';
  20. import FeatureBadge from 'sentry/components/featureBadge';
  21. import ExternalLink from 'sentry/components/links/externalLink';
  22. import {Panel, PanelFooter, PanelHeader} from 'sentry/components/panels';
  23. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  24. import {IconAdd} from 'sentry/icons';
  25. import {t, tct} from 'sentry/locale';
  26. import ProjectStore from 'sentry/stores/projectsStore';
  27. import space from 'sentry/styles/space';
  28. import {Project} from 'sentry/types';
  29. import {
  30. SamplingConditionOperator,
  31. SamplingRule,
  32. SamplingRuleOperator,
  33. SamplingRuleType,
  34. UniformModalsSubmit,
  35. } from 'sentry/types/sampling';
  36. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  37. import handleXhrErrorResponse from 'sentry/utils/handleXhrErrorResponse';
  38. import useApi from 'sentry/utils/useApi';
  39. import useOrganization from 'sentry/utils/useOrganization';
  40. import usePrevious from 'sentry/utils/usePrevious';
  41. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  42. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  43. import PermissionAlert from 'sentry/views/settings/organization/permissionAlert';
  44. import {SpecificConditionsModal} from './modals/specificConditionsModal';
  45. import {responsiveModal} from './modals/styles';
  46. import {UniformRateModal} from './modals/uniformRateModal';
  47. import {useProjectStats} from './utils/useProjectStats';
  48. import {useRecommendedSdkUpgrades} from './utils/useRecommendedSdkUpgrades';
  49. import {DraggableRuleList, DraggableRuleListUpdateItemsProps} from './draggableRuleList';
  50. import {
  51. ActiveColumn,
  52. Column,
  53. ConditionColumn,
  54. GrabColumn,
  55. OperatorColumn,
  56. RateColumn,
  57. Rule,
  58. } from './rule';
  59. import {SamplingBreakdown} from './samplingBreakdown';
  60. import {SamplingProjectIncompatibleAlert} from './samplingProjectIncompatibleAlert';
  61. import {SamplingPromo} from './samplingPromo';
  62. import {SamplingSDKClientRateChangeAlert} from './samplingSDKClientRateChangeAlert';
  63. import {SamplingSDKUpgradesAlert} from './samplingSDKUpgradesAlert';
  64. import {isUniformRule, SERVER_SIDE_SAMPLING_DOC_LINK} from './utils';
  65. type Props = {
  66. project: Project;
  67. };
  68. export function ServerSideSampling({project}: Props) {
  69. const organization = useOrganization();
  70. const api = useApi();
  71. const hasAccess = organization.access.includes('project:write');
  72. const currentRules = project.dynamicSampling?.rules;
  73. const previousRules = usePrevious(currentRules);
  74. const [rules, setRules] = useState<SamplingRule[]>(currentRules ?? []);
  75. useEffect(() => {
  76. trackAdvancedAnalyticsEvent('sampling.settings.view', {
  77. organization,
  78. project_id: project.id,
  79. });
  80. }, [project.id, organization]);
  81. useEffect(() => {
  82. if (!isEqual(previousRules, currentRules)) {
  83. setRules(currentRules ?? []);
  84. }
  85. }, [currentRules, previousRules]);
  86. useEffect(() => {
  87. if (!hasAccess) {
  88. return;
  89. }
  90. async function fetchData() {
  91. await fetchSamplingDistribution({
  92. orgSlug: organization.slug,
  93. projSlug: project.slug,
  94. api,
  95. });
  96. await fetchSamplingSdkVersions({
  97. orgSlug: organization.slug,
  98. api,
  99. projectID: project.id,
  100. });
  101. await fetchProjectStats48h({
  102. orgSlug: organization.slug,
  103. api,
  104. projId: project.id,
  105. });
  106. await fetchProjectStats30d({
  107. orgSlug: organization.slug,
  108. api,
  109. projId: project.id,
  110. });
  111. }
  112. fetchData();
  113. }, [api, organization.slug, project.slug, project.id, hasAccess]);
  114. const {projectStats48h} = useProjectStats();
  115. const {recommendedSdkUpgrades, isProjectIncompatible} = useRecommendedSdkUpgrades({
  116. orgSlug: organization.slug,
  117. projectId: project.id,
  118. });
  119. async function handleActivateToggle(rule: SamplingRule) {
  120. if (isProjectIncompatible) {
  121. addErrorMessage(
  122. t('Your project is currently incompatible with Server-Side Sampling.')
  123. );
  124. return;
  125. }
  126. const newRules = rules.map(r => {
  127. if (r.id === rule.id) {
  128. return {
  129. ...r,
  130. id: 0,
  131. active: !r.active,
  132. };
  133. }
  134. return r;
  135. });
  136. addLoadingMessage();
  137. try {
  138. const result = await api.requestPromise(
  139. `/projects/${organization.slug}/${project.slug}/`,
  140. {
  141. method: 'PUT',
  142. data: {dynamicSampling: {rules: newRules}},
  143. }
  144. );
  145. ProjectStore.onUpdateSuccess(result);
  146. addSuccessMessage(t('Successfully updated the sampling rule'));
  147. } catch (error) {
  148. const message = t('Unable to update the sampling rule');
  149. handleXhrErrorResponse(message)(error);
  150. addErrorMessage(message);
  151. }
  152. if (isUniformRule(rule)) {
  153. trackAdvancedAnalyticsEvent(
  154. rule.active
  155. ? 'sampling.settings.rule.uniform_deactivate'
  156. : 'sampling.settings.rule.uniform_activate',
  157. {
  158. organization,
  159. project_id: project.id,
  160. sampling_rate: rule.sampleRate,
  161. }
  162. );
  163. } else {
  164. const analyticsConditions = rule.condition.inner.map(condition => condition.name);
  165. const analyticsConditionsStringified = analyticsConditions.sort().join(', ');
  166. trackAdvancedAnalyticsEvent(
  167. rule.active
  168. ? 'sampling.settings.rule.specific_deactivate'
  169. : 'sampling.settings.rule.specific_activate',
  170. {
  171. organization,
  172. project_id: project.id,
  173. sampling_rate: rule.sampleRate,
  174. conditions: analyticsConditions,
  175. conditions_stringified: analyticsConditionsStringified,
  176. }
  177. );
  178. }
  179. }
  180. function handleGetStarted() {
  181. trackAdvancedAnalyticsEvent('sampling.settings.view_get_started', {
  182. organization,
  183. project_id: project.id,
  184. });
  185. openModal(
  186. modalProps => (
  187. <UniformRateModal
  188. {...modalProps}
  189. organization={organization}
  190. project={project}
  191. rules={rules}
  192. onSubmit={saveUniformRule}
  193. onReadDocs={handleReadDocs}
  194. />
  195. ),
  196. {
  197. modalCss: responsiveModal,
  198. }
  199. );
  200. }
  201. async function handleSortRules({
  202. overIndex,
  203. reorderedItems: ruleIds,
  204. }: DraggableRuleListUpdateItemsProps) {
  205. if (!rules[overIndex].condition.inner.length) {
  206. addErrorMessage(t('Specific rules cannot be below uniform rules'));
  207. return;
  208. }
  209. const sortedRules = ruleIds
  210. .map(ruleId => rules.find(rule => String(rule.id) === ruleId))
  211. .filter(rule => !!rule) as SamplingRule[];
  212. setRules(sortedRules);
  213. try {
  214. const result = await api.requestPromise(
  215. `/projects/${organization.slug}/${project.slug}/`,
  216. {
  217. method: 'PUT',
  218. data: {dynamicSampling: {rules: sortedRules}},
  219. }
  220. );
  221. ProjectStore.onUpdateSuccess(result);
  222. addSuccessMessage(t('Successfully sorted sampling rules'));
  223. } catch (error) {
  224. setRules(previousRules ?? []);
  225. const message = t('Unable to sort sampling rules');
  226. handleXhrErrorResponse(message)(error);
  227. addErrorMessage(message);
  228. }
  229. }
  230. function handleAddRule() {
  231. openModal(modalProps => (
  232. <SpecificConditionsModal
  233. {...modalProps}
  234. organization={organization}
  235. project={project}
  236. rules={rules}
  237. />
  238. ));
  239. }
  240. function handleEditRule(rule: SamplingRule) {
  241. if (isUniformRule(rule)) {
  242. openModal(
  243. modalProps => (
  244. <UniformRateModal
  245. {...modalProps}
  246. organization={organization}
  247. project={project}
  248. uniformRule={rule}
  249. rules={rules}
  250. onSubmit={saveUniformRule}
  251. onReadDocs={handleReadDocs}
  252. />
  253. ),
  254. {
  255. modalCss: responsiveModal,
  256. }
  257. );
  258. return;
  259. }
  260. openModal(modalProps => (
  261. <SpecificConditionsModal
  262. {...modalProps}
  263. organization={organization}
  264. project={project}
  265. rule={rule}
  266. rules={rules}
  267. />
  268. ));
  269. }
  270. async function handleDeleteRule(rule: SamplingRule) {
  271. const conditions = rule.condition.inner.map(({name}) => name);
  272. trackAdvancedAnalyticsEvent('sampling.settings.rule.specific_delete', {
  273. organization,
  274. project_id: project.id,
  275. sampling_rate: rule.sampleRate,
  276. conditions,
  277. conditions_stringified: conditions.sort().join(', '),
  278. });
  279. try {
  280. const result = await api.requestPromise(
  281. `/projects/${organization.slug}/${project.slug}/`,
  282. {
  283. method: 'PUT',
  284. data: {dynamicSampling: {rules: rules.filter(({id}) => id !== rule.id)}},
  285. }
  286. );
  287. ProjectStore.onUpdateSuccess(result);
  288. addSuccessMessage(t('Successfully deleted sampling rule'));
  289. } catch (error) {
  290. const message = t('Unable to delete sampling rule');
  291. handleXhrErrorResponse(message)(error);
  292. addErrorMessage(message);
  293. }
  294. }
  295. function handleReadDocs() {
  296. trackAdvancedAnalyticsEvent('sampling.settings.view_read_docs', {
  297. organization,
  298. project_id: project.id,
  299. });
  300. }
  301. async function saveUniformRule({
  302. sampleRate,
  303. uniformRateModalOrigin,
  304. onError,
  305. onSuccess,
  306. rule,
  307. }: Parameters<UniformModalsSubmit>[0]) {
  308. if (isProjectIncompatible) {
  309. addErrorMessage(
  310. t('Your project is currently incompatible with Server-Side Sampling.')
  311. );
  312. return;
  313. }
  314. const newRule: SamplingRule = {
  315. // All new/updated rules must have id equal to 0
  316. id: 0,
  317. active: rule ? rule.active : false,
  318. type: SamplingRuleType.TRACE,
  319. condition: {
  320. op: SamplingConditionOperator.AND,
  321. inner: [],
  322. },
  323. sampleRate,
  324. };
  325. trackAdvancedAnalyticsEvent(
  326. uniformRateModalOrigin
  327. ? 'sampling.settings.modal.uniform.rate_done'
  328. : 'sampling.settings.modal.recommended.next.steps_done',
  329. {
  330. organization,
  331. project_id: project.id,
  332. }
  333. );
  334. trackAdvancedAnalyticsEvent(
  335. rule
  336. ? 'sampling.settings.rule.uniform_update'
  337. : 'sampling.settings.rule.uniform_create',
  338. {
  339. organization,
  340. project_id: project.id,
  341. sampling_rate: newRule.sampleRate,
  342. old_sampling_rate: rule ? rule.sampleRate : null,
  343. }
  344. );
  345. trackAdvancedAnalyticsEvent('sampling.settings.rule.uniform_save', {
  346. organization,
  347. project_id: project.id,
  348. sampling_rate: newRule.sampleRate,
  349. old_sampling_rate: rule ? rule.sampleRate : null,
  350. });
  351. const newRules = rule
  352. ? rules.map(existingRule => (existingRule.id === rule.id ? newRule : existingRule))
  353. : [...rules, newRule];
  354. try {
  355. const response = await api.requestPromise(
  356. `/projects/${organization.slug}/${project.slug}/`,
  357. {method: 'PUT', data: {dynamicSampling: {rules: newRules}}}
  358. );
  359. ProjectStore.onUpdateSuccess(response);
  360. addSuccessMessage(
  361. rule
  362. ? t('Successfully edited sampling rule')
  363. : t('Successfully added sampling rule')
  364. );
  365. onSuccess?.(response.dynamicSampling?.rules ?? []);
  366. } catch (error) {
  367. addErrorMessage(
  368. typeof error === 'string'
  369. ? error
  370. : error.message || t('Failed to save sampling rule')
  371. );
  372. onError?.();
  373. }
  374. }
  375. // Rules without a condition (Else case) always have to be 'pinned' to the bottom of the list
  376. // and cannot be sorted
  377. const items = rules.map(rule => ({
  378. ...rule,
  379. id: String(rule.id),
  380. }));
  381. const uniformRule = rules.find(isUniformRule);
  382. return (
  383. <SentryDocumentTitle title={t('Server-Side Sampling')}>
  384. <Fragment>
  385. <SettingsPageHeader
  386. title={
  387. <Fragment>
  388. {t('Server-Side Sampling')} <FeatureBadge type="beta" />
  389. </Fragment>
  390. }
  391. />
  392. <TextBlock>
  393. {tct(
  394. 'Enhance the Performance monitoring experience by targeting which transactions are most valuable to your organization. To learn more about our beta program, [faqLink: visit our FAQ], for more general information, [docsLink: read our docs].',
  395. {
  396. faqLink: (
  397. <ExternalLink href="https://help.sentry.io/account/account-settings/dynamic-sampling/" />
  398. ),
  399. docsLink: <ExternalLink href={SERVER_SIDE_SAMPLING_DOC_LINK} />,
  400. }
  401. )}
  402. </TextBlock>
  403. <PermissionAlert
  404. access={['project:write']}
  405. message={t(
  406. 'These settings can only be edited by users with the organization owner, manager, or admin role.'
  407. )}
  408. />
  409. <SamplingProjectIncompatibleAlert
  410. organization={organization}
  411. projectId={project.id}
  412. isProjectIncompatible={isProjectIncompatible}
  413. />
  414. {!!rules.length && (
  415. <SamplingSDKUpgradesAlert
  416. organization={organization}
  417. projectId={project.id}
  418. recommendedSdkUpgrades={recommendedSdkUpgrades}
  419. onReadDocs={handleReadDocs}
  420. />
  421. )}
  422. {!!rules.length && !recommendedSdkUpgrades.length && (
  423. <SamplingSDKClientRateChangeAlert
  424. onReadDocs={handleReadDocs}
  425. projectStats={projectStats48h.data}
  426. organization={organization}
  427. projectId={project.id}
  428. />
  429. )}
  430. {hasAccess && <SamplingBreakdown orgSlug={organization.slug} />}
  431. {!rules.length ? (
  432. <SamplingPromo
  433. onGetStarted={handleGetStarted}
  434. onReadDocs={handleReadDocs}
  435. hasAccess={hasAccess}
  436. isProjectIncompatible={isProjectIncompatible}
  437. />
  438. ) : (
  439. <RulesPanel>
  440. <RulesPanelHeader lightText>
  441. <RulesPanelLayout>
  442. <GrabColumn />
  443. <OperatorColumn>{t('Operator')}</OperatorColumn>
  444. <ConditionColumn>{t('Condition')}</ConditionColumn>
  445. <RateColumn>{t('Rate')}</RateColumn>
  446. <ActiveColumn>{t('Active')}</ActiveColumn>
  447. <Column />
  448. </RulesPanelLayout>
  449. </RulesPanelHeader>
  450. <DraggableRuleList
  451. disabled={!hasAccess}
  452. items={items}
  453. onUpdateItems={handleSortRules}
  454. wrapperStyle={({isDragging, isSorting, index}) => {
  455. if (isDragging) {
  456. return {
  457. cursor: 'grabbing',
  458. };
  459. }
  460. if (isSorting) {
  461. return {};
  462. }
  463. return {
  464. transform: 'none',
  465. transformOrigin: '0',
  466. '--box-shadow': 'none',
  467. '--box-shadow-picked-up': 'none',
  468. overflow: 'visible',
  469. position: 'relative',
  470. zIndex: rules.length - index,
  471. cursor: 'default',
  472. };
  473. }}
  474. renderItem={({value, listeners, attributes, dragging, sorting}) => {
  475. const itemsRuleIndex = items.findIndex(item => item.id === value);
  476. if (itemsRuleIndex === -1) {
  477. return null;
  478. }
  479. const itemsRule = items[itemsRuleIndex];
  480. const currentRule = {
  481. active: itemsRule.active,
  482. condition: itemsRule.condition,
  483. sampleRate: itemsRule.sampleRate,
  484. type: itemsRule.type,
  485. id: Number(itemsRule.id),
  486. };
  487. return (
  488. <RulesPanelLayout isContent>
  489. <Rule
  490. operator={
  491. itemsRule.id === items[0].id
  492. ? SamplingRuleOperator.IF
  493. : isUniformRule(currentRule)
  494. ? SamplingRuleOperator.ELSE
  495. : SamplingRuleOperator.ELSE_IF
  496. }
  497. hideGrabButton={items.length === 1}
  498. rule={currentRule}
  499. onEditRule={() => handleEditRule(currentRule)}
  500. onDeleteRule={() => handleDeleteRule(currentRule)}
  501. onActivate={() => handleActivateToggle(currentRule)}
  502. noPermission={!hasAccess}
  503. upgradeSdkForProjects={recommendedSdkUpgrades.map(
  504. recommendedSdkUpgrade => recommendedSdkUpgrade.project.slug
  505. )}
  506. listeners={listeners}
  507. grabAttributes={attributes}
  508. dragging={dragging}
  509. sorting={sorting}
  510. />
  511. </RulesPanelLayout>
  512. );
  513. }}
  514. />
  515. <RulesPanelFooter>
  516. <ButtonBar gap={1}>
  517. <Button
  518. href={SERVER_SIDE_SAMPLING_DOC_LINK}
  519. onClick={handleReadDocs}
  520. external
  521. >
  522. {t('Read Docs')}
  523. </Button>
  524. <GuideAnchor
  525. target="add_conditional_rule"
  526. disabled={!uniformRule?.active || !hasAccess || rules.length !== 1}
  527. >
  528. <AddRuleButton
  529. disabled={!hasAccess}
  530. title={
  531. !hasAccess
  532. ? t("You don't have permission to add a rule")
  533. : undefined
  534. }
  535. priority="primary"
  536. onClick={handleAddRule}
  537. icon={<IconAdd isCircled />}
  538. >
  539. {t('Add Rule')}
  540. </AddRuleButton>
  541. </GuideAnchor>
  542. </ButtonBar>
  543. </RulesPanelFooter>
  544. </RulesPanel>
  545. )}
  546. </Fragment>
  547. </SentryDocumentTitle>
  548. );
  549. }
  550. const RulesPanel = styled(Panel)``;
  551. const RulesPanelHeader = styled(PanelHeader)`
  552. padding: ${space(0.5)} 0;
  553. font-size: ${p => p.theme.fontSizeSmall};
  554. `;
  555. const RulesPanelLayout = styled('div')<{isContent?: boolean}>`
  556. width: 100%;
  557. display: grid;
  558. grid-template-columns: 1fr 0.5fr 74px;
  559. @media (min-width: ${p => p.theme.breakpoints.small}) {
  560. grid-template-columns: 48px 97px 1fr 0.5fr 77px 74px;
  561. }
  562. ${p =>
  563. p.isContent &&
  564. css`
  565. > * {
  566. /* match the height of the ellipsis button */
  567. line-height: 34px;
  568. border-bottom: 1px solid ${p.theme.border};
  569. }
  570. `}
  571. `;
  572. const RulesPanelFooter = styled(PanelFooter)`
  573. border-top: none;
  574. padding: ${space(1.5)} ${space(2)};
  575. grid-column: 1 / -1;
  576. display: flex;
  577. align-items: center;
  578. justify-content: flex-end;
  579. `;
  580. const AddRuleButton = styled(Button)`
  581. @media (max-width: ${p => p.theme.breakpoints.small}) {
  582. width: 100%;
  583. }
  584. `;