mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
35a1c31658
commit
b16c8e1429
6 changed files with 240 additions and 3 deletions
6
.changeset/honest-fishes-love.md
Normal file
6
.changeset/honest-fishes-love.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@hyperdx/common-utils": patch
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: compute charts ratio
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
135
packages/app/src/hooks/__tests__/useChartConfig.test.tsx
Normal file
135
packages/app/src/hooks/__tests__/useChartConfig.test.tsx
Normal 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue