Browse Source

ref(dynamic-sampling): Refactoring before introducing manual mode

Prepare the existing code for introducing the `manual` / `project`
sampling mode.
- Rename files to better suit the two separate modes.
- Move code into helper functions / components to make it reusable.
ArthurKnaus 4 months ago
parent
commit
ac0425826e

+ 1 - 0
static/app/types/organization.tsx

@@ -86,6 +86,7 @@ export interface Organization extends OrganizationSummary {
   relayPiiConfig: string | null;
   requiresSso: boolean;
   safeFields: string[];
+  samplingMode: 'organization' | 'project';
   scrapeJavaScript: boolean;
   scrubIPAddresses: boolean;
   sensitiveFields: string[];

+ 2 - 2
static/app/views/settings/dynamicSampling/index.tsx

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

+ 3 - 3
static/app/views/settings/dynamicSampling/targetSampleRateField.tsx → static/app/views/settings/dynamicSampling/organizationSampleRateField.tsx

@@ -5,12 +5,12 @@ import FieldGroup from 'sentry/components/forms/fieldGroup';
 import {InputGroup} from 'sentry/components/inputGroup';
 import {Tooltip} from 'sentry/components/tooltip';
 import {t} from 'sentry/locale';
-import {dynamicSamplingForm} from 'sentry/views/settings/dynamicSampling/dynamicSamplingForm';
+import {organizationSamplingForm} from 'sentry/views/settings/dynamicSampling/utils/organizationSamplingForm';
 import {useAccess} from 'sentry/views/settings/projectMetrics/access';
 
-const {useFormField} = dynamicSamplingForm;
+const {useFormField} = organizationSamplingForm;
 
-export function TargetSampleRateField({}) {
+export function OrganizationSampleRateField({}) {
   const field = useFormField('targetSampleRate');
   const {hasAccess} = useAccess({access: ['org:write']});
 

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

@@ -4,7 +4,6 @@ import styled from '@emotion/styled';
 
 import {addErrorMessage, 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 ExternalLink from 'sentry/components/links/externalLink';
 import Panel from 'sentry/components/panels/panel';
@@ -20,14 +19,15 @@ 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 {dynamicSamplingForm} from 'sentry/views/settings/dynamicSampling/dynamicSamplingForm';
+import {OrganizationSampleRateField} from 'sentry/views/settings/dynamicSampling/organizationSampleRateField';
 import {ProjectsPreviewTable} from 'sentry/views/settings/dynamicSampling/projectsPreviewTable';
-import {TargetSampleRateField} from 'sentry/views/settings/dynamicSampling/targetSampleRateField';
+import {SamplingModeField} from 'sentry/views/settings/dynamicSampling/samplingModeField';
+import {organizationSamplingForm} from 'sentry/views/settings/dynamicSampling/utils/organizationSamplingForm';
 import {useAccess} from 'sentry/views/settings/projectMetrics/access';
 
-const {useFormState, FormProvider} = dynamicSamplingForm;
+const {useFormState, FormProvider} = organizationSamplingForm;
 
-export function DynamicSampling() {
+export function OrganizationSampling() {
   const api = useApi();
   const organization = useOrganization();
   const {hasAccess} = useAccess({access: ['org:write']});
@@ -36,10 +36,8 @@ export function DynamicSampling() {
 
   const formState = useFormState({
     targetSampleRate: ((organization.targetSampleRate ?? 1) * 100)?.toLocaleString(),
-    samplingMode: 'auto' as const,
   });
 
-  const modeField = formState.fields.samplingMode;
   const endpoint = `/organizations/${organization.slug}/`;
 
   const {mutate: updateOrganization, isPending} = useMutation<Organization>({
@@ -102,27 +100,8 @@ export function DynamicSampling() {
               </div>
             </FieldGroup>
             {/* TODO(aknaus): move into separate component when we make it interactive */}
-            <FieldGroup
-              disabled
-              label={t('Switch Mode')}
-              help={t(
-                'Take control over the individual sample rates in your projects. This disables automatic adjustments.'
-              )}
-            >
-              <Confirm disabled>
-                <Button
-                  title={t('This feature is not yet available.')}
-                  css={css`
-                    width: max-content;
-                  `}
-                >
-                  {modeField.value === 'auto'
-                    ? t('Switch to Manual')
-                    : t('Switch to Auto')}
-                </Button>
-              </Confirm>
-            </FieldGroup>
-            {modeField.value === 'auto' ? <TargetSampleRateField /> : null}
+            <SamplingModeField />
+            <OrganizationSampleRateField />
           </PanelBody>
         </Panel>
         <FormActions>

+ 79 - 145
static/app/views/settings/dynamicSampling/projectsPreviewTable.tsx

@@ -10,132 +10,52 @@ import {Tooltip} from 'sentry/components/tooltip';
 import {IconArrow, IconChevron} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
-import type {MRI} from 'sentry/types/metrics';
 import type {Project} from 'sentry/types/project';
 import {formatAbbreviatedNumber} from 'sentry/utils/formatters';
-import {
-  type MetricsQueryApiQueryParams,
-  useMetricsQuery,
-} from 'sentry/utils/metrics/useMetricsQuery';
 import {formatNumberWithDynamicDecimalPoints} from 'sentry/utils/number/formatNumberWithDynamicDecimalPoints';
 import {useDebouncedValue} from 'sentry/utils/useDebouncedValue';
-import useProjects from 'sentry/utils/useProjects';
-import {dynamicSamplingForm} from 'sentry/views/settings/dynamicSampling/dynamicSamplingForm';
+import {organizationSamplingForm} from 'sentry/views/settings/dynamicSampling/utils/organizationSamplingForm';
 import {balanceSampleRate} from 'sentry/views/settings/dynamicSampling/utils/rebalancing';
+import {useProjectSampleCounts} from 'sentry/views/settings/dynamicSampling/utils/useProjectSampleCounts';
 
-const {useFormField} = dynamicSamplingForm;
-
-// TODO(aknaus): Switch to c:spans/count_per_root_project@none once available
-const SPANS_COUNT_METRIC: MRI = `c:transactions/count_per_root_project@none`;
-const metricsQuery: MetricsQueryApiQueryParams[] = [
-  {
-    mri: SPANS_COUNT_METRIC,
-    aggregation: 'count',
-    name: 'spans',
-    groupBy: ['project'],
-    orderBy: 'desc',
-  },
-];
-
-const fakeSubProjects = ['angular', 'sentry', 'snuba', 'relay', 'email-service'];
+const {useFormField} = organizationSamplingForm;
 
 interface Props {
   period: '24h' | '30d';
 }
 
 export function ProjectsPreviewTable({period}: Props) {
-  const {projects, fetching} = useProjects();
   const [tableSort, setTableSort] = useState<'asc' | 'desc'>('desc');
   const {value: targetSampleRate, initialValue: initialTargetSampleRate} =
     useFormField('targetSampleRate');
 
-  const {data, isPending, isError, refetch} = useMetricsQuery(
-    metricsQuery,
-    {
-      datetime: {
-        start: null,
-        end: null,
-        utc: true,
-        period,
-      },
-      environments: [],
-      projects: [],
-    },
-    {
-      includeSeries: false,
-      interval: period === '24h' ? '1h' : '1d',
-    }
-  );
-
-  const projectBySlug = useMemo(
-    () =>
-      projects.reduce((acc, project) => {
-        acc[project.slug] = project;
-        return acc;
-      }, {}),
-    [projects]
-  );
-
-  const items = useMemo(
-    () =>
-      (data?.data[0] ?? [])
-        .map(item => {
-          // TODO(aknaus): Remove mock data once real data is available
-          // Create random sub-projects for testing UI
-          const hasSubProjects = Math.random() > 0.3;
-          const countMagnitude = Math.floor(Math.log10(item.totals));
-          const subProjects = hasSubProjects
-            ? fakeSubProjects.map(slug => ({
-                slug: slug,
-                count: Math.floor(Math.random() * Math.pow(10, countMagnitude + 1)),
-              }))
-            : [];
-
-          const total =
-            item.totals +
-            subProjects.reduce((acc, subProject) => acc + subProject.count, 0);
-
-          return {
-            id: item.by.project,
-            project: projectBySlug[item.by.project],
-            count: total,
-            ownCount: item.totals,
-            // This is a placeholder value to satisfy typing
-            // the actual value is calculated in the balanceSampleRate function
-            sampleRate: 1,
-            subProjects: subProjects.toSorted((a, b) => b.count - a.count),
-          };
-        })
-        // Remove items where we cannot match the project
-        .filter(item => item.project),
-    [data?.data, projectBySlug]
-  );
+  const {data, isPending, isError, refetch} = useProjectSampleCounts({period});
 
   const debouncedTargetSampleRate = useDebouncedValue(
     targetSampleRate,
     // For longer lists we debounce the input to avoid too many re-renders
-    items.length > 100 ? 200 : 0
+    data.length > 100 ? 200 : 0
   );
 
   const {balancedItems} = useMemo(() => {
     const targetRate = Math.min(100, Math.max(0, Number(debouncedTargetSampleRate) || 0));
     return balanceSampleRate({
       targetSampleRate: targetRate / 100,
-      items,
+      items: data,
     });
-  }, [debouncedTargetSampleRate, items]);
+  }, [debouncedTargetSampleRate, data]);
 
   const initialSampleRatesBySlug = useMemo(() => {
     const targetRate = Math.min(100, Math.max(0, Number(initialTargetSampleRate) || 0));
     const {balancedItems: initialBalancedItems} = balanceSampleRate({
       targetSampleRate: targetRate / 100,
-      items,
+      items: data,
     });
     return initialBalancedItems.reduce((acc, item) => {
       acc[item.id] = item.sampleRate;
       return acc;
     }, {});
-  }, [initialTargetSampleRate, items]);
+  }, [initialTargetSampleRate, data]);
 
   const handleTableSort = useCallback(() => {
     setTableSort(value => (value === 'asc' ? 'desc' : 'asc'));
@@ -146,67 +66,51 @@ export function ProjectsPreviewTable({period}: Props) {
   }
 
   return (
-    <Fragment>
-      <ProjectsTable
-        stickyHeaders
-        emptyMessage={t('No active projects found in the selected period.')}
-        isEmpty={!items.length}
-        isLoading={isPending || fetching}
-        headers={[
-          t('Project'),
-          t('Spans'),
-          <SortableHeader key="spans" onClick={handleTableSort}>
-            {t('Total Spans')}
-            <IconArrow direction={tableSort === 'desc' ? 'down' : 'up'} size="xs" />
-          </SortableHeader>,
-          t('Projected Rate'),
-        ]}
-      >
-        {balancedItems
-          .toSorted((a, b) => {
-            if (tableSort === 'asc') {
-              return a.count - b.count;
-            }
-            return b.count - a.count;
-          })
-          .map(({id, project, count, ownCount, sampleRate, subProjects}) => (
-            <TableRow
-              key={id}
-              project={project}
-              count={count}
-              ownCount={ownCount}
-              sampleRate={sampleRate}
-              initialSampleRate={initialSampleRatesBySlug[project.slug]}
-              subProjects={subProjects}
-            />
-          ))}
-      </ProjectsTable>
-    </Fragment>
+    <ProjectsTable
+      stickyHeaders
+      emptyMessage={t('No active projects found in the selected period.')}
+      isEmpty={!data.length}
+      isLoading={isPending}
+      headers={[
+        t('Project'),
+        t('Spans'),
+        <SortableHeader key="spans" onClick={handleTableSort}>
+          {t('Total Spans')}
+          <IconArrow direction={tableSort === 'desc' ? 'down' : 'up'} size="xs" />
+        </SortableHeader>,
+        t('Projected Rate'),
+      ]}
+    >
+      {balancedItems
+        .toSorted((a, b) => {
+          if (tableSort === 'asc') {
+            return a.count - b.count;
+          }
+          return b.count - a.count;
+        })
+        .map(({id, project, count, ownCount, sampleRate, subProjects}) => (
+          <TableRow
+            key={id}
+            project={project}
+            count={count}
+            ownCount={ownCount}
+            sampleRate={sampleRate}
+            initialSampleRate={initialSampleRatesBySlug[project.slug]}
+            subProjects={subProjects}
+          />
+        ))}
+    </ProjectsTable>
   );
 }
 
-const MAX_PROJECTS_COLLAPSED = 3;
-const TableRow = memo(function TableRow({
-  project,
-  count,
-  ownCount,
-  sampleRate,
-  initialSampleRate,
-  subProjects,
-}: {
+interface SubProject {
   count: number;
-  initialSampleRate: number;
-  ownCount: number;
-  project: Project;
-  sampleRate: number;
-  subProjects: {count: number; slug: string}[];
-}) {
-  const [isExpanded, setIsExpanded] = useState(false);
-
-  const hasSubProjects = subProjects.length > 0;
+  slug: string;
+}
 
+function getSubProjectContent(subProjects: SubProject[], isExpanded: boolean) {
   let subProjectContent: React.ReactNode = t('No distributed traces');
-  if (hasSubProjects) {
+  if (subProjects.length > 0) {
     const truncatedSubProjects = subProjects.slice(0, MAX_PROJECTS_COLLAPSED);
     const overflowingProjects = subProjects.length - MAX_PROJECTS_COLLAPSED;
     const stringifiedSubProjects =
@@ -218,8 +122,12 @@ const TableRow = memo(function TableRow({
       : stringifiedSubProjects;
   }
 
+  return subProjectContent;
+}
+
+function getSubSpansContent(subProjects: SubProject[], isExpanded: boolean) {
   let subSpansContent: React.ReactNode = '+0';
-  if (hasSubProjects) {
+  if (subProjects.length > 0) {
     const subProjectSum = subProjects.reduce(
       (acc, subProject) => acc + subProject.count,
       0
@@ -232,6 +140,32 @@ const TableRow = memo(function TableRow({
       : `+${formatAbbreviatedNumber(subProjectSum, 2)}`;
   }
 
+  return subSpansContent;
+}
+
+const MAX_PROJECTS_COLLAPSED = 3;
+const TableRow = memo(function TableRow({
+  project,
+  count,
+  ownCount,
+  sampleRate,
+  initialSampleRate,
+  subProjects,
+}: {
+  count: number;
+  initialSampleRate: number;
+  ownCount: number;
+  project: Project;
+  sampleRate: number;
+  subProjects: SubProject[];
+}) {
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  const hasSubProjects = subProjects.length > 0;
+
+  const subProjectContent = getSubProjectContent(subProjects, isExpanded);
+  const subSpansContent = getSubSpansContent(subProjects, isExpanded);
+
   return (
     <Fragment key={project.slug}>
       <Cell>

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

@@ -0,0 +1,33 @@
+import {css} from '@emotion/react';
+
+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 useOrganization from 'sentry/utils/useOrganization';
+
+export function SamplingModeField() {
+  const {samplingMode} = useOrganization();
+
+  // TODO: Add logic to switch between manual and automatic sampling mode
+  return (
+    <FieldGroup
+      disabled
+      label={t('Switch Mode')}
+      help={t(
+        'Take control over the individual sample rates in your projects. This disables automatic adjustments.'
+      )}
+    >
+      <Confirm disabled>
+        <Button
+          title={t('This feature is not yet available.')}
+          css={css`
+            width: max-content;
+          `}
+        >
+          {samplingMode === 'organization' ? t('Switch to Manual') : t('Switch to Auto')}
+        </Button>
+      </Confirm>
+    </FieldGroup>
+  );
+}

+ 0 - 0
static/app/views/settings/dynamicSampling/formContext.tsx → static/app/views/settings/dynamicSampling/utils/formContext.tsx


+ 2 - 3
static/app/views/settings/dynamicSampling/dynamicSamplingForm.tsx → static/app/views/settings/dynamicSampling/utils/organizationSamplingForm.tsx

@@ -1,12 +1,11 @@
 import {t} from 'sentry/locale';
-import {createForm} from 'sentry/views/settings/dynamicSampling/formContext';
+import {createForm} from 'sentry/views/settings/dynamicSampling/utils/formContext';
 
 type FormFields = {
-  samplingMode: 'auto' | 'manual';
   targetSampleRate: string;
 };
 
-export const dynamicSamplingForm = createForm<FormFields>({
+export const organizationSamplingForm = createForm<FormFields>({
   validators: {
     targetSampleRate: (value: string) => {
       if (value === '') {

+ 90 - 0
static/app/views/settings/dynamicSampling/utils/useProjectSampleCounts.tsx

@@ -0,0 +1,90 @@
+import {useMemo} from 'react';
+
+import type {MRI} from 'sentry/types/metrics';
+import {
+  type MetricsQueryApiQueryParams,
+  useMetricsQuery,
+} from 'sentry/utils/metrics/useMetricsQuery';
+import useProjects from 'sentry/utils/useProjects';
+
+// TODO(aknaus): Switch to c:spans/count_per_root_project@none once available
+const SPANS_COUNT_METRIC: MRI = `c:transactions/count_per_root_project@none`;
+const metricsQuery: MetricsQueryApiQueryParams[] = [
+  {
+    mri: SPANS_COUNT_METRIC,
+    aggregation: 'count',
+    name: 'spans',
+    groupBy: ['project'],
+    orderBy: 'desc',
+  },
+];
+
+const fakeSubProjects = ['angular', 'sentry', 'snuba', 'relay', 'email-service'];
+
+export function useProjectSampleCounts({period}: {period: '24h' | '30d'}) {
+  const {projects, fetching} = useProjects();
+
+  const {data, isPending, isError, refetch} = useMetricsQuery(
+    metricsQuery,
+    {
+      datetime: {
+        start: null,
+        end: null,
+        utc: true,
+        period,
+      },
+      environments: [],
+      projects: [],
+    },
+    {
+      includeSeries: false,
+      interval: period === '24h' ? '1h' : '1d',
+    }
+  );
+
+  const projectBySlug = useMemo(
+    () =>
+      projects.reduce((acc, project) => {
+        acc[project.slug] = project;
+        return acc;
+      }, {}),
+    [projects]
+  );
+
+  const groupedCounts = useMemo(
+    () =>
+      (data?.data[0] ?? [])
+        .map(item => {
+          // TODO(aknaus): Remove mock data once real data is available
+          // Create random sub-projects for testing UI
+          const hasSubProjects = Math.random() > 0.3;
+          const countMagnitude = Math.floor(Math.log10(item.totals));
+          const subProjects = hasSubProjects
+            ? fakeSubProjects.map(slug => ({
+                slug: slug,
+                count: Math.floor(Math.random() * Math.pow(10, countMagnitude + 1)),
+              }))
+            : [];
+
+          const total =
+            item.totals +
+            subProjects.reduce((acc, subProject) => acc + subProject.count, 0);
+
+          return {
+            id: item.by.project,
+            project: projectBySlug[item.by.project],
+            count: total,
+            ownCount: item.totals,
+            // This is a placeholder value to satisfy typing
+            // the actual value is calculated in the balanceSampleRate function
+            sampleRate: 1,
+            subProjects: subProjects.toSorted((a, b) => b.count - a.count),
+          };
+        })
+        // Remove items where we cannot match the project
+        .filter(item => item.project),
+    [data?.data, projectBySlug]
+  );
+
+  return {data: groupedCounts, isPending: fetching || isPending, isError, refetch};
+}

+ 1 - 0
tests/js/fixtures/organization.ts

@@ -81,6 +81,7 @@ export function OrganizationFixture( params: Partial<Organization> = {}): Organi
     require2FA: false,
     requiresSso: false,
     safeFields: [],
+    samplingMode: 'organization',
     scrubIPAddresses: false,
     sensitiveFields: [],
     aggregatedDataConsent: false,