Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 size, in pixels, of a single heat map bucket along each axis. Both the
* X-axis (time) interval and the Y-axis bucket count are chosen so that cells
* are roughly this size, keeping them approximately square.
*/
export const PIXELS_PER_BUCKET = 10;

/**
* 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,40 @@
import type {PageFilters} from 'sentry/types/core';
import {getIntervalOptionsForPageFilter} from 'sentry/utils/useChartInterval';
import {getHeatmapXAxisBucketInterval} from 'sentry/views/dashboards/widgets/heatMapWidget/utils/getHeatmapXAxisBucketInterval';

function makeSelection(period: string): PageFilters {
return {
projects: [],
environments: [],
datetime: {period, start: null, end: null, utc: null},
};
}

function optionValues(period: string) {
return getIntervalOptionsForPageFilter(makeSelection(period).datetime).map(
option => option.value
);
}

describe('getHeatmapXAxisBucketInterval()', () => {
it('falls back to the largest available interval when the width is 0', () => {
const options = optionValues('24h');
expect(getHeatmapXAxisBucketInterval(makeSelection('24h'), 0)).toBe(
options[options.length - 1]
);
});

it('picks a larger interval as the container gets narrower', () => {
const options = optionValues('24h');
const wide = getHeatmapXAxisBucketInterval(makeSelection('24h'), 1200);
const narrow = getHeatmapXAxisBucketInterval(makeSelection('24h'), 200);
// A narrower container fits fewer columns, so each bucket spans more time.
expect(options.indexOf(narrow)).toBeGreaterThanOrEqual(options.indexOf(wide));
});

it('only returns intervals available for the selection', () => {
const options = optionValues('24h');
const result = getHeatmapXAxisBucketInterval(makeSelection('24h'), 724);
expect(options).toContain(result);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {getDiffInMinutes} from 'sentry/components/charts/utils';
import type {PageFilters} from 'sentry/types/core';
import {millisecondsToClosestInterval} from 'sentry/utils/duration/millisecondsToInterval';
import {getIntervalOptionsForPageFilter} from 'sentry/utils/useChartInterval';
import {PIXELS_PER_BUCKET} from 'sentry/views/dashboards/widgets/heatMapWidget/settings';

/**
* Computes the X-axis (time) bucket interval for the heatmap API. We target
* ~`PIXELS_PER_BUCKET`-wide columns for the given width and snap that to the
* closest interval the current date range supports. Falls back to the largest
* available interval (e.g. before the container has been measured).
*/
export function getHeatmapXAxisBucketInterval(
selection: PageFilters,
chartContainerWidth: number
): string {
const intervalOptions = getIntervalOptionsForPageFilter(selection.datetime).map(
option => option.value
);
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
const timeRangeInMs = getDiffInMinutes(selection.datetime) * 60 * 1000;
const msPerXBucket = Math.round(
timeRangeInMs / (chartContainerWidth / PIXELS_PER_BUCKET)
);

return (
millisecondsToClosestInterval(msPerXBucket, intervalOptions) ??
intervalOptions[intervalOptions.length - 1] ??
''
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {getHeatmapYAxisBucketCount} from 'sentry/views/dashboards/widgets/heatMapWidget/utils/getHeatmapYAxisBucketCount';

describe('getHeatmapYAxisBucketCount()', () => {
it('returns 0 when the container has no height', () => {
expect(getHeatmapYAxisBucketCount(0)).toBe(0);
});

it('divides the container height by the target bucket size', () => {
// 360 / 10 = 36
expect(getHeatmapYAxisBucketCount(360)).toBe(36);
});

it('never returns fewer than 1 bucket when there is height', () => {
expect(getHeatmapYAxisBucketCount(5)).toBe(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {PIXELS_PER_BUCKET} from 'sentry/views/dashboards/widgets/heatMapWidget/settings';

/**
* Computes the number of Y-axis buckets for the heatmap API. The X-axis
* interval already targets ~`PIXELS_PER_BUCKET`-wide columns, so we size the
* Y axis the same way — dividing the container height by that target — to keep
* cells roughly square.
*/
export function getHeatmapYAxisBucketCount(chartContainerHeight: number): number {
if (chartContainerHeight <= 0) {
return 0;
}

return Math.max(1, Math.round(chartContainerHeight / PIXELS_PER_BUCKET));
}
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: 6 additions & 92 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 @@ -56,10 +53,7 @@ import {
} 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 +68,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 +171,8 @@ export function MetricPanel({

const chartContainerRef = useRef<HTMLDivElement>(null);
const {width: chartContainerWidth} = useDimensions({elementRef: chartContainerRef});
const xBucketInterval = getHeatmapXBucketInterval(
selection,
interval,
chartContainerWidth,
intervalOptions
);
const yBuckets = getHeatmapYBuckets(selection, xBucketInterval, chartContainerWidth);
const xBucketInterval = getHeatmapXAxisBucketInterval(selection, chartContainerWidth);
const yBuckets = getHeatmapYAxisBucketCount(STACKED_GRAPH_HEIGHT);
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

const heatmapApiOptions = metricHeatmapApiOptions({
traceMetric,
Expand Down Expand Up @@ -390,77 +378,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