Repository navigation
feat(app): exemplar overlay for metric and PromQL time charts #2808
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8d416c6
a0a13d1
8137157
190df61
4f4e243
8e582f5
551d611
2ce0c86
80c16a5
dccc735
0b544c9
62b4c59
ccb184f
1933e7e
7cc91dd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| --- | ||
| '@hyperdx/common-utils': minor | ||
| '@hyperdx/app': minor | ||
| '@hyperdx/api': patch | ||
| --- | ||
|
|
||
| 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 "Show exemplars" toggle in the chart | ||
| editor's display settings. 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 the toggle. How many markers a chart draws is a team | ||
| setting, Max Exemplars per Chart. | ||
|
|
||
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,7 @@ import { | |
| Customized, | ||
| Legend, | ||
| ReferenceArea, | ||
| ReferenceDot, | ||
| ReferenceLine, | ||
| ResponsiveContainer, | ||
| Tooltip, | ||
|
|
@@ -25,6 +26,7 @@ import { | |
| } from 'recharts'; | ||
| import { | ||
| DisplayType, | ||
| Exemplar, | ||
| LineInterpolation, | ||
| } from '@hyperdx/common-utils/dist/types'; | ||
|
|
||
|
|
@@ -38,6 +40,7 @@ import { AnnotationTooltip } from '@/components/charts/AnnotationTooltip'; | |
| 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 { useContentFontSize } from '@/useUserPreferences'; | ||
|
|
@@ -67,6 +70,7 @@ import { | |
| Y_AXIS_WIDTH, | ||
| } from './constants'; | ||
| import { getLineCurveType, useChartScales } from './useChartScales'; | ||
| import { useExemplarMarkers } from './useExemplarMarkers'; | ||
|
|
||
| import styles from '@styles/HDXLineChart.module.scss'; | ||
|
|
||
|
|
@@ -132,6 +136,15 @@ export const MemoChart = memo(function MemoChart({ | |
| granularity, | ||
| dateRangeEndInclusive = true, | ||
| fitYAxisToData = false, | ||
| exemplars, | ||
| maxExemplars = 12, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Marker-budget default duplicated instead of importing
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Default maxExemplars = 12 duplicates DEFAULT_MAX_EXEMPLARS Use Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond |
||
| onExemplarHover, | ||
| onExemplarHoverEnd, | ||
| onExemplarSelect, | ||
| pinnedExemplarKey = null, | ||
| onExemplarPinEnd, | ||
| onActiveExemplarMoved, | ||
| onExemplarsDropped, | ||
| lineInterpolation, | ||
| }: { | ||
| // Matches what useChartScales narrows to, so the hook's stricter type is | ||
|
|
@@ -179,11 +192,33 @@ 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; | ||
| /** Unset follows the display type's default (see getLineCurveType). */ | ||
| lineInterpolation?: LineInterpolation; | ||
| }) { | ||
| const _id = useId(); | ||
| const id = _id.replace(/:/g, ''); | ||
| const rawId = useId(); | ||
| const id = rawId.replace(/:/g, ''); | ||
|
|
||
| // The y-axis width budget is expressed in characters (see MAX_AXIS_MANTISSA), | ||
| // so it has to grow with the tick font or the widest labels clip. | ||
|
|
@@ -316,24 +351,31 @@ export const MemoChart = memo(function MemoChart({ | |
|
|
||
| const [containerWidth, setContainerWidth] = useState(0); | ||
|
|
||
| // Axis domains and annotation elements — see useChartScales. | ||
| const { yAxisBounds, xAxisDomain, annotationElements, laidOutAnnotations } = | ||
| useChartScales({ | ||
| annotations, | ||
| containerWidth, | ||
| dateRange, | ||
| granularity, | ||
| dateRangeEndInclusive, | ||
| displayType, | ||
| fitYAxisToData, | ||
| graphResults, | ||
| lineData, | ||
| visibleLineData, | ||
| selectedSeriesNames, | ||
| referenceLineValues, | ||
| axisNumberFormat, | ||
| yAxisWidth, | ||
| }); | ||
| // Axis domains, the exemplar clamp range, and annotation elements — see | ||
| // useChartScales. | ||
| const { | ||
| yAxisBounds, | ||
| exemplarYBounds, | ||
| xAxisDomain, | ||
| annotationElements, | ||
| laidOutAnnotations, | ||
| } = useChartScales({ | ||
| annotations, | ||
| containerWidth, | ||
| dateRange, | ||
| granularity, | ||
| dateRangeEndInclusive, | ||
| displayType, | ||
| fitYAxisToData, | ||
| graphResults, | ||
| lineData, | ||
| visibleLineData, | ||
| selectedSeriesNames, | ||
| referenceLineValues, | ||
| axisNumberFormat, | ||
| yAxisWidth, | ||
| hasExemplars: !!exemplars?.length, | ||
| }); | ||
| const yAxisDomain = yAxisBounds.domain; | ||
|
|
||
| // The chart's outer positioned container. Used to convert a pointer's | ||
|
|
@@ -730,6 +772,31 @@ export const MemoChart = memo(function MemoChart({ | |
| const [hoveredAnnotation, setHoveredAnnotation] = | ||
| useState<HoveredAnnotation | null>(null); | ||
|
|
||
| // 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 ( | ||
| <div | ||
| ref={containerRef} | ||
|
|
@@ -862,12 +929,14 @@ export const MemoChart = memo(function MemoChart({ | |
| Hidden once a point is clicked, where the pinned tooltip takes over. | ||
| Portaled to body so HDXLineChartTooltip can self-position (see its | ||
| docblock) and escape the chart's bounds near an edge. */} | ||
| {isClickActive == null && ( | ||
| <Tooltip | ||
| content={hoverTooltipContent} | ||
| portal={typeof document !== 'undefined' ? document.body : null} | ||
| /> | ||
| )} | ||
| {isClickActive == null && | ||
| !isExemplarHovered && | ||
| pinnedExemplarKey == null && ( | ||
| <Tooltip | ||
| content={hoverTooltipContent} | ||
| portal={typeof document !== 'undefined' ? document.body : null} | ||
| /> | ||
| )} | ||
| {referenceLines} | ||
| {annotationElements} | ||
| {laidOutAnnotations != null && ( | ||
|
|
@@ -880,6 +949,29 @@ export const MemoChart = memo(function MemoChart({ | |
| } | ||
| /> | ||
| )} | ||
| {exemplarPoints.map(p => ( | ||
| <ReferenceDot | ||
| key={p.key} | ||
| // Already placed inside the x-domain by useExemplarMarkers, which | ||
| // also drops markers that belong to a different window. | ||
| x={p.x} | ||
| // Already placed inside the y-range by useExemplarMarkers. | ||
| y={p.y} | ||
| // Stated rather than assumed: both clamps exist because the default | ||
| // is "discard", and recharts 3 landed here recently. | ||
| ifOverflow="discard" | ||
| shape={ | ||
| <ExemplarDot | ||
| exemplar={p.exemplar} | ||
| onHoverStart={handleExemplarHoverStart} | ||
| onHoverEnd={handleExemplarHoverEnd} | ||
| onSelect={handleExemplarSelect} | ||
| isActive={p.key === activeExemplarKey} | ||
| onPositionChange={onActiveExemplarMoved} | ||
| /> | ||
| } | ||
| /> | ||
| ))} | ||
| {highlightStart && highlightEnd ? ( | ||
| <ReferenceArea | ||
| // yAxisId="1" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔵 minor — Marker-budget default is hardcoded twice
maxExemplars = 12here andDEFAULT_MAX_EXEMPLARS = 12inpackages/app/src/defaults.ts:9are two sources of truth for the same number, kept in sync by hand. ImportDEFAULT_MAX_EXEMPLARSfor the default parameter (the same constantDBTimeChart.tsx:679already uses).