Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
8d416c6
feat(app): exemplar overlay for metric and PromQL time charts
jordan-simonovski Aug 5, 2026
a0a13d1
fix(app): close the hover card on a range change too, not just the pin
jordan-simonovski Aug 5, 2026
8137157
fix(app): close exemplar cards when the markers actually move
jordan-simonovski Aug 5, 2026
190df61
chore: re-baseline the lint ceilings for the exemplar overlay
jordan-simonovski Aug 5, 2026
4f4e243
fix(app): close moved exemplar cards immediately, not on the cancella…
jordan-simonovski Aug 5, 2026
8e582f5
fix(app): have exemplar cards follow their marker instead of closing
jordan-simonovski Aug 5, 2026
551d611
fix(app): clear exemplar cards when the plot itself goes away
jordan-simonovski Aug 5, 2026
2ce0c86
fix(api): classify the exemplar tile settings as evaluation-inert for…
jordan-simonovski Sep 17, 2026
80c16a5
feat(app): give exemplars an author path in the chart editor
jordan-simonovski Sep 18, 2026
dccc735
fix(api): persist the team-level exemplar marker budget
jordan-simonovski Sep 18, 2026
0b544c9
fix(app): bound the exemplar trace lookup to a window around the marker
jordan-simonovski Sep 18, 2026
62b4c59
Merge branch 'jordansimonovski/split-chart-files' into jordansimonovs…
jordan-simonovski Oct 1, 2026
ccb184f
Merge remote-tracking branch 'origin/jordansimonovski/split-chart-fil…
jordan-simonovski Oct 6, 2026
1933e7e
Merge commit 'c583e116be01a0669019249f380c1b7a68bd335c' into work-exe…
jordan-simonovski Nov 3, 2026
7cc91dd
chore: restore the common-utils lint warning cap the merge with main …
jordan-simonovski Nov 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/exemplar-overlay.md
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.
1 change: 1 addition & 0 deletions packages/api/src/models/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export default mongoose.model<ITeam>(
fieldMetadataDisabled: Boolean,
parallelizeWhenPossible: Boolean,
filterKeysFetchLimit: Number,
maxExemplars: Number,
},
{
timestamps: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,33 @@ describe('convertAlertChartConfigToExternal', () => {
}
});

it('still emits a number config carrying exemplar settings', () => {
// The chart explorer can author an inline alert off a chart with the
// exemplar overlay switched on. Markers are drawn client-side and the
// alert task never reads either field, so dropping them beats blanking
// chartConfig. Asserted on the number variant because its schema has no
// spelling for either field however the line/bar dialect evolves.
const external = convertAlertChartConfigToExternal(
baseInternal({
displayType: DisplayType.Number,
enableExemplars: true,
exemplarTraceSourceId: '65f5e4a3b9e77c001a222222',
select: [
{
aggFn: 'count',
aggCondition: '',
aggConditionLanguage: 'lucene',
valueExpression: '',
},
],
}),
);

expect(external).toMatchObject({ displayType: DisplayType.Number });
expect(external).not.toHaveProperty('enableExemplars');
expect(external).not.toHaveProperty('exemplarTraceSourceId');
});

it.each([
[
'filters',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ export const EVALUATION_INERT_CONFIG_KEYS: ReadonlySet<string> = new Set([
'heatmap',
// Client-side null-bucket rendering; the alert task ignores it.
'fillNulls',
// Exemplar markers are a chart-rendering overlay; the alert task never reads
// them. Needed even where the tile dialect carries them: neither the number
// schema nor the raw SQL schemas an alert can use derive from
// externalDashboardTimeChartConfigSchema.
'enableExemplars',
'exemplarTraceSourceId',
// Evaluation-relevant only with a groupBy, which (on variants whose shape
// lacks seriesLimit, i.e. number) is itself refused when present.
'seriesLimit',
Expand Down
2 changes: 1 addition & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 568",
"lint": "npx eslint . --ext .ts,.tsx --max-warnings 573",
"lint:fix": "npx eslint . --ext .ts,.tsx --fix",
"lint:styles": "stylelint **/*/*.{css,scss}",
"ci:lint": "yarn lint && yarn tsc --noEmit && yarn lint:styles --quiet",
Expand Down
144 changes: 118 additions & 26 deletions packages/app/src/HDXMultiSeriesTimeChart/MemoChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
Customized,
Legend,
ReferenceArea,
ReferenceDot,
ReferenceLine,
ResponsiveContainer,
Tooltip,
Expand All @@ -25,6 +26,7 @@ import {
} from 'recharts';
import {
DisplayType,
Exemplar,
LineInterpolation,
} from '@hyperdx/common-utils/dist/types';

Expand All @@ -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';
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -132,6 +136,15 @@ export const MemoChart = memo(function MemoChart({
granularity,
dateRangeEndInclusive = true,
fitYAxisToData = false,
exemplars,
maxExemplars = 12,

Copy link
Copy Markdown
Contributor

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 = 12 here and DEFAULT_MAX_EXEMPLARS = 12 in packages/app/src/defaults.ts:9 are two sources of truth for the same number, kept in sync by hand. Import DEFAULT_MAX_EXEMPLARS for the default parameter (the same constant DBTimeChart.tsx:679 already uses).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Marker-budget default duplicated instead of importing DEFAULT_MAX_EXEMPLARS

maxExemplars = 12 restates DEFAULT_MAX_EXEMPLARS (packages/app/src/defaults.ts:9), which DBTimeChart.tsx:679 already passes as the fallback; import the constant here so the two cannot drift.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Default maxExemplars = 12 duplicates DEFAULT_MAX_EXEMPLARS

Use maxExemplars = DEFAULT_MAX_EXEMPLARS from @/defaults, so changing the team default in defaults.ts also changes MemoChart's fallback.

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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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 && (
Expand All @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const baseArgs = {
lineData,
selectedSeriesNames: undefined as Set<string> | undefined,
referenceLineValues: [] as number[],
hasExemplars: false,
yAxisWidth: 40,
};

Expand Down Expand Up @@ -135,6 +136,52 @@ describe('useChartScales y-domain', () => {
});
});

describe('useChartScales exemplar clamp', () => {
it('bounds markers by the top of the drawn axis', () => {
expect(scales({ hasExemplars: true }).exemplarYBounds).toEqual({
min: 0,
max: 42,
});
});

// A reference line leaves the upper bound 'auto', so the clamp falls back to
// the visible series max: an outlier marker pins to the top of the series
// range instead of stretching the axis and flattening every line.
it('falls back to the visible series max when the axis top is auto', () => {
expect(
scales({ hasExemplars: true, referenceLineValues: [100] })
.exemplarYBounds,
).toEqual({ min: 0, max: 40 });
});

it('follows the legend selection rather than every series', () => {
// A selection gives the axis numeric bounds, so the clamp takes those
// directly ([9, 31]) instead of falling back to the series max. Either way
// it tracks what is on screen — with B shown too it would be [8.5, 41.5].
expect(
scales({ hasExemplars: true, selectedSeriesNames: new Set(['A']) })
.exemplarYBounds,
).toEqual({ min: 9, max: 31 });
});

it('skips the O(rows x series) scan when no marker can draw', () => {
// Every time chart in the app pays for this pass otherwise, including
// deployments running with the overlay switched off entirely.
expect(
scales({ hasExemplars: false, referenceLineValues: [100] })
.exemplarYBounds.max,
).toBe(0);
});

it('takes a numeric upper bound from the axis when there is one', () => {
// Fitting to data gives a real number, and the marker should respect the
// axis the chart actually drew rather than the raw series max.
expect(
scales({ hasExemplars: true, fitYAxisToData: true }).exemplarYBounds,
).toEqual({ min: 8.5, max: 41.5 });
});
});

describe('useChartScales x-domain', () => {
it('spans the requested range in seconds', () => {
const [start, end] = scales().xAxisDomain;
Expand Down
Loading
Loading