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