Skip to content
Merged
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/gorgeous-parrots-tie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@hyperdx/api": minor
"@hyperdx/app": minor
---

Add support for increase aggFn on sum counter metrics and rewrite sum metric rate computation to fix correctness issues.
83 changes: 83 additions & 0 deletions packages/api/src/clickhouse/__tests__/renderChartConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,89 @@ describe('renderChartConfig', () => {
);
expect(await queryData(maxQuery)).toMatchSnapshot('maxSum');
});

it('new series appearing mid-window: v2 suppresses first-row spike', async () => {
// Scenario: An existing series (customerA) has data from the start,
// and a new series (customerB) appears mid-window with a high
// cumulative counter value (e.g. 10000). A naive inter-bucket diff
// approach would leak the full cumulative value as a spike on the
// first bucket of customerB. The v2 lagInFrame-based Rate yields 0
// for the first row of each series, preventing the spike.

// Use a unique metric name to isolate from the beforeEach data.
const metricName = 'customer.events.total';

// customerA: present from the start, steady increase
const customerAPoints = [
{ value: 0, timestamp: now - ms('1m') },
{ value: 10, timestamp: now + ms('2m') },
{ value: 20, timestamp: now + ms('7m') },
{ value: 30, timestamp: now + ms('12m') },
{ value: 40, timestamp: now + ms('17m') },
].map(point => ({
MetricName: metricName,
ServiceName: 'api',
ResourceAttributes: { customer: 'customerA' },
Value: point.value,
TimeUnix: new Date(point.timestamp),
IsMonotonic: true,
AggregationTemporality: 2, // Cumulative
}));

// customerB: appears at +10m with a high cumulative value (10000),
// then increases to 10050 by +17m. The real per-bucket increase is
// only 50. A naive approach would show 10000 in the bucket where it
// first appears.
const customerBPoints = [
{ value: 10000, timestamp: now + ms('10m') },
{ value: 10020, timestamp: now + ms('12m') },
{ value: 10050, timestamp: now + ms('17m') },
].map(point => ({
MetricName: metricName,
ServiceName: 'api',
ResourceAttributes: { customer: 'customerB' },
Value: point.value,
TimeUnix: new Date(point.timestamp),
IsMonotonic: true,
AggregationTemporality: 2, // Cumulative
}));

await bulkInsertMetricsSum([...customerAPoints, ...customerBPoints]);

// --- v2 query (current code with lagInFrame) ---
const v2Query = await renderChartConfig(
{
select: [
{
aggFn: 'sum',
metricName: metricName,
metricType: MetricsDataType.Sum,
valueExpression: 'Value',
},
],
from: metricSource.from,
where: '',
metricTables: TEST_METRIC_TABLES,
dateRange: [new Date(now), new Date(now + ms('20m'))],
granularity: '5 minute',
timestampValueExpression: metricSource.timestampValueExpression,
connection: connection.id,
},
metadata,
querySettings,
);
const v2Results = await queryData(v2Query);

// v2 should NOT have a spike — the max bucket value should be much
// smaller than customerB's initial cumulative value (10000). The real
// per-bucket increases across both series are at most ~50.
const v2Values = v2Results.map((r: any) => Number(r.Value));
const v2MaxValue = Math.max(...v2Values);
expect(v2MaxValue).toBeLessThan(100);

// Verify we actually got data back (not an empty result set).
expect(v2Results.length).toBeGreaterThan(0);
});
});

describe('Query Metrics - Histogram', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/ChartUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const AGG_FNS = [
isAttributable: false,
},
{ value: 'any' as const, label: 'Any' },
{ value: 'increase' as const, label: 'Increase', isAttributable: false },
{ value: 'none' as const, label: 'Custom' },
];

Expand Down
17 changes: 16 additions & 1 deletion packages/app/src/components/AggFnSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback, useMemo } from 'react';
import { useController, UseControllerProps } from 'react-hook-form';
import { MetricsDataType } from '@hyperdx/common-utils/dist/types';
import { Select } from '@mantine/core';

import { AGG_FNS } from '@/ChartUtils';
Expand All @@ -14,11 +15,13 @@ function AggFnSelect({
defaultValue,
onChange,
hideCustom,
metricType,
}: {
value: string;
defaultValue: string;
onChange: (value: OnChangeValue) => void;
hideCustom?: boolean;
metricType?: MetricsDataType;
}) {
const _onChange = useCallback(
(value: string | null) => {
Expand All @@ -37,14 +40,23 @@ function AggFnSelect({
[onChange],
);

const options = useMemo(() => {
let opts = hideCustom ? AGG_FNS.filter(fn => fn.value !== 'none') : AGG_FNS;
// Only show 'increase' when the source is a Sum (counter) metric.
if (metricType !== MetricsDataType.Sum) {
opts = opts.filter(fn => fn.value !== 'increase');
}
return opts;
}, [hideCustom, metricType]);

return (
<Select
withScrollArea={false}
searchable
value={value}
defaultValue={defaultValue}
onChange={_onChange}
data={hideCustom ? AGG_FNS.filter(fn => fn.value !== 'none') : AGG_FNS}
data={options}
data-testid="agg-fn-select"
/>
);
Expand All @@ -55,12 +67,14 @@ export function AggFnSelectControlled({
quantileLevelName,
defaultValue,
hideCustom,
metricType,
...props
}: {
defaultValue: string;
aggFnName: string;
quantileLevelName: string;
hideCustom?: boolean;
metricType?: MetricsDataType;
} & Omit<UseControllerProps<any>, 'name'>) {
const {
field: { onChange: onAggFnChange, value: aggFnValue },
Expand Down Expand Up @@ -101,6 +115,7 @@ export function AggFnSelectControlled({
defaultValue={defaultValue}
onChange={onChange}
hideCustom={hideCustom}
metricType={metricType}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,18 @@ export function ChartSeriesEditor({
}
}, [tableSource?.kind, metricType, aggFn, namePrefix, setValue]);

// 'increase' aggFn is only valid on Sum metrics. Reset it if the user
// switches to a different metric type or source kind so the backend does
// not error on a stale 'increase' selection.
useEffect(() => {
const isSumMetric =
tableSource?.kind === SourceKind.Metric &&
metricType === MetricsDataType.Sum;
if (!isSumMetric && aggFn === 'increase') {
setValue(`${namePrefix}aggFn`, 'sum');
}
}, [tableSource?.kind, metricType, aggFn, namePrefix, setValue]);

const tableName =
tableSource?.kind === SourceKind.Metric
? getMetricTableName(tableSource, metricType)
Expand Down Expand Up @@ -273,6 +285,9 @@ export function ChartSeriesEditor({
defaultValue={AGG_FNS[0]?.value ?? 'avg'}
control={control}
hideCustom={tableSource?.kind === SourceKind.Metric}
metricType={
tableSource?.kind === SourceKind.Metric ? metricType : undefined
}
/>
</div>
{tableSource?.kind === SourceKind.Metric && metricType && (
Expand Down
Loading
Loading