From 2c78a82198ecfde8bf90ebb782075e68cf29bd93 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:36:58 +0000 Subject: [PATCH 1/6] feat(app): show RED metrics on the trace search results view Trace search showed a single count histogram above the results. For trace sources with a duration column it now shows Throughput, Errors and Duration side by side, under a shared sync scope so hovering one moves the cursor on all three. Each chart is built from the same base config the histogram uses, in a pure module so the aggregations can be unit tested without rendering. Every aggregation is over a raw column so materialized views can satisfy it; the error ratio and the duration unit are applied at display instead. Logs and other sources keep the existing histogram. --- .changeset/red-metrics-trace-search.md | 5 + packages/app/src/ChartUtils.tsx | 31 ++- packages/app/src/DBSearchPage.tsx | 106 ++++++--- packages/app/src/HDXMultiSeriesTimeChart.tsx | 101 ++++++++- packages/app/src/__tests__/ChartUtils.test.ts | 76 ++++++- .../__tests__/HDXMultiSeriesTimeChart.test.ts | 46 ++++ packages/app/src/components/DBTimeChart.tsx | 24 ++ .../Search/TraceRedMetricsChart.tsx | 171 +++++++++++++++ .../Search/__tests__/traceRedMetrics.test.ts | 197 +++++++++++++++++ .../src/components/Search/traceRedMetrics.ts | 205 ++++++++++++++++++ 10 files changed, 920 insertions(+), 42 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..12632044e9 --- /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 clicking a status series on the volume chart filters the results. Logs and other sources keep the existing histogram. diff --git a/packages/app/src/ChartUtils.tsx b/packages/app/src/ChartUtils.tsx index fbbd1c4faf..7f76cca48f 100644 --- a/packages/app/src/ChartUtils.tsx +++ b/packages/app/src/ChartUtils.tsx @@ -35,6 +35,7 @@ import { BuilderSavedChartConfig, ChartConfigWithDateRange, ChartConfigWithOptDateRange, + ChartPaletteToken, DateRange, DisplayType, Filter, @@ -62,7 +63,12 @@ import { import { getMetricNameSql } from './otelSemanticConventions'; import { AggFn, TableChartSeries, TimeChartSeries } from './types'; import { NumberFormat } from './types'; -import { getColorProps, getLogLevelColorOrder, logLevelColor } from './utils'; +import { + getColorFromCSSToken, + getColorProps, + getLogLevelColorOrder, + logLevelColor, +} from './utils'; type SortOrder = 'asc' | 'desc'; @@ -636,6 +642,7 @@ interface LineDataWithOptionalColor extends Omit { function setLineColors( sortedLineData: LineDataWithOptionalColor[], + seriesColors?: Record, ): LineData[] { // Ensure that the current and previous period lines are the same color const lineColorByCurrentPeriodKey = new Map(); @@ -646,10 +653,15 @@ function setLineColors( if (lineColorByCurrentPeriodKey.has(currentPeriodKey)) { line.color = lineColorByCurrentPeriodKey.get(currentPeriodKey); } else if (!line.color) { - line.color = getColorProps( - colorIndex++, - line.displayName ?? line.dataKey, - ); + const seriesName = line.displayName ?? line.dataKey; + // A caller-pinned color (keyed by series name) wins over the positional + // palette, so a series keeps the same hue no matter how the data sorts + // it. A pinned series does not consume a positional slot, so unpinned + // series keep their natural palette order. + const pinnedToken = seriesColors?.[seriesName]; + line.color = pinnedToken + ? getColorFromCSSToken(pinnedToken) + : getColorProps(colorIndex++, seriesName); lineColorByCurrentPeriodKey.set(currentPeriodKey, line.color); } else { lineColorByCurrentPeriodKey.set(currentPeriodKey, line.color); @@ -801,6 +813,7 @@ export function formatResponseForTimeChart({ hiddenSeries = [], previousPeriodOffsetSeconds = 0, maxSeries = MAX_RENDERED_TIME_CHART_SERIES, + seriesColors, }: { dateRange: [Date, Date]; granularity?: SQLInterval; @@ -816,6 +829,12 @@ export function formatResponseForTimeChart({ * every series (the "load all" escape hatch behind the hidden-series notice). */ maxSeries?: number; + /** + * Pin specific series (by display name) to a palette token instead of the + * positional palette, so their color is stable across sessions and can avoid + * hues reserved for other meanings (e.g. keeping latency off error red). + */ + seriesColors?: Record; }) { const meta = currentPeriodResponse.meta; @@ -1050,7 +1069,7 @@ export function formatResponseForTimeChart({ (a, b) => a[timestampColumn.name] - b[timestampColumn.name], ); - const sortedLineDataWithColors = setLineColors(sortedLineData); + const sortedLineDataWithColors = setLineColors(sortedLineData, seriesColors); // Count of LOGICAL groups actually rendered, in the same unit as // hiddenSeriesCount — so a comparison chart (current + previous entries per diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 2a90cae9c6..f4dbd002d3 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -155,6 +155,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'; @@ -220,6 +221,13 @@ const SearchConfigSchema = z.object({ type SearchConfigFromSchema = z.infer; const QUERY_KEY_PREFIX = 'search'; +// The RED tiles are auxiliary viz, not the results query, so they key their +// queries under a separate prefix to stay out of the `search executed` +// telemetry (which matches [QUERY_KEY_PREFIX] only). Otherwise the slow +// avg/p95/p99 duration query would become what the reported search latency +// measures. The live-tail pause, on the other hand, does wait on this prefix +// (see the pause signal below) so live ticks don't stack fresh quantile scans. +const RED_QUERY_KEY_PREFIX = `${QUERY_KEY_PREFIX}-red`; // Clicks inside the results panel keep the row side panel open (so users can // scroll the table or select a different row); clicks anywhere else on the page @@ -1710,6 +1718,16 @@ function DBSearchPageContent() { queryKey: [QUERY_KEY_PREFIX], }) > 0; + // Live tail also waits on the RED metric queries. They live under a separate + // prefix (kept out of the search-latency telemetry above), so without this + // each ~10s tick would fire a fresh avg/p95/p99 scan without waiting for the + // previous one, stacking slow quantile queries while live tail runs. Resolves + // to 0 when the RED tiles aren't mounted, so it's a no-op off the trace view. + const isRedQueryFetching = + useIsFetching({ + queryKey: [RED_QUERY_KEY_PREFIX], + }) > 0; + const { searchElapsedMs } = useSearchTelemetry({ isAnyQueryFetching, isLive: isLive ?? false, @@ -1766,9 +1784,12 @@ function DBSearchPageContent() { onTimeRangeSelect, // The save modal captures the search as it stands, so refreshing the time // range underneath it is both surprising and a source of query-string - // writes that would race the navigation to the new saved search. + // writes that would race the navigation to the new saved search. Live tail + // also waits on the RED metric queries (isRedQueryFetching) so ticks don't + // stack fresh quantile scans while a previous one is still in flight. pause: isAnyQueryFetching || + isRedQueryFetching || !queryReady || !isTabVisible || saveSearchModalState != null, @@ -1995,6 +2016,17 @@ function DBSearchPageContent() { const aliasWith = useMemo(() => aliasMapToWithClauses(aliasMap), [aliasMap]); + // The trace results chart shows RED metrics only for a trace source that + // exposes a duration column; otherwise the single histogram stays. Derived + // once, as the narrowed source or null, so the callers can't drift apart on + // which one renders. + const traceRedMetricsSource = + searchedSource != null && + isTraceSource(searchedSource) && + searchedSource.durationExpression + ? searchedSource + : null; + const histogramTimeChartConfig = useMemo(() => { if (chartConfig == null) { return undefined; @@ -2711,11 +2743,7 @@ function DBSearchPageContent() { setAnalysisMode={setAnalysisMode} chartConfig={filtersChartConfig} sourceId={inputSourceObj?.id} - showDelta={ - !!(searchedSource?.kind === SourceKind.Trace - ? searchedSource.durationExpression - : undefined) - } + showDelta={traceRedMetricsSource != null} onColumnToggle={toggleColumn} displayedColumns={displayedColumns} onCollapse={isCompact ? undefined : collapseFilters} @@ -2872,30 +2900,48 @@ function DBSearchPageContent() { - {!hasQueryError && ( - - - - )} + {!hasQueryError && + (traceRedMetricsSource != null ? ( + + + + ) : ( + + + + ))} )} {hasQueryError && queryError ? ( diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index 3de997cf70..a80e4c3f27 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -22,13 +22,14 @@ import { ReferenceArea, ReferenceLine, ResponsiveContainer, + Text, Tooltip, XAxis, YAxis, } from 'recharts'; import { getTickValuesFixedDomain } from 'recharts/lib/util/scale/getNiceTickValues'; import { CurveType } from 'recharts/types/shape/Curve'; -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, @@ -1178,6 +1179,7 @@ export function computeYAxisBounds( displayType: DisplayType, referenceLineValues: number[], axisNumberFormat?: NumberFormat, + yAxisMaxDomain?: number, ): YAxisBounds { if ( displayType === DisplayType.StackedBar || @@ -1188,6 +1190,19 @@ export function computeYAxisBounds( const shouldFitYAxis = fitYAxisToData; 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). Recharts ticks + // this domain itself, so no explicit ticks are returned. + return { + domain: [ + 0, + (dataMax: number) => cappedYAxisUpperBound(dataMax, yAxisMaxDomain), + ], + ticks: undefined, + }; + } // A fully numeric domain skips Recharts' own nice rounding, and a // reference line can extend it further - defer to Recharts entirely. if (referenceLineValues.length > 0) { @@ -1303,6 +1318,37 @@ export function computeYAxisBounds( }; } +/** + * Upper bound for a y-axis that has an explicit cap (`yAxisMaxDomain`). Recharts + * calls this with the data's max value. Auto-scale up to the data max with 10% + * headroom, but never above the cap; a flat or zero/non-finite series uses the + * cap instead of a degenerate auto-domain (which recharts otherwise renders as + * e.g. 0-400% for a 0% error rate). + */ +export function cappedYAxisUpperBound(dataMax: number, cap: number): number { + return Number.isFinite(dataMax) && dataMax > 0 + ? Math.min(dataMax * 1.1, cap) + : cap; +} + +/** + * Text anchor for a compact x-axis tick. The first and last ticks are anchored + * to their inner edge so the edge time labels stay inside the plot instead of + * clipping; interior ticks stay centered. + */ +export function compactTickAnchor( + index: number, + visibleTicksCount: number, +): 'start' | 'middle' | 'end' { + if (index <= 0) { + return 'start'; + } + if (index >= visibleTicksCount - 1) { + return 'end'; + } + return 'middle'; +} + export const MemoChart = memo(function MemoChart({ graphResults, setIsClickActive, @@ -1330,6 +1376,8 @@ export const MemoChart = memo(function MemoChart({ dateRangeEndInclusive = true, fitYAxisToData = false, lineInterpolation, + compactXAxisLabels = false, + yAxisMaxDomain, }: { graphResults: any[]; setIsClickActive: (v: ActiveClickPayload | undefined) => void; @@ -1376,6 +1424,19 @@ export const MemoChart = memo(function MemoChart({ fitYAxisToData?: boolean; /** Unset follows the display type's default (see getLineCurveType). */ lineInterpolation?: LineInterpolation; + /** + * 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; + /** + * 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, ''); @@ -1519,6 +1580,7 @@ export const MemoChart = memo(function MemoChart({ displayType, referenceLineValues, axisNumberFormat, + yAxisMaxDomain, ), [ graphResults, @@ -1528,6 +1590,7 @@ export const MemoChart = memo(function MemoChart({ displayType, referenceLineValues, axisNumberFormat, + yAxisMaxDomain, ], ); const yAxisDomain = yAxisBounds.domain; @@ -1643,6 +1706,30 @@ 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 = compactTickAnchor(index, visibleTicksCount); + return ( + + {xTickFormatter(Number(payload.value), index)} + + ); + }, + [xTickFormatter], + ); + const tickFormatter = useCallback( (value: number) => formatAxisTick(value, axisNumberFormat), [axisNumberFormat], @@ -2102,10 +2189,14 @@ export const MemoChart = memo(function MemoChart({ type="number" tickFormatter={xTickFormatter} minTickGap={100} - tick={{ - fontSize: axisTickFontSize, - fontFamily: 'IBM Plex Mono, monospace', - }} + tick={ + compactXAxisLabels + ? renderCompactXTick + : { + fontSize: axisTickFontSize, + fontFamily: 'IBM Plex Mono, monospace', + } + } /> { ]); }); + it('pins a series to a palette token via seriesColors, overriding the positional palette', () => { + const res = { + data: [ + { p99: 100, __hdx_time_bucket: '2025-11-26T11:12:00Z' }, + { p99: 200, __hdx_time_bucket: '2025-11-26T11:13:00Z' }, + ], + meta: [ + { name: 'p99', type: 'Float64' }, + { name: '__hdx_time_bucket', type: 'DateTime' }, + ], + }; + + const pinned = formatResponseForTimeChart({ + currentPeriodResponse: res, + dateRange: [new Date(), new Date()], + granularity: '1 minute', + generateEmptyBuckets: false, + seriesColors: { p99: 'chart-purple' }, + }); + // The pinned token wins over the default first palette hue and stays + // fixed regardless of where the series sorts. + expect(pinned.lineData[0].color).toBe( + getColorFromCSSToken('chart-purple'), + ); + expect(pinned.lineData[0].color).not.toBe(COLORS[0]); + + // Without the pin, the same single series takes the positional palette. + const unpinned = formatResponseForTimeChart({ + currentPeriodResponse: res, + dateRange: [new Date(), new Date()], + granularity: '1 minute', + generateEmptyBuckets: false, + }); + expect(unpinned.lineData[0].color).toBe(COLORS[0]); + }); + + it('does not let a pinned series consume a positional palette slot', () => { + // seriesHi has the higher peak, so it sorts (and colors) first. If a + // pinned series wrongly advanced the positional index, the one unpinned + // series would land on COLORS[1] instead of COLORS[0]. + const res = { + data: [ + { + seriesHi: 500, + seriesLo: 100, + __hdx_time_bucket: '2025-11-26T11:12:00Z', + }, + { + seriesHi: 600, + seriesLo: 200, + __hdx_time_bucket: '2025-11-26T11:13:00Z', + }, + ], + meta: [ + { name: 'seriesHi', type: 'Float64' }, + { name: 'seriesLo', type: 'Float64' }, + { name: '__hdx_time_bucket', type: 'DateTime' }, + ], + }; + + const actual = formatResponseForTimeChart({ + currentPeriodResponse: res, + dateRange: [new Date(), new Date()], + granularity: '1 minute', + generateEmptyBuckets: false, + seriesColors: { seriesHi: 'chart-purple' }, + }); + + const pinned = actual.lineData.find(l => l.dataKey === 'seriesHi'); + const plain = actual.lineData.find(l => l.dataKey === 'seriesLo'); + expect(pinned?.color).toBe(getColorFromCSSToken('chart-purple')); + expect(plain?.color).toBe(COLORS[0]); + }); + it('should format a response with multiple value columns and a group by', () => { const res = { data: [ diff --git a/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts b/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts index 9a53c84384..305e38633a 100644 --- a/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts +++ b/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts @@ -12,7 +12,9 @@ import type { LineData } from '@/ChartUtils'; import type { ActiveClickSeries } from '@/HDXMultiSeriesTimeChart'; import { buildActiveClickSeries, + cappedYAxisUpperBound, collectMemoChartGradientHexes, + compactTickAnchor, computeYAxisBounds, formatAxisTick, getExpandableYAxisTicks, @@ -1142,3 +1144,47 @@ describe('getVisibleTooltipRows', () => { expect(result.rows).toHaveLength(20); }); }); + +describe('cappedYAxisUpperBound', () => { + it('auto-scales to the data max plus 10% headroom when below the cap', () => { + expect(cappedYAxisUpperBound(0.5, 1)).toBeCloseTo(0.55); + }); + + it('never exceeds the cap, even when the data max plus headroom would', () => { + // 0.95 * 1.1 = 1.045, clamped to the cap. + expect(cappedYAxisUpperBound(0.95, 1)).toBe(1); + expect(cappedYAxisUpperBound(5, 1)).toBe(1); + }); + + it('uses the cap for a flat/zero series instead of a degenerate domain', () => { + expect(cappedYAxisUpperBound(0, 1)).toBe(1); + }); + + it('uses the cap for a negative or non-finite data max', () => { + expect(cappedYAxisUpperBound(-1, 1)).toBe(1); + expect(cappedYAxisUpperBound(NaN, 1)).toBe(1); + expect(cappedYAxisUpperBound(Infinity, 1)).toBe(1); + }); +}); + +describe('compactTickAnchor', () => { + it('anchors the first tick to the start edge', () => { + expect(compactTickAnchor(0, 5)).toBe('start'); + }); + + it('anchors the last tick to the end edge', () => { + expect(compactTickAnchor(4, 5)).toBe('end'); + }); + + it('centers interior ticks', () => { + expect(compactTickAnchor(2, 5)).toBe('middle'); + }); + + it('treats a negative index as the start edge', () => { + expect(compactTickAnchor(-1, 5)).toBe('start'); + }); + + it('resolves the single-tick case to start (the first-edge check wins)', () => { + expect(compactTickAnchor(0, 1)).toBe('start'); + }); +}); diff --git a/packages/app/src/components/DBTimeChart.tsx b/packages/app/src/components/DBTimeChart.tsx index 5e2f24db2a..20406db9a4 100644 --- a/packages/app/src/components/DBTimeChart.tsx +++ b/packages/app/src/components/DBTimeChart.tsx @@ -21,6 +21,7 @@ import { import { BuilderChartConfigWithDateRange, ChartConfigWithDateRange, + ChartPaletteToken, DisplayType, } from '@hyperdx/common-utils/dist/types'; import { Popover, Portal } from '@mantine/core'; @@ -374,6 +375,12 @@ type DBTimeChartComponentProps = { sourceId?: string; /** Names of series that should not be shown in the chart */ hiddenSeries?: string[]; + /** + * Pin specific series (by display name) to a palette token instead of the + * positional palette, so their color stays stable across sessions and can + * avoid hues reserved for other meanings (e.g. keeping latency off error red). + */ + seriesColors?: Record; title?: React.ReactNode; toolbarPrefix?: React.ReactNode[]; toolbarSuffix?: React.ReactNode[]; @@ -394,6 +401,16 @@ type DBTimeChartComponentProps = { * with the series' group-by expressions (or PromQL labels) and values. */ onFilter?: TileFilterHandler; + /** + * 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; + /** + * Cap the y-axis upper bound (e.g. 1 for a 0-100% rate) while still + * auto-scaling below it. Forwarded to the chart. + */ + yAxisMaxDomain?: number; }; function DBTimeChartComponent({ @@ -413,6 +430,7 @@ function DBTimeChartComponent({ showLegend = true, sourceId, hiddenSeries, + seriesColors, title, toolbarPrefix, toolbarSuffix, @@ -421,6 +439,8 @@ function DBTimeChartComponent({ errorVariant, onFocusSeries, onFilter, + compactXAxisLabels, + yAxisMaxDomain, }: DBTimeChartComponentProps) { const [selectedSeriesSet, setSelectedSeriesSet] = useState>( new Set(), @@ -669,6 +689,7 @@ function DBTimeChartComponent({ generateEmptyBuckets: shouldFillNullsWithZero(fillNulls), source, hiddenSeries, + seriesColors, previousPeriodOffsetSeconds, // "Load all" (from the warning / pinned tooltip) overrides everything; // otherwise the per-tile Series Limit drives the cap (null = default, @@ -710,6 +731,7 @@ function DBTimeChartComponent({ config.seriesLimit, previousPeriodData, hiddenSeries, + seriesColors, previousPeriodOffsetSeconds, showAllSeries, ]); @@ -1168,6 +1190,8 @@ function DBTimeChartComponent({ dateRangeEndInclusive={queriedConfig.dateRangeEndInclusive} fitYAxisToData={queriedConfig.fitYAxisToData} lineInterpolation={queriedConfig.lineInterpolation} + compactXAxisLabels={compactXAxisLabels} + yAxisMaxDomain={yAxisMaxDomain} /> )} diff --git a/packages/app/src/components/Search/TraceRedMetricsChart.tsx b/packages/app/src/components/Search/TraceRedMetricsChart.tsx new file mode 100644 index 0000000000..cea4fe8607 --- /dev/null +++ b/packages/app/src/components/Search/TraceRedMetricsChart.tsx @@ -0,0 +1,171 @@ +import { type CSSProperties, useMemo, useState } from 'react'; +import { + BuilderChartConfigWithDateRange, + TTraceSource, +} from '@hyperdx/common-utils/dist/types'; +import { Flex, SegmentedControl } from '@mantine/core'; + +import { IsolatedChartSyncProvider } from '@/chartSync'; +import { ChartCard } from '@/components/charts/ChartCard'; +import { DBTimeChart, type SeriesGroupFilter } from '@/components/DBTimeChart'; +import { getTraceDurationNumberFormat } from '@/source'; + +import { + DURATION_SERIES_COLORS, + durationConfig, + ERROR_RATE_HELPER_SERIES, + errorsConfig, + ErrorsMode, + redBaseConfig, + throughputConfig, +} from './traceRedMetrics'; + +// Each RED tile is a ChartCard flex child that fills its column; ChartCard +// supplies the dashboard-tile chrome (border, background, full-bleed header +// divider) and the card-header context, so the three headers line up on their +// own without any manual height pinning. +const RED_TILE_STYLE = { + flex: 1, + minWidth: 0, + minHeight: 0, + height: '100%', +} satisfies CSSProperties; + +/** + * 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 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({ + histogramTimeChartConfig, + source, + isReady, + queryKeyPrefix, + onTimeRangeSelect, + onFocusSeries, +}: { + /** The count-histogram config; RED charts spread this and swap only select. */ + histogramTimeChartConfig: BuilderChartConfigWithDateRange; + source: TTraceSource; + isReady: boolean; + queryKeyPrefix?: string; + onTimeRangeSelect?: (start: Date, end: Date) => void; + /** Filter the results by a clicked status series, matching the histogram's + * per-status drill-down. Only the volume Errors chart is grouped by status, + * so only it surfaces this. */ + onFocusSeries?: (filters: SeriesGroupFilter[]) => void; +}) { + const [errorsMode, setErrorsMode] = useState('rate'); + + // Aggregate the raw Duration column (MV-friendly) and let the display format, + // derived from the source's durationPrecision, convert the unit. + const durationExpression = source.durationExpression ?? ''; + // Memoized: getTraceDurationNumberFormat returns a fresh object, so an + // unmemoized value would change identity every render and defeat the + // `duration` memo below (re-rendering DBTimeChart on every keystroke/tick). + const durationFormat = useMemo( + () => + getTraceDurationNumberFormat(source, { + valueExpression: durationExpression, + aggFn: 'avg', + }), + [source, durationExpression], + ); + + const base = useMemo( + () => redBaseConfig(histogramTimeChartConfig), + [histogramTimeChartConfig], + ); + const throughput = useMemo(() => throughputConfig(base), [base]); + const errors = useMemo( + () => errorsConfig(base, source.statusCodeExpression, errorsMode), + [base, source.statusCodeExpression, errorsMode], + ); + const duration = useMemo( + () => durationConfig(base, durationExpression, durationFormat), + [base, durationExpression, durationFormat], + ); + + const errorsModeControl = ( + setErrorsMode(v === 'volume' ? 'volume' : 'rate')} + data={[ + { label: 'Rate', value: 'rate' }, + { label: 'Vol', value: 'volume' }, + ]} + /> + ); + + const commonProps = { + sourceId: source.id, + enabled: isReady, + showDisplaySwitcher: false, + showMVOptimizationIndicator: false, + showDateRangeIndicator: false, + queryKeyPrefix, + onTimeRangeSelect, + enableParallelQueries: true, + // narrow tiles: keep the edge time labels from clipping + compactXAxisLabels: true, + } as const; + + return ( + + + + + + {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..d2e74f1558 --- /dev/null +++ b/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts @@ -0,0 +1,197 @@ +import { + BuilderChartConfigWithDateRange, + DisplayType, +} from '@hyperdx/common-utils/dist/types'; + +import { INTEGER_NUMBER_FORMAT } from '@/ChartUtils'; +import { + DURATION_SERIES_COLORS, + durationConfig, + ERROR_RATE_FORMAT, + ERROR_RATE_HELPER_SERIES, + errorsConfig, + redBaseConfig, + throughputConfig, +} from '@/components/Search/traceRedMetrics'; +import type { NumberFormat } from '@/types'; + +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) IN ('error', 'status_code_error')"; +const DURATION_EXPR = 'Duration'; +const DURATION_FORMAT: NumberFormat = { output: 'duration', factor: 1e-9 }; + +describe('traceRedMetrics', () => { + 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: count + countIf aggregated separately, divided post-aggregation (MV-friendly)', () => { + const result = errorsConfig(redBaseConfig(base), 'StatusCode', 'rate'); + expect(result).toBeDefined(); + expect(result?.displayType).toBe(DisplayType.Line); + expect(result?.numberFormat).toBe(ERROR_RATE_FORMAT); + // rate aggregates across all statuses, so no per-status groupBy + expect(result?.groupBy).toBeUndefined(); + expect(result?.select).toEqual([ + { + alias: 'total_spans', + aggFn: 'count', + aggCondition: '', + valueExpression: '', + }, + { + alias: 'error_spans', + aggFn: 'count', + aggCondition: ERROR_COND, + aggConditionLanguage: 'sql', + valueExpression: '', + }, + { + alias: 'Error rate', + // guards the empty-bucket 0/0; ratio can't exceed 1 so no clamp + valueExpression: 'if(total_spans > 0, error_spans / total_spans, 0)', + }, + ]); + // the two aggregated counts are the ones hidden from the chart + expect(ERROR_RATE_HELPER_SERIES).toEqual(['total_spans', 'error_spans']); + }); + + it('volume: error spans grouped by status, filtered to the error condition, as bars', () => { + const result = errorsConfig(redBaseConfig(base), 'StatusCode', 'volume'); + expect(result).toBeDefined(); + expect(result?.displayType).toBe(DisplayType.StackedBar); + expect(result?.numberFormat).toBe(INTEGER_NUMBER_FORMAT); + // grouped by status so a clicked bar decodes to a status filter (the + // histogram's drill-down); restricted to errors so it stays errors-only + expect(result?.groupBy).toBe('StatusCode'); + expect(result?.select).toEqual([ + { + alias: 'Errors', + aggFn: 'count', + aggCondition: '', + valueExpression: '', + }, + ]); + expect(result?.filters).toEqual([{ type: 'sql', condition: ERROR_COND }]); + }); + + it('returns undefined when the source has no usable status expression', () => { + expect( + errorsConfig(redBaseConfig(base), undefined, 'rate'), + ).toBeUndefined(); + expect( + errorsConfig(redBaseConfig(base), undefined, 'volume'), + ).toBeUndefined(); + // blank/whitespace is treated as no status expression + expect( + errorsConfig(redBaseConfig(base), ' ', 'volume'), + ).toBeUndefined(); + }); + }); + + describe('durationConfig', () => { + 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); + // 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_EXPR, + }, + { + alias: 'p95', + aggFn: 'quantile', + level: 0.95, + aggCondition: '', + valueExpression: DURATION_EXPR, + }, + { + alias: 'p99', + aggFn: 'quantile', + level: 0.99, + aggCondition: '', + valueExpression: DURATION_EXPR, + }, + ]); + }); + + // The color pins are resolved by result-column name, so a select alias + // that no longer has a matching key silently falls back to the positional + // palette. Assert the two stay in lockstep rather than trusting that both + // sides get updated together. + it('pins a color for exactly the series it selects', () => { + const { select } = durationConfig( + redBaseConfig(base), + DURATION_EXPR, + DURATION_FORMAT, + ); + // durationConfig always builds a select list, never a raw SQL string + const aliases = Array.isArray(select) + ? select.map(s => ('alias' in s ? s.alias : undefined)) + : []; + expect(aliases).toEqual(Object.keys(DURATION_SERIES_COLORS)); + }); + + // Latency is not an error signal: red is reserved for the Errors tile, and + // the rest of the semantic palette carries its own meaning. The positional + // palette would hand slot 2 to chart-red, so this is the regression guard + // for that specific hue coming back. + it('never colors a latency series with a reserved semantic hue', () => { + const reserved = [ + 'chart-red', + 'chart-error', + 'chart-warning', + 'chart-success', + ]; + expect( + Object.values(DURATION_SERIES_COLORS).filter(token => + reserved.includes(token), + ), + ).toEqual([]); + }); + }); +}); diff --git a/packages/app/src/components/Search/traceRedMetrics.ts b/packages/app/src/components/Search/traceRedMetrics.ts new file mode 100644 index 0000000000..e8b10ec554 --- /dev/null +++ b/packages/app/src/components/Search/traceRedMetrics.ts @@ -0,0 +1,205 @@ +import { errorStatusPredicateSql } from '@hyperdx/common-utils/dist/core/spanPredicates'; +import { + BuilderChartConfigWithDateRange, + ChartPaletteToken, + DisplayType, +} from '@hyperdx/common-utils/dist/types'; + +import { + ERROR_RATE_PERCENTAGE_NUMBER_FORMAT, + INTEGER_NUMBER_FORMAT, +} from '@/ChartUtils'; +import type { NumberFormat } from '@/types'; + +export type ErrorsMode = 'rate' | 'volume'; + +// The app-wide error-rate percent format, but with one decimal so sub-1% rates +// read (e.g. 0.4%) instead of rounding to 0% on this tile's capped axis. +export const ERROR_RATE_FORMAT: NumberFormat = { + ...ERROR_RATE_PERCENTAGE_NUMBER_FORMAT, + mantissa: 1, +}; + +// Column aliases for the two counts that back the error-rate ratio. Declared +// once so the select, the post-aggregation ratio expression, and the +// hidden-series list below all stay in lockstep; renaming here can't silently +// unhide the helper counts. +const TOTAL_SPANS_ALIAS = 'total_spans'; +const ERROR_SPANS_ALIAS = 'error_spans'; + +/** + * 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_ALIAS, ERROR_SPANS_ALIAS]; + +/** + * 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. + */ + +/** 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 or a volume, derived from the source's status-code + * expression. Returns undefined when the source has no usable status + * expression (so the caller drops the Errors tile). + * + * 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 the error span count grouped by status and restricted to the error + * condition. Grouping keeps the histogram's drill-down: clicking the bar + * decodes to the status value, so the results list can filter to it. The error + * filter keeps the tile errors-only rather than reintroducing the healthy + * spans. + */ +export function errorsConfig( + base: BuilderChartConfigWithDateRange, + statusCodeExpression: string | undefined, + mode: ErrorsMode, +): BuilderChartConfigWithDateRange | undefined { + const groupBy = statusCodeExpression?.trim(); + if (!groupBy) { + return undefined; + } + const errorCondition = errorStatusPredicateSql(groupBy); + if (mode === 'rate') { + return { + ...base, + select: [ + { + alias: TOTAL_SPANS_ALIAS, + aggFn: 'count', + aggCondition: '', + valueExpression: '', + }, + { + alias: ERROR_SPANS_ALIAS, + aggFn: 'count', + aggCondition: errorCondition, + aggConditionLanguage: 'sql', + valueExpression: '', + }, + { + // Guard the empty-bucket 0/0 (which becomes NaN and breaks the + // auto-scaled y-axis). error_spans is a countIf over the same rows as + // total_spans = count(), so the ratio can't exceed 1; no clamp needed. + alias: 'Error rate', + valueExpression: `if(${TOTAL_SPANS_ALIAS} > 0, ${ERROR_SPANS_ALIAS} / ${TOTAL_SPANS_ALIAS}, 0)`, + }, + ], + displayType: DisplayType.Line, + numberFormat: ERROR_RATE_FORMAT, + }; + } + return { + ...base, + groupBy, + select: [ + { + alias: 'Errors', + aggFn: 'count', + aggCondition: '', + valueExpression: '', + }, + ], + filters: [ + ...(base.filters ?? []), + { type: 'sql' as const, condition: errorCondition }, + ], + displayType: DisplayType.StackedBar, + numberFormat: INTEGER_NUMBER_FORMAT, + }; +} + +// Duration series aliases, declared once so durationConfig's select and the +// DURATION_SERIES_COLORS pins below key off the same strings. setLineColors +// resolves a pin by result-column name, so a rename that touched only one side +// would silently drop the pin and let that percentile fall back to the +// positional palette slot (chart-red) the pins exist to avoid, with no test or +// type failure to catch it. +const DURATION_AVG_ALIAS = 'Avg'; +const DURATION_P95_ALIAS = 'p95'; +const DURATION_P99_ALIAS = 'p99'; + +/** + * Pin the Duration percentiles to stable, cool palette hues so latency never + * borrows error red (or warning orange / success green), and so each percentile + * keeps its color across sessions regardless of how the data sorts the lines. + */ +export const DURATION_SERIES_COLORS = { + [DURATION_AVG_ALIAS]: 'chart-blue', + [DURATION_P95_ALIAS]: 'chart-cyan', + [DURATION_P99_ALIAS]: 'chart-purple', +} satisfies Record; + +/** + * 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, + durationExpression: string, + numberFormat: NumberFormat | undefined, +): BuilderChartConfigWithDateRange { + return { + ...base, + select: [ + { + alias: DURATION_AVG_ALIAS, + aggFn: 'avg', + aggCondition: '', + valueExpression: durationExpression, + }, + { + alias: DURATION_P95_ALIAS, + aggFn: 'quantile', + level: 0.95, + aggCondition: '', + valueExpression: durationExpression, + }, + { + alias: DURATION_P99_ALIAS, + aggFn: 'quantile', + level: 0.99, + aggCondition: '', + valueExpression: durationExpression, + }, + ], + displayType: DisplayType.Line, + numberFormat, + }; +} From a289f0087b3b2d63c459d359df089d71d7223ec3 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Sat, 14 Nov 2026 00:58:44 +0000 Subject: [PATCH 2/6] fix(app): address review feedback on the trace RED metrics - Fall back to the single histogram on a compact (mobile) viewport instead of three unreadable side-by-side tiles. - Compute a numeric capped y-axis domain for the rate tile so it dedupes tick labels and no longer collapses to [0, 0] when the sole series is selected; the cap now applies on the selection and fit paths too. - Drop the unreachable 0/0 guard on the error-rate column; a returned GROUP BY bucket always has total_spans >= 1, so divide directly the way HttpTab does. - Rename the Duration percentile series p95/p99 to P95/P99 to match the app's acronym casing. - Strengthen the volume-mode test so it catches dropped sidebar filters, and add computeYAxisBounds coverage for the capped axis. --- packages/app/src/DBSearchPage.tsx | 5 +- packages/app/src/HDXMultiSeriesTimeChart.tsx | 41 +++++--- .../__tests__/HDXMultiSeriesTimeChart.test.ts | 94 +++++++++++++++++++ .../Search/__tests__/traceRedMetrics.test.ts | 25 +++-- .../src/components/Search/traceRedMetrics.ts | 14 +-- 5 files changed, 154 insertions(+), 25 deletions(-) diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index f4dbd002d3..606dfb0820 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -2901,7 +2901,10 @@ function DBSearchPageContent() { {!hasQueryError && - (traceRedMetricsSource != null ? ( + // The three side-by-side RED tiles need room; on a + // compact (narrow/mobile) viewport they would be + // unreadable, so fall back to the single histogram. + (traceRedMetricsSource != null && !isCompact ? ( + formatAxisTick(value, axisNumberFormat); return { - domain: [ - 0, - (dataMax: number) => cappedYAxisUpperBound(dataMax, yAxisMaxDomain), - ], - ticks: undefined, + domain: [0, upperBound], + ticks: getYAxisTicks(0, upperBound, baseFormat), + tickFormatter: baseFormat, }; } + return { + domain: [0, expanded.max], + ticks: expanded.ticks, + tickFormatter: expanded.tickFormatter, + }; + } + + if (!hasSelection && !shouldFitYAxis) { // A fully numeric domain skips Recharts' own nice rounding, and a // reference line can extend it further - defer to Recharts entirely. if (referenceLineValues.length > 0) { diff --git a/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts b/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts index 305e38633a..8837f81813 100644 --- a/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts +++ b/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts @@ -599,6 +599,100 @@ describe('computeYAxisBounds', () => { expect(bounds).toEqual({ domain: [0, 'auto'], ticks: undefined }); }); + describe('with yAxisMaxDomain (capped axis, e.g. a 0-100% rate tile)', () => { + it('pins a flat/zero series to [0, cap] with clean ticks instead of auto', () => { + // The flat-data case above falls back to [0, 'auto']; with a cap it must + // show the full [0, cap] range so a 0% rate reads as 0%, not a nonsense + // auto-scaled range. + const bounds = computeYAxisBounds( + [{ a: 0 }, { a: 0 }], + [series('a')], + false, + false, + DisplayType.Line, + [], + undefined, + 1, + ); + expect(bounds).toEqual({ + domain: [0, 1], + ticks: [0, 0.5, 1], + tickFormatter: expect.any(Function), + }); + }); + + it('scales the upper bound to the data peak but never above the cap', () => { + const bounds = computeYAxisBounds( + [{ a: 0.5 }], + [series('a')], + false, + false, + DisplayType.Line, + [], + undefined, + 1, + ); + // Peak 0.5 -> 0.55 with headroom, nice-stepped to 0.6, still under the cap. + expect(bounds).toEqual({ + domain: [0, 0.6], + ticks: [0, 0.2, 0.4, 0.6], + tickFormatter: expect.any(Function), + }); + }); + + it('keeps the cap under a selection so selecting the sole series cannot collapse the axis', () => { + // Regression: on the selection path a flat 0% rate used to fall through + // to min/max fitting and collapse the capped axis to [0, 0]. + const bounds = computeYAxisBounds( + [{ a: 0 }], + [series('a')], + true, + false, + DisplayType.Line, + [], + undefined, + 1, + ); + expect(bounds.domain).toEqual([0, 1]); + expect((bounds.ticks ?? []).length).toBeGreaterThan(1); + }); + + it('wins over reference lines rather than deferring to Recharts', () => { + const bounds = computeYAxisBounds( + [{ a: 0.5 }], + [series('a')], + false, + false, + DisplayType.Line, + [0.8], + undefined, + 1, + ); + // Identical to the no-reference-line case: the cap is applied before the + // reference-line handling, so the line at 0.8 neither widens the domain + // nor defers to Recharts' auto domain. + expect(bounds).toEqual({ + domain: [0, 0.6], + ticks: [0, 0.2, 0.4, 0.6], + tickFormatter: expect.any(Function), + }); + }); + + it('does not apply to a stacked chart (left to Recharts)', () => { + const bounds = computeYAxisBounds( + [{ a: 0.5 }], + [series('a')], + false, + false, + DisplayType.StackedBar, + [], + undefined, + 1, + ); + expect(bounds).toEqual({ domain: [0, 'auto'], ticks: undefined }); + }); + }); + it('falls back to auto for flat negative data on a fitted axis', () => { // Regression: the same guard could also invert the domain (e.g. // [0, -5]) for all-negative data padded past zero. diff --git a/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts b/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts index d2e74f1558..65571624a4 100644 --- a/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts +++ b/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts @@ -15,6 +15,10 @@ import { } from '@/components/Search/traceRedMetrics'; import type { NumberFormat } from '@/types'; +// A sidebar filter the user has applied; every RED config must carry it so the +// tiles stay in sync with the results list. +const BASE_FILTER = { type: 'sql' as const, condition: "SpanKind = 'Server'" }; + const base: BuilderChartConfigWithDateRange = { connection: 'conn', from: { databaseName: 'default', tableName: 'otel_traces' }, @@ -22,7 +26,7 @@ const base: BuilderChartConfigWithDateRange = { select: [{ aggFn: 'count', aggCondition: '', valueExpression: '' }], where: "ServiceName = 'api'", whereLanguage: 'sql', - filters: [], + filters: [BASE_FILTER], dateRange: [new Date(0), new Date(60_000)], granularity: 'auto', groupBy: 'StatusCode', @@ -34,11 +38,13 @@ const DURATION_FORMAT: NumberFormat = { output: 'duration', factor: 1e-9 }; describe('traceRedMetrics', () => { describe('redBaseConfig', () => { - it('strips the status-code groupBy and preserves filter + time range', () => { + it('strips the status-code groupBy and preserves filters + where + time range', () => { const result = redBaseConfig(base); expect(result.groupBy).toBeUndefined(); expect(result.where).toBe(base.where); expect(result.dateRange).toBe(base.dateRange); + // sidebar filters must survive, or the tiles would disagree with results + expect(result.filters).toEqual([BASE_FILTER]); }); }); @@ -84,8 +90,8 @@ describe('traceRedMetrics', () => { }, { alias: 'Error rate', - // guards the empty-bucket 0/0; ratio can't exceed 1 so no clamp - valueExpression: 'if(total_spans > 0, error_spans / total_spans, 0)', + // divided directly: total_spans is always >= 1 in a returned bucket + valueExpression: 'error_spans / total_spans', }, ]); // the two aggregated counts are the ones hidden from the chart @@ -108,7 +114,12 @@ describe('traceRedMetrics', () => { valueExpression: '', }, ]); - expect(result?.filters).toEqual([{ type: 'sql', condition: ERROR_COND }]); + // the error condition is APPENDED to the user's active filters, not + // replacing them, so the tile stays scoped to the current search + expect(result?.filters).toEqual([ + BASE_FILTER, + { type: 'sql', condition: ERROR_COND }, + ]); }); it('returns undefined when the source has no usable status expression', () => { @@ -143,14 +154,14 @@ describe('traceRedMetrics', () => { valueExpression: DURATION_EXPR, }, { - alias: 'p95', + alias: 'P95', aggFn: 'quantile', level: 0.95, aggCondition: '', valueExpression: DURATION_EXPR, }, { - alias: 'p99', + alias: 'P99', aggFn: 'quantile', level: 0.99, aggCondition: '', diff --git a/packages/app/src/components/Search/traceRedMetrics.ts b/packages/app/src/components/Search/traceRedMetrics.ts index e8b10ec554..9ce3349bb3 100644 --- a/packages/app/src/components/Search/traceRedMetrics.ts +++ b/packages/app/src/components/Search/traceRedMetrics.ts @@ -112,11 +112,13 @@ export function errorsConfig( valueExpression: '', }, { - // Guard the empty-bucket 0/0 (which becomes NaN and breaks the - // auto-scaled y-axis). error_spans is a countIf over the same rows as - // total_spans = count(), so the ratio can't exceed 1; no clamp needed. + // A GROUP BY time bucket is only emitted when it has rows, so + // total_spans is always >= 1 in the result set (missing buckets are + // gap-filled to 0 client-side, not in SQL). error_spans is a countIf + // over those same rows, so the ratio stays in [0, 1] with no + // divide-by-zero to guard, matching how HttpTab divides its rate. alias: 'Error rate', - valueExpression: `if(${TOTAL_SPANS_ALIAS} > 0, ${ERROR_SPANS_ALIAS} / ${TOTAL_SPANS_ALIAS}, 0)`, + valueExpression: `${ERROR_SPANS_ALIAS} / ${TOTAL_SPANS_ALIAS}`, }, ], displayType: DisplayType.Line, @@ -150,8 +152,8 @@ export function errorsConfig( // positional palette slot (chart-red) the pins exist to avoid, with no test or // type failure to catch it. const DURATION_AVG_ALIAS = 'Avg'; -const DURATION_P95_ALIAS = 'p95'; -const DURATION_P99_ALIAS = 'p99'; +const DURATION_P95_ALIAS = 'P95'; +const DURATION_P99_ALIAS = 'P99'; /** * Pin the Duration percentiles to stable, cool palette hues so latency never From b8e1a555d79877f33cfbd1813fd7d39254459b48 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Sat, 14 Nov 2026 01:18:22 +0000 Subject: [PATCH 3/6] fix(app): share the throughput scan and color duration via select items - Throughput now renders the status-grouped count histogram this view replaces, under the results-count query-key prefix, so react-query runs a single shared scan instead of a second ungrouped count() on every search and live-tail tick. This also restores the per-status drill-down the histogram offered. - Duration series pin their colors through each select item's `color` rather than a name-keyed seriesColors prop, so there is one source of truth for per-series color. Drops the DBTimeChart seriesColors prop, the DURATION_SERIES_COLORS map, and the alias/color lockstep test; setLineColors reads the color off the select item by result-column name. --- packages/app/src/ChartUtils.tsx | 17 +++--- packages/app/src/DBSearchPage.tsx | 1 + packages/app/src/components/DBTimeChart.tsx | 24 +++++--- .../Search/TraceRedMetricsChart.tsx | 25 ++++++--- .../Search/__tests__/traceRedMetrics.test.ts | 56 +++++-------------- .../src/components/Search/traceRedMetrics.ts | 50 +++++------------ 6 files changed, 72 insertions(+), 101 deletions(-) diff --git a/packages/app/src/ChartUtils.tsx b/packages/app/src/ChartUtils.tsx index 7f76cca48f..310a009e15 100644 --- a/packages/app/src/ChartUtils.tsx +++ b/packages/app/src/ChartUtils.tsx @@ -654,11 +654,11 @@ function setLineColors( line.color = lineColorByCurrentPeriodKey.get(currentPeriodKey); } else if (!line.color) { const seriesName = line.displayName ?? line.dataKey; - // A caller-pinned color (keyed by series name) wins over the positional - // palette, so a series keeps the same hue no matter how the data sorts - // it. A pinned series does not consume a positional slot, so unpinned - // series keep their natural palette order. - const pinnedToken = seriesColors?.[seriesName]; + // A color set on the series' select item (keyed by its result-column name) + // wins over the positional palette, so a series keeps the same hue no + // matter how the data sorts it. A pinned series does not consume a + // positional slot, so unpinned series keep their natural palette order. + const pinnedToken = seriesColors?.[line.valueColumnName]; line.color = pinnedToken ? getColorFromCSSToken(pinnedToken) : getColorProps(colorIndex++, seriesName); @@ -830,9 +830,10 @@ export function formatResponseForTimeChart({ */ maxSeries?: number; /** - * Pin specific series (by display name) to a palette token instead of the - * positional palette, so their color is stable across sessions and can avoid - * hues reserved for other meanings (e.g. keeping latency off error red). + * Pin specific series (keyed by result-column name) to a palette token instead + * of the positional palette, so their color is stable across sessions and can + * avoid hues reserved for other meanings (e.g. keeping latency off error red). + * Callers derive this from each select item's `color`. */ seriesColors?: Record; }) { diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 606dfb0820..a389c5a414 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -2917,6 +2917,7 @@ function DBSearchPageContent() { source={traceRedMetricsSource} isReady={isReady} queryKeyPrefix={RED_QUERY_KEY_PREFIX} + throughputQueryKeyPrefix={QUERY_KEY_PREFIX} onTimeRangeSelect={handleTimeRangeSelect} onFocusSeries={handleFocusSeries} /> diff --git a/packages/app/src/components/DBTimeChart.tsx b/packages/app/src/components/DBTimeChart.tsx index 20406db9a4..c79043d8b9 100644 --- a/packages/app/src/components/DBTimeChart.tsx +++ b/packages/app/src/components/DBTimeChart.tsx @@ -375,12 +375,6 @@ type DBTimeChartComponentProps = { sourceId?: string; /** Names of series that should not be shown in the chart */ hiddenSeries?: string[]; - /** - * Pin specific series (by display name) to a palette token instead of the - * positional palette, so their color stays stable across sessions and can - * avoid hues reserved for other meanings (e.g. keeping latency off error red). - */ - seriesColors?: Record; title?: React.ReactNode; toolbarPrefix?: React.ReactNode[]; toolbarSuffix?: React.ReactNode[]; @@ -430,7 +424,6 @@ function DBTimeChartComponent({ showLegend = true, sourceId, hiddenSeries, - seriesColors, title, toolbarPrefix, toolbarSuffix, @@ -651,6 +644,23 @@ function DBTimeChartComponent({ const { formatByColumn, chartFormat: axisNumberFormat } = useChartNumberFormats(queriedConfig, data?.meta); + // Per-series colors come from each select item's `color` (keyed by its result + // column name, i.e. its alias), the single source of truth for pinning a + // series to a palette token. Derived here so setLineColors can override the + // positional palette for exactly those columns, without a separate color map. + const seriesColors = useMemo(() => { + if (!isBuilderChartConfig(config) || !Array.isArray(config.select)) { + return undefined; + } + const entries: [string, ChartPaletteToken][] = []; + for (const selectItem of config.select) { + if (selectItem.alias && selectItem.color) { + entries.push([selectItem.alias, selectItem.color]); + } + } + return entries.length > 0 ? Object.fromEntries(entries) : undefined; + }, [config]); + const { error: resultFormattingError, graphResults, diff --git a/packages/app/src/components/Search/TraceRedMetricsChart.tsx b/packages/app/src/components/Search/TraceRedMetricsChart.tsx index cea4fe8607..e00d3d4997 100644 --- a/packages/app/src/components/Search/TraceRedMetricsChart.tsx +++ b/packages/app/src/components/Search/TraceRedMetricsChart.tsx @@ -11,13 +11,11 @@ import { DBTimeChart, type SeriesGroupFilter } from '@/components/DBTimeChart'; import { getTraceDurationNumberFormat } from '@/source'; import { - DURATION_SERIES_COLORS, durationConfig, ERROR_RATE_HELPER_SERIES, errorsConfig, ErrorsMode, redBaseConfig, - throughputConfig, } from './traceRedMetrics'; // Each RED tile is a ChartCard flex child that fills its column; ChartCard @@ -46,6 +44,7 @@ export function TraceRedMetricsChart({ source, isReady, queryKeyPrefix, + throughputQueryKeyPrefix, onTimeRangeSelect, onFocusSeries, }: { @@ -53,11 +52,17 @@ export function TraceRedMetricsChart({ histogramTimeChartConfig: BuilderChartConfigWithDateRange; source: TTraceSource; isReady: boolean; + /** Query-key prefix for the auxiliary Errors/Duration tiles, kept separate so + * their slow quantile scans stay out of the reported search latency. */ queryKeyPrefix?: string; + /** Query-key prefix for the Throughput tile. It is the status-grouped count + * histogram this view replaces, so passing the results header's prefix here + * lets react-query dedupe the two into a single scan. */ + throughputQueryKeyPrefix?: string; onTimeRangeSelect?: (start: Date, end: Date) => void; /** Filter the results by a clicked status series, matching the histogram's - * per-status drill-down. Only the volume Errors chart is grouped by status, - * so only it surfaces this. */ + * per-status drill-down. Surfaced by the Throughput tile and the volume + * Errors chart, which are the status-grouped ones. */ onFocusSeries?: (filters: SeriesGroupFilter[]) => void; }) { const [errorsMode, setErrorsMode] = useState('rate'); @@ -81,7 +86,6 @@ export function TraceRedMetricsChart({ () => redBaseConfig(histogramTimeChartConfig), [histogramTimeChartConfig], ); - const throughput = useMemo(() => throughputConfig(base), [base]); const errors = useMemo( () => errorsConfig(base, source.statusCodeExpression, errorsMode), [base, source.statusCodeExpression, errorsMode], @@ -121,11 +125,17 @@ export function TraceRedMetricsChart({ + {/* Throughput is the status-grouped count histogram this view + replaces, rendered from the same config under the results header's + query-key prefix so react-query runs a single shared scan, and it + keeps the per-status drill-down via onFocusSeries. */} {errors != null && ( @@ -157,10 +167,11 @@ export function TraceRedMetricsChart({ )} + {/* Duration series pin their colors via each select item's `color` + (set in durationConfig), so no seriesColors prop is needed here. */} diff --git a/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts b/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts index 65571624a4..f431405415 100644 --- a/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts +++ b/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts @@ -5,13 +5,11 @@ import { import { INTEGER_NUMBER_FORMAT } from '@/ChartUtils'; import { - DURATION_SERIES_COLORS, durationConfig, ERROR_RATE_FORMAT, ERROR_RATE_HELPER_SERIES, errorsConfig, redBaseConfig, - throughputConfig, } from '@/components/Search/traceRedMetrics'; import type { NumberFormat } from '@/types'; @@ -48,24 +46,6 @@ describe('traceRedMetrics', () => { }); }); - 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: count + countIf aggregated separately, divided post-aggregation (MV-friendly)', () => { const result = errorsConfig(redBaseConfig(base), 'StatusCode', 'rate'); @@ -152,6 +132,7 @@ describe('traceRedMetrics', () => { aggFn: 'avg', aggCondition: '', valueExpression: DURATION_EXPR, + color: 'chart-blue', }, { alias: 'P95', @@ -159,6 +140,7 @@ describe('traceRedMetrics', () => { level: 0.95, aggCondition: '', valueExpression: DURATION_EXPR, + color: 'chart-cyan', }, { alias: 'P99', @@ -166,43 +148,33 @@ describe('traceRedMetrics', () => { level: 0.99, aggCondition: '', valueExpression: DURATION_EXPR, + color: 'chart-purple', }, ]); }); - // The color pins are resolved by result-column name, so a select alias - // that no longer has a matching key silently falls back to the positional - // palette. Assert the two stay in lockstep rather than trusting that both - // sides get updated together. - it('pins a color for exactly the series it selects', () => { + // Latency is not an error signal: red/error/warning/success are reserved for + // other meanings, and the positional palette would otherwise hand slot 2 to + // chart-red. Pinning each series via its select-item color keeps latency off + // those hues; this guards both the specific pins and a reserved hue creeping + // back in. The color lives on the select item, so it can't drift from the + // series the way a separate name-keyed map could. + it('pins each duration series to a stable, non-reserved hue', () => { const { select } = durationConfig( redBaseConfig(base), DURATION_EXPR, DURATION_FORMAT, ); - // durationConfig always builds a select list, never a raw SQL string - const aliases = Array.isArray(select) - ? select.map(s => ('alias' in s ? s.alias : undefined)) - : []; - expect(aliases).toEqual(Object.keys(DURATION_SERIES_COLORS)); - }); - - // Latency is not an error signal: red is reserved for the Errors tile, and - // the rest of the semantic palette carries its own meaning. The positional - // palette would hand slot 2 to chart-red, so this is the regression guard - // for that specific hue coming back. - it('never colors a latency series with a reserved semantic hue', () => { const reserved = [ 'chart-red', 'chart-error', 'chart-warning', 'chart-success', ]; - expect( - Object.values(DURATION_SERIES_COLORS).filter(token => - reserved.includes(token), - ), - ).toEqual([]); + // durationConfig always builds a select list, never a raw SQL string + const colors = Array.isArray(select) ? select.map(s => s.color) : []; + expect(colors).toEqual(['chart-blue', 'chart-cyan', 'chart-purple']); + expect(colors.filter(c => c != null && reserved.includes(c))).toEqual([]); }); }); }); diff --git a/packages/app/src/components/Search/traceRedMetrics.ts b/packages/app/src/components/Search/traceRedMetrics.ts index 9ce3349bb3..d40354fa15 100644 --- a/packages/app/src/components/Search/traceRedMetrics.ts +++ b/packages/app/src/components/Search/traceRedMetrics.ts @@ -1,7 +1,6 @@ import { errorStatusPredicateSql } from '@hyperdx/common-utils/dist/core/spanPredicates'; import { BuilderChartConfigWithDateRange, - ChartPaletteToken, DisplayType, } from '@hyperdx/common-utils/dist/types'; @@ -54,21 +53,6 @@ export function redBaseConfig( 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 or a volume, derived from the source's status-code * expression. Returns undefined when the source has no usable status @@ -145,32 +129,21 @@ export function errorsConfig( }; } -// Duration series aliases, declared once so durationConfig's select and the -// DURATION_SERIES_COLORS pins below key off the same strings. setLineColors -// resolves a pin by result-column name, so a rename that touched only one side -// would silently drop the pin and let that percentile fall back to the -// positional palette slot (chart-red) the pins exist to avoid, with no test or -// type failure to catch it. +// Duration series aliases. The palette hue lives on each select item's `color` +// below, so the alias and its color are declared together and can't drift apart. const DURATION_AVG_ALIAS = 'Avg'; const DURATION_P95_ALIAS = 'P95'; const DURATION_P99_ALIAS = 'P99'; /** - * Pin the Duration percentiles to stable, cool palette hues so latency never - * borrows error red (or warning orange / success green), and so each percentile - * keeps its color across sessions regardless of how the data sorts the lines. - */ -export const DURATION_SERIES_COLORS = { - [DURATION_AVG_ALIAS]: 'chart-blue', - [DURATION_P95_ALIAS]: 'chart-cyan', - [DURATION_P99_ALIAS]: 'chart-purple', -} satisfies Record; - -/** - * 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. + * Duration: Avg, P95, P99 over the source's raw duration expression. Each series + * pins a stable, cool palette hue via its select item's `color`, so latency never + * borrows error red (or warning orange / success green) and each percentile keeps + * its color across sessions regardless of how the data sorts the lines. + * 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, @@ -185,6 +158,7 @@ export function durationConfig( aggFn: 'avg', aggCondition: '', valueExpression: durationExpression, + color: 'chart-blue', }, { alias: DURATION_P95_ALIAS, @@ -192,6 +166,7 @@ export function durationConfig( level: 0.95, aggCondition: '', valueExpression: durationExpression, + color: 'chart-cyan', }, { alias: DURATION_P99_ALIAS, @@ -199,6 +174,7 @@ export function durationConfig( level: 0.99, aggCondition: '', valueExpression: durationExpression, + color: 'chart-purple', }, ], displayType: DisplayType.Line, From 9887f8e9b7963e68379a87b8999061738e6ec2d9 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Sat, 14 Nov 2026 01:32:44 +0000 Subject: [PATCH 4/6] fix(app): fall back to the histogram for custom group-by; drop redundant duration format - When a trace source has a custom group-by selected, fall back to the single histogram (which honors arbitrary group-by) instead of the RED tiles, whose grouping is fixed (Throughput and Errors by status, Duration ungrouped). The group-by selector now has a coherent effect rather than silently regrouping only part of the view. - Drop the hand-computed duration numberFormat: DBTimeChart already infers it via useChartNumberFormats -> getTraceDurationNumberFormat, because each duration series aggregates the source's duration expression. Removes the getTraceDurationNumberFormat memo and durationConfig's numberFormat parameter. --- packages/app/src/DBSearchPage.tsx | 13 +++++++---- .../Search/TraceRedMetricsChart.tsx | 22 +++++-------------- .../Search/__tests__/traceRedMetrics.test.ts | 21 +++++------------- .../src/components/Search/traceRedMetrics.ts | 9 ++++---- 4 files changed, 25 insertions(+), 40 deletions(-) diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index a389c5a414..9246506a14 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -2901,10 +2901,15 @@ function DBSearchPageContent() { {!hasQueryError && - // The three side-by-side RED tiles need room; on a - // compact (narrow/mobile) viewport they would be - // unreadable, so fall back to the single histogram. - (traceRedMetricsSource != null && !isCompact ? ( + // The three side-by-side RED tiles need room, so on a + // compact (narrow/mobile) viewport fall back to the + // single histogram. The RED tiles also have fixed + // grouping (Throughput by status, Errors by status, + // Duration ungrouped), so when the user picks a custom + // group-by fall back to the histogram, which honors it. + (traceRedMetricsSource != null && + !isCompact && + !isCustomGroupBy ? ( ('rate'); - // Aggregate the raw Duration column (MV-friendly) and let the display format, - // derived from the source's durationPrecision, convert the unit. + // Aggregate the raw Duration column (MV-friendly). DBTimeChart's own + // number-format inference applies the duration unit at display, since every + // duration series aggregates the source's duration expression (see + // durationConfig), so no explicit format is computed here. const durationExpression = source.durationExpression ?? ''; - // Memoized: getTraceDurationNumberFormat returns a fresh object, so an - // unmemoized value would change identity every render and defeat the - // `duration` memo below (re-rendering DBTimeChart on every keystroke/tick). - const durationFormat = useMemo( - () => - getTraceDurationNumberFormat(source, { - valueExpression: durationExpression, - aggFn: 'avg', - }), - [source, durationExpression], - ); const base = useMemo( () => redBaseConfig(histogramTimeChartConfig), @@ -91,8 +81,8 @@ export function TraceRedMetricsChart({ [base, source.statusCodeExpression, errorsMode], ); const duration = useMemo( - () => durationConfig(base, durationExpression, durationFormat), - [base, durationExpression, durationFormat], + () => durationConfig(base, durationExpression), + [base, durationExpression], ); const errorsModeControl = ( diff --git a/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts b/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts index f431405415..96ca3aae6f 100644 --- a/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts +++ b/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts @@ -11,7 +11,6 @@ import { errorsConfig, redBaseConfig, } from '@/components/Search/traceRedMetrics'; -import type { NumberFormat } from '@/types'; // A sidebar filter the user has applied; every RED config must carry it so the // tiles stay in sync with the results list. @@ -32,7 +31,6 @@ const base: BuilderChartConfigWithDateRange = { const ERROR_COND = "lower(StatusCode) IN ('error', 'status_code_error')"; const DURATION_EXPR = 'Duration'; -const DURATION_FORMAT: NumberFormat = { output: 'duration', factor: 1e-9 }; describe('traceRedMetrics', () => { describe('redBaseConfig', () => { @@ -117,15 +115,12 @@ describe('traceRedMetrics', () => { }); describe('durationConfig', () => { - it('aggregates the raw duration column and passes the display format through', () => { - const result = durationConfig( - redBaseConfig(base), - DURATION_EXPR, - DURATION_FORMAT, - ); + it('aggregates the raw duration column without a hand-set number format', () => { + const result = durationConfig(redBaseConfig(base), DURATION_EXPR); expect(result.displayType).toBe(DisplayType.Line); - // no SQL-side unit conversion: the format handles the unit at display - expect(result.numberFormat).toBe(DURATION_FORMAT); + // No explicit numberFormat: DBTimeChart infers the duration unit from the + // select items (each aggregates the duration expression) at display time. + expect(result.numberFormat).toBeUndefined(); expect(result.select).toEqual([ { alias: 'Avg', @@ -160,11 +155,7 @@ describe('traceRedMetrics', () => { // back in. The color lives on the select item, so it can't drift from the // series the way a separate name-keyed map could. it('pins each duration series to a stable, non-reserved hue', () => { - const { select } = durationConfig( - redBaseConfig(base), - DURATION_EXPR, - DURATION_FORMAT, - ); + const { select } = durationConfig(redBaseConfig(base), DURATION_EXPR); const reserved = [ 'chart-red', 'chart-error', diff --git a/packages/app/src/components/Search/traceRedMetrics.ts b/packages/app/src/components/Search/traceRedMetrics.ts index d40354fa15..98aa4a454b 100644 --- a/packages/app/src/components/Search/traceRedMetrics.ts +++ b/packages/app/src/components/Search/traceRedMetrics.ts @@ -141,14 +141,14 @@ const DURATION_P99_ALIAS = 'P99'; * borrows error red (or warning orange / success green) and each percentile keeps * its color across sessions regardless of how the data sorts the lines. * 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. + * MV-friendly; because every series aggregates the source's duration expression + * with a unit-preserving aggFn, DBTimeChart's own number-format inference + * (useChartNumberFormats -> getTraceDurationNumberFormat) applies the duration + * unit at display, so no explicit numberFormat is needed here. */ export function durationConfig( base: BuilderChartConfigWithDateRange, durationExpression: string, - numberFormat: NumberFormat | undefined, ): BuilderChartConfigWithDateRange { return { ...base, @@ -178,6 +178,5 @@ export function durationConfig( }, ], displayType: DisplayType.Line, - numberFormat, }; } From f44f15651875b9123d37fa901d058a76a4810317 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Sat, 14 Nov 2026 01:40:21 +0000 Subject: [PATCH 5/6] fix(app): responsive compact tick font and accurate yAxisMaxDomain doc - Compact x-axis ticks use the responsive axis tick font size instead of a hardcoded 11px, matching the non-compact ticks and honoring the user's content font scale. - Correct the yAxisMaxDomain prop doc: the cap applies on every path (selection and fit included), not only the default path. --- packages/app/src/HDXMultiSeriesTimeChart.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index 64aabc7707..b4f3b98a39 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -1453,7 +1453,8 @@ export const MemoChart = memo(function MemoChart({ * 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. + * auto-domain. Applied on every path (selection and fit included) so the + * capped axis cannot collapse; only stacked charts ignore it. */ yAxisMaxDomain?: number; }) { @@ -1738,7 +1739,7 @@ export const MemoChart = memo(function MemoChart({ dy={8} textAnchor={textAnchor} verticalAnchor="start" - fontSize={11} + fontSize={axisTickFontSize} fontFamily="IBM Plex Mono, monospace" fill="var(--color-text-muted)" > @@ -1746,7 +1747,7 @@ export const MemoChart = memo(function MemoChart({ ); }, - [xTickFormatter], + [xTickFormatter, axisTickFontSize], ); const tickFormatter = useCallback( From ec9cb4990ebe4662dcb7e3b539f1666c2d648a65 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Sat, 14 Nov 2026 01:59:06 +0000 Subject: [PATCH 6/6] fix(app): hide the Throughput legend when it is a single ungrouped series Group-by none resolves to no grouping, which collapses Throughput to a single count() series; with the legend on, that surfaced the raw aggregate expression ("count()") as the series label. Show the Throughput legend only when the view is grouped (the status-code default), where it names each status, matching how the histogram gated its own legend. The ungrouped case keeps just the "Throughput" title. --- .../src/components/Search/TraceRedMetricsChart.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/Search/TraceRedMetricsChart.tsx b/packages/app/src/components/Search/TraceRedMetricsChart.tsx index 2674e59054..3fa5cb92d3 100644 --- a/packages/app/src/components/Search/TraceRedMetricsChart.tsx +++ b/packages/app/src/components/Search/TraceRedMetricsChart.tsx @@ -111,6 +111,17 @@ export function TraceRedMetricsChart({ compactXAxisLabels: true, } as const; + // Throughput stacks per status when the view is grouped (the source default), + // where the legend names each status and is worth showing. With "group by + // none" it collapses to a single count() series, so hide the legend instead + // of surfacing the raw aggregate expression as a label. + const throughputGrouped = + typeof histogramTimeChartConfig.groupBy === 'string' + ? histogramTimeChartConfig.groupBy.trim().length > 0 + : Array.isArray(histogramTimeChartConfig.groupBy) + ? histogramTimeChartConfig.groupBy.length > 0 + : false; + return ( @@ -122,7 +133,7 @@ export function TraceRedMetricsChart({