Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions static/app/views/dashboards/widgets/heatMapWidget/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,16 @@ export const HEATMAP_COLORS = [
'#921178',
'#990056',
] as const;

/**
* Target width, in pixels, of a single heat map X-axis (time) bucket. The
* interval is chosen so that columns are roughly this wide for the rendered
* container width.
*/
export const PIXELS_PER_X_BUCKET = 15;

/**
* Scale used for the heat map's Z axis (the cell color). A logarithmic scale
* handles the wide range of counts better than a linear one.
*/
export const HEATMAP_Z_AXIS_SCALE = 'log' as const;
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {PageFiltersFixture} from 'sentry-fixture/pageFilters';

import {getHeatmapXAxisBucketInterval} from 'sentry/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval';

function makeSelection(period: string) {
return PageFiltersFixture({datetime: {period, start: null, end: null, utc: null}});
}

const INTERVAL_OPTIONS = [
{value: '1m', label: '1 minute'},
{value: '5m', label: '5 minutes'},
{value: '1h', label: '1 hour'},
{value: '12h', label: '12 hours'},
{value: '1d', label: '1 day'},
];

describe('getHeatmapXAxisBucketInterval()', () => {
it('snaps to a wider interval as the container gets narrower', () => {
const values = INTERVAL_OPTIONS.map(option => option.value);
const wide = getHeatmapXAxisBucketInterval(
makeSelection('24h'),
'1h',
1200,
INTERVAL_OPTIONS
);
const narrow = getHeatmapXAxisBucketInterval(
makeSelection('24h'),
'1h',
200,
INTERVAL_OPTIONS
);
// A narrower container fits fewer columns, so each bucket spans more time.
expect(values.indexOf(narrow)).toBeGreaterThanOrEqual(values.indexOf(wide));
});

it('only returns one of the provided interval options', () => {
const result = getHeatmapXAxisBucketInterval(
makeSelection('24h'),
'1h',
724,
INTERVAL_OPTIONS
);
expect(INTERVAL_OPTIONS.map(option => option.value)).toContain(result);
});

it('falls back to the given interval before the container has been measured', () => {
// Width 0 yields no finite bucket size, so there is nothing to snap to and
// the currently selected interval is kept.
expect(
getHeatmapXAxisBucketInterval(makeSelection('24h'), '1h', 0, INTERVAL_OPTIONS)
).toBe('1h');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {getDiffInMinutes} from 'sentry/components/charts/utils';
import type {PageFilters} from 'sentry/types/core';
import {millisecondsToClosestInterval} from 'sentry/utils/duration/millisecondsToInterval';
import {PIXELS_PER_X_BUCKET} from 'sentry/views/dashboards/widgets/heatMapWidget/settings';

/**
* Computes the X-axis bucket interval for the heatmap API.
* The X-axis bucket interval is derived from the container width and the number of
* pixels per X bucket.
*/
export function getHeatmapXAxisBucketInterval(
selection: PageFilters,
interval: string,
chartContainerWidth: number,
intervalOptions: Array<{label: string; value: string}>
): string {
const timeRangeInMs = getDiffInMinutes(selection.datetime) * 60 * 1000;
const msPerXBucket = Math.round(
timeRangeInMs / (chartContainerWidth / PIXELS_PER_X_BUCKET)
);
const xBucketInterval = millisecondsToClosestInterval(
msPerXBucket,
intervalOptions.map(option => option.value)
);
return xBucketInterval || interval;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {PageFiltersFixture} from 'sentry-fixture/pageFilters';

import {getHeatmapYAxisBucketCount} from 'sentry/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount';

function makeSelection(period: string) {
return PageFiltersFixture({datetime: {period, start: null, end: null, utc: null}});
}

describe('getHeatmapYAxisBucketCount()', () => {
it('returns 0 before the container has been measured', () => {
expect(getHeatmapYAxisBucketCount(makeSelection('24h'), '1h', 0)).toBe(0);
});

it('returns 0 for a non-positive interval', () => {
expect(getHeatmapYAxisBucketCount(makeSelection('24h'), '0', 800)).toBe(0);
});

it('returns a positive bucket count once the container is measured', () => {
expect(
getHeatmapYAxisBucketCount(makeSelection('24h'), '1h', 800)
).toBeGreaterThanOrEqual(1);
});

it('fits fewer Y buckets into a wider container', () => {
const narrow = getHeatmapYAxisBucketCount(makeSelection('24h'), '1h', 400);
const wide = getHeatmapYAxisBucketCount(makeSelection('24h'), '1h', 1600);
expect(wide).toBeLessThanOrEqual(narrow);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {getDiffInMinutes} from 'sentry/components/charts/utils';
import type {PageFilters} from 'sentry/types/core';
import {intervalToMilliseconds} from 'sentry/utils/duration/intervalToMilliseconds';
import {STACKED_GRAPH_HEIGHT} from 'sentry/views/explore/metrics/settings';
Comment thread
gggritso marked this conversation as resolved.

/**
* Computes the number of Y-axis buckets for the heatmap API so that cells
* are roughly square. The X-axis bucket count comes from the time range
* divided by the selected interval. We derive Y buckets by scaling
* xBuckets by the container's height/width aspect ratio.
*/
export function getHeatmapYAxisBucketCount(
selection: PageFilters,
interval: string,
chartContainerWidth: number
): number {
const timeRangeInMs = getDiffInMinutes(selection.datetime) * 60 * 1000;
const intervalInMs = intervalToMilliseconds(interval);
if (intervalInMs <= 0 || chartContainerWidth <= 0) {
return 0;
}
const xBuckets = Math.round(timeRangeInMs / intervalInMs);
if (xBuckets <= 0) {
return 0;
}

return Math.max(1, Math.round(xBuckets * (STACKED_GRAPH_HEIGHT / chartContainerWidth)));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type {DataUnit} from 'sentry/utils/discover/fields';
import type {HeatMapSeries} from 'sentry/views/dashboards/widgets/common/types';
import {mapMetricUnitToFieldType} from 'sentry/views/explore/metrics/utils';

/**
* The heatmap API returns the Y axis using the generic `value` field, so the
* response meta carries no metric unit. Patch the Y axis with the selected
* metric's unit/type so the chart formats values correctly (e.g. durations,
* sizes) instead of rendering raw numbers.
*
* Ideally the backend would return the metric's unit in the response meta and
* this client-side patch wouldn't be needed.
*/
export function mergeMetricUnit(
series: HeatMapSeries,
metricUnit: string | undefined
): HeatMapSeries {
const {fieldType, unit} = mapMetricUnitToFieldType(metricUnit);
if (!unit) {
return series;
}
return {
...series,
meta: {
...series.meta,
yAxis: {
...series.meta.yAxis,
valueType: fieldType,
valueUnit: unit as DataUnit,
},
},
};
}
98 changes: 10 additions & 88 deletions static/app/views/explore/metrics/metricPanel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,21 @@ import {Container, Grid, Stack} from '@sentry/scraps/layout';
import {OverlayTrigger} from '@sentry/scraps/overlayTrigger';
import {Text} from '@sentry/scraps/text';

import {getDiffInMinutes} from 'sentry/components/charts/utils';
import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters';
import {Panel} from 'sentry/components/panels/panel';
import {PanelBody} from 'sentry/components/panels/panelBody';
import {Placeholder} from 'sentry/components/placeholder';
import {IconClock, IconGraph} from 'sentry/icons';
import {t} from 'sentry/locale';
import type {PageFilters} from 'sentry/types/core';
import type {DataUnit} from 'sentry/utils/discover/fields';
import {intervalToMilliseconds} from 'sentry/utils/duration/intervalToMilliseconds';
import {millisecondsToClosestInterval} from 'sentry/utils/duration/millisecondsToInterval';
import {
ChartIntervalUnspecifiedStrategy,
useChartInterval,
} from 'sentry/utils/useChartInterval';
import {useDimensions} from 'sentry/utils/useDimensions';
import {useOrganization} from 'sentry/utils/useOrganization';
import type {HeatMapSeries} from 'sentry/views/dashboards/widgets/common/types';
import {getHeatmapXAxisBucketInterval} from 'sentry/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval';
import {getHeatmapYAxisBucketCount} from 'sentry/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount';
import {mergeMetricUnit} from 'sentry/views/dashboards/widgets/heatMapWidget/utils/mergeMetricUnit';
import {EXPLORE_FIVE_MIN_STALE_TIME} from 'sentry/views/explore/constants';
import {useMetricsPanelAnalytics} from 'sentry/views/explore/hooks/useAnalytics';
import {useMetricOptions} from 'sentry/views/explore/hooks/useMetricOptions';
Expand Down Expand Up @@ -55,11 +52,7 @@ import {
useSetMetricVisualizes,
} from 'sentry/views/explore/metrics/metricsQueryParams';
import {MetricToolbar} from 'sentry/views/explore/metrics/metricToolbar';
import {STACKED_GRAPH_HEIGHT} from 'sentry/views/explore/metrics/settings';
import {
mapMetricUnitToFieldType,
updateVisualizeYAxis,
} from 'sentry/views/explore/metrics/utils';
import {updateVisualizeYAxis} from 'sentry/views/explore/metrics/utils';
import {
useQueryParamsAggregateSortBys,
useQueryParamsMode,
Expand All @@ -74,7 +67,6 @@ import {ChartType} from 'sentry/views/insights/common/components/chart';

const RESULT_LIMIT = 50;
const TWO_MINUTE_DELAY = 120;
const PIXELS_PER_X_BUCKET = 15;

const CHART_TYPE_TO_ICON: Record<ChartType, 'line' | 'area' | 'bar' | 'heatmap'> = {
[ChartType.LINE]: 'line',
Expand Down Expand Up @@ -178,13 +170,17 @@ export function MetricPanel({

const chartContainerRef = useRef<HTMLDivElement>(null);
const {width: chartContainerWidth} = useDimensions({elementRef: chartContainerRef});
const xBucketInterval = getHeatmapXBucketInterval(
const xBucketInterval = getHeatmapXAxisBucketInterval(
selection,
interval,
chartContainerWidth,
intervalOptions
);
const yBuckets = getHeatmapYBuckets(selection, xBucketInterval, chartContainerWidth);
const yBuckets = getHeatmapYAxisBucketCount(
selection,
xBucketInterval,
chartContainerWidth
);

const heatmapApiOptions = metricHeatmapApiOptions({
traceMetric,
Expand Down Expand Up @@ -390,77 +386,3 @@ function DnDPlaceholder({
</Container>
);
}

/**
* Computes the number of Y-axis buckets for the heatmap API so that cells
* are roughly square. The X-axis bucket count comes from the time range
* divided by the selected interval. We derive Y buckets by scaling
* xBuckets by the container's height/width aspect ratio.
*/
function getHeatmapYBuckets(
selection: PageFilters,
interval: string,
chartContainerWidth: number
): number {
const timeRangeInMs = getDiffInMinutes(selection.datetime) * 60 * 1000;
const intervalInMs = intervalToMilliseconds(interval);
if (intervalInMs <= 0 || chartContainerWidth <= 0) {
return 0;
}
const xBuckets = Math.round(timeRangeInMs / intervalInMs);
if (xBuckets <= 0) {
return 0;
}

return Math.max(1, Math.round(xBuckets * (STACKED_GRAPH_HEIGHT / chartContainerWidth)));
}

/**
* Computes the X-axis bucket interval for the heatmap API.
* The X-axis bucket interval is derived from the container width and the number of
* pixels per X bucket.
*/
function getHeatmapXBucketInterval(
selection: PageFilters,
interval: string,
chartContainerWidth: number,
intervalOptions: Array<{label: string; value: string}>
): string {
const timeRangeInMs = getDiffInMinutes(selection.datetime) * 60 * 1000;
const msPerXBucket = Math.round(
timeRangeInMs / (chartContainerWidth / PIXELS_PER_X_BUCKET)
);
const xBucketInterval = millisecondsToClosestInterval(
msPerXBucket,
intervalOptions.map(option => option.value)
);
return xBucketInterval || interval;
}

/**
* The heatmap API response doesn't include the metric unit because the
* query uses the generic `value` field. This function patches the Y-axis
* meta with the known unit from the selected trace metric so the
* visualization can format axis labels correctly (e.g. "1.5 KB" instead
* of "1500").
*/
function mergeMetricUnit(
series: HeatMapSeries,
metricUnit: string | undefined
): HeatMapSeries {
const {fieldType, unit} = mapMetricUnitToFieldType(metricUnit);
if (!unit) {
return series;
}
return {
...series,
meta: {
...series.meta,
yAxis: {
...series.meta.yAxis,
valueType: fieldType,
valueUnit: unit as DataUnit,
},
},
};
}
3 changes: 2 additions & 1 deletion static/app/views/explore/metrics/metricsHeatMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {HeatMapSeries} from 'sentry/views/dashboards/widgets/common/types';
import {WidgetLoadingPanel} from 'sentry/views/dashboards/widgets/common/widgetLoadingPanel';
import {HeatMapWidgetVisualization} from 'sentry/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization';
import {HeatMap} from 'sentry/views/dashboards/widgets/heatMapWidget/plottables/heatMap';
import {HEATMAP_Z_AXIS_SCALE} from 'sentry/views/dashboards/widgets/heatMapWidget/settings';
import {Widget} from 'sentry/views/dashboards/widgets/widget/widget';
import {WidgetWrapper} from 'sentry/views/explore/metrics/metricGraph/styles';
import {
Expand Down Expand Up @@ -88,7 +89,7 @@ export function MetricsHeatMap({heatmapResult, actions, title}: MetricsHeatMapPr
) : (
<HeatMapWidgetVisualization
plottables={[new HeatMap(heatMapSeries)]}
scale="log"
scale={HEATMAP_Z_AXIS_SCALE}
makeExploreUrl={getFilteredExploreUrl}
updateLocalFilterQuery={updateMetricQuery}
/>
Expand Down
Loading