Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/event-source-formulas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@hyperdx/common-utils': minor
'@hyperdx/app': minor
---

Formulas now work on log and trace sources, not just metrics. Time series, table and number charts on event sources can define derived series via letter-ref arithmetic expressions (e.g. `A / B * 100`), with the same editor controls (Add Formula, series letter badges, Show input series) previously offered only on metric sources. Event formulas compile inline into the chart's single-scan SELECT — no per-series query fan-out — with the same missing-data semantics as the existing events ratio toggle.
58 changes: 56 additions & 2 deletions packages/app/src/components/ChartEditor/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ const traceSource: TSource = {
durationPrecision: 9,
};

const sessionSource: TSource = {
id: 'source-session',
name: 'Session Source',
kind: SourceKind.Session,
connection: 'conn-1',
from: { databaseName: 'db', tableName: 'sessions' },
timestampValueExpression: 'Timestamp',
traceSourceId: 'source-trace',
};

const seriesItem = {
aggFn: 'count' as const,
valueExpression: '*',
Expand Down Expand Up @@ -1746,7 +1756,7 @@ describe('metric formulas (HDX-5080)', () => {
]);
});

it('skips formula validation for non-metric sources (formulas are stripped on save)', () => {
it('validates formulas for log event sources', () => {
const setError = jest.fn();
const errors = validateChartForm(
makeMetricForm({
Expand All @@ -1757,6 +1767,36 @@ describe('metric formulas (HDX-5080)', () => {
logSource,
setError,
);
expect(errors).toEqual([
expect.objectContaining({ path: 'formulas.0.expression' }),
]);
});

it('accepts a valid formula on a trace source', () => {
const setError = jest.fn();
const errors = validateChartForm(
makeMetricForm({
source: 'source-trace',
series: [seriesItem, seriesItem],
formulas: [{ expression: 'A / B * 100' }],
}),
traceSource,
setError,
);
expect(errors).toHaveLength(0);
});

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

Expand Down Expand Up @@ -1897,7 +1937,7 @@ describe('metric formulas (HDX-5080)', () => {
expect(saved.showOperandSeries).toBeUndefined();
});

it('strips formulas for non-metric sources', () => {
it('retains formulas for a log event source', () => {
const saved = savedBuilderConfig(
makeMetricForm({
source: 'source-log',
Expand All @@ -1907,6 +1947,20 @@ describe('metric formulas (HDX-5080)', () => {
}),
logSource,
);
expect(saved.formulas).toEqual([{ expression: 'A * 100' }]);
expect(saved.showOperandSeries).toBe(false);
});

it('strips formulas for formula-incapable source kinds', () => {
const saved = savedBuilderConfig(
makeMetricForm({
source: 'source-session',
series: [seriesItem],
formulas: [{ expression: 'A * 100' }],
showOperandSeries: false,
}),
sessionSource,
);
expect(saved.formulas).toBeUndefined();
expect(saved.showOperandSeries).toBeUndefined();
});
Expand Down
43 changes: 28 additions & 15 deletions packages/app/src/components/ChartEditor/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,13 @@ function normalizeChartConfig<
>,
>(config: C, source: TSource): C {
const isMetricSource = source.kind === SourceKind.Metric;
// Formulas (HDX-5080) only render on metric sources, and only through the
// composed multi-series metric query shapes (time series / table / number).
// Strip them elsewhere so a source or display-type switch can't persist a
// config the renderer would reject. The form state keeps them, so switching
// back restores the formula rows.
// Formulas (HDX-5080) render on metric and event (log/trace) sources, and
// only on the display types the formula query paths support (time series /
// table / number). Strip them elsewhere so a source or display-type switch
// can't persist a config the renderer would reject. The form state keeps
// them, so switching back restores the formula rows.
const keepFormulas =
isMetricSource &&
isFormulaSourceKind(source.kind) &&
(config.formulas?.length ?? 0) > 0 &&
isFormulaDisplayType(config.displayType);
return {
Expand Down Expand Up @@ -174,9 +174,9 @@ const isCustomOrderByDisplayType = (
displayType === DisplayType.Pie;

/**
* Display types that can carry metric formulas (HDX-5080) — the shapes the
* composed multi-series metric query renders. Mirrors the "Add Formula"
* gating in ChartEditorControls.
* Display types that can carry formulas (HDX-5080) — the shapes the formula
* query paths render (composed multi-series for metrics, inline single-scan
* for events). Mirrors the "Add Formula" gating in ChartEditorControls.
*/
export const isFormulaDisplayType = (
displayType: DisplayType | undefined,
Expand All @@ -190,6 +190,19 @@ export const isFormulaDisplayType = (
displayType === DisplayType.Table ||
displayType === DisplayType.Number;

/**
* Source kinds that can carry formulas: metric sources (rendered via the
* composed multi-series metric query) and event sources (log/trace, compiled
* inline in the single-scan SELECT — see renderSelectListWithFormulas in
* common-utils).
*/
export const isFormulaSourceKind = (
kind: SourceKind | undefined,
): kind is SourceKind.Metric | SourceKind.Log | SourceKind.Trace =>
kind === SourceKind.Metric ||
kind === SourceKind.Log ||
kind === SourceKind.Trace;

export function convertFormStateToSavedChartConfig(
form: ChartEditorFormState,
source: TSource | undefined,
Expand Down Expand Up @@ -485,13 +498,13 @@ export const validateChartForm = (
});
}

// Validate metric formulas (HDX-5080) with the structured validator the
// query renderer uses, so a bad expression is caught here rather than at
// render time. Only applies where formulas survive normalization (metric
// source + formula-capable display type).
// Validate formulas (HDX-5080) with the structured validator the query
// renderer uses, so a bad expression is caught here rather than at render
// time. Only applies where formulas survive normalization (formula-capable
// source kind + display type).
if (
!isRawSqlChart &&
source?.kind === SourceKind.Metric &&
isFormulaSourceKind(source?.kind) &&
isFormulaDisplayType(form.displayType) &&
Array.isArray(form.formulas)
) {
Expand Down Expand Up @@ -578,7 +591,7 @@ export const validateChartForm = (
!isRawSqlChart &&
Array.isArray(form.series) &&
form.displayType === DisplayType.Number &&
!(form.formulas?.length && source?.kind === SourceKind.Metric) &&
!(form.formulas?.length && isFormulaSourceKind(source?.kind)) &&
form.series.length > (form.seriesReturnType === 'ratio' ? 2 : 1)
) {
errors.push({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ import {
ChartEditorFormState,
SavedChartConfigWithSelectArray,
} from '@/components/ChartEditor/types';
import { isFormulaDisplayType } from '@/components/ChartEditor/utils';
import {
isFormulaDisplayType,
isFormulaSourceKind,
} from '@/components/ChartEditor/utils';
import MVOptimizationIndicator from '@/components/MaterializedViews/MVOptimizationIndicator';
import SearchWhereInput from '@/components/SearchInput/SearchWhereInput';
import SourceSchemaPreview, {
Expand Down Expand Up @@ -110,10 +113,10 @@ export function ChartEditorControls({
openDisplaySettings,
openHeatmapSettings,
}: ChartEditorControlsProps) {
// Metric formulas (HDX-5080): derived series computed from the chart's
// series via letter-ref arithmetic expressions. Metric sources only, and
// only on display types the composed multi-series metric query renders
// (time series / table / number).
// Formulas (HDX-5080): derived series computed from the chart's series via
// letter-ref arithmetic expressions. Metric and event (log/trace) sources,
// and only on display types the formula query paths render (time series /
// table / number).
const {
fields: formulaFields,
append: appendFormula,
Expand All @@ -122,8 +125,7 @@ export function ChartEditorControls({
const hasFormulas = formulaFields.length > 0;
const showOperandSeries = useWatch({ control, name: 'showOperandSeries' });
const sourceSupportsFormulas =
tableSource?.kind === SourceKind.Metric &&
isFormulaDisplayType(displayType);
isFormulaSourceKind(tableSource?.kind) && isFormulaDisplayType(displayType);
// Formulas and the ratio toggle are mutually exclusive (formulas supersede
// ratio in the renderer, so the editor never lets both be set). Number
// tiles display a single value, so they take exactly one formula.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
ChartEditorFormState,
SavedChartConfigWithSelectArray,
} from '@/components/ChartEditor/types';
import { isFormulaSourceKind } from '@/components/ChartEditor/utils';
import {
CheckBoxControlled,
TextInputControlled,
Expand Down Expand Up @@ -258,9 +259,9 @@ export function ChartSeriesEditor({
<Group gap="xs">
{/* Formula series reference (HDX-5080): formulas address series
positionally by letter (`A` = series 1, ...), so surface the
letter on each row. Metric sources only — formulas are only
supported there. */}
{tableSource?.kind === SourceKind.Metric && (
letter on each row of formula-capable sources (metric and
log/trace events). */}
{isFormulaSourceKind(tableSource?.kind) && (
<Tooltip label="Reference this series in a formula by this letter">
<Badge
size="sm"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,13 @@ jest.mock('@/components/SearchInput/SearchInputV2', () => ({
default: () => <div>Search Input</div>,
}));

// The sample-events panel (rendered for event sources) reads Next router
// query state via nuqs, which isn't mounted in this test environment.
jest.mock('@/components/DBSqlRowTableWithSidebar', () => ({
__esModule: true,
default: () => <div>SQL Row Table</div>,
}));

jest.mock('../../MaterializedViews/MVOptimizationIndicator', () => ({
__esModule: true,
default: () => <div>MV Indicator</div>,
Expand Down Expand Up @@ -907,7 +914,8 @@ describe('DBEditTimeChartForm - Metric formulas', () => {
expect(onSave.mock.calls[0][0].showOperandSeries).toBe(false);
});

it('does not show formula controls for non-metric sources', () => {
it('shows formula controls and series letter badges for log event sources', async () => {
const onSave = jest.fn();
mockUseSourceData({
id: 'log-source',
kind: SourceKind.Log,
Expand All @@ -917,10 +925,55 @@ describe('DBEditTimeChartForm - Metric formulas', () => {
timestampValueExpression: 'Timestamp',
});

const countSeries = {
aggFn: 'count' as const,
aggCondition: '',
aggConditionLanguage: 'lucene' as const,
valueExpression: '',
};
renderComponent({
chartConfig: {
...defaultChartConfig,
source: 'log-source',
select: [
{ ...countSeries, aggCondition: 'SeverityText:error' },
countSeries,
],
},
onSave,
});

const badges = screen.getAllByTestId('series-ref-badge');
expect(badges.map(b => b.textContent)).toEqual(['A', 'B']);

await userEvent.click(screen.getByTestId('add-formula-button'));
await userEvent.type(
screen.getByTestId('formula-expression-input'),
'A / B * 100',
);
await userEvent.click(screen.getByTestId('chart-save-button'));

await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave.mock.calls[0][0].formulas).toEqual([
{ expression: 'A / B * 100', alias: '' },
]);
});

it('does not show formula controls for formula-incapable source kinds', () => {
mockUseSourceData({
id: 'session-source',
kind: SourceKind.Session,
name: 'Sessions',
from: { databaseName: 'default', tableName: 'sessions' },
connection: 'default',
timestampValueExpression: 'Timestamp',
traceSourceId: 'trace-source',
});

renderComponent({
chartConfig: {
...defaultChartConfig,
source: 'session-source',
select: [
{
aggFn: 'count',
Expand Down
14 changes: 6 additions & 8 deletions packages/app/src/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,18 +607,16 @@ interface ResolvedNumberFormats {
}

/**
* The formula projection settings of a builder metric config, or undefined
* when formulas don't apply (non-builder configs, non-metric sources, or no
* formulas configured).
* The formula projection settings of a builder config, or undefined when
* formulas don't apply (non-builder configs or no formulas configured).
* Source-kind agnostic: metric formulas render through the composed
* multi-series query and event (log/trace) formulas compile inline, but both
* project the same column shape (operands unless hidden, then formulas).
*/
function getFormulaConfig(
config: ChartConfig | ChartConfigWithOptDateRange,
): { formulas: MetricFormula[]; operandsHidden: boolean } | undefined {
if (
!isBuilderChartConfig(config) ||
config.metricTables == null ||
!config.formulas?.length
) {
if (!isBuilderChartConfig(config) || !config.formulas?.length) {
return undefined;
}
return {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing

exports[`renderBuilderConfigAsSqlTemplate compiles event formulas into the single-scan template projection 1`] = `
"SELECT
countIf(ServiceName = 'api') AS "api",
count() AS "total",
(
(
coalesce(countIf(ServiceName = 'api'), 0) / nullif(coalesce(count(), 0), 0)
) * 100
) AS "pct",
$__timeInterval(timestamp) AS \`__hdx_time_bucket\`
FROM
$__sourceTable
WHERE
(
timestamp >= $__fromTime_ms
AND timestamp <= $__toTime_ms
)
AND ((ServiceName ILIKE '%api%'))
AND $__filters
GROUP BY
$__timeInterval(timestamp) AS \`__hdx_time_bucket\`
ORDER BY
$__timeInterval(timestamp) AS \`__hdx_time_bucket\`"
`;

exports[`renderBuilderConfigAsSqlTemplate full SQL output per display type generates the full macro-based SQL for a line chart 1`] = `
"WITH
\`__hdx_series_limit\` AS (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ exports[`renderChartConfig containing CTE clauses should render a ChSql CTE conf

exports[`renderChartConfig containing CTE clauses should render a chart config CTE configuration correctly 1`] = `"WITH Parts AS (SELECT _part, _part_offset FROM default.some_table WHERE ((FieldA = 'test')) ORDER BY rand() DESC LIMIT 1000 SETTINGS optimize_read_in_order = 0, cast_keep_nullable = 1, additional_result_filter = 'x != 2', count_distinct_implementation = 'uniqCombined64', async_insert_busy_timeout_min_ms = 20000) SELECT * FROM Parts WHERE ((FieldA = 'test') AND (indexHint((_part, _part_offset) IN (SELECT tuple(_part, _part_offset) FROM Parts)))) ORDER BY rand() DESC LIMIT 1000 SETTINGS optimize_read_in_order = 0, cast_keep_nullable = 1, additional_result_filter = 'x != 2', count_distinct_implementation = 'uniqCombined64', async_insert_busy_timeout_min_ms = 20000"`;

exports[`renderChartConfig event (log/trace) formula charts (inline single-query) appends the compiled formula column after the operand columns in one scan 1`] = `"SELECT countIf(SeverityText = 'error') AS "errors",count() AS "total",((coalesce(countIf(SeverityText = 'error'), 0) / nullif(coalesce(count(), 0), 0)) * 100) AS "Error rate",toStartOfInterval(toDateTime(timestamp), INTERVAL 1 minute) AS \`__hdx_time_bucket\` FROM default.otel_logs WHERE (timestamp >= fromUnixTimestamp64Milli(1739318400000) AND timestamp <= fromUnixTimestamp64Milli(1739491200000)) GROUP BY toStartOfInterval(toDateTime(timestamp), INTERVAL 1 minute) AS \`__hdx_time_bucket\` ORDER BY toStartOfInterval(toDateTime(timestamp), INTERVAL 1 minute) AS \`__hdx_time_bucket\` SETTINGS optimize_read_in_order = 0, cast_keep_nullable = 1, additional_result_filter = 'x != 2', count_distinct_implementation = 'uniqCombined64', async_insert_busy_timeout_min_ms = 20000"`;

exports[`renderChartConfig histogram metric queries count should generate a count query with grouping and time bucketing 1`] = `
"WITH source AS (
SELECT
Expand Down
Loading
Loading