Browse Source

feat(dynamic-sampling): Allow switching mode (#79903)

Allow switching sampling mode.

Closes https://github.com/getsentry/projects/issues/189
ArthurKnaus 4 months ago
parent
commit
d116bd3369

+ 6 - 1
static/app/views/settings/dynamicSampling/index.tsx

@@ -7,6 +7,7 @@ import {hasDynamicSamplingCustomFeature} from 'sentry/utils/dynamicSampling/feat
 import useOrganization from 'sentry/utils/useOrganization';
 import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
 import {OrganizationSampling} from 'sentry/views/settings/dynamicSampling/organizationSampling';
+import {ProjectSampling} from 'sentry/views/settings/dynamicSampling/projectSampling';
 
 export default function DynamicSamplingSettings() {
   const organization = useOrganization();
@@ -20,7 +21,11 @@ export default function DynamicSamplingSettings() {
       <SentryDocumentTitle title={t('Dynamic Sampling')} orgSlug={organization.slug} />
       <div>
         <SettingsPageHeader title={t('Dynamic Sampling')} />
-        <OrganizationSampling />
+        {organization.samplingMode === 'organization' ? (
+          <OrganizationSampling />
+        ) : (
+          <ProjectSampling />
+        )}
       </div>
     </Fragment>
   );

+ 16 - 28
static/app/views/settings/dynamicSampling/organizationSampling.tsx

@@ -13,22 +13,18 @@ import QuestionTooltip from 'sentry/components/questionTooltip';
 import {SegmentedControl} from 'sentry/components/segmentedControl';
 import {Tooltip} from 'sentry/components/tooltip';
 import {t, tct} from 'sentry/locale';
-import OrganizationStore from 'sentry/stores/organizationStore';
 import {space} from 'sentry/styles/space';
-import type {Organization} from 'sentry/types/organization';
-import {useMutation} from 'sentry/utils/queryClient';
-import useApi from 'sentry/utils/useApi';
 import useOrganization from 'sentry/utils/useOrganization';
 import {OrganizationSampleRateField} from 'sentry/views/settings/dynamicSampling/organizationSampleRateField';
 import {ProjectsPreviewTable} from 'sentry/views/settings/dynamicSampling/projectsPreviewTable';
 import {SamplingModeField} from 'sentry/views/settings/dynamicSampling/samplingModeField';
 import {organizationSamplingForm} from 'sentry/views/settings/dynamicSampling/utils/organizationSamplingForm';
+import {useUpdateOrganization} from 'sentry/views/settings/dynamicSampling/utils/useUpdateOrganization';
 import {useAccess} from 'sentry/views/settings/projectMetrics/access';
 
 const {useFormState, FormProvider} = organizationSamplingForm;
 
 export function OrganizationSampling() {
-  const api = useApi();
   const organization = useOrganization();
   const {hasAccess} = useAccess({access: ['org:write']});
 
@@ -38,30 +34,23 @@ export function OrganizationSampling() {
     targetSampleRate: ((organization.targetSampleRate ?? 1) * 100)?.toLocaleString(),
   });
 
-  const endpoint = `/organizations/${organization.slug}/`;
-
-  const {mutate: updateOrganization, isPending} = useMutation<Organization>({
-    mutationFn: () => {
-      const {fields} = formState;
-      return api.requestPromise(endpoint, {
-        method: 'PUT',
-        data: {
-          targetSampleRate: Number(fields.targetSampleRate.value) / 100,
-        },
-      });
-    },
-    onSuccess: newOrg => {
-      OrganizationStore.onUpdate(newOrg);
-      addSuccessMessage(t('Changes applied.'));
-      formState.save();
-    },
-    onError: () => {
-      addErrorMessage(t('Unable to save changes. Please try again.'));
-    },
-  });
+  const {mutate: updateOrganization, isPending} = useUpdateOrganization();
 
   const handleSubmit = () => {
-    updateOrganization();
+    updateOrganization(
+      {
+        targetSampleRate: Number(formState.fields.targetSampleRate.value) / 100,
+      },
+      {
+        onSuccess: () => {
+          addSuccessMessage(t('Changes applied.'));
+          formState.save();
+        },
+        onError: () => {
+          addErrorMessage(t('Unable to save changes. Please try again.'));
+        },
+      }
+    );
   };
 
   const handleReset = () => {
@@ -99,7 +88,6 @@ export function OrganizationSampling() {
                 />
               </div>
             </FieldGroup>
-            {/* TODO(aknaus): move into separate component when we make it interactive */}
             <SamplingModeField />
             <OrganizationSampleRateField />
           </PanelBody>

+ 85 - 0
static/app/views/settings/dynamicSampling/projectSampling.tsx

@@ -0,0 +1,85 @@
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+
+import {Button} from 'sentry/components/button';
+import FieldGroup from 'sentry/components/forms/fieldGroup';
+import ExternalLink from 'sentry/components/links/externalLink';
+import Panel from 'sentry/components/panels/panel';
+import PanelBody from 'sentry/components/panels/panelBody';
+import PanelHeader from 'sentry/components/panels/panelHeader';
+import QuestionTooltip from 'sentry/components/questionTooltip';
+import {t, tct} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {SamplingModeField} from 'sentry/views/settings/dynamicSampling/samplingModeField';
+
+export function ProjectSampling() {
+  return (
+    <form onSubmit={event => event.preventDefault()}>
+      <Panel>
+        <PanelHeader>{t('Manual Sampling')}</PanelHeader>
+        <PanelBody>
+          <FieldGroup
+            label={t('Sampling Mode')}
+            help={t('Changes the level of detail and configuring sample rates.')}
+          >
+            <div
+              css={css`
+                display: flex;
+                align-items: center;
+                gap: ${space(1)};
+              `}
+            >
+              {t('Manual Sampling per Project')}{' '}
+              <QuestionTooltip
+                size="sm"
+                isHoverable
+                title={tct(
+                  'Manual sampling allows you to set individual sample rates for each project. [link:Learn more about sampling]',
+                  {
+                    // TODO(aknaus): Add link to documentation
+                    link: <ExternalLink href="https://docs.sentry.io/" />,
+                  }
+                )}
+              />
+            </div>
+          </FieldGroup>
+          <SamplingModeField />
+        </PanelBody>
+      </Panel>
+      <HeadingRow>
+        <h4>Customize Projects</h4>
+      </HeadingRow>
+      <CommingSoonPanel>Coming soon</CommingSoonPanel>
+      <FormActions>
+        <Button disabled>{t('Reset')}</Button>
+        <Button priority="primary" disabled>
+          {t('Apply Changes')}
+        </Button>
+      </FormActions>
+    </form>
+  );
+}
+
+const FormActions = styled('div')`
+  display: grid;
+  grid-template-columns: repeat(2, max-content);
+  gap: ${space(1)};
+  justify-content: flex-end;
+  padding-bottom: ${space(4)};
+`;
+
+const HeadingRow = styled('div')`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding-bottom: ${space(1.5)};
+
+  & > h4 {
+    margin: 0;
+  }
+`;
+
+const CommingSoonPanel = styled(Panel)`
+  padding: ${space(2)};
+  color: ${p => p.theme.subText};
+`;

+ 74 - 8
static/app/views/settings/dynamicSampling/samplingModeField.tsx

@@ -1,26 +1,92 @@
+import {Fragment} from 'react';
 import {css} from '@emotion/react';
 
+import {
+  addErrorMessage,
+  addLoadingMessage,
+  addSuccessMessage,
+} from 'sentry/actionCreators/indicator';
 import {Button} from 'sentry/components/button';
 import Confirm from 'sentry/components/confirm';
 import FieldGroup from 'sentry/components/forms/fieldGroup';
-import {t} from 'sentry/locale';
+import ExternalLink from 'sentry/components/links/externalLink';
+import {t, tct} from 'sentry/locale';
 import useOrganization from 'sentry/utils/useOrganization';
+import {useUpdateOrganization} from 'sentry/views/settings/dynamicSampling/utils/useUpdateOrganization';
+import {useAccess} from 'sentry/views/settings/projectMetrics/access';
+
+const switchToManualMessage = tct(
+  'Switching to manual mode will disable automatic adjustments for your projects. You will be able to set individual sample rates for each project. Those rates will be initially set to their current automatic value. [link:Learn more about sampling]',
+  // TODO(aknaus): Add link to documentation
+  {link: <ExternalLink href="https://docs.sentry.io" />}
+);
+
+const switchToAutoMessage = tct(
+  'Switching to automatic mode will enable automatic adjustments for your projects based on a global rate. By switching [strong:you will lose your manually defined sample rates]. [link:Learn more about sampling]',
+  // TODO(aknaus): Add link to documentation
+  {link: <ExternalLink href="https://docs.sentry.io" />, strong: <strong />}
+);
 
 export function SamplingModeField() {
   const {samplingMode} = useOrganization();
+  const hasAccess = useAccess({access: ['org:write']});
+
+  const {mutate: updateOrganization, isPending} = useUpdateOrganization({
+    onMutate: () => {
+      addLoadingMessage(t('Switching sampling mode...'));
+    },
+    onSuccess: () => {
+      addSuccessMessage(t('Changes applied.'));
+    },
+    onError: () => {
+      addErrorMessage(t('Unable to save changes. Please try again.'));
+    },
+  });
+
+  const handleSwitchMode = () => {
+    updateOrganization({
+      samplingMode: samplingMode === 'organization' ? 'project' : 'organization',
+    });
+  };
 
-  // TODO: Add logic to switch between manual and automatic sampling mode
   return (
     <FieldGroup
-      disabled
+      disabled={!hasAccess}
       label={t('Switch Mode')}
-      help={t(
-        'Take control over the individual sample rates in your projects. This disables automatic adjustments.'
-      )}
+      help={
+        samplingMode === 'organization'
+          ? t(
+              'Take control over the individual sample rates in your projects. This disables automatic adjustments.'
+            )
+          : t(
+              'Let Sentry monitor span volume and adjust sample rates automatically. This resets the custom rates below.'
+            )
+      }
     >
-      <Confirm disabled>
+      <Confirm
+        disabled={!hasAccess || isPending}
+        message={
+          <Fragment>
+            <strong>{t('Are you sure?')}</strong>
+            <p>
+              {samplingMode === 'organization'
+                ? switchToManualMessage
+                : switchToAutoMessage}
+            </p>
+          </Fragment>
+        }
+        header={
+          <h5>
+            {samplingMode === 'organization'
+              ? t('Switch to Manual Mode')
+              : t('Switch to Automatic Mode')}
+          </h5>
+        }
+        confirmText={t('Switch Mode')}
+        cancelText={t('Cancel')}
+        onConfirm={handleSwitchMode}
+      >
         <Button
-          title={t('This feature is not yet available.')}
           css={css`
             width: max-content;
           `}

+ 33 - 0
static/app/views/settings/dynamicSampling/utils/useUpdateOrganization.tsx

@@ -0,0 +1,33 @@
+import OrganizationStore from 'sentry/stores/organizationStore';
+import type {Organization} from 'sentry/types/organization';
+import {useMutation, type UseMutationOptions} from 'sentry/utils/queryClient';
+import type RequestError from 'sentry/utils/requestError/requestError';
+import useApi from 'sentry/utils/useApi';
+import useOrganization from 'sentry/utils/useOrganization';
+
+type Variables = Pick<Partial<Organization>, 'targetSampleRate' | 'samplingMode'>;
+
+export function useUpdateOrganization(
+  options?: Omit<
+    UseMutationOptions<Organization, RequestError, Variables, unknown>,
+    'mutationFn'
+  >
+) {
+  const api = useApi();
+  const organization = useOrganization();
+  const endpoint = `/organizations/${organization.slug}/`;
+
+  return useMutation<Organization, RequestError, Variables>({
+    ...options,
+    mutationFn: variables => {
+      return api.requestPromise(endpoint, {
+        method: 'PUT',
+        data: variables,
+      });
+    },
+    onSuccess: (newOrg, variables, context) => {
+      options?.onSuccess?.(newOrg, variables, context);
+      OrganizationStore.onUpdate(newOrg);
+    },
+  });
+}