projectSampling.tsx 6.4 KB

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