diff --git a/heatmapchart/src/components/HeatMapChart.exemplars.test.tsx b/heatmapchart/src/components/HeatMapChart.exemplars.test.tsx new file mode 100644 index 000000000..839d84264 --- /dev/null +++ b/heatmapchart/src/components/HeatMapChart.exemplars.test.tsx @@ -0,0 +1,276 @@ +// 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, 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'; +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 PinElsewhereButton(): ReactElement { + const { setLastTooltipPinnedCoords } = useChartsContext(); + return ( + + ); +} + +function chartElement(props: Partial = {}, enablePinning = true): 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.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.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.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'); + + 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(); + }); + + 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 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()); + + 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 11c56312b..e346f61ce 100644 --- a/heatmapchart/src/components/HeatMapChart.tsx +++ b/heatmapchart/src/components/HeatMapChart.tsx @@ -11,22 +11,25 @@ // 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, 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 type { EChartsCoreOption } from 'echarts/core'; +import { CustomChart, ScatterChart as EChartsScatterChart } from 'echarts/charts'; +import type { ECharts as EChartsInstance, EChartsCoreOption } from 'echarts/core'; import { graphic, use as registerECharts } from 'echarts/core'; +import isEqual from 'lodash/isEqual'; 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]); type CartesianCoordSys = CustomSeriesRenderItemParams['coordSys'] & { x: number; @@ -50,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 { @@ -62,11 +71,25 @@ export interface HeatMapDataItem { }; } +interface HoveredExemplar { + exemplar: Exemplar; + seriesLabels?: Labels; + /** The point actually plotted: [xIndex, exemplar value]. */ + 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; data: HeatMapDataItem[]; xAxisCategories: number[]; + /** Exemplar markers to render on top of the heatmap, if any. */ + exemplars?: HeatMapExemplarDataItem[]; yAxisFormat?: FormatOptions; countFormat?: FormatOptions; countMin?: number; @@ -83,6 +106,7 @@ export function HeatMapChart({ height, data, xAxisCategories, + exemplars, yAxisFormat, countFormat, countMin, @@ -93,18 +117,82 @@ export function HeatMapChart({ max, logBase, }: HeatMapChartProps): ReactElement | null { - const chartsTheme = useChartsTheme(); + const { chartsTheme, enablePinning, lastTooltipPinnedCoords, setLastTooltipPinnedCoords } = useChartsContext(); const theme = useTheme(); const { timeZone } = useTimeZone(); + const chartRef = useRef(undefined); + 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 => { + // 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; + 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, @@ -160,7 +248,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: [ @@ -222,6 +312,7 @@ export function HeatMapChart({ progressive: 1000, animation: false, }, + ...(exemplarSeries !== undefined ? [exemplarSeries] : []), ], }; }, [ @@ -239,6 +330,7 @@ export function HeatMapChart({ min, max, logBase, + exemplarSeries, ]); const chart = useMemo( @@ -253,10 +345,113 @@ 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}; + // 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 = { + 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, + }; + // 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); + 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; + } + + // 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. */} + {(activePinnedExemplar !== null || hoveredExemplar !== null) && ( + { + setPinnedExemplar(null); + setPinnedExemplarPos(null); + }} + /> + )} + {chart} + + ); } diff --git a/heatmapchart/src/components/HeatMapChartPanel.tsx b/heatmapchart/src/components/HeatMapChartPanel.tsx index c1d54b1f0..a5cf799cf 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,8 @@ export function HeatMapChartPanel(props: HeatMapChartPanelProps): ReactElement | } return { data, + // 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, @@ -228,6 +235,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/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 {} + }, + ); +} diff --git a/heatmapchart/src/utils/data-transform.test.ts b/heatmapchart/src/utils/data-transform.test.ts new file mode 100644 index 000000000..df77adc5d --- /dev/null +++ b/heatmapchart/src/utils/data-transform.test.ts @@ -0,0 +1,149 @@ +// 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, + isExemplarGroupInSeries, +} 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([]); + }); + + 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); + }); +}); + +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..203556ee5 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,128 @@ 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; +} + +/** 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. 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 || 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 = withoutMetricName(group.seriesLabels); + 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, + }; +}