serverSideSampling.tsx 18 KB

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