Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,86 @@ export default Storybook.story('HeatMapWidgetVisualization', story => {
</Fragment>
);
});

story('Tooltip Options', () => {
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>makeLocalQueryUpdateUrl</code> to add an <em>Add to filter</em> link
in the tooltip. The callback receives the same Y-axis and X-axis filter query
and should return a URL that applies that filter to the current view. Navigation
is handled client-side via React Router.
</p>
<p>
<CodeBlock language="jsx">
{`<HeatMapWidgetVisualization
plottables={[new HeatMap(heatMapData)]}
makeLocalQueryUpdateUrl={(query, filteredSelection) =>
getMetricsUrl({
organization,
selection: filteredSelection,
metricQueries: [{
metric,
queryParams: {...queryParams, query: newQuery},
}],
})
}
/>`}
</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>
<HeatMapWidgetVisualization
plottables={[new HeatMap(sampleLatencyHeatMap)]}
makeExploreUrl={(query, filteredSelection) =>
`/explore/traces/?query=${encodeURIComponent(query)}&start=${filteredSelection.datetime.start}&end=${filteredSelection.datetime.end}`
}
makeLocalQueryUpdateUrl={query =>
`/explore/metrics/?query=${encodeURIComponent(query)}`
}
/>
</LargeWidget>
</Fragment>
);
});
});

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 @@ -42,25 +42,76 @@ interface HeatMapWidgetVisualizationProps {
*/
plottables: [HeatMap, ...HeatMapPlottable[]];
/**
* Experimental! Specify the Z-axis scale type. Logarithmic scales can be much more useful for values with a high range.
* Callback that returns an explore URL for a given query and filtered datetime selection
*/
scale?: 'linear' | 'log';
makeExploreUrl?: (query: string, filteredSelection: PageFilters) => string;
/**
* 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.
*/
makeLocalQueryUpdateUrl?: (query: string) => string;
/**
* Experimental! Specify the Z-axis scale type. Logarithmic scales can be much more useful for values with a high range.
*/
tooltipExploreUrlArgs?: Omit<GetExploreUrlArgs, 'organization'>;
scale?: 'linear' | 'log';
}

export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProps) {
const {plottables} = props;
const {plottables, makeLocalQueryUpdateUrl, 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-update-url]'
);
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 localUrl = localQueryUpdateTarget.getAttribute(
'data-local-query-update-url'
);
if (localUrl) {
if (openInNewTab) {
window.open(localUrl, '_blank');
} else {
navigate(localUrl);
}
}
}
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]);
Comment thread
nikkikapadia marked this conversation as resolved.
Outdated

if (!plottablesCanBeVisualized(plottables)) {
throw new Error(NO_PLOTTABLE_VALUES);
}
Expand Down Expand Up @@ -126,7 +177,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 +226,34 @@ export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProp
}
}

let exploreLink: ReactNode;
let tracesLink: string | undefined;
let localQueryUpdateUrl: 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);
}
}

if (makeLocalQueryUpdateUrl && metricsQuery) {
localQueryUpdateUrl = makeLocalQueryUpdateUrl(metricsQuery);
}

return (
Expand All @@ -221,18 +264,37 @@ export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProp
</span>{' '}
{formattedZValue}
</div>
{exploreLink && (
{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>
)}
{defined(localQueryUpdateUrl) && (
<div>
<span className="tooltip-label tooltip-label-centered">
<a
data-local-query-update-url={localQueryUpdateUrl}
href={localQueryUpdateUrl}
>
{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
Loading
Loading