Skip to content

Commit e80630c

Browse files
authored
feat: supporting quantile histogram metrics (#635)
Additional `renderChartConfig` support to transform a histogram select into the correct SQL syntax to generate a chart. For parity with v1, this query only handles quantile queries. <img width="1939" alt="Screenshot 2025-02-26 at 12 58 55 PM" src="https://github.com/user-attachments/assets/1126ac6c-c431-4d89-92d7-9df1e49e25cf" /> <img width="1960" alt="Screenshot 2025-02-26 at 3 11 07 PM" src="https://github.com/user-attachments/assets/e4fa09bf-1e27-4a90-ad25-6c6cb2890877" /> Ref: HDX-1339
1 parent 521793d commit e80630c

File tree

4 files changed

+210
-26
lines changed

4 files changed

+210
-26
lines changed

.changeset/odd-hats-wash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/common-utils": minor
3+
---
4+
5+
Add chart support for querying OTEL histogram metric table

packages/common-utils/src/__tests__/renderChartConfig.test.ts

Lines changed: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,12 @@ describe('renderChartConfig', () => {
6060
expect(actual).toBe(
6161
'SELECT quantile(0.95)(toFloat64OrNull(toString(Value))),toStartOfInterval(toDateTime(TimeUnix), INTERVAL 1 minute) AS `__hdx_time_bucket`' +
6262
' FROM default.otel_metrics_gauge WHERE (TimeUnix >= fromUnixTimestamp64Milli(1739318400000) AND TimeUnix <= fromUnixTimestamp64Milli(1765670400000)) AND' +
63-
" (MetricName = 'nodejs.event_loop.utilization') GROUP BY toStartOfInterval(toDateTime(TimeUnix), INTERVAL 1 minute) AS `__hdx_time_bucket` " +
64-
'ORDER BY toStartOfInterval(toDateTime(TimeUnix), INTERVAL 1 minute) AS `__hdx_time_bucket` LIMIT 10',
63+
" (MetricName = 'nodejs.event_loop.utilization') GROUP BY toStartOfInterval(toDateTime(TimeUnix), INTERVAL 1 minute) AS `__hdx_time_bucket`" +
64+
' ORDER BY toStartOfInterval(toDateTime(TimeUnix), INTERVAL 1 minute) AS `__hdx_time_bucket`' +
65+
' WITH FILL FROM toUnixTimestamp(toStartOfInterval(fromUnixTimestamp64Milli(1739318400000), INTERVAL 1 minute))\n' +
66+
' TO toUnixTimestamp(toStartOfInterval(fromUnixTimestamp64Milli(1765670400000), INTERVAL 1 minute))\n' +
67+
' STEP 60' +
68+
' LIMIT 10',
6569
);
6670
});
6771

@@ -93,7 +97,7 @@ describe('renderChartConfig', () => {
9397
whereLanguage: 'sql',
9498
timestampValueExpression: 'TimeUnix',
9599
dateRange: [new Date('2025-02-12'), new Date('2025-12-14')],
96-
granularity: '5 minutes',
100+
granularity: '5 minute',
97101
limit: { limit: 10 },
98102
};
99103

@@ -112,12 +116,84 @@ describe('renderChartConfig', () => {
112116
' FROM default.otel_metrics_sum\n' +
113117
" WHERE MetricName = 'db.client.connections.usage'\n" +
114118
' ORDER BY AttributesHash, TimeUnix ASC\n' +
115-
' ) )SELECT avg(\n' +
119+
' ) ) SELECT avg(\n' +
116120
' toFloat64OrNull(toString(Rate))\n' +
117-
' ),toStartOfInterval(toDateTime(TimeUnix), INTERVAL 5 minutes) AS `__hdx_time_bucket` ' +
118-
'FROM RawSum WHERE (TimeUnix >= fromUnixTimestamp64Milli(1739318400000) AND TimeUnix <= fromUnixTimestamp64Milli(1765670400000)) ' +
119-
"AND (MetricName = 'db.client.connections.usage') GROUP BY toStartOfInterval(toDateTime(TimeUnix), INTERVAL 5 minutes) AS `__hdx_time_bucket` " +
120-
'ORDER BY toStartOfInterval(toDateTime(TimeUnix), INTERVAL 5 minutes) AS `__hdx_time_bucket` LIMIT 10',
121+
' ),toStartOfInterval(toDateTime(TimeUnix), INTERVAL 5 minute) AS `__hdx_time_bucket`' +
122+
' FROM RawSum WHERE (TimeUnix >= fromUnixTimestamp64Milli(1739318400000) AND TimeUnix <= fromUnixTimestamp64Milli(1765670400000))' +
123+
' GROUP BY toStartOfInterval(toDateTime(TimeUnix), INTERVAL 5 minute) AS `__hdx_time_bucket`' +
124+
' ORDER BY toStartOfInterval(toDateTime(TimeUnix), INTERVAL 5 minute) AS `__hdx_time_bucket`' +
125+
' WITH FILL FROM toUnixTimestamp(toStartOfInterval(fromUnixTimestamp64Milli(1739318400000), INTERVAL 5 minute))\n' +
126+
' TO toUnixTimestamp(toStartOfInterval(fromUnixTimestamp64Milli(1765670400000), INTERVAL 5 minute))\n' +
127+
' STEP 300' +
128+
' LIMIT 10',
129+
);
130+
});
131+
132+
it('should generate sql for a single histogram metric', async () => {
133+
const config: ChartConfigWithOptDateRange = {
134+
displayType: DisplayType.Line,
135+
connection: 'test-connection',
136+
// metricTables is added from the Source object via spread operator
137+
metricTables: {
138+
gauge: 'otel_metrics_gauge',
139+
histogram: 'otel_metrics_histogram',
140+
sum: 'otel_metrics_sum',
141+
},
142+
from: {
143+
databaseName: 'default',
144+
tableName: '',
145+
},
146+
select: [
147+
{
148+
aggFn: 'quantile',
149+
level: 0.5,
150+
valueExpression: 'Value',
151+
metricName: 'http.server.duration',
152+
metricType: MetricsDataType.Histogram,
153+
},
154+
],
155+
where: '',
156+
whereLanguage: 'sql',
157+
timestampValueExpression: 'TimeUnix',
158+
dateRange: [new Date('2025-02-12'), new Date('2025-12-14')],
159+
limit: { limit: 10 },
160+
};
161+
162+
const generatedSql = await renderChartConfig(config, mockMetadata);
163+
const actual = parameterizedQueryToSql(generatedSql);
164+
expect(actual).toBe(
165+
'WITH HistRate AS (SELECT *, any(BucketCounts) OVER (ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS PrevBucketCounts,\n' +
166+
' any(CountLength) OVER (ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS PrevCountLength,\n' +
167+
' any(AttributesHash) OVER (ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS PrevAttributesHash,\n' +
168+
' IF(AggregationTemporality = 1,\n' +
169+
' BucketCounts,\n' +
170+
' IF(AttributesHash = PrevAttributesHash AND CountLength = PrevCountLength,\n' +
171+
' arrayMap((prev, curr) -> IF(curr < prev, curr, toUInt64(toInt64(curr) - toInt64(prev))), PrevBucketCounts, BucketCounts),\n' +
172+
' BucketCounts)) as BucketRates\n' +
173+
' FROM (\n' +
174+
' SELECT *, cityHash64(mapConcat(ScopeAttributes, ResourceAttributes, Attributes)) AS AttributesHash,\n' +
175+
' length(BucketCounts) as CountLength\n' +
176+
' FROM default.otel_metrics_histogram)\n' +
177+
" WHERE MetricName = 'http.server.duration'\n " +
178+
' ORDER BY Attributes, TimeUnix ASC\n' +
179+
' ),RawHist AS (\n' +
180+
' SELECT *, toUInt64( 0.5 * arraySum(BucketRates)) AS Rank,\n' +
181+
' arrayCumSum(BucketRates) as CumRates,\n' +
182+
' arrayFirstIndex(x -> if(x > Rank, 1, 0), CumRates) AS BucketLowIdx,\n' +
183+
' IF(BucketLowIdx = length(BucketRates),\n' +
184+
' ExplicitBounds[length(ExplicitBounds)], -- if the low bound is the last bucket, use the last bound value\n' +
185+
' IF(BucketLowIdx > 1, -- indexes are 1-based\n' +
186+
' ExplicitBounds[BucketLowIdx] + (ExplicitBounds[BucketLowIdx + 1] - ExplicitBounds[BucketLowIdx]) *\n' +
187+
' intDivOrZero(\n' +
188+
' Rank - CumRates[BucketLowIdx - 1],\n' +
189+
' CumRates[BucketLowIdx] - CumRates[BucketLowIdx - 1]),\n' +
190+
' arrayElement(ExplicitBounds, BucketLowIdx + 1) * intDivOrZero(Rank, CumRates[BucketLowIdx]))) as Rate\n' +
191+
' FROM HistRate) SELECT sum(\n' +
192+
' toFloat64OrNull(toString(Rate))\n' +
193+
' )' +
194+
' FROM RawHist' +
195+
' WHERE (TimeUnix >= fromUnixTimestamp64Milli(1739318400000) AND TimeUnix <= fromUnixTimestamp64Milli(1765670400000))' +
196+
' LIMIT 10',
121197
);
122198
});
123199
});

packages/common-utils/src/clickhouse.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,11 @@ export const concatChSql = (sep: string, ...args: (ChSql | ChSql[])[]) => {
186186
}
187187

188188
acc.sql +=
189-
(acc.sql.length > 0 ? sep : '') + arg.map(a => a.sql).join(sep);
189+
(acc.sql.length > 0 ? sep : '') +
190+
arg
191+
.map(a => a.sql)
192+
.filter(Boolean) // skip empty string expressions
193+
.join(sep);
190194
acc.params = arg.reduce((acc, a) => {
191195
Object.assign(acc, a.params);
192196
return acc;

packages/common-utils/src/renderChartConfig.ts

Lines changed: 116 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ const aggFnExpr = ({
292292

293293
async function renderSelectList(
294294
selectList: SelectList,
295-
chartConfig: ChartConfigWithOptDateRateAndCte,
295+
chartConfig: ChartConfigWithOptDateRangeEx,
296296
metadata: Metadata,
297297
) {
298298
if (typeof selectList === 'string') {
@@ -467,7 +467,7 @@ async function timeFilterExpr({
467467
}
468468

469469
async function renderSelect(
470-
chartConfig: ChartConfigWithOptDateRateAndCte,
470+
chartConfig: ChartConfigWithOptDateRangeEx,
471471
metadata: Metadata,
472472
): Promise<ChSql> {
473473
/**
@@ -565,7 +565,7 @@ async function renderWhereExpression({
565565
}
566566

567567
async function renderWhere(
568-
chartConfig: ChartConfigWithOptDateRateAndCte,
568+
chartConfig: ChartConfigWithOptDateRangeEx,
569569
metadata: Metadata,
570570
): Promise<ChSql> {
571571
let whereSearchCondition: ChSql | [] = [];
@@ -729,28 +729,64 @@ function renderLimit(
729729

730730
// CTE (Common Table Expressions) isn't exported at this time. It's only used internally
731731
// for metric SQL generation.
732-
type ChartConfigWithOptDateRateAndCte = ChartConfigWithOptDateRange & {
732+
type ChartConfigWithOptDateRangeEx = ChartConfigWithOptDateRange & {
733733
with?: { name: string; sql: ChSql }[];
734734
};
735735

736736
function renderWith(
737-
chartConfig: ChartConfigWithOptDateRateAndCte,
737+
chartConfig: ChartConfigWithOptDateRangeEx,
738738
metadata: Metadata,
739739
): ChSql | undefined {
740740
const { with: withClauses } = chartConfig;
741741
if (withClauses) {
742742
return concatChSql(
743-
'',
744-
withClauses.map(clause => chSql`WITH ${clause.name} AS (${clause.sql})`),
743+
',',
744+
withClauses.map(clause => chSql`${clause.name} AS (${clause.sql})`),
745745
);
746746
}
747747

748748
return undefined;
749749
}
750750

751+
function intervalToSeconds(interval: SQLInterval): number {
752+
// Parse interval string like "15 second" into number of seconds
753+
const [amount, unit] = interval.split(' ');
754+
const value = parseInt(amount, 10);
755+
switch (unit) {
756+
case 'second':
757+
return value;
758+
case 'minute':
759+
return value * 60;
760+
case 'hour':
761+
return value * 60 * 60;
762+
case 'day':
763+
return value * 24 * 60 * 60;
764+
default:
765+
throw new Error(`Invalid interval unit ${unit} in interval ${interval}`);
766+
}
767+
}
768+
769+
function renderFill(
770+
chartConfig: ChartConfigWithOptDateRangeEx,
771+
): ChSql | undefined {
772+
const { granularity, dateRange } = chartConfig;
773+
if (dateRange && granularity && granularity !== 'auto') {
774+
const [start, end] = dateRange;
775+
const step = intervalToSeconds(granularity);
776+
777+
return concatChSql(' ', [
778+
chSql`FROM toUnixTimestamp(toStartOfInterval(fromUnixTimestamp64Milli(${{ Int64: start.getTime() }}), INTERVAL ${granularity}))
779+
TO toUnixTimestamp(toStartOfInterval(fromUnixTimestamp64Milli(${{ Int64: end.getTime() }}), INTERVAL ${granularity}))
780+
STEP ${{ Int32: step }}`,
781+
]);
782+
}
783+
784+
return undefined;
785+
}
786+
751787
function translateMetricChartConfig(
752788
chartConfig: ChartConfigWithOptDateRange,
753-
): ChartConfigWithOptDateRateAndCte {
789+
): ChartConfigWithOptDateRangeEx {
754790
const metricTables = chartConfig.metricTables;
755791
if (!metricTables) {
756792
return chartConfig;
@@ -810,8 +846,67 @@ function translateMetricChartConfig(
810846
databaseName: '',
811847
tableName: 'RawSum',
812848
},
813-
where: `MetricName = '${metricName}'`,
814-
whereLanguage: 'sql',
849+
};
850+
} else if (metricType === MetricsDataType.Histogram && metricName) {
851+
// histograms are only valid for quantile selections
852+
const { aggFn, level, ..._selectRest } = _select as {
853+
aggFn: string;
854+
level?: number;
855+
};
856+
857+
if (aggFn !== 'quantile' || level == null) {
858+
throw new Error('quantile must be specified for histogram metrics');
859+
}
860+
861+
return {
862+
...restChartConfig,
863+
with: [
864+
{
865+
name: 'HistRate',
866+
sql: chSql`SELECT *, any(BucketCounts) OVER (ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS PrevBucketCounts,
867+
any(CountLength) OVER (ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS PrevCountLength,
868+
any(AttributesHash) OVER (ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS PrevAttributesHash,
869+
IF(AggregationTemporality = 1,
870+
BucketCounts,
871+
IF(AttributesHash = PrevAttributesHash AND CountLength = PrevCountLength,
872+
arrayMap((prev, curr) -> IF(curr < prev, curr, toUInt64(toInt64(curr) - toInt64(prev))), PrevBucketCounts, BucketCounts),
873+
BucketCounts)) as BucketRates
874+
FROM (
875+
SELECT *, cityHash64(mapConcat(ScopeAttributes, ResourceAttributes, Attributes)) AS AttributesHash,
876+
length(BucketCounts) as CountLength
877+
FROM ${renderFrom({ from: { ...from, tableName: metricTables[MetricsDataType.Histogram] } })})
878+
WHERE MetricName = '${metricName}'
879+
ORDER BY Attributes, TimeUnix ASC
880+
`,
881+
},
882+
{
883+
name: 'RawHist',
884+
sql: chSql`
885+
SELECT *, toUInt64( ${{ Float64: level }} * arraySum(BucketRates)) AS Rank,
886+
arrayCumSum(BucketRates) as CumRates,
887+
arrayFirstIndex(x -> if(x > Rank, 1, 0), CumRates) AS BucketLowIdx,
888+
IF(BucketLowIdx = length(BucketRates),
889+
ExplicitBounds[length(ExplicitBounds)], -- if the low bound is the last bucket, use the last bound value
890+
IF(BucketLowIdx > 1, -- indexes are 1-based
891+
ExplicitBounds[BucketLowIdx] + (ExplicitBounds[BucketLowIdx + 1] - ExplicitBounds[BucketLowIdx]) *
892+
intDivOrZero(
893+
Rank - CumRates[BucketLowIdx - 1],
894+
CumRates[BucketLowIdx] - CumRates[BucketLowIdx - 1]),
895+
arrayElement(ExplicitBounds, BucketLowIdx + 1) * intDivOrZero(Rank, CumRates[BucketLowIdx]))) as Rate
896+
FROM HistRate`,
897+
},
898+
],
899+
select: [
900+
{
901+
..._selectRest,
902+
aggFn: 'sum',
903+
valueExpression: 'Rate',
904+
},
905+
],
906+
from: {
907+
databaseName: '',
908+
tableName: 'RawHist',
909+
},
815910
};
816911
}
817912

@@ -835,15 +930,19 @@ export async function renderChartConfig(
835930
const where = await renderWhere(chartConfig, metadata);
836931
const groupBy = await renderGroupBy(chartConfig, metadata);
837932
const orderBy = renderOrderBy(chartConfig);
933+
const fill = renderFill(chartConfig);
838934
const limit = renderLimit(chartConfig);
839935

840-
return chSql`${
841-
withClauses?.sql ? chSql`${withClauses}` : ''
842-
}SELECT ${select} FROM ${from} ${where?.sql ? chSql`WHERE ${where}` : ''} ${
843-
groupBy?.sql ? chSql`GROUP BY ${groupBy}` : ''
844-
} ${orderBy?.sql ? chSql`ORDER BY ${orderBy}` : ''} ${
845-
limit?.sql ? chSql`LIMIT ${limit}` : ''
846-
}`;
936+
return concatChSql(' ', [
937+
chSql`${withClauses?.sql ? chSql`WITH ${withClauses}` : ''}`,
938+
chSql`SELECT ${select}`,
939+
chSql`FROM ${from}`,
940+
chSql`${where.sql ? chSql`WHERE ${where}` : ''}`,
941+
chSql`${groupBy?.sql ? chSql`GROUP BY ${groupBy}` : ''}`,
942+
chSql`${orderBy?.sql ? chSql`ORDER BY ${orderBy}` : ''}`,
943+
chSql`${fill?.sql ? chSql`WITH FILL ${fill}` : ''}`,
944+
chSql`${limit?.sql ? chSql`LIMIT ${limit}` : ''}`,
945+
]);
847946
}
848947

849948
// EditForm -> translateToQueriedChartConfig -> QueriedChartConfig

0 commit comments

Comments
 (0)