From 138cfd3a90228caa2f9abd17db2f51e7d3e418d0 Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Thu, 14 May 2026 17:08:04 -0400 Subject: [PATCH 01/10] feat(explore): Add Heat Map widget to Explore metrics Adds a Heat Map visualization option to the Explore metrics tab, gated behind the `organizations:data-browsing-heat-map-widget` feature flag. - Add `ChartType.HEATMAP` to the chart type enum - Create `MetricsHeatMap` component using the existing `HeatMapWidgetVisualization` from dashboards widgets - Create `metricHeatmapApiOptions` for fetching heatmap data from the `/events-heatmap/` endpoint - Extract shared chart actions (type selector, interval) to `MetricPanel` so both `MetricsGraph` and `MetricsHeatMap` can use them - Disable aggregate and group-by dropdowns when heatmap is selected via `disabledReason` prop pattern - Filter heatmaps from dashboard widget export - Extract graph height constants to `settings.ts` Ref: DAIN-1635 --- knip.config.ts | 2 - .../heatMapWidget/plottables/heatMap.tsx | 2 +- .../contexts/pageParamsContext/visualizes.tsx | 1 + .../views/explore/hooks/useAddToDashboard.tsx | 5 +- .../metrics/hooks/metricHeatmapApiOptions.tsx | 65 +++++++++ .../explore/metrics/metricGraph/index.tsx | 107 +++++---------- .../explore/metrics/metricPanel/index.tsx | 126 ++++++++++++++++-- .../metricToolbar/aggregateDropdown.tsx | 116 ++++++++-------- .../metrics/metricToolbar/groupBySelector.tsx | 45 ++++--- .../explore/metrics/metricToolbar/index.tsx | 15 ++- .../views/explore/metrics/metricsFlags.tsx | 7 + .../views/explore/metrics/metricsHeatMap.tsx | 64 +++++++++ static/app/views/explore/metrics/settings.ts | 2 + .../explore/metrics/useSaveAsMetricItems.tsx | 15 ++- .../insights/common/components/chart.tsx | 1 + 15 files changed, 403 insertions(+), 170 deletions(-) create mode 100644 static/app/views/explore/metrics/hooks/metricHeatmapApiOptions.tsx create mode 100644 static/app/views/explore/metrics/metricsHeatMap.tsx create mode 100644 static/app/views/explore/metrics/settings.ts diff --git a/knip.config.ts b/knip.config.ts index 4e8539b31b37..b06dd59a3e91 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -22,8 +22,6 @@ const productionEntryPoints = [ 'static/app/chartcuterie/**/*.{js,ts,tsx}', // TODO: Remove when used 'static/app/views/seerExplorer/contexts/**/*.{js,ts,tsx}', - // TODO: Remove when integration into Explore has started - 'static/app/views/dashboards/widgets/heatMapWidget/**/*.{ts,tsx}', // TODO: Remove when used 'static/app/views/settings/organizationRepositories/connectProviderDropdown.tsx', 'static/app/views/settings/organizationRepositories/noIntegrationsEmptyState.tsx', diff --git a/static/app/views/dashboards/widgets/heatMapWidget/plottables/heatMap.tsx b/static/app/views/dashboards/widgets/heatMapWidget/plottables/heatMap.tsx index e5404affeb04..07c71d97c080 100644 --- a/static/app/views/dashboards/widgets/heatMapWidget/plottables/heatMap.tsx +++ b/static/app/views/dashboards/widgets/heatMapWidget/plottables/heatMap.tsx @@ -11,7 +11,7 @@ import {FALLBACK_TYPE} from 'sentry/views/dashboards/widgets/timeSeriesWidget/se import type {HeatMapPlottable, PlottableTimeSeriesValueType} from './heatMapPlottable'; -export type HeatMapPlottingOptions = { +type HeatMapPlottingOptions = { theme: Theme; /** * The Z-axis scale type. `'log'` applies `Math.log1p` to Z values so the diff --git a/static/app/views/explore/contexts/pageParamsContext/visualizes.tsx b/static/app/views/explore/contexts/pageParamsContext/visualizes.tsx index 8906951a5ff2..7a8f3bb099f2 100644 --- a/static/app/views/explore/contexts/pageParamsContext/visualizes.tsx +++ b/static/app/views/explore/contexts/pageParamsContext/visualizes.tsx @@ -174,6 +174,7 @@ export function determineDefaultChartType(yAxes: readonly string[]): ChartType { [ChartType.BAR]: 0, [ChartType.LINE]: 0, [ChartType.AREA]: 0, + [ChartType.HEATMAP]: 0, }; for (const yAxis of yAxes) { diff --git a/static/app/views/explore/hooks/useAddToDashboard.tsx b/static/app/views/explore/hooks/useAddToDashboard.tsx index 4f84d97e0bd9..274763f2e2cb 100644 --- a/static/app/views/explore/hooks/useAddToDashboard.tsx +++ b/static/app/views/explore/hooks/useAddToDashboard.tsx @@ -26,10 +26,13 @@ import { import {useSpansDataset} from 'sentry/views/explore/spans/spansQueryParams'; import {ChartType} from 'sentry/views/insights/common/components/chart'; -export const CHART_TYPE_TO_DISPLAY_TYPE = { +export const CHART_TYPE_TO_DISPLAY_TYPE: Record = { [ChartType.LINE]: DisplayType.LINE, [ChartType.BAR]: DisplayType.BAR, [ChartType.AREA]: DisplayType.AREA, + // Heatmaps are filtered out before reaching dashboard code, but the + // mapping must be exhaustive because other consumers index by ChartType. + [ChartType.HEATMAP]: DisplayType.LINE, }; export function useAddToDashboard() { diff --git a/static/app/views/explore/metrics/hooks/metricHeatmapApiOptions.tsx b/static/app/views/explore/metrics/hooks/metricHeatmapApiOptions.tsx new file mode 100644 index 000000000000..31574bbc3ae1 --- /dev/null +++ b/static/app/views/explore/metrics/hooks/metricHeatmapApiOptions.tsx @@ -0,0 +1,65 @@ +import {skipToken} from '@tanstack/react-query'; + +import {normalizeDateTimeParams} from 'sentry/components/pageFilters/parse'; +import type {PageFilters} from 'sentry/types/core'; +import type {Organization} from 'sentry/types/organization'; +import {defined} from 'sentry/utils'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {DiscoverDatasets} from 'sentry/utils/discover/types'; +import {intervalToMilliseconds} from 'sentry/utils/duration/intervalToMilliseconds'; +import type {HeatMapSeries} from 'sentry/views/dashboards/widgets/common/types'; +import type {TraceMetric} from 'sentry/views/explore/metrics/metricQuery'; +import {createTraceMetricEventsFilter} from 'sentry/views/explore/metrics/utils'; + +interface MetricHeatmapApiOptions { + enabled: boolean; + interval: string; + organization: Organization; + query: string; + selection: PageFilters; + traceMetric: TraceMetric; + yBuckets: number; +} + +export function metricHeatmapApiOptions({ + traceMetric, + enabled, + organization, + selection, + query, + interval, + yBuckets, +}: MetricHeatmapApiOptions) { + const traceMetricFilter = createTraceMetricEventsFilter([traceMetric]); + const combinedQuery = query ? `${traceMetricFilter} ${query}` : traceMetricFilter; + + const intervalInMilliseconds = intervalToMilliseconds(interval); + const {start, end, period} = normalizeDateTimeParams(selection.datetime); + const usesRelativeDateRange = !defined(start) && !defined(end) && defined(period); + + return apiOptions.as()( + '/organizations/$organizationIdOrSlug/events-heatmap/', + { + path: enabled ? {organizationIdOrSlug: organization.slug} : skipToken, + query: { + dataset: DiscoverDatasets.TRACEMETRICS, + xAxis: 'time', + yAxis: 'value', + zAxis: 'count()', + yBuckets, + interval, + query: combinedQuery, + project: selection.projects, + environment: selection.environments, + start, + end, + ...(period ? {statsPeriod: period} : {}), + referrer: 'api.explore.tracemetrics-heatmap', + }, + staleTime: + usesRelativeDateRange && intervalInMilliseconds !== 0 + ? intervalInMilliseconds + : Infinity, + } + ); +} diff --git a/static/app/views/explore/metrics/metricGraph/index.tsx b/static/app/views/explore/metrics/metricGraph/index.tsx index f1996f1b26dc..6fcfe5366ab1 100644 --- a/static/app/views/explore/metrics/metricGraph/index.tsx +++ b/static/app/views/explore/metrics/metricGraph/index.tsx @@ -1,26 +1,23 @@ -import {Fragment, useMemo} from 'react'; +import {useMemo} from 'react'; -import {CompactSelect} from '@sentry/scraps/compactSelect'; import {ExternalLink} from '@sentry/scraps/link'; -import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; -import {IconClock, IconGraph} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; +import type {Organization} from 'sentry/types/organization'; import {defined} from 'sentry/utils'; import {parseFunction} from 'sentry/utils/discover/fields'; import {DiscoverDatasets} from 'sentry/utils/discover/types'; -import {useChartInterval} from 'sentry/utils/useChartInterval'; import {determineSeriesSampleCountAndIsSampled} from 'sentry/views/alerts/rules/metric/utils/determineSeriesSampleCount'; import {formatTimeSeriesLabel} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesLabel'; import {Widget} from 'sentry/views/dashboards/widgets/widget/widget'; import {ChartVisualization} from 'sentry/views/explore/components/chart/chartVisualization'; import {ConfidenceFooter} from 'sentry/views/explore/metrics/confidenceFooter'; +import {canUseMetricsHeatMap} from 'sentry/views/explore/metrics/metricsFlags'; import { useMetricLabel, useMetricName, useMetricVisualize, useMetricVisualizes, - useSetMetricVisualizes, useTraceMetric, } from 'sentry/views/explore/metrics/metricsQueryParams'; import {METRICS_CHART_GROUP} from 'sentry/views/explore/metrics/metricsTab'; @@ -49,26 +46,37 @@ import {GenericWidgetEmptyStateWarning} from 'sentry/views/performance/landing/w import {WidgetWrapper} from './styles'; -const MINIMIZED_GRAPH_HEIGHT = 50; -const STACKED_GRAPH_HEIGHT = 362; +export function getMetricsChartTypeOptions(organization: Organization) { + if (canUseMetricsHeatMap(organization)) { + return [ + ...EXPLORE_CHART_TYPE_OPTIONS, + {value: ChartType.HEATMAP, label: t('Heat Map')}, + ]; + } + return EXPLORE_CHART_TYPE_OPTIONS; +} + +import { + MINIMIZED_GRAPH_HEIGHT, + STACKED_GRAPH_HEIGHT, +} from 'sentry/views/explore/metrics/settings'; interface MetricsGraphProps { + actions: React.ReactNode; timeseriesResult: ReturnType; - additionalActions?: React.ReactNode; isMetricOptionsEmpty?: boolean; title?: string; } export function MetricsGraph({ timeseriesResult, - additionalActions, + actions, isMetricOptionsEmpty, title, }: MetricsGraphProps) { const metricQueries = useMultiMetricsQueryParams(); const visualize = useMetricVisualize(); const visualizes = useMetricVisualizes(); - const setVisualizes = useSetMetricVisualizes(); useSynchronizeCharts( metricQueries.length, @@ -76,35 +84,32 @@ export function MetricsGraph({ METRICS_CHART_GROUP ); - function handleChartTypeChange(newChartType: ChartType) { - setVisualizes(visualizes.map(v => v.replace({chartType: newChartType}))); - } - return ( ); } -interface GraphProps extends MetricsGraphProps { - onChartTypeChange: (chartType: ChartType) => void; +interface GraphProps { + actions: React.ReactNode; + timeseriesResult: ReturnType; visualize: ReturnType; visualizes: ReturnType; + isMetricOptionsEmpty?: boolean; + title?: string; } function Graph({ - onChartTypeChange, timeseriesResult, visualize, visualizes, - additionalActions, + actions, isMetricOptionsEmpty, title, }: GraphProps) { @@ -113,7 +118,6 @@ function Graph({ const metricLabel = useMetricLabel(); const metricName = useMetricName(); const userQuery = useQueryParamsQuery(); - const [interval, setInterval, intervalOptions] = useChartInterval(); const traceMetric = useTraceMetric(); const rawMetricCounts = useRawCounts({ dataset: DiscoverDatasets.TRACEMETRICS, @@ -181,57 +185,6 @@ function Graph({ return title ?? metricLabel ?? prettifyAggregation(aggregate) ?? aggregate; }, [aggregate, metricLabel, metricName, visualizes.length, title]); - const Title = ; - - const chartIcon = - visualize.chartType === ChartType.LINE - ? 'line' - : visualize.chartType === ChartType.AREA - ? 'area' - : 'bar'; - - const Actions = ( - - ( - } - variant="transparent" - showChevron={false} - size="xs" - /> - )} - value={visualize.chartType} - menuTitle="Type" - options={EXPLORE_CHART_TYPE_OPTIONS} - onChange={option => onChartTypeChange(option.value)} - /> - setInterval(value)} - trigger={triggerProps => ( - } - variant="transparent" - showChevron={false} - size="xs" - /> - )} - menuTitle="Interval" - options={intervalOptions} - /> - {additionalActions} - - ); - const showEmptyState = isMetricOptionsEmpty && visualize.visible; const showChart = visualize.visible && !isMetricOptionsEmpty; @@ -240,8 +193,8 @@ function Graph({ return ( } + Actions={actions} Visualization={ showEmptyState ? ( - ) + ) : undefined } height={height} revealActions="always" diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index 16feb2cddc11..3b1986f358b1 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -1,15 +1,23 @@ import {Activity, Fragment, useRef, useState} from 'react'; import type {DraggableAttributes} from '@dnd-kit/core'; import type {SyntheticListenerMap} from '@dnd-kit/core/dist/hooks/utilities'; +import {useQuery} from '@tanstack/react-query'; +import {CompactSelect} from '@sentry/scraps/compactSelect'; 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 {intervalToMilliseconds} from 'sentry/utils/duration/intervalToMilliseconds'; import {useChartInterval} from 'sentry/utils/useChartInterval'; +import {useOrganization} from 'sentry/utils/useOrganization'; 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'; @@ -19,27 +27,46 @@ import { TraceSamplesTableColumns, } from 'sentry/views/explore/metrics/constants'; import {unresolveExpression} from 'sentry/views/explore/metrics/equationBuilder/utils'; +import {metricHeatmapApiOptions} from 'sentry/views/explore/metrics/hooks/metricHeatmapApiOptions'; import {useMetricAggregatesTable} from 'sentry/views/explore/metrics/hooks/useMetricAggregatesTable'; import {useMetricSamplesTable} from 'sentry/views/explore/metrics/hooks/useMetricSamplesTable'; import {useMetricTimeseries} from 'sentry/views/explore/metrics/hooks/useMetricTimeseries'; -import {MetricsGraph} from 'sentry/views/explore/metrics/metricGraph'; +import { + MetricsGraph, + getMetricsChartTypeOptions, +} from 'sentry/views/explore/metrics/metricGraph'; import {MetricInfoTabs} from 'sentry/views/explore/metrics/metricInfoTabs'; import {type TraceMetric} from 'sentry/views/explore/metrics/metricQuery'; -import {useMetricVisualize} from 'sentry/views/explore/metrics/metricsQueryParams'; +import {canUseMetricsHeatMap} from 'sentry/views/explore/metrics/metricsFlags'; +import {MetricsHeatMap} from 'sentry/views/explore/metrics/metricsHeatMap'; +import { + useMetricVisualize, + useMetricVisualizes, + useSetMetricVisualizes, +} from 'sentry/views/explore/metrics/metricsQueryParams'; import {MetricToolbar} from 'sentry/views/explore/metrics/metricToolbar'; import { useQueryParamsAggregateSortBys, useQueryParamsMode, + useQueryParamsQuery, useQueryParamsSortBys, } from 'sentry/views/explore/queryParams/context'; import { isVisualizeEquation, isVisualizeFunction, } from 'sentry/views/explore/queryParams/visualize'; +import {ChartType} from 'sentry/views/insights/common/components/chart'; const RESULT_LIMIT = 50; const TWO_MINUTE_DELAY = 120; +const CHART_TYPE_TO_ICON: Record = { + [ChartType.LINE]: 'line', + [ChartType.AREA]: 'area', + [ChartType.BAR]: 'bar', + [ChartType.HEATMAP]: 'scatter', +}; + interface MetricPanelProps extends React.HTMLAttributes { queryIndex: number; queryLabel: string; @@ -69,6 +96,9 @@ export function MetricPanel({ onEquationLabelsChange, ...rest }: MetricPanelProps) { + const organization = useOrganization(); + const {selection} = usePageFilters(); + const userQuery = useQueryParamsQuery(); const {isMetricOptionsEmpty} = useMetricOptions({enabled: Boolean(traceMetric.name)}); const fields = getTraceSamplesTableFields(TraceSamplesTableColumns); @@ -76,9 +106,11 @@ export function MetricPanel({ const mode = useQueryParamsMode(); const sortBys = useQueryParamsSortBys(); const aggregateSortBys = useQueryParamsAggregateSortBys(); - const [interval] = useChartInterval(); + const [interval, setInterval, intervalOptions] = useChartInterval(); const topEvents = useTopEvents(); const visualize = useMetricVisualize(); + const visualizes = useMetricVisualizes(); + const setVisualizes = useSetMetricVisualizes(); const [title, setTitle] = useState(() => { if (isVisualizeEquation(visualize)) { @@ -109,13 +141,33 @@ export function MetricPanel({ staleTime: Infinity, }); + const isHeatmap = visualize.chartType === ChartType.HEATMAP; + const hasHeatMap = canUseMetricsHeatMap(organization); + const {result: timeseriesResult} = useMetricTimeseries({ traceMetric, enabled: - !isMetricOptionsEmpty || - (isVisualizeEquation(visualize) && Boolean(visualize.expression.text)), + !isHeatmap && + (!isMetricOptionsEmpty || + (isVisualizeEquation(visualize) && Boolean(visualize.expression.text))), }); + const timeRangeInMs = getDiffInMinutes(selection.datetime) * 60 * 1000; + const intervalInMs = intervalToMilliseconds(interval); + const yBuckets = intervalInMs > 0 ? Math.round(timeRangeInMs / intervalInMs) : 0; + + const heatmapResult = useQuery( + metricHeatmapApiOptions({ + traceMetric, + enabled: hasHeatMap && isHeatmap && !isMetricOptionsEmpty, + organization, + selection, + query: userQuery, + interval, + yBuckets, + }) + ); + useMetricsPanelAnalytics({ interval, isTopN: !!topEvents, @@ -129,6 +181,51 @@ export function MetricPanel({ panelIndex: queryIndex, }); + function handleChartTypeChange(newChartType: ChartType) { + setVisualizes(visualizes.map(v => v.replace({chartType: newChartType}))); + } + + const actions = ( + + ( + } + variant="transparent" + showChevron={false} + size="xs" + /> + )} + value={visualize.chartType} + menuTitle="Type" + options={getMetricsChartTypeOptions(organization)} + onChange={option => handleChartTypeChange(option.value)} + /> + setInterval(value)} + trigger={triggerProps => ( + } + variant="transparent" + showChevron={false} + size="xs" + /> + )} + menuTitle="Interval" + options={intervalOptions} + /> + + ); + const contentHeightRef = useRef(null); return ( @@ -165,11 +262,20 @@ export function MetricPanel({ > - + {hasHeatMap && isHeatmap ? ( + + ) : ( + + )} handleChange([])} /> - } - style={{width: '100%'}} - trigger={triggerProps => ( - - {selectedList.length === 0 ? ( - {t('None')} - ) : ( - - {selectedList[0]} - {selectedList.length > 1 && ( - - {`+${selectedList.length - 1}`} - - )} - - )} - - )} - > - {groups.map(group => { - const groupKey = String(group.key); - const isMulti = !singleSelect && MULTI_SELECT_GROUP_KEYS.has(groupKey); - const activeValues = group.options - .map(opt => String(opt.value)) - .filter(v => selectedNames.has(v)); + + handleChange([])} /> + } + style={{width: '100%'}} + trigger={triggerProps => ( + + {selectedList.length === 0 ? ( + {t('None')} + ) : ( + + {selectedList[0]} + {selectedList.length > 1 && ( + + {`+${selectedList.length - 1}`} + + )} + + )} + + )} + > + {groups.map(group => { + const groupKey = String(group.key); + const isMulti = !singleSelect && MULTI_SELECT_GROUP_KEYS.has(groupKey); + const activeValues = group.options + .map(opt => String(opt.value)) + .filter(v => selectedNames.has(v)); + + if (isMulti) { + return ( + + ); + } - if (isMulti) { return ( handleChange([opt])} /> ); - } - - return ( - handleChange([opt])} - /> - ); - })} - + })} + + ); } diff --git a/static/app/views/explore/metrics/metricToolbar/groupBySelector.tsx b/static/app/views/explore/metrics/metricToolbar/groupBySelector.tsx index 1501028c05f1..36e4fe6fbe5c 100644 --- a/static/app/views/explore/metrics/metricToolbar/groupBySelector.tsx +++ b/static/app/views/explore/metrics/metricToolbar/groupBySelector.tsx @@ -4,6 +4,7 @@ import {useQuery} from '@tanstack/react-query'; import type {SelectOption} from '@sentry/scraps/compactSelect'; import {CompactSelect} from '@sentry/scraps/compactSelect'; import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; +import {Tooltip} from '@sentry/scraps/tooltip'; import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; import {t} from 'sentry/locale'; @@ -28,6 +29,10 @@ interface GroupBySelectorProps { * The metric to filter attributes by */ traceMetric: TraceMetric; + /** + * If set, disables the selector and shows this string as a tooltip. + */ + disabledReason?: string; /** * Whether to skip the trace metric filter. * @@ -45,11 +50,13 @@ interface GroupBySelectorProps { export function GroupBySelector({ traceMetric, skipTraceMetricFilter, + disabledReason, }: GroupBySelectorProps) { const {selection} = usePageFilters(); const organization = useOrganization(); const groupBys = useQueryParamsGroupBys(); const setGroupBys = useSetQueryParamsGroupBys(); + const isDisabled = disabledReason !== undefined; const traceMetricFilter = createTraceMetricFilter(traceMetric); @@ -113,23 +120,25 @@ export function GroupBySelector({ ); return ( - ( - - )} - options={enabledOptions} - value={[...groupBys]} - loading={isLoading} - disabled={!skipTraceMetricFilter && !traceMetricFilter} - onChange={handleChange} - style={{width: '100%'}} - /> + + ( + + )} + options={enabledOptions} + value={[...groupBys]} + loading={isLoading} + disabled={isDisabled || (!skipTraceMetricFilter && !traceMetricFilter)} + onChange={handleChange} + style={{width: '100%'}} + /> + ); } diff --git a/static/app/views/explore/metrics/metricToolbar/index.tsx b/static/app/views/explore/metrics/metricToolbar/index.tsx index 44bf32f98efe..352ca99aaef6 100644 --- a/static/app/views/explore/metrics/metricToolbar/index.tsx +++ b/static/app/views/explore/metrics/metricToolbar/index.tsx @@ -27,6 +27,7 @@ import { isVisualizeEquation, isVisualizeFunction, } from 'sentry/views/explore/queryParams/visualize'; +import {ChartType} from 'sentry/views/insights/common/components/chart'; interface MetricToolbarProps { queryLabel: string; @@ -58,6 +59,10 @@ export function MetricToolbar({ setVisualize(visualize.replace({visible: !visualize.visible})); }, [setVisualize, visualize]); const setTraceMetric = useSetTraceMetric(); + const isHeatmap = visualize.chartType === ChartType.HEATMAP; + const disabledReason = isHeatmap + ? t('Not configurable for Heat Map visualizations') + : undefined; // We need at least one metric visualized, but equations should always // be removable. @@ -121,10 +126,16 @@ export function MetricToolbar({ - + - + {!isNarrow && ( diff --git a/static/app/views/explore/metrics/metricsFlags.tsx b/static/app/views/explore/metrics/metricsFlags.tsx index 2afeb1e21147..96797b23cd03 100644 --- a/static/app/views/explore/metrics/metricsFlags.tsx +++ b/static/app/views/explore/metrics/metricsFlags.tsx @@ -51,3 +51,10 @@ export const canUseMetricsPiiScrubbingUI = (organization: Organization) => { organization.features.includes('tracemetrics-pii-scrubbing-ui') ); }; + +export const canUseMetricsHeatMap = (organization: Organization) => { + return ( + canUseMetricsUI(organization) && + organization.features.includes('data-browsing-heat-map-widget') + ); +}; diff --git a/static/app/views/explore/metrics/metricsHeatMap.tsx b/static/app/views/explore/metrics/metricsHeatMap.tsx new file mode 100644 index 000000000000..0597cbe0fb08 --- /dev/null +++ b/static/app/views/explore/metrics/metricsHeatMap.tsx @@ -0,0 +1,64 @@ +import type {UseQueryResult} from '@tanstack/react-query'; + +import {t} from 'sentry/locale'; +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 {Widget} from 'sentry/views/dashboards/widgets/widget/widget'; +import {WidgetWrapper} from 'sentry/views/explore/metrics/metricGraph/styles'; +import { + useMetricLabel, + useMetricName, + useMetricVisualize, + useMetricVisualizes, +} from 'sentry/views/explore/metrics/metricsQueryParams'; +import {STACKED_GRAPH_HEIGHT} from 'sentry/views/explore/metrics/settings'; +import {prettifyAggregation} from 'sentry/views/explore/utils'; + +interface MetricsHeatMapProps { + actions: React.ReactNode; + heatmapResult: UseQueryResult; + title?: string; +} + +export function MetricsHeatMap({heatmapResult, actions, title}: MetricsHeatMapProps) { + const visualize = useMetricVisualize(); + const visualizes = useMetricVisualizes(); + const metricLabel = useMetricLabel(); + const metricName = useMetricName(); + + const {data: heatMapSeries, isPending, error} = heatmapResult; + + const aggregate = visualize.yAxis; + const chartTitle = + visualizes.length > 1 + ? metricName + : (title ?? metricLabel ?? prettifyAggregation(aggregate) ?? aggregate); + + return ( + + } + Actions={actions} + Visualization={ + isPending || !heatMapSeries ? ( + + ) : error ? ( + + ) : heatMapSeries.values.length === 0 ? ( + + ) : ( + + ) + } + height={STACKED_GRAPH_HEIGHT} + revealActions="always" + borderless + /> + + ); +} diff --git a/static/app/views/explore/metrics/settings.ts b/static/app/views/explore/metrics/settings.ts new file mode 100644 index 000000000000..0d2163ad98ab --- /dev/null +++ b/static/app/views/explore/metrics/settings.ts @@ -0,0 +1,2 @@ +export const MINIMIZED_GRAPH_HEIGHT = 50; +export const STACKED_GRAPH_HEIGHT = 362; diff --git a/static/app/views/explore/metrics/useSaveAsMetricItems.tsx b/static/app/views/explore/metrics/useSaveAsMetricItems.tsx index ca82a876c7c9..c94efaa35116 100644 --- a/static/app/views/explore/metrics/useSaveAsMetricItems.tsx +++ b/static/app/views/explore/metrics/useSaveAsMetricItems.tsx @@ -29,6 +29,7 @@ import { } from 'sentry/views/explore/queryParams/visualize'; import {getVisualizeLabel} from 'sentry/views/explore/toolbar/toolbarVisualize'; import {TraceItemDataset} from 'sentry/views/explore/types'; +import {ChartType} from 'sentry/views/insights/common/components/chart'; import {getAlertsUrl} from 'sentry/views/insights/common/utils/getAlertsUrl'; import { @@ -192,7 +193,9 @@ export function useSaveAsMetricItems(options: UseSaveAsMetricItemsOptions) { addToDashboard( metricQueries.filter( metricQuery => - !isVisualizeEquation(metricQuery.queryParams.visualizes[0]!) + !isVisualizeEquation(metricQuery.queryParams.visualizes[0]!) && + metricQuery.queryParams.visualizes[0]!.chartType !== + ChartType.HEATMAP ) ); }, @@ -201,6 +204,8 @@ export function useSaveAsMetricItems(options: UseSaveAsMetricItemsOptions) { : []), ...metricQueries.map((metricQuery, index) => { const visualize = metricQuery.queryParams.visualizes[0]!; + const isUnsupported = + isVisualizeEquation(visualize) || visualize.chartType === ChartType.HEATMAP; const label = isVisualizeFunction(visualize) ? `${metricQuery.label ?? getVisualizeLabel(index, isVisualizeEquation(visualize))}: ${ formatTraceMetricsFunction( @@ -214,15 +219,17 @@ export function useSaveAsMetricItems(options: UseSaveAsMetricItemsOptions) { key: `add-to-dashboard-${index}`, label, onAction: () => { - if (isVisualizeEquation(visualize)) { + if (isUnsupported) { return; } addToDashboard(metricQuery); }, - disabled: isVisualizeEquation(visualize), + disabled: isUnsupported, tooltip: isVisualizeEquation(visualize) ? t('Equations cannot currently be added to a dashboard') - : undefined, + : visualize.chartType === ChartType.HEATMAP + ? t('Heat maps cannot currently be added to a dashboard') + : undefined, }; }), ], diff --git a/static/app/views/insights/common/components/chart.tsx b/static/app/views/insights/common/components/chart.tsx index 1214d756ad48..ce3f0490a7b3 100644 --- a/static/app/views/insights/common/components/chart.tsx +++ b/static/app/views/insights/common/components/chart.tsx @@ -61,6 +61,7 @@ export enum ChartType { BAR = 0, LINE = 1, AREA = 2, + HEATMAP = 3, } export function isChartType(value: any): value is ChartType { From 0226d5cf4bb0c603e0c06cebad0dbbb9ed96f174 Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Thu, 14 May 2026 17:14:14 -0400 Subject: [PATCH 02/10] fix(explore): Use tooltipProps on trigger button for disabled dropdowns The wrapping Tooltip component broke flex layout, causing the disabled aggregate and group-by dropdowns to not stretch correctly. Use tooltipProps on the OverlayTrigger.Button instead, matching the pattern used by DeleteMetricButton. --- .../metricToolbar/aggregateDropdown.tsx | 114 +++++++++--------- .../metrics/metricToolbar/groupBySelector.tsx | 40 +++--- 2 files changed, 75 insertions(+), 79 deletions(-) diff --git a/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx b/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx index 81db09bbfc02..827207667d50 100644 --- a/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx +++ b/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx @@ -7,7 +7,6 @@ import { TriggerLabel, } from '@sentry/scraps/compactSelect'; import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; -import {Tooltip} from '@sentry/scraps/tooltip'; import {t} from 'sentry/locale'; import { @@ -69,70 +68,69 @@ export function AggregateDropdown({ selectedList.length === 1 && selectedList[0] === defaultValue; return ( - - handleChange([])} /> - } - style={{width: '100%'}} - trigger={triggerProps => ( - - {selectedList.length === 0 ? ( - {t('None')} - ) : ( - - {selectedList[0]} - {selectedList.length > 1 && ( - - {`+${selectedList.length - 1}`} - - )} - - )} - - )} - > - {groups.map(group => { - const groupKey = String(group.key); - const isMulti = !singleSelect && MULTI_SELECT_GROUP_KEYS.has(groupKey); - const activeValues = group.options - .map(opt => String(opt.value)) - .filter(v => selectedNames.has(v)); - - if (isMulti) { - return ( - - ); - } + handleChange([])} /> + } + style={{width: '100%'}} + trigger={triggerProps => ( + + {selectedList.length === 0 ? ( + {t('None')} + ) : ( + + {selectedList[0]} + {selectedList.length > 1 && ( + + {`+${selectedList.length - 1}`} + + )} + + )} + + )} + > + {groups.map(group => { + const groupKey = String(group.key); + const isMulti = !singleSelect && MULTI_SELECT_GROUP_KEYS.has(groupKey); + const activeValues = group.options + .map(opt => String(opt.value)) + .filter(v => selectedNames.has(v)); + if (isMulti) { return ( handleChange([opt])} + value={activeValues} + onChange={handleChange} /> ); - })} - - + } + + return ( + handleChange([opt])} + /> + ); + })} + ); } diff --git a/static/app/views/explore/metrics/metricToolbar/groupBySelector.tsx b/static/app/views/explore/metrics/metricToolbar/groupBySelector.tsx index 36e4fe6fbe5c..7dee8d08aa69 100644 --- a/static/app/views/explore/metrics/metricToolbar/groupBySelector.tsx +++ b/static/app/views/explore/metrics/metricToolbar/groupBySelector.tsx @@ -4,7 +4,6 @@ import {useQuery} from '@tanstack/react-query'; import type {SelectOption} from '@sentry/scraps/compactSelect'; import {CompactSelect} from '@sentry/scraps/compactSelect'; import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; -import {Tooltip} from '@sentry/scraps/tooltip'; import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; import {t} from 'sentry/locale'; @@ -120,25 +119,24 @@ export function GroupBySelector({ ); return ( - - ( - - )} - options={enabledOptions} - value={[...groupBys]} - loading={isLoading} - disabled={isDisabled || (!skipTraceMetricFilter && !traceMetricFilter)} - onChange={handleChange} - style={{width: '100%'}} - /> - + ( + + )} + options={enabledOptions} + value={[...groupBys]} + loading={isLoading} + disabled={isDisabled || (!skipTraceMetricFilter && !traceMetricFilter)} + onChange={handleChange} + style={{width: '100%'}} + /> ); } From 038d3e2b3f7e2f6ed1a88bd020a6121ff24a1731 Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Thu, 14 May 2026 17:28:45 -0400 Subject: [PATCH 03/10] fix(explore): Compute heatmap Y buckets from measured container dimensions Use useDimensions to measure the chart container width, then derive yBuckets from the aspect ratio so heatmap cells are roughly square instead of rectangular. --- .../explore/metrics/metricPanel/index.tsx | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index 3b1986f358b1..5e7f7d52e4c4 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -15,8 +15,10 @@ 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 {intervalToMilliseconds} from 'sentry/utils/duration/intervalToMilliseconds'; import {useChartInterval} from 'sentry/utils/useChartInterval'; +import {useDimensions} from 'sentry/utils/useDimensions'; import {useOrganization} from 'sentry/utils/useOrganization'; import {EXPLORE_FIVE_MIN_STALE_TIME} from 'sentry/views/explore/constants'; import {useMetricsPanelAnalytics} from 'sentry/views/explore/hooks/useAnalytics'; @@ -45,6 +47,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 { useQueryParamsAggregateSortBys, useQueryParamsMode, @@ -152,14 +155,14 @@ export function MetricPanel({ (isVisualizeEquation(visualize) && Boolean(visualize.expression.text))), }); - const timeRangeInMs = getDiffInMinutes(selection.datetime) * 60 * 1000; - const intervalInMs = intervalToMilliseconds(interval); - const yBuckets = intervalInMs > 0 ? Math.round(timeRangeInMs / intervalInMs) : 0; + const chartContainerRef = useRef(null); + const {width: chartContainerWidth} = useDimensions({elementRef: chartContainerRef}); + const yBuckets = getHeatmapYBuckets(selection, interval, chartContainerWidth); const heatmapResult = useQuery( metricHeatmapApiOptions({ traceMetric, - enabled: hasHeatMap && isHeatmap && !isMetricOptionsEmpty, + enabled: hasHeatMap && isHeatmap && !isMetricOptionsEmpty && yBuckets > 0, organization, selection, query: userQuery, @@ -261,7 +264,7 @@ export function MetricPanel({ }} > - + {hasHeatMap && isHeatmap ? ( ); } + +/** + * 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))); +} From 206c7ee98188b975131a01c2b6e57cb06e321ed0 Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Thu, 14 May 2026 17:43:07 -0400 Subject: [PATCH 04/10] fix(explore): Merge metric unit into heatmap Y-axis meta The heatmap API returns generic types for the value field. Use the trace metric's known unit to patch the Y-axis meta so the visualization formats axis labels with the correct unit (e.g. "1.5 KB" not "1500"). --- .../explore/metrics/metricPanel/index.tsx | 58 +++++++++++++++---- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index 5e7f7d52e4c4..1bdfa597483b 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -16,10 +16,12 @@ 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 {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 {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'; @@ -48,6 +50,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} from 'sentry/views/explore/metrics/utils'; import { useQueryParamsAggregateSortBys, useQueryParamsMode, @@ -159,17 +162,22 @@ export function MetricPanel({ const {width: chartContainerWidth} = useDimensions({elementRef: chartContainerRef}); const yBuckets = getHeatmapYBuckets(selection, interval, chartContainerWidth); - const heatmapResult = useQuery( - metricHeatmapApiOptions({ - traceMetric, - enabled: hasHeatMap && isHeatmap && !isMetricOptionsEmpty && yBuckets > 0, - organization, - selection, - query: userQuery, - interval, - yBuckets, - }) - ); + const heatmapApiOptions = metricHeatmapApiOptions({ + traceMetric, + enabled: hasHeatMap && isHeatmap && !isMetricOptionsEmpty && yBuckets > 0, + organization, + selection, + query: userQuery, + interval, + yBuckets, + }); + const heatmapResult = useQuery({ + ...heatmapApiOptions, + select: data => { + const series = heatmapApiOptions.select(data); + return mergeMetricUnit(series, traceMetric.unit); + }, + }); useMetricsPanelAnalytics({ interval, @@ -356,3 +364,31 @@ function getHeatmapYBuckets( return Math.max(1, Math.round(xBuckets * (STACKED_GRAPH_HEIGHT / chartContainerWidth))); } + +/** + * 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, + }, + }, + }; +} From 95eb559b7d742dfca7960cebef6122c65ffe3050 Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Thu, 14 May 2026 23:56:15 -0400 Subject: [PATCH 05/10] fix(explore): Fix heatmap error state and misplaced import Check error before isPending in MetricsHeatMap so API failures show the error widget instead of an infinite loading spinner. Move settings import to the top of metricGraph/index.tsx. --- static/app/views/explore/metrics/metricGraph/index.tsx | 9 ++++----- static/app/views/explore/metrics/metricsHeatMap.tsx | 6 +++--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/static/app/views/explore/metrics/metricGraph/index.tsx b/static/app/views/explore/metrics/metricGraph/index.tsx index 6fcfe5366ab1..22b6814ad68d 100644 --- a/static/app/views/explore/metrics/metricGraph/index.tsx +++ b/static/app/views/explore/metrics/metricGraph/index.tsx @@ -22,6 +22,10 @@ import { } from 'sentry/views/explore/metrics/metricsQueryParams'; import {METRICS_CHART_GROUP} from 'sentry/views/explore/metrics/metricsTab'; import {useMultiMetricsQueryParams} from 'sentry/views/explore/metrics/multiMetricsQueryParams'; +import { + MINIMIZED_GRAPH_HEIGHT, + STACKED_GRAPH_HEIGHT, +} from 'sentry/views/explore/metrics/settings'; import { createTraceMetricEventsFilter, getEquationMetricsTotalFilter, @@ -56,11 +60,6 @@ export function getMetricsChartTypeOptions(organization: Organization) { return EXPLORE_CHART_TYPE_OPTIONS; } -import { - MINIMIZED_GRAPH_HEIGHT, - STACKED_GRAPH_HEIGHT, -} from 'sentry/views/explore/metrics/settings'; - interface MetricsGraphProps { actions: React.ReactNode; timeseriesResult: ReturnType; diff --git a/static/app/views/explore/metrics/metricsHeatMap.tsx b/static/app/views/explore/metrics/metricsHeatMap.tsx index 0597cbe0fb08..22403d0732c1 100644 --- a/static/app/views/explore/metrics/metricsHeatMap.tsx +++ b/static/app/views/explore/metrics/metricsHeatMap.tsx @@ -42,10 +42,10 @@ export function MetricsHeatMap({heatmapResult, actions, title}: MetricsHeatMapPr Title={} Actions={actions} Visualization={ - isPending || !heatMapSeries ? ( - - ) : error ? ( + error ? ( + ) : isPending || !heatMapSeries ? ( + ) : heatMapSeries.values.length === 0 ? ( ) : ( From ea6d705e00559da6fa735afbb2c456e63099d100 Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Thu, 14 May 2026 23:58:54 -0400 Subject: [PATCH 06/10] fix(explore): Fix TS errors in heatmap select and unit types Handle possibly undefined select from apiOptions and nullable traceMetric.unit. --- static/app/views/explore/metrics/metricPanel/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index 1bdfa597483b..06e1eddeaa29 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -174,8 +174,8 @@ export function MetricPanel({ const heatmapResult = useQuery({ ...heatmapApiOptions, select: data => { - const series = heatmapApiOptions.select(data); - return mergeMetricUnit(series, traceMetric.unit); + const series = heatmapApiOptions.select!(data); + return mergeMetricUnit(series, traceMetric.unit ?? undefined); }, }); From 5c1a3f0f28fe4f2514f48c7a2f468f8af67c2cde Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Fri, 15 May 2026 00:31:19 -0400 Subject: [PATCH 07/10] fix(explore): Fall back to timeseries when heatmap flag is off Only disable the timeseries query when the heatmap feature flag is enabled AND the chart type is heatmap. Previously a persisted heatmap URL param with the flag off would disable both queries. --- static/app/views/explore/metrics/metricPanel/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index 06e1eddeaa29..ed67b7dcf72d 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -153,7 +153,7 @@ export function MetricPanel({ const {result: timeseriesResult} = useMetricTimeseries({ traceMetric, enabled: - !isHeatmap && + !(hasHeatMap && isHeatmap) && (!isMetricOptionsEmpty || (isVisualizeEquation(visualize) && Boolean(visualize.expression.text))), }); From 3c07e5b6416247cd1d980668acd413fa82ce3158 Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Fri, 15 May 2026 10:18:55 -0400 Subject: [PATCH 08/10] fix(explore): Enforce count() aggregate and clear groupBy for heatmap When switching to heatmap, force the aggregate to count() and clear all groupBys since heatmap visualizations don't support custom aggregates or grouping. When switching away from heatmap, restore the default aggregate for the metric type. --- .../explore/metrics/metricPanel/index.tsx | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index ed67b7dcf72d..d337b70120ce 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -27,6 +27,7 @@ import {useMetricsPanelAnalytics} from 'sentry/views/explore/hooks/useAnalytics' import {useMetricOptions} from 'sentry/views/explore/hooks/useMetricOptions'; import {useTopEvents} from 'sentry/views/explore/hooks/useTopEvents'; import { + DEFAULT_YAXIS_BY_TYPE, getTraceSamplesTableFields, TraceSamplesTableColumns, } from 'sentry/views/explore/metrics/constants'; @@ -50,12 +51,17 @@ 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} from 'sentry/views/explore/metrics/utils'; +import { + mapMetricUnitToFieldType, + updateVisualizeYAxis, +} from 'sentry/views/explore/metrics/utils'; import { useQueryParamsAggregateSortBys, + useQueryParamsGroupBys, useQueryParamsMode, useQueryParamsQuery, useQueryParamsSortBys, + useSetQueryParamsGroupBys, } from 'sentry/views/explore/queryParams/context'; import { isVisualizeEquation, @@ -112,6 +118,8 @@ export function MetricPanel({ const mode = useQueryParamsMode(); const sortBys = useQueryParamsSortBys(); const aggregateSortBys = useQueryParamsAggregateSortBys(); + const groupBys = useQueryParamsGroupBys(); + const setGroupBys = useSetQueryParamsGroupBys(); const [interval, setInterval, intervalOptions] = useChartInterval(); const topEvents = useTopEvents(); const visualize = useMetricVisualize(); @@ -193,7 +201,35 @@ export function MetricPanel({ }); function handleChartTypeChange(newChartType: ChartType) { - setVisualizes(visualizes.map(v => v.replace({chartType: newChartType}))); + if (newChartType === ChartType.HEATMAP) { + // Heatmap always uses count() with no group by + setVisualizes( + visualizes.map(v => + isVisualizeFunction(v) + ? updateVisualizeYAxis(v, 'count', traceMetric).replace({ + chartType: ChartType.HEATMAP, + }) + : v.replace({chartType: ChartType.HEATMAP}) + ) + ); + if (groupBys.length > 0) { + setGroupBys([]); + } + } else if (isHeatmap) { + // Switching away from heatmap — restore the default aggregate + const defaultAggregate = DEFAULT_YAXIS_BY_TYPE[traceMetric.type] ?? 'count'; + setVisualizes( + visualizes.map(v => + isVisualizeFunction(v) + ? updateVisualizeYAxis(v, defaultAggregate, traceMetric).replace({ + chartType: newChartType, + }) + : v.replace({chartType: newChartType}) + ) + ); + } else { + setVisualizes(visualizes.map(v => v.replace({chartType: newChartType}))); + } } const actions = ( From cfcbf74cb333cb98d2127592368973dab4896a9f Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Fri, 15 May 2026 14:52:39 -0400 Subject: [PATCH 09/10] fix(explore): Fix missing time range in heatmap API request normalizeDateTimeParams returns statsPeriod, not period. Destructuring the wrong key meant the relative time range was never sent, causing the heatmap request to fail. --- .../views/explore/metrics/hooks/metricHeatmapApiOptions.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/static/app/views/explore/metrics/hooks/metricHeatmapApiOptions.tsx b/static/app/views/explore/metrics/hooks/metricHeatmapApiOptions.tsx index 31574bbc3ae1..890a240008be 100644 --- a/static/app/views/explore/metrics/hooks/metricHeatmapApiOptions.tsx +++ b/static/app/views/explore/metrics/hooks/metricHeatmapApiOptions.tsx @@ -34,8 +34,8 @@ export function metricHeatmapApiOptions({ const combinedQuery = query ? `${traceMetricFilter} ${query}` : traceMetricFilter; const intervalInMilliseconds = intervalToMilliseconds(interval); - const {start, end, period} = normalizeDateTimeParams(selection.datetime); - const usesRelativeDateRange = !defined(start) && !defined(end) && defined(period); + const {start, end, statsPeriod} = normalizeDateTimeParams(selection.datetime); + const usesRelativeDateRange = !defined(start) && !defined(end) && defined(statsPeriod); return apiOptions.as()( '/organizations/$organizationIdOrSlug/events-heatmap/', @@ -53,7 +53,7 @@ export function metricHeatmapApiOptions({ environment: selection.environments, start, end, - ...(period ? {statsPeriod: period} : {}), + statsPeriod, referrer: 'api.explore.tracemetrics-heatmap', }, staleTime: From e5074ba905234a2142b036030cf337d5988d2a5f Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Fri, 15 May 2026 14:56:53 -0400 Subject: [PATCH 10/10] fix(explore): Wrap user query in parens for heatmap filter Prevents boolean operators in the user query from combining with the trace metric filter in unexpected ways. --- .../app/views/explore/metrics/hooks/metricHeatmapApiOptions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/explore/metrics/hooks/metricHeatmapApiOptions.tsx b/static/app/views/explore/metrics/hooks/metricHeatmapApiOptions.tsx index 890a240008be..795f41803ed0 100644 --- a/static/app/views/explore/metrics/hooks/metricHeatmapApiOptions.tsx +++ b/static/app/views/explore/metrics/hooks/metricHeatmapApiOptions.tsx @@ -31,7 +31,7 @@ export function metricHeatmapApiOptions({ yBuckets, }: MetricHeatmapApiOptions) { const traceMetricFilter = createTraceMetricEventsFilter([traceMetric]); - const combinedQuery = query ? `${traceMetricFilter} ${query}` : traceMetricFilter; + const combinedQuery = query ? `${traceMetricFilter} (${query})` : traceMetricFilter; const intervalInMilliseconds = intervalToMilliseconds(interval); const {start, end, statsPeriod} = normalizeDateTimeParams(selection.datetime);