diff --git a/.changeset/slow-trainers-hope.md b/.changeset/slow-trainers-hope.md new file mode 100644 index 0000000000..6217e1627a --- /dev/null +++ b/.changeset/slow-trainers-hope.md @@ -0,0 +1,6 @@ +--- +"@hyperdx/common-utils": minor +"@hyperdx/app": minor +--- + +add apdex support for histograms diff --git a/packages/app/src/ChartUtils.tsx b/packages/app/src/ChartUtils.tsx index f8f813786d..4276f30e51 100644 --- a/packages/app/src/ChartUtils.tsx +++ b/packages/app/src/ChartUtils.tsx @@ -75,6 +75,10 @@ export const AGG_FNS = [ }, { value: 'any' as const, label: 'Any' }, { value: 'none' as const, label: 'None' }, + { + group: 'Extra', + items: [{ value: 'apdex' as const, label: 'Apdex' }], + }, ]; export const getMetricAggFns = ( diff --git a/packages/app/src/components/ApdexThresholdInput.tsx b/packages/app/src/components/ApdexThresholdInput.tsx new file mode 100644 index 0000000000..fc2f937d11 --- /dev/null +++ b/packages/app/src/components/ApdexThresholdInput.tsx @@ -0,0 +1,21 @@ +import { Control, useController } from 'react-hook-form'; +import { NumberInput } from '@mantine/core'; + +export const ApdexThresholdInput = ({ + name, + control, +}: { + name: string; + control: Control; +}) => { + const { field } = useController({ + name, + control, + }); + return ( + <> +
Threshold
+ + + ); +}; diff --git a/packages/app/src/components/DBEditTimeChartForm.tsx b/packages/app/src/components/DBEditTimeChartForm.tsx index 7b7c8991a0..27145a577f 100644 --- a/packages/app/src/components/DBEditTimeChartForm.tsx +++ b/packages/app/src/components/DBEditTimeChartForm.tsx @@ -65,7 +65,6 @@ import { import { SortingState } from '@tanstack/react-table'; import { - AGG_FNS, buildTableRowSearchUrl, convertToNumberChartConfig, convertToTableChartConfig, @@ -73,6 +72,7 @@ import { getPreviousDateRange, } from '@/ChartUtils'; import { AlertChannelForm, getAlertReferenceLines } from '@/components/Alerts'; +import { ApdexThresholdInput } from '@/components/ApdexThresholdInput'; import ChartSQLPreview from '@/components/ChartSQLPreview'; import DBTableChart from '@/components/DBTableChart'; import { DBTimeChart } from '@/components/DBTimeChart'; @@ -334,9 +334,17 @@ function ChartSeriesEditorComponent({ + {aggFn === 'apdex' && ( + + + + )} {tableSource?.kind === SourceKind.Metric && metricType && (
diff --git a/packages/common-utils/src/__tests__/__snapshots__/renderChartConfig.test.ts.snap b/packages/common-utils/src/__tests__/__snapshots__/renderChartConfig.test.ts.snap index 15b2108ed1..1efb85817e 100644 --- a/packages/common-utils/src/__tests__/__snapshots__/renderChartConfig.test.ts.snap +++ b/packages/common-utils/src/__tests__/__snapshots__/renderChartConfig.test.ts.snap @@ -32,6 +32,192 @@ 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 SETTINGS optimize_read_in_order = 0, cast_keep_nullable = 1, additional_result_filter = 'x != 2', count_distinct_implementation = 'uniqCombined64', async_insert_busy_timeout_min_ms = 20000) 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 SETTINGS optimize_read_in_order = 0, cast_keep_nullable = 1, additional_result_filter = 'x != 2', count_distinct_implementation = 'uniqCombined64', async_insert_busy_timeout_min_ms = 20000"`; +exports[`renderChartConfig histogram metric queries apdex should generate a query with grouping and time bucketing 1`] = ` +"WITH source AS ( + SELECT + ExplicitBounds, + toStartOfInterval(toDateTime(TimeUnix), INTERVAL 2 minute) AS \`__hdx_time_bucket\`, + [ResourceAttributes['host']] AS group, + sumForEach(deltas) AS bucket_counts, + 0.5 AS threshold + FROM ( + SELECT + TimeUnix, + AggregationTemporality, + ExplicitBounds, + ResourceAttributes, + Attributes, + 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 + OR prev_attr_hash != attr_hash + OR bounds_hash != prev_bounds_hash + OR arrayExists((x) -> x.2 < x.1, arrayZip(prev_counts, counts)), + counts, + counts - prev_counts + ) AS deltas + FROM ( + SELECT + TimeUnix, + AggregationTemporality, + ExplicitBounds, + ResourceAttributes, + Attributes, + cityHash64(mapConcat(ScopeAttributes, ResourceAttributes, Attributes)) AS attr_hash, + cityHash64(ExplicitBounds) AS bounds_hash, + CAST(BucketCounts AS Array(Int64)) AS counts + 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')) + ORDER BY attr_hash, TimeUnix ASC + ) + ) + GROUP BY \`__hdx_time_bucket\`, group, ExplicitBounds + ORDER BY \`__hdx_time_bucket\` + ),metrics AS ( + SELECT + \`__hdx_time_bucket\`, + group, + arrayResize(ExplicitBounds, length(bucket_counts), inf) AS safe_bounds, + arraySum((delta, bound) -> if(bound <= threshold, delta, 0), bucket_counts, safe_bounds) AS satisfied, + arraySum((delta, bound) -> if(bound > threshold AND bound <= (threshold * 4), delta, 0), bucket_counts, safe_bounds) AS tolerating, + arraySum((delta, bound) -> if(bound > (threshold * 4), delta, 0), bucket_counts, safe_bounds) AS frustrated, + if( + satisfied + tolerating + frustrated > 0, + (satisfied + tolerating * 0.5) / (satisfied + tolerating + frustrated), + NULL + ) AS \\"Value\\" + FROM source + ) 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 apdex should generate a query without grouping but time bucketing 1`] = ` +"WITH source AS ( + SELECT + ExplicitBounds, + toStartOfInterval(toDateTime(TimeUnix), INTERVAL 2 minute) AS \`__hdx_time_bucket\`, + + sumForEach(deltas) AS bucket_counts, + 0.5 AS threshold + FROM ( + SELECT + TimeUnix, + AggregationTemporality, + ExplicitBounds, + ResourceAttributes, + Attributes, + 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 + OR prev_attr_hash != attr_hash + OR bounds_hash != prev_bounds_hash + OR arrayExists((x) -> x.2 < x.1, arrayZip(prev_counts, counts)), + counts, + counts - prev_counts + ) AS deltas + FROM ( + SELECT + TimeUnix, + AggregationTemporality, + ExplicitBounds, + ResourceAttributes, + Attributes, + cityHash64(mapConcat(ScopeAttributes, ResourceAttributes, Attributes)) AS attr_hash, + cityHash64(ExplicitBounds) AS bounds_hash, + CAST(BucketCounts AS Array(Int64)) AS counts + 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')) + ORDER BY attr_hash, TimeUnix ASC + ) + ) + GROUP BY \`__hdx_time_bucket\`, ExplicitBounds + ORDER BY \`__hdx_time_bucket\` + ),metrics AS ( + SELECT + \`__hdx_time_bucket\`, + + arrayResize(ExplicitBounds, length(bucket_counts), inf) AS safe_bounds, + arraySum((delta, bound) -> if(bound <= threshold, delta, 0), bucket_counts, safe_bounds) AS satisfied, + arraySum((delta, bound) -> if(bound > threshold AND bound <= (threshold * 4), delta, 0), bucket_counts, safe_bounds) AS tolerating, + arraySum((delta, bound) -> if(bound > (threshold * 4), delta, 0), bucket_counts, safe_bounds) AS frustrated, + if( + satisfied + tolerating + frustrated > 0, + (satisfied + tolerating * 0.5) / (satisfied + tolerating + frustrated), + NULL + ) AS \\"Value\\" + FROM source + ) 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 apdex should generate a query without grouping or time bucketing 1`] = ` +"WITH source AS ( + SELECT + ExplicitBounds, + TimeUnix AS \`__hdx_time_bucket\`, + + sumForEach(deltas) AS bucket_counts, + 0.5 AS threshold + FROM ( + SELECT + TimeUnix, + AggregationTemporality, + ExplicitBounds, + ResourceAttributes, + Attributes, + 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 + OR prev_attr_hash != attr_hash + OR bounds_hash != prev_bounds_hash + OR arrayExists((x) -> x.2 < x.1, arrayZip(prev_counts, counts)), + counts, + counts - prev_counts + ) AS deltas + FROM ( + SELECT + TimeUnix, + AggregationTemporality, + ExplicitBounds, + ResourceAttributes, + Attributes, + cityHash64(mapConcat(ScopeAttributes, ResourceAttributes, Attributes)) AS attr_hash, + cityHash64(ExplicitBounds) AS bounds_hash, + CAST(BucketCounts AS Array(Int64)) AS counts + FROM default.otel_metrics_histogram + WHERE (TimeUnix >= fromUnixTimestamp64Milli(1739318400000) AND TimeUnix <= fromUnixTimestamp64Milli(1765670400000)) AND ((MetricName = 'http.server.duration')) + ORDER BY attr_hash, TimeUnix ASC + ) + ) + GROUP BY \`__hdx_time_bucket\`, ExplicitBounds + ORDER BY \`__hdx_time_bucket\` + ),metrics AS ( + SELECT + \`__hdx_time_bucket\`, + + arrayResize(ExplicitBounds, length(bucket_counts), inf) AS safe_bounds, + arraySum((delta, bound) -> if(bound <= threshold, delta, 0), bucket_counts, safe_bounds) AS satisfied, + arraySum((delta, bound) -> if(bound > threshold AND bound <= (threshold * 4), delta, 0), bucket_counts, safe_bounds) AS tolerating, + arraySum((delta, bound) -> if(bound > (threshold * 4), delta, 0), bucket_counts, safe_bounds) AS frustrated, + if( + satisfied + tolerating + frustrated > 0, + (satisfied + tolerating * 0.5) / (satisfied + tolerating + frustrated), + NULL + ) AS \\"Value\\" + FROM source + ) 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 with grouping and time bucketing 1`] = ` "WITH source AS ( SELECT diff --git a/packages/common-utils/src/__tests__/renderChartConfig.test.ts b/packages/common-utils/src/__tests__/renderChartConfig.test.ts index fe6bf7286b..c7272365f1 100644 --- a/packages/common-utils/src/__tests__/renderChartConfig.test.ts +++ b/packages/common-utils/src/__tests__/renderChartConfig.test.ts @@ -434,6 +434,119 @@ describe('renderChartConfig', () => { expect(actual).toMatchSnapshot(); }); }); + + describe('apdex', () => { + 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: 'apdex', + threshold: 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(); + }); + + 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: 'apdex', + threshold: 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: 'apdex', + threshold: 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(); + }); + }); }); describe('containing CTE clauses', () => { diff --git a/packages/common-utils/src/core/histogram.ts b/packages/common-utils/src/core/histogram.ts index b64c2577f8..b14fe1087b 100644 --- a/packages/common-utils/src/core/histogram.ts +++ b/packages/common-utils/src/core/histogram.ts @@ -26,6 +26,15 @@ export const translateHistogram = ({ if (select.aggFn === 'count') { return translateHistogramCount(rest); } + if (select.aggFn === 'apdex') { + if (!('threshold' in select) || select.threshold == null) { + throw new Error('apdex must have a threshold'); + } + return translateHistogramApdex({ + ...rest, + threshold: select.threshold, + }); + } throw new Error(`${select.aggFn} is not supported for histograms currently`); }; @@ -187,3 +196,86 @@ const translateHistogramQuantile = ({ `, }, ]; + +export const translateHistogramApdex = ({ + threshold, + timeBucketSelect, + groupBy, + from, + where, + valueAlias, +}: { + threshold: number; + timeBucketSelect: TemplatedInput; + groupBy?: TemplatedInput; + from: TemplatedInput; + where: TemplatedInput; + valueAlias: TemplatedInput; +}): WithClauses => [ + { + name: 'source', + sql: chSql` + SELECT + ExplicitBounds, + ${timeBucketSelect}, + ${groupBy ? chSql`[${groupBy}] AS group,` : ''} + sumForEach(deltas) AS bucket_counts, + ${{ Float64: threshold }} AS threshold + FROM ( + SELECT + TimeUnix, + AggregationTemporality, + ExplicitBounds, + ResourceAttributes, + Attributes, + 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 + OR prev_attr_hash != attr_hash + OR bounds_hash != prev_bounds_hash + OR arrayExists((x) -> x.2 < x.1, arrayZip(prev_counts, counts)), + counts, + counts - prev_counts + ) AS deltas + FROM ( + SELECT + TimeUnix, + AggregationTemporality, + ExplicitBounds, + ResourceAttributes, + Attributes, + cityHash64(mapConcat(ScopeAttributes, ResourceAttributes, Attributes)) AS attr_hash, + cityHash64(ExplicitBounds) AS bounds_hash, + CAST(BucketCounts AS Array(Int64)) AS counts + FROM ${from} + WHERE ${where} + ORDER BY attr_hash, TimeUnix ASC + ) + ) + GROUP BY \`__hdx_time_bucket\`, ${groupBy ? 'group, ' : ''}ExplicitBounds + ORDER BY \`__hdx_time_bucket\` + `, + }, + { + name: 'metrics', + sql: chSql` + SELECT + \`__hdx_time_bucket\`, + ${groupBy ? 'group,' : ''} + arrayResize(ExplicitBounds, length(bucket_counts), inf) AS safe_bounds, + arraySum((delta, bound) -> if(bound <= threshold, delta, 0), bucket_counts, safe_bounds) AS satisfied, + arraySum((delta, bound) -> if(bound > threshold AND bound <= (threshold * 4), delta, 0), bucket_counts, safe_bounds) AS tolerating, + arraySum((delta, bound) -> if(bound > (threshold * 4), delta, 0), bucket_counts, safe_bounds) AS frustrated, + if( + satisfied + tolerating + frustrated > 0, + (satisfied + tolerating * 0.5) / (satisfied + tolerating + frustrated), + NULL + ) AS "${valueAlias}" + FROM source + `, + }, +]; diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 5b1f23243c..c4c752c781 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -102,6 +102,17 @@ export const RootValueExpressionSchema = z isDelta: z.boolean().optional(), }), ) + .or( + z.object({ + aggFn: z.literal('apdex'), + aggCondition: SearchConditionSchema, + aggConditionLanguage: SearchConditionLanguageSchema, + valueExpression: z.string(), + valueExpressionLanguage: z.undefined().optional(), + isDelta: z.boolean().optional(), + threshold: z.number().optional(), + }), + ) .or( z.object({ aggFn: z.string().optional(),