-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
feat(explore): heatmap tooltip trace links #115925
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
3dac374
765df36
5866a75
58d6674
10e70fa
32e24c3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,25 +1,32 @@ | ||
| import 'echarts/lib/chart/heatmap'; | ||
|
|
||
| import {useRef} from 'react'; | ||
| import {Fragment, useRef, type ReactNode} from 'react'; | ||
| import {useTheme} from '@emotion/react'; | ||
| import type { | ||
| TooltipFormatterCallback, | ||
| TopLevelFormatterParams, | ||
| } from 'echarts/types/dist/shared'; | ||
|
|
||
| import {Flex} from '@sentry/scraps/layout'; | ||
| import {useRenderToString} from '@sentry/scraps/renderToString'; | ||
|
|
||
| import {BaseChart} from 'sentry/components/charts/baseChart'; | ||
| import {getFormatter} from 'sentry/components/charts/components/tooltip'; | ||
| import {isChartHovered, truncationFormatter} from 'sentry/components/charts/utils'; | ||
| import {defaultFormatAxisLabel} from 'sentry/components/charts/components/tooltip'; | ||
| import {isChartHovered} from 'sentry/components/charts/utils'; | ||
| import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; | ||
| import {t} from 'sentry/locale'; | ||
| 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 {NO_PLOTTABLE_VALUES} from 'sentry/views/dashboards/widgets/common/settings'; | ||
| 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 {formatYAxisValue} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatYAxisValue'; | ||
| 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'; | ||
|
|
@@ -34,11 +41,17 @@ interface HeatMapWidgetVisualizationProps { | |
| * 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. | ||
| */ | ||
| tooltipExploreUrlArgs?: Omit<GetExploreUrlArgs, 'organization'>; | ||
| } | ||
|
|
||
| export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProps) { | ||
| const {plottables} = props; | ||
| const theme = useTheme(); | ||
| const organization = useOrganization(); | ||
| const renderToString = useRenderToString(); | ||
|
|
||
| const pageFilters = usePageFilters(); | ||
| const {start, end, period, utc} = pageFilters.selection.datetime; | ||
|
|
@@ -68,43 +81,158 @@ export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProp | |
| const Zmax = | ||
| scale === 'log' ? Math.log1p(heatMapPlottable.Zend) : heatMapPlottable.Zend; | ||
|
|
||
| /** Extract the numeric value from ECharts tooltip param.value. */ | ||
| function extractValue(data: unknown): number | null { | ||
| // param.value can be either: | ||
| // 1. The numeric value directly (for heatmap charts with axis trigger) | ||
| // 2. An object {name, value} (depends on series config) | ||
| if (typeof data === 'number') { | ||
| return data; | ||
| } | ||
| if (typeof data === 'string') { | ||
| return parseFloat(data); | ||
| } | ||
|
|
||
| const value = (data as {value?: unknown} | null | undefined)?.value; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: Any way we can get rid of this type cast 👀
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. unfortunately not, the original typing for this value is from e-charts and this type in particular is not exported 😢 |
||
| return typeof value === 'number' ? value : null; | ||
| } | ||
|
|
||
| // Create tooltip formatter | ||
| const formatTooltip: TooltipFormatterCallback<TopLevelFormatterParams> = ( | ||
| params, | ||
| asyncTicket | ||
| ) => { | ||
| const formatTooltip: TooltipFormatterCallback<TopLevelFormatterParams> = params => { | ||
| // Only show the tooltip of the current chart. Otherwise, all tooltips | ||
| // in the chart group appear. | ||
| if (!isChartHovered(chartRef?.current)) { | ||
| return ''; | ||
| } | ||
|
|
||
| return getFormatter({ | ||
| isGroupedByDate: true, | ||
| showTimeInTooltip: true, | ||
| nameFormatter: function (seriesName, _nameFormatterParams) { | ||
| return truncationFormatter(seriesName, true, false); | ||
| }, | ||
| valueFormatter: function (value, _field, _valueFormatterParams) { | ||
| const bucketSize = heatMapPlottable.heatMapSeries.meta.yAxis.bucketSize; | ||
| const fieldType = heatMapPlottable?.yAxisValueType ?? FALLBACK_TYPE; | ||
|
|
||
| const yAxisMinValueFormatted = formatTooltipValue( | ||
| value, | ||
| fieldType, | ||
| heatMapPlottable.yAxisValueUnit ?? undefined | ||
| ); | ||
| const yAxisMaxValueFormatted = formatTooltipValue( | ||
| value + bucketSize, | ||
| fieldType, | ||
| heatMapPlottable.yAxisValueUnit ?? undefined | ||
| ); | ||
|
|
||
| return `${yAxisMinValueFormatted} – ${yAxisMaxValueFormatted}`; | ||
| }, | ||
| truncate: false, | ||
| utc: utc ?? false, | ||
| })(params, asyncTicket); | ||
| const seriesParams = Array.isArray(params) ? params : [params]; | ||
|
|
||
| // Filter null values from tooltip | ||
| const filteredParams = seriesParams.filter(param => { | ||
| // @ts-expect-error ECharts types param.value as unknown, but we know it's [xAxis, yAxis, zAxis] from our HeatMap plottable | ||
| const value = extractValue(param.value[2]); | ||
| return value !== null; | ||
| }); | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| let formattedXValue = ECHARTS_MISSING_DATA_VALUE; | ||
|
|
||
| return renderToString( | ||
| <Fragment> | ||
| <div className="tooltip-series"> | ||
| {filteredParams.map(param => { | ||
| const xAxisBucketSize = heatMapPlottable.heatMapSeries.meta.xAxis.bucketSize; | ||
| const yAxisBucketSize = heatMapPlottable.heatMapSeries.meta.yAxis.bucketSize; | ||
| const yAxisUnit = heatMapPlottable?.yAxisValueUnit; | ||
| const yAxisValueType = heatMapPlottable?.yAxisValueType ?? FALLBACK_TYPE; | ||
|
nikkikapadia marked this conversation as resolved.
Outdated
|
||
|
|
||
| let rawXValue: number | undefined; | ||
| let rawYValue: number | undefined; | ||
|
|
||
| let formattedYValue = ECHARTS_MISSING_DATA_VALUE; | ||
| let formattedZValue = ECHARTS_MISSING_DATA_VALUE; | ||
| if (Array.isArray(param.value) && param.value.length === 3) { | ||
| const [xValue, yValue, zValue] = param.value; | ||
|
|
||
| if (defined(xValue) && typeof xValue === 'number') { | ||
| rawXValue = xValue; | ||
| // bucket size seems to be in seconds but we need to convert to milliseconds | ||
| formattedXValue = defaultFormatAxisLabel( | ||
| xValue, | ||
| true, | ||
| utc ?? false, | ||
| true, | ||
| false, | ||
| xAxisBucketSize * 1000 | ||
| ).toString(); | ||
| } | ||
|
|
||
| if (defined(yValue) && typeof yValue === 'number') { | ||
| rawYValue = yValue; | ||
| const yAxisMinValueFormatted = formatTooltipValue( | ||
| yValue, | ||
| yAxisValueType, | ||
| yAxisUnit ?? undefined | ||
| ); | ||
|
|
||
| if (yAxisBucketSize === 0) { | ||
| formattedYValue = yAxisMinValueFormatted; | ||
| } else { | ||
| const yAxisMaxValueFormatted = formatTooltipValue( | ||
| yValue + yAxisBucketSize, | ||
| yAxisValueType, | ||
| yAxisUnit ?? undefined | ||
| ); | ||
|
|
||
| formattedYValue = `${yAxisMinValueFormatted} – ${yAxisMaxValueFormatted}`; | ||
| } | ||
| } | ||
|
|
||
| if (defined(zValue) && typeof zValue === 'number') { | ||
| formattedZValue = formatAbbreviatedNumber(zValue, 4, false); | ||
| } | ||
|
nikkikapadia marked this conversation as resolved.
|
||
| } | ||
|
|
||
| let exploreLink: ReactNode; | ||
|
|
||
| if (defined(rawXValue) && defined(rawYValue)) { | ||
| const xAxisMaxValue = rawXValue + xAxisBucketSize * 1000; | ||
| const yAxisMaxValue = rawYValue + yAxisBucketSize; | ||
|
|
||
| const exploreUrlProps: GetExploreUrlArgs = { | ||
| selection: { | ||
| ...pageFilters.selection, | ||
| datetime: { | ||
| ...pageFilters.selection.datetime, | ||
| start: new Date(rawXValue), | ||
| end: new Date(xAxisMaxValue), | ||
| period: null, | ||
| }, | ||
| }, | ||
| organization, | ||
| // 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 | ||
| ...props.tooltipExploreUrlArgs, | ||
| crossEvents: props.tooltipExploreUrlArgs?.crossEvents?.map(crossEvent => { | ||
| if (crossEvent.type === 'metrics') { | ||
| return { | ||
| ...crossEvent, | ||
| query: | ||
| yAxisBucketSize === 0 | ||
| ? `value:<=${rawYValue}` | ||
| : `value:>=${rawYValue} value:<${yAxisMaxValue}`, | ||
| }; | ||
| } | ||
| return crossEvent; | ||
| }), | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| }; | ||
|
|
||
| const tracesLink = getExploreUrl(exploreUrlProps); | ||
| exploreLink = <a href={tracesLink}>{t('View related traces')}</a>; | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| return ( | ||
| <Fragment key={param.seriesIndex}> | ||
| <div> | ||
| <span className="tooltip-label"> | ||
| <strong>{formattedYValue}</strong> | ||
| </span>{' '} | ||
| {formattedZValue} | ||
| </div> | ||
| {exploreLink && ( | ||
| <div> | ||
| <span className="tooltip-label tooltip-label-centered"> | ||
| {exploreLink} | ||
| </span> | ||
| </div> | ||
| )} | ||
| </Fragment> | ||
| ); | ||
| })} | ||
| </div> | ||
| <div className="tooltip-footer tooltip-footer-centered">{formattedXValue}</div> | ||
| <div className="tooltip-arrow" /> | ||
| </Fragment> | ||
| ); | ||
| }; | ||
|
|
||
| return ( | ||
|
|
@@ -118,6 +246,8 @@ export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProp | |
| ref={chartRef} | ||
| tooltip={{ | ||
| show: true, | ||
| enterable: true, | ||
| extraCssText: 'pointer-events: auto !important;', | ||
|
cursor[bot] marked this conversation as resolved.
Outdated
|
||
| axisPointer: { | ||
| show: false, | ||
| }, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.