diff --git a/.changeset/red-metrics-trace-search.md b/.changeset/red-metrics-trace-search.md new file mode 100644 index 0000000000..e064b6ef87 --- /dev/null +++ b/.changeset/red-metrics-trace-search.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': minor +--- + +Show RED metrics (Throughput, Errors, Duration) above the trace search results instead of the single count histogram. The three charts share a synced hover cursor, Errors toggles between rate and volume, and a RED/Heatmap switch flips the area to the duration heatmap. Logs and other sources keep the existing histogram. diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index cdae127d1e..4de2dbdf38 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -59,6 +59,7 @@ import { Group, Modal, Paper, + SegmentedControl, Select, Stack, Text, @@ -137,6 +138,7 @@ import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar'; import PatternTable from './components/PatternTable'; import { DBSearchHeatmapChart } from './components/Search/DBSearchHeatmapChart'; import DirectTraceSidePanel from './components/Search/DirectTraceSidePanel'; +import { TraceRedMetricsChart } from './components/Search/TraceRedMetricsChart'; import SourceSchemaPreview, { isSourceSchemaPreviewEnabled, } from './components/SourceSchemaPreview'; @@ -1059,6 +1061,12 @@ export function DBSearchPage() { ]).withDefault('results'), ); + // RED metrics vs heatmap for the trace results chart area. Owned here so the + // switch can live inline in the search stats row instead of a dedicated row. + const [traceChartMode, setTraceChartMode] = useState<'red' | 'heatmap'>( + 'red', + ); + const [patternColumn, setPatternColumn] = useQueryState( 'patternColumn', parseAsString, @@ -2556,6 +2564,23 @@ export function DBSearchPage() { enableParallelQueries /> + {searchedSource != null && + isTraceSource(searchedSource) && + searchedSource.durationExpression && ( + + setTraceChartMode( + v === 'heatmap' ? 'heatmap' : 'red', + ) + } + data={[ + { label: 'RED', value: 'red' }, + { label: 'Heatmap', value: 'heatmap' }, + ]} + /> + )} {shouldShowLiveModeHint && denoiseResults != true && ( - {!hasQueryError && ( - - - - )} + {!hasQueryError && + (searchedSource != null && + isTraceSource(searchedSource) && + searchedSource.durationExpression ? ( + + + + ) : ( + + + + ))} )} {hasQueryError && queryError ? ( diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index b334956ecb..d15cb8df5c 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -21,11 +21,12 @@ import { ReferenceArea, ReferenceLine, ResponsiveContainer, + Text, Tooltip, XAxis, YAxis, } from 'recharts'; -import { AxisDomain } from 'recharts/types/util/types'; +import { AxisDomain, XAxisTickContentProps } from 'recharts/types/util/types'; import { convertGranularityToSeconds } from '@hyperdx/common-utils/dist/core/utils'; import { DisplayType } from '@hyperdx/common-utils/dist/types'; import { Button, Popover, Tooltip as MantineTooltip } from '@mantine/core'; @@ -650,6 +651,8 @@ export const MemoChart = memo(function MemoChart({ granularity, dateRangeEndInclusive = true, fitYAxisToData = false, + compactXAxisLabels = false, + yAxisMaxDomain, }: { graphResults: any[]; setIsClickActive: (v: ActiveClickPayload | undefined) => void; @@ -683,6 +686,19 @@ export const MemoChart = memo(function MemoChart({ * (with padding) instead of zero. **/ fitYAxisToData?: boolean; + /** + * When true, anchor the first x-axis label to the start and the last to the + * end (instead of centering every label) so edge labels are not clipped on + * narrow charts, e.g. the side-by-side RED metrics tiles. + */ + compactXAxisLabels?: boolean; + /** + * 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, ''); @@ -799,6 +815,18 @@ export const MemoChart = memo(function MemoChart({ // fit the lower bound to the data. When neither applies, let Recharts // auto-calculate the upper bound while pinning the lower bound to zero. if (!hasSelection && !shouldFitYAxis) { + if (yAxisMaxDomain != null) { + // Auto-scale up to the data max (with headroom) but never above the + // cap; a flat or zero series uses the cap instead of a degenerate + // auto-domain (which recharts renders as e.g. 0-400% for a 0% rate). + return [ + 0, + (dataMax: number) => + Number.isFinite(dataMax) && dataMax > 0 + ? Math.min(dataMax * 1.1, yAxisMaxDomain) + : yAxisMaxDomain, + ]; + } return [0, 'auto']; } @@ -843,6 +871,7 @@ export const MemoChart = memo(function MemoChart({ selectedSeriesNames, fitYAxisToData, displayType, + yAxisMaxDomain, ]); const [containerWidth, setContainerWidth] = useState(0); @@ -919,6 +948,35 @@ export const MemoChart = memo(function MemoChart({ [formatTime], ); + // Compact mode: anchor the first label to the start and the last to the end + // so neither is clipped on a narrow chart. Renders every tick through one + // path (token color, mono) so the axis stays visually consistent. + const renderCompactXTick = useCallback( + ({ x, y, payload, index, visibleTicksCount }: XAxisTickContentProps) => { + const textAnchor = + index <= 0 + ? 'start' + : index >= visibleTicksCount - 1 + ? 'end' + : 'middle'; + return ( + + {xTickFormatter(Number(payload.value), index)} + + ); + }, + [xTickFormatter], + ); + const tickFormatter = useCallback( (value: number) => { return axisNumberFormat @@ -1265,7 +1323,11 @@ export const MemoChart = memo(function MemoChart({ type="number" tickFormatter={xTickFormatter} minTickGap={100} - tick={{ fontSize: 11, fontFamily: 'IBM Plex Mono, monospace' }} + tick={ + compactXAxisLabels + ? renderCompactXTick + : { fontSize: 11, fontFamily: 'IBM Plex Mono, monospace' } + } /> void; + /** + * Anchor the first/last x-axis labels inward so they are not clipped on + * narrow charts (e.g. side-by-side RED metric tiles). Forwarded to the chart. + */ + compactXAxisLabels?: boolean; + /** + * 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({ @@ -327,6 +337,8 @@ function DBTimeChartComponent({ showDateRangeIndicator = true, errorVariant, onFocusSeries, + compactXAxisLabels, + yAxisMaxDomain, }: DBTimeChartComponentProps) { const [selectedSeriesSet, setSelectedSeriesSet] = useState>( new Set(), @@ -848,6 +860,8 @@ function DBTimeChartComponent({ granularity={granularity} dateRangeEndInclusive={queriedConfig.dateRangeEndInclusive} fitYAxisToData={queriedConfig.fitYAxisToData} + compactXAxisLabels={compactXAxisLabels} + yAxisMaxDomain={yAxisMaxDomain} /> )} diff --git a/packages/app/src/components/Search/TraceRedMetricsChart.tsx b/packages/app/src/components/Search/TraceRedMetricsChart.tsx new file mode 100644 index 0000000000..8a572c3010 --- /dev/null +++ b/packages/app/src/components/Search/TraceRedMetricsChart.tsx @@ -0,0 +1,222 @@ +import { useMemo, useState } from 'react'; +import { + BuilderChartConfigWithDateRange, + TTraceSource, +} from '@hyperdx/common-utils/dist/types'; +import { Box, Flex, SegmentedControl } from '@mantine/core'; + +import { IsolatedChartSyncProvider } from '@/chartSync'; +import { ChartContainerCardHeaderProvider } from '@/components/charts/ChartContainer'; +import DBHeatmapChart, { + toHeatmapChartConfig, +} from '@/components/DBHeatmapChart'; +import { DBTimeChart } from '@/components/DBTimeChart'; +import { + getDurationMsExpression, + getTraceDurationNumberFormat, +} from '@/source'; +import type { NumberFormat } from '@/types'; + +import { + durationConfig, + ERROR_RATE_HELPER_SERIES, + errorConditionSql, + errorsConfig, + ErrorsMode, + redBaseConfig, + throughputConfig, +} from './traceRedMetrics'; + +export type TraceChartMode = 'red' | 'heatmap'; + +// Fixed card-header row height. Pinning both the titles and the Errors +// rate/volume control to the same height keeps all three headers identical, so +// the plots align top and bottom regardless of which header carries a control. +const HEADER_ROW_HEIGHT = 22; + +const titleNode = (label: string) => ( + + {label} + +); + +/** + * RED metrics (Throughput, Errors, Duration) for the trace search results view, + * replacing the single count histogram. The three charts render side by side as + * sibling DBTimeCharts under a shared sync scope, so hovering one shows a + * cross-chart cursor on all three at the same timestamp. The RED/Heatmap switch + * lives in the search stats row (passed in as `mode`); the Heatmap view is the + * same bare heatmap tile the dashboard renders. + * + * The per-chart aggregations are built by ./traceRedMetrics from the same base + * config the histogram uses, so all three honor the active WHERE filter and + * selected time range. + */ +export function TraceRedMetricsChart({ + mode, + histogramTimeChartConfig, + heatmapChartConfig, + source, + isReady, + queryKeyPrefix, + onTimeRangeSelect, +}: { + /** RED vs heatmap; owned by the search stats row so the switch sits inline. */ + mode: TraceChartMode; + /** The count-histogram config; RED charts spread this and swap only select. */ + histogramTimeChartConfig: BuilderChartConfigWithDateRange; + /** Base config for the heatmap tile, mirroring the delta-mode callsite. */ + heatmapChartConfig: BuilderChartConfigWithDateRange; + source: TTraceSource; + isReady: boolean; + queryKeyPrefix?: string; + onTimeRangeSelect?: (start: Date, end: Date) => void; +}) { + const [errorsMode, setErrorsMode] = useState('rate'); + + const errorCondition = errorConditionSql(source.statusCodeExpression); + // Aggregate the raw Duration column (MV-friendly) and let the display format, + // derived from the source's durationPrecision, convert the unit. Falls back + // to getDurationMsExpression only for the heatmap tile below. + const durationExpression = source.durationExpression ?? ''; + const durationFormat = getTraceDurationNumberFormat(source, { + valueExpression: durationExpression, + aggFn: 'avg', + }); + const durationMsExpression = getDurationMsExpression(source); + + const base = useMemo( + () => redBaseConfig(histogramTimeChartConfig), + [histogramTimeChartConfig], + ); + const throughput = useMemo(() => throughputConfig(base), [base]); + const errors = useMemo( + () => errorsConfig(base, errorCondition, errorsMode), + [base, errorCondition, errorsMode], + ); + const duration = useMemo( + () => durationConfig(base, durationExpression, durationFormat), + [base, durationExpression, durationFormat], + ); + // Heatmap tile config (duration distribution over time), matching the + // dashboard heatmap tile: DBHeatmapChart + toHeatmapChartConfig, no + // significant-fields comparison panel. + const { heatmapConfig, scaleType } = useMemo( + () => + toHeatmapChartConfig({ + ...heatmapChartConfig, + select: [ + { + valueExpression: durationMsExpression, + countExpression: 'count()', + heatmapScaleType: 'log', + }, + ], + numberFormat: { + output: 'duration', + factor: 0.001, + } satisfies NumberFormat, + }), + [heatmapChartConfig, durationMsExpression], + ); + + const errorsModeControl = ( + setErrorsMode(v === 'volume' ? 'volume' : 'rate')} + data={[ + { label: 'Rate', value: 'rate' }, + { label: 'Vol', value: 'volume' }, + ]} + // Pin the control to the title/actions-icon height so the Errors card + // header is exactly as tall as Throughput/Duration and the three plots + // share a top and bottom to the pixel. + styles={{ + root: { height: HEADER_ROW_HEIGHT, minHeight: 0, padding: 2 }, + label: { + paddingTop: 0, + paddingBottom: 0, + paddingInline: 8, + minHeight: 0, + fontSize: 11, + lineHeight: '18px', + }, + indicator: { minHeight: 0 }, + }} + /> + ); + + const commonProps = { + sourceId: source.id, + enabled: isReady, + showDisplaySwitcher: false, + showMVOptimizationIndicator: false, + showDateRangeIndicator: false, + queryKeyPrefix, + onTimeRangeSelect, + enableParallelQueries: true, + // narrow tiles: keep the edge time labels from clipping + compactXAxisLabels: true, + } as const; + + return ( + + {mode === 'red' ? ( + + + + + + {errors != null && ( + + {/* Remount on mode change: DBTimeChart seeds its display type + from the initial config when uncontrolled, so a key swap + re-seeds bars (volume) vs line (rate). */} + + + )} + + + + + + ) : ( + + + + )} + + ); +} diff --git a/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts b/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts new file mode 100644 index 0000000000..94129d653a --- /dev/null +++ b/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts @@ -0,0 +1,164 @@ +import { + BuilderChartConfigWithDateRange, + DisplayType, +} from '@hyperdx/common-utils/dist/types'; + +import { INTEGER_NUMBER_FORMAT } from '@/ChartUtils'; +import { + durationConfig, + ERROR_RATE_FORMAT, + ERROR_RATE_HELPER_SERIES, + errorConditionSql, + 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) = 'error'"; +const DURATION_EXPR = 'Duration'; +const DURATION_FORMAT: NumberFormat = { output: 'duration', factor: 1e-9 }; + +describe('traceRedMetrics', () => { + describe('errorConditionSql', () => { + it('builds a lowercased error condition from the status expression', () => { + expect(errorConditionSql('StatusCode')).toBe(ERROR_COND); + }); + it('returns undefined without a usable status expression', () => { + expect(errorConditionSql(undefined)).toBeUndefined(); + expect(errorConditionSql('')).toBeUndefined(); + }); + }); + + describe('redBaseConfig', () => { + it('strips the status-code groupBy and preserves filter + time range', () => { + const result = redBaseConfig(base); + expect(result.groupBy).toBeUndefined(); + expect(result.where).toBe(base.where); + expect(result.dateRange).toBe(base.dateRange); + }); + }); + + describe('throughputConfig', () => { + it('counts spans, rendered as bars', () => { + const result = throughputConfig(redBaseConfig(base)); + expect(result.displayType).toBe(DisplayType.StackedBar); + expect(result.numberFormat).toBe(INTEGER_NUMBER_FORMAT); + expect(result.select).toEqual([ + { + alias: 'Spans', + aggFn: 'count', + aggCondition: '', + valueExpression: '', + }, + ]); + // honors the active WHERE filter carried from the base config + expect(result.where).toBe(base.where); + }); + }); + + describe('errorsConfig', () => { + it('rate: count + countIf aggregated separately, divided post-aggregation (MV-friendly)', () => { + const result = errorsConfig(redBaseConfig(base), ERROR_COND, 'rate'); + expect(result).toBeDefined(); + expect(result?.displayType).toBe(DisplayType.Line); + expect(result?.numberFormat).toBe(ERROR_RATE_FORMAT); + 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 and caps at 100% + valueExpression: + 'least(if(total_spans > 0, error_spans / total_spans, 0), 1)', + }, + ]); + // the two aggregated counts are the ones hidden from the chart + expect(ERROR_RATE_HELPER_SERIES).toEqual(['total_spans', 'error_spans']); + }); + + it('volume: countIf error, rendered as bars', () => { + const result = errorsConfig(redBaseConfig(base), ERROR_COND, 'volume'); + expect(result).toBeDefined(); + expect(result?.displayType).toBe(DisplayType.StackedBar); + expect(result?.numberFormat).toBe(INTEGER_NUMBER_FORMAT); + expect(result?.select).toEqual([ + { + alias: 'Errors', + aggFn: 'count', + aggCondition: ERROR_COND, + aggConditionLanguage: 'sql', + valueExpression: '', + }, + ]); + }); + + it('returns undefined when the source has no error condition', () => { + expect( + errorsConfig(redBaseConfig(base), undefined, 'rate'), + ).toBeUndefined(); + expect( + errorsConfig(redBaseConfig(base), undefined, 'volume'), + ).toBeUndefined(); + }); + }); + + describe('durationConfig', () => { + it('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, + }, + ]); + }); + }); +}); diff --git a/packages/app/src/components/Search/traceRedMetrics.ts b/packages/app/src/components/Search/traceRedMetrics.ts new file mode 100644 index 0000000000..cf90b36a63 --- /dev/null +++ b/packages/app/src/components/Search/traceRedMetrics.ts @@ -0,0 +1,170 @@ +import { + BuilderChartConfigWithDateRange, + DisplayType, +} from '@hyperdx/common-utils/dist/types'; + +import { INTEGER_NUMBER_FORMAT } from '@/ChartUtils'; +import type { NumberFormat } from '@/types'; + +export type ErrorsMode = 'rate' | 'volume'; + +// One decimal so sub-1% rates read (e.g. 0.4%) instead of rounding to 0%, +// while the axis still auto-scales to the data. +export const ERROR_RATE_FORMAT: NumberFormat = { + output: 'percent', + mantissa: 1, +}; + +/** + * Helper series that back the error-rate ratio; hidden from the chart so only + * the computed rate shows. Aggregating count and countIf separately (instead of + * avg over a status boolean) lets AggregatingMergeTree materialized views + * satisfy the query. + */ +export const ERROR_RATE_HELPER_SERIES = ['total_spans', 'error_spans']; + +/** + * The RED metrics for a trace source, derived from the same base config the + * count histogram uses so they honor the active WHERE filter and time range. + * Kept as pure builders so the aggregations can be unit tested without + * rendering. Only the select, display type, and number format change per chart. + * + * Every aggregation is over a raw column (count, countIf, quantile/avg of the + * duration expression) so materialized views can satisfy it; unit and ratio + * conversion happen at the display layer or in a post-aggregation column. + */ + +/** SQL condition that marks a span as an error, or undefined when the source + * has no status-code expression to test. Mirrors the service dashboard's + * `lower(StatusCode) = 'error'` definition. */ +export function errorConditionSql( + statusCodeExpression: string | undefined, +): string | undefined { + return statusCodeExpression + ? `lower(${statusCodeExpression}) = 'error'` + : undefined; +} + +/** The histogram config with its status-code groupBy removed, so RED charts + * aggregate across all matching spans rather than splitting per status. */ +export function redBaseConfig( + histogramTimeChartConfig: BuilderChartConfigWithDateRange, +): BuilderChartConfigWithDateRange { + return { ...histogramTimeChartConfig, groupBy: undefined }; +} + +/** Throughput: span count per bucket. Bars (StackedBar is the only display + * type that renders bars; a single series draws like a plain bar chart). */ +export function throughputConfig( + base: BuilderChartConfigWithDateRange, +): BuilderChartConfigWithDateRange { + return { + ...base, + select: [ + { alias: 'Spans', aggFn: 'count', aggCondition: '', valueExpression: '' }, + ], + displayType: DisplayType.StackedBar, + numberFormat: INTEGER_NUMBER_FORMAT, + }; +} + +/** + * Errors as a rate or a volume. Rate is `countIf(error) / count()`: the two + * counts are aggregated separately (MV-friendly) and divided in a + * post-aggregation column, with the helper counts hidden via + * ERROR_RATE_HELPER_SERIES. Volume is `countIf(error)` as bars. Returns + * undefined when the source has no error condition. + */ +export function errorsConfig( + base: BuilderChartConfigWithDateRange, + errorCondition: string | undefined, + mode: ErrorsMode, +): BuilderChartConfigWithDateRange | undefined { + if (errorCondition == null) { + return undefined; + } + if (mode === 'rate') { + return { + ...base, + select: [ + { + alias: 'total_spans', + aggFn: 'count', + aggCondition: '', + valueExpression: '', + }, + { + alias: 'error_spans', + aggFn: 'count', + aggCondition: errorCondition, + aggConditionLanguage: 'sql', + valueExpression: '', + }, + { + // Guard the empty-bucket 0/0 (which becomes NaN and breaks the + // auto-scaled y-axis) and clamp to 100%, since an error rate cannot + // exceed 1 by definition. + alias: 'Error rate', + valueExpression: + 'least(if(total_spans > 0, error_spans / total_spans, 0), 1)', + }, + ], + displayType: DisplayType.Line, + numberFormat: ERROR_RATE_FORMAT, + }; + } + return { + ...base, + select: [ + { + alias: 'Errors', + aggFn: 'count', + aggCondition: errorCondition, + aggConditionLanguage: 'sql', + valueExpression: '', + }, + ], + displayType: DisplayType.StackedBar, + numberFormat: INTEGER_NUMBER_FORMAT, + }; +} + +/** + * Duration: Avg, p95, p99 over the source's 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: 'Avg', + aggFn: 'avg', + aggCondition: '', + valueExpression: durationExpression, + }, + { + alias: 'p95', + aggFn: 'quantile', + level: 0.95, + aggCondition: '', + valueExpression: durationExpression, + }, + { + alias: 'p99', + aggFn: 'quantile', + level: 0.99, + aggCondition: '', + valueExpression: durationExpression, + }, + ], + displayType: DisplayType.Line, + numberFormat, + }; +}