serverSideSampling.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  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 {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  6. import {openModal} from 'sentry/actionCreators/modal';
  7. import Alert from 'sentry/components/alert';
  8. import Button from 'sentry/components/button';
  9. import ButtonBar from 'sentry/components/buttonBar';
  10. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  11. import {Panel, PanelFooter, PanelHeader} from 'sentry/components/panels';
  12. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  13. import {IconAdd} from 'sentry/icons';
  14. import {t} from 'sentry/locale';
  15. import ProjectStore from 'sentry/stores/projectsStore';
  16. import space from 'sentry/styles/space';
  17. import {Project} from 'sentry/types';
  18. import {SamplingRule, SamplingRuleOperator, SamplingRules} from 'sentry/types/sampling';
  19. import {defined} from 'sentry/utils';
  20. import handleXhrErrorResponse from 'sentry/utils/handleXhrErrorResponse';
  21. import useApi from 'sentry/utils/useApi';
  22. import useOrganization from 'sentry/utils/useOrganization';
  23. import usePrevious from 'sentry/utils/usePrevious';
  24. import useProjects from 'sentry/utils/useProjects';
  25. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  26. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  27. import PermissionAlert from 'sentry/views/settings/organization/permissionAlert';
  28. import {DraggableList, UpdateItemsProps} from '../sampling/rules/draggableList';
  29. import {ActivateModal} from './modals/activateModal';
  30. import {RecommendedStepsModal} from './modals/recommendedStepsModal';
  31. import {SpecificConditionsModal} from './modals/specificConditionsModal';
  32. import {responsiveModal} from './modals/styles';
  33. import {UniformRateModal} from './modals/uniformRateModal';
  34. import useProjectStats from './utils/useProjectStats';
  35. import useSamplingDistribution from './utils/useSamplingDistribution';
  36. import useSdkVersions from './utils/useSdkVersions';
  37. import {Promo} from './promo';
  38. import {
  39. ActiveColumn,
  40. Column,
  41. ConditionColumn,
  42. GrabColumn,
  43. OperatorColumn,
  44. RateColumn,
  45. Rule,
  46. } from './rule';
  47. import {SERVER_SIDE_SAMPLING_DOC_LINK} from './utils';
  48. type Props = {
  49. project: Project;
  50. };
  51. export function ServerSideSampling({project}: Props) {
  52. const organization = useOrganization();
  53. const api = useApi();
  54. const hasAccess = organization.access.includes('project:write');
  55. const currentRules = project.dynamicSampling?.rules;
  56. const previousRules = usePrevious(currentRules);
  57. const [rules, setRules] = useState<SamplingRules>(currentRules ?? []);
  58. const {projectStats} = useProjectStats({
  59. orgSlug: organization.slug,
  60. projectId: project?.id,
  61. interval: '1h',
  62. statsPeriod: '48h',
  63. });
  64. const {samplingDistribution} = useSamplingDistribution({
  65. orgSlug: organization.slug,
  66. projSlug: project.slug,
  67. });
  68. const {samplingSdkVersions} = useSdkVersions({
  69. orgSlug: organization.slug,
  70. projSlug: project.slug,
  71. projectIds: samplingDistribution?.project_breakdown?.map(
  72. projectBreakdown => projectBreakdown.project_id
  73. ),
  74. });
  75. const notSendingSampleRateSdkUpgrades =
  76. samplingSdkVersions?.filter(
  77. samplingSdkVersion => !samplingSdkVersion.isSendingSampleRate
  78. ) ?? [];
  79. const {projects} = useProjects({
  80. slugs: notSendingSampleRateSdkUpgrades.map(sdkUpgrade => sdkUpgrade.project),
  81. orgId: organization.slug,
  82. });
  83. // Rules without a condition (Else case) always have to be 'pinned' to the bottom of the list
  84. // and cannot be sorted
  85. const items = rules.map(rule => ({
  86. ...rule,
  87. id: String(rule.id),
  88. bottomPinned: !rule.condition.inner.length,
  89. }));
  90. const recommendedSdkUpgrades = projects
  91. .map(upgradeSDKfromProject => {
  92. const sdkInfo = notSendingSampleRateSdkUpgrades.find(
  93. notSendingSampleRateSdkUpgrade =>
  94. notSendingSampleRateSdkUpgrade.project === upgradeSDKfromProject.slug
  95. );
  96. if (!sdkInfo) {
  97. return undefined;
  98. }
  99. return {
  100. project: upgradeSDKfromProject,
  101. latestSDKName: sdkInfo.latestSDKName,
  102. latestSDKVersion: sdkInfo.latestSDKVersion,
  103. };
  104. })
  105. .filter(defined);
  106. useEffect(() => {
  107. if (!isEqual(previousRules, currentRules)) {
  108. setRules(currentRules ?? []);
  109. }
  110. }, [currentRules, previousRules]);
  111. function handleActivateToggle(ruleId: SamplingRule['id']) {
  112. openModal(modalProps => (
  113. <ActivateModal
  114. {...modalProps}
  115. ruleId={ruleId}
  116. rules={rules}
  117. orgSlug={organization.slug}
  118. projSlug={project.slug}
  119. />
  120. ));
  121. }
  122. function handleGetStarted() {
  123. openModal(
  124. modalProps => (
  125. <UniformRateModal
  126. {...modalProps}
  127. organization={organization}
  128. project={project}
  129. projectStats={projectStats}
  130. rules={rules}
  131. recommendedSdkUpgrades={recommendedSdkUpgrades}
  132. />
  133. ),
  134. {
  135. modalCss: responsiveModal,
  136. }
  137. );
  138. }
  139. function handleOpenRecommendedSteps() {
  140. if (!recommendedSdkUpgrades.length) {
  141. return;
  142. }
  143. openModal(modalProps => (
  144. <RecommendedStepsModal
  145. {...modalProps}
  146. organization={organization}
  147. project={project}
  148. recommendedSdkUpgrades={recommendedSdkUpgrades}
  149. />
  150. ));
  151. }
  152. async function handleSortRules({overIndex, reorderedItems: ruleIds}: UpdateItemsProps) {
  153. if (!rules[overIndex].condition.inner.length) {
  154. addErrorMessage(
  155. t('Rules with conditions cannot be below rules without conditions')
  156. );
  157. return;
  158. }
  159. const sortedRules = ruleIds
  160. .map(ruleId => rules.find(rule => String(rule.id) === ruleId))
  161. .filter(rule => !!rule) as SamplingRule[];
  162. setRules(sortedRules);
  163. try {
  164. const newProjectDetails = await api.requestPromise(
  165. `/projects/${organization.slug}/${project.slug}/`,
  166. {
  167. method: 'PUT',
  168. data: {dynamicSampling: {rules: sortedRules}},
  169. }
  170. );
  171. ProjectStore.onUpdateSuccess(newProjectDetails);
  172. addSuccessMessage(t('Successfully sorted sampling rules'));
  173. } catch (error) {
  174. setRules(previousRules ?? []);
  175. const message = t('Unable to sort sampling rules');
  176. handleXhrErrorResponse(message)(error);
  177. addErrorMessage(message);
  178. }
  179. }
  180. function handleAddRule() {
  181. openModal(modalProps => (
  182. <SpecificConditionsModal
  183. {...modalProps}
  184. organization={organization}
  185. project={project}
  186. rules={rules}
  187. />
  188. ));
  189. }
  190. function handleEditRule(rule: SamplingRule) {
  191. openModal(modalProps => (
  192. <SpecificConditionsModal
  193. {...modalProps}
  194. organization={organization}
  195. project={project}
  196. rule={rule}
  197. rules={rules}
  198. />
  199. ));
  200. }
  201. async function handleDeleteRule(rule: SamplingRule) {
  202. try {
  203. const newProjectDetails = await api.requestPromise(
  204. `/projects/${organization.slug}/${project.slug}/`,
  205. {
  206. method: 'PUT',
  207. data: {dynamicSampling: {rules: rules.filter(({id}) => id !== rule.id)}},
  208. }
  209. );
  210. ProjectStore.onUpdateSuccess(newProjectDetails);
  211. addSuccessMessage(t('Successfully deleted sampling rule'));
  212. } catch (error) {
  213. const message = t('Unable to delete sampling rule');
  214. handleXhrErrorResponse(message)(error);
  215. addErrorMessage(message);
  216. }
  217. }
  218. return (
  219. <SentryDocumentTitle title={t('Server-side Sampling')}>
  220. <Fragment>
  221. <SettingsPageHeader title={t('Server-side Sampling')} />
  222. <TextBlock>
  223. {t(
  224. '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.'
  225. )}
  226. </TextBlock>
  227. <PermissionAlert
  228. access={['project:write']}
  229. message={t(
  230. 'These settings can only be edited by users with the organization owner, manager, or admin role.'
  231. )}
  232. />
  233. {!!recommendedSdkUpgrades.length && !!rules.length && (
  234. <Alert
  235. data-test-id="recommended-sdk-upgrades-alert"
  236. type="info"
  237. showIcon
  238. trailingItems={
  239. <Button onClick={handleOpenRecommendedSteps} priority="link" borderless>
  240. {t('Learn More')}
  241. </Button>
  242. }
  243. >
  244. {t(
  245. 'To keep a consistent amount of transactions across your applications multiple services, we recommend you update the SDK versions for the following projects:'
  246. )}
  247. <Projects>
  248. {recommendedSdkUpgrades.map(recommendedSdkUpgrade => (
  249. <ProjectBadge
  250. key={recommendedSdkUpgrade.project.id}
  251. project={recommendedSdkUpgrade.project}
  252. avatarSize={16}
  253. />
  254. ))}
  255. </Projects>
  256. </Alert>
  257. )}
  258. <RulesPanel>
  259. <RulesPanelHeader lightText>
  260. <RulesPanelLayout>
  261. <GrabColumn />
  262. <OperatorColumn>{t('Operator')}</OperatorColumn>
  263. <ConditionColumn>{t('Condition')}</ConditionColumn>
  264. <RateColumn>{t('Rate')}</RateColumn>
  265. <ActiveColumn>{t('Active')}</ActiveColumn>
  266. <Column />
  267. </RulesPanelLayout>
  268. </RulesPanelHeader>
  269. {!rules.length && (
  270. <Promo onGetStarted={handleGetStarted} hasAccess={hasAccess} />
  271. )}
  272. {!!rules.length && (
  273. <Fragment>
  274. <DraggableList
  275. disabled={!hasAccess}
  276. items={items}
  277. onUpdateItems={handleSortRules}
  278. wrapperStyle={({isDragging, isSorting, index}) => {
  279. if (isDragging) {
  280. return {
  281. cursor: 'grabbing',
  282. };
  283. }
  284. if (isSorting) {
  285. return {};
  286. }
  287. return {
  288. transform: 'none',
  289. transformOrigin: '0',
  290. '--box-shadow': 'none',
  291. '--box-shadow-picked-up': 'none',
  292. overflow: 'visible',
  293. position: 'relative',
  294. zIndex: rules.length - index,
  295. cursor: 'default',
  296. };
  297. }}
  298. renderItem={({value, listeners, attributes, dragging, sorting}) => {
  299. const itemsRuleIndex = items.findIndex(item => item.id === value);
  300. if (itemsRuleIndex === -1) {
  301. return null;
  302. }
  303. const itemsRule = items[itemsRuleIndex];
  304. const currentRule = {
  305. active: itemsRule.active,
  306. condition: itemsRule.condition,
  307. sampleRate: itemsRule.sampleRate,
  308. type: itemsRule.type,
  309. id: Number(itemsRule.id),
  310. };
  311. return (
  312. <RulesPanelLayout isContent>
  313. <Rule
  314. operator={
  315. itemsRule.id === items[0].id
  316. ? SamplingRuleOperator.IF
  317. : itemsRule.bottomPinned
  318. ? SamplingRuleOperator.ELSE
  319. : SamplingRuleOperator.ELSE_IF
  320. }
  321. hideGrabButton={items.length === 1}
  322. rule={{
  323. ...currentRule,
  324. bottomPinned: itemsRule.bottomPinned,
  325. }}
  326. onEditRule={() => handleEditRule(currentRule)}
  327. onDeleteRule={() => handleDeleteRule(currentRule)}
  328. onActivate={() => handleActivateToggle(currentRule.id)}
  329. noPermission={!hasAccess}
  330. listeners={listeners}
  331. grabAttributes={attributes}
  332. dragging={dragging}
  333. sorting={sorting}
  334. />
  335. </RulesPanelLayout>
  336. );
  337. }}
  338. />
  339. <RulesPanelFooter>
  340. <ButtonBar gap={1}>
  341. <Button href={SERVER_SIDE_SAMPLING_DOC_LINK} external>
  342. {t('Read Docs')}
  343. </Button>
  344. <AddRuleButton
  345. disabled={!hasAccess}
  346. title={
  347. !hasAccess
  348. ? t("You don't have permission to add a rule")
  349. : undefined
  350. }
  351. priority="primary"
  352. onClick={handleAddRule}
  353. icon={<IconAdd isCircled />}
  354. >
  355. {t('Add Rule')}
  356. </AddRuleButton>
  357. </ButtonBar>
  358. </RulesPanelFooter>
  359. </Fragment>
  360. )}
  361. </RulesPanel>
  362. </Fragment>
  363. </SentryDocumentTitle>
  364. );
  365. }
  366. const RulesPanel = styled(Panel)``;
  367. const RulesPanelHeader = styled(PanelHeader)`
  368. padding: ${space(0.5)} 0;
  369. font-size: ${p => p.theme.fontSizeSmall};
  370. `;
  371. const RulesPanelLayout = styled('div')<{isContent?: boolean}>`
  372. width: 100%;
  373. display: grid;
  374. grid-template-columns: 1fr 0.5fr 74px;
  375. @media (min-width: ${p => p.theme.breakpoints.small}) {
  376. grid-template-columns: 48px 95px 1fr 0.5fr 77px 74px;
  377. }
  378. ${p =>
  379. p.isContent &&
  380. css`
  381. > * {
  382. /* match the height of the ellipsis button */
  383. line-height: 34px;
  384. border-bottom: 1px solid ${p.theme.border};
  385. }
  386. `}
  387. `;
  388. const RulesPanelFooter = styled(PanelFooter)`
  389. border-top: none;
  390. padding: ${space(1.5)} ${space(2)};
  391. grid-column: 1 / -1;
  392. display: flex;
  393. align-items: center;
  394. justify-content: flex-end;
  395. `;
  396. const AddRuleButton = styled(Button)`
  397. @media (max-width: ${p => p.theme.breakpoints.small}) {
  398. width: 100%;
  399. }
  400. `;
  401. const Projects = styled('div')`
  402. display: flex;
  403. flex-wrap: wrap;
  404. gap: ${space(1.5)};
  405. justify-content: flex-start;
  406. margin-top: ${space(1)};
  407. `;