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(),