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/package.json b/packages/app/package.json index 3def64f2da..13c4b3a449 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -14,7 +14,7 @@ "build:clickhouse": "NEXT_PUBLIC_THEME=clickstack NEXT_PUBLIC_IS_LOCAL_MODE=true NEXT_PUBLIC_CLICKHOUSE_BUILD=true next build --webpack && node scripts/prepare-clickhouse-build-export.js", "run:clickhouse": "test -d out && npx rimraf tmp && mkdir tmp && cp -r out tmp/clickstack && echo 'visit http://localhost:3000/clickstack to start' && npx serve tmp -l 3000 || echo 'run build:clickhouse first'", "start": "next start", - "lint": "npx eslint . --ext .ts,.tsx --max-warnings 663", + "lint": "npx eslint . --ext .ts,.tsx --max-warnings 668", "lint:fix": "npx eslint . --ext .ts,.tsx --fix", "lint:styles": "stylelint **/*/*.{css,scss}", "ci:lint": "yarn lint && yarn tsc --noEmit && yarn lint:styles --quiet", diff --git a/packages/app/src/HDXMultiSeriesTimeChart/MemoChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart/MemoChart.tsx index 4fd8a77376..476d998bdb 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,15 @@ export const MemoChart = memo(function MemoChart({ granularity, dateRangeEndInclusive = true, fitYAxisToData = false, + exemplars, + maxExemplars = 12, + onExemplarHover, + onExemplarHoverEnd, + onExemplarSelect, + pinnedExemplarKey = null, + onExemplarPinEnd, + onActiveExemplarMoved, + 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 +157,31 @@ 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; + /** See useExemplarMarkers: re-anchors the open card when its marker moves. */ + onActiveExemplarMoved?: (cx: number, cy: number) => 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 +282,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 +452,31 @@ export const MemoChart = memo(function MemoChart({ return map; }, [lineData]); + // Exemplar marker layer — see useExemplarMarkers. + const { + activeExemplarKey, + exemplarPoints, + isExemplarHovered, + handleExemplarHoverStart, + handleExemplarHoverEnd, + handleExemplarSelect, + } = useExemplarMarkers({ + exemplars, + maxExemplars, + granularity, + pinnedExemplarKey, + xAxisDomain, + exemplarYBounds, + onExemplarHover, + onExemplarHoverEnd, + onExemplarSelect, + onExemplarPinEnd, + onActiveExemplarMoved, + onExemplarsDropped, + suppressNextClickRef, + brushOriginRef: mouseDownPosRef, + }); + return (