Skip to content

Commit b621c32

Browse files
gggritsoclaude
andauthored
fix(dashboards): Rebalance chart interval options and defaults (#112562)
Rebalance chart interval ladders and default strategy on dashboards to reduce data point counts and improve chart smoothness. Awaiting #112678 See [DAIN-1376](https://linear.app/getsentry/issue/DAIN-1376) for full context and audit. tl;dr the current behaviour is: give everyone 3-4 options for the interval, always default to the highest granularity. This causes much jank, since so much data is loading! What we think we want to do is choose the _second_ highest granularity by default, but in many cases that's not enough points. In this PR I both switch to using the second-highest granularity by default (in Dashboards, not Explore), and also re-balance the options so things are a bit more even. It's a bit of science and a bit of art, but the results should be pretty decent! ### Before | Duration Range | 1m | 5m | 15m | 30m | 1h | 3h | 12h | 1d | |---|---|---|---|---|---|---|---|---| | **1h–6h** | **60–359** | 12–71 | 4–23 | - | - | - | - | - | | **6h–48h** | - | **72–575** | 24–191 | 12–95 | 6–47 | - | - | - | | **48h–7d** | - | - | **192–671** | 96–335 | 48–167 | 16–55 | - | - | | **7d–14d** | - | - | - | **336–671** | 168–335 | 56–111 | 14–27 | - | | **14d–30d** | - | - | - | - | **336–719** | 112–239 | 28–59 | 14–29 | | **30d–90d** | - | - | - | - | - | **240–720** | 60–180 | 30–90 | Defaults (bold) hit 575–720 points at the upper end of every band, which is an awful lot of points to show by default. ### After | Duration Range | 1m | 5m | 10m | 30m | 1h | 3h | 6h | 12h | |---|---|---|---|---|---|---|---|---| | **1h–6h** | **60–359** | 12–71 | - | - | - | - | - | - | | **6h–12h** | *360–719* | **72–143** | 36–71 | - | - | - | - | - | | **12h–2d** | - | *144–575* | **72–287** | 24–95 | - | - | - | - | | **2d–4d** | - | - | *288–575* | **96–191** | 48–95 | - | - | - | | **4d–14d** | - | - | - | *192–671* | **96–335** | 32–111 | - | - | | **14d–30d** | - | - | - | - | *336–719* | **112–239** | 56–119 | - | | **30d–90d** | - | - | - | - | - | *240–720* | **120–360** | 60–180 | Defaults (bold) stay in the 60–360 range. High granularity is still available for those who want it. ### Changes - Replace `15m` with `10m`, add `6h`, remove `1d` from interval options - Rebalance MINIMUM/MAXIMUM ladders with new thresholds at 12h, 2d, 4d - Default to second-biggest interval on dashboards (`USE_SECOND_BIGGEST`), keep Explore using the highest granularity by default - 1h–6h band naturally defaults to 1m (finest) since it only has 2 options Refs DAIN-1376 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent af27c1d commit b621c32

File tree

8 files changed

+77
-58
lines changed

8 files changed

+77
-58
lines changed

static/app/components/charts/utils.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@ export const SIXTY_DAYS = 86400;
2727
export const THIRTY_DAYS = 43200;
2828
export const TWO_WEEKS = 20160;
2929
export const ONE_WEEK = 10080;
30+
export const FOUR_DAYS = 5760;
3031
export const FORTY_EIGHT_HOURS = 2880;
3132
export const TWENTY_FOUR_HOURS = 1440;
33+
export const TWELVE_HOURS = 720;
3234
export const SIX_HOURS = 360;
3335
const THREE_HOURS = 180;
3436
export const ONE_HOUR = 60;

static/app/utils/useChartInterval.spec.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,7 @@ describe('useChartInterval', () => {
2626
expect(intervalOptions).toEqual([
2727
{value: '1h', label: '1 hour'},
2828
{value: '3h', label: '3 hours'},
29-
{value: '12h', label: '12 hours'},
30-
{value: '1d', label: '1 day'},
29+
{value: '6h', label: '6 hours'},
3130
]);
3231
expect(chartInterval).toBe('1h'); // default
3332

@@ -47,12 +46,11 @@ describe('useChartInterval', () => {
4746
expect(intervalOptions).toEqual([
4847
{value: '1m', label: '1 minute'},
4948
{value: '5m', label: '5 minutes'},
50-
{value: '15m', label: '15 minutes'},
5149
]);
5250
act(() => {
53-
setChartInterval('15m');
51+
setChartInterval('5m');
5452
});
55-
expect(chartInterval).toBe('15m');
53+
expect(chartInterval).toBe('5m');
5654
});
5755
});
5856

static/app/utils/useChartInterval.tsx

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ import type {Location} from 'history';
44
import {
55
FIVE_MINUTES,
66
FORTY_EIGHT_HOURS,
7+
FOUR_DAYS,
78
getDiffInMinutes,
89
GranularityLadder,
910
ONE_HOUR,
10-
ONE_WEEK,
1111
SIX_HOURS,
1212
THIRTY_DAYS,
13+
TWELVE_HOURS,
1314
TWO_WEEKS,
1415
} from 'sentry/components/charts/utils';
1516
import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters';
@@ -20,7 +21,7 @@ import {decodeScalar} from 'sentry/utils/queryString';
2021
import {useLocation} from 'sentry/utils/useLocation';
2122
import {useNavigate} from 'sentry/utils/useNavigate';
2223

23-
enum ChartIntervalUnspecifiedStrategy {
24+
export enum ChartIntervalUnspecifiedStrategy {
2425
/** Use the second biggest possible interval (e.g., pretty big buckets) */
2526
USE_SECOND_BIGGEST = 'use_second_biggest',
2627
/** Use the smallest possible interval (e.g., the smallest possible buckets) */
@@ -104,12 +105,12 @@ function useChartIntervalImpl({
104105
const ALL_INTERVAL_OPTIONS = [
105106
{value: '1m', label: t('1 minute')},
106107
{value: '5m', label: t('5 minutes')},
107-
{value: '15m', label: t('15 minutes')},
108+
{value: '10m', label: t('10 minutes')},
108109
{value: '30m', label: t('30 minutes')},
109110
{value: '1h', label: t('1 hour')},
110111
{value: '3h', label: t('3 hours')},
112+
{value: '6h', label: t('6 hours')},
111113
{value: '12h', label: t('12 hours')},
112-
{value: '1d', label: t('1 day')},
113114
];
114115

115116
/**
@@ -119,19 +120,21 @@ const ALL_INTERVAL_OPTIONS = [
119120
const MINIMUM_INTERVAL = new GranularityLadder([
120121
[THIRTY_DAYS, '3h'],
121122
[TWO_WEEKS, '1h'],
122-
[ONE_WEEK, '30m'],
123-
[FORTY_EIGHT_HOURS, '15m'],
124-
[SIX_HOURS, '5m'],
123+
[FOUR_DAYS, '30m'],
124+
[FORTY_EIGHT_HOURS, '10m'],
125+
[TWELVE_HOURS, '5m'],
126+
[SIX_HOURS, '1m'],
125127
[0, '1m'],
126128
]);
127129

128130
const MAXIMUM_INTERVAL = new GranularityLadder([
129-
[THIRTY_DAYS, '1d'],
130-
[TWO_WEEKS, '1d'],
131-
[ONE_WEEK, '12h'],
132-
[FORTY_EIGHT_HOURS, '4h'],
133-
[SIX_HOURS, '1h'],
134-
[ONE_HOUR, '15m'],
131+
[THIRTY_DAYS, '12h'],
132+
[TWO_WEEKS, '6h'],
133+
[FOUR_DAYS, '3h'],
134+
[FORTY_EIGHT_HOURS, '1h'],
135+
[TWELVE_HOURS, '30m'],
136+
[SIX_HOURS, '10m'],
137+
[ONE_HOUR, '5m'],
135138
[FIVE_MINUTES, '5m'],
136139
[0, '1m'],
137140
]);

static/app/views/dashboards/dashboard.spec.tsx

Lines changed: 35 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ import {resetMockDate, setMockDate} from 'sentry-test/utils';
1111
import {PageFiltersStore} from 'sentry/components/pageFilters/store';
1212
import {MemberListStore} from 'sentry/stores/memberListStore';
1313
import {MEPSettingProvider} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
14-
import {useChartInterval} from 'sentry/utils/useChartInterval';
1514
import {useLocation} from 'sentry/utils/useLocation';
1615
import {Dashboard} from 'sentry/views/dashboards/dashboard';
1716
import {FiltersBar} from 'sentry/views/dashboards/filtersBar';
17+
import {useDashboardChartInterval} from 'sentry/views/dashboards/hooks/useDashboardChartInterval';
1818
import type {
1919
DashboardDetails,
2020
DashboardFilters,
@@ -499,7 +499,7 @@ describe('Dashboards > Dashboard', () => {
499499
dashboard?: DashboardDetails;
500500
} = {}) {
501501
const location = useLocation();
502-
const [widgetInterval] = useChartInterval();
502+
const [widgetInterval] = useDashboardChartInterval();
503503
return (
504504
<MEPSettingProvider forceTransactions={false}>
505505
<FiltersBar
@@ -541,39 +541,39 @@ describe('Dashboards > Dashboard', () => {
541541
});
542542

543543
describe('no interval set in URL', () => {
544-
it('defaults to the smallest valid interval for the dashboard period', async () => {
545-
const fiveMinuteMock = MockApiClient.addMockResponse({
544+
it('defaults to the second-biggest valid interval for the dashboard period', async () => {
545+
const tenMinuteMock = MockApiClient.addMockResponse({
546546
url: '/organizations/org-slug/events-stats/',
547547
method: 'GET',
548548
body: [],
549-
match: [MockApiClient.matchQuery({interval: '5m'})],
549+
match: [MockApiClient.matchQuery({interval: '10m'})],
550550
});
551-
const hourlyIntervalMock = MockApiClient.addMockResponse({
551+
const thirtyMinuteMock = MockApiClient.addMockResponse({
552552
url: '/organizations/org-slug/events-stats/',
553553
method: 'GET',
554554
body: [],
555-
match: [MockApiClient.matchQuery({interval: '1h'})],
555+
match: [MockApiClient.matchQuery({interval: '30m'})],
556556
});
557557

558-
// No interval in the URL — the 5m default is derived purely from the
559-
// dashboard's saved 24h period via PageFiltersStore → useChartInterval.
558+
// No interval in the URL — the 10m default (second-biggest of [5m, 10m, 30m])
559+
// is derived from the dashboard's saved 24h period via useDashboardChartInterval.
560560
const {router} = render(<DashboardWithIntervalSelector />, {
561561
organization: orgWithFlag,
562562
initialRouterConfig: {location: {pathname: '/'}},
563563
});
564564

565565
await screen.findByText('Test Spans Widget');
566-
await waitFor(() => expect(fiveMinuteMock).toHaveBeenCalled());
567-
expect(hourlyIntervalMock).not.toHaveBeenCalled();
566+
await waitFor(() => expect(tenMinuteMock).toHaveBeenCalled());
567+
expect(thirtyMinuteMock).not.toHaveBeenCalled();
568568

569-
// Click the interval selector and choose '1 hour'. FiltersBar writes
570-
// interval=1h to the URL, DashboardWithIntervalSelector re-renders with
569+
// Click the interval selector and choose '30 minutes'. FiltersBar writes
570+
// interval=30m to the URL, DashboardWithIntervalSelector re-renders with
571571
// the new widgetInterval, and the widget re-fetches with the new interval.
572-
await userEvent.click(screen.getByRole('button', {name: '5 minutes'}));
573-
await userEvent.click(screen.getByRole('option', {name: '1 hour'}));
572+
await userEvent.click(screen.getByRole('button', {name: '10 minutes'}));
573+
await userEvent.click(screen.getByRole('option', {name: '30 minutes'}));
574574

575-
await waitFor(() => expect(hourlyIntervalMock).toHaveBeenCalled());
576-
expect(router.location.query.interval).toBe('1h');
575+
await waitFor(() => expect(thirtyMinuteMock).toHaveBeenCalled());
576+
expect(router.location.query.interval).toBe('30m');
577577
});
578578
});
579579

@@ -592,11 +592,11 @@ describe('Dashboards > Dashboard', () => {
592592
match: [MockApiClient.matchQuery({interval: '5m'})],
593593
});
594594

595-
const hourlyIntervalMock = MockApiClient.addMockResponse({
595+
const tenMinuteMock = MockApiClient.addMockResponse({
596596
url: '/organizations/org-slug/events-stats/',
597597
method: 'GET',
598598
body: [],
599-
match: [MockApiClient.matchQuery({interval: '1h'})],
599+
match: [MockApiClient.matchQuery({interval: '10m'})],
600600
});
601601

602602
const {router} = render(<DashboardWithIntervalSelector />, {
@@ -615,28 +615,28 @@ describe('Dashboards > Dashboard', () => {
615615

616616
// Selecting a new interval updates the URL and triggers a re-fetch.
617617
await userEvent.click(screen.getByRole('button', {name: '30 minutes'}));
618-
await userEvent.click(screen.getByRole('option', {name: '1 hour'}));
618+
await userEvent.click(screen.getByRole('option', {name: '10 minutes'}));
619619

620-
await waitFor(() => expect(hourlyIntervalMock).toHaveBeenCalled());
621-
expect(router.location.query.interval).toBe('1h');
620+
await waitFor(() => expect(tenMinuteMock).toHaveBeenCalled());
621+
expect(router.location.query.interval).toBe('10m');
622622
});
623623
});
624624

625625
describe('URL interval not valid for the dashboard period', () => {
626626
beforeEach(() => {
627-
// Override the outer 24h store setup — valid intervals for 30d are 3h, 12h, 1d.
627+
// Override the outer 24h store setup — valid intervals for 30d are 3h, 6h, 12h.
628628
PageFiltersStore.init();
629629
PageFiltersStore.onInitializeUrlState(
630630
getSavedFiltersAsPageFilters(thirtyDayDashboard)
631631
);
632632
});
633633

634634
it('ignores the URL interval and falls back to the period default', async () => {
635-
const threeHourMock = MockApiClient.addMockResponse({
635+
const sixHourMock = MockApiClient.addMockResponse({
636636
url: '/organizations/org-slug/events-stats/',
637637
method: 'GET',
638638
body: [],
639-
match: [MockApiClient.matchQuery({interval: '3h'})],
639+
match: [MockApiClient.matchQuery({interval: '6h'})],
640640
});
641641
const fiveMinuteMock = MockApiClient.addMockResponse({
642642
url: '/organizations/org-slug/events-stats/',
@@ -653,11 +653,12 @@ describe('Dashboards > Dashboard', () => {
653653

654654
await screen.findByText('Test Spans Widget');
655655

656-
// The selector should show the period-derived default, not the invalid URL value.
657-
expect(screen.getByRole('button', {name: '3 hours'})).toBeInTheDocument();
656+
// The selector should show the period-derived default (6h = second-biggest
657+
// of [3h, 6h, 12h]), not the invalid URL value (5m).
658+
expect(screen.getByRole('button', {name: '6 hours'})).toBeInTheDocument();
658659

659660
// The widget should query with the valid default interval, not the URL value.
660-
await waitFor(() => expect(threeHourMock).toHaveBeenCalled());
661+
await waitFor(() => expect(sixHourMock).toHaveBeenCalled());
661662
expect(fiveMinuteMock).not.toHaveBeenCalled();
662663
});
663664
});
@@ -681,11 +682,11 @@ describe('Dashboards > Dashboard', () => {
681682
it('ignores the URL interval and falls back to the period default', async () => {
682683
// Uses the /sessions/ endpoint (session.status in columns → useSessionAPI=true),
683684
// which surfaces the "intervals too granular" error in production.
684-
const threeHourMock = MockApiClient.addMockResponse({
685+
const sixHourMock = MockApiClient.addMockResponse({
685686
url: '/organizations/org-slug/sessions/',
686687
method: 'GET',
687688
body: emptySessionsBody,
688-
match: [MockApiClient.matchQuery({interval: '3h'})],
689+
match: [MockApiClient.matchQuery({interval: '6h'})],
689690
});
690691
const fiveMinuteMock = MockApiClient.addMockResponse({
691692
url: '/organizations/org-slug/sessions/',
@@ -701,13 +702,13 @@ describe('Dashboards > Dashboard', () => {
701702

702703
await screen.findByText('Test Releases Widget');
703704

704-
// The selector should show the period-derived default (3h), not the
705-
// invalid URL value (5m).
706-
expect(screen.getByRole('button', {name: '3 hours'})).toBeInTheDocument();
705+
// The selector should show the period-derived default (6h = second-biggest
706+
// of [3h, 6h, 12h]), not the invalid URL value (5m).
707+
expect(screen.getByRole('button', {name: '6 hours'})).toBeInTheDocument();
707708

708709
// The widget should query the sessions endpoint with the valid default
709710
// interval, not the 5m value from the URL.
710-
await waitFor(() => expect(threeHourMock).toHaveBeenCalled());
711+
await waitFor(() => expect(sixHourMock).toHaveBeenCalled());
711712
expect(fiveMinuteMock).not.toHaveBeenCalled();
712713
});
713714
});

static/app/views/dashboards/detail.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,14 @@ import {OnRouteLeave} from 'sentry/utils/reactRouter6Compat/onRouteLeave';
5353
import {scheduleMicroTask} from 'sentry/utils/scheduleMicroTask';
5454
import {normalizeUrl} from 'sentry/utils/url/normalizeUrl';
5555
import {useApi} from 'sentry/utils/useApi';
56-
import {useChartInterval} from 'sentry/utils/useChartInterval';
5756
import {useLocation} from 'sentry/utils/useLocation';
5857
import type {ReactRouter3Navigate} from 'sentry/utils/useNavigate';
5958
import {useNavigate} from 'sentry/utils/useNavigate';
6059
import {useOrganization} from 'sentry/utils/useOrganization';
6160
import {useParams} from 'sentry/utils/useParams';
6261
import {useProjects} from 'sentry/utils/useProjects';
6362
import {useRouter} from 'sentry/utils/useRouter';
63+
import {useDashboardChartInterval} from 'sentry/views/dashboards/hooks/useDashboardChartInterval';
6464
import {
6565
cloneDashboard,
6666
getCurrentPageFilters,
@@ -1519,7 +1519,7 @@ export function DashboardDetailWithInjectedProps(
15191519
const location = useLocation();
15201520
const params = useParams<RouteParams>();
15211521
const router = useRouter();
1522-
const [chartInterval] = useChartInterval();
1522+
const [chartInterval] = useDashboardChartInterval();
15231523
const queryClient = useQueryClient();
15241524
const hasPageFrameFeature = useHasPageFrameFeature();
15251525
// Always use the validated chart interval so the UI dropdown and widget

static/app/views/dashboards/filtersBar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,14 @@ import type {User} from 'sentry/types/user';
2525
import {defined} from 'sentry/utils';
2626
import {trackAnalytics} from 'sentry/utils/analytics';
2727
import {ToggleOnDemand} from 'sentry/utils/performance/contexts/onDemandControl';
28-
import {useChartInterval} from 'sentry/utils/useChartInterval';
2928
import {useMaxPickableDays} from 'sentry/utils/useMaxPickableDays';
3029
import {useOrganization} from 'sentry/utils/useOrganization';
3130
import {useUser} from 'sentry/utils/useUser';
3231
import {useUserTeams} from 'sentry/utils/useUserTeams';
3332
import {AddFilter} from 'sentry/views/dashboards/globalFilter/addFilter';
3433
import {GenericFilterSelector} from 'sentry/views/dashboards/globalFilter/genericFilterSelector';
3534
import {globalFilterKeysAreEqual} from 'sentry/views/dashboards/globalFilter/utils';
35+
import {useDashboardChartInterval} from 'sentry/views/dashboards/hooks/useDashboardChartInterval';
3636
import {useDatasetSearchBarData} from 'sentry/views/dashboards/hooks/useDatasetSearchBarData';
3737
import {useInvalidateStarredDashboards} from 'sentry/views/dashboards/hooks/useInvalidateStarredDashboards';
3838
import {getDashboardFiltersFromURL} from 'sentry/views/dashboards/utils';
@@ -234,7 +234,7 @@ export function FiltersBar({
234234
const hasIntervalSelection = organization.features.includes(
235235
'dashboards-interval-selection'
236236
);
237-
const [interval, setInterval, intervalOptions] = useChartInterval();
237+
const [interval, setInterval, intervalOptions] = useDashboardChartInterval();
238238

239239
return (
240240
<Wrapper>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {
2+
ChartIntervalUnspecifiedStrategy,
3+
useChartInterval,
4+
} from 'sentry/utils/useChartInterval';
5+
6+
/**
7+
* Wrapper around `useChartInterval` that uses the `USE_SECOND_BIGGEST`
8+
* strategy for dashboards. This keeps the dashboard detail page, widget
9+
* builder preview, and filters bar in sync.
10+
*/
11+
export function useDashboardChartInterval() {
12+
return useChartInterval({
13+
unspecifiedStrategy: ChartIntervalUnspecifiedStrategy.USE_SECOND_BIGGEST,
14+
});
15+
}

static/app/views/dashboards/widgetBuilder/components/widgetPreview.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import {useState} from 'react';
33
import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters';
44
import {dedupeArray} from 'sentry/utils/dedupeArray';
55
import type {Sort} from 'sentry/utils/discover/fields';
6-
import {useChartInterval} from 'sentry/utils/useChartInterval';
76
import {useLocation} from 'sentry/utils/useLocation';
87
import {useNavigate} from 'sentry/utils/useNavigate';
98
import {useOrganization} from 'sentry/utils/useOrganization';
9+
import {useDashboardChartInterval} from 'sentry/views/dashboards/hooks/useDashboardChartInterval';
1010
import {
1111
DisplayType,
1212
WidgetType,
@@ -44,7 +44,7 @@ export function WidgetPreview({
4444
const location = useLocation();
4545
const navigate = useNavigate();
4646
const pageFilters = usePageFilters();
47-
const [chartInterval] = useChartInterval();
47+
const [chartInterval] = useDashboardChartInterval();
4848

4949
const {state, dispatch} = useWidgetBuilderContext();
5050
const [tableWidths, setTableWidths] = useState<number[]>();

0 commit comments

Comments
 (0)