feat: add support for visualizing histogram counts (#1477)

Adds support for histogram `count` aggregations. This partially resolves https://github.com/hyperdxio/hyperdx/issues/1441, which should probably be split into a new ticket to only address `sum`.

As part of this, I also moved the translation functionality for histograms to a new file `histogram.ts` to avoid contributing even more bloat to `renderChartConfig`. Happy to revert this and move that stuff back into the file if that's preferred.

I also noticed by doing this that there was actually a SQL error in the snapshots for the tests--the existing quantile test was missing a trailing `,` after the time bucket if no group was provided https://github.com/hyperdxio/hyperdx/blob/main/packages/common-utils/src/__tests__/__snapshots__/renderChartConfig.test.ts.snap#L194 so centralizing like this is probably desirable to keep things consistent.

I also personally use webstorm so I added that stuff to the gitignore.
This commit is contained in:
Sam Garfinkel 2025-12-23 17:56:20 -05:00 committed by GitHub
parent 12cd6433b7
commit ca693c0f56
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 542 additions and 228 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/common-utils": minor
---
Add support for visualizing histogram counts

5
.gitignore vendored
View file

@ -73,4 +73,7 @@ docker-compose.prod.yml
.volumes
# NX
.nx/
.nx/
# webstorm
.idea

View file

@ -26,7 +26,100 @@ exports[`renderChartConfig containing CTE clauses should render a ChSql CTE conf
exports[`renderChartConfig containing CTE clauses should render a chart config CTE configuration correctly 1`] = `"WITH Parts AS (SELECT _part, _part_offset FROM default.some_table WHERE ((FieldA = 'test')) ORDER BY rand() DESC LIMIT 1000) SELECT * FROM Parts WHERE ((FieldA = 'test') AND (indexHint((_part, _part_offset) IN (SELECT tuple(_part, _part_offset) FROM Parts)))) ORDER BY rand() DESC LIMIT 1000"`;
exports[`renderChartConfig histogram metric queries should generate a query with grouping and time bucketing 1`] = `
exports[`renderChartConfig histogram metric queries count should generate a count query with grouping and time bucketing 1`] = `
"WITH source AS (
SELECT
TimeUnix,
AggregationTemporality,
toStartOfInterval(toDateTime(TimeUnix), INTERVAL 2 minute) AS \`__hdx_time_bucket\`,
[ResourceAttributes['host']] AS group,
cityHash64(mapConcat(ScopeAttributes, ResourceAttributes, Attributes)) AS attr_hash,
cityHash64(ExplicitBounds) AS bounds_hash,
toInt64(Count) AS current_count,
lagInFrame(toNullable(current_count), 1, NULL) OVER (
PARTITION BY group, attr_hash, bounds_hash, AggregationTemporality
ORDER BY TimeUnix
) AS prev_count,
CASE
WHEN AggregationTemporality = 1 THEN current_count
WHEN AggregationTemporality = 2 THEN greatest(0, current_count - coalesce(prev_count, 0))
ELSE 0
END AS delta
FROM default.otel_metrics_histogram
WHERE (TimeUnix >= toStartOfInterval(fromUnixTimestamp64Milli(1739318400000), INTERVAL 2 minute) - INTERVAL 2 minute AND TimeUnix <= toStartOfInterval(fromUnixTimestamp64Milli(1765670400000), INTERVAL 2 minute) + INTERVAL 2 minute) AND ((MetricName = 'http.server.duration'))
),metrics AS (
SELECT
\`__hdx_time_bucket\`,
group,
sum(delta) AS \\"Value\\"
FROM source
GROUP BY group, \`__hdx_time_bucket\`
) SELECT \`__hdx_time_bucket\`, group, \\"Value\\" FROM metrics WHERE (\`__hdx_time_bucket\` >= fromUnixTimestamp64Milli(1739318400000) AND \`__hdx_time_bucket\` <= fromUnixTimestamp64Milli(1765670400000)) LIMIT 10 SETTINGS short_circuit_function_evaluation = 'force_enable'"
`;
exports[`renderChartConfig histogram metric queries count should generate a count query without grouping but time bucketing 1`] = `
"WITH source AS (
SELECT
TimeUnix,
AggregationTemporality,
toStartOfInterval(toDateTime(TimeUnix), INTERVAL 2 minute) AS \`__hdx_time_bucket\`,
cityHash64(mapConcat(ScopeAttributes, ResourceAttributes, Attributes)) AS attr_hash,
cityHash64(ExplicitBounds) AS bounds_hash,
toInt64(Count) AS current_count,
lagInFrame(toNullable(current_count), 1, NULL) OVER (
PARTITION BY attr_hash, bounds_hash, AggregationTemporality
ORDER BY TimeUnix
) AS prev_count,
CASE
WHEN AggregationTemporality = 1 THEN current_count
WHEN AggregationTemporality = 2 THEN greatest(0, current_count - coalesce(prev_count, 0))
ELSE 0
END AS delta
FROM default.otel_metrics_histogram
WHERE (TimeUnix >= toStartOfInterval(fromUnixTimestamp64Milli(1739318400000), INTERVAL 2 minute) - INTERVAL 2 minute AND TimeUnix <= toStartOfInterval(fromUnixTimestamp64Milli(1765670400000), INTERVAL 2 minute) + INTERVAL 2 minute) AND ((MetricName = 'http.server.duration'))
),metrics AS (
SELECT
\`__hdx_time_bucket\`,
sum(delta) AS \\"Value\\"
FROM source
GROUP BY \`__hdx_time_bucket\`
) SELECT \`__hdx_time_bucket\`, \\"Value\\" FROM metrics WHERE (\`__hdx_time_bucket\` >= fromUnixTimestamp64Milli(1739318400000) AND \`__hdx_time_bucket\` <= fromUnixTimestamp64Milli(1765670400000)) LIMIT 10 SETTINGS short_circuit_function_evaluation = 'force_enable'"
`;
exports[`renderChartConfig histogram metric queries count should generate a count query without grouping or time bucketing 1`] = `
"WITH source AS (
SELECT
TimeUnix,
AggregationTemporality,
TimeUnix AS \`__hdx_time_bucket\`,
cityHash64(mapConcat(ScopeAttributes, ResourceAttributes, Attributes)) AS attr_hash,
cityHash64(ExplicitBounds) AS bounds_hash,
toInt64(Count) AS current_count,
lagInFrame(toNullable(current_count), 1, NULL) OVER (
PARTITION BY attr_hash, bounds_hash, AggregationTemporality
ORDER BY TimeUnix
) AS prev_count,
CASE
WHEN AggregationTemporality = 1 THEN current_count
WHEN AggregationTemporality = 2 THEN greatest(0, current_count - coalesce(prev_count, 0))
ELSE 0
END AS delta
FROM default.otel_metrics_histogram
WHERE (TimeUnix >= fromUnixTimestamp64Milli(1739318400000) AND TimeUnix <= fromUnixTimestamp64Milli(1765670400000)) AND ((MetricName = 'http.server.duration'))
),metrics AS (
SELECT
\`__hdx_time_bucket\`,
sum(delta) AS \\"Value\\"
FROM source
GROUP BY \`__hdx_time_bucket\`
) SELECT \`__hdx_time_bucket\`, \\"Value\\" FROM metrics WHERE (\`__hdx_time_bucket\` >= fromUnixTimestamp64Milli(1739318400000) AND \`__hdx_time_bucket\` <= fromUnixTimestamp64Milli(1765670400000)) LIMIT 10 SETTINGS short_circuit_function_evaluation = 'force_enable'"
`;
exports[`renderChartConfig histogram metric queries quantile should generate a query with grouping and time bucketing 1`] = `
"WITH source AS (
SELECT
MetricName,
@ -110,7 +203,7 @@ exports[`renderChartConfig histogram metric queries should generate a query with
) SELECT \`__hdx_time_bucket\`, group, \\"Value\\" FROM metrics WHERE (\`__hdx_time_bucket\` >= fromUnixTimestamp64Milli(1739318400000) AND \`__hdx_time_bucket\` <= fromUnixTimestamp64Milli(1765670400000)) LIMIT 10 SETTINGS short_circuit_function_evaluation = 'force_enable'"
`;
exports[`renderChartConfig histogram metric queries should generate a query without grouping but time bucketing 1`] = `
exports[`renderChartConfig histogram metric queries quantile should generate a query without grouping but time bucketing 1`] = `
"WITH source AS (
SELECT
MetricName,
@ -194,12 +287,12 @@ exports[`renderChartConfig histogram metric queries should generate a query with
) SELECT \`__hdx_time_bucket\`, \\"Value\\" FROM metrics WHERE (\`__hdx_time_bucket\` >= fromUnixTimestamp64Milli(1739318400000) AND \`__hdx_time_bucket\` <= fromUnixTimestamp64Milli(1765670400000)) LIMIT 10 SETTINGS short_circuit_function_evaluation = 'force_enable'"
`;
exports[`renderChartConfig histogram metric queries should generate a query without grouping or time bucketing 1`] = `
exports[`renderChartConfig histogram metric queries quantile should generate a query without grouping or time bucketing 1`] = `
"WITH source AS (
SELECT
MetricName,
ExplicitBounds,
TimeUnix AS \`__hdx_time_bucket\`
TimeUnix AS \`__hdx_time_bucket\`,
sumForEach(deltas) as rates
FROM (

View file

@ -167,115 +167,227 @@ describe('renderChartConfig', () => {
});
describe('histogram metric queries', () => {
it('should generate a query without grouping or time bucketing', async () => {
const config: ChartConfigWithOptDateRange = {
displayType: DisplayType.Line,
connection: 'test-connection',
metricTables: {
gauge: 'otel_metrics_gauge',
histogram: 'otel_metrics_histogram',
sum: 'otel_metrics_sum',
summary: 'otel_metrics_summary',
'exponential histogram': 'otel_metrics_exponential_histogram',
},
from: {
databaseName: 'default',
tableName: '',
},
select: [
{
aggFn: 'quantile',
level: 0.5,
valueExpression: 'Value',
metricName: 'http.server.duration',
metricType: MetricsDataType.Histogram,
describe('quantile', () => {
it('should generate a query without grouping or time bucketing', async () => {
const config: ChartConfigWithOptDateRange = {
displayType: DisplayType.Line,
connection: 'test-connection',
metricTables: {
gauge: 'otel_metrics_gauge',
histogram: 'otel_metrics_histogram',
sum: 'otel_metrics_sum',
summary: 'otel_metrics_summary',
'exponential histogram': 'otel_metrics_exponential_histogram',
},
],
where: '',
whereLanguage: 'sql',
timestampValueExpression: 'TimeUnix',
dateRange: [new Date('2025-02-12'), new Date('2025-12-14')],
limit: { limit: 10 },
};
from: {
databaseName: 'default',
tableName: '',
},
select: [
{
aggFn: 'quantile',
level: 0.5,
valueExpression: 'Value',
metricName: 'http.server.duration',
metricType: MetricsDataType.Histogram,
},
],
where: '',
whereLanguage: 'sql',
timestampValueExpression: 'TimeUnix',
dateRange: [new Date('2025-02-12'), new Date('2025-12-14')],
limit: { limit: 10 },
};
const generatedSql = await renderChartConfig(config, mockMetadata);
const actual = parameterizedQueryToSql(generatedSql);
expect(actual).toMatchSnapshot();
const generatedSql = await renderChartConfig(config, mockMetadata);
const actual = parameterizedQueryToSql(generatedSql);
expect(actual).toMatchSnapshot();
});
it('should generate a query without grouping but time bucketing', async () => {
const config: ChartConfigWithOptDateRange = {
displayType: DisplayType.Line,
connection: 'test-connection',
metricTables: {
gauge: 'otel_metrics_gauge',
histogram: 'otel_metrics_histogram',
sum: 'otel_metrics_sum',
summary: 'otel_metrics_summary',
'exponential histogram': 'otel_metrics_exponential_histogram',
},
from: {
databaseName: 'default',
tableName: '',
},
select: [
{
aggFn: 'quantile',
level: 0.5,
valueExpression: 'Value',
metricName: 'http.server.duration',
metricType: MetricsDataType.Histogram,
},
],
where: '',
whereLanguage: 'sql',
timestampValueExpression: 'TimeUnix',
dateRange: [new Date('2025-02-12'), new Date('2025-12-14')],
granularity: '2 minute',
limit: { limit: 10 },
};
const generatedSql = await renderChartConfig(config, mockMetadata);
const actual = parameterizedQueryToSql(generatedSql);
expect(actual).toMatchSnapshot();
});
it('should generate a query with grouping and time bucketing', async () => {
const config: ChartConfigWithOptDateRange = {
displayType: DisplayType.Line,
connection: 'test-connection',
metricTables: {
gauge: 'otel_metrics_gauge',
histogram: 'otel_metrics_histogram',
sum: 'otel_metrics_sum',
summary: 'otel_metrics_summary',
'exponential histogram': 'otel_metrics_exponential_histogram',
},
from: {
databaseName: 'default',
tableName: '',
},
select: [
{
aggFn: 'quantile',
level: 0.5,
valueExpression: 'Value',
metricName: 'http.server.duration',
metricType: MetricsDataType.Histogram,
},
],
where: '',
whereLanguage: 'sql',
timestampValueExpression: 'TimeUnix',
dateRange: [new Date('2025-02-12'), new Date('2025-12-14')],
granularity: '2 minute',
groupBy: `ResourceAttributes['host']`,
limit: { limit: 10 },
};
const generatedSql = await renderChartConfig(config, mockMetadata);
const actual = parameterizedQueryToSql(generatedSql);
expect(actual).toMatchSnapshot();
});
});
it('should generate a query without grouping but time bucketing', async () => {
const config: ChartConfigWithOptDateRange = {
displayType: DisplayType.Line,
connection: 'test-connection',
metricTables: {
gauge: 'otel_metrics_gauge',
histogram: 'otel_metrics_histogram',
sum: 'otel_metrics_sum',
summary: 'otel_metrics_summary',
'exponential histogram': 'otel_metrics_exponential_histogram',
},
from: {
databaseName: 'default',
tableName: '',
},
select: [
{
aggFn: 'quantile',
level: 0.5,
valueExpression: 'Value',
metricName: 'http.server.duration',
metricType: MetricsDataType.Histogram,
describe('count', () => {
it('should generate a count query without grouping or time bucketing', async () => {
const config: ChartConfigWithOptDateRange = {
displayType: DisplayType.Line,
connection: 'test-connection',
metricTables: {
gauge: 'otel_metrics_gauge',
histogram: 'otel_metrics_histogram',
sum: 'otel_metrics_sum',
summary: 'otel_metrics_summary',
'exponential histogram': 'otel_metrics_exponential_histogram',
},
],
where: '',
whereLanguage: 'sql',
timestampValueExpression: 'TimeUnix',
dateRange: [new Date('2025-02-12'), new Date('2025-12-14')],
granularity: '2 minute',
limit: { limit: 10 },
};
const generatedSql = await renderChartConfig(config, mockMetadata);
const actual = parameterizedQueryToSql(generatedSql);
expect(actual).toMatchSnapshot();
});
it('should generate a query with grouping and time bucketing', async () => {
const config: ChartConfigWithOptDateRange = {
displayType: DisplayType.Line,
connection: 'test-connection',
metricTables: {
gauge: 'otel_metrics_gauge',
histogram: 'otel_metrics_histogram',
sum: 'otel_metrics_sum',
summary: 'otel_metrics_summary',
'exponential histogram': 'otel_metrics_exponential_histogram',
},
from: {
databaseName: 'default',
tableName: '',
},
select: [
{
aggFn: 'quantile',
level: 0.5,
valueExpression: 'Value',
metricName: 'http.server.duration',
metricType: MetricsDataType.Histogram,
from: {
databaseName: 'default',
tableName: '',
},
],
where: '',
whereLanguage: 'sql',
timestampValueExpression: 'TimeUnix',
dateRange: [new Date('2025-02-12'), new Date('2025-12-14')],
granularity: '2 minute',
groupBy: `ResourceAttributes['host']`,
limit: { limit: 10 },
};
select: [
{
aggFn: 'count',
valueExpression: 'Value',
metricName: 'http.server.duration',
metricType: MetricsDataType.Histogram,
},
],
where: '',
whereLanguage: 'sql',
timestampValueExpression: 'TimeUnix',
dateRange: [new Date('2025-02-12'), new Date('2025-12-14')],
limit: { limit: 10 },
};
const generatedSql = await renderChartConfig(config, mockMetadata);
const actual = parameterizedQueryToSql(generatedSql);
expect(actual).toMatchSnapshot();
const generatedSql = await renderChartConfig(config, mockMetadata);
const actual = parameterizedQueryToSql(generatedSql);
expect(actual).toMatchSnapshot();
});
it('should generate a count query without grouping but time bucketing', async () => {
const config: ChartConfigWithOptDateRange = {
displayType: DisplayType.Line,
connection: 'test-connection',
metricTables: {
gauge: 'otel_metrics_gauge',
histogram: 'otel_metrics_histogram',
sum: 'otel_metrics_sum',
summary: 'otel_metrics_summary',
'exponential histogram': 'otel_metrics_exponential_histogram',
},
from: {
databaseName: 'default',
tableName: '',
},
select: [
{
aggFn: 'count',
valueExpression: 'Value',
metricName: 'http.server.duration',
metricType: MetricsDataType.Histogram,
},
],
where: '',
whereLanguage: 'sql',
timestampValueExpression: 'TimeUnix',
dateRange: [new Date('2025-02-12'), new Date('2025-12-14')],
granularity: '2 minute',
limit: { limit: 10 },
};
const generatedSql = await renderChartConfig(config, mockMetadata);
const actual = parameterizedQueryToSql(generatedSql);
expect(actual).toMatchSnapshot();
});
it('should generate a count query with grouping and time bucketing', async () => {
const config: ChartConfigWithOptDateRange = {
displayType: DisplayType.Line,
connection: 'test-connection',
metricTables: {
gauge: 'otel_metrics_gauge',
histogram: 'otel_metrics_histogram',
sum: 'otel_metrics_sum',
summary: 'otel_metrics_summary',
'exponential histogram': 'otel_metrics_exponential_histogram',
},
from: {
databaseName: 'default',
tableName: '',
},
select: [
{
aggFn: 'count',
valueExpression: 'Value',
metricName: 'http.server.duration',
metricType: MetricsDataType.Histogram,
},
],
where: '',
whereLanguage: 'sql',
timestampValueExpression: 'TimeUnix',
dateRange: [new Date('2025-02-12'), new Date('2025-12-14')],
granularity: '2 minute',
groupBy: `ResourceAttributes['host']`,
limit: { limit: 10 },
};
const generatedSql = await renderChartConfig(config, mockMetadata);
const actual = parameterizedQueryToSql(generatedSql);
expect(actual).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,189 @@
import { ChSql, chSql } from '@/clickhouse';
import { ChartConfig } from '@/types';
type WithClauses = ChartConfig['with'];
type TemplatedInput = ChSql | string;
export const translateHistogram = ({
select,
...rest
}: {
select: Exclude<ChartConfig['select'], string>[number];
timeBucketSelect: TemplatedInput;
groupBy?: TemplatedInput;
from: TemplatedInput;
where: TemplatedInput;
valueAlias: TemplatedInput;
}) => {
if (select.aggFn === 'quantile') {
if (!('level' in select) || select.level === null)
throw new Error('quantile must have a level');
return translateHistogramQuantile({
...rest,
level: select.level,
});
}
if (select.aggFn === 'count') {
return translateHistogramCount(rest);
}
throw new Error(`${select.aggFn} is not supported for histograms currently`);
};
const translateHistogramCount = ({
timeBucketSelect,
groupBy,
from,
where,
valueAlias,
}: {
timeBucketSelect: TemplatedInput;
groupBy?: TemplatedInput;
from: TemplatedInput;
where: TemplatedInput;
valueAlias: TemplatedInput;
}): WithClauses => [
{
name: 'source',
sql: chSql`
SELECT
TimeUnix,
AggregationTemporality,
${timeBucketSelect},
${groupBy ? chSql`[${groupBy}] AS group,` : ''}
cityHash64(mapConcat(ScopeAttributes, ResourceAttributes, Attributes)) AS attr_hash,
cityHash64(ExplicitBounds) AS bounds_hash,
toInt64(Count) AS current_count,
lagInFrame(toNullable(current_count), 1, NULL) OVER (
PARTITION BY ${groupBy ? `group, ` : ''} attr_hash, bounds_hash, AggregationTemporality
ORDER BY TimeUnix
) AS prev_count,
CASE
WHEN AggregationTemporality = 1 THEN current_count
WHEN AggregationTemporality = 2 THEN greatest(0, current_count - coalesce(prev_count, 0))
ELSE 0
END AS delta
FROM ${from}
WHERE ${where}
`,
},
{
name: 'metrics',
sql: chSql`
SELECT
\`__hdx_time_bucket\`,
${groupBy ? 'group,' : ''}
sum(delta) AS "${valueAlias}"
FROM source
GROUP BY ${groupBy ? 'group, ' : ''}\`__hdx_time_bucket\`
`,
},
];
const translateHistogramQuantile = ({
timeBucketSelect,
groupBy,
from,
where,
valueAlias,
level,
}: {
timeBucketSelect: TemplatedInput;
groupBy?: TemplatedInput;
from: TemplatedInput;
where: TemplatedInput;
valueAlias: TemplatedInput;
level: number;
}): WithClauses => [
{
name: 'source',
sql: chSql`
SELECT
MetricName,
ExplicitBounds,
${timeBucketSelect},
${groupBy ? chSql`[${groupBy}] as group,` : ''}
sumForEach(deltas) as rates
FROM (
SELECT
TimeUnix,
MetricName,
ResourceAttributes,
Attributes,
ExplicitBounds,
attr_hash,
any(attr_hash) OVER (ROWS BETWEEN 1 preceding AND 1 preceding) AS prev_attr_hash,
any(bounds_hash) OVER (ROWS BETWEEN 1 preceding AND 1 preceding) AS prev_bounds_hash,
any(counts) OVER (ROWS BETWEEN 1 preceding AND 1 preceding) AS prev_counts,
counts,
IF(
AggregationTemporality = 1 ${'' /* denotes a metric that is not monotonic e.g. already a delta */}
OR prev_attr_hash != attr_hash ${'' /* the attributes have changed so this is a different metric */}
OR bounds_hash != prev_bounds_hash ${'' /* the bucketing has changed so should be treated as different metric */}
OR arrayExists((x) -> x.2 < x.1, arrayZip(prev_counts, counts)), ${'' /* a data point has gone down, probably a reset event */}
counts,
counts - prev_counts
) AS deltas
FROM (
SELECT
TimeUnix,
MetricName,
AggregationTemporality,
ExplicitBounds,
ResourceAttributes,
Attributes,
cityHash64(mapConcat(ScopeAttributes, ResourceAttributes, Attributes)) AS attr_hash,
cityHash64(ExplicitBounds) AS bounds_hash,
CAST(BucketCounts AS Array(Int64)) counts
FROM ${from}
WHERE ${where}
ORDER BY attr_hash, TimeUnix ASC
)
)
GROUP BY \`__hdx_time_bucket\`, MetricName, ${groupBy ? 'group, ' : ''}ExplicitBounds
ORDER BY \`__hdx_time_bucket\`
`,
},
{
name: 'points',
sql: chSql`
SELECT
\`__hdx_time_bucket\`,
MetricName,
${groupBy ? 'group,' : ''}
arrayZipUnaligned(arrayCumSum(rates), ExplicitBounds) as point,
length(point) as n
FROM source
`,
},
{
name: 'metrics',
sql: chSql`
SELECT
\`__hdx_time_bucket\`,
MetricName,
${groupBy ? 'group,' : ''}
point[n].1 AS total,
${{ Float64: level }} * total AS rank,
arrayFirstIndex(x -> if(x.1 > rank, 1, 0), point) AS upper_idx,
point[upper_idx].1 AS upper_count,
ifNull(point[upper_idx].2, inf) AS upper_bound,
CASE
WHEN upper_idx > 1 THEN point[upper_idx - 1].2
WHEN point[upper_idx].2 > 0 THEN 0
ELSE inf
END AS lower_bound,
if (
lower_bound = 0,
0,
point[upper_idx - 1].1
) AS lower_count,
CASE
WHEN upper_bound = inf THEN point[upper_idx - 1].2
WHEN lower_bound = inf THEN point[1].2
ELSE lower_bound + (upper_bound - lower_bound) * ((rank - lower_count) / (upper_count - lower_count))
END AS "${valueAlias}"
FROM points
WHERE length(point) > 1 AND total > 0
`,
},
];

View file

@ -3,23 +3,8 @@ import * as SQLParser from 'node-sql-parser';
import SqlString from 'sqlstring';
import { ChSql, chSql, concatChSql, wrapChSqlIfNotEmpty } from '@/clickhouse';
import { translateHistogram } from '@/core/histogram';
import { Metadata } from '@/core/metadata';
import { CustomSchemaSQLSerializerV2, SearchQueryBuilder } from '@/queryParser';
/**
* Helper function to create a MetricName filter condition.
* Uses metricNameSql if available (which handles both old and new metric names via OR),
* otherwise falls back to a simple equality check.
*/
function createMetricNameFilter(
metricName: string,
metricNameSql?: string,
): string {
if (metricNameSql) {
return metricNameSql;
}
return SqlString.format('MetricName = ?', [metricName]);
}
import {
convertDateRangeToGranularityString,
convertGranularityToSeconds,
@ -28,6 +13,7 @@ import {
parseToStartOfFunction,
splitAndTrimWithBracket,
} from '@/core/utils';
import { CustomSchemaSQLSerializerV2, SearchQueryBuilder } from '@/queryParser';
import {
AggregateFunction,
AggregateFunctionWithCombinators,
@ -47,6 +33,21 @@ import {
SQLInterval,
} from '@/types';
/**
* Helper function to create a MetricName filter condition.
* Uses metricNameSql if available (which handles both old and new metric names via OR),
* otherwise falls back to a simple equality check.
*/
function createMetricNameFilter(
metricName: string,
metricNameSql?: string,
): string {
if (metricNameSql) {
return metricNameSql;
}
return SqlString.format('MetricName = ?', [metricName]);
}
/** The default maximum number of buckets setting when determining a bucket duration for 'auto' granularity */
export const DEFAULT_AUTO_GRANULARITY_MAX_BUCKETS = 60;
@ -1261,17 +1262,7 @@ async function translateMetricChartConfig(
timestampValueExpression: `\`${timeBucketCol}\``,
};
} else if (metricType === MetricsDataType.Histogram && metricName) {
// histograms are only valid for quantile selections
const { aggFn, level, alias, ..._selectRest } = _select as {
aggFn: string;
level?: number;
alias?: string;
};
if (aggFn !== 'quantile' || level == null) {
throw new Error('quantile must be specified for histogram metrics');
}
const { alias } = _select;
// Use the alias from the select, defaulting to 'Value' for backwards compatibility
const valueAlias = alias || 'Value';
@ -1322,100 +1313,21 @@ async function translateMetricChartConfig(
return {
...restChartConfig,
with: [
{
name: 'source',
sql: chSql`
SELECT
MetricName,
ExplicitBounds,
${timeBucketSelect.sql ? chSql`${timeBucketSelect},` : 'TimeUnix AS `__hdx_time_bucket`'}
${groupBy ? chSql`[${groupBy}] as group,` : ''}
sumForEach(deltas) as rates
FROM (
SELECT
TimeUnix,
MetricName,
ResourceAttributes,
Attributes,
ExplicitBounds,
attr_hash,
any(attr_hash) OVER (ROWS BETWEEN 1 preceding AND 1 preceding) AS prev_attr_hash,
any(bounds_hash) OVER (ROWS BETWEEN 1 preceding AND 1 preceding) AS prev_bounds_hash,
any(counts) OVER (ROWS BETWEEN 1 preceding AND 1 preceding) AS prev_counts,
counts,
IF(
AggregationTemporality = 1 ${'' /* denotes a metric that is not monotonic e.g. already a delta */}
OR prev_attr_hash != attr_hash ${'' /* the attributes have changed so this is a different metric */}
OR bounds_hash != prev_bounds_hash ${'' /* the bucketing has changed so should be treated as different metric */}
OR arrayExists((x) -> x.2 < x.1, arrayZip(prev_counts, counts)), ${'' /* a data point has gone down, probably a reset event */}
counts,
counts - prev_counts
) AS deltas
FROM (
SELECT
TimeUnix,
MetricName,
AggregationTemporality,
ExplicitBounds,
ResourceAttributes,
Attributes,
cityHash64(mapConcat(ScopeAttributes, ResourceAttributes, Attributes)) AS attr_hash,
cityHash64(ExplicitBounds) AS bounds_hash,
CAST(BucketCounts AS Array(Int64)) counts
FROM ${renderFrom({ from: { ...from, tableName: metricTables[MetricsDataType.Histogram] } })}
WHERE ${where}
ORDER BY attr_hash, TimeUnix ASC
)
)
GROUP BY \`__hdx_time_bucket\`, MetricName, ${groupBy ? 'group, ' : ''}ExplicitBounds
ORDER BY \`__hdx_time_bucket\`
`,
},
{
name: 'points',
sql: chSql`
SELECT
\`__hdx_time_bucket\`,
MetricName,
${groupBy ? 'group,' : ''}
arrayZipUnaligned(arrayCumSum(rates), ExplicitBounds) as point,
length(point) as n
FROM source
`,
},
{
name: 'metrics',
sql: chSql`
SELECT
\`__hdx_time_bucket\`,
MetricName,
${groupBy ? 'group,' : ''}
point[n].1 AS total,
${{ Float64: level }} * total AS rank,
arrayFirstIndex(x -> if(x.1 > rank, 1, 0), point) AS upper_idx,
point[upper_idx].1 AS upper_count,
ifNull(point[upper_idx].2, inf) AS upper_bound,
CASE
WHEN upper_idx > 1 THEN point[upper_idx - 1].2
WHEN point[upper_idx].2 > 0 THEN 0
ELSE inf
END AS lower_bound,
if (
lower_bound = 0,
0,
point[upper_idx - 1].1
) AS lower_count,
CASE
WHEN upper_bound = inf THEN point[upper_idx - 1].2
WHEN lower_bound = inf THEN point[1].2
ELSE lower_bound + (upper_bound - lower_bound) * ((rank - lower_count) / (upper_count - lower_count))
END AS "${valueAlias}"
FROM points
WHERE length(point) > 1 AND total > 0
`,
},
],
with: translateHistogram({
select: _select,
timeBucketSelect: timeBucketSelect.sql
? chSql`${timeBucketSelect}`
: 'TimeUnix AS `__hdx_time_bucket`',
groupBy,
from: renderFrom({
from: {
...from,
tableName: metricTables[MetricsDataType.Histogram],
},
}),
where,
valueAlias,
}),
select: `\`__hdx_time_bucket\`${groupBy ? ', group' : ''}, "${valueAlias}"`,
from: {
databaseName: '',