uniformRateModal.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. import {Fragment, useEffect, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import isEqual from 'lodash/isEqual';
  4. import Alert from 'sentry/components/alert';
  5. import Button from 'sentry/components/button';
  6. import ButtonBar from 'sentry/components/buttonBar';
  7. import {NumberField} from 'sentry/components/forms';
  8. import ExternalLink from 'sentry/components/links/externalLink';
  9. import LoadingIndicator from 'sentry/components/loadingIndicator';
  10. import {PanelTable} from 'sentry/components/panels';
  11. import QuestionTooltip from 'sentry/components/questionTooltip';
  12. import Radio from 'sentry/components/radio';
  13. import {IconRefresh} from 'sentry/icons';
  14. import {t, tct} from 'sentry/locale';
  15. import ModalStore from 'sentry/stores/modalStore';
  16. import {useLegacyStore} from 'sentry/stores/useLegacyStore';
  17. import space from 'sentry/styles/space';
  18. import {Project, SeriesApi} from 'sentry/types';
  19. import {SamplingRule, UniformModalsSubmit} from 'sentry/types/sampling';
  20. import {defined} from 'sentry/utils';
  21. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  22. import {formatPercentage} from 'sentry/utils/formatters';
  23. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  24. import {SamplingSDKAlert} from '../samplingSDKAlert';
  25. import {
  26. isValidSampleRate,
  27. percentageToRate,
  28. rateToPercentage,
  29. SERVER_SIDE_SAMPLING_DOC_LINK,
  30. } from '../utils';
  31. import {hasFirstBucketsEmpty} from '../utils/hasFirstBucketsEmpty';
  32. import {projectStatsToPredictedSeries} from '../utils/projectStatsToPredictedSeries';
  33. import {projectStatsToSampleRates} from '../utils/projectStatsToSampleRates';
  34. import {projectStatsToSeries} from '../utils/projectStatsToSeries';
  35. import useProjectStats from '../utils/useProjectStats';
  36. import {useRecommendedSdkUpgrades} from '../utils/useRecommendedSdkUpgrades';
  37. import {RecommendedStepsModal, RecommendedStepsModalProps} from './recommendedStepsModal';
  38. import {UniformRateChart} from './uniformRateChart';
  39. const CONSERVATIVE_SAMPLE_RATE = 0.1;
  40. enum Strategy {
  41. CURRENT = 'current',
  42. RECOMMENDED = 'recommended',
  43. }
  44. enum Step {
  45. SET_UNIFORM_SAMPLE_RATE = 'set_uniform_sample_rate',
  46. RECOMMENDED_STEPS = 'recommended_steps',
  47. }
  48. type Props = Omit<
  49. RecommendedStepsModalProps,
  50. 'onSubmit' | 'recommendedSdkUpgrades' | 'projectId' | 'recommendedSampleRate'
  51. > & {
  52. onSubmit: UniformModalsSubmit;
  53. project: Project;
  54. rules: SamplingRule[];
  55. projectStats?: SeriesApi;
  56. };
  57. function UniformRateModal({
  58. Header,
  59. Body,
  60. Footer,
  61. closeModal,
  62. organization,
  63. projectStats,
  64. project,
  65. uniformRule,
  66. onSubmit,
  67. onReadDocs,
  68. ...props
  69. }: Props) {
  70. const [rules, setRules] = useState(props.rules);
  71. const modalStore = useLegacyStore(ModalStore);
  72. const {projectStats: projectStats30d, loading: loading30d} = useProjectStats({
  73. orgSlug: organization.slug,
  74. projectId: project.id,
  75. interval: '1d',
  76. statsPeriod: '30d',
  77. });
  78. const {recommendedSdkUpgrades, fetching: fetchingRecommendedSdkUpgrades} =
  79. useRecommendedSdkUpgrades({
  80. orgSlug: organization.slug,
  81. });
  82. const loading = loading30d || !projectStats || fetchingRecommendedSdkUpgrades;
  83. const [activeStep, setActiveStep] = useState<Step>(Step.SET_UNIFORM_SAMPLE_RATE);
  84. const shouldUseConservativeSampleRate =
  85. recommendedSdkUpgrades.length === 0 &&
  86. hasFirstBucketsEmpty(projectStats30d, 27) &&
  87. hasFirstBucketsEmpty(projectStats, 3);
  88. useEffect(() => {
  89. // updated or created rules will always have a new id,
  90. // therefore the isEqual will always work in this case
  91. if (modalStore.renderer === null && isEqual(rules, props.rules)) {
  92. trackAdvancedAnalyticsEvent(
  93. activeStep === Step.RECOMMENDED_STEPS
  94. ? 'sampling.settings.modal.recommended.next.steps_cancel'
  95. : 'sampling.settings.modal.uniform.rate_cancel',
  96. {
  97. organization,
  98. project_id: project.id,
  99. }
  100. );
  101. }
  102. }, [activeStep, modalStore.renderer, organization, project.id, rules, props.rules]);
  103. const uniformSampleRate = uniformRule?.sampleRate;
  104. const {trueSampleRate, maxSafeSampleRate} = projectStatsToSampleRates(projectStats);
  105. const currentClientSampling =
  106. defined(trueSampleRate) && !isNaN(trueSampleRate) ? trueSampleRate : undefined;
  107. const currentServerSampling =
  108. defined(uniformSampleRate) && !isNaN(uniformSampleRate)
  109. ? uniformSampleRate
  110. : undefined;
  111. const recommendedClientSampling =
  112. defined(maxSafeSampleRate) && !isNaN(maxSafeSampleRate)
  113. ? maxSafeSampleRate
  114. : undefined;
  115. const recommendedServerSampling = shouldUseConservativeSampleRate
  116. ? CONSERVATIVE_SAMPLE_RATE
  117. : currentClientSampling;
  118. const [selectedStrategy, setSelectedStrategy] = useState<Strategy>(Strategy.CURRENT);
  119. const [clientInput, setClientInput] = useState(
  120. rateToPercentage(recommendedClientSampling)
  121. );
  122. const [serverInput, setServerInput] = useState(
  123. rateToPercentage(recommendedServerSampling)
  124. );
  125. // ^^^ We use clientInput and serverInput variables just for the text fields, everywhere else we should use client and server variables vvv
  126. const client = percentageToRate(clientInput);
  127. const server = percentageToRate(serverInput);
  128. const [saving, setSaving] = useState(false);
  129. const shouldHaveNextStep =
  130. client !== currentClientSampling || recommendedSdkUpgrades.length > 0;
  131. useEffect(() => {
  132. setClientInput(rateToPercentage(recommendedClientSampling));
  133. setServerInput(rateToPercentage(recommendedServerSampling));
  134. }, [recommendedClientSampling, recommendedServerSampling]);
  135. useEffect(() => {
  136. trackAdvancedAnalyticsEvent(
  137. selectedStrategy === Strategy.RECOMMENDED
  138. ? 'sampling.settings.modal.uniform.rate_switch_recommended'
  139. : 'sampling.settings.modal.uniform.rate_switch_current',
  140. {
  141. organization,
  142. project_id: project.id,
  143. }
  144. );
  145. }, [selectedStrategy, organization, project.id]);
  146. const isEdited =
  147. client !== recommendedClientSampling || server !== recommendedServerSampling;
  148. const isValid = isValidSampleRate(client) && isValidSampleRate(server);
  149. function handlePrimaryButtonClick() {
  150. // this can either be "Next" or "Done"
  151. if (!isValid) {
  152. return;
  153. }
  154. if (shouldHaveNextStep) {
  155. trackAdvancedAnalyticsEvent('sampling.settings.modal.uniform.rate_next', {
  156. organization,
  157. project_id: project.id,
  158. });
  159. setActiveStep(Step.RECOMMENDED_STEPS);
  160. return;
  161. }
  162. setSaving(true);
  163. onSubmit({
  164. recommendedSampleRate: !isEdited,
  165. uniformRateModalOrigin: true,
  166. sampleRate: server!,
  167. rule: uniformRule,
  168. onSuccess: newRules => {
  169. setSaving(false);
  170. setRules(newRules);
  171. closeModal();
  172. },
  173. onError: () => {
  174. setSaving(false);
  175. },
  176. });
  177. }
  178. function handleReadDocs() {
  179. trackAdvancedAnalyticsEvent('sampling.settings.modal.uniform.rate_read_docs', {
  180. organization,
  181. project_id: project.id,
  182. });
  183. onReadDocs();
  184. }
  185. if (activeStep === Step.RECOMMENDED_STEPS) {
  186. return (
  187. <RecommendedStepsModal
  188. {...props}
  189. Header={Header}
  190. Body={Body}
  191. Footer={Footer}
  192. closeModal={closeModal}
  193. organization={organization}
  194. recommendedSdkUpgrades={recommendedSdkUpgrades}
  195. onGoBack={() => setActiveStep(Step.SET_UNIFORM_SAMPLE_RATE)}
  196. onSubmit={onSubmit}
  197. onReadDocs={onReadDocs}
  198. clientSampleRate={client}
  199. serverSampleRate={server}
  200. uniformRule={uniformRule}
  201. projectId={project.id}
  202. recommendedSampleRate={!isEdited}
  203. onSetRules={setRules}
  204. />
  205. );
  206. }
  207. return (
  208. <Fragment>
  209. <Header closeButton>
  210. <h4>{t('Set a global sample rate')}</h4>
  211. </Header>
  212. <Body>
  213. <TextBlock>
  214. {tct(
  215. 'Set a server-side sample rate for all transactions using our suggestion as a starting point. To accurately monitor overall performance, we also suggest changing your client(SDK) sample rate to allow more metrics to be processed. [learnMoreLink: Learn more about quota management].',
  216. {
  217. learnMoreLink: <ExternalLink href={SERVER_SIDE_SAMPLING_DOC_LINK} />,
  218. }
  219. )}
  220. </TextBlock>
  221. {loading ? (
  222. <LoadingIndicator />
  223. ) : (
  224. <Fragment>
  225. <UniformRateChart
  226. series={
  227. selectedStrategy === Strategy.CURRENT
  228. ? projectStatsToSeries(projectStats30d)
  229. : projectStatsToPredictedSeries(projectStats30d, client, server)
  230. }
  231. isLoading={loading30d}
  232. />
  233. <StyledPanelTable
  234. headers={[
  235. t('Sampling Values'),
  236. <RightAligned key="client">{t('Client')}</RightAligned>,
  237. <RightAligned key="server">{t('Server')}</RightAligned>,
  238. '',
  239. ]}
  240. >
  241. <Fragment>
  242. <Label htmlFor="sampling-current">
  243. <Radio
  244. id="sampling-current"
  245. checked={selectedStrategy === Strategy.CURRENT}
  246. onChange={() => {
  247. setSelectedStrategy(Strategy.CURRENT);
  248. }}
  249. />
  250. {t('Current')}
  251. </Label>
  252. <RightAligned>
  253. {defined(currentClientSampling)
  254. ? formatPercentage(currentClientSampling)
  255. : 'N/A'}
  256. </RightAligned>
  257. <RightAligned>
  258. {defined(currentServerSampling)
  259. ? formatPercentage(currentServerSampling)
  260. : 'N/A'}
  261. </RightAligned>
  262. <div />
  263. </Fragment>
  264. <Fragment>
  265. <Label htmlFor="sampling-recommended">
  266. <Radio
  267. id="sampling-recommended"
  268. checked={selectedStrategy === Strategy.RECOMMENDED}
  269. onChange={() => {
  270. setSelectedStrategy(Strategy.RECOMMENDED);
  271. }}
  272. />
  273. {isEdited ? t('New') : t('Suggested')}
  274. {!isEdited && (
  275. <QuestionTooltip
  276. title={t(
  277. 'Optimal sample rates based on your organization’s usage and quota.'
  278. )}
  279. size="sm"
  280. />
  281. )}
  282. </Label>
  283. <RightAligned>
  284. <StyledNumberField
  285. name="recommended-client-sampling"
  286. placeholder="%"
  287. value={clientInput ?? null}
  288. onChange={value => {
  289. setClientInput(value === '' ? undefined : value);
  290. }}
  291. onFocus={() => setSelectedStrategy(Strategy.RECOMMENDED)}
  292. stacked
  293. flexibleControlStateSize
  294. inline={false}
  295. />
  296. </RightAligned>
  297. <RightAligned>
  298. <StyledNumberField
  299. name="recommended-server-sampling"
  300. placeholder="%"
  301. value={serverInput ?? null}
  302. onChange={value => {
  303. setServerInput(value === '' ? undefined : value);
  304. }}
  305. onFocus={() => setSelectedStrategy(Strategy.RECOMMENDED)}
  306. stacked
  307. flexibleControlStateSize
  308. inline={false}
  309. />
  310. </RightAligned>
  311. <ResetButton>
  312. {isEdited && (
  313. <Button
  314. icon={<IconRefresh size="sm" />}
  315. aria-label={t('Reset to suggested values')}
  316. onClick={() => {
  317. setClientInput(rateToPercentage(recommendedClientSampling));
  318. setServerInput(rateToPercentage(recommendedServerSampling));
  319. }}
  320. borderless
  321. size="zero"
  322. />
  323. )}
  324. </ResetButton>
  325. </Fragment>
  326. </StyledPanelTable>
  327. <SamplingSDKAlert
  328. organization={organization}
  329. projectId={project.id}
  330. rules={rules}
  331. recommendedSdkUpgrades={recommendedSdkUpgrades}
  332. showLinkToTheModal={false}
  333. onReadDocs={onReadDocs}
  334. />
  335. {shouldUseConservativeSampleRate && (
  336. <Alert type="info" showIcon>
  337. {t(
  338. "For accurate suggestions, we need at least 48hrs to ingest transactions. Meanwhile, here's a conservative server-side sampling rate which can be changed later on."
  339. )}
  340. </Alert>
  341. )}
  342. </Fragment>
  343. )}
  344. </Body>
  345. <Footer>
  346. <FooterActions>
  347. <Button href={SERVER_SIDE_SAMPLING_DOC_LINK} onClick={handleReadDocs} external>
  348. {t('Read Docs')}
  349. </Button>
  350. <ButtonBar gap={1}>
  351. {shouldHaveNextStep && <Stepper>{t('Step 1 of 2')}</Stepper>}
  352. <Button onClick={closeModal}>{t('Cancel')}</Button>
  353. <Button
  354. priority="primary"
  355. onClick={handlePrimaryButtonClick}
  356. disabled={saving || !isValid || selectedStrategy === Strategy.CURRENT}
  357. title={
  358. selectedStrategy === Strategy.CURRENT
  359. ? t('Current sampling values selected')
  360. : !isValid
  361. ? t('Sample rate is not valid')
  362. : undefined
  363. }
  364. >
  365. {shouldHaveNextStep ? t('Next') : t('Done')}
  366. </Button>
  367. </ButtonBar>
  368. </FooterActions>
  369. </Footer>
  370. </Fragment>
  371. );
  372. }
  373. const StyledPanelTable = styled(PanelTable)`
  374. grid-template-columns: 1fr 115px 115px 35px;
  375. border-top-left-radius: 0;
  376. border-top-right-radius: 0;
  377. `;
  378. const RightAligned = styled('div')`
  379. text-align: right;
  380. `;
  381. const ResetButton = styled('div')`
  382. padding-left: 0;
  383. display: inline-flex;
  384. `;
  385. const Label = styled('label')`
  386. font-weight: 400;
  387. display: inline-flex;
  388. align-items: center;
  389. gap: ${space(1)};
  390. margin-bottom: 0;
  391. `;
  392. const StyledNumberField = styled(NumberField)`
  393. width: 100%;
  394. `;
  395. export const FooterActions = styled('div')`
  396. display: flex;
  397. justify-content: space-between;
  398. align-items: center;
  399. flex: 1;
  400. gap: ${space(1)};
  401. `;
  402. export const Stepper = styled('span')`
  403. font-size: ${p => p.theme.fontSizeMedium};
  404. color: ${p => p.theme.subText};
  405. `;
  406. export {UniformRateModal};