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/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 time-chart series limit is now configured per chart in the Display Settings drawer instead of as a workspace-wide team setting (the team "Time Chart Series Limit" setting is removed). It is disabled by default — charts fetch every series and no `__hdx_series_limit` CTE is emitted — and is cleared back to disabled by emptying the field. The control only appears for builder line/bar charts; the limit and its Generated SQL preview now come from the chart's own config. When a limit is set, chunked time-chart queries 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 so the union across chunks equals the limit.
1 change: 0 additions & 1 deletion packages/api/src/models/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ export default mongoose.model<ITeam>(
fieldMetadataDisabled: Boolean,
parallelizeWhenPossible: Boolean,
filterKeysFetchLimit: Number,
seriesLimit: Number,
},
{
timestamps: true,
Expand Down
11 changes: 9 additions & 2 deletions packages/app/src/ChartUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,14 @@ export const MAX_TIME_CHART_SERIES = DEFAULT_SERIES_LIMIT;

export function convertToTimeChartConfig(
config: ChartConfigWithDateRange,
teamSeriesLimit?: number,
): ChartConfigWithDateRange {
const seriesLimit = Math.max(1, teamSeriesLimit ?? MAX_TIME_CHART_SERIES);
// Series capping is opt-in per tile via the chart's Display Settings; when
// unset, no __hdx_series_limit CTE is emitted and every series is fetched.
const seriesLimit = isBuilderChartConfig(config)
? config.seriesLimit != null
? Math.max(1, config.seriesLimit)
: undefined
: undefined;

const granularity = getTimeChartGranularity(
config.granularity,
Expand Down Expand Up @@ -139,6 +144,8 @@ export function convertToTimeChartConfig(
dateRangeEndInclusive,
granularity,
limit: { limit: 100000 },
// Overwrite (not conditionally spread) so a cleared `null` from the
// source config is normalized to undefined rather than carried over.
seriesLimit,
}
: {
Expand Down
38 changes: 19 additions & 19 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 @@ -808,33 +807,34 @@ describe('ChartUtils', () => {
expect(granularityFromFunction).toBe('5 minute');
});

const seriesLimitConfig = {
granularity: '5 minute',
dateRange: [
new Date('2025-11-26T00:00:00Z'),
new Date('2025-11-27T00:00:00Z'),
],
} as BuilderChartConfigWithDateRange;

// seriesLimit lives on the builder member of the ChartConfigWithDateRange
// union, so narrow the result before reading it.
const seriesLimitOf = (teamSeriesLimit?: number) =>
// union, so narrow the result before reading it. The per-tile value is
// read from the config itself (no team override anymore).
const seriesLimitOf = (seriesLimit?: number | null) =>
(
convertToTimeChartConfig(
seriesLimitConfig,
teamSeriesLimit,
) as BuilderChartConfigWithDateRange
convertToTimeChartConfig({
granularity: '5 minute',
dateRange: [
new Date('2025-11-26T00:00:00Z'),
new Date('2025-11-27T00:00:00Z'),
],
...(seriesLimit !== undefined ? { seriesLimit } : {}),
} as BuilderChartConfigWithDateRange) 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 the tile has no limit', () => {
expect(seriesLimitOf()).toBeUndefined();
});

it('normalizes a cleared (null) seriesLimit to undefined (disabled)', () => {
expect(seriesLimitOf(null)).toBeUndefined();
});

it('uses the team seriesLimit when provided', () => {
it('uses the tile seriesLimit when provided', () => {
expect(seriesLimitOf(5)).toBe(5);
});

it('passes a large team seriesLimit through unbounded', () => {
it('passes a large tile seriesLimit through unbounded', () => {
expect(seriesLimitOf(100000)).toBe(100000);
});
});
Expand Down
36 changes: 36 additions & 0 deletions packages/app/src/components/ChartDisplaySettingsDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ import {
Divider,
Drawer,
Group,
NumberInput,
Stack,
Text,
} from '@mantine/core';

import { shouldFillNullsWithZero } from '@/ChartUtils';
import { DEFAULT_SERIES_LIMIT } from '@/defaults';
import { FormatTime } from '@/useFormatTime';

import {
Expand All @@ -41,6 +43,11 @@ export type ChartConfigDisplaySettings = Pick<
| 'colorRules'
> & {
groupByColumnsOnLeft?: boolean;
// Per-tile cap on the number of series fetched for a group-by time chart.
// null/undefined = disabled (no __hdx_series_limit CTE; every series is
// fetched). The editor clears to `null` (not `undefined`) so the cleared
// state survives JSON round-tripping through the URL query state.
seriesLimit?: number | null;
};

/**
Expand Down Expand Up @@ -81,6 +88,9 @@ function applyDefaultSettings(
compareToPreviousPeriod: settings.compareToPreviousPeriod ?? false,
fitYAxisToData: settings.fitYAxisToData ?? false,
groupByColumnsOnLeft: settings.groupByColumnsOnLeft ?? false,
// Coerce to null so `reset` clears the input; undefined leaves the
// previously registered field value in place.
seriesLimit: settings.seriesLimit ?? null,
color: settings.color,
colorRules: settings.colorRules
? attachLocalIds(settings.colorRules)
Expand Down Expand Up @@ -144,6 +154,10 @@ export default function ChartDisplaySettingsDrawer({
const isTimeChart =
displayType === DisplayType.Line || displayType === DisplayType.StackedBar;

// The series-limit CTE is only emitted for builder group-by time charts;
// raw SQL configs author their own LIMIT logic directly.
const showSeriesLimit = isTimeChart && configType !== 'sql';

// Group By column ordering only applies to builder table charts; raw SQL
// configs let the user author whatever column order they want directly.
const showGroupByColumnsOnLeft =
Expand Down Expand Up @@ -203,6 +217,28 @@ export default function ChartDisplaySettingsDrawer({
label="Fit Y-Axis to Data"
description="Start the y-axis at the minimum of the displayed data instead of zero. Only applicable to line charts."
/>
{showSeriesLimit && (
Comment thread
wrn14897 marked this conversation as resolved.
<Box>
<Controller
control={control}
name="seriesLimit"
render={({ field: { onChange, value } }) => (
<NumberInput
size="xs"
label="Series Limit"
description="Maximum number of series fetched for a group-by chart. Leave empty to fetch every series."
placeholder={`Disabled (e.g. ${DEFAULT_SERIES_LIMIT})`}
min={1}
allowDecimal={false}
value={value ?? ''}
onChange={v =>
onChange(v === '' || v == null ? null : Number(v))
}
/>
)}
/>
</Box>
)}
<Divider />
</>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ export default function EditTimeChartForm({
fitYAxisToData,
numberFormat,
groupByColumnsOnLeft,
seriesLimit,
color,
colorRules,
] = useWatch({
Expand All @@ -254,6 +255,7 @@ export default function EditTimeChartForm({
'fitYAxisToData',
'numberFormat',
'groupByColumnsOnLeft',
'seriesLimit',
'color',
'colorRules',
],
Expand All @@ -276,6 +278,7 @@ export default function EditTimeChartForm({
fitYAxisToData,
numberFormat,
groupByColumnsOnLeft,
seriesLimit,
color,
colorRules,
}),
Expand All @@ -286,6 +289,7 @@ export default function EditTimeChartForm({
fitYAxisToData,
numberFormat,
groupByColumnsOnLeft,
seriesLimit,
color,
colorRules,
],
Expand Down Expand Up @@ -568,6 +572,7 @@ export default function EditTimeChartForm({
compareToPreviousPeriod,
fitYAxisToData,
groupByColumnsOnLeft,
seriesLimit,
color,
colorRules,
}: ChartConfigDisplaySettings) => {
Expand All @@ -577,6 +582,10 @@ export default function EditTimeChartForm({
setValue('compareToPreviousPeriod', compareToPreviousPeriod);
setValue('fitYAxisToData', fitYAxisToData);
setValue('groupByColumnsOnLeft', groupByColumnsOnLeft);
// Persist `null` (not undefined) when cleared so the disabled state
// survives JSON round-tripping through the URL query state — otherwise
// the dropped key lets RHF's `values` sync restore the stale value.
setValue('seriesLimit', seriesLimit ?? null);
setValue('color', color);
setValue('colorRules', colorRules);
onSubmit();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,39 @@ describe('buildChartConfigForExplanations', () => {
expect(result).toBeDefined();
});

it("applies the tile's 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,
seriesLimit: 3,
} as ChartConfigWithDateRange,
});

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

it('omits seriesLimit (capping disabled) when the tile has no limit', () => {
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
4 changes: 2 additions & 2 deletions packages/app/src/components/DBTimeChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -290,8 +290,8 @@ function DBTimeChartComponent({
const { data: me, isLoading: isLoadingMe } = api.useMe();

const queriedConfig = useMemo(
() => convertToTimeChartConfig(config, me?.team?.seriesLimit),
[config, me?.team?.seriesLimit],
() => convertToTimeChartConfig(config),
[config],
);

// Determine whether the config can be optimized with an MV, to determine whether
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/components/SearchTotalCountChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ export function useSearchTotalCount(

// queriedConfig, queryKey, and enableQueryChunking match DBTimeChart so that react query can de-dupe these queries.
const queriedConfig = useMemo(
() => convertToTimeChartConfig(config, me?.team?.seriesLimit),
[config, me?.team?.seriesLimit],
() => convertToTimeChartConfig(config),
[config],
);
const {
data: totalCountData,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import {
DEFAULT_FILTER_KEYS_FETCH_LIMIT_WITH_MVS,
DEFAULT_QUERY_TIMEOUT,
DEFAULT_SEARCH_ROW_LIMIT,
DEFAULT_SERIES_LIMIT,
} from '@/defaults';
import { useBrandDisplayName } from '@/theme/ThemeProvider';

Expand Down Expand Up @@ -344,16 +343,6 @@ export default function TeamQueryConfigSection() {
type="boolean"
displayValue={value => (value ? 'Enabled' : 'Disabled')}
/>
<ClickhouseSettingForm
settingKey="seriesLimit"
label="Time Chart Series Limit"
tooltip="Maximum number of series fetched per time chart."
type="number"
defaultValue={DEFAULT_SERIES_LIMIT}
placeholder={`default = ${DEFAULT_SERIES_LIMIT}`}
min={1}
displayValue={displayValueWithUnit('series')}
/>
</Stack>
</Card>
</Box>
Expand Down
Loading
Loading