diff --git a/knip.config.ts b/knip.config.ts index e10e88437307eb..494668c439ff2d 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -23,8 +23,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}', ]; const testingEntryPoints = [ diff --git a/static/app/views/dashboards/widgets/heatMapWidget/plottables/heatMap.tsx b/static/app/views/dashboards/widgets/heatMapWidget/plottables/heatMap.tsx index e5404affeb04e6..07c71d97c08025 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 8906951a5ff208..7a8f3bb099f2b6 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 4f84d97e0bd934..274763f2e2cbc7 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 00000000000000..31574bbc3ae189 --- /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 f1996f1b26dc8b..22b6814ad68de7 100644 --- a/static/app/views/explore/metrics/metricGraph/index.tsx +++ b/static/app/views/explore/metrics/metricGraph/index.tsx @@ -1,30 +1,31 @@ -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'; import {useMultiMetricsQueryParams} from 'sentry/views/explore/metrics/multiMetricsQueryParams'; +import { + MINIMIZED_GRAPH_HEIGHT, + STACKED_GRAPH_HEIGHT, +} from 'sentry/views/explore/metrics/settings'; import { createTraceMetricEventsFilter, getEquationMetricsTotalFilter, @@ -49,26 +50,32 @@ 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; +} 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 +83,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 +117,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 +184,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 +192,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 16feb2cddc117e..d337b70120ce11 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -1,45 +1,84 @@ 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 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'; import {useTopEvents} from 'sentry/views/explore/hooks/useTopEvents'; import { + DEFAULT_YAXIS_BY_TYPE, getTraceSamplesTableFields, 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 {STACKED_GRAPH_HEIGHT} from 'sentry/views/explore/metrics/settings'; +import { + mapMetricUnitToFieldType, + updateVisualizeYAxis, +} from 'sentry/views/explore/metrics/utils'; import { useQueryParamsAggregateSortBys, + useQueryParamsGroupBys, useQueryParamsMode, + useQueryParamsQuery, useQueryParamsSortBys, + useSetQueryParamsGroupBys, } 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 +108,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 +118,13 @@ export function MetricPanel({ const mode = useQueryParamsMode(); const sortBys = useQueryParamsSortBys(); const aggregateSortBys = useQueryParamsAggregateSortBys(); - const [interval] = useChartInterval(); + const groupBys = useQueryParamsGroupBys(); + const setGroupBys = useSetQueryParamsGroupBys(); + 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,11 +155,36 @@ 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)), + !(hasHeatMap && isHeatmap) && + (!isMetricOptionsEmpty || + (isVisualizeEquation(visualize) && Boolean(visualize.expression.text))), + }); + + const chartContainerRef = useRef(null); + const {width: chartContainerWidth} = useDimensions({elementRef: chartContainerRef}); + const yBuckets = getHeatmapYBuckets(selection, interval, chartContainerWidth); + + 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 ?? undefined); + }, }); useMetricsPanelAnalytics({ @@ -129,6 +200,79 @@ export function MetricPanel({ panelIndex: queryIndex, }); + function handleChartTypeChange(newChartType: ChartType) { + 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 = ( + + ( + } + 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 ( @@ -164,12 +308,21 @@ 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))); +} + +/** + * 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, + }, + }, + }; +} diff --git a/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx b/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx index 25d168c3f97466..827207667d50ee 100644 --- a/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx +++ b/static/app/views/explore/metrics/metricToolbar/aggregateDropdown.tsx @@ -27,13 +27,16 @@ const MULTI_SELECT_GROUP_KEYS = new Set(['percentiles', 'stats']); export function AggregateDropdown({ traceMetric, singleSelect = false, + disabledReason, }: { traceMetric: TraceMetric; + disabledReason?: string; singleSelect?: boolean; }) { const visualize = useMetricVisualize(); const visualizes = useMetricVisualizes(); const setMetricVisualizes = useSetMetricVisualizes(); + const isDisabled = disabledReason !== undefined; const groups = GROUPED_OPTIONS_BY_TYPE[traceMetric.type] ?? []; const selectedNames = new Set( @@ -66,7 +69,7 @@ export function AggregateDropdown({ return ( ( diff --git a/static/app/views/explore/metrics/metricToolbar/groupBySelector.tsx b/static/app/views/explore/metrics/metricToolbar/groupBySelector.tsx index 7b4a87340c8c57..4d785cb84c1e4b 100644 --- a/static/app/views/explore/metrics/metricToolbar/groupBySelector.tsx +++ b/static/app/views/explore/metrics/metricToolbar/groupBySelector.tsx @@ -29,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. * @@ -46,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); @@ -129,6 +135,7 @@ export function GroupBySelector({ trigger={triggerProps => ( @@ -136,7 +143,7 @@ export function GroupBySelector({ options={enabledOptions} value={[...groupBys]} loading={isLoading} - disabled={!skipTraceMetricFilter && !traceMetricFilter} + 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 44bf32f98efe63..352ca99aaef69b 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 bbf0581b7167f0..c408fc11834936 100644 --- a/static/app/views/explore/metrics/metricsFlags.tsx +++ b/static/app/views/explore/metrics/metricsFlags.tsx @@ -58,3 +58,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 00000000000000..22403d0732c1e3 --- /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={ + error ? ( + + ) : isPending || !heatMapSeries ? ( + + ) : 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 00000000000000..0d2163ad98abd1 --- /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 981fb7392e4b89..bfb9a9187b81ee 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 404974a2e4e6c7..a11c6f229d9dfe 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 {