uniformRateModal.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  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} = useRecommendedSdkUpgrades({
  79. orgSlug: organization.slug,
  80. });
  81. const loading = loading30d || !projectStats;
  82. const [activeStep, setActiveStep] = useState<Step>(Step.SET_UNIFORM_SAMPLE_RATE);
  83. const shouldUseConservativeSampleRate =
  84. recommendedSdkUpgrades.length === 0 &&
  85. hasFirstBucketsEmpty(projectStats30d, 27) &&
  86. hasFirstBucketsEmpty(projectStats, 3);
  87. useEffect(() => {
  88. // updated or created rules will always have a new id,
  89. // therefore the isEqual will always work in this case
  90. if (modalStore.renderer === null && isEqual(rules, props.rules)) {
  91. trackAdvancedAnalyticsEvent(
  92. activeStep === Step.RECOMMENDED_STEPS
  93. ? 'sampling.settings.modal.recommended.next.steps_cancel'
  94. : 'sampling.settings.modal.uniform.rate_cancel',
  95. {
  96. organization,
  97. project_id: project.id,
  98. }
  99. );
  100. }
  101. }, [activeStep, modalStore.renderer, organization, project.id, rules, props.rules]);
  102. const uniformSampleRate = uniformRule?.sampleRate;
  103. const {trueSampleRate, maxSafeSampleRate} = projectStatsToSampleRates(projectStats);
  104. const currentClientSampling =
  105. defined(trueSampleRate) && !isNaN(trueSampleRate) ? trueSampleRate : undefined;
  106. const currentServerSampling =
  107. defined(uniformSampleRate) && !isNaN(uniformSampleRate)
  108. ? uniformSampleRate
  109. : undefined;
  110. const recommendedClientSampling =
  111. defined(maxSafeSampleRate) && !isNaN(maxSafeSampleRate)
  112. ? maxSafeSampleRate
  113. : undefined;
  114. const recommendedServerSampling = shouldUseConservativeSampleRate
  115. ? CONSERVATIVE_SAMPLE_RATE
  116. : currentClientSampling;
  117. const [selectedStrategy, setSelectedStrategy] = useState<Strategy>(Strategy.CURRENT);
  118. const [clientInput, setClientInput] = useState(
  119. rateToPercentage(recommendedClientSampling)
  120. );
  121. const [serverInput, setServerInput] = useState(
  122. rateToPercentage(recommendedServerSampling)
  123. );
  124. // ^^^ We use clientInput and serverInput variables just for the text fields, everywhere else we should use client and server variables vvv
  125. const client = percentageToRate(clientInput);
  126. const server = percentageToRate(serverInput);
  127. const [saving, setSaving] = useState(false);
  128. const shouldHaveNextStep =
  129. client !== currentClientSampling || recommendedSdkUpgrades.length > 0;
  130. useEffect(() => {
  131. setClientInput(rateToPercentage(recommendedClientSampling));
  132. setServerInput(rateToPercentage(recommendedServerSampling));
  133. }, [recommendedClientSampling, recommendedServerSampling]);
  134. useEffect(() => {
  135. trackAdvancedAnalyticsEvent(
  136. selectedStrategy === Strategy.RECOMMENDED
  137. ? 'sampling.settings.modal.uniform.rate_switch_recommended'
  138. : 'sampling.settings.modal.uniform.rate_switch_current',
  139. {
  140. organization,
  141. project_id: project.id,
  142. }
  143. );
  144. }, [selectedStrategy, organization, project.id]);
  145. const isEdited =
  146. client !== recommendedClientSampling || server !== recommendedServerSampling;
  147. const isValid = isValidSampleRate(client) && isValidSampleRate(server);
  148. function handlePrimaryButtonClick() {
  149. // this can either be "Next" or "Done"
  150. if (!isValid) {
  151. return;
  152. }
  153. if (shouldHaveNextStep) {
  154. trackAdvancedAnalyticsEvent('sampling.settings.modal.uniform.rate_next', {
  155. organization,
  156. project_id: project.id,
  157. });
  158. setActiveStep(Step.RECOMMENDED_STEPS);
  159. return;
  160. }
  161. setSaving(true);
  162. onSubmit({
  163. recommendedSampleRate: !isEdited,
  164. uniformRateModalOrigin: true,
  165. sampleRate: server!,
  166. rule: uniformRule,
  167. onSuccess: newRules => {
  168. setSaving(false);
  169. setRules(newRules);
  170. closeModal();
  171. },
  172. onError: () => {
  173. setSaving(false);
  174. },
  175. });
  176. }
  177. function handleReadDocs() {
  178. trackAdvancedAnalyticsEvent('sampling.settings.modal.uniform.rate_read_docs', {
  179. organization,
  180. project_id: project.id,
  181. });
  182. onReadDocs();
  183. }
  184. if (activeStep === Step.RECOMMENDED_STEPS) {
  185. return (
  186. <RecommendedStepsModal
  187. {...props}
  188. Header={Header}
  189. Body={Body}
  190. Footer={Footer}
  191. closeModal={closeModal}
  192. organization={organization}
  193. recommendedSdkUpgrades={recommendedSdkUpgrades}
  194. onGoBack={() => setActiveStep(Step.SET_UNIFORM_SAMPLE_RATE)}
  195. onSubmit={onSubmit}
  196. onReadDocs={onReadDocs}
  197. clientSampleRate={client}
  198. serverSampleRate={server}
  199. uniformRule={uniformRule}
  200. projectId={project.id}
  201. recommendedSampleRate={!isEdited}
  202. onSetRules={setRules}
  203. />
  204. );
  205. }
  206. return (
  207. <Fragment>
  208. <Header closeButton>
  209. <h4>{t('Define a global sample rate')}</h4>
  210. </Header>
  211. <Body>
  212. <TextBlock>
  213. {tct(
  214. 'Set a global sample rate for the percent of transactions you want to process (Client) and those you want to index (Server) for your project. Below are suggested rates based on your organization’s usage and quota. Once set, the number of transactions processed and indexed for this project come from your organization’s overall quota and might impact the amount of transactions retained for other projects. [learnMoreLink:Learn more about quota management.]',
  215. {
  216. learnMoreLink: <ExternalLink href="" />,
  217. }
  218. )}
  219. </TextBlock>
  220. {loading ? (
  221. <LoadingIndicator />
  222. ) : (
  223. <Fragment>
  224. <UniformRateChart
  225. series={
  226. selectedStrategy === Strategy.CURRENT
  227. ? projectStatsToSeries(projectStats30d)
  228. : projectStatsToPredictedSeries(projectStats30d, client, server)
  229. }
  230. isLoading={loading30d}
  231. />
  232. <StyledPanelTable
  233. headers={[
  234. t('Sampling Values'),
  235. <RightAligned key="client">{t('Client')}</RightAligned>,
  236. <RightAligned key="server">{t('Server')}</RightAligned>,
  237. '',
  238. ]}
  239. >
  240. <Fragment>
  241. <Label htmlFor="sampling-current">
  242. <Radio
  243. id="sampling-current"
  244. checked={selectedStrategy === Strategy.CURRENT}
  245. onChange={() => {
  246. setSelectedStrategy(Strategy.CURRENT);
  247. }}
  248. />
  249. {t('Current')}
  250. </Label>
  251. <RightAligned>
  252. {defined(currentClientSampling)
  253. ? formatPercentage(currentClientSampling)
  254. : 'N/A'}
  255. </RightAligned>
  256. <RightAligned>
  257. {defined(currentServerSampling)
  258. ? formatPercentage(currentServerSampling)
  259. : 'N/A'}
  260. </RightAligned>
  261. <div />
  262. </Fragment>
  263. <Fragment>
  264. <Label htmlFor="sampling-recommended">
  265. <Radio
  266. id="sampling-recommended"
  267. checked={selectedStrategy === Strategy.RECOMMENDED}
  268. onChange={() => {
  269. setSelectedStrategy(Strategy.RECOMMENDED);
  270. }}
  271. />
  272. {isEdited ? t('New') : t('Suggested')}
  273. {!isEdited && (
  274. <QuestionTooltip
  275. title={t(
  276. 'These are suggested sample rates you can set based on your organization’s overall usage and quota.'
  277. )}
  278. size="sm"
  279. />
  280. )}
  281. </Label>
  282. <RightAligned>
  283. <StyledNumberField
  284. name="recommended-client-sampling"
  285. placeholder="%"
  286. value={clientInput ?? null}
  287. onChange={value => {
  288. setClientInput(value === '' ? undefined : value);
  289. }}
  290. onFocus={() => setSelectedStrategy(Strategy.RECOMMENDED)}
  291. stacked
  292. flexibleControlStateSize
  293. inline={false}
  294. />
  295. </RightAligned>
  296. <RightAligned>
  297. <StyledNumberField
  298. name="recommended-server-sampling"
  299. placeholder="%"
  300. value={serverInput ?? null}
  301. onChange={value => {
  302. setServerInput(value === '' ? undefined : value);
  303. }}
  304. onFocus={() => setSelectedStrategy(Strategy.RECOMMENDED)}
  305. stacked
  306. flexibleControlStateSize
  307. inline={false}
  308. />
  309. </RightAligned>
  310. <ResetButton>
  311. {isEdited && (
  312. <Button
  313. icon={<IconRefresh size="sm" />}
  314. aria-label={t('Reset to suggested values')}
  315. onClick={() => {
  316. setClientInput(rateToPercentage(recommendedClientSampling));
  317. setServerInput(rateToPercentage(recommendedServerSampling));
  318. }}
  319. borderless
  320. size="zero"
  321. />
  322. )}
  323. </ResetButton>
  324. </Fragment>
  325. </StyledPanelTable>
  326. <SamplingSDKAlert
  327. organization={organization}
  328. projectId={project.id}
  329. rules={rules}
  330. recommendedSdkUpgrades={recommendedSdkUpgrades}
  331. showLinkToTheModal={false}
  332. onReadDocs={onReadDocs}
  333. />
  334. {shouldUseConservativeSampleRate && (
  335. <Alert type="info" showIcon>
  336. {t(
  337. "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."
  338. )}
  339. </Alert>
  340. )}
  341. </Fragment>
  342. )}
  343. </Body>
  344. <Footer>
  345. <FooterActions>
  346. <Button href={SERVER_SIDE_SAMPLING_DOC_LINK} onClick={handleReadDocs} external>
  347. {t('Read Docs')}
  348. </Button>
  349. <ButtonBar gap={1}>
  350. {shouldHaveNextStep && <Stepper>{t('Step 1 of 2')}</Stepper>}
  351. <Button onClick={closeModal}>{t('Cancel')}</Button>
  352. <Button
  353. priority="primary"
  354. onClick={handlePrimaryButtonClick}
  355. disabled={saving || !isValid || selectedStrategy === Strategy.CURRENT}
  356. title={
  357. selectedStrategy === Strategy.CURRENT
  358. ? t('Current sampling values selected')
  359. : !isValid
  360. ? t('Sample rate is not valid')
  361. : undefined
  362. }
  363. >
  364. {shouldHaveNextStep ? t('Next') : t('Done')}
  365. </Button>
  366. </ButtonBar>
  367. </FooterActions>
  368. </Footer>
  369. </Fragment>
  370. );
  371. }
  372. const StyledPanelTable = styled(PanelTable)`
  373. grid-template-columns: 1fr 115px 115px 35px;
  374. border-top-left-radius: 0;
  375. border-top-right-radius: 0;
  376. `;
  377. const RightAligned = styled('div')`
  378. text-align: right;
  379. `;
  380. const ResetButton = styled('div')`
  381. padding-left: 0;
  382. display: inline-flex;
  383. `;
  384. const Label = styled('label')`
  385. font-weight: 400;
  386. display: inline-flex;
  387. align-items: center;
  388. gap: ${space(1)};
  389. margin-bottom: 0;
  390. `;
  391. const StyledNumberField = styled(NumberField)`
  392. width: 100%;
  393. `;
  394. export const FooterActions = styled('div')`
  395. display: flex;
  396. justify-content: space-between;
  397. align-items: center;
  398. flex: 1;
  399. gap: ${space(1)};
  400. `;
  401. export const Stepper = styled('span')`
  402. font-size: ${p => p.theme.fontSizeMedium};
  403. color: ${p => p.theme.subText};
  404. `;
  405. export {UniformRateModal};