-
-
Notifications
You must be signed in to change notification settings - Fork 4.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(dynamic-sampling): List project sample rates (#79664)
List projects with their projected sample rate. Closes getsentry/projects#208
- Loading branch information
1 parent
1be596d
commit a58d63b
Showing
4 changed files
with
327 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
234 changes: 234 additions & 0 deletions
234
static/app/views/settings/dynamicSampling/projectsPreviewTable.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
`; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
80 changes: 80 additions & 0 deletions
80
static/app/views/settings/dynamicSampling/utils/rebalancing.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}; | ||
} |