Skip to content

Commit fc7e432

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); Errors toggles rate (countIf/count) vs volume (countIf) with the rate axis capped at 100%; Duration shows Avg / p95 / p99 over the raw Duration column with the unit applied at display from the source precision. - 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. - Aggregations are over raw columns (count, countIf, quantile/avg) so materialized views can satisfy them; ratio/unit conversion happens 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, used by the RED tiles. Logs and session sources keep the existing histogram. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 712ddfc commit fc7e432

7 files changed

Lines changed: 709 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: 70 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,
@@ -2556,6 +2564,23 @@ export function DBSearchPage() {
25562564
enableParallelQueries
25572565
/>
25582566
<Group gap="sm" align="center">
2567+
{searchedSource != null &&
2568+
isTraceSource(searchedSource) &&
2569+
searchedSource.durationExpression && (
2570+
<SegmentedControl
2571+
size="xs"
2572+
value={traceChartMode}
2573+
onChange={v =>
2574+
setTraceChartMode(
2575+
v === 'heatmap' ? 'heatmap' : 'red',
2576+
)
2577+
}
2578+
data={[
2579+
{ label: 'RED', value: 'red' },
2580+
{ label: 'Heatmap', value: 'heatmap' },
2581+
]}
2582+
/>
2583+
)}
25592584
{shouldShowLiveModeHint &&
25602585
denoiseResults != true && (
25612586
<ResumeLiveTailButton
@@ -2576,26 +2601,51 @@ export function DBSearchPage() {
25762601
</Group>
25772602
</Group>
25782603
</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-
)}
2604+
{!hasQueryError &&
2605+
(searchedSource != null &&
2606+
isTraceSource(searchedSource) &&
2607+
searchedSource.durationExpression ? (
2608+
<Box
2609+
className={searchPageStyles.timeChartContainer}
2610+
mih="0"
2611+
style={{ height: 240 }}
2612+
>
2613+
<TraceRedMetricsChart
2614+
mode={traceChartMode}
2615+
histogramTimeChartConfig={
2616+
histogramTimeChartConfig
2617+
}
2618+
heatmapChartConfig={{
2619+
...chartConfig,
2620+
dateRange: searchedTimeRange,
2621+
with: aliasWith,
2622+
}}
2623+
source={searchedSource}
2624+
isReady={isReady}
2625+
queryKeyPrefix={QUERY_KEY_PREFIX}
2626+
onTimeRangeSelect={handleTimeRangeSelect}
2627+
/>
2628+
</Box>
2629+
) : (
2630+
<Box
2631+
className={searchPageStyles.timeChartContainer}
2632+
mih="0"
2633+
>
2634+
<DBTimeChart
2635+
sourceId={searchedConfig.source ?? undefined}
2636+
showLegend={false}
2637+
config={histogramTimeChartConfig}
2638+
enabled={isReady}
2639+
showDisplaySwitcher={false}
2640+
showMVOptimizationIndicator={false}
2641+
showDateRangeIndicator={false}
2642+
queryKeyPrefix={QUERY_KEY_PREFIX}
2643+
onTimeRangeSelect={handleTimeRangeSelect}
2644+
onFocusSeries={handleFocusSeries}
2645+
enableParallelQueries
2646+
/>
2647+
</Box>
2648+
))}
25992649
</>
26002650
)}
26012651
{hasQueryError && queryError ? (

packages/app/src/HDXMultiSeriesTimeChart.tsx

Lines changed: 64 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';
@@ -782,6 +783,8 @@ export const MemoChart = memo(function MemoChart({
782783
granularity,
783784
dateRangeEndInclusive = true,
784785
fitYAxisToData = false,
786+
compactXAxisLabels = false,
787+
yAxisMaxDomain,
785788
}: {
786789
graphResults: any[];
787790
setIsClickActive: (v: ActiveClickPayload | undefined) => void;
@@ -823,6 +826,19 @@ export const MemoChart = memo(function MemoChart({
823826
* (with padding) instead of zero.
824827
**/
825828
fitYAxisToData?: boolean;
829+
/**
830+
* When true, anchor the first x-axis label to the start and the last to the
831+
* end (instead of centering every label) so edge labels are not clipped on
832+
* narrow charts, e.g. the side-by-side RED metrics tiles.
833+
*/
834+
compactXAxisLabels?: boolean;
835+
/**
836+
* Cap the y-axis upper bound at this value (e.g. 1 for a 0-100% rate). The
837+
* axis still auto-scales below the cap so small values keep a tight range,
838+
* and a flat/zero series falls back to the cap instead of a degenerate
839+
* auto-domain. Only applied on the default (non-fit, non-selection) path.
840+
*/
841+
yAxisMaxDomain?: number;
826842
}) {
827843
const _id = useId();
828844
const id = _id.replace(/:/g, '');
@@ -948,6 +964,18 @@ export const MemoChart = memo(function MemoChart({
948964
// fit the lower bound to the data. When neither applies, let Recharts
949965
// auto-calculate the upper bound while pinning the lower bound to zero.
950966
if (!hasSelection && !shouldFitYAxis) {
967+
if (yAxisMaxDomain != null) {
968+
// Auto-scale up to the data max (with headroom) but never above the
969+
// cap; a flat or zero series uses the cap instead of a degenerate
970+
// auto-domain (which recharts renders as e.g. 0-400% for a 0% rate).
971+
return [
972+
0,
973+
(dataMax: number) =>
974+
Number.isFinite(dataMax) && dataMax > 0
975+
? Math.min(dataMax * 1.1, yAxisMaxDomain)
976+
: yAxisMaxDomain,
977+
];
978+
}
951979
return [0, 'auto'];
952980
}
953981

@@ -992,6 +1020,7 @@ export const MemoChart = memo(function MemoChart({
9921020
selectedSeriesNames,
9931021
fitYAxisToData,
9941022
displayType,
1023+
yAxisMaxDomain,
9951024
]);
9961025

9971026
const [containerWidth, setContainerWidth] = useState(0);
@@ -1105,6 +1134,35 @@ export const MemoChart = memo(function MemoChart({
11051134
[formatTime],
11061135
);
11071136

1137+
// Compact mode: anchor the first label to the start and the last to the end
1138+
// so neither is clipped on a narrow chart. Renders every tick through one
1139+
// path (token color, mono) so the axis stays visually consistent.
1140+
const renderCompactXTick = useCallback(
1141+
({ x, y, payload, index, visibleTicksCount }: XAxisTickContentProps) => {
1142+
const textAnchor =
1143+
index <= 0
1144+
? 'start'
1145+
: index >= visibleTicksCount - 1
1146+
? 'end'
1147+
: 'middle';
1148+
return (
1149+
<Text
1150+
x={x}
1151+
y={y}
1152+
dy={8}
1153+
textAnchor={textAnchor}
1154+
verticalAnchor="start"
1155+
fontSize={11}
1156+
fontFamily="IBM Plex Mono, monospace"
1157+
fill="var(--mantine-color-dimmed)"
1158+
>
1159+
{xTickFormatter(Number(payload.value), index)}
1160+
</Text>
1161+
);
1162+
},
1163+
[xTickFormatter],
1164+
);
1165+
11081166
const tickFormatter = useCallback(
11091167
(value: number) => {
11101168
return axisNumberFormat
@@ -1533,7 +1591,11 @@ export const MemoChart = memo(function MemoChart({
15331591
type="number"
15341592
tickFormatter={xTickFormatter}
15351593
minTickGap={100}
1536-
tick={{ fontSize: 11, fontFamily: 'IBM Plex Mono, monospace' }}
1594+
tick={
1595+
compactXAxisLabels
1596+
? renderCompactXTick
1597+
: { fontSize: 11, fontFamily: 'IBM Plex Mono, monospace' }
1598+
}
15371599
/>
15381600
<YAxis
15391601
width={Y_AXIS_WIDTH}

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)