From a58d63b12c65b36069bbe70597d81401c16c1eb1 Mon Sep 17 00:00:00 2001 From: ArthurKnaus Date: Thu, 24 Oct 2024 14:07:55 +0200 Subject: [PATCH] feat(dynamic-sampling): List project sample rates (#79664) List projects with their projected sample rate. Closes https://github.com/getsentry/projects/issues/208 --- .../dynamicSampling/dynamicSampling.tsx | 5 + .../dynamicSampling/projectsPreviewTable.tsx | 234 ++++++++++++++++++ .../dynamicSampling/targetSampleRateField.tsx | 9 +- .../dynamicSampling/utils/rebalancing.tsx | 80 ++++++ 4 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 static/app/views/settings/dynamicSampling/projectsPreviewTable.tsx create mode 100644 static/app/views/settings/dynamicSampling/utils/rebalancing.tsx diff --git a/static/app/views/settings/dynamicSampling/dynamicSampling.tsx b/static/app/views/settings/dynamicSampling/dynamicSampling.tsx index b9fdfbe9ab9a89..ee81f573b4a3cd 100644 --- a/static/app/views/settings/dynamicSampling/dynamicSampling.tsx +++ b/static/app/views/settings/dynamicSampling/dynamicSampling.tsx @@ -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'; @@ -109,6 +110,9 @@ export function DynamicSampling() { {t('Save changes')} + +

{t('Project Preview')}

+ ); @@ -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)}; `; diff --git a/static/app/views/settings/dynamicSampling/projectsPreviewTable.tsx b/static/app/views/settings/dynamicSampling/projectsPreviewTable.tsx new file mode 100644 index 00000000000000..902fd1b91350a9 --- /dev/null +++ b/static/app/views/settings/dynamicSampling/projectsPreviewTable.tsx @@ -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 refetch()} />; + } + + return ( + + setTableSort(value => (value === 'asc' ? 'desc' : 'asc'))} + > + {t('Spans')} + + + + setPeriod('24h')} + > + {t('24h')} + + setPeriod('30d')} + > + {t('30d')} + + + , + 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}) => ( + + ))} + + ); +} + +const TableRow = memo(function TableRow({ + project, + count, + sampleRate, +}: { + count: number; + project: Project; + sampleRate: number; +}) { + // TODO(aknaus): Also display delta to initial sanmple rate + return ( + + + + + {formatAbbreviatedNumber(count, 2)} + + + + + + % + + + + + + ); +}); + +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; +`; diff --git a/static/app/views/settings/dynamicSampling/targetSampleRateField.tsx b/static/app/views/settings/dynamicSampling/targetSampleRateField.tsx index 2a86c6645579b1..f274bc6108a2fd 100644 --- a/static/app/views/settings/dynamicSampling/targetSampleRateField.tsx +++ b/static/app/views/settings/dynamicSampling/targetSampleRateField.tsx @@ -1,3 +1,4 @@ +import {css} from '@emotion/react'; import styled from '@emotion/styled'; import FieldGroup from 'sentry/components/forms/fieldGroup'; @@ -23,7 +24,11 @@ export function TargetSampleRateField({}) { )} error={field.error} > - + field.onChange(event.target.value)} diff --git a/static/app/views/settings/dynamicSampling/utils/rebalancing.tsx b/static/app/views/settings/dynamicSampling/utils/rebalancing.tsx new file mode 100644 index 00000000000000..c5e357a071faab --- /dev/null +++ b/static/app/views/settings/dynamicSampling/utils/rebalancing.tsx @@ -0,0 +1,80 @@ +interface BalancingItem { + count: number; + id: string; + sampleRate: number; +} + +interface Params { + 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({ + targetSampleRate, + items, + intensity = 1, + minBudget: minBudgetParam, +}: Params): { + 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}; +}