projectSampling.tsx 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. import {useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {
  4. addErrorMessage,
  5. addLoadingMessage,
  6. addSuccessMessage,
  7. } from 'sentry/actionCreators/indicator';
  8. import {Button} from 'sentry/components/button';
  9. import LoadingError from 'sentry/components/loadingError';
  10. import Panel from 'sentry/components/panels/panel';
  11. import PanelBody from 'sentry/components/panels/panelBody';
  12. import PanelHeader from 'sentry/components/panels/panelHeader';
  13. import {t} from 'sentry/locale';
  14. import {space} from 'sentry/styles/space';
  15. import {OnRouteLeave} from 'sentry/utils/reactRouter6Compat/onRouteLeave';
  16. import {ProjectionPeriodControl} from 'sentry/views/settings/dynamicSampling/projectionPeriodControl';
  17. import {ProjectsEditTable} from 'sentry/views/settings/dynamicSampling/projectsEditTable';
  18. import {SamplingModeField} from 'sentry/views/settings/dynamicSampling/samplingModeField';
  19. import {useHasDynamicSamplingWriteAccess} from 'sentry/views/settings/dynamicSampling/utils/access';
  20. import {projectSamplingForm} from 'sentry/views/settings/dynamicSampling/utils/projectSamplingForm';
  21. import {
  22. type ProjectionSamplePeriod,
  23. useProjectSampleCounts,
  24. } from 'sentry/views/settings/dynamicSampling/utils/useProjectSampleCounts';
  25. import {
  26. useGetSamplingProjectRates,
  27. useUpdateSamplingProjectRates,
  28. } from 'sentry/views/settings/dynamicSampling/utils/useSamplingProjectRates';
  29. const {useFormState, FormProvider} = projectSamplingForm;
  30. const UNSAVED_CHANGES_MESSAGE = t(
  31. 'You have unsaved changes, are you sure you want to leave?'
  32. );
  33. export function ProjectSampling() {
  34. const hasAccess = useHasDynamicSamplingWriteAccess();
  35. const [period, setPeriod] = useState<ProjectionSamplePeriod>('24h');
  36. const [editMode, setEditMode] = useState<'single' | 'bulk'>('single');
  37. const sampleRatesQuery = useGetSamplingProjectRates();
  38. const sampleCountsQuery = useProjectSampleCounts({period});
  39. const updateSamplingProjectRates = useUpdateSamplingProjectRates();
  40. const projectRates = useMemo(
  41. () =>
  42. (sampleRatesQuery.data || []).reduce(
  43. (acc, item) => {
  44. acc[item.id.toString()] = (item.sampleRate * 100).toString();
  45. return acc;
  46. },
  47. {} as Record<string, string>
  48. ),
  49. [sampleRatesQuery.data]
  50. );
  51. const initialValues = useMemo(() => ({projectRates}), [projectRates]);
  52. const formState = useFormState({
  53. initialValues: initialValues,
  54. enableReInitialize: true,
  55. });
  56. const handleReset = () => {
  57. formState.reset();
  58. setEditMode('single');
  59. };
  60. const handleSubmit = () => {
  61. const ratesArray = Object.entries(formState.fields.projectRates.value).map(
  62. ([id, rate]) => ({
  63. id: Number(id),
  64. sampleRate: Number(rate) / 100,
  65. })
  66. );
  67. addLoadingMessage(t('Saving changes...'));
  68. updateSamplingProjectRates.mutate(ratesArray, {
  69. onSuccess: () => {
  70. formState.save();
  71. setEditMode('single');
  72. addSuccessMessage(t('Changes applied'));
  73. },
  74. onError: () => {
  75. addErrorMessage(t('Unable to save changes. Please try again.'));
  76. },
  77. });
  78. };
  79. // TODO(aknaus): This calculation + stiching of the two requests is repeated in a few places
  80. // and should be moved to a shared utility function.
  81. const initialTargetRate = useMemo(() => {
  82. const sampleRates = sampleRatesQuery.data ?? [];
  83. const spanCounts = sampleCountsQuery.data ?? [];
  84. const totalSpanCount = spanCounts.reduce((acc, item) => acc + item.count, 0);
  85. const spanCountsById = spanCounts.reduce(
  86. (acc, item) => {
  87. acc[item.project.id] = item.count;
  88. return acc;
  89. },
  90. {} as Record<string, number>
  91. );
  92. return (
  93. sampleRates.reduce((acc, item) => {
  94. const count = spanCountsById[item.id] ?? 0;
  95. return acc + count * item.sampleRate;
  96. }, 0) / totalSpanCount
  97. );
  98. }, [sampleRatesQuery.data, sampleCountsQuery.data]);
  99. const isFormActionDisabled =
  100. !hasAccess ||
  101. sampleRatesQuery.isPending ||
  102. updateSamplingProjectRates.isPending ||
  103. !formState.hasChanged;
  104. return (
  105. <FormProvider formState={formState}>
  106. <OnRouteLeave
  107. message={UNSAVED_CHANGES_MESSAGE}
  108. when={locationChange =>
  109. locationChange.currentLocation.pathname !==
  110. locationChange.nextLocation.pathname && formState.hasChanged
  111. }
  112. />
  113. <form onSubmit={event => event.preventDefault()} noValidate>
  114. <Panel>
  115. <PanelHeader>{t('General Settings')}</PanelHeader>
  116. <PanelBody>
  117. <SamplingModeField initialTargetRate={initialTargetRate} />
  118. </PanelBody>
  119. </Panel>
  120. <HeadingRow>
  121. <h4>{t('Customize Projects')}</h4>
  122. <ProjectionPeriodControl period={period} onChange={setPeriod} />
  123. </HeadingRow>
  124. <p>
  125. {t(
  126. '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.'
  127. )}
  128. </p>
  129. <p>
  130. {t(
  131. 'Rates apply to all spans in traces that start in each project, including a portion of spans in connected other projects.'
  132. )}
  133. </p>
  134. {sampleCountsQuery.isError ? (
  135. <LoadingError onRetry={sampleCountsQuery.refetch} />
  136. ) : (
  137. <ProjectsEditTable
  138. editMode={editMode}
  139. onEditModeChange={setEditMode}
  140. isLoading={sampleRatesQuery.isPending || sampleCountsQuery.isPending}
  141. sampleCounts={sampleCountsQuery.data}
  142. />
  143. )}
  144. <FormActions>
  145. <Button disabled={isFormActionDisabled} onClick={handleReset}>
  146. {t('Reset')}
  147. </Button>
  148. <Button
  149. priority="primary"
  150. disabled={isFormActionDisabled}
  151. onClick={handleSubmit}
  152. >
  153. {t('Apply Changes')}
  154. </Button>
  155. </FormActions>
  156. </form>
  157. </FormProvider>
  158. );
  159. }
  160. const FormActions = styled('div')`
  161. display: grid;
  162. grid-template-columns: repeat(2, max-content);
  163. gap: ${space(1)};
  164. justify-content: flex-end;
  165. padding-bottom: ${space(4)};
  166. `;
  167. const HeadingRow = styled('div')`
  168. display: flex;
  169. align-items: center;
  170. justify-content: space-between;
  171. padding-top: ${space(3)};
  172. padding-bottom: ${space(1.5)};
  173. & > * {
  174. margin: 0;
  175. }
  176. `;