Browse Source

feat(dynamic-sampling): Sampling breakdown (#80304)

Display a breakdown visualization of the whole org in manual sampling
mode.

Closes https://github.com/getsentry/projects/issues/355
ArthurKnaus 4 months ago
parent
commit
25f1e04807

+ 10 - 19
static/app/views/settings/dynamicSampling/organizationSampleRateField.tsx

@@ -2,9 +2,9 @@ import {css} from '@emotion/react';
 import styled from '@emotion/styled';
 
 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 {PercentInput} from 'sentry/views/settings/dynamicSampling/percentInput';
 import {organizationSamplingForm} from 'sentry/views/settings/dynamicSampling/utils/organizationSamplingForm';
 import {useAccess} from 'sentry/views/settings/projectMetrics/access';
 
@@ -32,20 +32,15 @@ export function OrganizationSampleRateField({}) {
           disabled={hasAccess}
           title={t('You do not have permission to change the sample rate.')}
         >
-          <InputGroup>
-            <InputGroup.Input
-              width={100}
-              type="number"
-              min={0}
-              max={100}
-              disabled={!hasAccess}
-              value={field.value}
-              onChange={event => field.onChange(event.target.value)}
-            />
-            <InputGroup.TrailingItems>
-              <TrailingPercent>%</TrailingPercent>
-            </InputGroup.TrailingItems>
-          </InputGroup>
+          <PercentInput
+            width={100}
+            type="number"
+            min={0}
+            max={100}
+            disabled={!hasAccess}
+            value={field.value}
+            onChange={event => field.onChange(event.target.value)}
+          />
         </Tooltip>
         {field.error ? (
           <ErrorMessage>{field.error}</ErrorMessage>
@@ -74,7 +69,3 @@ const InputWrapper = styled('div')`
   flex-direction: column;
   gap: 4px;
 `;
-
-const TrailingPercent = styled('strong')`
-  padding: 0 2px;
-`;

+ 27 - 0
static/app/views/settings/dynamicSampling/percentInput.tsx

@@ -0,0 +1,27 @@
+import type React from 'react';
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+
+import {InputGroup} from 'sentry/components/inputGroup';
+import {space} from 'sentry/styles/space';
+
+interface Props extends React.ComponentProps<typeof InputGroup.Input> {}
+
+export function PercentInput(props: Props) {
+  return (
+    <InputGroup
+      css={css`
+        width: 160px;
+      `}
+    >
+      <InputGroup.Input type="number" {...props} />
+      <InputGroup.TrailingItems>
+        <TrailingPercent>%</TrailingPercent>
+      </InputGroup.TrailingItems>
+    </InputGroup>
+  );
+}
+
+const TrailingPercent = styled('strong')`
+  padding: 0 ${space(0.25)};
+`;

+ 90 - 11
static/app/views/settings/dynamicSampling/projectsEditTable.tsx

@@ -1,10 +1,18 @@
-import {useCallback, useMemo} from 'react';
+import {Fragment, useCallback, useMemo} from 'react';
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
 import partition from 'lodash/partition';
 
 import LoadingError from 'sentry/components/loadingError';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import Panel from 'sentry/components/panels/panel';
 import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {formatNumberWithDynamicDecimalPoints} from 'sentry/utils/number/formatNumberWithDynamicDecimalPoints';
 import useProjects from 'sentry/utils/useProjects';
+import {PercentInput} from 'sentry/views/settings/dynamicSampling/percentInput';
 import {ProjectsTable} from 'sentry/views/settings/dynamicSampling/projectsTable';
+import {SamplingBreakdown} from 'sentry/views/settings/dynamicSampling/samplingBreakdown';
 import {projectSamplingForm} from 'sentry/views/settings/dynamicSampling/utils/projectSamplingForm';
 import {useProjectSampleCounts} from 'sentry/views/settings/dynamicSampling/utils/useProjectSampleCounts';
 
@@ -16,7 +24,7 @@ interface Props {
 const {useFormField} = projectSamplingForm;
 const EMPTY_ARRAY = [];
 
-export function ProjectsEditTable({isLoading, period}: Props) {
+export function ProjectsEditTable({isLoading: isLoadingProp, period}: Props) {
   const {projects, fetching} = useProjects();
 
   const {value, initialValue, error, onChange} = useFormField('projectRates');
@@ -64,19 +72,90 @@ export function ProjectsEditTable({isLoading, period}: Props) {
     [onChange]
   );
 
+  // weighted average of all projects' sample rates
+  const totalSpans = items.reduce((acc, item) => acc + item.count, 0);
+  const projectedOrgRate = useMemo(() => {
+    const totalSampledSpans = items.reduce(
+      (acc, item) => acc + item.count * Number(value[item.project.id] ?? 100),
+      0
+    );
+    return totalSampledSpans / totalSpans;
+  }, [items, value, totalSpans]);
+
+  const breakdownSampleRates = useMemo(
+    () =>
+      Object.entries(value).reduce(
+        (acc, [projectId, rate]) => {
+          acc[projectId] = Number(rate) / 100;
+          return acc;
+        },
+        {} as Record<string, number>
+      ),
+    [value]
+  );
+
   if (isError) {
     return <LoadingError onRetry={refetch} />;
   }
 
+  const isLoading = fetching || isPending || isLoadingProp;
+
   return (
-    <ProjectsTable
-      canEdit
-      onChange={handleChange}
-      emptyMessage={t('No active projects found in the selected period.')}
-      isEmpty={!data.length}
-      isLoading={fetching || isPending || isLoading}
-      items={activeItems}
-      inactiveItems={inactiveItems}
-    />
+    <Fragment>
+      <BreakdownPanel>
+        {isLoading ? (
+          <LoadingIndicator
+            css={css`
+              margin: ${space(4)} 0;
+            `}
+          />
+        ) : (
+          <Fragment>
+            <ProjectedOrgRateWrapper>
+              {t('Projected Organization Rate')}
+              <PercentInput
+                type="number"
+                disabled
+                min={0}
+                max={100}
+                size="sm"
+                value={formatNumberWithDynamicDecimalPoints(projectedOrgRate, 2)}
+              />
+            </ProjectedOrgRateWrapper>
+            <Divider />
+            <SamplingBreakdown period={period} sampleRates={breakdownSampleRates} />
+          </Fragment>
+        )}
+      </BreakdownPanel>
+
+      <ProjectsTable
+        canEdit
+        onChange={handleChange}
+        emptyMessage={t('No active projects found in the selected period.')}
+        isEmpty={!data.length}
+        isLoading={isLoading}
+        items={activeItems}
+        inactiveItems={inactiveItems}
+      />
+    </Fragment>
   );
 }
+
+const BreakdownPanel = styled(Panel)`
+  margin-bottom: ${space(3)};
+  padding: ${space(2)};
+`;
+const ProjectedOrgRateWrapper = styled('label')`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-wrap: wrap;
+  gap: ${space(1)};
+  font-weight: ${p => p.theme.fontWeightNormal};
+`;
+
+const Divider = styled('hr')`
+  margin: ${space(2)} -${space(2)};
+  border: none;
+  border-top: 1px solid ${p => p.theme.innerBorder};
+`;

+ 10 - 24
static/app/views/settings/dynamicSampling/projectsTable.tsx

@@ -1,11 +1,9 @@
 import {Fragment, memo, useCallback, useState} from 'react';
-import {css} from '@emotion/react';
 import styled from '@emotion/styled';
 
 import {hasEveryAccess} from 'sentry/components/acl/access';
 import {LinkButton} from 'sentry/components/button';
 import ProjectBadge from 'sentry/components/idBadge/projectBadge';
-import {InputGroup} from 'sentry/components/inputGroup';
 import {PanelTable} from 'sentry/components/panels/panelTable';
 import {Tooltip} from 'sentry/components/tooltip';
 import {IconArrow, IconChevron, IconSettings} from 'sentry/icons';
@@ -15,6 +13,7 @@ import type {Project} from 'sentry/types/project';
 import {formatAbbreviatedNumber} from 'sentry/utils/formatters';
 import oxfordizeArray from 'sentry/utils/oxfordizeArray';
 import useOrganization from 'sentry/utils/useOrganization';
+import {PercentInput} from 'sentry/views/settings/dynamicSampling/percentInput';
 
 interface ProjectItem {
   count: number;
@@ -281,24 +280,15 @@ const TableRow = memo(function TableRow({
             disabled={canEdit}
             title={t('To edit project sample rates, switch to manual sampling mode.')}
           >
-            <InputGroup
-              css={css`
-                width: 160px;
-              `}
-            >
-              <InputGroup.Input
-                type="number"
-                disabled={!canEdit}
-                onChange={handleChange}
-                min={0}
-                max={100}
-                size="sm"
-                value={sampleRate}
-              />
-              <InputGroup.TrailingItems>
-                <TrailingPercent>%</TrailingPercent>
-              </InputGroup.TrailingItems>
-            </InputGroup>
+            <PercentInput
+              type="number"
+              disabled={!canEdit}
+              onChange={handleChange}
+              min={0}
+              max={100}
+              size="sm"
+              value={sampleRate}
+            />
           </Tooltip>
         </FirstCellLine>
         {error ? (
@@ -439,7 +429,3 @@ const SettingsButton = styled(LinkButton)`
     visibility: visible;
   }
 `;
-
-const TrailingPercent = styled('strong')`
-  padding: 0 ${space(0.25)};
-`;

+ 176 - 0
static/app/views/settings/dynamicSampling/samplingBreakdown.tsx

@@ -0,0 +1,176 @@
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+import {PlatformIcon} from 'platformicons';
+
+import ProjectBadge from 'sentry/components/idBadge/projectBadge';
+import {Tooltip} from 'sentry/components/tooltip';
+import {CHART_PALETTE} from 'sentry/constants/chartPalette';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {
+  formatAbbreviatedNumber,
+  formatAbbreviatedNumberWithDynamicPrecision,
+} from 'sentry/utils/formatters';
+import {useProjectSampleCounts} from 'sentry/views/settings/dynamicSampling/utils/useProjectSampleCounts';
+
+const ITEMS_TO_SHOW = 5;
+const palette = CHART_PALETTE[ITEMS_TO_SHOW - 1];
+
+interface Props extends React.HTMLAttributes<HTMLDivElement> {
+  period: '24h' | '30d';
+  sampleRates: Record<string, number>;
+}
+
+function OthersBadge() {
+  return (
+    <div
+      css={css`
+        display: flex;
+        align-items: center;
+        gap: ${space(0.75)};
+      `}
+    >
+      <PlatformIcon
+        css={css`
+          width: 16px;
+          height: 16px;
+        `}
+        platform="other"
+      />
+      {t('other projects')}
+    </div>
+  );
+}
+
+export function SamplingBreakdown({period, sampleRates, ...props}: Props) {
+  const {data} = useProjectSampleCounts({period});
+
+  const spansWithSampleRates = data
+    ?.map(item => {
+      const sampledSpans = Math.floor(item.count * (sampleRates[item.project.id] ?? 1));
+      return {
+        project: item.project,
+        sampledSpans,
+      };
+    })
+    .toSorted((a, b) => b.sampledSpans - a.sampledSpans);
+
+  const hasOthers = spansWithSampleRates.length > ITEMS_TO_SHOW;
+
+  const topItems = hasOthers
+    ? spansWithSampleRates.slice(0, ITEMS_TO_SHOW - 1)
+    : spansWithSampleRates.slice(0, ITEMS_TO_SHOW);
+  const otherSpanCount = spansWithSampleRates
+    .slice(ITEMS_TO_SHOW - 1)
+    .reduce((acc, item) => acc + item.sampledSpans, 0);
+  const total = spansWithSampleRates.reduce((acc, item) => acc + item.sampledSpans, 0);
+
+  const getSpanPercent = spanCount => (spanCount / total) * 100;
+  const otherPercent = getSpanPercent(otherSpanCount);
+
+  return (
+    <div {...props}>
+      <Heading>{t('Breakdown')}</Heading>
+      <Breakdown>
+        {topItems.map((item, index) => {
+          return (
+            <Tooltip
+              key={item.project.id}
+              overlayStyle={{maxWidth: 'none'}}
+              title={
+                <LegendItem key={item.project.id}>
+                  <ProjectBadge disableLink avatarSize={16} project={item.project} />
+                  {`${formatAbbreviatedNumberWithDynamicPrecision(getSpanPercent(item.sampledSpans))}%`}
+                  <SubText>
+                    {t(
+                      '%s of %s sampled spans',
+                      formatAbbreviatedNumber(item.sampledSpans),
+                      formatAbbreviatedNumber(total)
+                    )}
+                  </SubText>
+                </LegendItem>
+              }
+              skipWrapper
+            >
+              <div
+                style={{
+                  width: `${getSpanPercent(item.sampledSpans)}%`,
+                  backgroundColor: palette[index],
+                }}
+              />
+            </Tooltip>
+          );
+        })}
+        {hasOthers && (
+          <Tooltip
+            overlayStyle={{maxWidth: 'none'}}
+            title={
+              <LegendItem>
+                <OthersBadge />
+                {`${formatAbbreviatedNumberWithDynamicPrecision(otherPercent)}%`}
+                <SubText>
+                  {`${formatAbbreviatedNumber(otherSpanCount)} of ${formatAbbreviatedNumber(total)}`}
+                </SubText>
+              </LegendItem>
+            }
+            skipWrapper
+          >
+            <div
+              style={{
+                width: `${otherPercent}%`,
+                backgroundColor: palette[palette.length - 1],
+              }}
+            />
+          </Tooltip>
+        )}
+      </Breakdown>
+      <Legend>
+        {topItems.map(item => {
+          return (
+            <LegendItem key={item.project.id}>
+              <ProjectBadge avatarSize={16} project={item.project} />
+              {`${formatAbbreviatedNumberWithDynamicPrecision(getSpanPercent(item.sampledSpans))}%`}
+            </LegendItem>
+          );
+        })}
+        {hasOthers && (
+          <LegendItem>
+            <OthersBadge />
+            {`${formatAbbreviatedNumberWithDynamicPrecision(otherPercent)}%`}
+          </LegendItem>
+        )}
+      </Legend>
+    </div>
+  );
+}
+
+const Heading = styled('h6')`
+  margin-bottom: ${space(1)};
+  font-size: ${p => p.theme.fontSizeMedium};
+`;
+
+const Breakdown = styled('div')`
+  display: flex;
+  height: ${space(2)};
+  width: 100%;
+  border-radius: ${p => p.theme.borderRadius};
+  overflow: hidden;
+`;
+
+const Legend = styled('div')`
+  display: flex;
+  flex-wrap: wrap;
+  margin-top: ${space(1)};
+  gap: ${space(1.5)};
+`;
+
+const LegendItem = styled('div')`
+  display: flex;
+  align-items: center;
+  gap: ${space(0.75)};
+`;
+
+const SubText = styled('span')`
+  color: ${p => p.theme.gray300};
+  white-space: nowrap;
+`;