Skip to content

Commit 2e1c639

Browse files
authored
feat(explore): heat maps fixed buckets (#117083)
instead of having buckets based on the interval the users choose, we'll provide a default bucket size that's at least 15px. The logic here is a little interesting because the backend only supports a select number of intervals for the x-axis. What i've done is found the closest interval (made a new function for this) based off the pixel calculation and then passing that in to the y-axis bucket calculation to get a square bucket. Since there is no need for the interval selector i've disabled it! Closes DAIN-1658 (but there's still discussion going on about this)
1 parent e00bb51 commit 2e1c639

3 files changed

Lines changed: 223 additions & 9 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import {millisecondsToClosestInterval} from 'sentry/utils/duration/millisecondsToInterval';
2+
3+
const TEST_INTERVALS = [
4+
'1m',
5+
'2m',
6+
'5m',
7+
'10m',
8+
'15m',
9+
'30m',
10+
'1h',
11+
'2h',
12+
'3h',
13+
'4h',
14+
'6h',
15+
'12h',
16+
'1d',
17+
];
18+
19+
describe('millisecondsToClosestInterval()', () => {
20+
it.each([
21+
[60_000, '1m'],
22+
[2 * 60_000, '2m'],
23+
[5 * 60_000, '5m'],
24+
[10 * 60_000, '10m'],
25+
[15 * 60_000, '15m'],
26+
[30 * 60_000, '30m'],
27+
[3600_000, '1h'],
28+
[6 * 3600_000, '6h'],
29+
[24 * 3600_000, '1d'],
30+
])('returns an exact string for valid granularity (%s)', (ms, expected) => {
31+
expect(millisecondsToClosestInterval(ms, TEST_INTERVALS)).toBe(expected);
32+
});
33+
34+
it.each([
35+
// 45s is between 30s and 1m — equidistant, ties go to larger
36+
[45_000, '1m'],
37+
// 50s is closer to 1m (60s) than to 30s
38+
[50_000, '1m'],
39+
// 4m is closer to 5m than to 2m
40+
[4 * 60_000, '5m'],
41+
// 7.5m is between 5m and 10m — equidistant, ties go to larger
42+
[7.5 * 60_000, '10m'],
43+
// 90m is between 1h and 2h — equidistant, ties go to larger
44+
[90 * 60_000, '2h'],
45+
// 100m is closer to 2h than to 1h
46+
[100 * 60_000, '2h'],
47+
])(
48+
'rounds to the nearest interval when between two valid granularities (%s)',
49+
(ms, expected) => {
50+
expect(millisecondsToClosestInterval(ms, TEST_INTERVALS)).toBe(expected);
51+
}
52+
);
53+
54+
it.each([
55+
[1_000, '1m'],
56+
[5_000, '1m'],
57+
])(
58+
'clamps to the smallest valid interval for values below the minimum (%s)',
59+
(ms, expected) => {
60+
expect(millisecondsToClosestInterval(ms, TEST_INTERVALS)).toBe(expected);
61+
}
62+
);
63+
64+
it.each([
65+
[48 * 3600_000, '1d'],
66+
[7 * 86400_000, '1d'],
67+
])(
68+
'clamps to the largest valid interval for values above the maximum (%s)',
69+
(ms, expected) => {
70+
expect(millisecondsToClosestInterval(ms, TEST_INTERVALS)).toBe(expected);
71+
}
72+
);
73+
74+
it.each([
75+
[0, undefined],
76+
[-60_000, undefined],
77+
[Infinity, undefined],
78+
])('returns undefined for invalid inputs (%s)', (ms, expected) => {
79+
expect(millisecondsToClosestInterval(ms, TEST_INTERVALS)).toBe(expected);
80+
});
81+
82+
describe('less availableIntervals option', () => {
83+
const availableIntervals = ['1m', '5m', '1h'];
84+
85+
it.each([
86+
// 90s is closer to 1m (diff=30s) than to 15s (diff=75s) among available intervals
87+
[90_000, '1m'],
88+
// 3m is equidistant between 1m and 5m — ties go to larger
89+
[3 * 60_000, '5m'],
90+
// exact match still works
91+
[5 * 60_000, '5m'],
92+
])('restricts selection to the provided available intervals (%s)', (ms, expected) => {
93+
expect(millisecondsToClosestInterval(ms, availableIntervals)).toBe(expected);
94+
});
95+
96+
it.each([[1_000, '1m']])(
97+
'clamps to the first available interval for values below the minimum (%s)',
98+
(ms, expected) => {
99+
expect(millisecondsToClosestInterval(ms, availableIntervals)).toBe(expected);
100+
}
101+
);
102+
103+
it.each([
104+
[48 * 3600_000, '1h'],
105+
[7 * 86400_000, '1h'],
106+
])(
107+
'clamps to the last available interval for values above the maximum (%s)',
108+
(ms, expected) => {
109+
expect(millisecondsToClosestInterval(ms, availableIntervals)).toBe(expected);
110+
}
111+
);
112+
});
113+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import {intervalToMilliseconds} from 'sentry/utils/duration/intervalToMilliseconds';
2+
import {RangeMap, type Range} from 'sentry/utils/number/rangeMap';
3+
4+
/**
5+
* Converts a millisecond value to the closest valid interval string.
6+
* If the milliseconds value is not one of the exact valid interval durations,
7+
* it will return the closest valid interval string (based on rounding rules).
8+
* @param ms - The milliseconds value to convert.
9+
* @param availableIntervals - Array of available interval strings (e.g. '1m', '5m', '1h') to choose from.
10+
* @returns The closest valid interval string.
11+
*/
12+
export function millisecondsToClosestInterval(
13+
ms: number,
14+
availableIntervals: string[]
15+
): string | undefined {
16+
if (ms <= 0 || !Number.isFinite(ms)) {
17+
return undefined;
18+
}
19+
20+
// sort the intervals in ascending order in case they are not in order already
21+
const sortedIntervals = availableIntervals.sort(
22+
(a, b) => intervalToMilliseconds(a) - intervalToMilliseconds(b)
23+
);
24+
25+
// calculate the MIDPOINT value ranges to allow the interval to be chosen.
26+
// For example if the available intervals are [1m, 5m, 1h, 4h, 6h, 1d], the valid interval range
27+
// boundaries would be the numbers exactly in between the intervals.
28+
// so for example:
29+
// - anything from 0 -> 3m would give the 1m interval,
30+
// - anything from 3m -> 32.5m would give the 5m interval (because it's closer to 5m than to 1h),
31+
// - anything from 32.5m -> 2.5h would give the 1h interval,
32+
// - anything from 2.5h -> 5h would give the 4h interval,
33+
// - anything from 5h -> 12h would give the 6h interval,
34+
// - anything from 12h -> Infinity would give the 1d interval,
35+
const intervalRanges: Array<Range<string>> = [];
36+
for (let i = 0; i < sortedIntervals.length; i++) {
37+
const range: Range<string> = {min: 0, max: 0, value: sortedIntervals[i]!};
38+
if (i < sortedIntervals.length - 1) {
39+
// min value should cover end of the previous interval (or 0 if there is no previous interval)
40+
if (i === 0) {
41+
range.min = 0;
42+
} else {
43+
range.min = intervalRanges[i - 1]!.max;
44+
}
45+
// max value should cover up until the value that is considered "closest" to the interval.
46+
// Any value up to halfway between the current and next interval would take the current interval.
47+
const halfIntervalDifference = Math.round(
48+
Math.abs(
49+
intervalToMilliseconds(sortedIntervals[i]!) -
50+
intervalToMilliseconds(sortedIntervals[i + 1]!)
51+
) / 2
52+
);
53+
range.max = intervalToMilliseconds(sortedIntervals[i]!) + halfIntervalDifference;
54+
intervalRanges.push(range);
55+
} else if (sortedIntervals.length > 1) {
56+
// last interval should cover all values close to and greater than the last interval
57+
range.min = intervalRanges[i - 1]?.max ?? 0;
58+
range.max = Infinity;
59+
intervalRanges.push(range);
60+
} else {
61+
range.min = 0;
62+
range.max = Infinity;
63+
intervalRanges.push(range);
64+
}
65+
}
66+
67+
const intervalRangeMap = new RangeMap(intervalRanges ?? []);
68+
const closestInterval = intervalRangeMap.get(ms);
69+
return closestInterval;
70+
}

static/app/views/explore/metrics/metricPanel/index.tsx

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {t} from 'sentry/locale';
1818
import type {PageFilters} from 'sentry/types/core';
1919
import type {DataUnit} from 'sentry/utils/discover/fields';
2020
import {intervalToMilliseconds} from 'sentry/utils/duration/intervalToMilliseconds';
21+
import {millisecondsToClosestInterval} from 'sentry/utils/duration/millisecondsToInterval';
2122
import {
2223
ChartIntervalUnspecifiedStrategy,
2324
useChartInterval,
@@ -73,6 +74,7 @@ import {ChartType} from 'sentry/views/insights/common/components/chart';
7374

7475
const RESULT_LIMIT = 50;
7576
const TWO_MINUTE_DELAY = 120;
77+
const PIXELS_PER_X_BUCKET = 15;
7678

7779
const CHART_TYPE_TO_ICON: Record<ChartType, 'line' | 'area' | 'bar' | 'heatmap'> = {
7880
[ChartType.LINE]: 'line',
@@ -125,12 +127,14 @@ export function MetricPanel({
125127
const visualizes = useMetricVisualizes();
126128
const setVisualizes = useSetMetricVisualizes();
127129
const setAggregateFields = useSetMetricAggregateFields();
130+
131+
const isHeatmap = visualize.chartType === ChartType.HEATMAP;
132+
128133
// use the biggest interval for the heat map as this produces better patterns
129134
const [interval, setInterval, intervalOptions] = useChartInterval({
130-
unspecifiedStrategy:
131-
visualize.chartType === ChartType.HEATMAP
132-
? ChartIntervalUnspecifiedStrategy.USE_BIGGEST
133-
: ChartIntervalUnspecifiedStrategy.USE_SMALLEST,
135+
unspecifiedStrategy: isHeatmap
136+
? ChartIntervalUnspecifiedStrategy.USE_BIGGEST
137+
: ChartIntervalUnspecifiedStrategy.USE_SMALLEST,
134138
});
135139

136140
const [title, setTitle] = useState<string | undefined>(() => {
@@ -162,7 +166,6 @@ export function MetricPanel({
162166
staleTime: Infinity,
163167
});
164168

165-
const isHeatmap = visualize.chartType === ChartType.HEATMAP;
166169
const hasHeatMap = canUseMetricsHeatMap(organization);
167170

168171
const {result: timeseriesResult} = useMetricTimeseries({
@@ -175,15 +178,21 @@ export function MetricPanel({
175178

176179
const chartContainerRef = useRef<HTMLDivElement>(null);
177180
const {width: chartContainerWidth} = useDimensions({elementRef: chartContainerRef});
178-
const yBuckets = getHeatmapYBuckets(selection, interval, chartContainerWidth);
181+
const xBucketInterval = getHeatmapXBucketInterval(
182+
selection,
183+
interval,
184+
chartContainerWidth,
185+
intervalOptions
186+
);
187+
const yBuckets = getHeatmapYBuckets(selection, xBucketInterval, chartContainerWidth);
179188

180189
const heatmapApiOptions = metricHeatmapApiOptions({
181190
traceMetric,
182191
enabled: hasHeatMap && isHeatmap && !isMetricOptionsEmpty && yBuckets > 0,
183192
organization,
184193
selection,
185194
query: userQuery,
186-
interval,
195+
interval: xBucketInterval,
187196
yBuckets,
188197
});
189198
const heatmapResult = useQuery({
@@ -258,7 +267,8 @@ export function MetricPanel({
258267
onChange={option => handleChartTypeChange(option.value)}
259268
/>
260269
<CompactSelect
261-
value={interval}
270+
value={isHeatmap ? xBucketInterval : interval}
271+
disabled={isHeatmap}
262272
onChange={({value}) => setInterval(value)}
263273
trigger={triggerProps => (
264274
<OverlayTrigger.Button
@@ -397,7 +407,6 @@ function getHeatmapYBuckets(
397407
if (intervalInMs <= 0 || chartContainerWidth <= 0) {
398408
return 0;
399409
}
400-
401410
const xBuckets = Math.round(timeRangeInMs / intervalInMs);
402411
if (xBuckets <= 0) {
403412
return 0;
@@ -406,6 +415,28 @@ function getHeatmapYBuckets(
406415
return Math.max(1, Math.round(xBuckets * (STACKED_GRAPH_HEIGHT / chartContainerWidth)));
407416
}
408417

418+
/**
419+
* Computes the X-axis bucket interval for the heatmap API.
420+
* The X-axis bucket interval is derived from the container width and the number of
421+
* pixels per X bucket.
422+
*/
423+
function getHeatmapXBucketInterval(
424+
selection: PageFilters,
425+
interval: string,
426+
chartContainerWidth: number,
427+
intervalOptions: Array<{label: string; value: string}>
428+
): string {
429+
const timeRangeInMs = getDiffInMinutes(selection.datetime) * 60 * 1000;
430+
const msPerXBucket = Math.round(
431+
timeRangeInMs / (chartContainerWidth / PIXELS_PER_X_BUCKET)
432+
);
433+
const xBucketInterval = millisecondsToClosestInterval(
434+
msPerXBucket,
435+
intervalOptions.map(option => option.value)
436+
);
437+
return xBucketInterval || interval;
438+
}
439+
409440
/**
410441
* The heatmap API response doesn't include the metric unit because the
411442
* query uses the generic `value` field. This function patches the Y-axis

0 commit comments

Comments
 (0)