diff --git a/.changeset/error-span-predicate.md b/.changeset/error-span-predicate.md new file mode 100644 index 0000000000..6856153a68 --- /dev/null +++ b/.changeset/error-span-predicate.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': patch +--- + +Count spans recorded with the legacy `STATUS_CODE_ERROR` status as errors on the Services and LLM dashboards. The errored-span definition now lives in one place and matches both that value and the short `Error` form, so those dashboards no longer undercount errors from collectors that emit the long form. 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/.changeset/trace-search-duration-heatmap.md b/.changeset/trace-search-duration-heatmap.md new file mode 100644 index 0000000000..0d3278cc02 --- /dev/null +++ b/.changeset/trace-search-duration-heatmap.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': minor +--- + +Add a RED/Heatmap switch to the trace search results view. Heatmap shows the duration distribution over time as the same tile the dashboard renders, and the choice is kept in the URL so a reload or a shared link reopens the same view. 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..43dffbbbaf 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,53 @@ 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]); + + // Pin live tail off while the heatmap is the visible chart. A live tick + // moves the absolute time range, and the heatmap keys its uPlot instance on + // the chart config, so every refresh would tear the chart down and rebuild + // it with a recomputed y-axis instead of updating in place. Delta and + // pattern modes already pin it off (see the analysisMode effect above), so + // the delta-view heatmap never hit this; results mode defaults to live, so + // the view has to opt out for itself. Only pinned while the heatmap is + // actually rendered, so a stale ?traceChartMode=heatmap on a log source + // can't disable live tail for a search that shows the histogram. + const isHeatmapChartVisible = + analysisMode === 'results' && + traceRedMetricsSource != null && + traceChartMode === 'heatmap'; + useEffect(() => { + if (isHeatmapChartVisible) { + setIsLive(false); + } + }, [isHeatmapChartVisible, setIsLive]); + const histogramTimeChartConfig = useMemo(() => { if (chartConfig == null) { return undefined; @@ -2491,11 +2569,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 +2686,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 6b86ef905c..bff981c42b 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -22,12 +22,13 @@ import { ReferenceArea, ReferenceLine, ResponsiveContainer, + Text, Tooltip, XAxis, YAxis, } from 'recharts'; import { getTickValuesFixedDomain } from 'recharts/lib/util/scale/getNiceTickValues'; -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'; @@ -873,6 +874,37 @@ export function getYAxisTicks( return getTickValuesFixedDomain([min, max], 5, true); } +/** + * 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, @@ -898,6 +930,8 @@ export const MemoChart = memo(function MemoChart({ granularity, dateRangeEndInclusive = true, fitYAxisToData = false, + compactXAxisLabels = false, + yAxisMaxDomain, }: { graphResults: any[]; setIsClickActive: (v: ActiveClickPayload | undefined) => void; @@ -939,6 +973,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, ''); @@ -1064,6 +1111,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']; } @@ -1108,6 +1164,7 @@ export const MemoChart = memo(function MemoChart({ selectedSeriesNames, fitYAxisToData, displayType, + yAxisMaxDomain, ]); const [containerWidth, setContainerWidth] = useState(0); @@ -1221,6 +1278,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], @@ -1681,7 +1762,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 6ca574e2bb..a3dc879641 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, formatAxisTick, getSelectedLineData, getVisibleLineData, @@ -611,3 +613,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..83c57741e1 --- /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 { errorStatusPredicateSql } 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 = errorStatusPredicateSql(groupBy); + if (mode === 'rate') { + return { + ...base, + select: [ + { + alias: TOTAL_SPANS_ALIAS, + aggFn: 'count', + aggCondition: '', + valueExpression: '', + }, + { + alias: ERROR_SPANS_ALIAS, + aggFn: 'count', + aggCondition: errorCondition, + aggConditionLanguage: 'sql', + valueExpression: '', + }, + { + // Guard the empty-bucket 0/0 (which becomes NaN and breaks the + // auto-scaled y-axis). error_spans is a countIf over the same rows as + // total_spans = count(), so the ratio can't exceed 1; no clamp needed. + alias: 'Error rate', + valueExpression: `if(${TOTAL_SPANS_ALIAS} > 0, ${ERROR_SPANS_ALIAS} / ${TOTAL_SPANS_ALIAS}, 0)`, + }, + ], + displayType: DisplayType.Line, + numberFormat: ERROR_RATE_FORMAT, + }; + } + return { + ...base, + groupBy, + select: [ + { + alias: 'Errors', + aggFn: 'count', + aggCondition: '', + valueExpression: '', + }, + ], + filters: [ + ...(base.filters ?? []), + { type: 'sql' as const, condition: errorCondition }, + ], + displayType: DisplayType.StackedBar, + numberFormat: INTEGER_NUMBER_FORMAT, + }; +} + +// Duration series aliases, declared once so durationConfig's select and the +// DURATION_SERIES_COLORS pins below key off the same strings. setLineColors +// resolves a pin by result-column name, so a rename that touched only one side +// would silently drop the pin and let that percentile fall back to the +// positional palette slot (chart-red) the pins exist to avoid, with no test or +// type failure to catch it. +const DURATION_AVG_ALIAS = 'Avg'; +const DURATION_P95_ALIAS = 'p95'; +const DURATION_P99_ALIAS = 'p99'; + +/** + * Pin the Duration percentiles to stable, cool palette hues so latency never + * borrows error red (or warning orange / success green), and so each percentile + * keeps its color across sessions regardless of how the data sorts the lines. + */ +export const DURATION_SERIES_COLORS = { + [DURATION_AVG_ALIAS]: 'chart-blue', + [DURATION_P95_ALIAS]: 'chart-cyan', + [DURATION_P99_ALIAS]: 'chart-purple', +} satisfies Record; + +/** + * Duration: Avg, p95, p99 over the source's raw duration expression. Aggregating + * the raw column (not a divided-to-ms expression) keeps it MV-friendly; the + * caller passes a duration NumberFormat derived from the source's precision + * (via getTraceDurationNumberFormat) so unit conversion happens at display. + */ +export function durationConfig( + base: BuilderChartConfigWithDateRange, + durationExpression: string, + numberFormat: NumberFormat | undefined, +): BuilderChartConfigWithDateRange { + return { + ...base, + select: [ + { + alias: DURATION_AVG_ALIAS, + aggFn: 'avg', + aggCondition: '', + valueExpression: durationExpression, + }, + { + alias: DURATION_P95_ALIAS, + aggFn: 'quantile', + level: 0.95, + aggCondition: '', + valueExpression: durationExpression, + }, + { + alias: DURATION_P99_ALIAS, + aggFn: 'quantile', + level: 0.99, + aggCondition: '', + valueExpression: durationExpression, + }, + ], + displayType: DisplayType.Line, + numberFormat, + }; +} 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..ae5743b164 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 { errorStatusPredicateSql } 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: errorStatusPredicateSql(fieldExpressions.severityText), hasTokens: `${attributeExpressions.totalTokens} > 0`, }; } @@ -481,7 +483,13 @@ export function getLLMLogExpressions( ...attributeExpressions, service: source.serviceNameExpression || 'ServiceName', severityText, - /** Error log events (mirrors the serviceDashboard convention). */ + /** + * Error log events. Severity text has its own vocabulary, so this keeps a + * predicate of its own rather than reusing the span-status one: the extra + * status value that predicate matches cannot appear in a severity-text + * column, and the values severity text does use for errors are a + * different set. + */ isError: `lower(${severityText}) = 'error'`, /** * Log events that belong to LLM activity: explicit LLM markers or any diff --git a/packages/app/src/serviceDashboard.ts b/packages/app/src/serviceDashboard.ts index edf2056625..ab88211b92 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 { errorStatusPredicateSql } 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: errorStatusPredicateSql(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..ee71013907 100644 --- a/packages/app/src/source.ts +++ b/packages/app/src/source.ts @@ -484,10 +484,36 @@ export function getDurationMsExpression(source: TTraceSource) { return `(${source.durationExpression})/1e${(source.durationPrecision ?? 9) - 3}`; } +/** + * Number format for a duration heatmap whose value expression is already in + * milliseconds, which is what `getDurationMsExpression` returns: it applies + * the source's `durationPrecision`, so the only conversion left for display + * is milliseconds to seconds. Pair the two; this factor is wrong against a + * raw duration column. + */ +export const DURATION_HEATMAP_NUMBER_FORMAT = { + output: 'duration', + factor: 0.001, +} satisfies NumberFormat; + export function getDurationSecondsExpression(source: TTraceSource) { return `(${source.durationExpression})/1e${source.durationPrecision ?? 9}`; } +/** + * The single definition of an errored span, as a SQL predicate over a span + * status-code expression. Matches both the short OTel status ('Error'/'ERROR') + * and the legacy collector form ('STATUS_CODE_ERROR'), which some collectors + * write instead. + * + * Span status only. Log severity text is a different vocabulary (its error + * values are 'fatal', 'crit' and friends, never 'status_code_error'), so log + * callers keep their own predicate rather than reusing this one. + */ +export function errorStatusPredicateSql(statusCodeExpression: string): string { + return `lower(${statusCodeExpression}) IN ('error', 'status_code_error')`; +} + // Aggregate functions whose output preserves the unit of the input value. // count and count_distinct produce dimensionless counts and should not // inherit the duration format.