feat: compute charts ratio (#756)

Ref: HDX-1607

Support ratio for both events and metrics charts

<img width="1504" alt="image" src="https://github.com/user-attachments/assets/e563c609-b48f-4217-bf6a-b11c5e075435" />
This commit is contained in:
Warren 2025-04-16 21:50:19 -07:00 committed by GitHub
parent 35a1c31658
commit b16c8e1429
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 240 additions and 3 deletions

View file

@ -0,0 +1,6 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/app": patch
---
feat: compute charts ratio

View file

@ -16,6 +16,7 @@
"lint:styles": "stylelint **/*/*.{css,scss}",
"ci:lint": "yarn lint && yarn tsc --noEmit && yarn lint:styles --quiet",
"ci:unit": "jest --ci --coverage",
"dev:unit": "jest --watchAll --detectOpenHandles",
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build",
"knip": "knip"

View file

@ -33,6 +33,7 @@ import {
Group,
Paper,
Stack,
Switch,
Tabs,
Text,
Textarea,
@ -338,6 +339,7 @@ export default function EditTimeChartForm({
const sourceId = watch('source');
const whereLanguage = watch('whereLanguage');
const alert = watch('alert');
const seriesReturnType = watch('seriesReturnType');
const { data: tableSource } = useSource({ id: sourceId });
const databaseName = tableSource?.from.databaseName;
@ -632,6 +634,22 @@ export default function EditTimeChartForm({
Add Series
</Button>
)}
{select.length == 2 && displayType !== DisplayType.Number && (
<Switch
label="As Ratio"
size="sm"
color="gray"
variant="subtle"
onClick={() => {
setValue(
'seriesReturnType',
seriesReturnType === 'ratio' ? 'column' : 'ratio',
);
onSubmit();
}}
checked={seriesReturnType === 'ratio'}
/>
)}
{displayType === DisplayType.Line &&
dashboardId &&
!IS_LOCAL_MODE && (

View file

@ -0,0 +1,135 @@
import { ResponseJSON } from '@clickhouse/client-web';
import { computeRatio, computeResultSetRatio } from '../useChartConfig';
describe('computeRatio', () => {
it('should correctly compute ratio of two numbers', () => {
expect(computeRatio('10', '2')).toBe(5);
expect(computeRatio('3', '4')).toBe(0.75);
expect(computeRatio('0', '5')).toBe(0);
});
it('should return NaN when denominator is zero', () => {
expect(isNaN(computeRatio('10', '0'))).toBe(true);
});
it('should return NaN for non-numeric inputs', () => {
expect(isNaN(computeRatio('abc', '2'))).toBe(true);
expect(isNaN(computeRatio('10', 'xyz'))).toBe(true);
expect(isNaN(computeRatio('abc', 'xyz'))).toBe(true);
expect(isNaN(computeRatio('', '5'))).toBe(true);
});
it('should handle string representations of numbers', () => {
expect(computeRatio('10.5', '2')).toBe(5.25);
expect(computeRatio('-10', '5')).toBe(-2);
expect(computeRatio('10', '-5')).toBe(-2);
});
it('should handle number input types', () => {
expect(computeRatio(10, 2)).toBe(5);
expect(computeRatio(3, 4)).toBe(0.75);
expect(computeRatio(10.5, 2)).toBe(5.25);
expect(computeRatio(0, 5)).toBe(0);
expect(isNaN(computeRatio(10, 0))).toBe(true);
expect(computeRatio(-10, 5)).toBe(-2);
});
it('should handle mixed string and number inputs', () => {
expect(computeRatio('10', 2)).toBe(5);
expect(computeRatio(10, '2')).toBe(5);
expect(computeRatio(3, '4')).toBe(0.75);
expect(isNaN(computeRatio(10, ''))).toBe(true);
});
});
describe('computeResultSetRatio', () => {
it('should compute ratio for a valid result set with timestamp column', () => {
const mockResultSet: ResponseJSON<any> = {
meta: [
{ name: 'timestamp', type: 'DateTime' },
{ name: 'requests', type: 'UInt64' },
{ name: 'errors', type: 'UInt64' },
],
data: [
{ timestamp: '2025-04-15 10:00:00', requests: '100', errors: '10' },
{ timestamp: '2025-04-15 11:00:00', requests: '200', errors: '20' },
],
rows: 2,
statistics: { elapsed: 0.1, rows_read: 2, bytes_read: 100 },
};
const result = computeResultSetRatio(mockResultSet);
expect(result.meta.length).toBe(2);
expect(result.meta[0].name).toBe('requests/errors');
expect(result.meta[0].type).toBe('Float64');
expect(result.meta[1].name).toBe('timestamp');
expect(result.data.length).toBe(2);
expect(result.data[0]['requests/errors']).toBe(10);
expect(result.data[0].timestamp).toBe('2025-04-15 10:00:00');
expect(result.data[1]['requests/errors']).toBe(10);
expect(result.data[1].timestamp).toBe('2025-04-15 11:00:00');
});
it('should compute ratio for a valid result set without timestamp column', () => {
const mockResultSet: ResponseJSON<any> = {
meta: [
{ name: 'requests', type: 'UInt64' },
{ name: 'errors', type: 'UInt64' },
],
data: [{ requests: '100', errors: '10' }],
rows: 1,
statistics: { elapsed: 0.1, rows_read: 1, bytes_read: 50 },
};
const result = computeResultSetRatio(mockResultSet);
expect(result.meta.length).toBe(1);
expect(result.meta[0].name).toBe('requests/errors');
expect(result.meta[0].type).toBe('Float64');
expect(result.data.length).toBe(1);
expect(result.data[0]['requests/errors']).toBe(10);
expect(result.data[0].timestamp).toBeUndefined();
});
it('should handle NaN values in ratio computation', () => {
const mockResultSet: ResponseJSON<any> = {
meta: [
{ name: 'timestamp', type: 'DateTime' },
{ name: 'requests', type: 'UInt64' },
{ name: 'errors', type: 'UInt64' },
],
data: [
{ timestamp: '2025-04-15 10:00:00', requests: '100', errors: '0' },
{ timestamp: '2025-04-15 11:00:00', requests: 'invalid', errors: '20' },
],
rows: 2,
statistics: { elapsed: 0.1, rows_read: 2, bytes_read: 100 },
};
const result = computeResultSetRatio(mockResultSet);
expect(result.data.length).toBe(2);
expect(isNaN(result.data[0]['requests/errors'])).toBe(true);
expect(isNaN(result.data[1]['requests/errors'])).toBe(true);
});
it('should throw error when result set has insufficient columns', () => {
const mockResultSet: ResponseJSON<any> = {
meta: [
{ name: 'timestamp', type: 'DateTime' },
{ name: 'requests', type: 'UInt64' },
],
data: [{ timestamp: '2025-04-15 10:00:00', requests: '100' }],
rows: 1,
statistics: { elapsed: 0.1, rows_read: 1, bytes_read: 50 },
};
expect(() => computeResultSetRatio(mockResultSet)).toThrow(
/Unable to compute ratio/,
);
});
});

View file

@ -52,6 +52,74 @@ export const splitChartConfigs = (config: ChartConfigWithOptDateRange) => {
return [config];
};
const castToNumber = (value: string | number) => {
if (typeof value === 'string') {
if (value.trim() === '') {
return NaN;
}
return Number(value);
}
return value;
};
export const computeRatio = (
numeratorInput: string | number,
denominatorInput: string | number,
) => {
const numerator = castToNumber(numeratorInput);
const denominator = castToNumber(denominatorInput);
if (isNaN(numerator) || isNaN(denominator) || denominator === 0) {
return NaN;
}
return numerator / denominator;
};
export const computeResultSetRatio = (resultSet: ResponseJSON<any>) => {
const _meta = resultSet.meta;
const _data = resultSet.data;
const timestampColumn = inferTimestampColumn(_meta ?? []);
const _restColumns = _meta?.filter(m => m.name !== timestampColumn?.name);
const firstColumn = _restColumns?.[0];
const secondColumn = _restColumns?.[1];
if (!firstColumn || !secondColumn) {
throw new Error(
`Unable to compute ratio - meta information: ${JSON.stringify(_meta)}.`,
);
}
const ratioColumnName = `${firstColumn.name}/${secondColumn.name}`;
const result = {
...resultSet,
data: _data.map(row => ({
[ratioColumnName]: computeRatio(
row[firstColumn.name],
row[secondColumn.name],
),
...(timestampColumn
? {
[timestampColumn.name]: row[timestampColumn.name],
}
: {}),
})),
meta: [
{
name: ratioColumnName,
type: 'Float64',
},
...(timestampColumn
? [
{
name: timestampColumn.name,
type: timestampColumn.type,
},
]
: []),
],
};
return result;
};
interface AdditionalUseQueriedChartConfigOptions {
onError?: (error: Error | ClickHouseQueryError) => void;
}
@ -80,6 +148,7 @@ export function useQueriedChartConfig(
query = await renderMTViewConfig();
}
// TODO: move multi-series logics to common-utils so alerting can use it
const queries: ChSql[] = await Promise.all(
splitChartConfigs(config).map(c => renderChartConfig(c, getMetadata())),
);
@ -100,9 +169,12 @@ export function useQueriedChartConfig(
);
if (resultSets.length === 1) {
return resultSets[0];
const isRatio =
config.seriesReturnType === 'ratio' &&
resultSets[0].meta?.length === 3;
return isRatio ? computeResultSetRatio(resultSets[0]) : resultSets[0];
}
// join resultSets
// metrics -> join resultSets
else if (resultSets.length > 1) {
const metaSet = new Map<string, { name: string; type: string }>();
const tsBucketMap = new Map<string, Record<string, string | number>>();
@ -146,10 +218,14 @@ export function useQueriedChartConfig(
}
}
return {
const isRatio =
config.seriesReturnType === 'ratio' && resultSets.length === 2;
const _resultSet = {
meta: Array.from(metaSet.values()),
data: Array.from(tsBucketMap.values()),
};
return isRatio ? computeResultSetRatio(_resultSet) : _resultSet;
}
throw new Error('No result sets');
},

View file

@ -346,6 +346,7 @@ export const _ChartConfigSchema = z.object({
fillNulls: z.union([z.number(), z.literal(false)]).optional(),
selectGroupBy: z.boolean().optional(),
metricTables: MetricTableSchema.optional(),
seriesReturnType: z.enum(['ratio', 'column']).optional(),
});
// This is a ChartConfig type without the `with` CTE clause included.