From adccd78df87a1a5200fb07956de612b25e6c8ea7 Mon Sep 17 00:00:00 2001 From: Juliano Costa Date: Thu, 15 Oct 2026 14:42:12 +0200 Subject: [PATCH 1/5] [FEATURE] HeatMapChart: display exemplars as diamond markers (perses#3445) Render the exemplar data returned by time series queries (e.g. the PrometheusTimeSeriesQuery plugin) on top of the heatmap: - exemplars are drawn as white diamond scatter markers above the heatmap series, positioned at their timestamp (nearest X category) and observed value - hovering a marker opens the shared ExemplarMetadataTooltip (same component as the TimeSeriesChart), with click-to-pin/unpin support - the visual map is restricted to the heatmap series so exemplar markers keep their own style - exemplars outside the chart time range are dropped Unit tests cover the data transform (nearest-timestamp mapping, __name__ stripping) and the hover/pin/unpin lifecycle of the chart. Signed-off-by: Juliano Costa --- .../src/components/HeatMapChart.test.tsx | 172 ++++++++++++++++ heatmapchart/src/components/HeatMapChart.tsx | 189 ++++++++++++++++-- .../src/components/HeatMapChartPanel.tsx | 9 +- heatmapchart/src/utils/data-transform.test.ts | 110 ++++++++++ heatmapchart/src/utils/data-transform.ts | 108 +++++++++- 5 files changed, 570 insertions(+), 18 deletions(-) create mode 100644 heatmapchart/src/components/HeatMapChart.test.tsx create mode 100644 heatmapchart/src/utils/data-transform.test.ts diff --git a/heatmapchart/src/components/HeatMapChart.test.tsx b/heatmapchart/src/components/HeatMapChart.test.tsx new file mode 100644 index 000000000..d80b271e4 --- /dev/null +++ b/heatmapchart/src/components/HeatMapChart.test.tsx @@ -0,0 +1,172 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type * as ComponentsModule from '@perses-dev/components'; +import { ChartsProvider, testChartsTheme } from '@perses-dev/components'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import type { ECharts } from 'echarts/core'; +import { init } from 'echarts/core'; +import type { ReactElement } from 'react'; + +import type { HeatMapExemplarDataItem } from '../utils'; +import { getExemplarScatterSeries } from '../utils'; +import type { HeatMapDataItem, HeatMapChartProps } from './HeatMapChart'; +import { HeatMapChart } from './HeatMapChart'; + +vi.mock('@perses-dev/components', async (importOriginal) => ({ + ...(await importOriginal()), + ExemplarMetadataTooltip: ({ onUnpinClick }: { onUnpinClick?: () => void }): ReactElement => ( +
+ Exemplar tooltip +
+ ), +})); + +const DATA: HeatMapDataItem[] = [ + { value: [0, 0.1, 0.2, 4], label: '4' }, + { value: [1, 0.1, 0.2, 6], label: '6' }, +]; + +const CATEGORIES = [0, 60_000]; + +const EXEMPLARS: HeatMapExemplarDataItem[] = [ + { + value: [0, 0.15], + exemplar: { labels: { trace_id: 'trace-1' }, value: 0.15, timestamp: 5_000 }, + seriesLabels: { route: '/checkout' }, + }, + { + value: [1, 0.18], + exemplar: { labels: { trace_id: 'trace-2' }, value: 0.18, timestamp: 65_000 }, + seriesLabels: { route: '/checkout' }, + }, +]; + +function chartElement(props: Partial = {}): ReactElement { + return ( + + + + ); +} + +function mockChart(): { + resize: ReturnType; + setOption: ReturnType; + on: ReturnType; +} { + const chart = { + resize: vi.fn(), + setOption: vi.fn(), + getWidth: (): number => 500, + getHeight: (): number => 300, + containPixel: (): boolean => false, + dispose: vi.fn(), + isDisposed: (): boolean => false, + on: vi.fn(), + off: vi.fn(), + dispatchAction: vi.fn(), + }; + vi.mocked(init).mockReturnValue(chart as unknown as ECharts); + return chart; +} + +describe('HeatMapChart exemplars', () => { + it('adds the exemplar scatter series on top of the heatmap series', () => { + const chart = mockChart(); + render(chartElement()); + const option = chart.setOption.mock.calls.at(-1)?.[0]; + const heatmapSeries = option.series.find((series: { type: string }) => series.type === 'custom'); + const marker = option.series.find((series: { type: string }) => series.type === 'scatter'); + expect(heatmapSeries).toBeDefined(); + expect(marker).toBeDefined(); + // The exemplar scatter series renders after (above) the heatmap custom series. + expect(option.series.indexOf(marker)).toBeGreaterThan(option.series.indexOf(heatmapSeries)); + // The visual map only colors the heatmap series. + expect(option.visualMap.seriesIndex).toBe(0); + }); + + it('does not add the exemplar scatter series without exemplars', () => { + const chart = mockChart(); + render(chartElement({ exemplars: undefined })); + const option = chart.setOption.mock.calls.at(-1)?.[0]; + expect(option.series.find((series: { type: string }) => series.type === 'scatter')).toBeUndefined(); + }); + + it('shows the exemplar tooltip on hover, pins it on click and unpins from the tooltip', () => { + const chart = mockChart(); + const { container } = render(chartElement()); + + const marker = getExemplarScatterSeries(EXEMPLARS); + const mouseover = chart.on.mock.calls.find(([event]) => event === 'mouseover')?.[1]; + expect(mouseover).toBeTypeOf('function'); + act(() => + mouseover({ + componentType: 'series', + seriesType: 'scatter', + seriesId: marker.id, + data: (marker.data as HeatMapExemplarDataItem[])[0], + }), + ); + expect(screen.getByRole('tooltip')).toHaveTextContent('Exemplar tooltip'); + + // Clicking the canvas while hovering the marker pins the tooltip, and it stays + // visible after the mouse leaves the chart. + const wrapper = container.firstElementChild; + if (!(wrapper instanceof HTMLElement)) throw new Error('Chart wrapper is missing'); + const canvas = document.createElement('canvas'); + wrapper.appendChild(canvas); + fireEvent.click(canvas); + fireEvent.mouseLeave(wrapper); + expect(screen.getByRole('tooltip')).toHaveTextContent('Exemplar tooltip'); + + // Unpin from the button inside the tooltip. + fireEvent.click(screen.getByRole('button', { name: 'Unpin exemplar' })); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('unpins the pinned exemplar tooltip when clicking elsewhere on the canvas', () => { + const chart = mockChart(); + const { container } = render(chartElement()); + + const marker = getExemplarScatterSeries(EXEMPLARS); + const mouseover = chart.on.mock.calls.find(([event]) => event === 'mouseover')?.[1]; + act(() => + mouseover({ + componentType: 'series', + seriesType: 'scatter', + seriesId: marker.id, + data: (marker.data as HeatMapExemplarDataItem[])[0], + }), + ); + const wrapper = container.firstElementChild; + if (!(wrapper instanceof HTMLElement)) throw new Error('Chart wrapper is missing'); + const canvas = document.createElement('canvas'); + wrapper.appendChild(canvas); + fireEvent.click(canvas); // pin + expect(screen.getByRole('tooltip')).toHaveTextContent('Exemplar tooltip'); + + const mouseout = chart.on.mock.calls.find(([event]) => event === 'mouseout')?.[1]; + act(() => mouseout({ componentType: 'series', seriesType: 'scatter', seriesId: marker.id })); + expect(screen.getByRole('tooltip')).toHaveTextContent('Exemplar tooltip'); // pinned stays + fireEvent.click(canvas); // click elsewhere with no hover: unpins + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); +}); diff --git a/heatmapchart/src/components/HeatMapChart.tsx b/heatmapchart/src/components/HeatMapChart.tsx index 829d2abdf..b535b1979 100644 --- a/heatmapchart/src/components/HeatMapChart.tsx +++ b/heatmapchart/src/components/HeatMapChart.tsx @@ -11,22 +11,32 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { useTheme } from '@mui/material'; -import type { FormatOptions } from '@perses-dev/components'; -import { EChart, formatValue, useChartsTheme, useTimeZone } from '@perses-dev/components'; -import type { TimeScale } from '@perses-dev/spec'; +import { Box, useTheme } from '@mui/material'; +import type { CursorCoordinates, FormatOptions, OnEventsType } from '@perses-dev/components'; +import { + EChart, + ExemplarMetadataTooltip, + formatValue, + useChartsContext, + useChartsTheme, + useTimeZone, +} from '@perses-dev/components'; +import type { Exemplar, Labels, TimeScale } from '@perses-dev/spec'; import type { CustomSeriesRenderItemAPI, CustomSeriesRenderItemParams, CustomSeriesRenderItemReturn } from 'echarts'; -import { CustomChart } from 'echarts/charts'; +import { CustomChart, ScatterChart as EChartsScatterChart } from 'echarts/charts'; import type { EChartsCoreOption } from 'echarts/core'; +import type { ECharts as EChartsInstance } from 'echarts/core'; import { use as registerECharts } from 'echarts/core'; import type { ReactElement } from 'react'; -import { useMemo } from 'react'; +import { useMemo, useRef, useState } from 'react'; import type { LOG_BASE } from '../heat-map-chart-model'; import { getFormattedHeatmapAxisLabel } from '../utils'; +import type { HeatMapExemplarDataItem } from '../utils/data-transform'; +import { EXEMPLAR_SERIES_ID_PREFIX, EXEMPLAR_SYMBOL_SIZE, getExemplarScatterSeries } from '../utils/data-transform'; import { generateTooltipHTML } from './HeatMapTooltip'; -registerECharts([CustomChart]); +registerECharts([CustomChart, EChartsScatterChart]); // The default coloring is a blue->yellow->red gradient const DEFAULT_VISUAL_MAP_COLORS = [ @@ -43,6 +53,12 @@ const DEFAULT_VISUAL_MAP_COLORS = [ '#a50026', ]; +/** + * Maximum pixel distance from an exemplar marker center for the cursor to count as still + * hovering it (covers the full diamond bounding box, corners included). + */ +const EXEMPLAR_HOVER_RADIUS = (Math.SQRT2 * EXEMPLAR_SYMBOL_SIZE) / 2; + export type HeatMapData = [number, number, number, number | undefined]; // [xIndex, yLower, yUpper, count] export interface HeatMapDataItem { @@ -55,11 +71,20 @@ export interface HeatMapDataItem { }; } +interface HoveredExemplar { + exemplar: Exemplar; + seriesLabels?: Labels; + /** The point actually plotted: [xIndex, exemplar value]. */ + plotted: [number, number]; +} + export interface HeatMapChartProps { width: number; height: number; data: HeatMapDataItem[]; xAxisCategories: number[]; + /** Exemplar markers to render on top of the heatmap, if any. */ + exemplars?: HeatMapExemplarDataItem[]; yAxisFormat?: FormatOptions; countFormat?: FormatOptions; countMin?: number; @@ -76,6 +101,7 @@ export function HeatMapChart({ height, data, xAxisCategories, + exemplars, yAxisFormat, countFormat, countMin, @@ -86,18 +112,78 @@ export function HeatMapChart({ max, logBase, }: HeatMapChartProps): ReactElement | null { - const chartsTheme = useChartsTheme(); + const { chartsTheme, enablePinning } = useChartsContext(); const theme = useTheme(); const { timeZone } = useTimeZone(); + const chartRef = useRef(); + const [hoveredExemplar, setHoveredExemplar] = useState(null); + const [pinnedExemplar, setPinnedExemplar] = useState(null); + const [pinnedExemplarPos, setPinnedExemplarPos] = useState(null); + + const exemplarSeries = useMemo( + () => (exemplars !== undefined && exemplars.length > 0 ? getExemplarScatterSeries(exemplars) : undefined), + [exemplars], + ); + + const handleEvents = useMemo>(() => { + return { + mouseover: (params): void => { + if ( + params.componentType === 'series' && + params.seriesType === 'scatter' && + 'seriesId' in params && + typeof params.seriesId === 'string' && + params.seriesId.startsWith(EXEMPLAR_SERIES_ID_PREFIX) + ) { + // This series ID identifies the metadata embedded by getExemplarScatterSeries. + const point = params.data as { exemplar?: Exemplar; seriesLabels?: Labels; value?: [number, number] }; + if (point?.exemplar && point.value) { + setHoveredExemplar({ + exemplar: point.exemplar, + seriesLabels: point.seriesLabels, + plotted: point.value, + }); + } + return; + } + }, + mouseout: (params): void => { + if ( + params.componentType === 'series' && + params.seriesType === 'scatter' && + 'seriesId' in params && + typeof params.seriesId === 'string' && + params.seriesId.startsWith(EXEMPLAR_SERIES_ID_PREFIX) + ) { + setHoveredExemplar(null); + } + }, + globalout: (): void => { + // Cursor left the chart canvas — guarantee the exemplar tooltip is dismissed. + setHoveredExemplar(null); + }, + }; + }, []); + const option: EChartsCoreOption = useMemo(() => { return { tooltip: { appendToBody: true, - formatter: (params: { data: HeatMapDataItem; marker: string }): string => { + formatter: (params: { + data: HeatMapDataItem | HeatMapExemplarDataItem; + marker: string; + seriesId?: unknown; + }): string => { + // Exemplar markers are covered by their own ExemplarMetadataTooltip: hide the + // regular heatmap tooltip for them (an empty string renders no tooltip). + if (typeof params.seriesId === 'string' && params.seriesId.startsWith(EXEMPLAR_SERIES_ID_PREFIX)) { + return ''; + } + const item = params.data as HeatMapDataItem; return generateTooltipHTML({ - data: params.data.value, - label: params.data.label, + data: item.value, + label: item.label, marker: params.marker, xAxisCategories, theme, @@ -151,7 +237,9 @@ export function HeatMapChart({ textBorderColor: theme.palette.background.default, textBorderWidth: 5, }, - // Color by the count dimension (index 3) + // Color by the count dimension (index 3), and only the heatmap series: the + // exemplar scatter series keeps its own white/diamond marker style. + seriesIndex: 0, dimension: 3, }, series: [ @@ -200,6 +288,7 @@ export function HeatMapChart({ progressive: 1000, animation: false, }, + ...(exemplarSeries !== undefined ? [exemplarSeries] : []), ], }; }, [ @@ -217,6 +306,7 @@ export function HeatMapChart({ min, max, logBase, + exemplarSeries, ]); const chart = useMemo( @@ -231,10 +321,81 @@ export function HeatMapChart({ }} option={option} theme={chartsTheme.echartsTheme} + onEvents={handleEvents} + _instance={chartRef} /> ), - [chartsTheme.container.padding.default, chartsTheme.echartsTheme, height, option, width], + [chartsTheme.container.padding.default, chartsTheme.echartsTheme, height, option, width, handleEvents], ); - return <>{chart}; + return ( + { + if (hoveredExemplar !== null && e.target instanceof HTMLCanvasElement) { + // Clicking while hovering an exemplar marker pins/unpins the exemplar tooltip. + const pinnedPos: CursorCoordinates = { + page: { x: e.pageX, y: e.pageY }, + client: { x: e.clientX, y: e.clientY }, + plotCanvas: { x: e.nativeEvent.offsetX, y: e.nativeEvent.offsetY }, + target: e.target, + }; + const isUnpinClick = pinnedExemplar !== null && pinnedExemplar.exemplar === hoveredExemplar.exemplar; + // Compute the next state first and call the setters separately: updater functions + // may run more than once (e.g. StrictMode), so they must stay side-effect free. + setPinnedExemplar(isUnpinClick ? null : hoveredExemplar); + setPinnedExemplarPos(isUnpinClick ? null : pinnedPos); + return; + } + + // Clicking elsewhere on the canvas unpins a pinned exemplar tooltip. + if (pinnedExemplar !== null && e.target instanceof HTMLCanvasElement) { + setPinnedExemplar(null); + setPinnedExemplarPos(null); + } + }} + onMouseMove={(e) => { + if (!(e.target instanceof HTMLCanvasElement)) { + return; + } + // ECharts does not reliably emit mouseout when the cursor moves off an exemplar + // marker onto the plot area, which would keep the exemplar tooltip stuck on screen. + // Clear the hovered exemplar as soon as the cursor is no longer within its marker. + setHoveredExemplar((current) => { + if (current === null || chartRef.current === undefined) return current; + let markerPixel: number[] | undefined; + try { + markerPixel = chartRef.current.convertToPixel('grid', current.plotted); + } catch { + // Coordinates cannot be resolved (e.g. grid not ready yet), keep the current hover. + return current; + } + const [markerPixelX, markerPixelY] = markerPixel ?? []; + if (markerPixelX === undefined || markerPixelY === undefined) return current; + const distance = Math.hypot(e.nativeEvent.offsetX - markerPixelX, e.nativeEvent.offsetY - markerPixelY); + return distance <= EXEMPLAR_HOVER_RADIUS ? current : null; + }); + }} + onMouseLeave={() => { + // Defensive: clear the hovered exemplar in case ECharts missed a mouseout event. + setHoveredExemplar(null); + }} + > + {/* Pinned exemplar takes priority over hovered. */} + {(pinnedExemplar !== null || hoveredExemplar !== null) && ( + { + setPinnedExemplar(null); + setPinnedExemplarPos(null); + }} + /> + )} + {chart} + + ); } diff --git a/heatmapchart/src/components/HeatMapChartPanel.tsx b/heatmapchart/src/components/HeatMapChartPanel.tsx index c1d54b1f0..931b3f598 100644 --- a/heatmapchart/src/components/HeatMapChartPanel.tsx +++ b/heatmapchart/src/components/HeatMapChartPanel.tsx @@ -20,7 +20,8 @@ import { useMemo } from 'react'; import type { HeatMapChartOptions, LOG_BASE } from '../heat-map-chart-model'; import { DEFAULT_FORMAT } from '../heat-map-chart-model'; -import { generateCompleteTimestamps, getCommonTimeScaleForQueries } from '../utils'; +import { generateCompleteTimestamps, getCommonTimeScaleForQueries, getHeatMapExemplarData } from '../utils'; +import type { HeatMapExemplarDataItem } from '../utils'; import type { HeatMapDataItem } from './HeatMapChart'; import { HeatMapChart } from './HeatMapChart'; @@ -48,6 +49,7 @@ export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | const { data, + exemplarData, xAxisCategories, min, max, @@ -56,6 +58,7 @@ export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | timeScale, }: { data: HeatMapDataItem[]; + exemplarData: HeatMapExemplarDataItem[]; xAxisCategories: number[]; min?: number; max?: number; @@ -66,6 +69,7 @@ export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | if (!queryResults || queryResults.length === 0) { return { data: [], + exemplarData: [], xAxisCategories: [], min: 0, max: 0, @@ -82,6 +86,7 @@ export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | ) { return { data: [], + exemplarData: [], xAxisCategories: [], min: 0, max: 0, @@ -164,6 +169,7 @@ export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | } return { data, + exemplarData: getHeatMapExemplarData(queryResults[0]?.data.exemplars, xAxisCategories), xAxisCategories, min: lowestBound === Infinity ? undefined : lowestBound, max: highestBound === -Infinity ? undefined : highestBound, @@ -228,6 +234,7 @@ export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | width={contentDimensions.width} height={contentDimensions.height} data={data} + exemplars={exemplarData} xAxisCategories={xAxisCategories} yAxisFormat={yAxisFormat} countFormat={countFormat} diff --git a/heatmapchart/src/utils/data-transform.test.ts b/heatmapchart/src/utils/data-transform.test.ts new file mode 100644 index 000000000..a546f074c --- /dev/null +++ b/heatmapchart/src/utils/data-transform.test.ts @@ -0,0 +1,110 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { TimeSeriesExemplars } from '@perses-dev/spec'; +import { describe, expect, it } from 'vitest'; + +import { + EXEMPLAR_SERIES_ID_PREFIX, + findNearestTimestampIndex, + getExemplarScatterSeries, + getHeatMapExemplarData, +} from './data-transform'; + +const CATEGORIES = [0, 60_000, 120_000, 180_000, 240_000]; + +describe('findNearestTimestampIndex', () => { + it('returns the index of the exact timestamp', () => { + expect(findNearestTimestampIndex(CATEGORIES, 120_000)).toBe(2); + }); + + it('returns the closest category when the timestamp is between two', () => { + // 100s is closer to 120s (index 2) than to 60s (index 1) + expect(findNearestTimestampIndex(CATEGORIES, 100_000)).toBe(2); + // 70s is closer to 60s (index 1) + expect(findNearestTimestampIndex(CATEGORIES, 70_000)).toBe(1); + // exactly in the middle rounds down + expect(findNearestTimestampIndex(CATEGORIES, 90_000)).toBe(1); + }); + + it('returns -1 for timestamps outside the categories range', () => { + expect(findNearestTimestampIndex(CATEGORIES, -1)).toBe(-1); + expect(findNearestTimestampIndex(CATEGORIES, 240_001)).toBe(-1); + }); + + it('returns -1 for empty categories', () => { + expect(findNearestTimestampIndex([], 1000)).toBe(-1); + }); +}); + +describe('getHeatMapExemplarData', () => { + const groups: TimeSeriesExemplars[] = [ + { + seriesLabels: { __name__: 'demo_request_duration_seconds', route: '/checkout' }, + exemplars: [ + { labels: { trace_id: 'trace-1' }, value: 0.42, timestamp: 61_000 }, + { labels: { trace_id: 'trace-2' }, value: 1.5, timestamp: 250_000 }, + ], + }, + { + seriesLabels: { route: '/api' }, + exemplars: [{ labels: { trace_id: 'trace-3' }, value: 0.05, timestamp: 119_000 }], + }, + ]; + + it('maps exemplars to the nearest category index and keeps the exemplar for the tooltip', () => { + const items = getHeatMapExemplarData(groups, CATEGORIES); + // trace-2 at 250s is out of the categories range, so only trace-1 and trace-3 remain + expect(items).toHaveLength(2); + expect(items[0]).toMatchObject({ + value: [1, 0.42], + exemplar: { labels: { trace_id: 'trace-1' }, value: 0.42 }, + seriesLabels: { route: '/checkout' }, // __name__ stripped + }); + expect(items[1]).toMatchObject({ + value: [2, 0.05], + exemplar: { labels: { trace_id: 'trace-3' } }, + seriesLabels: { route: '/api' }, + }); + }); + + it('drops exemplars outside the chart time range', () => { + const items = getHeatMapExemplarData(groups, CATEGORIES); + // trace-2 at 250s is past the last category (240s) + expect(items.find((item) => item.exemplar.labels['trace_id'] === 'trace-2')).toBeUndefined(); + }); + + it('returns an empty list for undefined groups or categories', () => { + expect(getHeatMapExemplarData(undefined, CATEGORIES)).toEqual([]); + expect(getHeatMapExemplarData(groups, [])).toEqual([]); + }); +}); + +describe('getExemplarScatterSeries', () => { + it('builds a diamond scatter series with the exemplar series id and embedded metadata', () => { + const items = getHeatMapExemplarData( + [ + { + seriesLabels: { route: '/api' }, + exemplars: [{ labels: { trace_id: 'trace-3' }, value: 0.05, timestamp: 119_000 }], + }, + ], + CATEGORIES, + ); + const series = getExemplarScatterSeries(items); + expect(series.id).toBe(`${EXEMPLAR_SERIES_ID_PREFIX}heatmap`); + expect(series.type).toBe('scatter'); + expect(series.symbol).toBe('diamond'); + expect(series.data).toBe(items); + }); +}); diff --git a/heatmapchart/src/utils/data-transform.ts b/heatmapchart/src/utils/data-transform.ts index e5f0b59e1..01adcb55a 100644 --- a/heatmapchart/src/utils/data-transform.ts +++ b/heatmapchart/src/utils/data-transform.ts @@ -1,19 +1,20 @@ // Copyright The Perses Authors -// Licensed under the Apache License, Version 2.0 (the \"License\"); +// Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an \"AS IS\" BASIS, +// distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import { getCommonTimeScale } from '@perses-dev/components'; import type { PanelData } from '@perses-dev/plugin-system'; -import type { TimeScale, TimeSeriesData } from '@perses-dev/spec'; +import type { Exemplar, Labels, TimeScale, TimeSeriesData, TimeSeriesExemplars } from '@perses-dev/spec'; +import type { ScatterSeriesOption } from 'echarts/charts'; export function getCommonTimeScaleForQueries(queries: Array>): TimeScale | undefined { const seriesData = queries.map((query) => query.data); @@ -31,3 +32,104 @@ export function generateCompleteTimestamps(timescale?: TimeScale): number[] { } return timestamps; } + +/** + * Prefix of the ECharts series id used for the exemplar markers, so hover/pin + * event handlers can identify them (same convention as the TimeSeriesChart). + */ +export const EXEMPLAR_SERIES_ID_PREFIX = 'exemplar-'; + +/** Diamond marker size, in pixels (same as the TimeSeriesChart). */ +export const EXEMPLAR_SYMBOL_SIZE = 14; + +export interface HeatMapExemplarDataItem { + /** + * The plotted point: the index in the X axis categories (nearest timestamp) + * and the exemplar's observed value on the Y axis. + */ + value: [number, number]; + /** The original exemplar, kept for the metadata tooltip. */ + exemplar: Exemplar; + /** Labels of the exemplar's series (`__name__` stripped, like the TimeSeriesChart). */ + seriesLabels?: Labels; +} + +/** + * Finds the index of the category closest to `timestamp` in an ascending list of + * timestamps. Returns -1 when the timestamp is outside the categories range. + */ +export function findNearestTimestampIndex(categories: number[], timestamp: number): number { + if (categories.length === 0 || timestamp < (categories[0] ?? 0) || timestamp > (categories.at(-1) ?? 0)) { + return -1; + } + let low = 0; + let high = categories.length - 1; + while (low < high) { + const mid = (low + high) >> 1; + if ((categories[mid] ?? 0) < timestamp) { + low = mid + 1; + } else { + high = mid; + } + } + // `low` is the first category >= timestamp: pick it or its predecessor, whichever is closer. + const current = categories[low] ?? 0; + if (low > 0 && Math.abs((categories[low - 1] ?? 0) - timestamp) <= Math.abs(current - timestamp)) { + return low - 1; + } + return low; +} + +/** + * Converts the exemplar groups of the TimeSeriesData contract into chart-friendly + * data items for the heatmap: one flat list, since the heatmap renders a single + * series. Exemplars outside the chart time range are dropped. + */ +export function getHeatMapExemplarData( + groups: TimeSeriesExemplars[] | undefined, + xAxisCategories: number[], +): HeatMapExemplarDataItem[] { + if (!groups || xAxisCategories.length === 0) return []; + const items: HeatMapExemplarDataItem[] = []; + for (const group of groups) { + // Prometheus includes __name__ in exemplar series labels but omits it from range-query + // labels for expressions; strip it like the TimeSeriesChart does, so the metadata + // tooltip shows the same series labels on both panels. + const seriesLabels = Object.fromEntries( + Object.entries(group.seriesLabels).filter(([labelName]) => labelName !== '__name__'), + ); + for (const exemplar of group.exemplars) { + const xIndex = findNearestTimestampIndex(xAxisCategories, exemplar.timestamp); + if (xIndex < 0) continue; + items.push({ + value: [xIndex, exemplar.value], + exemplar, + seriesLabels, + }); + } + } + return items; +} + +/** + * Gets an ECharts scatter series rendering exemplar markers as diamonds on top of + * the heatmap. Each data item embeds its exemplar (and series labels) so the hover + * handlers can open the metadata tooltip. Rendered white with a dark border to stay + * visible over the heatmap gradient. + */ +export function getExemplarScatterSeries(data: HeatMapExemplarDataItem[]): ScatterSeriesOption { + return { + id: `${EXEMPLAR_SERIES_ID_PREFIX}heatmap`, + name: 'Exemplars', + type: 'scatter', + symbol: 'diamond', + symbolSize: EXEMPLAR_SYMBOL_SIZE, + z: 10, // render above the heatmap custom series + itemStyle: { + color: '#ffffff', + borderColor: '#000000', + borderWidth: 1, + }, + data, + }; +} From 7c378d1c080abee6df13071ea56e81ae19b152a1 Mon Sep 17 00:00:00 2001 From: Juliano Costa Date: Fri, 16 Oct 2026 15:13:34 +0200 Subject: [PATCH 2/5] [FEATURE] HeatMapChart: keep the tooltip confined to the chart (perses#3445) Add confine: true to the heatmap tooltip: without it, ECharts flips the tooltip over the cursor when hovering tiles near the edges of the chart, which covers the hovered tile and makes the tooltip look like it jumps around (most visible on small windows, and near the exemplar markers at the right of the time range). Signed-off-by: Juliano Costa --- heatmapchart/src/components/HeatMapChart.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/heatmapchart/src/components/HeatMapChart.tsx b/heatmapchart/src/components/HeatMapChart.tsx index b535b1979..6f57baaba 100644 --- a/heatmapchart/src/components/HeatMapChart.tsx +++ b/heatmapchart/src/components/HeatMapChart.tsx @@ -170,6 +170,10 @@ export function HeatMapChart({ return { tooltip: { appendToBody: true, + // Keep the tooltip inside the chart container: without confine, ECharts flips it over + // the cursor when hovering near the edges, which covers the hovered tile and looks + // like the tooltip jumps around (most visible on small windows). + confine: true, formatter: (params: { data: HeatMapDataItem | HeatMapExemplarDataItem; marker: string; From 3be9effc1670c02dfd822b5c92f8a2e5cd518cc9 Mon Sep 17 00:00:00 2001 From: Juliano Costa Date: Tue, 27 Oct 2026 11:27:37 +0100 Subject: [PATCH 3/5] HeatMapChart: respect pinning settings and sync exemplar pins (perses#3445) - Only pin exemplar tooltips when pinning is enabled in the charts context. - Share the pinned coordinates with adjacent charts and unpin when a tooltip is pinned elsewhere, matching the time series chart behaviour. - Remove the unused useChartsTheme import and merge the duplicate echarts/core imports, fixing the lint errors. - Stub ResizeObserver in the test setup so the HeatMapChart tests run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KvsQXfRjAyL31TAY5CvJ7z Signed-off-by: Juliano Costa --- .../src/components/HeatMapChart.test.tsx | 79 +++++++++++++++++-- heatmapchart/src/components/HeatMapChart.tsx | 53 +++++++++---- heatmapchart/src/setup-tests.ts | 11 +++ 3 files changed, 123 insertions(+), 20 deletions(-) diff --git a/heatmapchart/src/components/HeatMapChart.test.tsx b/heatmapchart/src/components/HeatMapChart.test.tsx index d80b271e4..24d259123 100644 --- a/heatmapchart/src/components/HeatMapChart.test.tsx +++ b/heatmapchart/src/components/HeatMapChart.test.tsx @@ -12,7 +12,7 @@ // limitations under the License. import type * as ComponentsModule from '@perses-dev/components'; -import { ChartsProvider, testChartsTheme } from '@perses-dev/components'; +import { ChartsProvider, testChartsTheme, useChartsContext } from '@perses-dev/components'; import { act, fireEvent, render, screen } from '@testing-library/react'; import type { ECharts } from 'echarts/core'; import { init } from 'echarts/core'; @@ -52,9 +52,28 @@ const EXEMPLARS: HeatMapExemplarDataItem[] = [ }, ]; -function chartElement(props: Partial = {}): ReactElement { +function PinElsewhereButton(): ReactElement { + const { setLastTooltipPinnedCoords } = useChartsContext(); return ( - + + ); +} + +function chartElement(props: Partial = {}, enablePinning = true): ReactElement { + return ( + + { // Clicking the canvas while hovering the marker pins the tooltip, and it stays // visible after the mouse leaves the chart. - const wrapper = container.firstElementChild; + const wrapper = container.querySelector('button + div'); if (!(wrapper instanceof HTMLElement)) throw new Error('Chart wrapper is missing'); const canvas = document.createElement('canvas'); wrapper.appendChild(canvas); @@ -156,7 +175,7 @@ describe('HeatMapChart exemplars', () => { data: (marker.data as HeatMapExemplarDataItem[])[0], }), ); - const wrapper = container.firstElementChild; + const wrapper = container.querySelector('button + div'); if (!(wrapper instanceof HTMLElement)) throw new Error('Chart wrapper is missing'); const canvas = document.createElement('canvas'); wrapper.appendChild(canvas); @@ -169,4 +188,54 @@ describe('HeatMapChart exemplars', () => { fireEvent.click(canvas); // click elsewhere with no hover: unpins expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); }); + + it('does not pin the exemplar tooltip when pinning is disabled', () => { + const chart = mockChart(); + const { container } = render(chartElement({}, false)); + + const marker = getExemplarScatterSeries(EXEMPLARS); + const mouseover = chart.on.mock.calls.find(([event]) => event === 'mouseover')?.[1]; + act(() => + mouseover({ + componentType: 'series', + seriesType: 'scatter', + seriesId: marker.id, + data: (marker.data as HeatMapExemplarDataItem[])[0], + }), + ); + const wrapper = container.querySelector('button + div'); + if (!(wrapper instanceof HTMLElement)) throw new Error('Chart wrapper is missing'); + const canvas = document.createElement('canvas'); + wrapper.appendChild(canvas); + fireEvent.click(canvas); + fireEvent.mouseLeave(wrapper); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('unpins the pinned exemplar tooltip when a tooltip is pinned in another chart', () => { + const chart = mockChart(); + const { container } = render(chartElement()); + + const marker = getExemplarScatterSeries(EXEMPLARS); + const mouseover = chart.on.mock.calls.find(([event]) => event === 'mouseover')?.[1]; + act(() => + mouseover({ + componentType: 'series', + seriesType: 'scatter', + seriesId: marker.id, + data: (marker.data as HeatMapExemplarDataItem[])[0], + }), + ); + const wrapper = container.querySelector('button + div'); + if (!(wrapper instanceof HTMLElement)) throw new Error('Chart wrapper is missing'); + const canvas = document.createElement('canvas'); + wrapper.appendChild(canvas); + fireEvent.click(canvas); // pin + fireEvent.mouseLeave(wrapper); + // Pinning here shares the coordinates without unpinning this chart's own tooltip. + expect(screen.getByRole('tooltip')).toHaveTextContent('Exemplar tooltip'); + + fireEvent.click(screen.getByRole('button', { name: 'Pin in another chart' })); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); }); diff --git a/heatmapchart/src/components/HeatMapChart.tsx b/heatmapchart/src/components/HeatMapChart.tsx index 6f57baaba..b84e026f4 100644 --- a/heatmapchart/src/components/HeatMapChart.tsx +++ b/heatmapchart/src/components/HeatMapChart.tsx @@ -13,20 +13,13 @@ import { Box, useTheme } from '@mui/material'; import type { CursorCoordinates, FormatOptions, OnEventsType } from '@perses-dev/components'; -import { - EChart, - ExemplarMetadataTooltip, - formatValue, - useChartsContext, - useChartsTheme, - useTimeZone, -} from '@perses-dev/components'; +import { EChart, ExemplarMetadataTooltip, formatValue, useChartsContext, useTimeZone } from '@perses-dev/components'; import type { Exemplar, Labels, TimeScale } from '@perses-dev/spec'; import type { CustomSeriesRenderItemAPI, CustomSeriesRenderItemParams, CustomSeriesRenderItemReturn } from 'echarts'; import { CustomChart, ScatterChart as EChartsScatterChart } from 'echarts/charts'; -import type { EChartsCoreOption } from 'echarts/core'; -import type { ECharts as EChartsInstance } from 'echarts/core'; +import type { ECharts as EChartsInstance, EChartsCoreOption } from 'echarts/core'; import { use as registerECharts } from 'echarts/core'; +import isEqual from 'lodash/isEqual'; import type { ReactElement } from 'react'; import { useMemo, useRef, useState } from 'react'; @@ -112,7 +105,7 @@ export function HeatMapChart({ max, logBase, }: HeatMapChartProps): ReactElement | null { - const { chartsTheme, enablePinning } = useChartsContext(); + const { chartsTheme, enablePinning, lastTooltipPinnedCoords, setLastTooltipPinnedCoords } = useChartsContext(); const theme = useTheme(); const { timeZone } = useTimeZone(); @@ -332,9 +325,34 @@ export function HeatMapChart({ [chartsTheme.container.padding.default, chartsTheme.echartsTheme, height, option, width, handleEvents], ); + // A pinned exemplar tooltip is unpinned when a tooltip is pinned in another chart, unless + // it is the one just pinned by this chart at these exact coordinates. + const [previousLastTooltipPinnedCoords, setPreviousLastTooltipPinnedCoords] = useState(lastTooltipPinnedCoords); + if (previousLastTooltipPinnedCoords !== lastTooltipPinnedCoords) { + setPreviousLastTooltipPinnedCoords(lastTooltipPinnedCoords); + if ( + pinnedExemplarPos !== null && + lastTooltipPinnedCoords !== null && + !isEqual(lastTooltipPinnedCoords, pinnedExemplarPos) + ) { + setPinnedExemplar(null); + setPinnedExemplarPos(null); + } + } + + // Pinning can be turned off while an exemplar tooltip is pinned: only show it while allowed. + const activePinnedExemplar = enablePinning ? pinnedExemplar : null; + return ( { + if (!enablePinning) { + return; + } + + // Allows the user to keep tooltips pinned in other charts when Ctrl or Cmd is held down. + const isControlKeyPressed = e.ctrlKey || e.metaKey; + if (hoveredExemplar !== null && e.target instanceof HTMLCanvasElement) { // Clicking while hovering an exemplar marker pins/unpins the exemplar tooltip. const pinnedPos: CursorCoordinates = { @@ -348,6 +366,11 @@ export function HeatMapChart({ // may run more than once (e.g. StrictMode), so they must stay side-effect free. setPinnedExemplar(isUnpinClick ? null : hoveredExemplar); setPinnedExemplarPos(isUnpinClick ? null : pinnedPos); + if (!isUnpinClick && !isControlKeyPressed) { + // Let adjacent charts know a tooltip is pinned at these coordinates, so only one + // tooltip is pinned at a time. + setLastTooltipPinnedCoords(pinnedPos); + } return; } @@ -385,13 +408,13 @@ export function HeatMapChart({ }} > {/* Pinned exemplar takes priority over hovered. */} - {(pinnedExemplar !== null || hoveredExemplar !== null) && ( + {(activePinnedExemplar !== null || hoveredExemplar !== null) && ( { setPinnedExemplar(null); diff --git a/heatmapchart/src/setup-tests.ts b/heatmapchart/src/setup-tests.ts index 78af56004..9bf3efef4 100644 --- a/heatmapchart/src/setup-tests.ts +++ b/heatmapchart/src/setup-tests.ts @@ -15,3 +15,14 @@ import '@testing-library/jest-dom/vitest'; // Always mock e-charts during tests since we don't have a proper canvas in jsdom vi.mock('echarts/core'); + +if (typeof ResizeObserver === 'undefined') { + vi.stubGlobal( + 'ResizeObserver', + class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + }, + ); +} From 41bb6047b261aed4129dd3b2a29e9dc0425adcd5 Mon Sep 17 00:00:00 2001 From: Juliano Costa Date: Mon, 2 Nov 2026 23:20:59 +0100 Subject: [PATCH 4/5] HeatMapChart: address exemplar review feedback - Compare pinned/hovered exemplars by value so clicking the same diamond still unpins its tooltip after a query refresh. - Only show exemplar groups whose series labels are consistent with the displayed histogram series (subset match, __name__ ignored), so queries like `h and on(route) enabled_routes` no longer show exemplars of excluded routes, while aggregated histograms keep their exemplars. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KvsQXgBWwSaTAQuQn94PFd Signed-off-by: Juliano Costa Signed-off-by: Juliano Costa --- .../src/components/HeatMapChart.test.tsx | 35 ++++++++++++++ heatmapchart/src/components/HeatMapChart.tsx | 9 +++- .../src/components/HeatMapChartPanel.tsx | 3 +- heatmapchart/src/utils/data-transform.test.ts | 47 +++++++++++++++++-- heatmapchart/src/utils/data-transform.ts | 34 ++++++++++++-- 5 files changed, 117 insertions(+), 11 deletions(-) diff --git a/heatmapchart/src/components/HeatMapChart.test.tsx b/heatmapchart/src/components/HeatMapChart.test.tsx index 24d259123..839d84264 100644 --- a/heatmapchart/src/components/HeatMapChart.test.tsx +++ b/heatmapchart/src/components/HeatMapChart.test.tsx @@ -212,6 +212,41 @@ describe('HeatMapChart exemplars', () => { expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); }); + it('unpins the pinned exemplar tooltip when clicking its marker again after a query refresh', () => { + const chart = mockChart(); + const { container, rerender } = render(chartElement()); + + const mouseover = chart.on.mock.calls.find(([event]) => event === 'mouseover')?.[1]; + const hover = (exemplars: HeatMapExemplarDataItem[]): void => { + const marker = getExemplarScatterSeries(exemplars); + act(() => + mouseover({ + componentType: 'series', + seriesType: 'scatter', + seriesId: marker.id, + data: (marker.data as HeatMapExemplarDataItem[])[0], + }), + ); + }; + hover(EXEMPLARS); + const wrapper = container.querySelector('button + div'); + if (!(wrapper instanceof HTMLElement)) throw new Error('Chart wrapper is missing'); + const canvas = document.createElement('canvas'); + wrapper.appendChild(canvas); + fireEvent.click(canvas); // pin + expect(screen.getByRole('tooltip')).toHaveTextContent('Exemplar tooltip'); + + // A refresh returns the same exemplars as new objects. + const refreshed = structuredClone(EXEMPLARS); + rerender(chartElement({ exemplars: refreshed })); + hover(refreshed); + fireEvent.click(canvas); // click the same diamond: unpins + fireEvent.mouseLeave(wrapper); + const mouseout = chart.on.mock.calls.find(([event]) => event === 'mouseout')?.[1]; + act(() => mouseout({ componentType: 'series', seriesType: 'scatter', seriesId: 'exemplar-heatmap' })); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + it('unpins the pinned exemplar tooltip when a tooltip is pinned in another chart', () => { const chart = mockChart(); const { container } = render(chartElement()); diff --git a/heatmapchart/src/components/HeatMapChart.tsx b/heatmapchart/src/components/HeatMapChart.tsx index b84e026f4..62d8d3414 100644 --- a/heatmapchart/src/components/HeatMapChart.tsx +++ b/heatmapchart/src/components/HeatMapChart.tsx @@ -71,6 +71,11 @@ interface HoveredExemplar { plotted: [number, number]; } +/** Whether two hovered exemplars are the same exemplar of the same series, by value. */ +function isSameExemplar(a: HoveredExemplar, b: HoveredExemplar): boolean { + return isEqual(a.exemplar, b.exemplar) && isEqual(a.seriesLabels, b.seriesLabels); +} + export interface HeatMapChartProps { width: number; height: number; @@ -361,7 +366,9 @@ export function HeatMapChart({ plotCanvas: { x: e.nativeEvent.offsetX, y: e.nativeEvent.offsetY }, target: e.target, }; - const isUnpinClick = pinnedExemplar !== null && pinnedExemplar.exemplar === hoveredExemplar.exemplar; + // Compare by value: a query refresh replaces the exemplar objects, and clicking the + // same pinned diamond must still unpin it. + const isUnpinClick = pinnedExemplar !== null && isSameExemplar(pinnedExemplar, hoveredExemplar); // Compute the next state first and call the setters separately: updater functions // may run more than once (e.g. StrictMode), so they must stay side-effect free. setPinnedExemplar(isUnpinClick ? null : hoveredExemplar); diff --git a/heatmapchart/src/components/HeatMapChartPanel.tsx b/heatmapchart/src/components/HeatMapChartPanel.tsx index 931b3f598..a5cf799cf 100644 --- a/heatmapchart/src/components/HeatMapChartPanel.tsx +++ b/heatmapchart/src/components/HeatMapChartPanel.tsx @@ -169,7 +169,8 @@ export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | } return { data, - exemplarData: getHeatMapExemplarData(queryResults[0]?.data.exemplars, xAxisCategories), + // Only the exemplars of the displayed histogram series, not every group Prometheus returns. + exemplarData: getHeatMapExemplarData(queryResults[0]?.data.exemplars, xAxisCategories, series.labels), xAxisCategories, min: lowestBound === Infinity ? undefined : lowestBound, max: highestBound === -Infinity ? undefined : highestBound, diff --git a/heatmapchart/src/utils/data-transform.test.ts b/heatmapchart/src/utils/data-transform.test.ts index a546f074c..df77adc5d 100644 --- a/heatmapchart/src/utils/data-transform.test.ts +++ b/heatmapchart/src/utils/data-transform.test.ts @@ -19,6 +19,7 @@ import { findNearestTimestampIndex, getExemplarScatterSeries, getHeatMapExemplarData, + isExemplarGroupInSeries, } from './data-transform'; const CATEGORIES = [0, 60_000, 120_000, 180_000, 240_000]; @@ -63,7 +64,7 @@ describe('getHeatMapExemplarData', () => { ]; it('maps exemplars to the nearest category index and keeps the exemplar for the tooltip', () => { - const items = getHeatMapExemplarData(groups, CATEGORIES); + const items = getHeatMapExemplarData(groups, CATEGORIES, {}); // trace-2 at 250s is out of the categories range, so only trace-1 and trace-3 remain expect(items).toHaveLength(2); expect(items[0]).toMatchObject({ @@ -79,14 +80,51 @@ describe('getHeatMapExemplarData', () => { }); it('drops exemplars outside the chart time range', () => { - const items = getHeatMapExemplarData(groups, CATEGORIES); + const items = getHeatMapExemplarData(groups, CATEGORIES, {}); // trace-2 at 250s is past the last category (240s) expect(items.find((item) => item.exemplar.labels['trace_id'] === 'trace-2')).toBeUndefined(); }); it('returns an empty list for undefined groups or categories', () => { - expect(getHeatMapExemplarData(undefined, CATEGORIES)).toEqual([]); - expect(getHeatMapExemplarData(groups, [])).toEqual([]); + expect(getHeatMapExemplarData(undefined, CATEGORIES, {})).toEqual([]); + expect(getHeatMapExemplarData(groups, [], {})).toEqual([]); + }); + + it('keeps every group for an aggregated histogram without labels', () => { + // e.g. sum(rate(demo_request_duration_seconds[5m])) + const items = getHeatMapExemplarData(groups, CATEGORIES, {}); + expect(items.map((item) => item.exemplar.labels['trace_id'])).toEqual(['trace-1', 'trace-3']); + }); + + it('drops groups excluded from the displayed histogram', () => { + // e.g. demo_request_duration_seconds and on(route) enabled_routes, with only /checkout + // enabled: Prometheus still returns the exemplars of /api, which must not be displayed. + const items = getHeatMapExemplarData(groups, CATEGORIES, { + __name__: 'demo_request_duration_seconds', + route: '/checkout', + }); + expect(items.map((item) => item.exemplar.labels['trace_id'])).toEqual(['trace-1']); + }); + + it('returns an empty list without the displayed series labels', () => { + expect(getHeatMapExemplarData(groups, CATEGORIES, undefined)).toEqual([]); + }); +}); + +describe('isExemplarGroupInSeries', () => { + it('matches when the displayed series labels are a subset of the group labels', () => { + expect(isExemplarGroupInSeries({ route: '/api', instance: 'a' }, { route: '/api' })).toBe(true); + expect(isExemplarGroupInSeries({ route: '/api' }, {})).toBe(true); + }); + + it('does not match a different or missing label value', () => { + expect(isExemplarGroupInSeries({ route: '/api' }, { route: '/checkout' })).toBe(false); + expect(isExemplarGroupInSeries({ instance: 'a' }, { route: '/api' })).toBe(false); + }); + + it('ignores __name__ on both sides', () => { + expect(isExemplarGroupInSeries({ route: '/api' }, { __name__: 'h', route: '/api' })).toBe(true); + expect(isExemplarGroupInSeries({ __name__: 'other', route: '/api' }, { __name__: 'h', route: '/api' })).toBe(true); }); }); @@ -100,6 +138,7 @@ describe('getExemplarScatterSeries', () => { }, ], CATEGORIES, + {}, ); const series = getExemplarScatterSeries(items); expect(series.id).toBe(`${EXEMPLAR_SERIES_ID_PREFIX}heatmap`); diff --git a/heatmapchart/src/utils/data-transform.ts b/heatmapchart/src/utils/data-transform.ts index 01adcb55a..203556ee5 100644 --- a/heatmapchart/src/utils/data-transform.ts +++ b/heatmapchart/src/utils/data-transform.ts @@ -80,24 +80,48 @@ export function findNearestTimestampIndex(categories: number[], timestamp: numbe return low; } +/** Returns the labels without `__name__`. */ +function withoutMetricName(labels: Labels): Labels { + return Object.fromEntries(Object.entries(labels).filter(([labelName]) => labelName !== '__name__')); +} + +/** + * Whether an exemplar group belongs to the displayed histogram series: every label of the + * displayed series must be present, with the same value, in the group's series labels. + * `__name__` is ignored on both sides (Prometheus drops it from expression results). + * + * Prometheus fetches exemplars from the query's selectors, not from the evaluated expression, + * so a query like `h and on(route) enabled_routes` also returns exemplars for routes the + * histogram excludes. Requiring the displayed labels to be a subset of the group labels drops + * those, while still keeping exemplars for aggregated histograms: `sum by (route) (rate(h[5m]))` + * keeps the groups of its route, and `sum(rate(h[5m]))` (no labels) keeps every group. + */ +export function isExemplarGroupInSeries(groupLabels: Labels, displayedSeriesLabels: Labels): boolean { + return Object.entries(displayedSeriesLabels).every( + ([labelName, labelValue]) => labelName === '__name__' || groupLabels[labelName] === labelValue, + ); +} + /** * Converts the exemplar groups of the TimeSeriesData contract into chart-friendly * data items for the heatmap: one flat list, since the heatmap renders a single - * series. Exemplars outside the chart time range are dropped. + * series. Only groups belonging to the displayed histogram series are kept (see + * `isExemplarGroupInSeries`), and exemplars outside the chart time range are dropped. + * Without the displayed series labels, groups cannot be matched and none are kept. */ export function getHeatMapExemplarData( groups: TimeSeriesExemplars[] | undefined, xAxisCategories: number[], + displayedSeriesLabels: Labels | undefined, ): HeatMapExemplarDataItem[] { - if (!groups || xAxisCategories.length === 0) return []; + if (!groups || xAxisCategories.length === 0 || displayedSeriesLabels === undefined) return []; const items: HeatMapExemplarDataItem[] = []; for (const group of groups) { + if (!isExemplarGroupInSeries(group.seriesLabels, displayedSeriesLabels)) continue; // Prometheus includes __name__ in exemplar series labels but omits it from range-query // labels for expressions; strip it like the TimeSeriesChart does, so the metadata // tooltip shows the same series labels on both panels. - const seriesLabels = Object.fromEntries( - Object.entries(group.seriesLabels).filter(([labelName]) => labelName !== '__name__'), - ); + const seriesLabels = withoutMetricName(group.seriesLabels); for (const exemplar of group.exemplars) { const xIndex = findNearestTimestampIndex(xAxisCategories, exemplar.timestamp); if (xIndex < 0) continue; From 667596fa7ec40d719424651fc2d35fd2d15c11a5 Mon Sep 17 00:00:00 2001 From: Juliano Costa Date: Tue, 3 Nov 2026 07:04:52 +0000 Subject: [PATCH 5/5] HeatMapChart: give the chart ref an initial value for React 19 React 19 types require an argument for useRef. Use the same form as the other chart plugins. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KvsQXgBWwSaTAQuQn94PFd Signed-off-by: Juliano Costa --- heatmapchart/src/components/HeatMapChart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/heatmapchart/src/components/HeatMapChart.tsx b/heatmapchart/src/components/HeatMapChart.tsx index 62d8d3414..d27bb6fc3 100644 --- a/heatmapchart/src/components/HeatMapChart.tsx +++ b/heatmapchart/src/components/HeatMapChart.tsx @@ -114,7 +114,7 @@ export function HeatMapChart({ const theme = useTheme(); const { timeZone } = useTimeZone(); - const chartRef = useRef(); + const chartRef = useRef(undefined); const [hoveredExemplar, setHoveredExemplar] = useState(null); const [pinnedExemplar, setPinnedExemplar] = useState(null); const [pinnedExemplarPos, setPinnedExemplarPos] = useState(null);