projectSampling.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. import React, {Fragment, useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {motion} from 'framer-motion';
  4. import {
  5. addErrorMessage,
  6. addLoadingMessage,
  7. addSuccessMessage,
  8. } from 'sentry/actionCreators/indicator';
  9. import {Button} from 'sentry/components/button';
  10. import LoadingError from 'sentry/components/loadingError';
  11. import Panel from 'sentry/components/panels/panel';
  12. import PanelBody from 'sentry/components/panels/panelBody';
  13. import PanelHeader from 'sentry/components/panels/panelHeader';
  14. import {Tooltip} from 'sentry/components/tooltip';
  15. import {IconArrow} from 'sentry/icons';
  16. import {t} from 'sentry/locale';
  17. import {space} from 'sentry/styles/space';
  18. import {OnRouteLeave} from 'sentry/utils/reactRouter6Compat/onRouteLeave';
  19. import {ProjectionPeriodControl} from 'sentry/views/settings/dynamicSampling/projectionPeriodControl';
  20. import {ProjectsEditTable} from 'sentry/views/settings/dynamicSampling/projectsEditTable';
  21. import {SamplingModeField} from 'sentry/views/settings/dynamicSampling/samplingModeField';
  22. import {useHasDynamicSamplingWriteAccess} from 'sentry/views/settings/dynamicSampling/utils/access';
  23. import {parsePercent} from 'sentry/views/settings/dynamicSampling/utils/parsePercent';
  24. import {projectSamplingForm} from 'sentry/views/settings/dynamicSampling/utils/projectSamplingForm';
  25. import {
  26. type ProjectionSamplePeriod,
  27. useProjectSampleCounts,
  28. } from 'sentry/views/settings/dynamicSampling/utils/useProjectSampleCounts';
  29. import {
  30. useGetSamplingProjectRates,
  31. useUpdateSamplingProjectRates,
  32. } from 'sentry/views/settings/dynamicSampling/utils/useSamplingProjectRates';
  33. const {useFormState, FormProvider} = projectSamplingForm;
  34. const UNSAVED_CHANGES_MESSAGE = t(
  35. 'You have unsaved changes, are you sure you want to leave?'
  36. );
  37. export function ProjectSampling() {
  38. const hasAccess = useHasDynamicSamplingWriteAccess();
  39. const [period, setPeriod] = useState<ProjectionSamplePeriod>('24h');
  40. const [editMode, setEditMode] = useState<'single' | 'bulk'>('single');
  41. const sampleRatesQuery = useGetSamplingProjectRates();
  42. const sampleCountsQuery = useProjectSampleCounts({period});
  43. const updateSamplingProjectRates = useUpdateSamplingProjectRates();
  44. const projectRates = useMemo(
  45. () =>
  46. (sampleRatesQuery.data || []).reduce(
  47. (acc, item) => {
  48. acc[item.id.toString()] = (item.sampleRate * 100).toString();
  49. return acc;
  50. },
  51. {} as Record<string, string>
  52. ),
  53. [sampleRatesQuery.data]
  54. );
  55. const initialValues = useMemo(() => ({projectRates}), [projectRates]);
  56. const formState = useFormState({
  57. initialValues,
  58. enableReInitialize: true,
  59. });
  60. const handleReset = () => {
  61. formState.reset();
  62. setEditMode('single');
  63. };
  64. const handleSubmit = () => {
  65. const ratesArray = Object.entries(formState.fields.projectRates.value).map(
  66. ([id, rate]) => ({
  67. id: Number(id),
  68. sampleRate: parsePercent(rate),
  69. })
  70. );
  71. addLoadingMessage(t('Saving changes...'));
  72. updateSamplingProjectRates.mutate(ratesArray, {
  73. onSuccess: () => {
  74. formState.save();
  75. setEditMode('single');
  76. addSuccessMessage(t('Changes applied'));
  77. },
  78. onError: () => {
  79. addErrorMessage(t('Unable to save changes. Please try again.'));
  80. },
  81. });
  82. };
  83. // TODO(aknaus): This calculation + stiching of the two requests is repeated in a few places
  84. // and should be moved to a shared utility function.
  85. const initialTargetRate = useMemo(() => {
  86. const sampleRates = sampleRatesQuery.data ?? [];
  87. const spanCounts = sampleCountsQuery.data ?? [];
  88. const totalSpanCount = spanCounts.reduce((acc, item) => acc + item.count, 0);
  89. const spanCountsById = spanCounts.reduce(
  90. (acc, item) => {
  91. acc[item.project.id] = item.count;
  92. return acc;
  93. },
  94. {} as Record<string, number>
  95. );
  96. return (
  97. sampleRates.reduce((acc, item) => {
  98. const count = spanCountsById[item.id] ?? 0;
  99. return acc + count * item.sampleRate;
  100. }, 0) / totalSpanCount
  101. );
  102. }, [sampleRatesQuery.data, sampleCountsQuery.data]);
  103. const isFormActionDisabled =
  104. !hasAccess ||
  105. sampleRatesQuery.isPending ||
  106. updateSamplingProjectRates.isPending ||
  107. !formState.hasChanged;
  108. return (
  109. <FormProvider formState={formState}>
  110. <OnRouteLeave
  111. message={UNSAVED_CHANGES_MESSAGE}
  112. when={locationChange =>
  113. locationChange.currentLocation.pathname !==
  114. locationChange.nextLocation.pathname && formState.hasChanged
  115. }
  116. />
  117. <form onSubmit={event => event.preventDefault()} noValidate>
  118. <Panel>
  119. <PanelHeader>{t('General Settings')}</PanelHeader>
  120. <PanelBody>
  121. <SamplingModeField initialTargetRate={initialTargetRate} />
  122. </PanelBody>
  123. </Panel>
  124. <HeadingRow>
  125. <h4>{t('Customize Projects')}</h4>
  126. <ProjectionPeriodControl period={period} onChange={setPeriod} />
  127. </HeadingRow>
  128. <p>
  129. {t(
  130. 'Configure sample rates for each of your projects. These rates stay fixed if volumes change, which can lead to a change in the overall sample rate of your organization.'
  131. )}
  132. </p>
  133. <p>
  134. {t(
  135. 'Rates apply to all spans in traces that start in each project, including a portion of spans in connected other projects.'
  136. )}
  137. </p>
  138. {sampleCountsQuery.isError ? (
  139. <LoadingError onRetry={sampleCountsQuery.refetch} />
  140. ) : (
  141. <ProjectsEditTable
  142. period={period}
  143. editMode={editMode}
  144. onEditModeChange={setEditMode}
  145. isLoading={sampleRatesQuery.isPending || sampleCountsQuery.isPending}
  146. sampleCounts={sampleCountsQuery.data}
  147. />
  148. )}
  149. <FormActions>
  150. <Button disabled={isFormActionDisabled} onClick={handleReset}>
  151. {t('Reset')}
  152. </Button>
  153. <ScrollIntoViewButton enabled={!isFormActionDisabled && formState.isValid}>
  154. <Button
  155. priority="primary"
  156. disabled={isFormActionDisabled || !formState.isValid}
  157. onClick={handleSubmit}
  158. >
  159. {t('Apply Changes')}
  160. </Button>
  161. </ScrollIntoViewButton>
  162. </FormActions>
  163. </form>
  164. </FormProvider>
  165. );
  166. }
  167. function ScrollIntoViewButton({
  168. children,
  169. enabled,
  170. }: {
  171. children: React.ReactElement;
  172. enabled: boolean;
  173. }) {
  174. if (React.Children.count(children) !== 1) {
  175. throw new Error('ScrollIntoViewButton only accepts a single child');
  176. }
  177. const [isVisible, setIsVisible] = useState(false);
  178. const [targetElement, setTargetElement] = useState<HTMLElement>();
  179. useEffect(() => {
  180. if (!targetElement || !enabled) {
  181. return () => {};
  182. }
  183. const observer = new IntersectionObserver(
  184. observerEntries => {
  185. const entry = observerEntries[0]!;
  186. setIsVisible(!entry.isIntersecting);
  187. },
  188. {
  189. root: null,
  190. threshold: 0.5,
  191. }
  192. );
  193. observer.observe(targetElement);
  194. return () => {
  195. observer.disconnect();
  196. setIsVisible(false);
  197. };
  198. }, [targetElement, enabled]);
  199. return (
  200. <Fragment>
  201. {React.cloneElement(children, {ref: setTargetElement})}
  202. {isVisible && (
  203. <Tooltip title={t('Scroll down to apply changes')} skipWrapper>
  204. <FloatingButton
  205. type="button"
  206. onClick={() => {
  207. targetElement?.scrollIntoView({behavior: 'smooth'});
  208. }}
  209. initial={{opacity: 0, scale: 0.5}}
  210. animate={{opacity: 1, scale: 1}}
  211. transition={{
  212. ease: [0, 0.71, 0.2, 1.4],
  213. }}
  214. >
  215. <IconArrow direction="down" size="sm" />
  216. </FloatingButton>
  217. </Tooltip>
  218. )}
  219. </Fragment>
  220. );
  221. }
  222. const FloatingButton = styled(motion.button)`
  223. position: fixed;
  224. bottom: ${space(4)};
  225. right: ${space(1)};
  226. border-radius: 50%;
  227. border: 1px solid ${p => p.theme.border};
  228. background-color: ${p => p.theme.purple400};
  229. color: ${p => p.theme.white};
  230. box-shadow: ${p => p.theme.dropShadowHeavy};
  231. width: 36px;
  232. height: 36px;
  233. display: flex;
  234. align-items: center;
  235. justify-content: center;
  236. `;
  237. const FormActions = styled('div')`
  238. display: grid;
  239. grid-template-columns: repeat(2, max-content);
  240. gap: ${space(1)};
  241. justify-content: flex-end;
  242. padding-bottom: ${space(4)};
  243. `;
  244. const HeadingRow = styled('div')`
  245. display: flex;
  246. align-items: center;
  247. justify-content: space-between;
  248. padding-top: ${space(3)};
  249. padding-bottom: ${space(1.5)};
  250. & > * {
  251. margin: 0;
  252. }
  253. `;