diff --git a/.changeset/refactor-metric-ratios-cte.md b/.changeset/refactor-metric-ratios-cte.md new file mode 100644 index 0000000000..ee88c0bfec --- /dev/null +++ b/.changeset/refactor-metric-ratios-cte.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/common-utils": patch +--- + +Refactor metric ratios to use native ClickHouse CTE diff --git a/packages/common-utils/src/__tests__/queryChartConfig.int.test.ts b/packages/common-utils/src/__tests__/queryChartConfig.int.test.ts index 1006464886..c691cafa7b 100644 --- a/packages/common-utils/src/__tests__/queryChartConfig.int.test.ts +++ b/packages/common-utils/src/__tests__/queryChartConfig.int.test.ts @@ -247,6 +247,66 @@ describe('queryChartConfig Integration Tests', () => { } }); + it('computes ratio via native CTE when seriesReturnType is "ratio"', async () => { + 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 () => { diff --git a/packages/common-utils/src/__tests__/ratioChartConfig.int.test.ts b/packages/common-utils/src/__tests__/ratioChartConfig.int.test.ts new file mode 100644 index 0000000000..ec69d63913 --- /dev/null +++ b/packages/common-utils/src/__tests__/ratioChartConfig.int.test.ts @@ -0,0 +1,522 @@ +import type { ClickHouseClient } from '@clickhouse/client'; +import { createClient } from '@clickhouse/client'; + +import { ClickhouseClient as HdxClickhouseClient } from '@/clickhouse/node'; +import { Metadata, MetadataCache } from '@/core/metadata'; +import { + ChartConfigWithOptDateRange, + DisplayType, + MetricsDataType, +} from '@/types'; + +describe('ratioChartConfig Integration Tests', () => { + let client: ClickHouseClient; + let hdxClient: HdxClickhouseClient; + let metadata: Metadata; + + const DATABASE = 'default'; + const GAUGE_TABLE = 'otel_metrics_gauge_ratio_int_test'; + const SUM_TABLE = 'otel_metrics_sum_ratio_int_test'; + const HISTOGRAM_TABLE = 'otel_metrics_histogram_ratio_int_test'; + const EXP_HISTOGRAM_TABLE = + 'otel_metrics_exponential_histogram_ratio_int_test'; + + beforeAll(async () => { + const host = process.env.CLICKHOUSE_HOST || 'http://localhost:8123'; + const username = process.env.CLICKHOUSE_USER || 'default'; + const password = process.env.CLICKHOUSE_PASSWORD || ''; + + client = createClient({ url: host, username, password }); + hdxClient = new HdxClickhouseClient({ host, username, password }); + + // 1. Gauge Table + await client.command({ + query: `CREATE OR REPLACE TABLE ${DATABASE}.${GAUGE_TABLE} ( + ServiceName LowCardinality(String) CODEC(ZSTD(1)), + MetricName String CODEC(ZSTD(1)), + Attributes Map(LowCardinality(String), String) CODEC(ZSTD(1)), + TimeUnix DateTime64(9) CODEC(Delta(8), ZSTD(1)), + Value Float64 CODEC(ZSTD(1)) + ) ENGINE = MergeTree ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix))`, + }); + + // 2. Sum Table + await client.command({ + query: `CREATE OR REPLACE TABLE ${DATABASE}.${SUM_TABLE} ( + ServiceName LowCardinality(String) CODEC(ZSTD(1)), + MetricName String CODEC(ZSTD(1)), + Attributes Map(LowCardinality(String), String) CODEC(ZSTD(1)), + TimeUnix DateTime64(9) CODEC(Delta(8), ZSTD(1)), + Value Float64 CODEC(ZSTD(1)) + ) ENGINE = MergeTree ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix))`, + }); + + // 3. Histogram Table + await client.command({ + query: `CREATE OR REPLACE TABLE ${DATABASE}.${HISTOGRAM_TABLE} ( + ServiceName LowCardinality(String) CODEC(ZSTD(1)), + MetricName String CODEC(ZSTD(1)), + Attributes Map(LowCardinality(String), String) CODEC(ZSTD(1)), + TimeUnix DateTime64(9) CODEC(Delta(8), ZSTD(1)), + Count UInt64 CODEC(ZSTD(1)), + Sum Float64 CODEC(ZSTD(1)), + BucketCounts Array(UInt64) CODEC(ZSTD(1)), + ExplicitBounds Array(Float64) CODEC(ZSTD(1)) + ) ENGINE = MergeTree ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix))`, + }); + + // 4. Exponential Histogram Table + await client.command({ + query: `CREATE OR REPLACE TABLE ${DATABASE}.${EXP_HISTOGRAM_TABLE} ( + ServiceName LowCardinality(String) CODEC(ZSTD(1)), + MetricName String CODEC(ZSTD(1)), + Attributes Map(LowCardinality(String), String) CODEC(ZSTD(1)), + TimeUnix DateTime64(9) CODEC(Delta(8), ZSTD(1)), + Count UInt64 CODEC(ZSTD(1)), + Sum Float64 CODEC(ZSTD(1)), + Scale Int32 CODEC(ZSTD(1)), + ZeroCount UInt64 CODEC(ZSTD(1)), + PositiveOffset Int32 CODEC(ZSTD(1)), + PositiveBucketCounts Array(UInt64) CODEC(ZSTD(1)), + NegativeOffset Int32 CODEC(ZSTD(1)), + NegativeBucketCounts Array(UInt64) CODEC(ZSTD(1)) + ) ENGINE = MergeTree ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix))`, + }); + + // Seed Gauge Data + // ratio = sum(metric.alpha) / sum(metric.beta) + // svc-a: alpha=20, beta=10 -> ratio=2 + // svc-b: alpha=30, beta=60 -> ratio=0.5 + // svc-c: alpha=0, beta=10 -> ratio=0 + // svc-d: alpha=10, beta=0 -> ratio=null (or Infinity depending on handling) + const ts = '2025-04-15 10:00:00'; + await client.insert({ + table: `${DATABASE}.${GAUGE_TABLE}`, + values: [ + { + ServiceName: 'svc-a', + MetricName: 'metric.alpha', + Attributes: { env: 'prod', 'http.flavor': '1.1' }, + TimeUnix: ts, + Value: 20, + }, + { + ServiceName: 'svc-a', + MetricName: 'metric.beta', + Attributes: { env: 'prod', 'http.flavor': '1.1' }, + TimeUnix: ts, + Value: 10, + }, + { + ServiceName: 'svc-b', + MetricName: 'metric.alpha', + Attributes: { env: 'dev', 'http.flavor': '2.0' }, + TimeUnix: ts, + Value: 30, + }, + { + ServiceName: 'svc-b', + MetricName: 'metric.beta', + Attributes: { env: 'dev', 'http.flavor': '2.0' }, + TimeUnix: ts, + Value: 60, + }, + { + ServiceName: 'svc-c', + MetricName: 'metric.beta', + Attributes: { env: 'prod' }, + TimeUnix: ts, + Value: 10, + }, + { + ServiceName: 'svc-d', + MetricName: 'metric.alpha', + Attributes: { env: 'prod' }, + TimeUnix: ts, + Value: 10, + }, + ], + format: 'JSONEachRow', + }); + + // Seed Sum Data (delta sums) + // ratio = sum(metric.req_errors) / sum(metric.req_total) + // svc-a: err=5, total=100 -> ratio=0.05 + await client.insert({ + table: `${DATABASE}.${SUM_TABLE}`, + values: [ + { + ServiceName: 'svc-a', + MetricName: 'metric.req_errors', + Attributes: { env: 'prod' }, + TimeUnix: ts, + Value: 5, + }, + { + ServiceName: 'svc-a', + MetricName: 'metric.req_total', + Attributes: { env: 'prod' }, + TimeUnix: ts, + Value: 100, + }, + ], + format: 'JSONEachRow', + }); + + // Seed Histogram Data + // ratio = histogram_sum(metric.hist_alpha) / histogram_sum(metric.hist_beta) + // svc-a: alpha sum=50, beta sum=100 -> ratio=0.5 + await client.insert({ + table: `${DATABASE}.${HISTOGRAM_TABLE}`, + values: [ + { + ServiceName: 'svc-a', + MetricName: 'metric.hist_alpha', + Attributes: {}, + TimeUnix: ts, + Count: 2, + Sum: 50, + BucketCounts: [1, 1], + ExplicitBounds: [10], + }, + { + ServiceName: 'svc-a', + MetricName: 'metric.hist_beta', + Attributes: {}, + TimeUnix: ts, + Count: 4, + Sum: 100, + BucketCounts: [2, 2], + ExplicitBounds: [10], + }, + ], + format: 'JSONEachRow', + }); + + // Seed Exponential Histogram Data + // ratio = histogram_sum(metric.exp_alpha) / histogram_sum(metric.exp_beta) + // svc-a: alpha sum=100, beta sum=25 -> ratio=4 + await client.insert({ + table: `${DATABASE}.${EXP_HISTOGRAM_TABLE}`, + values: [ + { + ServiceName: 'svc-a', + MetricName: 'metric.exp_alpha', + Attributes: {}, + TimeUnix: ts, + Count: 2, + Sum: 100, + Scale: 0, + ZeroCount: 0, + PositiveOffset: 0, + PositiveBucketCounts: [2], + NegativeOffset: 0, + NegativeBucketCounts: [], + }, + { + ServiceName: 'svc-a', + MetricName: 'metric.exp_beta', + Attributes: {}, + TimeUnix: ts, + Count: 1, + Sum: 25, + Scale: 0, + ZeroCount: 0, + PositiveOffset: 0, + PositiveBucketCounts: [1], + NegativeOffset: 0, + NegativeBucketCounts: [], + }, + ], + format: 'JSONEachRow', + }); + }); + + beforeEach(() => { + metadata = new Metadata(hdxClient, new MetadataCache()); + }); + + afterAll(async () => { + await client.command({ + query: `DROP TABLE IF EXISTS ${DATABASE}.${GAUGE_TABLE}`, + }); + await client.command({ + query: `DROP TABLE IF EXISTS ${DATABASE}.${SUM_TABLE}`, + }); + await client.command({ + query: `DROP TABLE IF EXISTS ${DATABASE}.${HISTOGRAM_TABLE}`, + }); + await client.command({ + query: `DROP TABLE IF EXISTS ${DATABASE}.${EXP_HISTOGRAM_TABLE}`, + }); + }); + + const getBaseConfig = (): ChartConfigWithOptDateRange => ({ + displayType: DisplayType.Line, + connection: 'test-connection', + from: { databaseName: DATABASE, tableName: '' }, + seriesReturnType: 'ratio', + where: '', + whereLanguage: 'sql', + timestampValueExpression: 'TimeUnix', + dateRange: [new Date('2025-04-14'), new Date('2025-04-16')], + granularity: '1 minute', + limit: { limit: 100 }, + select: [], + }); + + // GAUGE TESTS + describe('Gauge', () => { + it('computes ratio with no group-by', async () => { + const config: ChartConfigWithOptDateRange = { + ...getBaseConfig(), + from: { databaseName: DATABASE, tableName: GAUGE_TABLE }, + metricTables: { [MetricsDataType.Gauge]: GAUGE_TABLE } as any, + select: [ + { + aggFn: 'sum', + aggCondition: '', + aggConditionLanguage: 'sql', + valueExpression: 'Value', + metricName: 'metric.alpha', + metricType: MetricsDataType.Gauge, + }, + { + aggFn: 'sum', + aggCondition: '', + aggConditionLanguage: 'sql', + valueExpression: 'Value', + metricName: 'metric.beta', + metricType: MetricsDataType.Gauge, + }, + ], + groupBy: undefined, + }; + const result = await hdxClient.queryChartConfig({ + config, + metadata, + querySettings: undefined, + }); + const data = result.data as any[]; + // total alpha = 20 + 30 + 0 + 10 = 60 + // total beta = 10 + 60 + 10 + 0 = 80 + // ratio = 60 / 80 = 0.75 + expect(data.length).toBeGreaterThan(0); + expect(data[0]['sum(metric.alpha)/sum(metric.beta)']).toBeCloseTo(0.75); + }); + + it('computes ratio grouped by column (ServiceName)', async () => { + const config: ChartConfigWithOptDateRange = { + ...getBaseConfig(), + from: { databaseName: DATABASE, tableName: GAUGE_TABLE }, + metricTables: { [MetricsDataType.Gauge]: GAUGE_TABLE } as any, + select: [ + { + aggFn: 'sum', + aggCondition: '', + aggConditionLanguage: 'sql', + valueExpression: 'Value', + metricName: 'metric.alpha', + metricType: MetricsDataType.Gauge, + }, + { + aggFn: 'sum', + aggCondition: '', + aggConditionLanguage: 'sql', + valueExpression: 'Value', + metricName: 'metric.beta', + metricType: MetricsDataType.Gauge, + }, + ], + groupBy: [{ aggCondition: '', valueExpression: 'ServiceName' }], + }; + const result = await hdxClient.queryChartConfig({ + config, + metadata, + querySettings: undefined, + }); + const data = result.data as any[]; + + const svcA = data.find(d => d.ServiceName === 'svc-a'); + const svcB = data.find(d => d.ServiceName === 'svc-b'); + const svcC = data.find(d => d.ServiceName === 'svc-c'); + + expect(svcA['sum(metric.alpha)/sum(metric.beta)']).toBe(2); // 20 / 10 + expect(svcB['sum(metric.alpha)/sum(metric.beta)']).toBe(0.5); // 30 / 60 + expect(svcC['sum(metric.alpha)/sum(metric.beta)']).toBe(0); // 0 / 10 + }); + + it('computes ratio grouped by Attributes map', async () => { + const config: ChartConfigWithOptDateRange = { + ...getBaseConfig(), + from: { databaseName: DATABASE, tableName: GAUGE_TABLE }, + metricTables: { [MetricsDataType.Gauge]: GAUGE_TABLE } as any, + select: [ + { + aggFn: 'sum', + aggCondition: '', + aggConditionLanguage: 'sql', + valueExpression: 'Value', + metricName: 'metric.alpha', + metricType: MetricsDataType.Gauge, + }, + { + aggFn: 'sum', + aggCondition: '', + aggConditionLanguage: 'sql', + valueExpression: 'Value', + metricName: 'metric.beta', + metricType: MetricsDataType.Gauge, + }, + ], + groupBy: [ + { aggCondition: '', valueExpression: "Attributes['http.flavor']" }, + ], + }; + const result = await hdxClient.queryChartConfig({ + config, + metadata, + querySettings: undefined, + }); + const data = result.data as any[]; + + const flavor1 = data.find(d => d["Attributes['http.flavor']"] === '1.1'); + const flavor2 = data.find(d => d["Attributes['http.flavor']"] === '2.0'); + + expect(flavor1['sum(metric.alpha)/sum(metric.beta)']).toBe(2); + expect(flavor2['sum(metric.alpha)/sum(metric.beta)']).toBe(0.5); + }); + }); + + // SUM TESTS + describe('Sum', () => { + it('computes ratio with multi-column group-by', async () => { + const config: ChartConfigWithOptDateRange = { + ...getBaseConfig(), + from: { databaseName: DATABASE, tableName: SUM_TABLE }, + metricTables: { [MetricsDataType.Sum]: SUM_TABLE } as any, + select: [ + { + aggFn: 'sum', + aggCondition: '', + aggConditionLanguage: 'sql', + valueExpression: 'Value', + metricName: 'metric.req_errors', + metricType: MetricsDataType.Sum, + isDelta: true, + }, + { + aggFn: 'sum', + aggCondition: '', + aggConditionLanguage: 'sql', + valueExpression: 'Value', + metricName: 'metric.req_total', + metricType: MetricsDataType.Sum, + isDelta: true, + }, + ], + groupBy: [ + { aggCondition: '', valueExpression: 'ServiceName' }, + { aggCondition: '', valueExpression: "Attributes['env']" }, + ], + }; + const result = await hdxClient.queryChartConfig({ + config, + metadata, + querySettings: undefined, + }); + const data = result.data as any[]; + + const svcA = data.find( + d => d.ServiceName === 'svc-a' && d["Attributes['env']"] === 'prod', + ); + expect( + svcA['sum(delta(metric.req_errors))/sum(delta(metric.req_total))'], + ).toBe(0.05); + }); + }); + + // HISTOGRAM TESTS + describe('Histogram', () => { + it('computes ratio of histogram_sum', async () => { + const config: ChartConfigWithOptDateRange = { + ...getBaseConfig(), + from: { databaseName: DATABASE, tableName: HISTOGRAM_TABLE }, + metricTables: { [MetricsDataType.Histogram]: HISTOGRAM_TABLE } as any, + select: [ + { + aggFn: 'histogram_sum', + aggCondition: '', + aggConditionLanguage: 'sql', + valueExpression: 'Value', + metricName: 'metric.hist_alpha', + metricType: MetricsDataType.Histogram, + }, + { + aggFn: 'histogram_sum', + aggCondition: '', + aggConditionLanguage: 'sql', + valueExpression: 'Value', + metricName: 'metric.hist_beta', + metricType: MetricsDataType.Histogram, + }, + ], + groupBy: undefined, + }; + const result = await hdxClient.queryChartConfig({ + config, + metadata, + querySettings: undefined, + }); + const data = result.data as any[]; + expect( + data[0][ + 'histogram_sum(metric.hist_alpha)/histogram_sum(metric.hist_beta)' + ], + ).toBe(0.5); + }); + }); + + // EXPONENTIAL HISTOGRAM TESTS + describe('Exponential Histogram', () => { + it('computes ratio of histogram_sum', async () => { + const config: ChartConfigWithOptDateRange = { + ...getBaseConfig(), + from: { databaseName: DATABASE, tableName: EXP_HISTOGRAM_TABLE }, + metricTables: { + [MetricsDataType.ExponentialHistogram]: EXP_HISTOGRAM_TABLE, + } as any, + select: [ + { + aggFn: 'histogram_sum', + aggCondition: '', + aggConditionLanguage: 'sql', + valueExpression: 'Value', + metricName: 'metric.exp_alpha', + metricType: MetricsDataType.ExponentialHistogram, + }, + { + aggFn: 'histogram_sum', + aggCondition: '', + aggConditionLanguage: 'sql', + valueExpression: 'Value', + metricName: 'metric.exp_beta', + metricType: MetricsDataType.ExponentialHistogram, + }, + ], + groupBy: undefined, + }; + const result = await hdxClient.queryChartConfig({ + config, + metadata, + querySettings: undefined, + }); + const data = result.data as any[]; + expect( + data[0][ + 'histogram_sum(metric.exp_alpha)/histogram_sum(metric.exp_beta)' + ], + ).toBe(4); + }); + }); +}); diff --git a/packages/common-utils/src/clickhouse/index.ts b/packages/common-utils/src/clickhouse/index.ts index 7e24cd5793..4b346a1614 100644 --- a/packages/common-utils/src/clickhouse/index.ts +++ b/packages/common-utils/src/clickhouse/index.ts @@ -15,7 +15,7 @@ import { getMetadata, Metadata } from '@/core/metadata'; import { FIXED_TIME_BUCKET_EXPR_ALIAS, renderChartConfig, - setChartSelectsAlias, + renderRatioChartConfig, splitChartConfigs, } from '@/core/renderChartConfig'; import { @@ -834,15 +834,29 @@ export abstract class BaseClickhouseClient { }; querySettings: QuerySettings | undefined; }): Promise>> { - config = isBuilderChartConfig(config) - ? setChartSelectsAlias(config) - : config; + // Try the native CTE ratio path first — one query instead of split+merge + const ratioSql = await renderRatioChartConfig( + config, + metadata, + querySettings, + ); + if (ratioSql) { + 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(); + } + const queries: ChSql[] = await Promise.all( splitChartConfigs(config).map(c => renderChartConfig(c, metadata, querySettings), ), ); - const isTimeSeries = isTimeSeriesDisplayType(config.displayType); const resultSets = await Promise.all( diff --git a/packages/common-utils/src/core/renderChartConfig.ts b/packages/common-utils/src/core/renderChartConfig.ts index f111ae8298..1e035f94d0 100644 --- a/packages/common-utils/src/core/renderChartConfig.ts +++ b/packages/common-utils/src/core/renderChartConfig.ts @@ -2241,6 +2241,101 @@ export async function renderRawSqlChartConfig( }; } +/** + * For a two-series ratio metric config (seriesReturnType === 'ratio', + * ratioMode !== 'share_of_total'), renders the complete CTE query: + * WITH q0 AS (...), q1 AS (...) + * SELECT (COALESCE(q0.val, 0) / q1.val) AS ratio, ...joinKeys + * FROM q0 FULL OUTER JOIN q1 USING (...) + * + * Returns null when the config doesn't qualify for the native ratio path. + */ +export async function renderRatioChartConfig( + rawConfig: ChartConfigWithOptDateRange, + metadata: Metadata, + querySettings: QuerySettings | undefined, +): Promise { + if (!isBuilderChartConfig(rawConfig)) return null; + if (rawConfig.seriesReturnType !== 'ratio') return null; + if (rawConfig.ratioMode === 'share_of_total') return null; + if (!isMetricChartConfig(rawConfig)) return null; + if (!Array.isArray(rawConfig.select) || rawConfig.select.length !== 2) + return null; + + // Apply aliases + const config = setChartSelectsAlias(rawConfig); + if (!Array.isArray(config.select) || config.select.length !== 2) return null; + + // Normalize groupBy: ensure all items have aliases for the USING clause + if (config.groupBy) { + if (typeof config.groupBy === 'string') { + config.groupBy = splitAndTrimWithBracket(config.groupBy).map(gb => ({ + type: 'string' as const, + valueExpression: gb, + alias: gb, + })); + } else if (Array.isArray(config.groupBy)) { + config.groupBy = config.groupBy.map(gb => { + if (typeof gb === 'string') { + return { + type: 'string' as const, + valueExpression: gb, + alias: gb, + }; + } + if (!gb.alias) { + return { ...gb, alias: gb.valueExpression }; + } + return gb; + }); + } + } + + // Split and render each sub-query + const splits = splitChartConfigs(config); + if (splits.length !== 2) return null; + + const queries = await Promise.all( + splits.map(c => renderChartConfig(c, metadata, querySettings)), + ); + + const q0Alias = config.select[0].alias ?? 'q0_val'; + const originalQ1Alias = config.select[1].alias ?? 'q1_val'; + const ratioAlias = `${q0Alias}/${originalQ1Alias}`; + + // Build join keys + const joinKeys: string[] = []; + const isTimeSeries = isUsingGranularity(config); + if (isTimeSeries) { + joinKeys.push(FIXED_TIME_BUCKET_EXPR_ALIAS); + } + if (config.groupBy && Array.isArray(config.groupBy)) { + for (const gb of config.groupBy) { + if (typeof gb === 'string') { + joinKeys.push(gb); + } else { + joinKeys.push(gb.alias || gb.valueExpression); + } + } + } + const uniqueJoinKeys = Array.from(new Set(joinKeys)); + + // Build SELECT columns — only the ratio + dimensions (no raw operands) + const selectCols = [ + chSql`(COALESCE(q0.${{ Identifier: q0Alias }}, 0) / q1.${{ Identifier: originalQ1Alias }}) AS ${{ Identifier: ratioAlias }}`, + ...uniqueJoinKeys.map(k => chSql`${{ Identifier: k }}`), + ]; + const selectClause = concatChSql(', ', selectCols); + + if (uniqueJoinKeys.length > 0) { + const joinKeysSql = uniqueJoinKeys.map(k => chSql`${{ Identifier: k }}`); + const usingClause = concatChSql(', ', joinKeysSql); + return chSql`WITH q0 AS (${queries[0]}), q1 AS (${queries[1]}) SELECT ${selectClause} FROM q0 FULL OUTER JOIN q1 USING (${usingClause})`; + } else { + return chSql`WITH q0 AS (${queries[0]}), q1 AS (${queries[1]}) SELECT ${selectClause} FROM q0 CROSS JOIN q1`; + } +} + export async function renderChartConfig( rawChartConfig: ChartConfigWithOptDateRangeEx, metadata: Metadata, @@ -2255,6 +2350,15 @@ export async function renderChartConfig( return renderRawSqlChartConfig(rawChartConfig, metadata); } + const ratioCteSql = await renderRatioChartConfig( + rawChartConfig, + metadata, + querySettings, + ); + if (ratioCteSql) { + return ratioCteSql; + } + // metric types require more rewriting since we know more about the schema // but goes through the same generation process const translatedChartConfig = isMetricChartConfig(rawChartConfig)