Skip to content

Commit a99b07b

Browse files
alex-fedotyevclaude
andcommitted
feat(app): show RED metrics on the trace search results view
For a trace source in results mode, replace the single count histogram above the results with a Throughput / Errors / Duration trio: - Throughput counts spans (bars). Duration shows Avg / p95 / p99 over the raw Duration column, with the unit applied at display from the source precision. - Errors toggles between rate and volume. Rate is countIf/count as a percent line, capped at 100%. Volume groups error spans by status and renders them as bars, so clicking a bar filters the results to that status; this keeps the per-status drill-down the count histogram had. - The three charts are DBTimeCharts under a shared sync scope (synced hover cursor) and use the dashboard tile card header. A RED/Heatmap switch in the stats row flips to the same heatmap tile the dashboard renders, and resets to RED when the source changes. - Aggregations are over raw columns (count, countIf, quantile/avg) so materialized views can satisfy them; ratio and unit conversion happen at the display layer. Aggregation builders live in a pure, unit-tested module. - Adds an opt-in compactXAxisLabels (edge-anchor first/last x labels) and yAxisMaxDomain (cap the y-axis while auto-scaling below it) to the shared time chart, both covered by focused unit tests. Logs and session sources keep the existing histogram. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 712ddfc commit a99b07b

8 files changed

Lines changed: 911 additions & 22 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hyperdx/app': minor
3+
---
4+
5+
Show RED metrics (Throughput, Errors, Duration) above the trace search results instead of the single count histogram. The three charts share a synced hover cursor, Errors toggles between rate and volume, and a RED/Heatmap switch flips the area to the duration heatmap. Logs and other sources keep the existing histogram.

packages/app/src/DBSearchPage.tsx

Lines changed: 84 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import {
5959
Group,
6060
Modal,
6161
Paper,
62+
SegmentedControl,
6263
Select,
6364
Stack,
6465
Text,
@@ -137,6 +138,7 @@ import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar';
137138
import PatternTable from './components/PatternTable';
138139
import { DBSearchHeatmapChart } from './components/Search/DBSearchHeatmapChart';
139140
import DirectTraceSidePanel from './components/Search/DirectTraceSidePanel';
141+
import { TraceRedMetricsChart } from './components/Search/TraceRedMetricsChart';
140142
import SourceSchemaPreview, {
141143
isSourceSchemaPreviewEnabled,
142144
} from './components/SourceSchemaPreview';
@@ -1059,6 +1061,12 @@ export function DBSearchPage() {
10591061
]).withDefault('results'),
10601062
);
10611063

1064+
// RED metrics vs heatmap for the trace results chart area. Owned here so the
1065+
// switch can live inline in the search stats row instead of a dedicated row.
1066+
const [traceChartMode, setTraceChartMode] = useState<'red' | 'heatmap'>(
1067+
'red',
1068+
);
1069+
10621070
const [patternColumn, setPatternColumn] = useQueryState(
10631071
'patternColumn',
10641072
parseAsString,
@@ -1808,6 +1816,23 @@ export function DBSearchPage() {
18081816

18091817
const aliasWith = useMemo(() => aliasMapToWithClauses(aliasMap), [aliasMap]);
18101818

1819+
// The trace results chart shows RED metrics (and a heatmap) only for a trace
1820+
// source that exposes a duration column; otherwise the single histogram
1821+
// stays. Derived once, as the narrowed source or null, so the stats-row
1822+
// toggle and the chart branch can't drift apart on which one renders.
1823+
const traceRedMetricsSource =
1824+
searchedSource != null &&
1825+
isTraceSource(searchedSource) &&
1826+
searchedSource.durationExpression
1827+
? searchedSource
1828+
: null;
1829+
1830+
// Reset to the default RED view when the source changes, so returning to a
1831+
// trace source after viewing another one doesn't silently reopen in Heatmap.
1832+
useEffect(() => {
1833+
setTraceChartMode('red');
1834+
}, [searchedSource?.id]);
1835+
18111836
const histogramTimeChartConfig = useMemo(() => {
18121837
if (chartConfig == null) {
18131838
return undefined;
@@ -2556,6 +2581,21 @@ export function DBSearchPage() {
25562581
enableParallelQueries
25572582
/>
25582583
<Group gap="sm" align="center">
2584+
{traceRedMetricsSource != null && (
2585+
<SegmentedControl
2586+
size="xs"
2587+
value={traceChartMode}
2588+
onChange={v =>
2589+
setTraceChartMode(
2590+
v === 'heatmap' ? 'heatmap' : 'red',
2591+
)
2592+
}
2593+
data={[
2594+
{ label: 'RED', value: 'red' },
2595+
{ label: 'Heatmap', value: 'heatmap' },
2596+
]}
2597+
/>
2598+
)}
25592599
{shouldShowLiveModeHint &&
25602600
denoiseResults != true && (
25612601
<ResumeLiveTailButton
@@ -2576,26 +2616,50 @@ export function DBSearchPage() {
25762616
</Group>
25772617
</Group>
25782618
</Box>
2579-
{!hasQueryError && (
2580-
<Box
2581-
className={searchPageStyles.timeChartContainer}
2582-
mih="0"
2583-
>
2584-
<DBTimeChart
2585-
sourceId={searchedConfig.source ?? undefined}
2586-
showLegend={false}
2587-
config={histogramTimeChartConfig}
2588-
enabled={isReady}
2589-
showDisplaySwitcher={false}
2590-
showMVOptimizationIndicator={false}
2591-
showDateRangeIndicator={false}
2592-
queryKeyPrefix={QUERY_KEY_PREFIX}
2593-
onTimeRangeSelect={handleTimeRangeSelect}
2594-
onFocusSeries={handleFocusSeries}
2595-
enableParallelQueries
2596-
/>
2597-
</Box>
2598-
)}
2619+
{!hasQueryError &&
2620+
(traceRedMetricsSource != null ? (
2621+
<Box
2622+
className={searchPageStyles.timeChartContainer}
2623+
mih="0"
2624+
h={240}
2625+
>
2626+
<TraceRedMetricsChart
2627+
mode={traceChartMode}
2628+
histogramTimeChartConfig={
2629+
histogramTimeChartConfig
2630+
}
2631+
heatmapChartConfig={{
2632+
...chartConfig,
2633+
dateRange: searchedTimeRange,
2634+
with: aliasWith,
2635+
}}
2636+
source={traceRedMetricsSource}
2637+
isReady={isReady}
2638+
queryKeyPrefix={QUERY_KEY_PREFIX}
2639+
onTimeRangeSelect={handleTimeRangeSelect}
2640+
onFocusSeries={handleFocusSeries}
2641+
/>
2642+
</Box>
2643+
) : (
2644+
<Box
2645+
className={searchPageStyles.timeChartContainer}
2646+
mih="0"
2647+
>
2648+
<DBTimeChart
2649+
sourceId={searchedConfig.source ?? undefined}
2650+
showLegend={false}
2651+
config={histogramTimeChartConfig}
2652+
enabled={isReady}
2653+
showDisplaySwitcher={false}
2654+
showMVOptimizationIndicator={false}
2655+
showDateRangeIndicator={false}
2656+
queryKeyPrefix={QUERY_KEY_PREFIX}
2657+
onTimeRangeSelect={handleTimeRangeSelect}
2658+
onFocusSeries={handleFocusSeries}
2659+
enableParallelQueries
2660+
/>
2661+
</Box>
2662+
))}
25992663
</>
26002664
)}
26012665
{hasQueryError && queryError ? (

packages/app/src/HDXMultiSeriesTimeChart.tsx

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@ import {
2121
ReferenceArea,
2222
ReferenceLine,
2323
ResponsiveContainer,
24+
Text,
2425
Tooltip,
2526
XAxis,
2627
YAxis,
2728
} from 'recharts';
28-
import { AxisDomain } from 'recharts/types/util/types';
29+
import { AxisDomain, XAxisTickContentProps } from 'recharts/types/util/types';
2930
import { convertGranularityToSeconds } from '@hyperdx/common-utils/dist/core/utils';
3031
import { DisplayType } from '@hyperdx/common-utils/dist/types';
3132
import { Popover } from '@mantine/core';
@@ -757,6 +758,37 @@ export function collectMemoChartGradientHexes(
757758
);
758759
}
759760

761+
/**
762+
* Upper bound for a y-axis that has an explicit cap (`yAxisMaxDomain`). Recharts
763+
* calls this with the data's max value. Auto-scale up to the data max with 10%
764+
* headroom, but never above the cap; a flat or zero/non-finite series uses the
765+
* cap instead of a degenerate auto-domain (which recharts otherwise renders as
766+
* e.g. 0-400% for a 0% error rate).
767+
*/
768+
export function cappedYAxisUpperBound(dataMax: number, cap: number): number {
769+
return Number.isFinite(dataMax) && dataMax > 0
770+
? Math.min(dataMax * 1.1, cap)
771+
: cap;
772+
}
773+
774+
/**
775+
* Text anchor for a compact x-axis tick. The first and last ticks are anchored
776+
* to their inner edge so the edge time labels stay inside the plot instead of
777+
* clipping; interior ticks stay centered.
778+
*/
779+
export function compactTickAnchor(
780+
index: number,
781+
visibleTicksCount: number,
782+
): 'start' | 'middle' | 'end' {
783+
if (index <= 0) {
784+
return 'start';
785+
}
786+
if (index >= visibleTicksCount - 1) {
787+
return 'end';
788+
}
789+
return 'middle';
790+
}
791+
760792
export const MemoChart = memo(function MemoChart({
761793
graphResults,
762794
setIsClickActive,
@@ -782,6 +814,8 @@ export const MemoChart = memo(function MemoChart({
782814
granularity,
783815
dateRangeEndInclusive = true,
784816
fitYAxisToData = false,
817+
compactXAxisLabels = false,
818+
yAxisMaxDomain,
785819
}: {
786820
graphResults: any[];
787821
setIsClickActive: (v: ActiveClickPayload | undefined) => void;
@@ -823,6 +857,19 @@ export const MemoChart = memo(function MemoChart({
823857
* (with padding) instead of zero.
824858
**/
825859
fitYAxisToData?: boolean;
860+
/**
861+
* When true, anchor the first x-axis label to the start and the last to the
862+
* end (instead of centering every label) so edge labels are not clipped on
863+
* narrow charts, e.g. the side-by-side RED metrics tiles.
864+
*/
865+
compactXAxisLabels?: boolean;
866+
/**
867+
* Cap the y-axis upper bound at this value (e.g. 1 for a 0-100% rate). The
868+
* axis still auto-scales below the cap so small values keep a tight range,
869+
* and a flat/zero series falls back to the cap instead of a degenerate
870+
* auto-domain. Only applied on the default (non-fit, non-selection) path.
871+
*/
872+
yAxisMaxDomain?: number;
826873
}) {
827874
const _id = useId();
828875
const id = _id.replace(/:/g, '');
@@ -948,6 +995,15 @@ export const MemoChart = memo(function MemoChart({
948995
// fit the lower bound to the data. When neither applies, let Recharts
949996
// auto-calculate the upper bound while pinning the lower bound to zero.
950997
if (!hasSelection && !shouldFitYAxis) {
998+
if (yAxisMaxDomain != null) {
999+
// Auto-scale up to the data max (with headroom) but never above the
1000+
// cap; a flat or zero series uses the cap instead of a degenerate
1001+
// auto-domain (which recharts renders as e.g. 0-400% for a 0% rate).
1002+
return [
1003+
0,
1004+
(dataMax: number) => cappedYAxisUpperBound(dataMax, yAxisMaxDomain),
1005+
];
1006+
}
9511007
return [0, 'auto'];
9521008
}
9531009

@@ -992,6 +1048,7 @@ export const MemoChart = memo(function MemoChart({
9921048
selectedSeriesNames,
9931049
fitYAxisToData,
9941050
displayType,
1051+
yAxisMaxDomain,
9951052
]);
9961053

9971054
const [containerWidth, setContainerWidth] = useState(0);
@@ -1105,6 +1162,30 @@ export const MemoChart = memo(function MemoChart({
11051162
[formatTime],
11061163
);
11071164

1165+
// Compact mode: anchor the first label to the start and the last to the end
1166+
// so neither is clipped on a narrow chart. Renders every tick through one
1167+
// path (token color, mono) so the axis stays visually consistent.
1168+
const renderCompactXTick = useCallback(
1169+
({ x, y, payload, index, visibleTicksCount }: XAxisTickContentProps) => {
1170+
const textAnchor = compactTickAnchor(index, visibleTicksCount);
1171+
return (
1172+
<Text
1173+
x={x}
1174+
y={y}
1175+
dy={8}
1176+
textAnchor={textAnchor}
1177+
verticalAnchor="start"
1178+
fontSize={11}
1179+
fontFamily="IBM Plex Mono, monospace"
1180+
fill="var(--mantine-color-dimmed)"
1181+
>
1182+
{xTickFormatter(Number(payload.value), index)}
1183+
</Text>
1184+
);
1185+
},
1186+
[xTickFormatter],
1187+
);
1188+
11081189
const tickFormatter = useCallback(
11091190
(value: number) => {
11101191
return axisNumberFormat
@@ -1533,7 +1614,11 @@ export const MemoChart = memo(function MemoChart({
15331614
type="number"
15341615
tickFormatter={xTickFormatter}
15351616
minTickGap={100}
1536-
tick={{ fontSize: 11, fontFamily: 'IBM Plex Mono, monospace' }}
1617+
tick={
1618+
compactXAxisLabels
1619+
? renderCompactXTick
1620+
: { fontSize: 11, fontFamily: 'IBM Plex Mono, monospace' }
1621+
}
15371622
/>
15381623
<YAxis
15391624
width={Y_AXIS_WIDTH}

packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import type { LineData } from '@/ChartUtils';
1010
import type { ActiveClickSeries } from '@/HDXMultiSeriesTimeChart';
1111
import {
1212
buildActiveClickSeries,
13+
cappedYAxisUpperBound,
1314
collectMemoChartGradientHexes,
15+
compactTickAnchor,
1416
getSelectedLineData,
1517
getVisibleLineData,
1618
getVisibleTooltipRows,
@@ -366,3 +368,47 @@ describe('getVisibleTooltipRows', () => {
366368
expect(result.rows).toHaveLength(20);
367369
});
368370
});
371+
372+
describe('cappedYAxisUpperBound', () => {
373+
it('auto-scales to the data max plus 10% headroom when below the cap', () => {
374+
expect(cappedYAxisUpperBound(0.5, 1)).toBeCloseTo(0.55);
375+
});
376+
377+
it('never exceeds the cap, even when the data max plus headroom would', () => {
378+
// 0.95 * 1.1 = 1.045, clamped to the cap.
379+
expect(cappedYAxisUpperBound(0.95, 1)).toBe(1);
380+
expect(cappedYAxisUpperBound(5, 1)).toBe(1);
381+
});
382+
383+
it('uses the cap for a flat/zero series instead of a degenerate domain', () => {
384+
expect(cappedYAxisUpperBound(0, 1)).toBe(1);
385+
});
386+
387+
it('uses the cap for a negative or non-finite data max', () => {
388+
expect(cappedYAxisUpperBound(-1, 1)).toBe(1);
389+
expect(cappedYAxisUpperBound(NaN, 1)).toBe(1);
390+
expect(cappedYAxisUpperBound(Infinity, 1)).toBe(1);
391+
});
392+
});
393+
394+
describe('compactTickAnchor', () => {
395+
it('anchors the first tick to the start edge', () => {
396+
expect(compactTickAnchor(0, 5)).toBe('start');
397+
});
398+
399+
it('anchors the last tick to the end edge', () => {
400+
expect(compactTickAnchor(4, 5)).toBe('end');
401+
});
402+
403+
it('centers interior ticks', () => {
404+
expect(compactTickAnchor(2, 5)).toBe('middle');
405+
});
406+
407+
it('treats a negative index as the start edge', () => {
408+
expect(compactTickAnchor(-1, 5)).toBe('start');
409+
});
410+
411+
it('resolves the single-tick case to start (the first-edge check wins)', () => {
412+
expect(compactTickAnchor(0, 1)).toBe('start');
413+
});
414+
});

packages/app/src/components/DBTimeChart.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,16 @@ type DBTimeChartComponentProps = {
323323
* behavior), which is all a standalone chart can do.
324324
*/
325325
onFocusSeries?: (filters: SeriesGroupFilter[]) => void;
326+
/**
327+
* Anchor the first/last x-axis labels inward so they are not clipped on
328+
* narrow charts (e.g. side-by-side RED metric tiles). Forwarded to the chart.
329+
*/
330+
compactXAxisLabels?: boolean;
331+
/**
332+
* Cap the y-axis upper bound (e.g. 1 for a 0-100% rate) while still
333+
* auto-scaling below it. Forwarded to the chart.
334+
*/
335+
yAxisMaxDomain?: number;
326336
};
327337

328338
function DBTimeChartComponent({
@@ -348,6 +358,8 @@ function DBTimeChartComponent({
348358
showDateRangeIndicator = true,
349359
errorVariant,
350360
onFocusSeries,
361+
compactXAxisLabels,
362+
yAxisMaxDomain,
351363
}: DBTimeChartComponentProps) {
352364
const [selectedSeriesSet, setSelectedSeriesSet] = useState<Set<string>>(
353365
new Set(),
@@ -1012,6 +1024,8 @@ function DBTimeChartComponent({
10121024
granularity={granularity}
10131025
dateRangeEndInclusive={queriedConfig.dateRangeEndInclusive}
10141026
fitYAxisToData={queriedConfig.fitYAxisToData}
1027+
compactXAxisLabels={compactXAxisLabels}
1028+
yAxisMaxDomain={yAxisMaxDomain}
10151029
/>
10161030
</>
10171031
)}

0 commit comments

Comments
 (0)