Skip to content

Commit b16c8e1

Browse files
authored
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" />
1 parent 35a1c31 commit b16c8e1

File tree

6 files changed

+240
-3
lines changed

6 files changed

+240
-3
lines changed

.changeset/honest-fishes-love.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@hyperdx/common-utils": patch
3+
"@hyperdx/app": patch
4+
---
5+
6+
feat: compute charts ratio

packages/app/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"lint:styles": "stylelint **/*/*.{css,scss}",
1717
"ci:lint": "yarn lint && yarn tsc --noEmit && yarn lint:styles --quiet",
1818
"ci:unit": "jest --ci --coverage",
19+
"dev:unit": "jest --watchAll --detectOpenHandles",
1920
"storybook": "storybook dev -p 6006",
2021
"storybook:build": "storybook build",
2122
"knip": "knip"

packages/app/src/components/DBEditTimeChartForm.tsx

+18
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
Group,
3434
Paper,
3535
Stack,
36+
Switch,
3637
Tabs,
3738
Text,
3839
Textarea,
@@ -338,6 +339,7 @@ export default function EditTimeChartForm({
338339
const sourceId = watch('source');
339340
const whereLanguage = watch('whereLanguage');
340341
const alert = watch('alert');
342+
const seriesReturnType = watch('seriesReturnType');
341343

342344
const { data: tableSource } = useSource({ id: sourceId });
343345
const databaseName = tableSource?.from.databaseName;
@@ -632,6 +634,22 @@ export default function EditTimeChartForm({
632634
Add Series
633635
</Button>
634636
)}
637+
{select.length == 2 && displayType !== DisplayType.Number && (
638+
<Switch
639+
label="As Ratio"
640+
size="sm"
641+
color="gray"
642+
variant="subtle"
643+
onClick={() => {
644+
setValue(
645+
'seriesReturnType',
646+
seriesReturnType === 'ratio' ? 'column' : 'ratio',
647+
);
648+
onSubmit();
649+
}}
650+
checked={seriesReturnType === 'ratio'}
651+
/>
652+
)}
635653
{displayType === DisplayType.Line &&
636654
dashboardId &&
637655
!IS_LOCAL_MODE && (
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { ResponseJSON } from '@clickhouse/client-web';
2+
3+
import { computeRatio, computeResultSetRatio } from '../useChartConfig';
4+
5+
describe('computeRatio', () => {
6+
it('should correctly compute ratio of two numbers', () => {
7+
expect(computeRatio('10', '2')).toBe(5);
8+
expect(computeRatio('3', '4')).toBe(0.75);
9+
expect(computeRatio('0', '5')).toBe(0);
10+
});
11+
12+
it('should return NaN when denominator is zero', () => {
13+
expect(isNaN(computeRatio('10', '0'))).toBe(true);
14+
});
15+
16+
it('should return NaN for non-numeric inputs', () => {
17+
expect(isNaN(computeRatio('abc', '2'))).toBe(true);
18+
expect(isNaN(computeRatio('10', 'xyz'))).toBe(true);
19+
expect(isNaN(computeRatio('abc', 'xyz'))).toBe(true);
20+
expect(isNaN(computeRatio('', '5'))).toBe(true);
21+
});
22+
23+
it('should handle string representations of numbers', () => {
24+
expect(computeRatio('10.5', '2')).toBe(5.25);
25+
expect(computeRatio('-10', '5')).toBe(-2);
26+
expect(computeRatio('10', '-5')).toBe(-2);
27+
});
28+
29+
it('should handle number input types', () => {
30+
expect(computeRatio(10, 2)).toBe(5);
31+
expect(computeRatio(3, 4)).toBe(0.75);
32+
expect(computeRatio(10.5, 2)).toBe(5.25);
33+
expect(computeRatio(0, 5)).toBe(0);
34+
expect(isNaN(computeRatio(10, 0))).toBe(true);
35+
expect(computeRatio(-10, 5)).toBe(-2);
36+
});
37+
38+
it('should handle mixed string and number inputs', () => {
39+
expect(computeRatio('10', 2)).toBe(5);
40+
expect(computeRatio(10, '2')).toBe(5);
41+
expect(computeRatio(3, '4')).toBe(0.75);
42+
expect(isNaN(computeRatio(10, ''))).toBe(true);
43+
});
44+
});
45+
46+
describe('computeResultSetRatio', () => {
47+
it('should compute ratio for a valid result set with timestamp column', () => {
48+
const mockResultSet: ResponseJSON<any> = {
49+
meta: [
50+
{ name: 'timestamp', type: 'DateTime' },
51+
{ name: 'requests', type: 'UInt64' },
52+
{ name: 'errors', type: 'UInt64' },
53+
],
54+
data: [
55+
{ timestamp: '2025-04-15 10:00:00', requests: '100', errors: '10' },
56+
{ timestamp: '2025-04-15 11:00:00', requests: '200', errors: '20' },
57+
],
58+
rows: 2,
59+
statistics: { elapsed: 0.1, rows_read: 2, bytes_read: 100 },
60+
};
61+
62+
const result = computeResultSetRatio(mockResultSet);
63+
64+
expect(result.meta.length).toBe(2);
65+
expect(result.meta[0].name).toBe('requests/errors');
66+
expect(result.meta[0].type).toBe('Float64');
67+
expect(result.meta[1].name).toBe('timestamp');
68+
69+
expect(result.data.length).toBe(2);
70+
expect(result.data[0]['requests/errors']).toBe(10);
71+
expect(result.data[0].timestamp).toBe('2025-04-15 10:00:00');
72+
expect(result.data[1]['requests/errors']).toBe(10);
73+
expect(result.data[1].timestamp).toBe('2025-04-15 11:00:00');
74+
});
75+
76+
it('should compute ratio for a valid result set without timestamp column', () => {
77+
const mockResultSet: ResponseJSON<any> = {
78+
meta: [
79+
{ name: 'requests', type: 'UInt64' },
80+
{ name: 'errors', type: 'UInt64' },
81+
],
82+
data: [{ requests: '100', errors: '10' }],
83+
rows: 1,
84+
statistics: { elapsed: 0.1, rows_read: 1, bytes_read: 50 },
85+
};
86+
87+
const result = computeResultSetRatio(mockResultSet);
88+
89+
expect(result.meta.length).toBe(1);
90+
expect(result.meta[0].name).toBe('requests/errors');
91+
expect(result.meta[0].type).toBe('Float64');
92+
93+
expect(result.data.length).toBe(1);
94+
expect(result.data[0]['requests/errors']).toBe(10);
95+
expect(result.data[0].timestamp).toBeUndefined();
96+
});
97+
98+
it('should handle NaN values in ratio computation', () => {
99+
const mockResultSet: ResponseJSON<any> = {
100+
meta: [
101+
{ name: 'timestamp', type: 'DateTime' },
102+
{ name: 'requests', type: 'UInt64' },
103+
{ name: 'errors', type: 'UInt64' },
104+
],
105+
data: [
106+
{ timestamp: '2025-04-15 10:00:00', requests: '100', errors: '0' },
107+
{ timestamp: '2025-04-15 11:00:00', requests: 'invalid', errors: '20' },
108+
],
109+
rows: 2,
110+
statistics: { elapsed: 0.1, rows_read: 2, bytes_read: 100 },
111+
};
112+
113+
const result = computeResultSetRatio(mockResultSet);
114+
115+
expect(result.data.length).toBe(2);
116+
expect(isNaN(result.data[0]['requests/errors'])).toBe(true);
117+
expect(isNaN(result.data[1]['requests/errors'])).toBe(true);
118+
});
119+
120+
it('should throw error when result set has insufficient columns', () => {
121+
const mockResultSet: ResponseJSON<any> = {
122+
meta: [
123+
{ name: 'timestamp', type: 'DateTime' },
124+
{ name: 'requests', type: 'UInt64' },
125+
],
126+
data: [{ timestamp: '2025-04-15 10:00:00', requests: '100' }],
127+
rows: 1,
128+
statistics: { elapsed: 0.1, rows_read: 1, bytes_read: 50 },
129+
};
130+
131+
expect(() => computeResultSetRatio(mockResultSet)).toThrow(
132+
/Unable to compute ratio/,
133+
);
134+
});
135+
});

packages/app/src/hooks/useChartConfig.tsx

+79-3
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,74 @@ export const splitChartConfigs = (config: ChartConfigWithOptDateRange) => {
5252
return [config];
5353
};
5454

55+
const castToNumber = (value: string | number) => {
56+
if (typeof value === 'string') {
57+
if (value.trim() === '') {
58+
return NaN;
59+
}
60+
return Number(value);
61+
}
62+
return value;
63+
};
64+
65+
export const computeRatio = (
66+
numeratorInput: string | number,
67+
denominatorInput: string | number,
68+
) => {
69+
const numerator = castToNumber(numeratorInput);
70+
const denominator = castToNumber(denominatorInput);
71+
72+
if (isNaN(numerator) || isNaN(denominator) || denominator === 0) {
73+
return NaN;
74+
}
75+
76+
return numerator / denominator;
77+
};
78+
79+
export const computeResultSetRatio = (resultSet: ResponseJSON<any>) => {
80+
const _meta = resultSet.meta;
81+
const _data = resultSet.data;
82+
const timestampColumn = inferTimestampColumn(_meta ?? []);
83+
const _restColumns = _meta?.filter(m => m.name !== timestampColumn?.name);
84+
const firstColumn = _restColumns?.[0];
85+
const secondColumn = _restColumns?.[1];
86+
if (!firstColumn || !secondColumn) {
87+
throw new Error(
88+
`Unable to compute ratio - meta information: ${JSON.stringify(_meta)}.`,
89+
);
90+
}
91+
const ratioColumnName = `${firstColumn.name}/${secondColumn.name}`;
92+
const result = {
93+
...resultSet,
94+
data: _data.map(row => ({
95+
[ratioColumnName]: computeRatio(
96+
row[firstColumn.name],
97+
row[secondColumn.name],
98+
),
99+
...(timestampColumn
100+
? {
101+
[timestampColumn.name]: row[timestampColumn.name],
102+
}
103+
: {}),
104+
})),
105+
meta: [
106+
{
107+
name: ratioColumnName,
108+
type: 'Float64',
109+
},
110+
...(timestampColumn
111+
? [
112+
{
113+
name: timestampColumn.name,
114+
type: timestampColumn.type,
115+
},
116+
]
117+
: []),
118+
],
119+
};
120+
return result;
121+
};
122+
55123
interface AdditionalUseQueriedChartConfigOptions {
56124
onError?: (error: Error | ClickHouseQueryError) => void;
57125
}
@@ -80,6 +148,7 @@ export function useQueriedChartConfig(
80148
query = await renderMTViewConfig();
81149
}
82150

151+
// TODO: move multi-series logics to common-utils so alerting can use it
83152
const queries: ChSql[] = await Promise.all(
84153
splitChartConfigs(config).map(c => renderChartConfig(c, getMetadata())),
85154
);
@@ -100,9 +169,12 @@ export function useQueriedChartConfig(
100169
);
101170

102171
if (resultSets.length === 1) {
103-
return resultSets[0];
172+
const isRatio =
173+
config.seriesReturnType === 'ratio' &&
174+
resultSets[0].meta?.length === 3;
175+
return isRatio ? computeResultSetRatio(resultSets[0]) : resultSets[0];
104176
}
105-
// join resultSets
177+
// metrics -> join resultSets
106178
else if (resultSets.length > 1) {
107179
const metaSet = new Map<string, { name: string; type: string }>();
108180
const tsBucketMap = new Map<string, Record<string, string | number>>();
@@ -146,10 +218,14 @@ export function useQueriedChartConfig(
146218
}
147219
}
148220

149-
return {
221+
const isRatio =
222+
config.seriesReturnType === 'ratio' && resultSets.length === 2;
223+
224+
const _resultSet = {
150225
meta: Array.from(metaSet.values()),
151226
data: Array.from(tsBucketMap.values()),
152227
};
228+
return isRatio ? computeResultSetRatio(_resultSet) : _resultSet;
153229
}
154230
throw new Error('No result sets');
155231
},

packages/common-utils/src/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ export const _ChartConfigSchema = z.object({
346346
fillNulls: z.union([z.number(), z.literal(false)]).optional(),
347347
selectGroupBy: z.boolean().optional(),
348348
metricTables: MetricTableSchema.optional(),
349+
seriesReturnType: z.enum(['ratio', 'column']).optional(),
349350
});
350351

351352
// This is a ChartConfig type without the `with` CTE clause included.

0 commit comments

Comments
 (0)