diff --git a/static/app/views/settings/dynamicSampling/organizationSampleRateField.tsx b/static/app/views/settings/dynamicSampling/organizationSampleRateField.tsx index 6fae016eb43580..75ff36062b166b 100644 --- a/static/app/views/settings/dynamicSampling/organizationSampleRateField.tsx +++ b/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.')} > - - field.onChange(event.target.value)} - /> - - % - - + field.onChange(event.target.value)} + /> {field.error ? ( {field.error} @@ -74,7 +69,3 @@ const InputWrapper = styled('div')` flex-direction: column; gap: 4px; `; - -const TrailingPercent = styled('strong')` - padding: 0 2px; -`; diff --git a/static/app/views/settings/dynamicSampling/percentInput.tsx b/static/app/views/settings/dynamicSampling/percentInput.tsx new file mode 100644 index 00000000000000..a9227656296d6c --- /dev/null +++ b/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 {} + +export function PercentInput(props: Props) { + return ( + + + + % + + + ); +} + +const TrailingPercent = styled('strong')` + padding: 0 ${space(0.25)}; +`; diff --git a/static/app/views/settings/dynamicSampling/projectsEditTable.tsx b/static/app/views/settings/dynamicSampling/projectsEditTable.tsx index 9c4482b9ea2391..26e1b9f113f427 100644 --- a/static/app/views/settings/dynamicSampling/projectsEditTable.tsx +++ b/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 + ), + [value] + ); + if (isError) { return ; } + const isLoading = fetching || isPending || isLoadingProp; + return ( - + + + {isLoading ? ( + + ) : ( + + + {t('Projected Organization Rate')} + + + + + + )} + + + + ); } + +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}; +`; diff --git a/static/app/views/settings/dynamicSampling/projectsTable.tsx b/static/app/views/settings/dynamicSampling/projectsTable.tsx index e69c5d2adcad25..5840340b313725 100644 --- a/static/app/views/settings/dynamicSampling/projectsTable.tsx +++ b/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.')} > - - - - % - - + {error ? ( @@ -439,7 +429,3 @@ const SettingsButton = styled(LinkButton)` visibility: visible; } `; - -const TrailingPercent = styled('strong')` - padding: 0 ${space(0.25)}; -`; diff --git a/static/app/views/settings/dynamicSampling/samplingBreakdown.tsx b/static/app/views/settings/dynamicSampling/samplingBreakdown.tsx new file mode 100644 index 00000000000000..dcfafc39f163ee --- /dev/null +++ b/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 { + period: '24h' | '30d'; + sampleRates: Record; +} + +function OthersBadge() { + return ( +
+ + {t('other projects')} +
+ ); +} + +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 ( +
+ {t('Breakdown')} + + {topItems.map((item, index) => { + return ( + + + {`${formatAbbreviatedNumberWithDynamicPrecision(getSpanPercent(item.sampledSpans))}%`} + + {t( + '%s of %s sampled spans', + formatAbbreviatedNumber(item.sampledSpans), + formatAbbreviatedNumber(total) + )} + + + } + skipWrapper + > +
+ + ); + })} + {hasOthers && ( + + + {`${formatAbbreviatedNumberWithDynamicPrecision(otherPercent)}%`} + + {`${formatAbbreviatedNumber(otherSpanCount)} of ${formatAbbreviatedNumber(total)}`} + + + } + skipWrapper + > +
+ + )} + + + {topItems.map(item => { + return ( + + + {`${formatAbbreviatedNumberWithDynamicPrecision(getSpanPercent(item.sampledSpans))}%`} + + ); + })} + {hasOthers && ( + + + {`${formatAbbreviatedNumberWithDynamicPrecision(otherPercent)}%`} + + )} + +
+ ); +} + +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; +`;