Skip to content

Commit

Permalink
feat(dynamic-sampling): List project sample rates (#79664)
Browse files Browse the repository at this point in the history
List projects with their projected sample rate.

Closes getsentry/projects#208
  • Loading branch information
ArthurKnaus authored Oct 24, 2024
1 parent 1be596d commit a58d63b
Show file tree
Hide file tree
Showing 4 changed files with 327 additions and 1 deletion.
5 changes: 5 additions & 0 deletions static/app/views/settings/dynamicSampling/dynamicSampling.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ 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 {ProjectsPreviewTable} from 'sentry/views/settings/dynamicSampling/projectsPreviewTable';
import {TargetSampleRateField} from 'sentry/views/settings/dynamicSampling/targetSampleRateField';
import {useAccess} from 'sentry/views/settings/projectMetrics/access';

Expand Down Expand Up @@ -109,6 +110,9 @@ export function DynamicSampling() {
{t('Save changes')}
</Button>
</FormActions>

<h4>{t('Project Preview')}</h4>
<ProjectsPreviewTable />
</form>
</FormProvider>
);
Expand All @@ -119,4 +123,5 @@ const FormActions = styled('div')`
grid-template-columns: repeat(2, max-content);
gap: ${space(1)};
justify-content: flex-end;
padding-bottom: ${space(2)};
`;
234 changes: 234 additions & 0 deletions static/app/views/settings/dynamicSampling/projectsPreviewTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import {Fragment, memo, useMemo, useState} from 'react';
import {css} from '@emotion/react';
import styled from '@emotion/styled';

import ProjectBadge from 'sentry/components/idBadge/projectBadge';
import {InputGroup} from 'sentry/components/inputGroup';
import LoadingError from 'sentry/components/loadingError';
import {PanelTable} from 'sentry/components/panels/panelTable';
import {Tooltip} from 'sentry/components/tooltip';
import {IconArrow} 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 {balanceSampleRate} from 'sentry/views/settings/dynamicSampling/utils/rebalancing';

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',
},
];

export function ProjectsPreviewTable() {
const {projects, fetching} = useProjects();
const [tableSort, setTableSort] = useState<'asc' | 'desc'>('desc');
const [period, setPeriod] = useState<'24h' | '30d'>('24h');
const {value: targetSampleRate} = 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 => ({
id: item.by.project,
project: projectBySlug[item.by.project],
count: item.totals,
// This is a placeholder value to satisfy typing
// the actual value is calculated in the balanceSampleRate function
sampleRate: 1,
}))
// Remove items where we cannot match the project
.filter(item => item.project),
[data?.data, projectBySlug]
);

const debouncedTargetSampleRate = useDebouncedValue(
targetSampleRate,
// For longer lists we debounce the input to avoid too many re-renders
items.length > 100 ? 200 : 0
);

const {balancedItems} = useMemo(() => {
const targetRate = Math.min(100, Math.max(0, Number(debouncedTargetSampleRate) || 0));
return balanceSampleRate({
targetSampleRate: targetRate / 100,
items,
});
}, [debouncedTargetSampleRate, items]);

if (isError) {
return <LoadingError onRetry={() => refetch()} />;
}

return (
<ProjectsTable
stickyHeaders
emptyMessage={t('No active projects found in the selected period.')}
isEmpty={!items.length}
isLoading={isPending || fetching}
headers={[
t('Project'),
<div key={'spans'} style={{display: 'flex', alignItems: 'center'}}>
<SortableHeader
onClick={() => setTableSort(value => (value === 'asc' ? 'desc' : 'asc'))}
>
{t('Spans')}
<IconArrow direction={tableSort === 'desc' ? 'down' : 'up'} size="xs" />
</SortableHeader>
<ToggleWrapper>
<PeriodToggle
data-is-active={period === '24h'}
onClick={() => setPeriod('24h')}
>
{t('24h')}
</PeriodToggle>
<PeriodToggle
data-is-active={period === '30d'}
onClick={() => setPeriod('30d')}
>
{t('30d')}
</PeriodToggle>
</ToggleWrapper>
</div>,
t('Projected Rate'),
]}
>
{balancedItems
.toSorted((a, b) => {
if (tableSort === 'asc') {
return a.count - b.count;
}
return b.count - a.count;
})
.map(({id, project, count, sampleRate}) => (
<TableRow key={id} project={project} count={count} sampleRate={sampleRate} />
))}
</ProjectsTable>
);
}

const TableRow = memo(function TableRow({
project,
count,
sampleRate,
}: {
count: number;
project: Project;
sampleRate: number;
}) {
// TODO(aknaus): Also display delta to initial sanmple rate
return (
<Fragment key={project.slug}>
<Cell>
<ProjectBadge project={project} avatarSize={20} />
</Cell>
<Cell data-align="right">{formatAbbreviatedNumber(count, 2)}</Cell>
<Cell>
<Tooltip
title={t('To edit project sample rates, switch to manual sampling mode.')}
>
<InputGroup
css={css`
width: 150px;
`}
>
<InputGroup.Input
disabled
value={formatNumberWithDynamicDecimalPoints(sampleRate * 100, 3)}
/>
<InputGroup.TrailingItems>
<TrailingPercent>%</TrailingPercent>
</InputGroup.TrailingItems>
</InputGroup>
</Tooltip>
</Cell>
</Fragment>
);
});

const ProjectsTable = styled(PanelTable)`
grid-template-columns: 1fr max-content max-content;
`;

const SortableHeader = styled('button')`
border: none;
background: none;
cursor: pointer;
display: flex;
text-transform: inherit;
align-items: center;
gap: ${space(0.5)};
`;

const ToggleWrapper = styled('div')`
display: flex;
align-items: center;
gap: ${space(0.75)};
padding: 0 0 0 ${space(1)};
`;

const PeriodToggle = styled('button')`
border: none;
background: none;
color: ${p => (p['data-is-active'] ? p.theme.textColor : p.theme.disabled)};
cursor: pointer;
padding: 0;
text-transform: uppercase;
`;

const Cell = styled('div')`
display: flex;
align-items: center;
&[data-align='right'] {
justify-content: flex-end;
}
`;

const TrailingPercent = styled('strong')`
padding: 0 2px;
`;
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {css} from '@emotion/react';
import styled from '@emotion/styled';

import FieldGroup from 'sentry/components/forms/fieldGroup';
Expand All @@ -23,7 +24,11 @@ export function TargetSampleRateField({}) {
)}
error={field.error}
>
<InputWrapper>
<InputWrapper
css={css`
width: 150px;
`}
>
<Tooltip
disabled={hasAccess}
title={t('You do not have permission to change the sample rate.')}
Expand All @@ -32,6 +37,8 @@ export function TargetSampleRateField({}) {
<InputGroup.Input
width={100}
type="number"
min={0}
max={100}
disabled={!hasAccess}
value={field.value}
onChange={event => field.onChange(event.target.value)}
Expand Down
80 changes: 80 additions & 0 deletions static/app/views/settings/dynamicSampling/utils/rebalancing.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
interface BalancingItem {
count: number;
id: string;
sampleRate: number;
}

interface Params<T extends BalancingItem> {
items: T[];
targetSampleRate: number;
intensity?: number;
minBudget?: number;
}

/**
* Balances the sample rate of items to match the target sample rate.
* Mirrors the behavior of the dynamic sampling backend.
*
* See `src/sentry/dynamic_sampling/models/projects_rebalancing.py`
* and `src/sentry/dynamic_sampling/models/full_rebalancing.py`
*
* @param targetSampleRate The target sample rate to balance the items to.
* @param items The items to balance.
* @param intensity The intensity of the balancing. How close to the ideal should we go from our current position (0=do not change, 1 go to ideal)
* @param minBudget Ensure that we use at least min_budget (in order to keep the overall rate)
* @returns The balanced items and the used budget.
*/
export function balanceSampleRate<T extends BalancingItem>({
targetSampleRate,
items,
intensity = 1,
minBudget: minBudgetParam,
}: Params<T>): {
balancedItems: T[];
usedBudget: number;
} {
// Sort the items ascending by count, so the available budget is distributed to the items with the lowest count first
const sortedItems = items.toSorted((a, b) => a.count - b.count);
const total = items.reduce((acc, item) => acc + item.count, 0);

let numItems = items.length;
let ideal = (total * targetSampleRate) / numItems;
let minBudget = Math.min(total, minBudgetParam ?? total * targetSampleRate);
let usedBudget = 0;

const balancedItems: T[] = [];
for (const item of sortedItems) {
const count = item.count;
let newSampleRate = 0;
let used = 0;

if (ideal * numItems < minBudget) {
// If we keep to our ideal we will not be able to use the minimum budget (readjust our target)
ideal = minBudget / numItems;
}

const sampled = count * targetSampleRate;
const delta = ideal - sampled;
const correction = delta * intensity;
const desiredCount = sampled + correction;

if (desiredCount > count) {
// We desire more than we have, so we give it all
newSampleRate = 1;
used = count;
} else {
newSampleRate = desiredCount / count;
used = desiredCount;
}

usedBudget += used;
minBudget -= used;
numItems -= 1;
balancedItems.push({
...item,
sampleRate: newSampleRate,
});
}

return {balancedItems, usedBudget};
}

0 comments on commit a58d63b

Please sign in to comment.