Skip to content
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Fragment} from 'react';
import {Fragment, useState} from 'react';
import styled from '@emotion/styled';

import {CodeBlock} from '@sentry/scraps/code';
Expand Down Expand Up @@ -102,6 +102,84 @@ export default Storybook.story('HeatMapWidgetVisualization', story => {
</Fragment>
);
});

story('Tooltip Options', () => {
function TooltipOptionsStory() {
const [localFilterQuery, setLocalFilterQuery] = useState<string | undefined>(
undefined
);
return (
<Fragment>
<p>
<Storybook.JSXNode name="HeatMapWidgetVisualization" /> supports two optional
tooltip action props. Click a cell to open the tooltip and see the links.
</p>
<p>
With no extra props, the tooltip shows the Y-axis bucket range and the Z-axis
count for the clicked cell.
</p>
<p>
Pass <code>makeExploreUrl</code> to add a <em>View connected spans</em> link
in the tooltip. The callback receives the Y-axis filter query (e.g.{' '}
<code>value:&gt;=200 value:&lt;250</code>) and a <code>PageFilters</code>{' '}
object whose datetime is narrowed to the clicked X-axis bucket. Use these to
build a cross-event query link into Explore.
</p>
<p>
<CodeBlock language="jsx">
{`<HeatMapWidgetVisualization
plottables={[new HeatMap(heatMapData)]}
makeExploreUrl={(query, filteredSelection) =>
getExploreUrl({
organization,
selection: filteredSelection,
crossEvents: [{
type: 'metrics',
metric,
query,
}]
})
}
/>`}
</CodeBlock>
</p>

<p>
Pass <code>updateLocalFilterQuery</code> to add an <em>Add to filter</em> 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).
</p>
<p>
<CodeBlock language="jsx">
{`<HeatMapWidgetVisualization
plottables={[new HeatMap(heatMapData)]}
updateLocalFilterQuery={(query) =>
setLocalFilterQuery(query)
}
/>`}
</CodeBlock>
</p>

<p>
Both props can be used together. The tooltip shows{' '}
<em>View related traces</em> and <em>Add to filter</em> as separate actions.
</p>
<LargeWidget>
<p>{`Local Filter Query: ${localFilterQuery}`}</p>
<HeatMapWidgetVisualization
plottables={[new HeatMap(sampleLatencyHeatMap)]}
makeExploreUrl={(query, filteredSelection) =>
`/explore/traces/?query=${encodeURIComponent(query)}&start=${filteredSelection.datetime.start}&end=${filteredSelection.datetime.end}`
}
updateLocalFilterQuery={query => setLocalFilterQuery(query)}
/>
</LargeWidget>
</Fragment>
);
}
return <TooltipOptionsStory />;
});
});

const LargeWidget = styled('div')`
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import 'echarts/lib/chart/heatmap';

import {Fragment, useRef, type ReactNode} from 'react';
import {Fragment, useEffect, useRef} from 'react';
import {useTheme} from '@emotion/react';
import type {
TooltipFormatterCallback,
Expand All @@ -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';
Expand All @@ -41,26 +41,69 @@ 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 returns an updated query string.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Since we're setting this directly and not returning anything in it, should the description be updated 👀

*/
tooltipExploreUrlArgs?: Omit<GetExploreUrlArgs, 'organization'>;
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<ReactEchartsRef | null>(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 `<a>` 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.
Comment on lines +67 to +73
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😭

useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (!chartRef.current?.ele.contains(e.target as Node)) {
Comment thread
sentry[bot] marked this conversation as resolved.
Outdated
return;
}
const localQueryUpdateTarget = (e.target as Element).closest('[data-local-query]');
const tracesLinkTarget = (e.target as Element).closest('[data-traces-link]');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Text clicks break tooltip routing

Medium Severity

The document click handler calls closest on event.target cast to Element, but clicks on tooltip link labels often target a Text node, which has no closest. That throws before preventDefault, so client-side navigate is skipped and the browser follows href with a full page reload.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b81dc9d. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idk what they're talking about the client side navigation works

Comment thread
nikkikapadia marked this conversation as resolved.
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);
}
}
}
};
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, [navigate, updateLocalFilterQuery]);

if (!plottablesCanBeVisualized(plottables)) {
throw new Error(NO_PLOTTABLE_VALUES);
}
Expand Down Expand Up @@ -126,7 +169,7 @@ export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProp

return renderToString(
<Fragment>
<div className="tooltip-series">
<div className="tooltip-series" style={{cursor: 'default'}}>
{filteredParams.map(param => {
let rawXValue: number | undefined;
let rawYValue: number | undefined;
Expand Down Expand Up @@ -175,42 +218,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 = <a href={tracesLink}>{t('View related traces')}</a>;
if (makeExploreUrl && metricsQuery) {
tracesLink = makeExploreUrl(metricsQuery, filteredSelection);
}
}

return (
Expand All @@ -221,18 +251,32 @@ export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProp
</span>{' '}
{formattedZValue}
</div>
{exploreLink && (
{makeExploreUrl && defined(tracesLink) && (
<div>
<span className="tooltip-label tooltip-label-centered">
{exploreLink}
<a data-traces-link={tracesLink} href={tracesLink}>
{t('View connected spans')}
</a>
</span>
</div>
)}
{updateLocalFilterQuery && defined(metricsQuery) && (
<div>
<span className="tooltip-label tooltip-label-centered">
<a data-local-query={metricsQuery}>{t('Add to filter')}</a>
</span>
</div>
)}
</Fragment>
);
})}
</div>
<div className="tooltip-footer tooltip-footer-centered">{formattedXValue}</div>
<div
className="tooltip-footer tooltip-footer-centered"
style={{cursor: 'default'}}
>
{formattedXValue}
</div>
<div className="tooltip-arrow" />
</Fragment>
);
Expand Down
1 change: 1 addition & 0 deletions static/app/views/explore/metrics/metricPanel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ export function MetricPanel({
heatmapResult={heatmapResult}
actions={actions}
title={title}
queryIndex={queryIndex}
/>
) : (
<MetricsGraph
Expand Down
52 changes: 38 additions & 14 deletions static/app/views/explore/metrics/metricsHeatMap.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,11 +18,16 @@ 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;
heatmapResult: UseQueryResult<HeatMapSeries>;
queryIndex: number;
title?: string;
}

Expand All @@ -30,6 +37,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;

Expand All @@ -39,17 +50,29 @@ export function MetricsHeatMap({heatmapResult, actions, title}: MetricsHeatMapPr
? metricName
: (title ?? metricLabel ?? prettifyAggregation(aggregate) ?? aggregate);

const tooltipExploreUrlArgs: Omit<GetExploreUrlArgs, 'organization'> = 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 getUpdatedMetricsQueryUrl = useCallback(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Since we're setting the query here, we may want to change the name

(query: string) => {
setMetricQuery(userQuery ? `${userQuery} ${query}` : query);
},
[userQuery, setMetricQuery]
);

return (
<WidgetWrapper>
Expand All @@ -67,7 +90,8 @@ export function MetricsHeatMap({heatmapResult, actions, title}: MetricsHeatMapPr
<HeatMapWidgetVisualization
plottables={[new HeatMap(heatMapSeries)]}
scale="log"
tooltipExploreUrlArgs={tooltipExploreUrlArgs}
makeExploreUrl={getFilteredExploreUrl}
updateLocalFilterQuery={getUpdatedMetricsQueryUrl}
/>
)
}
Expand Down
Loading