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/fix-series-limit-chunk-consistency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@hyperdx/common-utils': patch
'@hyperdx/app': patch
---

feat(charts): the team "Time Chart Series Limit" setting is now opt-in — it defaults to disabled (charts fetch every series, no limit CTE) and a configured value can be cleared back to disabled from the team settings page. When a limit is set, chunked time-chart queries now keep a consistent top-N series set: previously each time-window chunk ranked its own top-N, so charts could render more series than the limit and adjacent windows disagreed; the ranking is now pinned to the newest chunk window for every chunk. The chart editor's "Generated SQL" preview also reflects the team's configured limit instead of always showing 100.
7 changes: 5 additions & 2 deletions packages/app/src/ChartUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,10 @@ export function convertToTimeChartConfig(
config: ChartConfigWithDateRange,
teamSeriesLimit?: number,
): ChartConfigWithDateRange {
const seriesLimit = Math.max(1, teamSeriesLimit ?? MAX_TIME_CHART_SERIES);
// Series capping is opt-in via the team setting; when unset, no
// __hdx_series_limit CTE is emitted and charts fetch every series.
const seriesLimit =
teamSeriesLimit != null ? Math.max(1, teamSeriesLimit) : undefined;

const granularity = getTimeChartGranularity(
config.granularity,
Expand Down Expand Up @@ -139,7 +142,7 @@ export function convertToTimeChartConfig(
dateRangeEndInclusive,
granularity,
limit: { limit: 100000 },
seriesLimit,
...(seriesLimit != null ? { seriesLimit } : {}),
}
: {
...config,
Expand Down
5 changes: 2 additions & 3 deletions packages/app/src/__tests__/ChartUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
formatResponseForPieChart,
formatResponseForTimeChart,
} from '@/ChartUtils';
import { DEFAULT_SERIES_LIMIT } from '@/defaults';
import { COLORS } from '@/utils';

// Anchor info/error to concrete hexes rather than `getChartColorInfo()` /
Expand Down Expand Up @@ -826,8 +825,8 @@ describe('ChartUtils', () => {
) as BuilderChartConfigWithDateRange
).seriesLimit;

it('defaults seriesLimit to DEFAULT_SERIES_LIMIT when no team value is given', () => {
expect(seriesLimitOf()).toBe(DEFAULT_SERIES_LIMIT);
it('omits seriesLimit (capping disabled) when no team value is given', () => {
expect(seriesLimitOf()).toBeUndefined();
});

it('uses the team seriesLimit when provided', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
IconTable,
} from '@tabler/icons-react';

import api from '@/api';
import { getPreviousDateRange } from '@/ChartUtils';
import ChartDisplaySettingsDrawer, {
ChartConfigDisplaySettings,
Expand Down Expand Up @@ -514,6 +515,8 @@ export default function EditTimeChartForm({
});
}, [dateRange]);

const { data: me } = api.useMe();

const chartConfigForExplanations = useMemo(
() =>
buildChartConfigForExplanations({
Expand All @@ -524,6 +527,7 @@ export default function EditTimeChartForm({
dateRange,
activeTab,
dbTimeChartConfig,
teamSeriesLimit: me?.team?.seriesLimit,
}),
[
queriedConfig,
Expand All @@ -533,6 +537,7 @@ export default function EditTimeChartForm({
dateRange,
activeTab,
dbTimeChartConfig,
me?.team?.seriesLimit,
],
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,37 @@ describe('buildChartConfigForExplanations', () => {
expect(result).toBeDefined();
});

it('applies the team series limit so the SQL preview matches the chart query', () => {
const result = buildChartConfigForExplanations({
...baseParams,
queriedConfig: builderConfig,
queriedSourceId: logSource.id,
tableSource: logSource,
activeTab: 'time',
dbTimeChartConfig: builderConfig,
teamSeriesLimit: 3,
});

expect(result).toBeDefined();
// @ts-expect-error union types..
expect(result!.seriesLimit).toBe(3);
});

it('omits seriesLimit (capping disabled) when no team limit is set', () => {
const result = buildChartConfigForExplanations({
...baseParams,
queriedConfig: builderConfig,
queriedSourceId: logSource.id,
tableSource: logSource,
activeTab: 'time',
dbTimeChartConfig: builderConfig,
});

expect(result).toBeDefined();
// @ts-expect-error union types..
expect(result!.seriesLimit).toBeUndefined();
});

it.each(['table', 'number', 'pie'] as const)(
'uses queriedConfig for activeTab=%s and applies tab transform',
activeTab => {
Expand Down
7 changes: 6 additions & 1 deletion packages/app/src/components/DBEditTimeChartForm/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@ type BuildChartConfigForExplanationsParams = {
dateRange: [Date, Date];
activeTab: string;
dbTimeChartConfig?: ChartConfigWithDateRange;
// The team's configured series cap — must match what DBTimeChart passes to
// convertToTimeChartConfig or the generated SQL preview shows the wrong
// seriesLimit.
teamSeriesLimit?: number;
};

export function buildChartConfigForExplanations({
Expand All @@ -194,6 +198,7 @@ export function buildChartConfigForExplanations({
dateRange,
activeTab,
dbTimeChartConfig,
teamSeriesLimit,
}: BuildChartConfigForExplanationsParams):
| ChartConfigWithOptTimestamp
| undefined {
Expand Down Expand Up @@ -239,7 +244,7 @@ export function buildChartConfigForExplanations({
const builderConfig = config as BuilderChartConfigWithDateRange;

if (activeTab === 'time') {
return convertToTimeChartConfig(builderConfig);
return convertToTimeChartConfig(builderConfig, teamSeriesLimit);
} else if (activeTab === 'number') {
return convertToNumberChartConfig(builderConfig);
} else if (activeTab === 'table') {
Expand Down
52 changes: 35 additions & 17 deletions packages/app/src/components/TeamSettings/TeamQueryConfigSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ interface ClickhouseSettingFormProps {
max?: number;
displayValue?: (value: any, defaultValue?: any) => string;
description?: string;
// For settings with no server-side default: lets the user clear the value
// back to undefined (which disables the feature) instead of resetting to
// a defaultValue.
allowUnset?: boolean;
resetLabel?: string;
}

function getFieldErrorMessage(error: unknown): string | undefined {
Expand All @@ -63,6 +68,8 @@ function ClickhouseSettingForm({
max,
displayValue,
description,
allowUnset = false,
resetLabel = 'Reset to default',
}: ClickhouseSettingFormProps) {
const { data: me, refetch: refetchMe } = api.useMe();
const updateClickhouseSettings = api.useUpdateClickhouseSettings();
Expand Down Expand Up @@ -124,7 +131,7 @@ function ClickhouseSettingForm({
);

const handleReset = useCallback(() => {
if (defaultValue == null) return;
if (defaultValue == null && !allowUnset) return;
updateClickhouseSettings.mutate(
{ [settingKey]: null },
{
Expand All @@ -137,9 +144,12 @@ function ClickhouseSettingForm({
onSuccess: () => {
notifications.show({
color: 'green',
message: `Reset ${label} to default`,
message:
defaultValue != null
? `Reset ${label} to default`
: `Updated ${label}`,
});
form.reset({ value: defaultValue });
form.reset({ value: defaultValue ?? '' });
refetchMe();
setIsEditing(false);
},
Expand All @@ -151,6 +161,7 @@ function ClickhouseSettingForm({
settingKey,
label,
defaultValue,
allowUnset,
form,
]);

Expand Down Expand Up @@ -255,16 +266,18 @@ function ClickhouseSettingForm({
Change
</Button>
)}
{hasAdminAccess && isCustomValue && defaultValue != null && (
<Button
size="xs"
variant="subtle"
loading={updateClickhouseSettings.isPending}
onClick={handleReset}
>
Reset to default
</Button>
)}
{hasAdminAccess &&
isCustomValue &&
(defaultValue != null || allowUnset) && (
<Button
size="xs"
variant="subtle"
loading={updateClickhouseSettings.isPending}
onClick={handleReset}
>
{resetLabel}
</Button>
)}
</Group>
)}
</Stack>
Expand Down Expand Up @@ -347,12 +360,17 @@ export default function TeamQueryConfigSection() {
<ClickhouseSettingForm
settingKey="seriesLimit"
label="Time Chart Series Limit"
tooltip="Maximum number of series fetched per time chart."
tooltip="Maximum number of series fetched per group-by time chart. Disabled by default — charts fetch every series."
type="number"
defaultValue={DEFAULT_SERIES_LIMIT}
placeholder={`default = ${DEFAULT_SERIES_LIMIT}`}
placeholder={`e.g. ${DEFAULT_SERIES_LIMIT}`}
min={1}
displayValue={displayValueWithUnit('series')}
displayValue={value =>
value == null
? 'Disabled'
: `${Number(value).toLocaleString()} series`
}
allowUnset
resetLabel="Disable"
/>
</Stack>
</Card>
Expand Down
93 changes: 93 additions & 0 deletions packages/app/src/hooks/__tests__/useChartConfig.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,68 @@ describe('useChartConfig', () => {
expect(result.current.isPending).toBe(false);
});

it('pins the series-limit ranking to the newest window on each chunk', async () => {
const config = createMockChartConfig({
dateRange: [
new Date('2025-10-01 00:00:00Z'),
new Date('2025-10-02 00:00:00Z'),
],
granularity: '3 hour',
seriesLimit: 3,
});

mockClickhouseClient.queryChartConfig.mockResolvedValue(
createMockQueryResponse([]),
);

const { result } = renderHook(
() => useQueriedChartConfig(config, { enableQueryChunking: true }),
{ wrapper },
);

await waitFor(() => expect(result.current.isSuccess).toBe(true));
await waitFor(() => expect(result.current.isFetching).toBe(false));

// Each chunk queries its own window but must rank top-N series over
// the same fixed range, or the union across chunks exceeds the limit.
// The newest window (first 6h mock window) bounds the ranking scan.
const newestWindow: [Date, Date] = [
new Date('2025-10-01T18:00:00.000Z'),
new Date('2025-10-02T00:00:00.000Z'),
];
const calls = mockClickhouseClient.queryChartConfig.mock.calls;
expect(calls).toHaveLength(3);
for (const [{ config: windowed }] of calls) {
expect(windowed.seriesLimitDateRange).toEqual(newestWindow);
}
});

it('does not set seriesLimitDateRange when the query is not chunked', async () => {
const config = createMockChartConfig({
dateRange: [
new Date('2025-10-01 00:00:00Z'),
new Date('2025-10-02 00:00:00Z'),
],
granularity: '3 hour',
seriesLimit: 3,
});

mockClickhouseClient.queryChartConfig.mockResolvedValue(
createMockQueryResponse([]),
);

const { result } = renderHook(() => useQueriedChartConfig(config), {
wrapper,
});

await waitFor(() => expect(result.current.isSuccess).toBe(true));
await waitFor(() => expect(result.current.isFetching).toBe(false));

const calls = mockClickhouseClient.queryChartConfig.mock.calls;
expect(calls).toHaveLength(1);
expect('seriesLimitDateRange' in calls[0][0].config).toBe(false);
});

it('remains in a fetching state, with partial data until all data is loaded', async () => {
const config = createMockChartConfig({
dateRange: [
Expand Down Expand Up @@ -1327,6 +1389,37 @@ describe('useChartConfig', () => {
});
});

it('pins the series-limit ranking to the newest window with parallel queries', async () => {
const { config } = setupParallelQueries();
const configWithLimit = { ...config, seriesLimit: 3 };

mockClickhouseClient.queryChartConfig.mockResolvedValue(
createMockQueryResponse([]),
);

const { result } = renderHook(
() =>
useQueriedChartConfig(configWithLimit, {
enableQueryChunking: true,
enableParallelQueries: true,
}),
{ wrapper },
);

await waitFor(() => expect(result.current.isSuccess).toBe(true));
await waitFor(() => expect(result.current.isFetching).toBe(false));

const newestWindow: [Date, Date] = [
new Date('2025-10-01T18:00:00.000Z'),
new Date('2025-10-02T00:00:00.000Z'),
];
const calls = mockClickhouseClient.queryChartConfig.mock.calls;
expect(calls).toHaveLength(3);
for (const [{ config: windowed }] of calls) {
expect(windowed.seriesLimitDateRange).toEqual(newestWindow);
}
});

it('should not execute query while useMVOptimizationExplanation is in loading state', async () => {
const config = createMockChartConfig({
dateRange: [
Expand Down
Loading
Loading