Skip to content

Commit de63586

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 d76cb7e commit de63586

17 files changed

Lines changed: 1063 additions & 49 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
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.
6+
7+
The shared "error span" definition now also matches the legacy `STATUS_CODE_ERROR` status value in addition to `Error`, so error counts on the Services and LLM dashboards include those spans too.

‎packages/app/src/ChartUtils.tsx‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
BuilderSavedChartConfig,
2929
ChartConfigWithDateRange,
3030
ChartConfigWithOptDateRange,
31+
ChartPaletteToken,
3132
DisplayType,
3233
Filter,
3334
isSearchableSource,
@@ -49,7 +50,12 @@ import {
4950
import { getMetricNameSql } from './otelSemanticConventions';
5051
import { AggFn, TableChartSeries, TimeChartSeries } from './types';
5152
import { NumberFormat } from './types';
52-
import { getColorProps, getLogLevelColorOrder, logLevelColor } from './utils';
53+
import {
54+
getColorFromCSSToken,
55+
getColorProps,
56+
getLogLevelColorOrder,
57+
logLevelColor,
58+
} from './utils';
5359

5460
type SortOrder = 'asc' | 'desc';
5561

@@ -585,6 +591,7 @@ interface LineDataWithOptionalColor extends Omit<LineData, 'color'> {
585591

586592
function setLineColors(
587593
sortedLineData: LineDataWithOptionalColor[],
594+
seriesColors?: Record<string, ChartPaletteToken>,
588595
): LineData[] {
589596
// Ensure that the current and previous period lines are the same color
590597
const lineColorByCurrentPeriodKey = new Map<string, string>();
@@ -595,10 +602,15 @@ function setLineColors(
595602
if (lineColorByCurrentPeriodKey.has(currentPeriodKey)) {
596603
line.color = lineColorByCurrentPeriodKey.get(currentPeriodKey);
597604
} else if (!line.color) {
598-
line.color = getColorProps(
599-
colorIndex++,
600-
line.displayName ?? line.dataKey,
601-
);
605+
const seriesName = line.displayName ?? line.dataKey;
606+
// A caller-pinned color (keyed by series name) wins over the positional
607+
// palette, so a series keeps the same hue no matter how the data sorts
608+
// it. A pinned series does not consume a positional slot, so unpinned
609+
// series keep their natural palette order.
610+
const pinnedToken = seriesColors?.[seriesName];
611+
line.color = pinnedToken
612+
? getColorFromCSSToken(pinnedToken)
613+
: getColorProps(colorIndex++, seriesName);
602614
lineColorByCurrentPeriodKey.set(currentPeriodKey, line.color);
603615
} else {
604616
lineColorByCurrentPeriodKey.set(currentPeriodKey, line.color);
@@ -749,6 +761,7 @@ export function formatResponseForTimeChart({
749761
hiddenSeries = [],
750762
previousPeriodOffsetSeconds = 0,
751763
maxSeries = MAX_RENDERED_TIME_CHART_SERIES,
764+
seriesColors,
752765
}: {
753766
dateRange: [Date, Date];
754767
granularity?: SQLInterval;
@@ -764,6 +777,12 @@ export function formatResponseForTimeChart({
764777
* every series (the "load all" escape hatch behind the hidden-series notice).
765778
*/
766779
maxSeries?: number;
780+
/**
781+
* Pin specific series (by display name) to a palette token instead of the
782+
* positional palette, so their color is stable across sessions and can avoid
783+
* hues reserved for other meanings (e.g. keeping latency off error red).
784+
*/
785+
seriesColors?: Record<string, ChartPaletteToken>;
767786
}) {
768787
const meta = currentPeriodResponse.meta;
769788

@@ -998,7 +1017,7 @@ export function formatResponseForTimeChart({
9981017
(a, b) => a[timestampColumn.name] - b[timestampColumn.name],
9991018
);
10001019

1001-
const sortedLineDataWithColors = setLineColors(sortedLineData);
1020+
const sortedLineDataWithColors = setLineColors(sortedLineData, seriesColors);
10021021

10031022
// Count of LOGICAL groups actually rendered, in the same unit as
10041023
// hiddenSeriesCount — so a comparison chart (current + previous entries per

‎packages/app/src/DBSearchPage.tsx‎

Lines changed: 120 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import {
6060
Group,
6161
Modal,
6262
Paper,
63+
SegmentedControl,
6364
Select,
6465
Stack,
6566
Text,
@@ -140,6 +141,10 @@ import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar';
140141
import PatternTable from './components/PatternTable';
141142
import { DBSearchHeatmapChart } from './components/Search/DBSearchHeatmapChart';
142143
import DirectTraceSidePanel from './components/Search/DirectTraceSidePanel';
144+
import {
145+
type TraceChartMode,
146+
TraceRedMetricsChart,
147+
} from './components/Search/TraceRedMetricsChart';
143148
import SourceSchemaPreview, {
144149
isSourceSchemaPreviewEnabled,
145150
} from './components/SourceSchemaPreview';
@@ -206,6 +211,13 @@ const SearchConfigSchema = z.object({
206211
type SearchConfigFromSchema = z.infer<typeof SearchConfigSchema>;
207212

208213
const QUERY_KEY_PREFIX = 'search';
214+
// The RED tiles are auxiliary viz, not the results query, so they key their
215+
// queries under a separate prefix to stay out of the `search executed`
216+
// telemetry (which matches [QUERY_KEY_PREFIX] only). Otherwise the slow
217+
// avg/p95/p99 duration query would become what the reported search latency
218+
// measures. The live-tail pause, on the other hand, does wait on this prefix
219+
// (see the pause signal below) so live ticks don't stack fresh quantile scans.
220+
const RED_QUERY_KEY_PREFIX = `${QUERY_KEY_PREFIX}-red`;
209221

210222
// Clicks inside the results panel keep the row side panel open (so users can
211223
// scroll the table or select a different row); clicks anywhere else on the page
@@ -1061,6 +1073,14 @@ export function DBSearchPage() {
10611073
]).withDefault('results'),
10621074
);
10631075

1076+
// RED metrics vs heatmap for the trace results chart area. URL state (like
1077+
// the other view toggles on this page) so a reload or shared link keeps the
1078+
// chosen view; the switch lives inline in the search stats row.
1079+
const [traceChartMode, setTraceChartMode] = useQueryState(
1080+
'traceChartMode',
1081+
parseAsStringEnum<TraceChartMode>(['red', 'heatmap']).withDefault('red'),
1082+
);
1083+
10641084
const [patternColumn, setPatternColumn] = useQueryState(
10651085
'patternColumn',
10661086
parseAsString,
@@ -1611,6 +1631,16 @@ export function DBSearchPage() {
16111631
queryKey: [QUERY_KEY_PREFIX],
16121632
}) > 0;
16131633

1634+
// Live tail also waits on the RED metric queries. They live under a separate
1635+
// prefix (kept out of the search-latency telemetry above), so without this
1636+
// each ~10s tick would fire a fresh avg/p95/p99 scan without waiting for the
1637+
// previous one, stacking slow quantile queries while live tail runs. Resolves
1638+
// to 0 when the RED tiles aren't mounted, so it's a no-op off the trace view.
1639+
const isRedQueryFetching =
1640+
useIsFetching({
1641+
queryKey: [RED_QUERY_KEY_PREFIX],
1642+
}) > 0;
1643+
16141644
const { searchElapsedMs } = useSearchTelemetry({
16151645
isAnyQueryFetching,
16161646
isLive: isLive ?? false,
@@ -1665,7 +1695,8 @@ export function DBSearchPage() {
16651695
interval,
16661696
refreshFrequency,
16671697
onTimeRangeSelect,
1668-
pause: isAnyQueryFetching || !queryReady || !isTabVisible,
1698+
pause:
1699+
isAnyQueryFetching || isRedQueryFetching || !queryReady || !isTabVisible,
16691700
});
16701701

16711702
// This ensures we only render this conditionally on the client
@@ -1864,6 +1895,34 @@ export function DBSearchPage() {
18641895

18651896
const aliasWith = useMemo(() => aliasMapToWithClauses(aliasMap), [aliasMap]);
18661897

1898+
// The trace results chart shows RED metrics (and a heatmap) only for a trace
1899+
// source that exposes a duration column; otherwise the single histogram
1900+
// stays. Derived once, as the narrowed source or null, so the stats-row
1901+
// toggle and the chart branch can't drift apart on which one renders.
1902+
const traceRedMetricsSource =
1903+
searchedSource != null &&
1904+
isTraceSource(searchedSource) &&
1905+
searchedSource.durationExpression
1906+
? searchedSource
1907+
: null;
1908+
1909+
// Reset to the default RED view when the source actually changes, so
1910+
// returning to a trace source after viewing another one doesn't silently
1911+
// reopen in Heatmap. The ref starts undefined and is only compared once a
1912+
// real id has been seen, so the async source resolution on first load
1913+
// (undefined -> id) doesn't count as a change and a shared link that pins
1914+
// ?traceChartMode=heatmap survives.
1915+
const prevSourceIdRef = useRef<string | undefined>(undefined);
1916+
useEffect(() => {
1917+
if (
1918+
prevSourceIdRef.current !== undefined &&
1919+
prevSourceIdRef.current !== searchedSource?.id
1920+
) {
1921+
setTraceChartMode('red');
1922+
}
1923+
prevSourceIdRef.current = searchedSource?.id;
1924+
}, [searchedSource?.id, setTraceChartMode]);
1925+
18671926
const histogramTimeChartConfig = useMemo(() => {
18681927
if (chartConfig == null) {
18691928
return undefined;
@@ -2491,11 +2550,7 @@ export function DBSearchPage() {
24912550
setAnalysisMode={setAnalysisMode}
24922551
chartConfig={filtersChartConfig}
24932552
sourceId={inputSourceObj?.id}
2494-
showDelta={
2495-
!!(searchedSource?.kind === SourceKind.Trace
2496-
? searchedSource.durationExpression
2497-
: undefined)
2498-
}
2553+
showDelta={traceRedMetricsSource != null}
24992554
onColumnToggle={toggleColumn}
25002555
displayedColumns={displayedColumns}
25012556
onCollapse={() => setIsFilterSidebarCollapsed(true)}
@@ -2612,6 +2667,21 @@ export function DBSearchPage() {
26122667
enableParallelQueries
26132668
/>
26142669
<Group gap="sm" align="center">
2670+
{traceRedMetricsSource != null && (
2671+
<SegmentedControl
2672+
size="xs"
2673+
value={traceChartMode}
2674+
onChange={v =>
2675+
setTraceChartMode(
2676+
v === 'heatmap' ? 'heatmap' : 'red',
2677+
)
2678+
}
2679+
data={[
2680+
{ label: 'RED', value: 'red' },
2681+
{ label: 'Heatmap', value: 'heatmap' },
2682+
]}
2683+
/>
2684+
)}
26152685
{shouldShowLiveModeHint &&
26162686
denoiseResults != true && (
26172687
<ResumeLiveTailButton
@@ -2632,26 +2702,50 @@ export function DBSearchPage() {
26322702
</Group>
26332703
</Group>
26342704
</Box>
2635-
{!hasQueryError && (
2636-
<Box
2637-
className={searchPageStyles.timeChartContainer}
2638-
mih="0"
2639-
>
2640-
<DBTimeChart
2641-
sourceId={searchedConfig.source ?? undefined}
2642-
showLegend={false}
2643-
config={histogramTimeChartConfig}
2644-
enabled={isReady}
2645-
showDisplaySwitcher={false}
2646-
showMVOptimizationIndicator={false}
2647-
showDateRangeIndicator={false}
2648-
queryKeyPrefix={QUERY_KEY_PREFIX}
2649-
onTimeRangeSelect={handleTimeRangeSelect}
2650-
onFocusSeries={handleFocusSeries}
2651-
enableParallelQueries
2652-
/>
2653-
</Box>
2654-
)}
2705+
{!hasQueryError &&
2706+
(traceRedMetricsSource != null ? (
2707+
<Box
2708+
className={searchPageStyles.timeChartContainer}
2709+
mih="0"
2710+
h={240}
2711+
>
2712+
<TraceRedMetricsChart
2713+
mode={traceChartMode}
2714+
histogramTimeChartConfig={
2715+
histogramTimeChartConfig
2716+
}
2717+
heatmapChartConfig={{
2718+
...chartConfig,
2719+
dateRange: searchedTimeRange,
2720+
with: aliasWith,
2721+
}}
2722+
source={traceRedMetricsSource}
2723+
isReady={isReady}
2724+
queryKeyPrefix={RED_QUERY_KEY_PREFIX}
2725+
onTimeRangeSelect={handleTimeRangeSelect}
2726+
onFocusSeries={handleFocusSeries}
2727+
/>
2728+
</Box>
2729+
) : (
2730+
<Box
2731+
className={searchPageStyles.timeChartContainer}
2732+
mih="0"
2733+
>
2734+
<DBTimeChart
2735+
sourceId={searchedConfig.source ?? undefined}
2736+
showLegend={false}
2737+
config={histogramTimeChartConfig}
2738+
enabled={isReady}
2739+
showDisplaySwitcher={false}
2740+
showMVOptimizationIndicator={false}
2741+
showDateRangeIndicator={false}
2742+
queryKeyPrefix={QUERY_KEY_PREFIX}
2743+
onTimeRangeSelect={handleTimeRangeSelect}
2744+
onFocusSeries={handleFocusSeries}
2745+
enableParallelQueries
2746+
/>
2747+
</Box>
2748+
))}
26552749
</>
26562750
)}
26572751
{hasQueryError && queryError ? (

0 commit comments

Comments
 (0)