Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/slow-trainers-hope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@hyperdx/common-utils": minor
"@hyperdx/app": minor
---

add apdex support for histograms
4 changes: 4 additions & 0 deletions packages/app/src/ChartUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ export const AGG_FNS = [
},
{ value: 'any' as const, label: 'Any' },
{ value: 'none' as const, label: 'None' },
{
group: 'Extra',
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@teeohhem I mentioned this in discord a while back so wanted to just draw your attention to the UX here. I wasn't sure the best way to display this as a non-aggregation function. This makes it appear as its own group.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three things:

  1. Let's add a top group labeled "Aggregations"
  2. Let's rename this to "Derived Metrics"
  3. Let's update the label for now to Apdex (histogram only)

I'd also prefer it if this is disabled or validates into an error state when a non-histogram is selected. Let me know if you need a hand and I can help with some of this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do! I think I can get at the selected metric type, will have to investigate if that's available via a watch.

items: [{ value: 'apdex' as const, label: 'Apdex' }],
},
];

export const getMetricAggFns = (
Expand Down
21 changes: 21 additions & 0 deletions packages/app/src/components/ApdexThresholdInput.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div>Threshold</div>
<NumberInput placeholder="Your metric target" hideControls {...field} />
</>
);
};
12 changes: 10 additions & 2 deletions packages/app/src/components/DBEditTimeChartForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,14 @@ import {
import { SortingState } from '@tanstack/react-table';

import {
AGG_FNS,
buildTableRowSearchUrl,
convertToNumberChartConfig,
convertToTableChartConfig,
convertToTimeChartConfig,
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';
Expand Down Expand Up @@ -334,9 +334,17 @@ function ChartSeriesEditorComponent({
<AggFnSelectControlled
aggFnName={`${namePrefix}aggFn`}
quantileLevelName={`${namePrefix}level`}
defaultValue={AGG_FNS[0].value}
defaultValue={'count'}
control={control}
/>
{aggFn === 'apdex' && (
<Group mt="xxs" gap="xs">
<ApdexThresholdInput
name={`${namePrefix}threshold`}
control={control}
/>
</Group>
)}
</div>
{tableSource?.kind === SourceKind.Metric && metricType && (
<div style={{ minWidth: 220 }}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
113 changes: 113 additions & 0 deletions packages/common-utils/src/__tests__/renderChartConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading