From bffb63887501a724dbfc474d855375c1c15dd2bb Mon Sep 17 00:00:00 2001 From: Jordan Simonovski Date: Wed, 5 Aug 2026 17:03:21 +1000 Subject: [PATCH 1/7] feat(app): exemplar overlay for metric and PromQL time charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Time charts on metric and PromQL sources can overlay exemplars — individual trace-linked points — behind NEXT_PUBLIC_ENABLE_EXEMPLARS, off by default, and per-chart behind enableExemplars. Hovering a marker shows the exemplar's own value and time plus trace metadata, with a button to open the trace. The shared common-utils primitives ship here rather than separately: every one of those exports is consumed only by the app, so landing them alone would fail the unused-export check. The rule the whole feature turns on is that a marker sits at the trace's own measurement on the chart's shared axis, so it is only honest when the chart draws one line in the same unit. That is enforced in four places: a single non-ratio histogram series with no group by; an aggregation that leaves the axis on the observation scale (a count of observations is not a duration); for PromQL an expression that plots a duration, with the duration call spanning the whole expression; and a rendered-series count taken from the main query rather than the exemplar response, since Prometheus only returns series that carry a sampled exemplar and so cannot say how many lines are drawn. Where a marker cannot be drawn honestly it is dropped, not moved. Out of the rendered window by more than one bucket, or below a fitted y-axis floor, and it does not render — with the count surfaced on the chart, because a silently thinning overlay is worse than an explained one. The exception is above the ceiling, where pinning reads as "at least this high" and the card carries the real number. Includes the four rounds of review fixes from #2536: the hover card showing value and time (both clamps cite it as their justification), the PromQL aggregation parsing that decides series identity, the ClickHouse scan's ex_TimeUnix bound, the query-key quantisation, and the card lifecycle across zoom, live tail and display-type switches. make ci-lint and make ci-unit pass (5350 tests). Dashboard E2E 80 passed, 0 failed, 2 unrelated flakes on listing-page specs. Stacked on the chart-file refactor so this reads as a feature diff. --- .changeset/exemplar-overlay.md | 25 + .../src/HDXMultiSeriesTimeChart/MemoChart.tsx | 138 ++- .../HDXMultiSeriesTimeChart/useChartScales.ts | 60 +- .../useExemplarMarkers.ts | 197 +++++ .../__tests__/DBSearchPageQueryKey.test.tsx | 7 + packages/app/src/api.ts | 43 +- .../components/DBTimeChart/DBTimeChart.tsx | 84 +- .../__tests__/DBTimeChart.test.tsx | 9 + .../__tests__/DBTimeChartExemplarPin.test.tsx | 285 +++++++ .../DBTimeChartExemplarTrace.test.tsx | 280 +++++++ .../DBTimeChart/useChartToolbarItems.tsx | 28 +- .../components/DBTimeChart/useExemplarCard.ts | 290 +++++++ .../Exemplars/ExemplarDot.stories.tsx | 69 ++ .../src/components/Exemplars/ExemplarDot.tsx | 61 ++ .../Exemplars/ExemplarHoverCard.stories.tsx | 103 +++ .../Exemplars/ExemplarHoverCard.tsx | 175 ++++ .../Exemplars/__tests__/ExemplarDot.test.tsx | 66 ++ .../__tests__/ExemplarHoverCard.test.tsx | 89 ++ .../__tests__/exemplarPoints.test.ts | 385 +++++++++ .../__tests__/promqlSeriesLabels.test.ts | 167 ++++ .../components/Exemplars/exemplarPoints.ts | 272 ++++++ .../app/src/components/Exemplars/index.ts | 10 + .../Exemplars/promqlSeriesLabels.ts | 163 ++++ packages/app/src/config.ts | 4 + packages/app/src/defaults.ts | 2 + .../__tests__/useExemplars.test.tsx | 786 ++++++++++++++++++ .../hooks/useExemplars/exemplarNormalize.ts | 224 +++++ packages/app/src/hooks/useExemplars/index.ts | 14 + .../app/src/hooks/useExemplars/quantize.ts | 28 + .../useExemplars/useExemplarTraceMeta.ts | 69 ++ .../src/hooks/useExemplars/useExemplars.tsx | 219 +++++ .../src/__tests__/renderChartConfig.test.ts | 523 ++++++++++++ .../src/core/renderChartConfig.ts | 351 ++++++++ packages/common-utils/src/types.ts | 40 + 34 files changed, 5224 insertions(+), 42 deletions(-) create mode 100644 .changeset/exemplar-overlay.md create mode 100644 packages/app/src/HDXMultiSeriesTimeChart/useExemplarMarkers.ts create mode 100644 packages/app/src/components/DBTimeChart/__tests__/DBTimeChartExemplarPin.test.tsx create mode 100644 packages/app/src/components/DBTimeChart/__tests__/DBTimeChartExemplarTrace.test.tsx create mode 100644 packages/app/src/components/DBTimeChart/useExemplarCard.ts create mode 100644 packages/app/src/components/Exemplars/ExemplarDot.stories.tsx create mode 100644 packages/app/src/components/Exemplars/ExemplarDot.tsx create mode 100644 packages/app/src/components/Exemplars/ExemplarHoverCard.stories.tsx create mode 100644 packages/app/src/components/Exemplars/ExemplarHoverCard.tsx create mode 100644 packages/app/src/components/Exemplars/__tests__/ExemplarDot.test.tsx create mode 100644 packages/app/src/components/Exemplars/__tests__/ExemplarHoverCard.test.tsx create mode 100644 packages/app/src/components/Exemplars/__tests__/exemplarPoints.test.ts create mode 100644 packages/app/src/components/Exemplars/__tests__/promqlSeriesLabels.test.ts create mode 100644 packages/app/src/components/Exemplars/exemplarPoints.ts create mode 100644 packages/app/src/components/Exemplars/index.ts create mode 100644 packages/app/src/components/Exemplars/promqlSeriesLabels.ts create mode 100644 packages/app/src/hooks/useExemplars/__tests__/useExemplars.test.tsx create mode 100644 packages/app/src/hooks/useExemplars/exemplarNormalize.ts create mode 100644 packages/app/src/hooks/useExemplars/index.ts create mode 100644 packages/app/src/hooks/useExemplars/quantize.ts create mode 100644 packages/app/src/hooks/useExemplars/useExemplarTraceMeta.ts create mode 100644 packages/app/src/hooks/useExemplars/useExemplars.tsx diff --git a/.changeset/exemplar-overlay.md b/.changeset/exemplar-overlay.md new file mode 100644 index 0000000000..bfb30ad023 --- /dev/null +++ b/.changeset/exemplar-overlay.md @@ -0,0 +1,25 @@ +--- +'@hyperdx/common-utils': minor +'@hyperdx/app': minor +--- + +feat: exemplar overlay for metric and PromQL time charts + +Time charts on metric and PromQL sources can overlay exemplars — individual +trace-linked data points — via the "Exemplars" toggle in the chart editor. +Hovering a marker shows the exemplar's own value and time plus trace metadata +from a configurable trace source, with a button to open the trace. + +Off by default for the whole deployment behind `NEXT_PUBLIC_ENABLE_EXEMPLARS`, +and per-chart behind `enableExemplars`. + +Markers are sampled the way Grafana samples them: bucketed at the chart's +granularity, keeping the slowest trace in each bucket plus any further trace more +than 2σ below it. The overlay shows the shape of the latency distribution rather +than tracing the top of the chart. + +A marker sits at the trace's own measurement, so it is only shown where that is +honest: a single non-ratio histogram series with no group by, aggregated in a way +that leaves the axis on the observation scale, and for PromQL an expression that +plots a duration. Markers outside the rendered window, or below a fitted y-axis +floor, are dropped rather than moved — and the count is surfaced on the chart. diff --git a/packages/app/src/HDXMultiSeriesTimeChart/MemoChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart/MemoChart.tsx index 4fd8a77376..5ba2c58fa0 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart/MemoChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart/MemoChart.tsx @@ -15,19 +15,21 @@ import { CartesianGrid, Legend, ReferenceArea, + ReferenceDot, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis, } from 'recharts'; -import { DisplayType } from '@hyperdx/common-utils/dist/types'; +import { DisplayType, Exemplar } from '@hyperdx/common-utils/dist/types'; import { useChartSyncId } from '@/chartSync'; import { findNearestSeriesKey, LineData } from '@/ChartUtils'; import { ChartAnnotation } from '@/components/charts/chartAnnotations'; import { ChartOverlayControls } from '@/components/charts/ChartOverlayControls'; import { toViewportPoint } from '@/components/charts/ChartTooltip'; +import { ExemplarDot } from '@/components/Exemplars'; import type { NumberFormat } from '@/types'; import { useFormatTime } from '@/useFormatTime'; import { COLORS, formatNumber } from '@/utils'; @@ -51,6 +53,7 @@ import { Y_AXIS_WIDTH, } from './constants'; import { useChartScales } from './useChartScales'; +import { useExemplarMarkers } from './useExemplarMarkers'; // Debounce (ms) for the chart's ResponsiveContainer resize observer. Without // it the observer fires on every frame, and a resize → re-render → resize @@ -108,6 +111,14 @@ export const MemoChart = memo(function MemoChart({ granularity, dateRangeEndInclusive = true, fitYAxisToData = false, + exemplars, + maxExemplars = 12, + onExemplarHover, + onExemplarHoverEnd, + onExemplarSelect, + pinnedExemplarKey = null, + onExemplarPinEnd, + onExemplarsDropped, }: { // Matches what useChartScales narrows to, so the hook's stricter type is // actually checked at this boundary rather than satisfied by `any`. @@ -145,9 +156,29 @@ export const MemoChart = memo(function MemoChart({ * (with padding) instead of zero. **/ fitYAxisToData?: boolean; + /** Exemplar markers to overlay on the chart (linked to traces). */ + exemplars?: Exemplar[]; + /** Target number of exemplar markers to show (0 = unlimited). */ + maxExemplars?: number; + /** Invoked when the cursor enters an exemplar marker, with its pixel coords. */ + onExemplarHover?: (exemplar: Exemplar, cx: number, cy: number) => void; + /** Invoked when the cursor leaves an exemplar marker. */ + onExemplarHoverEnd?: () => void; + /** Invoked when an exemplar marker is clicked, with its pixel coords. */ + onExemplarSelect?: (exemplar: Exemplar, cx: number, cy: number) => void; + /** + * Key of the exemplar whose card is pinned open, or null. A key rather than a + * boolean so the chart can tell when that marker stops being rendered — see + * the reset effect below. A pin also suppresses the series tooltip. + */ + pinnedExemplarKey?: string | null; + /** Invoked when the pinned marker is no longer in the rendered set. */ + onExemplarPinEnd?: () => void; + /** How many markers the render-layer clamps dropped; see useExemplarMarkers. */ + onExemplarsDropped?: (count: number) => void; }) { - const _id = useId(); - const id = _id.replace(/:/g, ''); + const rawId = useId(); + const id = rawId.replace(/:/g, ''); // recharts sync group, scoped via context (see chartSync). const syncId = useChartSyncId(); @@ -248,18 +279,21 @@ export const MemoChart = memo(function MemoChart({ captureActivePointY, ]); - // Axis domains and annotation elements — see useChartScales. - const { yAxisDomain, xAxisDomain, annotationElements } = useChartScales({ - annotations, - dateRange, - granularity, - dateRangeEndInclusive, - displayType, - fitYAxisToData, - graphResults, - lineData, - selectedSeriesNames, - }); + // Axis domains, the exemplar clamp range, and annotation elements — see + // useChartScales. + const { yAxisDomain, exemplarYBounds, xAxisDomain, annotationElements } = + useChartScales({ + annotations, + dateRange, + granularity, + dateRangeEndInclusive, + displayType, + fitYAxisToData, + graphResults, + lineData, + selectedSeriesNames, + hasExemplars: !!exemplars?.length, + }); const [containerWidth, setContainerWidth] = useState(0); @@ -415,6 +449,29 @@ export const MemoChart = memo(function MemoChart({ return map; }, [lineData]); + // Exemplar marker layer — see useExemplarMarkers. + const { + exemplarPoints, + isExemplarHovered, + handleExemplarHoverStart, + handleExemplarHoverEnd, + handleExemplarSelect, + } = useExemplarMarkers({ + exemplars, + maxExemplars, + granularity, + pinnedExemplarKey, + xAxisDomain, + exemplarYBounds, + onExemplarHover, + onExemplarHoverEnd, + onExemplarSelect, + onExemplarPinEnd, + onExemplarsDropped, + suppressNextClickRef, + brushOriginRef: mouseDownPosRef, + }); + return (