Browse Source

feat(sampling): Add rate suggestion UI [TET-164] (#36316)

Matej Minar 2 years ago
parent
commit
24f0977f7a

+ 116 - 0
fixtures/js-stubs/outcomes.js

@@ -0,0 +1,116 @@
+export function Outcomes() {
+  return {
+    start: '2022-07-02T19:00:00Z',
+    end: '2022-07-04T18:35:00Z',
+    intervals: [
+      '2022-07-02T19:00:00Z',
+      '2022-07-02T20:00:00Z',
+      '2022-07-02T21:00:00Z',
+      '2022-07-02T22:00:00Z',
+      '2022-07-02T23:00:00Z',
+      '2022-07-03T00:00:00Z',
+      '2022-07-03T01:00:00Z',
+      '2022-07-03T02:00:00Z',
+      '2022-07-03T03:00:00Z',
+      '2022-07-03T04:00:00Z',
+      '2022-07-03T05:00:00Z',
+      '2022-07-03T06:00:00Z',
+      '2022-07-03T07:00:00Z',
+      '2022-07-03T08:00:00Z',
+      '2022-07-03T09:00:00Z',
+      '2022-07-03T10:00:00Z',
+      '2022-07-03T11:00:00Z',
+      '2022-07-03T12:00:00Z',
+      '2022-07-03T13:00:00Z',
+      '2022-07-03T14:00:00Z',
+      '2022-07-03T15:00:00Z',
+      '2022-07-03T16:00:00Z',
+      '2022-07-03T17:00:00Z',
+      '2022-07-03T18:00:00Z',
+      '2022-07-03T19:00:00Z',
+      '2022-07-03T20:00:00Z',
+      '2022-07-03T21:00:00Z',
+      '2022-07-03T22:00:00Z',
+      '2022-07-03T23:00:00Z',
+      '2022-07-04T00:00:00Z',
+      '2022-07-04T01:00:00Z',
+      '2022-07-04T02:00:00Z',
+      '2022-07-04T03:00:00Z',
+      '2022-07-04T04:00:00Z',
+      '2022-07-04T05:00:00Z',
+      '2022-07-04T06:00:00Z',
+      '2022-07-04T07:00:00Z',
+      '2022-07-04T08:00:00Z',
+      '2022-07-04T09:00:00Z',
+      '2022-07-04T10:00:00Z',
+      '2022-07-04T11:00:00Z',
+      '2022-07-04T12:00:00Z',
+      '2022-07-04T13:00:00Z',
+      '2022-07-04T14:00:00Z',
+      '2022-07-04T15:00:00Z',
+      '2022-07-04T16:00:00Z',
+      '2022-07-04T17:00:00Z',
+      '2022-07-04T18:00:00Z',
+    ],
+    groups: [
+      {
+        by: {outcome: 'client_discard'},
+        totals: {'sum(quantity)': 1231344},
+        series: {
+          'sum(quantity)': [
+            0, 1, 1, 1, 94, 1, 1, 0, 566, 179, 1, 1, 1, 0, 222, 6, 287, 465, 83, 7, 0,
+            1835, 145, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0, 1, 849, 25331, 147200, 220014,
+            189001, 99590, 81288, 134522, 151489, 128585, 41643, 6404, 145, 1381,
+          ],
+        },
+      },
+      {
+        by: {outcome: 'rate_limited'},
+        totals: {'sum(quantity)': 1335469},
+        series: {
+          'sum(quantity)': [
+            0, 0, 0, 0, 191, 0, 0, 0, 385, 0, 0, 0, 0, 0, 345, 0, 445, 276, 56, 0, 0,
+            1101, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 656, 45581, 143705, 168077, 168143,
+            127756, 114636, 148322, 162904, 153069, 80977, 17299, 512, 1033,
+          ],
+        },
+      },
+      {
+        by: {outcome: 'accepted'},
+        totals: {'sum(quantity)': 19707040},
+        series: {
+          'sum(quantity)': [
+            294117, 281850, 263003, 259581, 246831, 278464, 290677, 242770, 242559,
+            248963, 250920, 268994, 296129, 308165, 302398, 301891, 316698, 333888,
+            336204, 329735, 323717, 317564, 312407, 307008, 301681, 299652, 276849,
+            274486, 298985, 368148, 444434, 423119, 416110, 464443, 526387, 692300,
+            720026, 719854, 719658, 719237, 717889, 719757, 718147, 719843, 712099,
+            643028, 545065, 311310,
+          ],
+        },
+      },
+      {
+        by: {outcome: 'filtered'},
+        totals: {'sum(quantity)': 13974},
+        series: {
+          'sum(quantity)': [
+            250, 278, 247, 251, 270, 269, 285, 256, 248, 267, 326, 335, 258, 255, 269,
+            292, 271, 246, 254, 285, 291, 295, 260, 292, 242, 318, 326, 302, 299, 299,
+            321, 310, 320, 371, 323, 331, 286, 256, 275, 316, 294, 295, 301, 282, 391,
+            358, 391, 217,
+          ],
+        },
+      },
+      {
+        by: {outcome: 'invalid'},
+        totals: {'sum(quantity)': 38},
+        series: {
+          'sum(quantity)': [
+            0, 1, 2, 0, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 3, 2, 0, 0, 0, 0,
+            0, 0, 1, 2, 0, 0, 0, 2, 1, 1, 1, 2, 2, 0, 0, 1, 1, 4, 3, 0, 0, 0,
+          ],
+        },
+      },
+    ],
+  };
+}

+ 1 - 0
fixtures/js-stubs/types.tsx

@@ -75,6 +75,7 @@ type TestStubFixtures = {
   OrganizationEvent: OverridableStub;
   OrganizationIntegrations: OverridableStub;
   Organizations: OverridableStub;
+  Outcomes: SimpleStub;
   PhabricatorCreate: SimpleStub;
   PhabricatorPlugin: SimpleStub;
   PlatformExternalIssue: OverridableStub;

+ 2 - 2
static/app/views/organizationStats/usageChart/index.tsx

@@ -28,14 +28,14 @@ import {getTooltipFormatter, getXAxisDates, getXAxisLabelInterval} from './utils
 type ChartProps = React.ComponentProps<typeof BaseChart>;
 
 const COLOR_ERRORS = Color(commonTheme.dataCategory.errors).lighten(0.25).string();
-const COLOR_TRANSACTIONS = Color(commonTheme.dataCategory.transactions)
+export const COLOR_TRANSACTIONS = Color(commonTheme.dataCategory.transactions)
   .lighten(0.35)
   .string();
 const COLOR_ATTACHMENTS = Color(commonTheme.dataCategory.attachments)
   .lighten(0.65)
   .string();
 
-const COLOR_DROPPED = commonTheme.red300;
+export const COLOR_DROPPED = commonTheme.red300;
 const COLOR_FILTERED = commonTheme.pink100;
 
 export type CategoryOption = {

+ 9 - 0
static/app/views/settings/project/server-side-sampling/modals/styles.tsx

@@ -0,0 +1,9 @@
+import {css} from '@emotion/react';
+
+import commonTheme from 'sentry/utils/theme';
+
+export const responsiveModal = css`
+  @media (max-width: ${commonTheme.breakpoints.small}) {
+    width: 100%;
+  }
+`;

+ 69 - 0
static/app/views/settings/project/server-side-sampling/modals/uniformRateChart.tsx

@@ -0,0 +1,69 @@
+import styled from '@emotion/styled';
+
+import {BarChart} from 'sentry/components/charts/barChart';
+import {ChartContainer, HeaderTitle} from 'sentry/components/charts/styles';
+import TransitionChart from 'sentry/components/charts/transitionChart';
+import {Panel} from 'sentry/components/panels';
+import Placeholder from 'sentry/components/placeholder';
+import {t} from 'sentry/locale';
+import {Series} from 'sentry/types/echarts';
+import {formatAbbreviatedNumber} from 'sentry/utils/formatters';
+import getDynamicText from 'sentry/utils/getDynamicText';
+
+type Props = {
+  isLoading: boolean;
+  series: Series[];
+};
+
+function UniformRateChart({series, isLoading}: Props) {
+  const legend = {
+    right: 10,
+    top: 5,
+    data: series.map(s => s.seriesName),
+  };
+
+  return (
+    <ChartPanel>
+      <ChartContainer>
+        <TransitionChart loading={isLoading} reloading={false} height="224px">
+          <HeaderTitle>{t('Last 30 days of Transactions ')}</HeaderTitle>
+
+          {getDynamicText({
+            value: (
+              <BarChart
+                legend={legend}
+                series={series}
+                grid={{
+                  left: '10px',
+                  right: '10px',
+                  top: '40px',
+                  bottom: '0px',
+                }}
+                height={200}
+                isGroupedByDate
+                showTimeInTooltip={false}
+                tooltip={{valueFormatter: value => formatAbbreviatedNumber(value)}}
+                yAxis={{
+                  axisLabel: {
+                    formatter: (value: number) => formatAbbreviatedNumber(value),
+                  },
+                }}
+              />
+            ),
+
+            fixed: <Placeholder height="224px" />,
+          })}
+        </TransitionChart>
+      </ChartContainer>
+    </ChartPanel>
+  );
+}
+
+const ChartPanel = styled(Panel)`
+  margin-bottom: 0;
+  border-bottom-left-radius: 0;
+  border-bottom: none;
+  border-bottom-right-radius: 0;
+`;
+
+export {UniformRateChart};

+ 166 - 98
static/app/views/settings/project/server-side-sampling/modals/uniformRateModal.tsx

@@ -1,4 +1,4 @@
-import {Fragment, useState} from 'react';
+import {Fragment, useEffect, useState} from 'react';
 import styled from '@emotion/styled';
 
 import {ModalRenderProps} from 'sentry/actionCreators/modal';
@@ -6,17 +6,25 @@ import Alert from 'sentry/components/alert';
 import Button from 'sentry/components/button';
 import ButtonBar from 'sentry/components/buttonBar';
 import {NumberField} from 'sentry/components/forms';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
 import {PanelTable} from 'sentry/components/panels';
 import Radio from 'sentry/components/radio';
 import {IconRefresh} from 'sentry/icons';
 import {t, tct} from 'sentry/locale';
 import space from 'sentry/styles/space';
-import {Organization, Project} from 'sentry/types';
+import {Organization, Project, SeriesApi} from 'sentry/types';
+import {SamplingRules, SamplingRuleType} from 'sentry/types/sampling';
 import {defined} from 'sentry/utils';
 import {formatPercentage} from 'sentry/utils/formatters';
 import TextBlock from 'sentry/views/settings/components/text/textBlock';
 
 import {SERVER_SIDE_SAMPLING_DOC_LINK} from '../utils';
+import {projectStatsToPredictedSeries} from '../utils/projectStatsToPredictedSeries';
+import {projectStatsToSampleRates} from '../utils/projectStatsToSampleRates';
+import {projectStatsToSeries} from '../utils/projectStatsToSeries';
+import useProjectStats from '../utils/useProjectStats';
+
+import {UniformRateChart} from './uniformRateChart';
 
 enum Strategy {
   CURRENT = 'current',
@@ -25,23 +33,58 @@ enum Strategy {
 
 type Props = ModalRenderProps & {
   organization: Organization;
+  rules: SamplingRules;
   project?: Project;
+  projectStats?: SeriesApi;
 };
 
-function UniformRateModal({Header, Body, Footer, closeModal}: Props) {
+function UniformRateModal({
+  organization,
+  project,
+  projectStats,
+  rules,
+  Header,
+  Body,
+  Footer,
+  closeModal,
+}: Props) {
+  const {projectStats: projectStats30d, loading: loading30d} = useProjectStats({
+    orgSlug: organization.slug,
+    projectId: project?.id,
+    interval: '1d',
+    statsPeriod: '30d',
+  });
+
+  const loading = loading30d || !projectStats;
+
   // TODO(sampling): fetch from API
   const affectedProjects = ['ProjectA', 'ProjectB', 'ProjectC'];
 
-  // TODO(sampling): calculate dynamically
-  const currentClientSampling = 10;
-  const currentServerSampling = undefined;
-  const recommendedClientSampling = 100;
-  const recommendedServerSampling = 10;
+  const baseSampleRate = rules.find(
+    rule => rule.type === SamplingRuleType.TRACE && rule.condition.inner.length === 0
+  )?.sampleRate;
+
+  const {trueSampleRate, maxSafeSampleRate} = projectStatsToSampleRates(projectStats);
+
+  const currentClientSampling =
+    defined(trueSampleRate) && !isNaN(trueSampleRate) ? trueSampleRate * 100 : undefined;
+  const currentServerSampling =
+    defined(baseSampleRate) && !isNaN(baseSampleRate) ? baseSampleRate * 100 : undefined;
+  const recommendedClientSampling =
+    defined(maxSafeSampleRate) && !isNaN(maxSafeSampleRate)
+      ? maxSafeSampleRate * 100
+      : undefined;
+  const recommendedServerSampling = currentClientSampling;
 
   const [selectedStrategy, setSelectedStrategy] = useState<Strategy>(Strategy.CURRENT);
   const [client, setClient] = useState(recommendedClientSampling);
   const [server, setServer] = useState(recommendedServerSampling);
 
+  useEffect(() => {
+    setClient(recommendedClientSampling);
+    setServer(recommendedServerSampling);
+  }, [recommendedClientSampling, recommendedServerSampling]);
+
   const isEdited =
     client !== recommendedClientSampling || server !== recommendedServerSampling;
 
@@ -63,98 +106,121 @@ function UniformRateModal({Header, Body, Footer, closeModal}: Props) {
           )}
         </TextBlock>
 
-        <StyledPanelTable
-          headers={[
-            t('Sampling Values'),
-            <RightAligned key="client">{t('Client')}</RightAligned>,
-            <RightAligned key="server">{t('Server')}</RightAligned>,
-            '',
-          ]}
-        >
-          <Fragment>
-            <Label htmlFor="sampling-current">
-              <Radio
-                id="sampling-current"
-                checked={selectedStrategy === Strategy.CURRENT}
-                onChange={() => {
-                  setSelectedStrategy(Strategy.CURRENT);
-                }}
-              />
-              {t('Current')}
-            </Label>
-            <RightAligned>{formatPercentage(currentClientSampling / 100)}</RightAligned>
-            <RightAligned>
-              {defined(currentServerSampling)
-                ? formatPercentage(currentServerSampling / 100)
-                : 'N/A'}
-            </RightAligned>
-            <div />
-          </Fragment>
+        {loading ? (
+          <LoadingIndicator />
+        ) : (
           <Fragment>
-            <Label htmlFor="sampling-recommended">
-              <Radio
-                id="sampling-recommended"
-                checked={selectedStrategy === Strategy.RECOMMENDED}
-                onChange={() => {
-                  setSelectedStrategy(Strategy.RECOMMENDED);
-                }}
-              />
-              {isEdited ? t('New') : t('Recommended')}
-            </Label>
-            <RightAligned>
-              <StyledNumberField
-                name="recommended-client-sampling"
-                placeholder="%"
-                value={client}
-                onChange={value => {
-                  setClient(value);
-                }}
-                onFocus={() => setSelectedStrategy(Strategy.RECOMMENDED)}
-                stacked
-                flexibleControlStateSize
-                inline={false}
-              />
-            </RightAligned>
-            <RightAligned>
-              <StyledNumberField
-                name="recommended-server-sampling"
-                placeholder="%"
-                value={server}
-                onChange={value => {
-                  setServer(value);
-                }}
-                onFocus={() => setSelectedStrategy(Strategy.RECOMMENDED)}
-                stacked
-                flexibleControlStateSize
-                inline={false}
-              />
-            </RightAligned>
-            <ResetButton>
-              {isEdited && (
-                <Button
-                  icon={<IconRefresh size="sm" />}
-                  aria-label={t('Reset to recommended values')}
-                  onClick={() => {
-                    setClient(recommendedClientSampling);
-                    setServer(recommendedServerSampling);
-                  }}
-                  borderless
-                  size="zero"
-                />
+            <UniformRateChart
+              series={
+                selectedStrategy === Strategy.CURRENT
+                  ? projectStatsToSeries(projectStats30d)
+                  : projectStatsToPredictedSeries(
+                      projectStats30d,
+                      client ? Math.max(Math.min(client / 100, 1), 0) : undefined, // clamping between 0-1
+                      server ? Math.max(Math.min(server / 100, 1), 0) : undefined
+                    )
+              }
+              isLoading={loading30d}
+            />
+
+            <StyledPanelTable
+              headers={[
+                t('Sampling Values'),
+                <RightAligned key="client">{t('Client')}</RightAligned>,
+                <RightAligned key="server">{t('Server')}</RightAligned>,
+                '',
+              ]}
+            >
+              <Fragment>
+                <Label htmlFor="sampling-current">
+                  <Radio
+                    id="sampling-current"
+                    checked={selectedStrategy === Strategy.CURRENT}
+                    onChange={() => {
+                      setSelectedStrategy(Strategy.CURRENT);
+                    }}
+                  />
+                  {t('Current')}
+                </Label>
+                <RightAligned>
+                  {defined(currentClientSampling)
+                    ? formatPercentage(currentClientSampling / 100)
+                    : 'N/A'}
+                </RightAligned>
+                <RightAligned>
+                  {defined(currentServerSampling)
+                    ? formatPercentage(currentServerSampling / 100)
+                    : 'N/A'}
+                </RightAligned>
+                <div />
+              </Fragment>
+              <Fragment>
+                <Label htmlFor="sampling-recommended">
+                  <Radio
+                    id="sampling-recommended"
+                    checked={selectedStrategy === Strategy.RECOMMENDED}
+                    onChange={() => {
+                      setSelectedStrategy(Strategy.RECOMMENDED);
+                    }}
+                  />
+                  {isEdited ? t('New') : t('Recommended')}
+                </Label>
+                <RightAligned>
+                  <StyledNumberField
+                    name="recommended-client-sampling"
+                    placeholder="%"
+                    value={client}
+                    onChange={value => {
+                      setClient(value);
+                    }}
+                    onFocus={() => setSelectedStrategy(Strategy.RECOMMENDED)}
+                    stacked
+                    flexibleControlStateSize
+                    inline={false}
+                  />
+                </RightAligned>
+                <RightAligned>
+                  <StyledNumberField
+                    name="recommended-server-sampling"
+                    placeholder="%"
+                    value={server}
+                    onChange={value => {
+                      setServer(value);
+                    }}
+                    onFocus={() => setSelectedStrategy(Strategy.RECOMMENDED)}
+                    stacked
+                    flexibleControlStateSize
+                    inline={false}
+                  />
+                </RightAligned>
+                <ResetButton>
+                  {isEdited && (
+                    <Button
+                      icon={<IconRefresh size="sm" />}
+                      aria-label={t('Reset to recommended values')}
+                      onClick={() => {
+                        setClient(recommendedClientSampling);
+                        setServer(recommendedServerSampling);
+                      }}
+                      borderless
+                      size="zero"
+                    />
+                  )}
+                </ResetButton>
+              </Fragment>
+            </StyledPanelTable>
+
+            <Alert>
+              {tct(
+                'To ensures that any active server-side sampling rules won’t sharply decrease the amount of accepted transactions, we recommend you update the Sentry SDK versions for [affectedProjects]. More details in [step2: Step 2].',
+                {
+                  step2: <strong />,
+                  affectedProjects: <strong>{affectedProjects.join(', ')}</strong>,
+                }
               )}
-            </ResetButton>
+            </Alert>
           </Fragment>
-        </StyledPanelTable>
-
-        <Alert>
-          {tct(
-            'To ensures that any active server-side sampling rules won’t sharply decrease the amount of accepted transactions, we recommend you update the Sentry SDK versions for [affectedProjects]. More details in [step2: Step 2].',
-            {
-              step2: <strong />,
-              affectedProjects: <strong>{affectedProjects.join(', ')}</strong>,
-            }
-          )}
-        </Alert>
+        )}
       </Body>
       <Footer>
         <FooterActions>
@@ -174,7 +240,9 @@ function UniformRateModal({Header, Body, Footer, closeModal}: Props) {
 }
 
 const StyledPanelTable = styled(PanelTable)`
-  grid-template-columns: 1fr 100px 100px 35px;
+  grid-template-columns: 1fr 115px 115px 35px;
+  border-top-left-radius: 0;
+  border-top-right-radius: 0;
 `;
 
 const RightAligned = styled('div')`

+ 23 - 3
static/app/views/settings/project/server-side-sampling/serverSideSampling.tsx

@@ -27,7 +27,9 @@ import {DraggableList, UpdateItemsProps} from '../sampling/rules/draggableList';
 
 import {ActivateModal} from './modals/activateModal';
 import {SpecificConditionsModal} from './modals/specificConditionsModal';
+import {responsiveModal} from './modals/styles';
 import {UniformRateModal} from './modals/uniformRateModal';
+import useProjectStats from './utils/useProjectStats';
 import {Promo} from './promo';
 import {
   ActiveColumn,
@@ -49,9 +51,16 @@ export function ServerSideSampling({project}: Props) {
   const api = useApi();
 
   const hasAccess = organization.access.includes('project:write');
+
   const currentRules = project.dynamicSampling?.rules;
   const previousRules = usePrevious(currentRules);
   const [rules, setRules] = useState<SamplingRules>(currentRules ?? []);
+  const {projectStats} = useProjectStats({
+    orgSlug: organization.slug,
+    projectId: project?.id,
+    interval: '1h',
+    statsPeriod: '48h',
+  });
 
   useEffect(() => {
     if (!isEqual(previousRules, currentRules)) {
@@ -64,9 +73,20 @@ export function ServerSideSampling({project}: Props) {
   }
 
   function handleGetStarted() {
-    openModal(modalProps => (
-      <UniformRateModal {...modalProps} organization={organization} project={project} />
-    ));
+    openModal(
+      modalProps => (
+        <UniformRateModal
+          {...modalProps}
+          organization={organization}
+          project={project}
+          projectStats={projectStats}
+          rules={rules}
+        />
+      ),
+      {
+        modalCss: responsiveModal,
+      }
+    );
   }
 
   async function handleSortRules({overIndex, reorderedItems: ruleIds}: UpdateItemsProps) {

+ 2 - 0
static/app/views/settings/project/server-side-sampling/utils.tsx → static/app/views/settings/project/server-side-sampling/utils/index.tsx

@@ -21,3 +21,5 @@ export function getInnerNameLabel(name: SamplingInnerName | string) {
       return '';
   }
 }
+
+export const quantityField = 'sum(quantity)';

+ 101 - 0
static/app/views/settings/project/server-side-sampling/utils/projectStatsToPredictedSeries.tsx

@@ -0,0 +1,101 @@
+import moment from 'moment';
+
+import {t} from 'sentry/locale';
+import {SeriesApi} from 'sentry/types';
+import {Series} from 'sentry/types/echarts';
+import {defined} from 'sentry/utils';
+import commonTheme from 'sentry/utils/theme';
+import {Outcome} from 'sentry/views/organizationStats/types';
+import {
+  COLOR_DROPPED,
+  COLOR_TRANSACTIONS,
+} from 'sentry/views/organizationStats/usageChart';
+
+import {quantityField} from '.';
+
+export function projectStatsToPredictedSeries(
+  projectStats?: SeriesApi,
+  clientRate?: number,
+  serverRate?: number
+): Series[] {
+  if (!projectStats || !defined(clientRate) || !defined(serverRate)) {
+    return [];
+  }
+
+  const commonSeriesConfig = {
+    barMinHeight: 1,
+    type: 'bar',
+    stack: 'predictedUsage',
+  };
+
+  const seriesData: Record<string, Series['data']> = {
+    accepted: [],
+    droppedServer: [],
+    droppedClient: [],
+  };
+
+  (
+    projectStats.intervals.map((interval, index) => {
+      const result = {};
+      projectStats.groups.forEach(group => {
+        result[group.by.outcome] = group.series[quantityField][index];
+      });
+      return {
+        interval,
+        ...result,
+      };
+    }) as Array<Record<Partial<Outcome>, number> & {interval: string}>
+  ).forEach((bucket, index) => {
+    const {
+      accepted = 0,
+      filtered = 0,
+      invalid = 0,
+      dropped = 0,
+      rate_limited: rateLimited = 0,
+      client_discard: clientDiscard = 0,
+      interval,
+    } = bucket;
+
+    const total = accepted + filtered + invalid + dropped + rateLimited + clientDiscard;
+    const newSentClient = clientRate * total;
+    const droppedClient = total - newSentClient;
+    const validEvents = newSentClient - (filtered + invalid + rateLimited);
+    const newAccepted = serverRate * validEvents;
+    const droppedServer = newSentClient - newAccepted;
+
+    const name = moment(interval).valueOf();
+    seriesData.accepted[index] = {
+      name,
+      value: Math.round(newAccepted),
+    };
+    seriesData.droppedServer[index] = {
+      name,
+      value: Math.round(droppedServer),
+    };
+    seriesData.droppedClient[index] = {
+      name,
+      value: Math.round(droppedClient),
+    };
+  });
+
+  return [
+    {
+      seriesName: t('Accepted'),
+      color: COLOR_TRANSACTIONS,
+      ...commonSeriesConfig,
+      data: seriesData.accepted,
+    },
+    {
+      seriesName: t('Dropped (Server)'),
+      color: COLOR_DROPPED,
+      data: seriesData.droppedServer,
+      ...commonSeriesConfig,
+    },
+    {
+      seriesName: t('Dropped (Client)'),
+      color: commonTheme.yellow300,
+      data: seriesData.droppedClient,
+      ...commonSeriesConfig,
+    },
+  ];
+}

+ 74 - 0
static/app/views/settings/project/server-side-sampling/utils/projectStatsToSampleRates.tsx

@@ -0,0 +1,74 @@
+import round from 'lodash/round';
+
+import {SeriesApi} from 'sentry/types';
+import {Outcome} from 'sentry/views/organizationStats/types';
+
+import {quantityField} from '.';
+
+const MAX_PER_HOUR = 100 * 60 * 60;
+
+export function projectStatsToSampleRates(stats: SeriesApi | undefined): {
+  hoursOverLimit?: number;
+  maxSafeSampleRate?: number;
+  trueSampleRate?: number;
+} {
+  if (!stats) {
+    return {
+      trueSampleRate: undefined,
+      maxSafeSampleRate: undefined,
+      hoursOverLimit: undefined,
+    };
+  }
+
+  const {groups, intervals} = stats;
+  const hours: number[] = [];
+  const trueSampleRates: number[] = [];
+  const safeSampleRates: number[] = [];
+  let hoursOverLimit = 0;
+
+  // We do not take filtered and invalid into account
+  const accepted = groups.find(g => g.by.outcome === Outcome.ACCEPTED)?.series[
+    quantityField
+  ];
+  const clientDiscard = groups.find(g => g.by.outcome === Outcome.CLIENT_DISCARD)?.series[
+    quantityField
+  ];
+  const rateLimited = groups.find(g => g.by.outcome === Outcome.RATE_LIMITED)?.series[
+    quantityField
+  ];
+
+  intervals.forEach((_interval, index) => {
+    const hourAccepted = accepted?.[index] ?? 0;
+    const hourClientDiscard = clientDiscard?.[index] ?? 0;
+    const hourRateLimited = rateLimited?.[index] ?? 0;
+
+    const hourRejected = hourClientDiscard + hourRateLimited;
+    const hourTotal = hourAccepted + hourRejected;
+    const hourTotalCapped = Math.min(hourTotal, MAX_PER_HOUR);
+    const trueSampleRate = hourTotal === 0 ? 1 : 1 - hourRejected / hourTotal;
+    const safeSampleRate = hourTotal === 0 ? 1 : hourTotalCapped / hourTotal;
+
+    hours.push(hourTotal);
+    trueSampleRates.push(trueSampleRate);
+    safeSampleRates.push(safeSampleRate);
+    if (hourTotal > MAX_PER_HOUR) {
+      hoursOverLimit += 1;
+    }
+  });
+
+  hours.sort((a, z) => a - z);
+  trueSampleRates.sort((a, z) => a - z);
+  safeSampleRates.sort((a, z) => a - z);
+
+  const trueSampleRate = round(
+    trueSampleRates[Math.floor(trueSampleRates.length / 2)],
+    4
+  );
+  const maxSafeSampleRate = round(safeSampleRates[0], 4);
+
+  return {
+    trueSampleRate,
+    maxSafeSampleRate,
+    hoursOverLimit,
+  };
+}

Some files were not shown because too many files changed in this diff