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/refactor-metric-ratios-cte.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hyperdx/common-utils": patch
---

Refactor metric ratios to use native ClickHouse CTE
60 changes: 60 additions & 0 deletions packages/common-utils/src/__tests__/queryChartConfig.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,66 @@ describe('queryChartConfig Integration Tests', () => {
}
});

it('computes ratio via native CTE when seriesReturnType is "ratio"', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is a good start, but to be confident in these changes, I think we'd want to create tests that include all of the following:

  1. Each test is hitting a real ClickHouse integration test container (eg. don't mock clickhouse) so that the generated query is being validated as correct against ClickHouse.
  2. Each test should seed a small amount of data to be queried, so that the calculations can be validated against an expected ratio value. See the recently added histogram metrics integration tests for examples
  3. Each metric type (gauge, sum, histogram, exponential histogram) should be tested. Each should be tested with and without a group by, and the group by should include Attributes values and table columns.
  4. Edge cases (divide by 0, missing numerator or demoninator, etc) should all be tested.

const config: ChartConfigWithOptDateRange = {
displayType: DisplayType.Line,
connection: 'test-connection',
from: { databaseName: DATABASE, tableName: TABLE_NAME },
metricTables: { [MetricsDataType.Gauge]: TABLE_NAME } as any,
seriesReturnType: 'ratio',
select: [
{
aggFn: 'avg',
aggCondition: '',
aggConditionLanguage: 'sql',
valueExpression: 'Value',
metricName: 'metric.alpha',
metricType: MetricsDataType.Gauge,
alias: 'avg(metric.alpha)',
},
{
aggFn: 'avg',
aggCondition: '',
aggConditionLanguage: 'sql',
valueExpression: 'Value',
metricName: 'metric.beta',
metricType: MetricsDataType.Gauge,
alias: 'avg(metric.beta)',
},
],
groupBy: [{ aggCondition: '', valueExpression: 'ServiceName' }],
where: '',
whereLanguage: 'sql',
timestampValueExpression: 'TimeUnix',
dateRange: [new Date('2025-04-14'), new Date('2025-04-16')],
granularity: '1 minute',
limit: { limit: 100 },
};

const result = await hdxClient.queryChartConfig({
config,
metadata,
querySettings: undefined,
});

const metaNames = result.meta?.map(m => m.name) ?? [];

// Check that the ratio is the first column
expect(metaNames[0]).toBe('avg(metric.alpha)/avg(metric.beta)');
expect(metaNames).toContain('__hdx_time_bucket');
expect(metaNames).toContain('ServiceName');

const data = result.data as any[];
expect(data.length).toBeGreaterThan(0);
for (const row of data) {
expect(row['avg(metric.alpha)/avg(metric.beta)']).toBeDefined();
// It might be a number or string depending on ClickHouse formatting for JSON, usually number for Float64
expect(
Number.isNaN(Number(row['avg(metric.alpha)/avg(metric.beta)'])),
).toBe(false);
}
});

// Regression: a comma-separated string group-by (with a Map access) must split
// per-column (not emit toString(col1, col2)); empty-string groups are kept.
it('handles a multi-column string group-by (with Map access) under seriesLimit', async () => {
Expand Down
84 changes: 81 additions & 3 deletions packages/common-utils/src/clickhouse/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -834,9 +834,30 @@ export abstract class BaseClickhouseClient {
};
querySettings: QuerySettings | undefined;
}): Promise<ResponseJSON<Record<string, string | number>>> {
config = isBuilderChartConfig(config)
? setChartSelectsAlias(config)
: config;
if (isBuilderChartConfig(config)) {
config = setChartSelectsAlias(config);
if (config.seriesReturnType === 'ratio' && config.ratioMode !== 'share_of_total') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like there are some lint / formatting issues here - please run make ci-lint and fix any errors.

if (config.groupBy) {
if (typeof config.groupBy === 'string') {
config.groupBy = splitAndTrimWithBracket(config.groupBy).map(gb => ({
type: 'string',
valueExpression: gb,
alias: gb, // Assign the raw expression as the alias so the CTE outputs exactly this column name
}));
} else if (Array.isArray(config.groupBy)) {
config.groupBy = config.groupBy.map(gb => {
if (typeof gb === 'string') {
return { type: 'string', valueExpression: gb, alias: gb };
}
if (!gb.alias) {
return { ...gb, alias: gb.valueExpression };
}
return gb;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This triggers a latent bug in renderSelectList (used to render group by lists as well). For group-bys with 2 items in the list, with seriesReturnType === 'ratio', the two group by items are divided (in renderChartConfig).

To reproduce, try GROUP BY Attributes['http.flavor'], ServiceName on a metrics ratio.

});
}
}
}
}
const queries: ChSql[] = await Promise.all(
splitChartConfigs(config).map(c =>
renderChartConfig(c, metadata, querySettings),
Expand All @@ -845,6 +866,63 @@ export abstract class BaseClickhouseClient {

const isTimeSeries = isTimeSeriesDisplayType(config.displayType);

if (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we move this query building into renderChartConfig, so that other query paths that call renderChartConfig but not queryChartConfig (eg. rendering the SQL preview) do the same rendering?

isBuilderChartConfig(config) &&
config.seriesReturnType === 'ratio' &&
config.ratioMode !== 'share_of_total' &&
queries.length === 2 &&
Array.isArray(config.select)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
) {
const q0Alias = config.select[0].alias ?? 'q0_val';
const originalQ1Alias = config.select[1].alias ?? 'q1_val';
const ratioAlias = `${q0Alias}/${originalQ1Alias}`;

const joinKeys: string[] = [];
if (isTimeSeries) {
joinKeys.push('__hdx_time_bucket');
}
if (config.groupBy) {
for (const gb of config.groupBy) {
if (typeof gb === 'string') {
joinKeys.push(gb);
} else {
joinKeys.push(gb.alias || gb.valueExpression);
}
}
}

Comment thread
greptile-apps[bot] marked this conversation as resolved.
// De-duplicate join keys just in case
const uniqueJoinKeys = Array.from(new Set(joinKeys));

let ratioSql: ChSql;
const selectCols = [
chSql`(COALESCE(q0.${{ Identifier: q0Alias }}, 0) / q1.${{ Identifier: originalQ1Alias }}) AS ${{ Identifier: ratioAlias }}`,
...uniqueJoinKeys.map(k => chSql`${{ Identifier: k }}`),
Comment thread
Aryainguz marked this conversation as resolved.
];
Comment thread
Aryainguz marked this conversation as resolved.
const selectClause = concatChSql(', ', selectCols);

if (uniqueJoinKeys.length > 0) {
const joinKeysSql = uniqueJoinKeys.map(
k => chSql`${{ Identifier: k }}`,
);
const usingClause = concatChSql(', ', joinKeysSql);
ratioSql = chSql`WITH q0 AS (${queries[0]}), q1 AS (${queries[1]}) SELECT ${selectClause} FROM q0 FULL OUTER JOIN q1 USING (${usingClause})`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When viewing the ratio between two gauge metrics, grouping by Attributes['http.flavor'], I get the following error:

JOIN  FULL OUTER JOIN ... USING (__hdx_time_bucket, `Attributes[\\'http.flavor\\']`) using identifier 'Attributes[\'http.flavor\']' cannot be resolved from left table expression. In scope WITH q0 AS (WITH Source AS (SELECT *, cityHash64(ScopeAttributes, ResourceAttributes, Attributes) AS AttributesHash FROM default.otel_metrics_gauge WHERE ((TimeUnix >= fromUnixTimestamp64Milli(_CAST(1785348960000, 'Int64'))) AND (TimeUnix < fromUnixTimestamp64Milli(_CAST(1785352620000, 'Int64')))) AND (MetricName = 'ClickHouseAsyncMetrics_OSGuestTime')), Bucketed AS (SELECT toStartOfInterval(toDateTime(TimeUnix), toIntervalMinute(1)) AS __hdx_time_bucket2, AttributesHash, last_value(Value) AS LastValue, any(ScopeAttributes) AS ScopeAttributes, any(ResourceAttributes) AS ResourceAttributes, any(Attributes) AS Attributes, any(ResourceSchemaUrl) AS ResourceSchemaUrl, any(ScopeName) AS ScopeName, any(ScopeVersion) AS ScopeVersion, any(ScopeDroppedAttrCount) AS ScopeDroppedAttrCount, any(ScopeSchemaUrl) AS ScopeSchemaUrl, any(ServiceName) AS ServiceName, any(MetricDescription) AS MetricDescription, any(MetricUnit) AS MetricUnit, any(StartTimeUnix) AS StartTimeUnix, any(Flags) AS Flags FROM Source GROUP BY AttributesHash, __hdx_time_bucket2 ORDER BY AttributesHash ASC, __hdx_time_bucket2 ASC) SELECT count() AS `1`, Attributes['http.flavor'] AS `Attributes['http.flavor']`, toStartOfInterval(toDateTime(__hdx_time_bucket2), toIntervalMinute(1)) AS __hdx_time_bucket FROM Bucketed WHERE ((__hdx_time_bucket2 >= fromUnixTimestamp64Milli(_CAST(1785348960000, 'Int64'))) AND (__hdx_time_bucket2 < fromUnixTimestamp64Milli(_CAST(1785352620000, 'Int64')))) GROUP BY Attributes['http.flavor'] AS `Attributes['http.flavor']`, toStartOfInterval(toDateTime(__hdx_time_bucket2), toIntervalMinute(1)) AS __hdx_time_bucket ORDER BY toStartOfInterval(toDateTime(__hdx_time_bucket2), toIntervalMinute(1)) AS __hdx_time_bucket ASC LIMIT _CAST(100000, 'Int32') SETTINGS short_circuit_function_evaluation = 'force_enable'), q1 AS (WITH Source AS (SELECT *, cityHash64(ScopeAttributes, ResourceAttributes, Attributes) AS AttributesHash FROM default.otel_metrics_gauge WHERE ((TimeUnix >= fromUnixTimestamp64Milli(_CAST(1785348960000, 'Int64'))) AND (TimeUnix < fromUnixTimestamp64Milli(_CAST(1785352620000, 'Int64')))) AND (MetricName = 'ClickHouseAsyncMetrics_OSGuestTime')), Bucketed AS (SELECT toStartOfInterval(toDateTime(TimeUnix), toIntervalMinute(1)) AS __hdx_time_bucket2, AttributesHash, last_value(Value) AS LastValue, any(ScopeAttributes) AS ScopeAttributes, any(ResourceAttributes) AS ResourceAttributes, any(Attributes) AS Attributes, any(ResourceSchemaUrl) AS ResourceSchemaUrl, any(ScopeName) AS ScopeName, any(ScopeVersion) AS ScopeVersion, any(ScopeDroppedAttrCount) AS ScopeDroppedAttrCount, any(ScopeSchemaUrl) AS ScopeSchemaUrl, any(ServiceName) AS ServiceName, any(MetricDescription) AS MetricDescription, any(MetricUnit) AS MetricUnit, any(StartTimeUnix) AS StartTimeUnix, any(Flags) AS Flags FROM Source GROUP BY AttributesHash, __hdx_time_bucket2 ORDER BY AttributesHash ASC, __hdx_time_bucket2 ASC) SELECT count() AS `2`, Attributes['http.flavor'] AS `Attributes['http.flavor']`, toStartOfInterval(toDateTime(__hdx_time_bucket2), toIntervalMinute(1)) AS __hdx_time_bucket FROM Bucketed WHERE ((__hdx_time_bucket2 >= fromUnixTimestamp64Milli(_CAST(1785348960000, 'Int64'))) AND (__hdx_time_bucket2 < fromUnixTimestamp64Milli(_CAST(1785352620000, 'Int64')))) GROUP BY Attributes['http.flavor'] AS `Attributes['http.flavor']`, toStartOfInterval(toDateTime(__hdx_time_bucket2), toIntervalMinute(1)) AS __hdx_time_bucket ORDER BY toStartOfInterval(toDateTime(__hdx_time_bucket2), toIntervalMinute(1)) AS __hdx_time_bucket ASC LIMIT _CAST(100000, 'Int32') SETTINGS short_circuit_function_evaluation = 'force_enable') SELECT (COALESCE(q0.`1`, 0) / q1.`2`) AS `1/2`, __hdx_time_bucket, `Attributes[\\'http.flavor\\']` FROM q0 FULL OUTER JOIN q1 USING (__hdx_time_bucket, `Attributes[\\'http.flavor\\']`). 

} else {
ratioSql = chSql`WITH q0 AS (${queries[0]}), q1 AS (${queries[1]}) SELECT ${selectClause} FROM q0 CROSS JOIN q1`;
}

const resp = await this.query<'JSON'>({
query: ratioSql.sql,
query_params: ratioSql.params,
format: 'JSON',
abort_signal: opts?.abort_signal,
connectionId: config.connection,
clickhouse_settings: opts?.clickhouse_settings,
});

return resp.json<any>();
}

const resultSets = await Promise.all(
queries.map(async query => {
const resp = await this.query<'JSON'>({
Expand Down
Loading