diff --git a/.changeset/red-metrics-trace-search.md b/.changeset/red-metrics-trace-search.md new file mode 100644 index 0000000000..b1ffdf3152 --- /dev/null +++ b/.changeset/red-metrics-trace-search.md @@ -0,0 +1,7 @@ +--- +'@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. + +The shared "error span" definition now also matches the legacy `STATUS_CODE_ERROR` status value in addition to `Error`, so error counts on the Services and LLM dashboards include those spans too. diff --git a/packages/app/src/ChartUtils.tsx b/packages/app/src/ChartUtils.tsx index 35a99e86b5..93a9b3f73e 100644 --- a/packages/app/src/ChartUtils.tsx +++ b/packages/app/src/ChartUtils.tsx @@ -28,6 +28,7 @@ import { BuilderSavedChartConfig, ChartConfigWithDateRange, ChartConfigWithOptDateRange, + ChartPaletteToken, DisplayType, Filter, isSearchableSource, @@ -49,7 +50,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'; @@ -585,6 +591,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(); @@ -595,10 +602,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); @@ -749,6 +761,7 @@ export function formatResponseForTimeChart({ hiddenSeries = [], previousPeriodOffsetSeconds = 0, maxSeries = MAX_RENDERED_TIME_CHART_SERIES, + seriesColors, }: { dateRange: [Date, Date]; granularity?: SQLInterval; @@ -764,6 +777,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; @@ -998,7 +1017,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 087a2a1063..813e2b6f85 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -60,6 +60,7 @@ import { Group, Modal, Paper, + SegmentedControl, Select, Stack, Text, @@ -140,6 +141,10 @@ import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar'; import PatternTable from './components/PatternTable'; import { DBSearchHeatmapChart } from './components/Search/DBSearchHeatmapChart'; import DirectTraceSidePanel from './components/Search/DirectTraceSidePanel'; +import { + type TraceChartMode, + TraceRedMetricsChart, +} from './components/Search/TraceRedMetricsChart'; import SourceSchemaPreview, { isSourceSchemaPreviewEnabled, } from './components/SourceSchemaPreview'; @@ -206,6 +211,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 @@ -1061,6 +1073,14 @@ export function DBSearchPage() { ]).withDefault('results'), ); + // RED metrics vs heatmap for the trace results chart area. URL state (like + // the other view toggles on this page) so a reload or shared link keeps the + // chosen view; the switch lives inline in the search stats row. + const [traceChartMode, setTraceChartMode] = useQueryState( + 'traceChartMode', + parseAsStringEnum(['red', 'heatmap']).withDefault('red'), + ); + const [patternColumn, setPatternColumn] = useQueryState( 'patternColumn', parseAsString, @@ -1611,6 +1631,16 @@ export function DBSearchPage() { 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, @@ -1665,7 +1695,8 @@ export function DBSearchPage() { interval, refreshFrequency, onTimeRangeSelect, - pause: isAnyQueryFetching || !queryReady || !isTabVisible, + pause: + isAnyQueryFetching || isRedQueryFetching || !queryReady || !isTabVisible, }); // This ensures we only render this conditionally on the client @@ -1864,6 +1895,34 @@ export function DBSearchPage() { const aliasWith = useMemo(() => aliasMapToWithClauses(aliasMap), [aliasMap]); + // The trace results chart shows RED metrics (and a heatmap) 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 stats-row + // toggle and the chart branch can't drift apart on which one renders. + const traceRedMetricsSource = + searchedSource != null && + isTraceSource(searchedSource) && + searchedSource.durationExpression + ? searchedSource + : null; + + // Reset to the default RED view when the source actually changes, so + // returning to a trace source after viewing another one doesn't silently + // reopen in Heatmap. The ref starts undefined and is only compared once a + // real id has been seen, so the async source resolution on first load + // (undefined -> id) doesn't count as a change and a shared link that pins + // ?traceChartMode=heatmap survives. + const prevSourceIdRef = useRef(undefined); + useEffect(() => { + if ( + prevSourceIdRef.current !== undefined && + prevSourceIdRef.current !== searchedSource?.id + ) { + setTraceChartMode('red'); + } + prevSourceIdRef.current = searchedSource?.id; + }, [searchedSource?.id, setTraceChartMode]); + const histogramTimeChartConfig = useMemo(() => { if (chartConfig == null) { return undefined; @@ -2491,11 +2550,7 @@ export function DBSearchPage() { setAnalysisMode={setAnalysisMode} chartConfig={filtersChartConfig} sourceId={inputSourceObj?.id} - showDelta={ - !!(searchedSource?.kind === SourceKind.Trace - ? searchedSource.durationExpression - : undefined) - } + showDelta={traceRedMetricsSource != null} onColumnToggle={toggleColumn} displayedColumns={displayedColumns} onCollapse={() => setIsFilterSidebarCollapsed(true)} @@ -2612,6 +2667,21 @@ export function DBSearchPage() { enableParallelQueries /> + {traceRedMetricsSource != null && ( + + setTraceChartMode( + v === 'heatmap' ? 'heatmap' : 'red', + ) + } + data={[ + { label: 'RED', value: 'red' }, + { label: 'Heatmap', value: 'heatmap' }, + ]} + /> + )} {shouldShowLiveModeHint && denoiseResults != true && ( - {!hasQueryError && ( - - - - )} + {!hasQueryError && + (traceRedMetricsSource != null ? ( + + + + ) : ( + + + + ))} )} {hasQueryError && queryError ? ( diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index 2e5c6cb004..2783dfa554 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -22,11 +22,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 { Popover } from '@mantine/core'; @@ -864,6 +865,37 @@ export function formatAxisTick( }); } +/** + * 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, @@ -889,6 +921,8 @@ export const MemoChart = memo(function MemoChart({ granularity, dateRangeEndInclusive = true, fitYAxisToData = false, + compactXAxisLabels = false, + yAxisMaxDomain, }: { graphResults: any[]; setIsClickActive: (v: ActiveClickPayload | undefined) => void; @@ -930,6 +964,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, ''); @@ -1055,6 +1102,15 @@ 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) => cappedYAxisUpperBound(dataMax, yAxisMaxDomain), + ]; + } return [0, 'auto']; } @@ -1099,6 +1155,7 @@ export const MemoChart = memo(function MemoChart({ selectedSeriesNames, fitYAxisToData, displayType, + yAxisMaxDomain, ]); const [containerWidth, setContainerWidth] = useState(0); @@ -1212,6 +1269,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], @@ -1662,7 +1743,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' } + } /> { ]); }); + 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 2f8ac5df38..eaceb96028 100644 --- a/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts +++ b/packages/app/src/__tests__/HDXMultiSeriesTimeChart.test.ts @@ -10,7 +10,9 @@ import type { LineData } from '@/ChartUtils'; import type { ActiveClickSeries } from '@/HDXMultiSeriesTimeChart'; import { buildActiveClickSeries, + cappedYAxisUpperBound, collectMemoChartGradientHexes, + compactTickAnchor, formatAxisTick, getSelectedLineData, getVisibleLineData, @@ -507,3 +509,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/__tests__/serviceDashboard.test.ts b/packages/app/src/__tests__/serviceDashboard.test.ts index 5a5821337e..4733695a2b 100644 --- a/packages/app/src/__tests__/serviceDashboard.test.ts +++ b/packages/app/src/__tests__/serviceDashboard.test.ts @@ -320,7 +320,7 @@ describe('Service Dashboard', () => { expect(result.current.isLoading).toBe(false); expect(result.current.expressions?.isError).toBe( - "lower(StatusCode) = 'error'", + "lower(StatusCode) IN ('error', 'status_code_error')", ); expect(result.current.expressions?.isSpanKindServer).toContain( 'SpanKind IN', diff --git a/packages/app/src/components/DBHeatmapChart.tsx b/packages/app/src/components/DBHeatmapChart.tsx index a752a45db0..545fbb05b5 100644 --- a/packages/app/src/components/DBHeatmapChart.tsx +++ b/packages/app/src/components/DBHeatmapChart.tsx @@ -718,6 +718,7 @@ function HeatmapContainer({ scaleType = 'log', showLegend = false, errorVariant, + queryKeyPrefix, }: { config: HeatmapChartConfig; enabled?: boolean; @@ -737,6 +738,14 @@ function HeatmapContainer({ scaleType?: HeatmapScaleType; showLegend?: boolean; errorVariant?: ChartErrorStateVariant; + /** + * Namespaces both heatmap queries, matching `DBTimeChart`'s prop of the same + * name. Callers that gate on `useIsFetching` (the search page pauses live + * tail while its charts are in flight) need the heatmap's queries to carry + * the same prefix as the sibling time charts, or a live tick fires a fresh + * pair of heatmap queries without waiting for the previous one. + */ + queryKeyPrefix?: string; }) { const dateRange = config.dateRange; const granularity = convertDateRangeToGranularityString(dateRange, 245); @@ -758,7 +767,7 @@ function HeatmapContainer({ isLoading: isMinMaxLoading, error: minMaxError, } = useQueriedChartConfig(minMaxConfig, { - queryKey: ['heatmap', minMaxConfig], + queryKey: [queryKeyPrefix, 'heatmap', minMaxConfig], enabled: enabled, }); @@ -782,7 +791,7 @@ function HeatmapContainer({ }); const { data, isLoading, error } = useQueriedChartConfig(bucketConfig, { - queryKey: ['heatmap_bucket', bucketConfig], + queryKey: [queryKeyPrefix, 'heatmap_bucket', bucketConfig], enabled: !!minMaxData && bucketConfig != null && max > effectiveMin, }); diff --git a/packages/app/src/components/DBTimeChart.tsx b/packages/app/src/components/DBTimeChart.tsx index 1ae67bcbb8..68b5c4434d 100644 --- a/packages/app/src/components/DBTimeChart.tsx +++ b/packages/app/src/components/DBTimeChart.tsx @@ -20,6 +20,7 @@ import { import { BuilderChartConfigWithDateRange, ChartConfigWithDateRange, + ChartPaletteToken, DisplayType, } from '@hyperdx/common-utils/dist/types'; import { Popover, Portal } from '@mantine/core'; @@ -312,6 +313,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[]; @@ -327,6 +334,16 @@ type DBTimeChartComponentProps = { * behavior), which is all a standalone chart can do. */ onFocusSeries?: (filters: SeriesGroupFilter[]) => 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({ @@ -345,6 +362,7 @@ function DBTimeChartComponent({ showLegend = true, sourceId, hiddenSeries, + seriesColors, title, toolbarPrefix, toolbarSuffix, @@ -352,6 +370,8 @@ function DBTimeChartComponent({ showDateRangeIndicator = true, errorVariant, onFocusSeries, + compactXAxisLabels, + yAxisMaxDomain, }: DBTimeChartComponentProps) { const [selectedSeriesSet, setSelectedSeriesSet] = useState>( new Set(), @@ -587,6 +607,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, @@ -628,6 +649,7 @@ function DBTimeChartComponent({ config.seriesLimit, previousPeriodData, hiddenSeries, + seriesColors, previousPeriodOffsetSeconds, showAllSeries, ]); @@ -1047,6 +1069,8 @@ function DBTimeChartComponent({ granularity={granularity} dateRangeEndInclusive={queriedConfig.dateRangeEndInclusive} fitYAxisToData={queriedConfig.fitYAxisToData} + compactXAxisLabels={compactXAxisLabels} + yAxisMaxDomain={yAxisMaxDomain} /> )} diff --git a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx index 435212c18f..025f82b0c2 100644 --- a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx +++ b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx @@ -26,8 +26,10 @@ import DBHeatmapChart, { toHeatmapChartConfig, } from '@/components/DBHeatmapChart'; import HeatmapSettingsDrawer from '@/components/HeatmapSettingsDrawer'; -import { getDurationMsExpression } from '@/source'; -import type { NumberFormat } from '@/types'; +import { + DURATION_HEATMAP_NUMBER_FORMAT, + getDurationMsExpression, +} from '@/source'; export function DBSearchHeatmapChart({ chartConfig, @@ -151,10 +153,7 @@ export function DBSearchHeatmapChart({ ], numberFormat: fields.value === getDurationMsExpression(source) - ? ({ - output: 'duration', - factor: 0.001, - } satisfies NumberFormat) + ? DURATION_HEATMAP_NUMBER_FORMAT : undefined, }).heatmapConfig } diff --git a/packages/app/src/components/Search/TraceRedMetricsChart.tsx b/packages/app/src/components/Search/TraceRedMetricsChart.tsx new file mode 100644 index 0000000000..4f9f2041e4 --- /dev/null +++ b/packages/app/src/components/Search/TraceRedMetricsChart.tsx @@ -0,0 +1,225 @@ +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 DBHeatmapChart, { + toHeatmapChartConfig, +} from '@/components/DBHeatmapChart'; +import { DBTimeChart, type SeriesGroupFilter } from '@/components/DBTimeChart'; +import { + DURATION_HEATMAP_NUMBER_FORMAT, + getDurationMsExpression, + getTraceDurationNumberFormat, +} from '@/source'; + +import { + DURATION_SERIES_COLORS, + durationConfig, + ERROR_RATE_HELPER_SERIES, + errorsConfig, + ErrorsMode, + redBaseConfig, + throughputConfig, +} from './traceRedMetrics'; + +export type TraceChartMode = 'red' | 'heatmap'; + +// 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 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, + onFocusSeries, +}: { + /** 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; + /** 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. Falls back + // to getDurationMsExpression only for the heatmap tile below. + 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 durationMsExpression = getDurationMsExpression(source); + + 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], + ); + /** + * Heatmap tile config (duration distribution over time), matching the + * dashboard heatmap tile: DBHeatmapChart + toHeatmapChartConfig, no + * significant-fields comparison panel. The duration value, count and scale + * are fixed here rather than read from the delta view's settings drawer, so + * this stays a plain duration heatmap independent of that view's query state. + * + * @source packages/app/src/components/Search/DBSearchHeatmapChart.tsx (tile config) + */ + const { heatmapConfig, scaleType } = useMemo( + () => + toHeatmapChartConfig({ + ...heatmapChartConfig, + select: [ + { + valueExpression: durationMsExpression, + countExpression: 'count()', + heatmapScaleType: 'log', + }, + ], + numberFormat: DURATION_HEATMAP_NUMBER_FORMAT, + }), + [heatmapChartConfig, durationMsExpression], + ); + + 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 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..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..00b2fe813a --- /dev/null +++ b/packages/app/src/components/Search/traceRedMetrics.ts @@ -0,0 +1,205 @@ +import { + BuilderChartConfigWithDateRange, + ChartPaletteToken, + DisplayType, +} from '@hyperdx/common-utils/dist/types'; + +import { + ERROR_RATE_PERCENTAGE_NUMBER_FORMAT, + INTEGER_NUMBER_FORMAT, +} from '@/ChartUtils'; +import { errorPredicateSql } from '@/source'; +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 = errorPredicateSql(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, + }; +} diff --git a/packages/app/src/llm/__tests__/expressions.test.ts b/packages/app/src/llm/__tests__/expressions.test.ts index 5852854ccc..6643b13965 100644 --- a/packages/app/src/llm/__tests__/expressions.test.ts +++ b/packages/app/src/llm/__tests__/expressions.test.ts @@ -32,7 +32,9 @@ describe('getLLMExpressions', () => { expect(expressions.isLLMSpan).toContain( "mapContains(SpanAttributes, 'gen_ai.operation.name')", ); - expect(expressions.isError).toBe("lower(StatusCode) = 'error'"); + expect(expressions.isError).toBe( + "lower(StatusCode) IN ('error', 'status_code_error')", + ); expect(expressions.durationInMillis).toBe('Duration/1e6'); // Provided cost takes precedence in the cost expression. expect(expressions.costUsd.startsWith('if((greatest(')).toBe(true); @@ -274,7 +276,9 @@ describe('getLLMExpressions', () => { [], ); expect(expressions.model).toContain("Attrs['gen_ai.response.model']"); - expect(expressions.isError).toBe("lower(Status) = 'error'"); + expect(expressions.isError).toBe( + "lower(Status) IN ('error', 'status_code_error')", + ); expect(expressions.durationInMillis).toBe('DurationNs/1e6'); }); }); diff --git a/packages/app/src/llm/lib/expressions.ts b/packages/app/src/llm/lib/expressions.ts index 72f901a18a..195e0d4608 100644 --- a/packages/app/src/llm/lib/expressions.ts +++ b/packages/app/src/llm/lib/expressions.ts @@ -1,5 +1,7 @@ import { TLogSource, TTraceSource } from '@hyperdx/common-utils/dist/types'; +import { errorPredicateSql } from '@/source'; + import { generateCostSqlExpression } from './cost'; import { buildAnyKeyExistsSql, buildLLMSpanSqlPredicate } from './detect'; @@ -452,7 +454,7 @@ export function getLLMExpressions(source: TTraceSource, jsonColumns: string[]) { ...attributeExpressions, ...fieldExpressions, ...auxExpressions, - isError: `lower(${fieldExpressions.severityText}) = 'error'`, + isError: errorPredicateSql(fieldExpressions.severityText), hasTokens: `${attributeExpressions.totalTokens} > 0`, }; } @@ -482,7 +484,7 @@ export function getLLMLogExpressions( service: source.serviceNameExpression || 'ServiceName', severityText, /** Error log events (mirrors the serviceDashboard convention). */ - isError: `lower(${severityText}) = 'error'`, + isError: errorPredicateSql(severityText), /** * Log events that belong to LLM activity: explicit LLM markers or any * session id (covers tool_result / lifecycle events that carry a diff --git a/packages/app/src/serviceDashboard.ts b/packages/app/src/serviceDashboard.ts index edf2056625..87e9e67586 100644 --- a/packages/app/src/serviceDashboard.ts +++ b/packages/app/src/serviceDashboard.ts @@ -4,6 +4,7 @@ import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; import { SourceKind, TTraceSource } from '@hyperdx/common-utils/dist/types'; import { useColumns, useJsonColumns } from './hooks/useMetadata'; +import { errorPredicateSql } from './source'; const COALESCE_FIELDS_LIMIT = 100; @@ -186,7 +187,7 @@ export function getExpressions( const filterExpressions = { isEndpointNonEmpty: `NOT empty(${auxExpressions.endpoint})`, - isError: `lower(${fieldExpressions.severityText}) = 'error'`, + isError: errorPredicateSql(fieldExpressions.severityText), isSpanKindServer: `${fieldExpressions.spanKind} IN ('Server', 'SPAN_KIND_SERVER')`, isDbSpan: `${fieldExpressions.dbStatement} <> ''`, }; diff --git a/packages/app/src/source.ts b/packages/app/src/source.ts index e16093f067..2e8b97b7a8 100644 --- a/packages/app/src/source.ts +++ b/packages/app/src/source.ts @@ -484,6 +484,29 @@ export function getDurationMsExpression(source: TTraceSource) { return `(${source.durationExpression})/1e${(source.durationPrecision ?? 9) - 3}`; } +/** + * Number format for a duration heatmap whose value expression is in + * milliseconds (e.g. getDurationMsExpression): the 0.001 factor converts ms + * back to seconds for display. Shared so the trace search RED/Heatmap tile and + * the significant-fields heatmap stay in lockstep on the unit. + */ +export const DURATION_HEATMAP_NUMBER_FORMAT = { + output: 'duration', + factor: 0.001, +} satisfies NumberFormat; + +/** + * The single definition of an "error" span/log as a SQL predicate over a + * status-code or severity-text expression. Shared by the service dashboard, + * the LLM expressions, and trace search so the definition lives in one place. + * Matches both the short OTel status ('Error'/'ERROR') and the legacy + * collector form ('STATUS_CODE_ERROR'); severity-text values never take the + * latter, so it's a no-op there. + */ +export function errorPredicateSql(statusOrSeverityExpression: string): string { + return `lower(${statusOrSeverityExpression}) IN ('error', 'status_code_error')`; +} + export function getDurationSecondsExpression(source: TTraceSource) { return `(${source.durationExpression})/1e${source.durationPrecision ?? 9}`; }