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..310a009e15 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 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); 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,13 @@ export function formatResponseForTimeChart({ * every series (the "load all" escape hatch behind the hidden-series notice). */ maxSeries?: number; + /** + * 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; }) { const meta = currentPeriodResponse.meta; @@ -1050,7 +1070,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..9246506a14 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,57 @@ function DBSearchPageContent() { - {!hasQueryError && ( - - - - )} + {!hasQueryError && + // 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 ? ( + + + + ) : ( + + + + ))} )} {hasQueryError && queryError ? ( diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index 3de997cf70..b4f3b98a39 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 || @@ -1187,6 +1189,38 @@ export function computeYAxisBounds( } const shouldFitYAxis = fitYAxisToData; + if (yAxisMaxDomain != null) { + // A capped axis (e.g. a 0-100% rate tile): pin the lower bound at 0 and + // scale the upper bound to the data peak with headroom but never above the + // cap, so a flat or near-zero series can't auto-scale to a nonsense range. + // Applied on every path (selection and fit included) so selecting the sole + // series can't collapse the axis to [0, 0]; a numeric bound (not a function + // domain) lets the nice-tick logic dedupe labels instead of Recharts + // repeating "0%" at very small rates. + const { max } = scanYAxisValueRange(graphResults, visibleLineData); + const upperBound = cappedYAxisUpperBound(max, yAxisMaxDomain); + const expanded = getExpandableYAxisTicks( + 0, + upperBound, + 5, + axisNumberFormat, + ); + if (expanded.ticks.length === 0) { + const baseFormat = (value: number) => + formatAxisTick(value, axisNumberFormat); + return { + 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. @@ -1303,6 +1337,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 +1395,8 @@ export const MemoChart = memo(function MemoChart({ dateRangeEndInclusive = true, fitYAxisToData = false, lineInterpolation, + compactXAxisLabels = false, + yAxisMaxDomain, }: { graphResults: any[]; setIsClickActive: (v: ActiveClickPayload | undefined) => void; @@ -1376,6 +1443,20 @@ 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. Applied on every path (selection and fit included) so the + * capped axis cannot collapse; only stacked charts ignore it. + */ + yAxisMaxDomain?: number; }) { const _id = useId(); const id = _id.replace(/:/g, ''); @@ -1519,6 +1600,7 @@ export const MemoChart = memo(function MemoChart({ displayType, referenceLineValues, axisNumberFormat, + yAxisMaxDomain, ), [ graphResults, @@ -1528,6 +1610,7 @@ export const MemoChart = memo(function MemoChart({ displayType, referenceLineValues, axisNumberFormat, + yAxisMaxDomain, ], ); const yAxisDomain = yAxisBounds.domain; @@ -1643,6 +1726,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, axisTickFontSize], + ); + const tickFormatter = useCallback( (value: number) => formatAxisTick(value, axisNumberFormat), [axisNumberFormat], @@ -2102,10 +2209,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..8837f81813 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, @@ -597,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. @@ -1142,3 +1238,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..c79043d8b9 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'; @@ -394,6 +395,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({ @@ -421,6 +432,8 @@ function DBTimeChartComponent({ errorVariant, onFocusSeries, onFilter, + compactXAxisLabels, + yAxisMaxDomain, }: DBTimeChartComponentProps) { const [selectedSeriesSet, setSelectedSeriesSet] = useState>( new Set(), @@ -631,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, @@ -669,6 +699,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 +741,7 @@ function DBTimeChartComponent({ config.seriesLimit, previousPeriodData, hiddenSeries, + seriesColors, previousPeriodOffsetSeconds, showAllSeries, ]); @@ -1168,6 +1200,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..3fa5cb92d3 --- /dev/null +++ b/packages/app/src/components/Search/TraceRedMetricsChart.tsx @@ -0,0 +1,183 @@ +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 { + durationConfig, + ERROR_RATE_HELPER_SERIES, + errorsConfig, + ErrorsMode, + redBaseConfig, +} 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, + throughputQueryKeyPrefix, + onTimeRangeSelect, + onFocusSeries, +}: { + /** The count-histogram config; RED charts spread this and swap only select. */ + 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. 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'); + + // 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 ?? ''; + + const base = useMemo( + () => redBaseConfig(histogramTimeChartConfig), + [histogramTimeChartConfig], + ); + const errors = useMemo( + () => errorsConfig(base, source.statusCodeExpression, errorsMode), + [base, source.statusCodeExpression, errorsMode], + ); + const duration = useMemo( + () => durationConfig(base, durationExpression), + [base, durationExpression], + ); + + 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; + + // 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 ( + + + + {/* 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 && ( + + {/* 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). */} + + + )} + + {/* 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 new file mode 100644 index 0000000000..96ca3aae6f --- /dev/null +++ b/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts @@ -0,0 +1,171 @@ +import { + BuilderChartConfigWithDateRange, + DisplayType, +} from '@hyperdx/common-utils/dist/types'; + +import { INTEGER_NUMBER_FORMAT } from '@/ChartUtils'; +import { + durationConfig, + ERROR_RATE_FORMAT, + ERROR_RATE_HELPER_SERIES, + errorsConfig, + redBaseConfig, +} from '@/components/Search/traceRedMetrics'; + +// 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' }, + timestampValueExpression: 'Timestamp', + select: [{ aggFn: 'count', aggCondition: '', valueExpression: '' }], + where: "ServiceName = 'api'", + whereLanguage: 'sql', + filters: [BASE_FILTER], + 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'; + +describe('traceRedMetrics', () => { + describe('redBaseConfig', () => { + 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]); + }); + }); + + 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', + // 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 + 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: '', + }, + ]); + // 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', () => { + 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 without a hand-set number format', () => { + const result = durationConfig(redBaseConfig(base), DURATION_EXPR); + expect(result.displayType).toBe(DisplayType.Line); + // 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', + aggFn: 'avg', + aggCondition: '', + valueExpression: DURATION_EXPR, + color: 'chart-blue', + }, + { + alias: 'P95', + aggFn: 'quantile', + level: 0.95, + aggCondition: '', + valueExpression: DURATION_EXPR, + color: 'chart-cyan', + }, + { + alias: 'P99', + aggFn: 'quantile', + level: 0.99, + aggCondition: '', + valueExpression: DURATION_EXPR, + color: 'chart-purple', + }, + ]); + }); + + // 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); + const reserved = [ + 'chart-red', + 'chart-error', + 'chart-warning', + 'chart-success', + ]; + // 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 new file mode 100644 index 0000000000..98aa4a454b --- /dev/null +++ b/packages/app/src/components/Search/traceRedMetrics.ts @@ -0,0 +1,182 @@ +import { errorStatusPredicateSql } from '@hyperdx/common-utils/dist/core/spanPredicates'; +import { + BuilderChartConfigWithDateRange, + 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 }; +} + +/** + * 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: '', + }, + { + // 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: `${ERROR_SPANS_ALIAS} / ${TOTAL_SPANS_ALIAS}`, + }, + ], + 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. 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'; + +/** + * 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; 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, +): BuilderChartConfigWithDateRange { + return { + ...base, + select: [ + { + alias: DURATION_AVG_ALIAS, + aggFn: 'avg', + aggCondition: '', + valueExpression: durationExpression, + color: 'chart-blue', + }, + { + alias: DURATION_P95_ALIAS, + aggFn: 'quantile', + level: 0.95, + aggCondition: '', + valueExpression: durationExpression, + color: 'chart-cyan', + }, + { + alias: DURATION_P99_ALIAS, + aggFn: 'quantile', + level: 0.99, + aggCondition: '', + valueExpression: durationExpression, + color: 'chart-purple', + }, + ], + displayType: DisplayType.Line, + }; +}