From a690c515208d122711b20244519f74057bc6b7f3 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:03:51 +0000 Subject: [PATCH 1/7] fix(app): count legacy STATUS_CODE_ERROR spans as errors The service dashboard and the LLM expressions each spelled out their own "is this an error" predicate as lower() = 'error'. Collectors that emit the long OTel form (STATUS_CODE_ERROR) therefore fell outside it, so error rates and error counts on those dashboards undercounted. Pull the predicate into one exported helper in source.ts and match both forms. The logs path passes a severity-text expression, which never takes the long form, so it is unaffected. --- .changeset/error-span-predicate.md | 5 +++++ packages/app/src/__tests__/serviceDashboard.test.ts | 2 +- packages/app/src/llm/__tests__/expressions.test.ts | 8 ++++++-- packages/app/src/llm/lib/expressions.ts | 6 ++++-- packages/app/src/serviceDashboard.ts | 3 ++- packages/app/src/source.ts | 12 ++++++++++++ 6 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 .changeset/error-span-predicate.md diff --git a/.changeset/error-span-predicate.md b/.changeset/error-span-predicate.md new file mode 100644 index 0000000000..b232a0445b --- /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 "error 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/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/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..638147a455 100644 --- a/packages/app/src/source.ts +++ b/packages/app/src/source.ts @@ -488,6 +488,18 @@ export function getDurationSecondsExpression(source: TTraceSource) { return `(${source.durationExpression})/1e${source.durationPrecision ?? 9}`; } +/** + * 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 and + * the LLM expressions 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 is + * a no-op there. + */ +export function errorPredicateSql(statusOrSeverityExpression: string): string { + return `lower(${statusOrSeverityExpression}) 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. From 7de8c841af5a81cc00cc77293cbf0360ec1c95c0 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:11:07 +0000 Subject: [PATCH 2/7] refactor(app): scope the error predicate to span status only The helper was also applied to the log-severity expression, where the legacy status value it matches can never appear and the values severity text does use for errors (fatal, crit and friends) are a different set. Mixing the two vocabularies in one predicate made it wrong in both directions on the log path. Rename it to errorStatusPredicateSql, document it as span-status only, and let the log caller keep its own predicate, unchanged from before this branch. --- .changeset/error-span-predicate.md | 2 +- packages/app/src/llm/lib/expressions.ts | 14 ++++++++++---- packages/app/src/serviceDashboard.ts | 4 ++-- packages/app/src/source.ts | 18 ++++++++++-------- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/.changeset/error-span-predicate.md b/.changeset/error-span-predicate.md index b232a0445b..6856153a68 100644 --- a/.changeset/error-span-predicate.md +++ b/.changeset/error-span-predicate.md @@ -2,4 +2,4 @@ '@hyperdx/app': patch --- -Count spans recorded with the legacy `STATUS_CODE_ERROR` status as errors on the Services and LLM dashboards. The "error 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. +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/packages/app/src/llm/lib/expressions.ts b/packages/app/src/llm/lib/expressions.ts index 195e0d4608..ae5743b164 100644 --- a/packages/app/src/llm/lib/expressions.ts +++ b/packages/app/src/llm/lib/expressions.ts @@ -1,6 +1,6 @@ import { TLogSource, TTraceSource } from '@hyperdx/common-utils/dist/types'; -import { errorPredicateSql } from '@/source'; +import { errorStatusPredicateSql } from '@/source'; import { generateCostSqlExpression } from './cost'; import { buildAnyKeyExistsSql, buildLLMSpanSqlPredicate } from './detect'; @@ -454,7 +454,7 @@ export function getLLMExpressions(source: TTraceSource, jsonColumns: string[]) { ...attributeExpressions, ...fieldExpressions, ...auxExpressions, - isError: errorPredicateSql(fieldExpressions.severityText), + isError: errorStatusPredicateSql(fieldExpressions.severityText), hasTokens: `${attributeExpressions.totalTokens} > 0`, }; } @@ -483,8 +483,14 @@ export function getLLMLogExpressions( ...attributeExpressions, service: source.serviceNameExpression || 'ServiceName', severityText, - /** Error log events (mirrors the serviceDashboard convention). */ - isError: errorPredicateSql(severityText), + /** + * 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 * 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 87e9e67586..ab88211b92 100644 --- a/packages/app/src/serviceDashboard.ts +++ b/packages/app/src/serviceDashboard.ts @@ -4,7 +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'; +import { errorStatusPredicateSql } from './source'; const COALESCE_FIELDS_LIMIT = 100; @@ -187,7 +187,7 @@ export function getExpressions( const filterExpressions = { isEndpointNonEmpty: `NOT empty(${auxExpressions.endpoint})`, - isError: errorPredicateSql(fieldExpressions.severityText), + 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 638147a455..9293ca7143 100644 --- a/packages/app/src/source.ts +++ b/packages/app/src/source.ts @@ -489,15 +489,17 @@ export function getDurationSecondsExpression(source: TTraceSource) { } /** - * 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 and - * the LLM expressions 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 is - * a no-op there. + * 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 errorPredicateSql(statusOrSeverityExpression: string): string { - return `lower(${statusOrSeverityExpression}) IN ('error', 'status_code_error')`; +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. From 76e4dca5e1ec2e53cf75d1db468b29610d23ee00 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:12:28 +0000 Subject: [PATCH 3/7] feat(app): show RED metrics on the trace search results view Trace search showed a single count histogram above the results. For trace sources with a duration column it now shows Throughput, Errors and Duration side by side, under a shared sync scope so hovering one moves the cursor on all three. Each chart is built from the same base config the histogram uses, in a pure module so the aggregations can be unit tested without rendering. Every aggregation is over a raw column so materialized views can satisfy it; the error ratio and the duration unit are applied at display instead. Logs and other sources keep the existing histogram. --- .changeset/red-metrics-trace-search.md | 5 + packages/app/src/ChartUtils.tsx | 31 ++- packages/app/src/DBSearchPage.tsx | 96 +++++--- packages/app/src/HDXMultiSeriesTimeChart.tsx | 89 +++++++- packages/app/src/__tests__/ChartUtils.test.ts | 76 ++++++- .../__tests__/HDXMultiSeriesTimeChart.test.ts | 46 ++++ packages/app/src/components/DBTimeChart.tsx | 24 ++ .../Search/TraceRedMetricsChart.tsx | 171 +++++++++++++++ .../Search/__tests__/traceRedMetrics.test.ts | 197 +++++++++++++++++ .../src/components/Search/traceRedMetrics.ts | 205 ++++++++++++++++++ 10 files changed, 905 insertions(+), 35 deletions(-) create mode 100644 .changeset/red-metrics-trace-search.md create mode 100644 packages/app/src/components/Search/TraceRedMetricsChart.tsx create mode 100644 packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts create mode 100644 packages/app/src/components/Search/traceRedMetrics.ts diff --git a/.changeset/red-metrics-trace-search.md b/.changeset/red-metrics-trace-search.md new file mode 100644 index 0000000000..12632044e9 --- /dev/null +++ b/.changeset/red-metrics-trace-search.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': minor +--- + +Show RED metrics (Throughput, Errors, Duration) above the trace search results instead of the single count histogram. The three charts share a synced hover cursor, Errors toggles between rate and volume, and clicking a status series on the volume chart filters the results. Logs and other sources keep the existing histogram. diff --git a/packages/app/src/ChartUtils.tsx b/packages/app/src/ChartUtils.tsx index 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..4bfb73976e 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -140,6 +140,7 @@ import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar'; import PatternTable from './components/PatternTable'; import { DBSearchHeatmapChart } from './components/Search/DBSearchHeatmapChart'; import DirectTraceSidePanel from './components/Search/DirectTraceSidePanel'; +import { TraceRedMetricsChart } from './components/Search/TraceRedMetricsChart'; import SourceSchemaPreview, { isSourceSchemaPreviewEnabled, } from './components/SourceSchemaPreview'; @@ -206,6 +207,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 @@ -1611,6 +1619,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 +1683,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 +1883,17 @@ export function DBSearchPage() { const aliasWith = useMemo(() => aliasMapToWithClauses(aliasMap), [aliasMap]); + // The trace results chart shows RED metrics only for a trace source that + // exposes a duration column; otherwise the single histogram stays. Derived + // once, as the narrowed source or null, so the callers can't drift apart on + // which one renders. + const traceRedMetricsSource = + searchedSource != null && + isTraceSource(searchedSource) && + searchedSource.durationExpression + ? searchedSource + : null; + const histogramTimeChartConfig = useMemo(() => { if (chartConfig == null) { return undefined; @@ -2491,11 +2521,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)} @@ -2632,26 +2658,44 @@ export function DBSearchPage() { - {!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/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/TraceRedMetricsChart.tsx b/packages/app/src/components/Search/TraceRedMetricsChart.tsx new file mode 100644 index 0000000000..cea4fe8607 --- /dev/null +++ b/packages/app/src/components/Search/TraceRedMetricsChart.tsx @@ -0,0 +1,171 @@ +import { type CSSProperties, useMemo, useState } from 'react'; +import { + BuilderChartConfigWithDateRange, + TTraceSource, +} from '@hyperdx/common-utils/dist/types'; +import { Flex, SegmentedControl } from '@mantine/core'; + +import { IsolatedChartSyncProvider } from '@/chartSync'; +import { ChartCard } from '@/components/charts/ChartCard'; +import { DBTimeChart, type SeriesGroupFilter } from '@/components/DBTimeChart'; +import { getTraceDurationNumberFormat } from '@/source'; + +import { + DURATION_SERIES_COLORS, + durationConfig, + ERROR_RATE_HELPER_SERIES, + errorsConfig, + ErrorsMode, + redBaseConfig, + throughputConfig, +} from './traceRedMetrics'; + +// Each RED tile is a ChartCard flex child that fills its column; ChartCard +// supplies the dashboard-tile chrome (border, background, full-bleed header +// divider) and the card-header context, so the three headers line up on their +// own without any manual height pinning. +const RED_TILE_STYLE = { + flex: 1, + minWidth: 0, + minHeight: 0, + height: '100%', +} satisfies CSSProperties; + +/** + * RED metrics (Throughput, Errors, Duration) for the trace search results view, + * replacing the single count histogram. The three charts render side by side as + * sibling DBTimeCharts under a shared sync scope, so hovering one shows a + * cross-chart cursor on all three at the same timestamp. + * + * The per-chart aggregations are built by ./traceRedMetrics from the same base + * config the histogram uses, so all three honor the active WHERE filter and + * selected time range. + */ +export function TraceRedMetricsChart({ + histogramTimeChartConfig, + source, + isReady, + queryKeyPrefix, + onTimeRangeSelect, + onFocusSeries, +}: { + /** The count-histogram config; RED charts spread this and swap only select. */ + histogramTimeChartConfig: BuilderChartConfigWithDateRange; + source: TTraceSource; + isReady: boolean; + queryKeyPrefix?: string; + onTimeRangeSelect?: (start: Date, end: Date) => void; + /** Filter the results by a clicked status series, matching the histogram's + * per-status drill-down. Only the volume Errors chart is grouped by status, + * so only it surfaces this. */ + onFocusSeries?: (filters: SeriesGroupFilter[]) => void; +}) { + const [errorsMode, setErrorsMode] = useState('rate'); + + // Aggregate the raw Duration column (MV-friendly) and let the display format, + // derived from the source's durationPrecision, convert the unit. + const durationExpression = source.durationExpression ?? ''; + // Memoized: getTraceDurationNumberFormat returns a fresh object, so an + // unmemoized value would change identity every render and defeat the + // `duration` memo below (re-rendering DBTimeChart on every keystroke/tick). + const durationFormat = useMemo( + () => + getTraceDurationNumberFormat(source, { + valueExpression: durationExpression, + aggFn: 'avg', + }), + [source, durationExpression], + ); + + const base = useMemo( + () => redBaseConfig(histogramTimeChartConfig), + [histogramTimeChartConfig], + ); + const throughput = useMemo(() => throughputConfig(base), [base]); + const errors = useMemo( + () => errorsConfig(base, source.statusCodeExpression, errorsMode), + [base, source.statusCodeExpression, errorsMode], + ); + const duration = useMemo( + () => durationConfig(base, durationExpression, durationFormat), + [base, durationExpression, durationFormat], + ); + + const errorsModeControl = ( + setErrorsMode(v === 'volume' ? 'volume' : 'rate')} + data={[ + { label: 'Rate', value: 'rate' }, + { label: 'Vol', value: 'volume' }, + ]} + /> + ); + + const commonProps = { + sourceId: source.id, + enabled: isReady, + showDisplaySwitcher: false, + showMVOptimizationIndicator: false, + showDateRangeIndicator: false, + queryKeyPrefix, + onTimeRangeSelect, + enableParallelQueries: true, + // narrow tiles: keep the edge time labels from clipping + compactXAxisLabels: true, + } as const; + + return ( + + + + + + {errors != null && ( + + {/* Remount on mode change: DBTimeChart seeds its display type + from the initial config when uncontrolled, so a key swap + re-seeds bars (volume) vs line (rate). */} + + + )} + + + + + + ); +} diff --git a/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts b/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts new file mode 100644 index 0000000000..d2e74f1558 --- /dev/null +++ b/packages/app/src/components/Search/__tests__/traceRedMetrics.test.ts @@ -0,0 +1,197 @@ +import { + BuilderChartConfigWithDateRange, + DisplayType, +} from '@hyperdx/common-utils/dist/types'; + +import { INTEGER_NUMBER_FORMAT } from '@/ChartUtils'; +import { + DURATION_SERIES_COLORS, + durationConfig, + ERROR_RATE_FORMAT, + ERROR_RATE_HELPER_SERIES, + errorsConfig, + redBaseConfig, + throughputConfig, +} from '@/components/Search/traceRedMetrics'; +import type { NumberFormat } from '@/types'; + +const base: BuilderChartConfigWithDateRange = { + connection: 'conn', + from: { databaseName: 'default', tableName: 'otel_traces' }, + timestampValueExpression: 'Timestamp', + select: [{ aggFn: 'count', aggCondition: '', valueExpression: '' }], + where: "ServiceName = 'api'", + whereLanguage: 'sql', + filters: [], + dateRange: [new Date(0), new Date(60_000)], + granularity: 'auto', + groupBy: 'StatusCode', +}; + +const ERROR_COND = "lower(StatusCode) IN ('error', 'status_code_error')"; +const DURATION_EXPR = 'Duration'; +const DURATION_FORMAT: NumberFormat = { output: 'duration', factor: 1e-9 }; + +describe('traceRedMetrics', () => { + describe('redBaseConfig', () => { + it('strips the status-code groupBy and preserves filter + time range', () => { + const result = redBaseConfig(base); + expect(result.groupBy).toBeUndefined(); + expect(result.where).toBe(base.where); + expect(result.dateRange).toBe(base.dateRange); + }); + }); + + describe('throughputConfig', () => { + it('counts spans, rendered as bars', () => { + const result = throughputConfig(redBaseConfig(base)); + expect(result.displayType).toBe(DisplayType.StackedBar); + expect(result.numberFormat).toBe(INTEGER_NUMBER_FORMAT); + expect(result.select).toEqual([ + { + alias: 'Spans', + aggFn: 'count', + aggCondition: '', + valueExpression: '', + }, + ]); + // honors the active WHERE filter carried from the base config + expect(result.where).toBe(base.where); + }); + }); + + describe('errorsConfig', () => { + it('rate: count + countIf aggregated separately, divided post-aggregation (MV-friendly)', () => { + const result = errorsConfig(redBaseConfig(base), 'StatusCode', 'rate'); + expect(result).toBeDefined(); + expect(result?.displayType).toBe(DisplayType.Line); + expect(result?.numberFormat).toBe(ERROR_RATE_FORMAT); + // rate aggregates across all statuses, so no per-status groupBy + expect(result?.groupBy).toBeUndefined(); + expect(result?.select).toEqual([ + { + alias: 'total_spans', + aggFn: 'count', + aggCondition: '', + valueExpression: '', + }, + { + alias: 'error_spans', + aggFn: 'count', + aggCondition: ERROR_COND, + aggConditionLanguage: 'sql', + valueExpression: '', + }, + { + alias: 'Error rate', + // guards the empty-bucket 0/0; ratio can't exceed 1 so no clamp + valueExpression: 'if(total_spans > 0, error_spans / total_spans, 0)', + }, + ]); + // the two aggregated counts are the ones hidden from the chart + expect(ERROR_RATE_HELPER_SERIES).toEqual(['total_spans', 'error_spans']); + }); + + it('volume: error spans grouped by status, filtered to the error condition, as bars', () => { + const result = errorsConfig(redBaseConfig(base), 'StatusCode', 'volume'); + expect(result).toBeDefined(); + expect(result?.displayType).toBe(DisplayType.StackedBar); + expect(result?.numberFormat).toBe(INTEGER_NUMBER_FORMAT); + // grouped by status so a clicked bar decodes to a status filter (the + // histogram's drill-down); restricted to errors so it stays errors-only + expect(result?.groupBy).toBe('StatusCode'); + expect(result?.select).toEqual([ + { + alias: 'Errors', + aggFn: 'count', + aggCondition: '', + valueExpression: '', + }, + ]); + expect(result?.filters).toEqual([{ type: 'sql', condition: ERROR_COND }]); + }); + + it('returns undefined when the source has no usable status expression', () => { + expect( + errorsConfig(redBaseConfig(base), undefined, 'rate'), + ).toBeUndefined(); + expect( + errorsConfig(redBaseConfig(base), undefined, 'volume'), + ).toBeUndefined(); + // blank/whitespace is treated as no status expression + expect( + errorsConfig(redBaseConfig(base), ' ', 'volume'), + ).toBeUndefined(); + }); + }); + + describe('durationConfig', () => { + it('aggregates the raw duration column and passes the display format through', () => { + const result = durationConfig( + redBaseConfig(base), + DURATION_EXPR, + DURATION_FORMAT, + ); + expect(result.displayType).toBe(DisplayType.Line); + // no SQL-side unit conversion: the format handles the unit at display + expect(result.numberFormat).toBe(DURATION_FORMAT); + expect(result.select).toEqual([ + { + alias: 'Avg', + aggFn: 'avg', + aggCondition: '', + valueExpression: DURATION_EXPR, + }, + { + alias: 'p95', + aggFn: 'quantile', + level: 0.95, + aggCondition: '', + valueExpression: DURATION_EXPR, + }, + { + alias: 'p99', + aggFn: 'quantile', + level: 0.99, + aggCondition: '', + valueExpression: DURATION_EXPR, + }, + ]); + }); + + // The color pins are resolved by result-column name, so a select alias + // that no longer has a matching key silently falls back to the positional + // palette. Assert the two stay in lockstep rather than trusting that both + // sides get updated together. + it('pins a color for exactly the series it selects', () => { + const { select } = durationConfig( + redBaseConfig(base), + DURATION_EXPR, + DURATION_FORMAT, + ); + // durationConfig always builds a select list, never a raw SQL string + const aliases = Array.isArray(select) + ? select.map(s => ('alias' in s ? s.alias : undefined)) + : []; + expect(aliases).toEqual(Object.keys(DURATION_SERIES_COLORS)); + }); + + // Latency is not an error signal: red is reserved for the Errors tile, and + // the rest of the semantic palette carries its own meaning. The positional + // palette would hand slot 2 to chart-red, so this is the regression guard + // for that specific hue coming back. + it('never colors a latency series with a reserved semantic hue', () => { + const reserved = [ + 'chart-red', + 'chart-error', + 'chart-warning', + 'chart-success', + ]; + expect( + Object.values(DURATION_SERIES_COLORS).filter(token => + reserved.includes(token), + ), + ).toEqual([]); + }); + }); +}); diff --git a/packages/app/src/components/Search/traceRedMetrics.ts b/packages/app/src/components/Search/traceRedMetrics.ts new file mode 100644 index 0000000000..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, + }; +} From 3c58c913275c40c299de82030c39bab9852d7fb8 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:12:27 +0000 Subject: [PATCH 4/7] refactor(app): follow the error predicate rename Picks up errorStatusPredicateSql from the base branch. --- packages/app/src/components/Search/traceRedMetrics.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/Search/traceRedMetrics.ts b/packages/app/src/components/Search/traceRedMetrics.ts index 00b2fe813a..83c57741e1 100644 --- a/packages/app/src/components/Search/traceRedMetrics.ts +++ b/packages/app/src/components/Search/traceRedMetrics.ts @@ -8,7 +8,7 @@ import { ERROR_RATE_PERCENTAGE_NUMBER_FORMAT, INTEGER_NUMBER_FORMAT, } from '@/ChartUtils'; -import { errorPredicateSql } from '@/source'; +import { errorStatusPredicateSql } from '@/source'; import type { NumberFormat } from '@/types'; export type ErrorsMode = 'rate' | 'volume'; @@ -93,7 +93,7 @@ export function errorsConfig( if (!groupBy) { return undefined; } - const errorCondition = errorPredicateSql(groupBy); + const errorCondition = errorStatusPredicateSql(groupBy); if (mode === 'rate') { return { ...base, From 2d6233701f51b12165835e1168162c881c184b7c Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:05:49 +0000 Subject: [PATCH 5/7] refactor(app): name the duration-heatmap number format The significant-fields heatmap built { output: 'duration', factor: 0.001 } inline. The factor is only correct next to getDurationMsExpression, which applies the source's durationPrecision and leaves the value in ms, so give it a name in source.ts and document that pairing. No behavior change. --- .../src/components/Search/DBSearchHeatmapChart.tsx | 11 +++++------ packages/app/src/source.ts | 12 ++++++++++++ 2 files changed, 17 insertions(+), 6 deletions(-) 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/source.ts b/packages/app/src/source.ts index 9293ca7143..ee71013907 100644 --- a/packages/app/src/source.ts +++ b/packages/app/src/source.ts @@ -484,6 +484,18 @@ 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}`; } From 8bb3d027d4ccffa8ef31eae54492870894f7762c Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:16:13 +0000 Subject: [PATCH 6/7] feat(app): add a duration heatmap view to trace search Adds a RED/Heatmap switch to the trace results chart area. Heatmap renders the duration distribution over time using the same tile the dashboard does. The mode is URL state so a reload or shared link reopens the same view, and it resets to RED when the source changes. DBHeatmapChart takes the queryKeyPrefix prop DBTimeChart already had, so the heatmap queries sit in the same namespace as the RED tiles and live tail waits on them instead of stacking a fresh pair each tick. --- .changeset/trace-search-duration-heatmap.md | 5 ++ packages/app/src/DBSearchPage.tsx | 60 ++++++++++++++++-- .../app/src/components/DBHeatmapChart.tsx | 13 +++- .../Search/TraceRedMetricsChart.tsx | 62 +++++++++++++++++-- 4 files changed, 129 insertions(+), 11 deletions(-) create mode 100644 .changeset/trace-search-duration-heatmap.md 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/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 4bfb73976e..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,7 +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 { TraceRedMetricsChart } from './components/Search/TraceRedMetricsChart'; +import { + type TraceChartMode, + TraceRedMetricsChart, +} from './components/Search/TraceRedMetricsChart'; import SourceSchemaPreview, { isSourceSchemaPreviewEnabled, } from './components/SourceSchemaPreview'; @@ -1069,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, @@ -1883,10 +1895,10 @@ export function DBSearchPage() { const aliasWith = useMemo(() => aliasMapToWithClauses(aliasMap), [aliasMap]); - // The trace results chart shows RED metrics only for a trace source that - // exposes a duration column; otherwise the single histogram stays. Derived - // once, as the narrowed source or null, so the callers can't drift apart on - // which one renders. + // 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) && @@ -1894,6 +1906,23 @@ export function DBSearchPage() { ? 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; @@ -2638,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 && ( effectiveMin, }); diff --git a/packages/app/src/components/Search/TraceRedMetricsChart.tsx b/packages/app/src/components/Search/TraceRedMetricsChart.tsx index cea4fe8607..4f9f2041e4 100644 --- a/packages/app/src/components/Search/TraceRedMetricsChart.tsx +++ b/packages/app/src/components/Search/TraceRedMetricsChart.tsx @@ -7,8 +7,15 @@ 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 { getTraceDurationNumberFormat } from '@/source'; +import { + DURATION_HEATMAP_NUMBER_FORMAT, + getDurationMsExpression, + getTraceDurationNumberFormat, +} from '@/source'; import { DURATION_SERIES_COLORS, @@ -20,6 +27,8 @@ import { 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 @@ -35,22 +44,30 @@ const RED_TILE_STYLE = { * 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. + * 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; @@ -63,7 +80,8 @@ export function TraceRedMetricsChart({ 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. + // 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 @@ -76,6 +94,7 @@ export function TraceRedMetricsChart({ }), [source, durationExpression], ); + const durationMsExpression = getDurationMsExpression(source); const base = useMemo( () => redBaseConfig(histogramTimeChartConfig), @@ -90,6 +109,30 @@ export function TraceRedMetricsChart({ () => 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 = ( @@ -167,5 +210,16 @@ export function TraceRedMetricsChart({ + ) : ( + + + ); } From 46b3d9ec7c378c1277efd88662f60768f834e78d Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:08:52 +0000 Subject: [PATCH 7/7] fix(app): pin live tail off while the trace heatmap is visible The heatmap keys its uPlot instance on the chart config, so a live-tail tick (which moves the absolute time range) tore the chart down and rebuilt it with a recomputed y-axis every 10s, blanking it to Loading in between. Delta and pattern modes already pin live tail off, which is why the delta-view heatmap never hit this. Results mode defaults to live, so the heatmap view opts out for itself while it is the rendered chart. --- packages/app/src/DBSearchPage.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 813e2b6f85..43dffbbbaf 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -1923,6 +1923,25 @@ export function DBSearchPage() { 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;