uniformRateModal.tsx 14 KB

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