Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/metric-formula-editor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperdx/app': minor
---

Add metric formula editing to the chart editor. Metric-source charts (time series, table, number) gain an "Add Formula" row: a letter-ref arithmetic expression over the chart's series (`A` = series 1, `B` = series 2, ...) such as `A / (A + B) * 100`, with inline structured validation (malformed expressions, unknown series references), per-formula alias and number format, and a "Show input series" toggle to render only the formula column(s) or the formula alongside its operand series. Series rows now carry their reference letter as a badge. Formulas and the "As Ratio" toggle are mutually exclusive, and formulas persist on dashboard tiles and standalone charts.
2 changes: 1 addition & 1 deletion packages/app/src/__tests__/SessionSidePanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const mockNuqs: {

jest.mock('nuqs', () => {
const actual = jest.requireActual('nuqs');
// eslint-disable-next-line @typescript-eslint/no-empty-function

const noop = () => {};
return {
...actual,
Expand Down
123 changes: 123 additions & 0 deletions packages/app/src/__tests__/source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { notifications } from '@mantine/notifications';
import { renderHook } from '@testing-library/react';

import {
getBuilderValueColumnCount,
getEventBody,
getSourceValidationNotificationId,
getTraceDurationNumberFormat,
Expand Down Expand Up @@ -230,6 +231,14 @@ describe('useSources validation notifications', () => {
});
});

const METRIC_TABLES = {
gauge: 'otel_metrics_gauge',
sum: 'otel_metrics_sum',
histogram: 'otel_metrics_histogram',
summary: 'otel_metrics_summary',
'exponential histogram': 'otel_metrics_exponential_histogram',
};

const DURATION_FORMAT: NumberFormat = { output: 'duration', factor: 1e-9 };
const CURRENCY_FORMAT: NumberFormat = { output: 'currency' };
const PERCENT_FORMAT: NumberFormat = { output: 'percent' };
Expand Down Expand Up @@ -521,4 +530,118 @@ describe('useChartNumberFormats', () => {
);
expect(result.current.formatByColumn.size).toBe(0);
});

// --- metric formula configs (HDX-5080) ---
// The composed metric query projects operand series columns (unless
// showOperandSeries is false) followed by one column per formula.

const META_FORMULA = { name: 'A / B', type: 'Float64' } as ColumnMetaType;

const makeFormulaConfig = (
overrides: Partial<ChartConfigWithOptTimestamp> = {},
) =>
makeBuilderConfig({
metricTables: METRIC_TABLES,
select: [
{ valueExpression: 'Value', numberFormat: NUMBER_FORMAT },
{ valueExpression: 'Value' },
],
formulas: [{ expression: 'A / B', numberFormat: PERCENT_FORMAT }],
...overrides,
});

it('formula config: maps operand columns then formula columns positionally', () => {
const config = makeFormulaConfig();
const { result } = renderHook(() =>
useChartNumberFormats(config, [META_A, META_B, META_FORMULA]),
);
expect(Array.from(result.current.formatByColumn.entries())).toEqual([
['col_a', NUMBER_FORMAT],
['A / B', PERCENT_FORMAT],
]);
});

it('formula config: maps only formula columns when operand series are hidden', () => {
const config = makeFormulaConfig({ showOperandSeries: false });
const { result } = renderHook(() =>
useChartNumberFormats(config, [META_FORMULA]),
);
expect(Array.from(result.current.formatByColumn.entries())).toEqual([
['A / B', PERCENT_FORMAT],
]);
});

it('formula config: formula columns fall back to config.numberFormat', () => {
const config = makeFormulaConfig({
formulas: [{ expression: 'A / B' }],
numberFormat: CURRENCY_FORMAT,
showOperandSeries: false,
});
const { result } = renderHook(() =>
useChartNumberFormats(config, [META_FORMULA]),
);
expect(result.current.formatByColumn.get('A / B')).toEqual(CURRENCY_FORMAT);
});

it('formula config: formula supersedes ratio mapping when both are set', () => {
const config = makeFormulaConfig({ seriesReturnType: 'ratio' });
const { result } = renderHook(() =>
useChartNumberFormats(config, [META_A, META_B, META_FORMULA]),
);
// The ratio branch would map only meta[0]; the formula branch maps
// operands + formula columns.
expect(result.current.formatByColumn.get('A / B')).toEqual(PERCENT_FORMAT);
});

it('formula config: chartFormat prefers formula format when operands are hidden', () => {
const config = makeFormulaConfig({ showOperandSeries: false });
const { result } = renderHook(() => useChartNumberFormats(config));
expect(result.current.chartFormat).toEqual(PERCENT_FORMAT);
});

it('formula config: chartFormat prefers series format when operands are shown', () => {
const config = makeFormulaConfig();
const { result } = renderHook(() => useChartNumberFormats(config));
expect(result.current.chartFormat).toEqual(NUMBER_FORMAT);
});
});

describe('getBuilderValueColumnCount', () => {
it('counts one column per select entry by default', () => {
const config = makeBuilderConfig({
select: [{ valueExpression: 'count()' }, { valueExpression: 'sum(x)' }],
});
expect(getBuilderValueColumnCount(config)).toBe(2);
});

it('counts one merged column for ratio configs', () => {
const config = makeBuilderConfig({
seriesReturnType: 'ratio',
select: [{ valueExpression: 'count()' }, { valueExpression: 'sum(x)' }],
});
expect(getBuilderValueColumnCount(config)).toBe(1);
});

it('counts operand and formula columns for metric formula configs', () => {
const config = makeBuilderConfig({
metricTables: METRIC_TABLES,
select: [{ valueExpression: 'Value' }, { valueExpression: 'Value' }],
formulas: [{ expression: 'A / B' }],
});
expect(getBuilderValueColumnCount(config)).toBe(3);
});

it('counts only formula columns when operand series are hidden', () => {
const config = makeBuilderConfig({
metricTables: METRIC_TABLES,
select: [{ valueExpression: 'Value' }, { valueExpression: 'Value' }],
formulas: [{ expression: 'A / B' }, { expression: 'A + B' }],
showOperandSeries: false,
});
expect(getBuilderValueColumnCount(config)).toBe(2);
});

it('returns 0 for raw SQL configs', () => {
expect(getBuilderValueColumnCount(makeRawSqlConfig({}))).toBe(0);
});
});
230 changes: 230 additions & 0 deletions packages/app/src/components/ChartEditor/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1619,3 +1619,233 @@ describe('heatmap round-trip', () => {
);
});
});

describe('metric formulas (HDX-5080)', () => {
const metricSeriesItem = {
aggFn: 'avg' as const,
valueExpression: 'Value',
aggCondition: '',
aggConditionLanguage: 'lucene' as const,
metricType: MetricsDataType.Gauge,
metricName: 'cpu.usage',
};

const makeMetricForm = (
overrides: Partial<ChartEditorFormState>,
): ChartEditorFormState => ({
displayType: DisplayType.Line,
source: 'source-metric',
where: '',
series: [
metricSeriesItem,
{ ...metricSeriesItem, metricName: 'cpu.limit' },
],
...overrides,
});

describe('validateChartForm', () => {
it('accepts a valid formula referencing existing series', () => {
const setError = jest.fn();
const errors = validateChartForm(
makeMetricForm({
formulas: [{ expression: 'A / (A + B) * 100' }],
}),
metricSource,
setError,
);
expect(errors).toHaveLength(0);
expect(setError).not.toHaveBeenCalled();
});

it('reports a malformed formula expression at its field path', () => {
const setError = jest.fn();
const errors = validateChartForm(
makeMetricForm({ formulas: [{ expression: 'A +' }] }),
metricSource,
setError,
);
expect(errors).toEqual([
expect.objectContaining({ path: 'formulas.0.expression' }),
]);
expect(setError).toHaveBeenCalledWith(
'formulas.0.expression',
expect.objectContaining({ type: 'manual' }),
);
});

it('reports an unknown series reference', () => {
const setError = jest.fn();
const errors = validateChartForm(
makeMetricForm({ formulas: [{ expression: 'A + C' }] }),
metricSource,
setError,
);
expect(errors).toEqual([
expect.objectContaining({
path: 'formulas.0.expression',
message: expect.stringContaining('Unknown series "C"'),
}),
]);
});

it('reports an empty formula expression', () => {
const setError = jest.fn();
const errors = validateChartForm(
makeMetricForm({ formulas: [{ expression: '' }] }),
metricSource,
setError,
);
expect(errors).toEqual([
expect.objectContaining({ path: 'formulas.0.expression' }),
]);
});

it('validates each formula independently', () => {
const setError = jest.fn();
const errors = validateChartForm(
makeMetricForm({
formulas: [{ expression: 'A + B' }, { expression: 'A *' }],
}),
metricSource,
setError,
);
expect(errors).toEqual([
expect.objectContaining({ path: 'formulas.1.expression' }),
]);
});

it('skips formula validation for non-metric sources (formulas are stripped on save)', () => {
const setError = jest.fn();
const errors = validateChartForm(
makeMetricForm({
source: 'source-log',
series: [seriesItem],
formulas: [{ expression: 'A +' }],
}),
logSource,
setError,
);
expect(errors).toHaveLength(0);
});

it('skips formula validation for non-formula display types (formulas are stripped on save)', () => {
const setError = jest.fn();
const errors = validateChartForm(
makeMetricForm({
displayType: DisplayType.Pie,
series: [metricSeriesItem],
formulas: [{ expression: 'A +' }],
}),
metricSource,
setError,
);
expect(errors).toHaveLength(0);
});

it('lifts the Number chart series cap when formulas are present', () => {
const setError = jest.fn();
const errors = validateChartForm(
makeMetricForm({
displayType: DisplayType.Number,
series: [metricSeriesItem, metricSeriesItem, metricSeriesItem],
formulas: [{ expression: 'A / (A + B + C) * 100' }],
showOperandSeries: false,
}),
metricSource,
setError,
);
expect(errors).toHaveLength(0);
});

it('keeps the Number chart series cap without formulas', () => {
const setError = jest.fn();
const errors = validateChartForm(
makeMetricForm({
displayType: DisplayType.Number,
series: [metricSeriesItem, metricSeriesItem, metricSeriesItem],
}),
metricSource,
setError,
);
expect(errors).toEqual([expect.objectContaining({ path: 'series' })]);
});
});

describe('normalization (convertFormStateToSavedChartConfig)', () => {
// Narrow the SavedChartConfig union without an unsafe assertion — every
// form here is a builder config, so anything else is a test failure.
const savedBuilderConfig = (
form: ChartEditorFormState,
source: TSource,
): BuilderSavedChartConfig => {
const saved = convertFormStateToSavedChartConfig(form, source);
if (!saved || 'configType' in saved) {
throw new Error('expected a builder saved chart config');
}
return saved;
};

it('retains formulas and showOperandSeries for a metric time series chart', () => {
const saved = savedBuilderConfig(
makeMetricForm({
formulas: [{ expression: 'A / B', alias: 'Ratio' }],
showOperandSeries: false,
}),
metricSource,
);
expect(saved.formulas).toEqual([{ expression: 'A / B', alias: 'Ratio' }]);
expect(saved.showOperandSeries).toBe(false);
});

it('strips formulas for non-metric sources', () => {
const saved = savedBuilderConfig(
makeMetricForm({
source: 'source-log',
series: [seriesItem],
formulas: [{ expression: 'A * 100' }],
showOperandSeries: false,
}),
logSource,
);
expect(saved.formulas).toBeUndefined();
expect(saved.showOperandSeries).toBeUndefined();
});

it('strips formulas for display types the composed metric query does not render', () => {
const saved = savedBuilderConfig(
makeMetricForm({
displayType: DisplayType.Pie,
series: [metricSeriesItem],
formulas: [{ expression: 'A * 100' }],
}),
metricSource,
);
expect(saved.formulas).toBeUndefined();
expect(saved.showOperandSeries).toBeUndefined();
});

it('round-trips formulas through saved config and form state', () => {
const saved = convertFormStateToSavedChartConfig(
makeMetricForm({
formulas: [
{
expression: 'A / (A + B) * 100',
alias: 'Error rate',
numberFormat: { output: 'percent' },
},
],
}),
metricSource,
);
expect(saved).toBeDefined();
const restored = convertSavedChartConfigToFormState(saved!);
expect(restored.formulas).toEqual([
{
expression: 'A / (A + B) * 100',
alias: 'Error rate',
numberFormat: { output: 'percent' },
},
]);
});
});
});
Loading
Loading