Skip to content

refactor(perf): offload metric ratio computation to native ClickHouse CTE - #2681

Open
Aryainguz wants to merge 10 commits into
hyperdxio:mainfrom
Aryainguz:feat/native-clickhouse-ratios
Open

refactor(perf): offload metric ratio computation to native ClickHouse CTE#2681
Aryainguz wants to merge 10 commits into
hyperdxio:mainfrom
Aryainguz:feat/native-clickhouse-ratios

Conversation

@Aryainguz

@Aryainguz Aryainguz commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Summary

the metric ratios computation in @hyperdx/common-utils to execute natively inside ClickHouse via CTEs instead of fetching two separate streams and zipping them in memory using Node.js. Updated tests, changeset file as per the changes.

Why this change was made:
Currently, when evaluating metric ratio charts (e.g. dividing the sum of one metric by the sum of another), the backend executes two separate queries concurrently. The results are then loaded entirely into Node.js memory, zipped by their respective series, and the mathematical ratio calculation is executed sequentially in JavaScript.

For heavy, high-cardinality charts such as time series distributions across thousands of service.names this leads to:

  • Significant serialization overhead to and from ClickHouse over the network.
  • An unnecessarily bloated memory footprint in the Node backend due to retaining massive parallel arrays in the V8 heap.
  • Higher latency and CPU cycle waste for mathematical zipping that the database is better optimized for.
  • When a multi-series query specifies seriesReturnType: 'ratio', the AST compiler now produces a single composite SQL query with an ANY LEFT JOIN on the requested grouping keys and time buckets. This severely decreases latency by letting the database execute the math directly in the SELECT projection, drastically reducing Node API memory footprint and serialization overhead. It fully preserves backwards compatibility for legacy column alias outputs (/).

Fixes #2680

Local Validations:
Screenshot 2026-07-18 at 5 46 12 PM
Screenshot 2026-07-18 at 5 45 12 PM
Screenshot 2026-07-18 at 5 44 43 PM

@changeset-bot

changeset-bot Bot commented Jul 18, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: afe1fd7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@hyperdx/common-utils Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jul 18, 2026

Copy link
Copy Markdown

@Aryainguz is attempting to deploy a commit to the HyperDX Team on Vercel.

A member of the Team first needs to authorize it.

@greptile-apps

greptile-apps Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR moves metric ratio calculation into a native ClickHouse query path. The main changes are:

  • Adds a CTE-based ratio query for two-series builder charts.
  • Normalizes ratio group-by aliases before rendering operand queries.
  • Keeps share-of-total ratios on the existing merge path.
  • Adds integration coverage and a patch changeset.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
packages/common-utils/src/clickhouse/index.ts Adds the native ClickHouse ratio query path and keeps share-of-total ratios on the existing merge path.
packages/common-utils/src/tests/queryChartConfig.int.test.ts Adds integration coverage for grouped native ratio results.
.changeset/refactor-metric-ratios-cte.md Adds a patch changeset for the common-utils ratio refactor.

Reviews (9): Last reviewed commit: "Merge branch 'main' into feat/native-cli..." | Re-trigger Greptile

Comment thread packages/common-utils/src/clickhouse/index.ts
Comment thread packages/common-utils/src/clickhouse/index.ts
@github-actions

github-actions Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Deep Review

✅ No critical issues found. The new native-CTE ratio path is SQL-injection-safe — every user-influenceable value (q0Alias, q1Alias, the <q0>/<q1> ratio alias, and each groupBy-derived join key) is bound through the { Identifier: ... } form of chSql and sent via query_params, never concatenated into raw SQL. The ratio column is selected first, matching the meta[0]-keyed assumption in useChartNumberFormats, so the column-format contract is preserved. The recommendations below are correctness-divergence and coverage concerns, not ship-blockers.

🟡 P2 -- recommended

  • packages/common-utils/src/clickhouse/index.ts:897 -- The native ratio COALESCE(q0.<q0Alias>, 0) / q1.<q1Alias> guards only the numerator, so its zero/missing-denominator behavior diverges from the legacy computeRatio, which returns NaN when the denominator is 0 or absent.
    • Fix: Normalize the denominator path (e.g. return null/NaN when the denominator is 0 or NULL) so a zero or one-sided group in a grouped ratio chart renders the same gap the in-memory path produced instead of inf/NULL.
    • correctness, api-contract, reliability
  • packages/common-utils/src/__tests__/queryChartConfig.int.test.ts:250 -- The single new test covers only the time-series + single-column-groupBy + distinct-alias case and asserts merely that the ratio parses as a number, leaving the CROSS JOIN (no groupBy), non-time-series (Table), duplicate-alias, zero-denominator, and share_of_total→legacy branches unverified and the computed ratio value unchecked.
    • Fix: Add cases for the un-grouped CROSS JOIN path, a Table display type, an identical-alias ratio, and a zero/one-sided denominator, and assert an expected numeric ratio rather than only !Number.isNaN.
    • testing
🔵 P3 nitpicks (4)
  • packages/common-utils/src/clickhouse/index.ts:837 -- queryChartConfig mutates its config argument in place (reassigning config.groupBy and, via setChartSelectsAlias, config.select), a side effect visible to the caller's object.
    • Fix: Clone the config (or the mutated sub-fields) before rewriting them.
  • packages/common-utils/src/clickhouse/index.ts:838 -- The guard config.seriesReturnType === 'ratio' && config.ratioMode !== 'share_of_total' is repeated three times and the string/array groupBy normalization branching is duplicated between normalization and join-key extraction.
    • Fix: Extract the ratio-eligibility predicate and the join-key derivation into named helpers, mirroring how mergeResultSets is factored out.
  • packages/common-utils/src/clickhouse/index.ts:923 -- The branch returns resp.json<any>(), widening the method's declared ResponseJSON<Record<string, string | number>> contract to any.
    • Fix: Type the response to the declared return type instead of any.
  • packages/common-utils/src/clickhouse/index.ts:875 -- config.select[0].alias and config.select[1].alias are read after only an Array.isArray(config.select) check, relying on the separate queries.length === 2 guard for length rather than checking select length directly.
    • Fix: Add an explicit config.select.length === 2 guard alongside Array.isArray so the indexing is locally sound.

Reviewers (12): correctness, testing, maintainability, project-standards, security, performance, api-contract, reliability, kieran-typescript, adversarial, agent-native, learnings.

Testing gaps: No test asserts the new native-CTE output equals the legacy in-memory output for the same input; zero/one-sided-denominator behavior (inf/NULL vs NaN) is unverified; groupBy values containing SQL metacharacters are not exercised to confirm identifier binding.

Comment thread packages/common-utils/src/clickhouse/index.ts
Comment thread packages/common-utils/src/clickhouse/index.ts Outdated
Comment thread packages/common-utils/src/clickhouse/index.ts Outdated
Comment thread packages/common-utils/src/clickhouse/index.ts
@Aryainguz Aryainguz changed the title perf: offload metric ratio computation to native ClickHouse CTE refactor(perf): offload metric ratio computation to native ClickHouse CTE Jul 20, 2026
@Aryainguz

Copy link
Copy Markdown
Contributor Author

@pulpdrew please review once

@pulpdrew pulpdrew left a comment

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.

Thanks for the PR! This is a nice problem to solve. The metrics queries are rough though, and it looks like there are some cases which are not handled. If you could handle those cases and add tests to demonstrate that they're all covered and correct, I can re-review.

: 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 (!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 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?

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\\']`). 

}
});

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: Optimize metric ratios computation using native ClickHouse CTEs

2 participants