diff --git a/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.stories.tsx b/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.stories.tsx index 88618cd13f3b..c7aca6b5bed4 100644 --- a/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.stories.tsx +++ b/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.stories.tsx @@ -1,4 +1,4 @@ -import {Fragment} from 'react'; +import {Fragment, useState} from 'react'; import styled from '@emotion/styled'; import {CodeBlock} from '@sentry/scraps/code'; @@ -102,6 +102,84 @@ export default Storybook.story('HeatMapWidgetVisualization', story => { ); }); + + story('Tooltip Options', () => { + function TooltipOptionsStory() { + const [localFilterQuery, setLocalFilterQuery] = useState( + undefined + ); + return ( + +

+ supports two optional + tooltip action props. Click a cell to open the tooltip and see the links. +

+

+ With no extra props, the tooltip shows the Y-axis bucket range and the Z-axis + count for the clicked cell. +

+

+ Pass makeExploreUrl to add a View connected spans link + in the tooltip. The callback receives the Y-axis filter query (e.g.{' '} + value:>=200 value:<250) and a PageFilters{' '} + object whose datetime is narrowed to the clicked X-axis bucket. Use these to + build a cross-event query link into Explore. +

+

+ + {` + getExploreUrl({ + organization, + selection: filteredSelection, + crossEvents: [{ + type: 'metrics', + metric, + query, + }] + }) + } +/>`} + +

+ +

+ Pass updateLocalFilterQuery to add an Add to filter link + in the tooltip. The callback receives the Y-axis filter query and should apply + that filter to the current view. Navigation is handled however you choose in + the passing function (most likely will use hooks). +

+

+ + {` + setLocalFilterQuery(query) + } +/>`} + +

+ +

+ Both props can be used together. The tooltip shows{' '} + View related traces and Add to filter as separate actions. +

+ +

{`Local Filter Query: ${localFilterQuery}`}

+ + `/explore/traces/?query=${encodeURIComponent(query)}&start=${filteredSelection.datetime.start}&end=${filteredSelection.datetime.end}` + } + updateLocalFilterQuery={query => setLocalFilterQuery(query)} + /> +
+
+ ); + } + return ; + }); }); const LargeWidget = styled('div')` diff --git a/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx b/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx index ab037277914e..6e815f17e215 100644 --- a/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx +++ b/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx @@ -1,6 +1,6 @@ import 'echarts/lib/chart/heatmap'; -import {Fragment, useRef, type ReactNode} from 'react'; +import {Fragment, useCallback, useEffect, useRef} from 'react'; import {useTheme} from '@emotion/react'; import type { TooltipFormatterCallback, @@ -15,18 +15,18 @@ import {defaultFormatAxisLabel} from 'sentry/components/charts/components/toolti import {isChartHovered} from 'sentry/components/charts/utils'; import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; import {t} from 'sentry/locale'; +import type {PageFilters} from 'sentry/types/core'; import type {ReactEchartsRef} from 'sentry/types/echarts'; import {defined} from 'sentry/utils'; import {formatAbbreviatedNumber} from 'sentry/utils/formatters'; import {ECHARTS_MISSING_DATA_VALUE} from 'sentry/utils/timeSeries/timeSeriesItemToEChartsDataPoint'; -import {useOrganization} from 'sentry/utils/useOrganization'; +import {useNavigate} from 'sentry/utils/useNavigate'; import {NO_PLOTTABLE_VALUES} from 'sentry/views/dashboards/widgets/common/settings'; import {formatYAxisValue} from 'sentry/views/dashboards/widgets/heatMapWidget/formatters/formatYAxisValue'; import {plottablesCanBeVisualized} from 'sentry/views/dashboards/widgets/plottablesCanBeVisualized'; import {formatTooltipValue} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatTooltipValue'; import {formatXAxisTimestamp} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatXAxisTimestamp'; import {FALLBACK_TYPE} from 'sentry/views/dashboards/widgets/timeSeriesWidget/settings'; -import {getExploreUrl, type GetExploreUrlArgs} from 'sentry/views/explore/utils'; import {HeatMap} from './plottables/heatMap'; import type {HeatMapPlottable} from './plottables/heatMapPlottable'; @@ -41,26 +41,73 @@ interface HeatMapWidgetVisualizationProps { * An single `HeatMap` object to render on the chart, and any number of other compatible Heat Map plottables. */ plottables: [HeatMap, ...HeatMapPlottable[]]; + /** + * Callback that returns an explore URL for a given query and filtered datetime selection + */ + makeExploreUrl?: (query: string, filteredSelection: PageFilters) => string; /** * Experimental! Specify the Z-axis scale type. Logarithmic scales can be much more useful for values with a high range. */ scale?: 'linear' | 'log'; /** - * getExploreUrl props that will be used to generate an explore link for the tooltip. Omitting this will not generate an explore link. + * Callback that updates the local filter to include the given Y-axis query. */ - tooltipExploreUrlArgs?: Omit; + updateLocalFilterQuery?: (query: string) => void; } export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProps) { - const {plottables} = props; + const {plottables, updateLocalFilterQuery, makeExploreUrl} = props; const theme = useTheme(); - const organization = useOrganization(); const renderToString = useRenderToString(); - + const navigate = useNavigate(); const pageFilters = usePageFilters(); const {start, end, period, utc} = pageFilters.selection.datetime; const chartRef = useRef(null); + // yes i am aware that this is UGLY but it's a hack so that we can use proper react routing. + // Basically the way ECharts renders the tooltip is by creating a string out of the dom tree. + // This means that we can't use any of the normal linking/routing tools that we use in React trees + // because they require contexts that won't be available properly in this string tree. + // Using the `` tag will make the page reload and navigate to the url because it doesn't have + // link history context. Doing the navigation here preserves the link history context and makes the + // page navigation smoother instead of reloading the page every time a link is clicked. + const handleTooltipLinksClick = useCallback( + (e: MouseEvent) => { + if (!chartRef.current?.ele?.contains(e.target as Node)) { + return; + } + const localQueryUpdateTarget = (e.target as Element).closest('[data-local-query]'); + const tracesLinkTarget = (e.target as Element).closest('[data-traces-link]'); + if (!localQueryUpdateTarget && !tracesLinkTarget) { + return; + } + e.preventDefault(); + const openInNewTab = e.metaKey || e.ctrlKey; + if (localQueryUpdateTarget) { + const localQuery = localQueryUpdateTarget.getAttribute('data-local-query'); + if (localQuery && updateLocalFilterQuery) { + updateLocalFilterQuery(localQuery); + } + } + if (tracesLinkTarget) { + const tracesUrl = tracesLinkTarget.getAttribute('data-traces-link'); + if (tracesUrl) { + if (openInNewTab) { + window.open(tracesUrl, '_blank'); + } else { + navigate(tracesUrl); + } + } + } + }, + [navigate, updateLocalFilterQuery] + ); + + useEffect(() => { + document.addEventListener('click', handleTooltipLinksClick); + return () => document.removeEventListener('click', handleTooltipLinksClick); + }, [handleTooltipLinksClick]); + if (!plottablesCanBeVisualized(plottables)) { throw new Error(NO_PLOTTABLE_VALUES); } @@ -126,7 +173,7 @@ export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProp return renderToString( -
+
{filteredParams.map(param => { let rawXValue: number | undefined; let rawYValue: number | undefined; @@ -175,42 +222,29 @@ export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProp } } - let exploreLink: ReactNode; + let tracesLink: string | undefined; + const metricsQuery = defined(rawYValue) + ? yAxisBucketSize === 0 + ? `value:<=${rawYValue}` + : `value:>=${rawYValue} value:<${rawYValue + yAxisBucketSize}` + : undefined; - if (defined(rawXValue) && defined(rawYValue) && props.tooltipExploreUrlArgs) { + if (defined(rawXValue) && defined(rawYValue)) { const xAxisMaxValue = rawXValue + xAxisBucketSize * 1000; - const yAxisMaxValue = rawYValue + yAxisBucketSize; - - const exploreUrlProps: GetExploreUrlArgs = { - organization, - ...props.tooltipExploreUrlArgs, - selection: { - ...pageFilters.selection, - datetime: { - ...pageFilters.selection.datetime, - start: new Date(rawXValue), - end: new Date(xAxisMaxValue), - period: null, - }, + + const filteredSelection = { + ...pageFilters.selection, + datetime: { + ...pageFilters.selection.datetime, + start: new Date(rawXValue), + end: new Date(xAxisMaxValue), + period: null, }, - // TODO(nikki): we're only handling metrics for now but if we're looking to support other explore - // surfaces then we'll need to add more logic here - crossEvents: props.tooltipExploreUrlArgs?.crossEvents?.map(crossEvent => { - if (crossEvent.type === 'metrics') { - return { - ...crossEvent, - query: - yAxisBucketSize === 0 - ? `value:<=${rawYValue}` - : `value:>=${rawYValue} value:<${yAxisMaxValue}`, - }; - } - return crossEvent; - }), }; - const tracesLink = getExploreUrl(exploreUrlProps); - exploreLink = {t('View related traces')}; + if (makeExploreUrl && metricsQuery) { + tracesLink = makeExploreUrl(metricsQuery, filteredSelection); + } } return ( @@ -221,10 +255,19 @@ export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProp {' '} {formattedZValue}
- {exploreLink && ( + {makeExploreUrl && defined(tracesLink) && (
- {exploreLink} + + {t('View connected spans')} + + +
+ )} + {updateLocalFilterQuery && defined(metricsQuery) && ( + )} @@ -232,7 +275,12 @@ export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProp ); })}
-
{formattedXValue}
+
+ {formattedXValue} +
); diff --git a/static/app/views/explore/metrics/metricsHeatMap.tsx b/static/app/views/explore/metrics/metricsHeatMap.tsx index b973762558c6..ddc1c516debc 100644 --- a/static/app/views/explore/metrics/metricsHeatMap.tsx +++ b/static/app/views/explore/metrics/metricsHeatMap.tsx @@ -1,7 +1,9 @@ -import {useMemo} from 'react'; +import {useCallback} from 'react'; import type {UseQueryResult} from '@tanstack/react-query'; import {t} from 'sentry/locale'; +import type {PageFilters} from 'sentry/types/core'; +import {useOrganization} from 'sentry/utils/useOrganization'; 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'; @@ -16,7 +18,11 @@ import { useTraceMetric, } from 'sentry/views/explore/metrics/metricsQueryParams'; import {STACKED_GRAPH_HEIGHT} from 'sentry/views/explore/metrics/settings'; -import {prettifyAggregation, type GetExploreUrlArgs} from 'sentry/views/explore/utils'; +import { + useQueryParamsQuery, + useSetQueryParamsQuery, +} from 'sentry/views/explore/queryParams/context'; +import {getExploreUrl, prettifyAggregation} from 'sentry/views/explore/utils'; interface MetricsHeatMapProps { actions: React.ReactNode; @@ -30,6 +36,10 @@ export function MetricsHeatMap({heatmapResult, actions, title}: MetricsHeatMapPr const metricLabel = useMetricLabel(); const metricName = useMetricName(); const metric = useTraceMetric(); + const userQuery = useQueryParamsQuery(); + const setMetricQuery = useSetQueryParamsQuery(); + + const organization = useOrganization(); const {data: heatMapSeries, isPending, error} = heatmapResult; @@ -39,17 +49,29 @@ export function MetricsHeatMap({heatmapResult, actions, title}: MetricsHeatMapPr ? metricName : (title ?? metricLabel ?? prettifyAggregation(aggregate) ?? aggregate); - const tooltipExploreUrlArgs: Omit = useMemo(() => { - return { - crossEvents: [ - { - type: 'metrics', - query: '', - metric, - }, - ], - }; - }, [metric]); + const getFilteredExploreUrl = useCallback( + (query: string, filteredSelection: PageFilters) => { + return getExploreUrl({ + organization, + selection: filteredSelection, + crossEvents: [ + { + type: 'metrics', + metric, + query, + }, + ], + }); + }, + [metric, organization] + ); + + const updateMetricQuery = useCallback( + (query: string) => { + setMetricQuery(userQuery ? `${userQuery} ${query}` : query); + }, + [userQuery, setMetricQuery] + ); return ( @@ -67,7 +89,8 @@ export function MetricsHeatMap({heatmapResult, actions, title}: MetricsHeatMapPr ) }