From 09595f133f39036fbb981490d7a2031a0858d3bf Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:22:01 +0000 Subject: [PATCH 1/4] 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 table with a Throughput / Errors / Duration trio: - Throughput counts spans, Errors toggles between rate (avg of the error boolean, as a percent) and volume (countIf error), and Duration shows Avg / p95 / p99 over the source's millisecond duration expression. - The three charts are DBTimeCharts under a shared sync scope, so hovering one shows a synced cursor on all three, and each is a dashboard-tile card. - A RED/Heatmap switch in the search stats row flips the area to the same duration heatmap tile the dashboard renders. Each chart is built from the same base config the histogram uses, so they honor the active WHERE filter and time range. Logs and session sources are unchanged. The aggregation builders live in a pure module with unit tests. Co-Authored-By: Claude Opus 4.8 --- .changeset/red-metrics-trace-search.md | 5 + packages/app/src/DBSearchPage.tsx | 90 ++++++-- .../Search/TraceRedMetricsChart.tsx | 201 ++++++++++++++++++ .../Search/__tests__/traceRedMetrics.test.ts | 144 +++++++++++++ .../src/components/Search/traceRedMetrics.ts | 131 ++++++++++++ 5 files changed, 551 insertions(+), 20 deletions(-) create mode 100644 .changeset/red-metrics-trace-search.md create mode 100644 packages/app/src/components/Search/TraceRedMetricsChart.tsx create mode 100644 packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts create mode 100644 packages/app/src/components/Search/traceRedMetrics.ts diff --git a/.changeset/red-metrics-trace-search.md b/.changeset/red-metrics-trace-search.md new file mode 100644 index 0000000000..e064b6ef87 --- /dev/null +++ b/.changeset/red-metrics-trace-search.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': minor +--- + +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. diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index cdae127d1e..4de2dbdf38 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -59,6 +59,7 @@ import { Group, Modal, Paper, + SegmentedControl, Select, Stack, Text, @@ -137,6 +138,7 @@ import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar'; import PatternTable from './components/PatternTable'; import { DBSearchHeatmapChart } from './components/Search/DBSearchHeatmapChart'; import DirectTraceSidePanel from './components/Search/DirectTraceSidePanel'; +import { TraceRedMetricsChart } from './components/Search/TraceRedMetricsChart'; import SourceSchemaPreview, { isSourceSchemaPreviewEnabled, } from './components/SourceSchemaPreview'; @@ -1059,6 +1061,12 @@ export function DBSearchPage() { ]).withDefault('results'), ); + // RED metrics vs heatmap for the trace results chart area. Owned here so the + // switch can live inline in the search stats row instead of a dedicated row. + const [traceChartMode, setTraceChartMode] = useState<'red' | 'heatmap'>( + 'red', + ); + const [patternColumn, setPatternColumn] = useQueryState( 'patternColumn', parseAsString, @@ -2556,6 +2564,23 @@ export function DBSearchPage() { enableParallelQueries /> + {searchedSource != null && + isTraceSource(searchedSource) && + searchedSource.durationExpression && ( + + setTraceChartMode( + v === 'heatmap' ? 'heatmap' : 'red', + ) + } + data={[ + { label: 'RED', value: 'red' }, + { label: 'Heatmap', value: 'heatmap' }, + ]} + /> + )} {shouldShowLiveModeHint && denoiseResults != true && ( - {!hasQueryError && ( - - - - )} + {!hasQueryError && + (searchedSource != null && + isTraceSource(searchedSource) && + searchedSource.durationExpression ? ( + + + + ) : ( + + + + ))} )} {hasQueryError && queryError ? ( diff --git a/packages/app/src/components/Search/TraceRedMetricsChart.tsx b/packages/app/src/components/Search/TraceRedMetricsChart.tsx new file mode 100644 index 0000000000..5120e4c55c --- /dev/null +++ b/packages/app/src/components/Search/TraceRedMetricsChart.tsx @@ -0,0 +1,201 @@ +import { useMemo, useState } from 'react'; +import { + BuilderChartConfigWithDateRange, + TTraceSource, +} from '@hyperdx/common-utils/dist/types'; +import { Box, Flex, SegmentedControl } from '@mantine/core'; + +import { IsolatedChartSyncProvider } from '@/chartSync'; +import { ChartContainerCardHeaderProvider } from '@/components/charts/ChartContainer'; +import DBHeatmapChart, { + toHeatmapChartConfig, +} from '@/components/DBHeatmapChart'; +import { DBTimeChart } from '@/components/DBTimeChart'; +import { getDurationMsExpression } from '@/source'; +import type { NumberFormat } from '@/types'; + +import { + durationConfig, + errorConditionSql, + errorsConfig, + ErrorsMode, + redBaseConfig, + throughputConfig, +} from './traceRedMetrics'; + +export type TraceChartMode = 'red' | 'heatmap'; + +// Fixed card-header row height. Pinning both the titles and the Errors +// rate/volume control to the same height keeps all three headers identical, so +// the plots align top and bottom regardless of which header carries a control. +const HEADER_ROW_HEIGHT = 22; + +const titleNode = (label: string) => ( + + {label} + +); + +/** + * RED metrics (Throughput, Errors, Duration) for the trace search results view, + * replacing the single count histogram. The three charts render side by side as + * sibling DBTimeCharts under a shared sync scope, so hovering one shows a + * cross-chart cursor on all three at the same timestamp. The RED/Heatmap switch + * lives in the search stats row (passed in as `mode`); the Heatmap view is the + * same bare heatmap tile the dashboard renders. + * + * The per-chart aggregations are built by ./traceRedMetrics from the same base + * config the histogram uses, so all three honor the active WHERE filter and + * selected time range. + */ +export function TraceRedMetricsChart({ + mode, + histogramTimeChartConfig, + heatmapChartConfig, + source, + isReady, + queryKeyPrefix, + onTimeRangeSelect, +}: { + /** RED vs heatmap; owned by the search stats row so the switch sits inline. */ + mode: TraceChartMode; + /** The count-histogram config; RED charts spread this and swap only select. */ + histogramTimeChartConfig: BuilderChartConfigWithDateRange; + /** Base config for the heatmap tile, mirroring the delta-mode callsite. */ + heatmapChartConfig: BuilderChartConfigWithDateRange; + source: TTraceSource; + isReady: boolean; + queryKeyPrefix?: string; + onTimeRangeSelect?: (start: Date, end: Date) => void; +}) { + const [errorsMode, setErrorsMode] = useState('rate'); + + const errorCondition = errorConditionSql(source.statusCodeExpression); + const durationMsExpression = getDurationMsExpression(source); + + const base = useMemo( + () => redBaseConfig(histogramTimeChartConfig), + [histogramTimeChartConfig], + ); + const throughput = useMemo(() => throughputConfig(base), [base]); + const errors = useMemo( + () => errorsConfig(base, errorCondition, errorsMode), + [base, errorCondition, errorsMode], + ); + const duration = useMemo( + () => durationConfig(base, durationMsExpression), + [base, durationMsExpression], + ); + // Heatmap tile config (duration distribution over time), matching the + // dashboard heatmap tile: DBHeatmapChart + toHeatmapChartConfig, no + // significant-fields comparison panel. + const { heatmapConfig, scaleType } = useMemo( + () => + toHeatmapChartConfig({ + ...heatmapChartConfig, + select: [ + { + valueExpression: durationMsExpression, + countExpression: 'count()', + heatmapScaleType: 'log', + }, + ], + numberFormat: { + output: 'duration', + factor: 0.001, + } satisfies NumberFormat, + }), + [heatmapChartConfig, durationMsExpression], + ); + + const errorsModeControl = ( + setErrorsMode(v === 'volume' ? 'volume' : 'rate')} + data={[ + { label: 'Rate', value: 'rate' }, + { label: 'Vol', value: 'volume' }, + ]} + // Pin the control to the title/actions-icon height so the Errors card + // header is exactly as tall as Throughput/Duration and the three plots + // share a top and bottom to the pixel. + styles={{ + root: { height: HEADER_ROW_HEIGHT, minHeight: 0, padding: 2 }, + label: { + paddingTop: 0, + paddingBottom: 0, + paddingInline: 8, + minHeight: 0, + fontSize: 11, + lineHeight: '18px', + }, + indicator: { minHeight: 0 }, + }} + /> + ); + + const commonProps = { + sourceId: source.id, + enabled: isReady, + showDisplaySwitcher: false, + showMVOptimizationIndicator: false, + showDateRangeIndicator: false, + queryKeyPrefix, + onTimeRangeSelect, + enableParallelQueries: true, + } as const; + + return ( + + {mode === 'red' ? ( + + + + + + {errors != null && ( + + {/* Remount on mode change: DBTimeChart seeds its display type + from the initial config when uncontrolled, so a key swap + re-seeds bars (volume) vs line (rate). */} + + + )} + + + + + + ) : ( + + + + )} + + ); +} diff --git a/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts b/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts new file mode 100644 index 0000000000..97e364e0f1 --- /dev/null +++ b/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts @@ -0,0 +1,144 @@ +import { + BuilderChartConfigWithDateRange, + DisplayType, +} from '@hyperdx/common-utils/dist/types'; + +import { + ERROR_RATE_PERCENTAGE_NUMBER_FORMAT, + INTEGER_NUMBER_FORMAT, + MS_NUMBER_FORMAT, +} from '@/ChartUtils'; +import { + durationConfig, + errorConditionSql, + errorsConfig, + redBaseConfig, + throughputConfig, +} from '@/components/Search/traceRedMetrics'; + +const base: BuilderChartConfigWithDateRange = { + connection: 'conn', + from: { databaseName: 'default', tableName: 'otel_traces' }, + timestampValueExpression: 'Timestamp', + select: [{ aggFn: 'count', aggCondition: '', valueExpression: '' }], + where: "ServiceName = 'api'", + whereLanguage: 'sql', + filters: [], + dateRange: [new Date(0), new Date(60_000)], + granularity: 'auto', + groupBy: 'StatusCode', +}; + +const ERROR_COND = "lower(StatusCode) = 'error'"; +const DURATION_MS = '(Duration)/1e6'; + +describe('traceRedMetrics', () => { + describe('errorConditionSql', () => { + it('builds a lowercased error condition from the status expression', () => { + expect(errorConditionSql('StatusCode')).toBe(ERROR_COND); + }); + it('returns undefined without a usable status expression', () => { + expect(errorConditionSql(undefined)).toBeUndefined(); + expect(errorConditionSql('')).toBeUndefined(); + }); + }); + + describe('redBaseConfig', () => { + it('strips the status-code groupBy and preserves filter + time range', () => { + const result = redBaseConfig(base); + expect(result.groupBy).toBeUndefined(); + expect(result.where).toBe(base.where); + expect(result.dateRange).toBe(base.dateRange); + }); + }); + + describe('throughputConfig', () => { + it('counts spans, rendered as bars', () => { + const result = throughputConfig(redBaseConfig(base)); + expect(result.displayType).toBe(DisplayType.StackedBar); + expect(result.numberFormat).toBe(INTEGER_NUMBER_FORMAT); + expect(result.select).toEqual([ + { + alias: 'Spans', + aggFn: 'count', + aggCondition: '', + valueExpression: '', + }, + ]); + // honors the active WHERE filter carried from the base config + expect(result.where).toBe(base.where); + }); + }); + + describe('errorsConfig', () => { + it('rate: avg of the error boolean, rendered as a percent line', () => { + const result = errorsConfig(redBaseConfig(base), ERROR_COND, 'rate'); + expect(result).toBeDefined(); + expect(result?.displayType).toBe(DisplayType.Line); + expect(result?.numberFormat).toBe(ERROR_RATE_PERCENTAGE_NUMBER_FORMAT); + expect(result?.select).toEqual([ + { + alias: 'Error rate', + aggFn: 'avg', + aggCondition: '', + valueExpression: ERROR_COND, + }, + ]); + }); + + it('volume: countIf error, rendered as bars', () => { + const result = errorsConfig(redBaseConfig(base), ERROR_COND, 'volume'); + expect(result).toBeDefined(); + expect(result?.displayType).toBe(DisplayType.StackedBar); + expect(result?.numberFormat).toBe(INTEGER_NUMBER_FORMAT); + expect(result?.select).toEqual([ + { + alias: 'Errors', + aggFn: 'count', + aggCondition: ERROR_COND, + aggConditionLanguage: 'sql', + valueExpression: '', + }, + ]); + }); + + it('returns undefined when the source has no error condition', () => { + expect( + errorsConfig(redBaseConfig(base), undefined, 'rate'), + ).toBeUndefined(); + expect( + errorsConfig(redBaseConfig(base), undefined, 'volume'), + ).toBeUndefined(); + }); + }); + + describe('durationConfig', () => { + it('avg + p95 + p99 over the ms duration expression, rendered as a line', () => { + const result = durationConfig(redBaseConfig(base), DURATION_MS); + expect(result.displayType).toBe(DisplayType.Line); + expect(result.numberFormat).toBe(MS_NUMBER_FORMAT); + expect(result.select).toEqual([ + { + alias: 'Avg', + aggFn: 'avg', + aggCondition: '', + valueExpression: DURATION_MS, + }, + { + alias: 'p95', + aggFn: 'quantile', + level: 0.95, + aggCondition: '', + valueExpression: DURATION_MS, + }, + { + alias: 'p99', + aggFn: 'quantile', + level: 0.99, + aggCondition: '', + valueExpression: DURATION_MS, + }, + ]); + }); + }); +}); diff --git a/packages/app/src/components/Search/traceRedMetrics.ts b/packages/app/src/components/Search/traceRedMetrics.ts new file mode 100644 index 0000000000..7613c1ddd2 --- /dev/null +++ b/packages/app/src/components/Search/traceRedMetrics.ts @@ -0,0 +1,131 @@ +import { + BuilderChartConfigWithDateRange, + DisplayType, +} from '@hyperdx/common-utils/dist/types'; + +import { + ERROR_RATE_PERCENTAGE_NUMBER_FORMAT, + INTEGER_NUMBER_FORMAT, + MS_NUMBER_FORMAT, +} from '@/ChartUtils'; + +export type ErrorsMode = 'rate' | 'volume'; + +/** + * The RED metrics for a trace source, derived from the same base config the + * count histogram uses so they honor the active WHERE filter and time range. + * Kept as pure builders so the aggregations can be unit tested without + * rendering. Only the select, display type, and number format change per chart. + */ + +/** SQL condition that marks a span as an error, or undefined when the source + * has no status-code expression to test. Mirrors the service dashboard's + * `lower(StatusCode) = 'error'` definition. */ +export function errorConditionSql( + statusCodeExpression: string | undefined, +): string | undefined { + return statusCodeExpression + ? `lower(${statusCodeExpression}) = 'error'` + : undefined; +} + +/** The histogram config with its status-code groupBy removed, so RED charts + * aggregate across all matching spans rather than splitting per status. */ +export function redBaseConfig( + histogramTimeChartConfig: BuilderChartConfigWithDateRange, +): BuilderChartConfigWithDateRange { + return { ...histogramTimeChartConfig, groupBy: undefined }; +} + +/** Throughput: span count per bucket. Bars (StackedBar is the only display + * type that renders bars; a single series draws like a plain bar chart). */ +export function throughputConfig( + base: BuilderChartConfigWithDateRange, +): BuilderChartConfigWithDateRange { + return { + ...base, + select: [ + { alias: 'Spans', aggFn: 'count', aggCondition: '', valueExpression: '' }, + ], + displayType: DisplayType.StackedBar, + numberFormat: INTEGER_NUMBER_FORMAT, + }; +} + +/** + * Errors as a rate (avg of a 0/1 error boolean, rendered as a percent line) or + * a volume (countIf error, rendered as bars). Returns undefined when the source + * has no error condition. + */ +export function errorsConfig( + base: BuilderChartConfigWithDateRange, + errorCondition: string | undefined, + mode: ErrorsMode, +): BuilderChartConfigWithDateRange | undefined { + if (errorCondition == null) { + return undefined; + } + if (mode === 'rate') { + return { + ...base, + select: [ + { + alias: 'Error rate', + aggFn: 'avg', + aggCondition: '', + valueExpression: errorCondition, + }, + ], + displayType: DisplayType.Line, + numberFormat: ERROR_RATE_PERCENTAGE_NUMBER_FORMAT, + }; + } + return { + ...base, + select: [ + { + alias: 'Errors', + aggFn: 'count', + aggCondition: errorCondition, + aggConditionLanguage: 'sql', + valueExpression: '', + }, + ], + displayType: DisplayType.StackedBar, + numberFormat: INTEGER_NUMBER_FORMAT, + }; +} + +/** Duration: Avg, p95, p99 over the source's millisecond duration expression. */ +export function durationConfig( + base: BuilderChartConfigWithDateRange, + durationMsExpression: string, +): BuilderChartConfigWithDateRange { + return { + ...base, + select: [ + { + alias: 'Avg', + aggFn: 'avg', + aggCondition: '', + valueExpression: durationMsExpression, + }, + { + alias: 'p95', + aggFn: 'quantile', + level: 0.95, + aggCondition: '', + valueExpression: durationMsExpression, + }, + { + alias: 'p99', + aggFn: 'quantile', + level: 0.99, + aggCondition: '', + valueExpression: durationMsExpression, + }, + ], + displayType: DisplayType.Line, + numberFormat: MS_NUMBER_FORMAT, + }; +} From b1ab3032ba26ce714a581ac895de062f28bc55ab Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:50:13 +0000 Subject: [PATCH 2/4] fix(app): make RED trace aggregations materialized-view friendly Address review feedback on the aggregation forms: - Error rate now aggregates count() and countIf(error) separately and divides them in a post-aggregation column (the two counts hidden via hiddenSeries), instead of avg() over a status boolean. This lets AggregatingMergeTree materialized views satisfy the query. - Duration aggregates the raw Duration column and converts the unit at display via getTraceDurationNumberFormat (from the source's durationPrecision), instead of dividing to milliseconds in SQL. Same values, adaptive units, and MV-friendly. Co-Authored-By: Claude Opus 4.8 --- .../Search/TraceRedMetricsChart.tsx | 21 ++++++-- .../Search/__tests__/traceRedMetrics.test.ts | 41 +++++++++----- .../src/components/Search/traceRedMetrics.ts | 54 ++++++++++++++----- 3 files changed, 88 insertions(+), 28 deletions(-) diff --git a/packages/app/src/components/Search/TraceRedMetricsChart.tsx b/packages/app/src/components/Search/TraceRedMetricsChart.tsx index 5120e4c55c..db751d3508 100644 --- a/packages/app/src/components/Search/TraceRedMetricsChart.tsx +++ b/packages/app/src/components/Search/TraceRedMetricsChart.tsx @@ -11,11 +11,15 @@ import DBHeatmapChart, { toHeatmapChartConfig, } from '@/components/DBHeatmapChart'; import { DBTimeChart } from '@/components/DBTimeChart'; -import { getDurationMsExpression } from '@/source'; +import { + getDurationMsExpression, + getTraceDurationNumberFormat, +} from '@/source'; import type { NumberFormat } from '@/types'; import { durationConfig, + ERROR_RATE_HELPER_SERIES, errorConditionSql, errorsConfig, ErrorsMode, @@ -71,6 +75,14 @@ export function TraceRedMetricsChart({ const [errorsMode, setErrorsMode] = useState('rate'); const errorCondition = errorConditionSql(source.statusCodeExpression); + // Aggregate the raw Duration column (MV-friendly) and let the display format, + // derived from the source's durationPrecision, convert the unit. Falls back + // to getDurationMsExpression only for the heatmap tile below. + const durationExpression = source.durationExpression ?? ''; + const durationFormat = getTraceDurationNumberFormat(source, { + valueExpression: durationExpression, + aggFn: 'avg', + }); const durationMsExpression = getDurationMsExpression(source); const base = useMemo( @@ -83,8 +95,8 @@ export function TraceRedMetricsChart({ [base, errorCondition, errorsMode], ); const duration = useMemo( - () => durationConfig(base, durationMsExpression), - [base, durationMsExpression], + () => durationConfig(base, durationExpression, durationFormat), + [base, durationExpression, durationFormat], ); // Heatmap tile config (duration distribution over time), matching the // dashboard heatmap tile: DBHeatmapChart + toHeatmapChartConfig, no @@ -171,6 +183,9 @@ export function TraceRedMetricsChart({ toolbarSuffix={[errorsModeControl]} config={errors} showLegend + hiddenSeries={ + errorsMode === 'rate' ? ERROR_RATE_HELPER_SERIES : undefined + } {...commonProps} /> diff --git a/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts b/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts index 97e364e0f1..35ec6778fd 100644 --- a/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts +++ b/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts @@ -6,15 +6,16 @@ import { import { ERROR_RATE_PERCENTAGE_NUMBER_FORMAT, INTEGER_NUMBER_FORMAT, - MS_NUMBER_FORMAT, } from '@/ChartUtils'; import { durationConfig, + ERROR_RATE_HELPER_SERIES, errorConditionSql, errorsConfig, redBaseConfig, throughputConfig, } from '@/components/Search/traceRedMetrics'; +import type { NumberFormat } from '@/types'; const base: BuilderChartConfigWithDateRange = { connection: 'conn', @@ -30,7 +31,8 @@ const base: BuilderChartConfigWithDateRange = { }; const ERROR_COND = "lower(StatusCode) = 'error'"; -const DURATION_MS = '(Duration)/1e6'; +const DURATION_EXPR = 'Duration'; +const DURATION_FORMAT: NumberFormat = { output: 'duration', factor: 1e-9 }; describe('traceRedMetrics', () => { describe('errorConditionSql', () => { @@ -71,19 +73,29 @@ describe('traceRedMetrics', () => { }); describe('errorsConfig', () => { - it('rate: avg of the error boolean, rendered as a percent line', () => { + it('rate: count + countIf aggregated separately, divided post-aggregation (MV-friendly)', () => { const result = errorsConfig(redBaseConfig(base), ERROR_COND, 'rate'); expect(result).toBeDefined(); expect(result?.displayType).toBe(DisplayType.Line); expect(result?.numberFormat).toBe(ERROR_RATE_PERCENTAGE_NUMBER_FORMAT); expect(result?.select).toEqual([ { - alias: 'Error rate', - aggFn: 'avg', + alias: 'total_spans', + aggFn: 'count', aggCondition: '', - valueExpression: ERROR_COND, + valueExpression: '', + }, + { + alias: 'error_spans', + aggFn: 'count', + aggCondition: ERROR_COND, + aggConditionLanguage: 'sql', + valueExpression: '', }, + { alias: 'Error rate', valueExpression: 'error_spans / total_spans' }, ]); + // the two aggregated counts are the ones hidden from the chart + expect(ERROR_RATE_HELPER_SERIES).toEqual(['total_spans', 'error_spans']); }); it('volume: countIf error, rendered as bars', () => { @@ -113,30 +125,35 @@ describe('traceRedMetrics', () => { }); describe('durationConfig', () => { - it('avg + p95 + p99 over the ms duration expression, rendered as a line', () => { - const result = durationConfig(redBaseConfig(base), DURATION_MS); + it('aggregates the raw duration column and passes the display format through', () => { + const result = durationConfig( + redBaseConfig(base), + DURATION_EXPR, + DURATION_FORMAT, + ); expect(result.displayType).toBe(DisplayType.Line); - expect(result.numberFormat).toBe(MS_NUMBER_FORMAT); + // no SQL-side unit conversion: the format handles the unit at display + expect(result.numberFormat).toBe(DURATION_FORMAT); expect(result.select).toEqual([ { alias: 'Avg', aggFn: 'avg', aggCondition: '', - valueExpression: DURATION_MS, + valueExpression: DURATION_EXPR, }, { alias: 'p95', aggFn: 'quantile', level: 0.95, aggCondition: '', - valueExpression: DURATION_MS, + valueExpression: DURATION_EXPR, }, { alias: 'p99', aggFn: 'quantile', level: 0.99, aggCondition: '', - valueExpression: DURATION_MS, + valueExpression: DURATION_EXPR, }, ]); }); diff --git a/packages/app/src/components/Search/traceRedMetrics.ts b/packages/app/src/components/Search/traceRedMetrics.ts index 7613c1ddd2..8697ed0619 100644 --- a/packages/app/src/components/Search/traceRedMetrics.ts +++ b/packages/app/src/components/Search/traceRedMetrics.ts @@ -6,16 +6,28 @@ import { import { ERROR_RATE_PERCENTAGE_NUMBER_FORMAT, INTEGER_NUMBER_FORMAT, - MS_NUMBER_FORMAT, } from '@/ChartUtils'; +import type { NumberFormat } from '@/types'; export type ErrorsMode = 'rate' | 'volume'; +/** + * Helper series that back the error-rate ratio; hidden from the chart so only + * the computed rate shows. Aggregating count and countIf separately (instead of + * avg over a status boolean) lets AggregatingMergeTree materialized views + * satisfy the query. + */ +export const ERROR_RATE_HELPER_SERIES = ['total_spans', 'error_spans']; + /** * The RED metrics for a trace source, derived from the same base config the * count histogram uses so they honor the active WHERE filter and time range. * Kept as pure builders so the aggregations can be unit tested without * rendering. Only the select, display type, and number format change per chart. + * + * Every aggregation is over a raw column (count, countIf, quantile/avg of the + * duration expression) so materialized views can satisfy it; unit and ratio + * conversion happen at the display layer or in a post-aggregation column. */ /** SQL condition that marks a span as an error, or undefined when the source @@ -53,9 +65,11 @@ export function throughputConfig( } /** - * Errors as a rate (avg of a 0/1 error boolean, rendered as a percent line) or - * a volume (countIf error, rendered as bars). Returns undefined when the source - * has no error condition. + * Errors as a rate or a volume. Rate is `countIf(error) / count()`: the two + * counts are aggregated separately (MV-friendly) and divided in a + * post-aggregation column, with the helper counts hidden via + * ERROR_RATE_HELPER_SERIES. Volume is `countIf(error)` as bars. Returns + * undefined when the source has no error condition. */ export function errorsConfig( base: BuilderChartConfigWithDateRange, @@ -70,11 +84,19 @@ export function errorsConfig( ...base, select: [ { - alias: 'Error rate', - aggFn: 'avg', + alias: 'total_spans', + aggFn: 'count', aggCondition: '', - valueExpression: errorCondition, + valueExpression: '', }, + { + alias: 'error_spans', + aggFn: 'count', + aggCondition: errorCondition, + aggConditionLanguage: 'sql', + valueExpression: '', + }, + { alias: 'Error rate', valueExpression: 'error_spans / total_spans' }, ], displayType: DisplayType.Line, numberFormat: ERROR_RATE_PERCENTAGE_NUMBER_FORMAT, @@ -96,10 +118,16 @@ export function errorsConfig( }; } -/** Duration: Avg, p95, p99 over the source's millisecond duration expression. */ +/** + * Duration: Avg, p95, p99 over the source's raw duration expression. Aggregating + * the raw column (not a divided-to-ms expression) keeps it MV-friendly; the + * caller passes a duration NumberFormat derived from the source's precision + * (via getTraceDurationNumberFormat) so unit conversion happens at display. + */ export function durationConfig( base: BuilderChartConfigWithDateRange, - durationMsExpression: string, + durationExpression: string, + numberFormat: NumberFormat | undefined, ): BuilderChartConfigWithDateRange { return { ...base, @@ -108,24 +136,24 @@ export function durationConfig( alias: 'Avg', aggFn: 'avg', aggCondition: '', - valueExpression: durationMsExpression, + valueExpression: durationExpression, }, { alias: 'p95', aggFn: 'quantile', level: 0.95, aggCondition: '', - valueExpression: durationMsExpression, + valueExpression: durationExpression, }, { alias: 'p99', aggFn: 'quantile', level: 0.99, aggCondition: '', - valueExpression: durationMsExpression, + valueExpression: durationExpression, }, ], displayType: DisplayType.Line, - numberFormat: MS_NUMBER_FORMAT, + numberFormat, }; } From 59033f84cf61f1aff07db7d850b77efeaa4bb356 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:12:45 +0000 Subject: [PATCH 3/4] fix(app): cap error rate and stop RED x-axis labels clipping - Error rate guards the empty-bucket 0/0 (which became NaN and blew up the auto-scaled y-axis, showing up to 400% even with no errors) and caps at 100%. It also reads with one decimal so sub-1% rates are not all '0%'. - Add an opt-in compactXAxisLabels to DBTimeChart / HDXMultiSeriesTimeChart that anchors the first and last x-axis labels inward, so the edge time labels are not clipped on the narrow side-by-side RED tiles. Default off, so every other chart is unchanged. Co-Authored-By: Claude Opus 4.8 --- packages/app/src/HDXMultiSeriesTimeChart.tsx | 45 ++++++++++++++++++- packages/app/src/components/DBTimeChart.tsx | 7 +++ .../Search/TraceRedMetricsChart.tsx | 2 + .../Search/__tests__/traceRedMetrics.test.ts | 15 ++++--- .../src/components/Search/traceRedMetrics.ts | 23 +++++++--- 5 files changed, 78 insertions(+), 14 deletions(-) diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index b334956ecb..77719bf62c 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -21,11 +21,12 @@ import { ReferenceArea, ReferenceLine, ResponsiveContainer, + Text, Tooltip, XAxis, YAxis, } from 'recharts'; -import { AxisDomain } from 'recharts/types/util/types'; +import { AxisDomain, XAxisTickContentProps } from 'recharts/types/util/types'; import { convertGranularityToSeconds } from '@hyperdx/common-utils/dist/core/utils'; import { DisplayType } from '@hyperdx/common-utils/dist/types'; import { Button, Popover, Tooltip as MantineTooltip } from '@mantine/core'; @@ -650,6 +651,7 @@ export const MemoChart = memo(function MemoChart({ granularity, dateRangeEndInclusive = true, fitYAxisToData = false, + compactXAxisLabels = false, }: { graphResults: any[]; setIsClickActive: (v: ActiveClickPayload | undefined) => void; @@ -683,6 +685,12 @@ export const MemoChart = memo(function MemoChart({ * (with padding) instead of zero. **/ fitYAxisToData?: boolean; + /** + * When true, anchor the first x-axis label to the start and the last to the + * end (instead of centering every label) so edge labels are not clipped on + * narrow charts, e.g. the side-by-side RED metrics tiles. + */ + compactXAxisLabels?: boolean; }) { const _id = useId(); const id = _id.replace(/:/g, ''); @@ -919,6 +927,35 @@ export const MemoChart = memo(function MemoChart({ [formatTime], ); + // Compact mode: anchor the first label to the start and the last to the end + // so neither is clipped on a narrow chart. Renders every tick through one + // path (token color, mono) so the axis stays visually consistent. + const renderCompactXTick = useCallback( + ({ x, y, payload, index, visibleTicksCount }: XAxisTickContentProps) => { + const textAnchor = + index <= 0 + ? 'start' + : index >= visibleTicksCount - 1 + ? 'end' + : 'middle'; + return ( + + {xTickFormatter(Number(payload.value), index)} + + ); + }, + [xTickFormatter], + ); + const tickFormatter = useCallback( (value: number) => { return axisNumberFormat @@ -1265,7 +1302,11 @@ export const MemoChart = memo(function MemoChart({ type="number" tickFormatter={xTickFormatter} minTickGap={100} - tick={{ fontSize: 11, fontFamily: 'IBM Plex Mono, monospace' }} + tick={ + compactXAxisLabels + ? renderCompactXTick + : { fontSize: 11, fontFamily: 'IBM Plex Mono, monospace' } + } /> void; + /** + * Anchor the first/last x-axis labels inward so they are not clipped on + * narrow charts (e.g. side-by-side RED metric tiles). Forwarded to the chart. + */ + compactXAxisLabels?: boolean; }; function DBTimeChartComponent({ @@ -327,6 +332,7 @@ function DBTimeChartComponent({ showDateRangeIndicator = true, errorVariant, onFocusSeries, + compactXAxisLabels, }: DBTimeChartComponentProps) { const [selectedSeriesSet, setSelectedSeriesSet] = useState>( new Set(), @@ -848,6 +854,7 @@ function DBTimeChartComponent({ granularity={granularity} dateRangeEndInclusive={queriedConfig.dateRangeEndInclusive} fitYAxisToData={queriedConfig.fitYAxisToData} + compactXAxisLabels={compactXAxisLabels} /> )} diff --git a/packages/app/src/components/Search/TraceRedMetricsChart.tsx b/packages/app/src/components/Search/TraceRedMetricsChart.tsx index db751d3508..e4326106df 100644 --- a/packages/app/src/components/Search/TraceRedMetricsChart.tsx +++ b/packages/app/src/components/Search/TraceRedMetricsChart.tsx @@ -157,6 +157,8 @@ export function TraceRedMetricsChart({ queryKeyPrefix, onTimeRangeSelect, enableParallelQueries: true, + // narrow tiles: keep the edge time labels from clipping + compactXAxisLabels: true, } as const; return ( diff --git a/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts b/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts index 35ec6778fd..94129d653a 100644 --- a/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts +++ b/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts @@ -3,12 +3,10 @@ import { DisplayType, } from '@hyperdx/common-utils/dist/types'; -import { - ERROR_RATE_PERCENTAGE_NUMBER_FORMAT, - INTEGER_NUMBER_FORMAT, -} from '@/ChartUtils'; +import { INTEGER_NUMBER_FORMAT } from '@/ChartUtils'; import { durationConfig, + ERROR_RATE_FORMAT, ERROR_RATE_HELPER_SERIES, errorConditionSql, errorsConfig, @@ -77,7 +75,7 @@ describe('traceRedMetrics', () => { const result = errorsConfig(redBaseConfig(base), ERROR_COND, 'rate'); expect(result).toBeDefined(); expect(result?.displayType).toBe(DisplayType.Line); - expect(result?.numberFormat).toBe(ERROR_RATE_PERCENTAGE_NUMBER_FORMAT); + expect(result?.numberFormat).toBe(ERROR_RATE_FORMAT); expect(result?.select).toEqual([ { alias: 'total_spans', @@ -92,7 +90,12 @@ describe('traceRedMetrics', () => { aggConditionLanguage: 'sql', valueExpression: '', }, - { alias: 'Error rate', valueExpression: 'error_spans / total_spans' }, + { + alias: 'Error rate', + // guards the empty-bucket 0/0 and caps at 100% + valueExpression: + 'least(if(total_spans > 0, error_spans / total_spans, 0), 1)', + }, ]); // the two aggregated counts are the ones hidden from the chart expect(ERROR_RATE_HELPER_SERIES).toEqual(['total_spans', 'error_spans']); diff --git a/packages/app/src/components/Search/traceRedMetrics.ts b/packages/app/src/components/Search/traceRedMetrics.ts index 8697ed0619..cf90b36a63 100644 --- a/packages/app/src/components/Search/traceRedMetrics.ts +++ b/packages/app/src/components/Search/traceRedMetrics.ts @@ -3,14 +3,18 @@ import { DisplayType, } from '@hyperdx/common-utils/dist/types'; -import { - ERROR_RATE_PERCENTAGE_NUMBER_FORMAT, - INTEGER_NUMBER_FORMAT, -} from '@/ChartUtils'; +import { INTEGER_NUMBER_FORMAT } from '@/ChartUtils'; import type { NumberFormat } from '@/types'; export type ErrorsMode = 'rate' | 'volume'; +// One decimal so sub-1% rates read (e.g. 0.4%) instead of rounding to 0%, +// while the axis still auto-scales to the data. +export const ERROR_RATE_FORMAT: NumberFormat = { + output: 'percent', + mantissa: 1, +}; + /** * Helper series that back the error-rate ratio; hidden from the chart so only * the computed rate shows. Aggregating count and countIf separately (instead of @@ -96,10 +100,17 @@ export function errorsConfig( aggConditionLanguage: 'sql', valueExpression: '', }, - { alias: 'Error rate', valueExpression: 'error_spans / total_spans' }, + { + // Guard the empty-bucket 0/0 (which becomes NaN and breaks the + // auto-scaled y-axis) and clamp to 100%, since an error rate cannot + // exceed 1 by definition. + alias: 'Error rate', + valueExpression: + 'least(if(total_spans > 0, error_spans / total_spans, 0), 1)', + }, ], displayType: DisplayType.Line, - numberFormat: ERROR_RATE_PERCENTAGE_NUMBER_FORMAT, + numberFormat: ERROR_RATE_FORMAT, }; } return { From 575ea72278bf771bd26bf8f5bd8c5a9f16bb0f47 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:31:09 +0000 Subject: [PATCH 4/4] fix(app): cap RED error-rate y-axis at 100% and tighten axis labels - The error-rate data is already bounded to [0,1], but recharts' default auto-domain turns a flat/zero series into a nonsense 0-400% scale. Add an opt-in yAxisMaxDomain to DBTimeChart / HDXMultiSeriesTimeChart that caps the upper bound (1 = 100%) while still auto-scaling to smaller values, and use it on the rate chart. A no-error range now shows 0-100%; a ~5% range still zooms to ~5%. - Nudge the compact x-axis tick offset up so labels sit closer to the axis (extra gap crept in with the edge-anchor change). Co-Authored-By: Claude Opus 4.8 --- packages/app/src/HDXMultiSeriesTimeChart.tsx | 23 ++++++++++++++++++- packages/app/src/components/DBTimeChart.tsx | 7 ++++++ .../Search/TraceRedMetricsChart.tsx | 4 ++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index 77719bf62c..d15cb8df5c 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -652,6 +652,7 @@ export const MemoChart = memo(function MemoChart({ dateRangeEndInclusive = true, fitYAxisToData = false, compactXAxisLabels = false, + yAxisMaxDomain, }: { graphResults: any[]; setIsClickActive: (v: ActiveClickPayload | undefined) => void; @@ -691,6 +692,13 @@ export const MemoChart = memo(function MemoChart({ * narrow charts, e.g. the side-by-side RED metrics tiles. */ compactXAxisLabels?: boolean; + /** + * Cap the y-axis upper bound at this value (e.g. 1 for a 0-100% rate). The + * axis still auto-scales below the cap so small values keep a tight range, + * and a flat/zero series falls back to the cap instead of a degenerate + * auto-domain. Only applied on the default (non-fit, non-selection) path. + */ + yAxisMaxDomain?: number; }) { const _id = useId(); const id = _id.replace(/:/g, ''); @@ -807,6 +815,18 @@ export const MemoChart = memo(function MemoChart({ // fit the lower bound to the data. When neither applies, let Recharts // auto-calculate the upper bound while pinning the lower bound to zero. if (!hasSelection && !shouldFitYAxis) { + if (yAxisMaxDomain != null) { + // Auto-scale up to the data max (with headroom) but never above the + // cap; a flat or zero series uses the cap instead of a degenerate + // auto-domain (which recharts renders as e.g. 0-400% for a 0% rate). + return [ + 0, + (dataMax: number) => + Number.isFinite(dataMax) && dataMax > 0 + ? Math.min(dataMax * 1.1, yAxisMaxDomain) + : yAxisMaxDomain, + ]; + } return [0, 'auto']; } @@ -851,6 +871,7 @@ export const MemoChart = memo(function MemoChart({ selectedSeriesNames, fitYAxisToData, displayType, + yAxisMaxDomain, ]); const [containerWidth, setContainerWidth] = useState(0); @@ -942,7 +963,7 @@ export const MemoChart = memo(function MemoChart({ >( new Set(), @@ -855,6 +861,7 @@ function DBTimeChartComponent({ dateRangeEndInclusive={queriedConfig.dateRangeEndInclusive} fitYAxisToData={queriedConfig.fitYAxisToData} compactXAxisLabels={compactXAxisLabels} + yAxisMaxDomain={yAxisMaxDomain} /> )} diff --git a/packages/app/src/components/Search/TraceRedMetricsChart.tsx b/packages/app/src/components/Search/TraceRedMetricsChart.tsx index e4326106df..8a572c3010 100644 --- a/packages/app/src/components/Search/TraceRedMetricsChart.tsx +++ b/packages/app/src/components/Search/TraceRedMetricsChart.tsx @@ -188,6 +188,10 @@ export function TraceRedMetricsChart({ hiddenSeries={ errorsMode === 'rate' ? ERROR_RATE_HELPER_SERIES : undefined } + // Rate is 0-100%: cap the axis so a flat/near-zero series + // can't render a nonsense 0-400% scale, while still + // auto-scaling to small values. + yAxisMaxDomain={errorsMode === 'rate' ? 1 : undefined} {...commonProps} />