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
7 changes: 7 additions & 0 deletions .changeset/time-chart-series-limit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/app": patch
"@hyperdx/api": patch
---

feat(charts): cap group-by time charts to a top-N series limit to prevent browser memory exhaustion on high-cardinality group-bys. The cap defaults to 100 (the number of series rendered) and is configurable per team via a new "Time Chart Series Limit" setting; series beyond the cap remain available in the series selector.
1 change: 1 addition & 0 deletions packages/api/src/models/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export default mongoose.model<ITeam>(
fieldMetadataDisabled: Boolean,
parallelizeWhenPossible: Boolean,
filterKeysFetchLimit: Number,
seriesLimit: Number,
},
{
timestamps: true,
Expand Down
7 changes: 7 additions & 0 deletions packages/app/src/ChartUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { notifications } from '@mantine/notifications';

import DateRangeIndicator from './components/charts/DateRangeIndicator';
import { MVOptimizationExplanationResult } from './hooks/useMVOptimizationExplanation';
import { DEFAULT_SERIES_LIMIT } from './defaults';
import { getMetricNameSql } from './otelSemanticConventions';
import { AggFn, TableChartSeries, TimeChartSeries } from './types';
import { NumberFormat } from './types';
Expand Down Expand Up @@ -103,9 +104,14 @@ function getTimeChartDateRange(
: getAlignedDateRange(dateRange, granularity);
}

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);

const granularity = getTimeChartGranularity(
config.granularity,
config.dateRange,
Expand Down Expand Up @@ -133,6 +139,7 @@ export function convertToTimeChartConfig(
dateRangeEndInclusive,
granularity,
limit: { limit: 100000 },
seriesLimit,
}
: {
...config,
Expand Down
8 changes: 6 additions & 2 deletions packages/app/src/HDXMultiSeriesTimeChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ import {
ChartTooltipContainer,
ChartTooltipItem,
} from './components/charts/ChartTooltip';
import { LineData, toStartOfInterval } from './ChartUtils';
import {
LineData,
MAX_TIME_CHART_SERIES,
toStartOfInterval,
} from './ChartUtils';
import { FormatTime, useFormatTime } from './useFormatTime';

import styles from '../styles/HDXLineChart.module.scss';
Expand Down Expand Up @@ -317,7 +321,7 @@ const LegendRenderer = memo<{
);
});

export const HARD_LINES_LIMIT = 60;
export const HARD_LINES_LIMIT = MAX_TIME_CHART_SERIES;

const StackedBarWithOverlap = (props: BarProps) => {
const { x, y, width, height, fill } = props;
Expand Down
31 changes: 31 additions & 0 deletions packages/app/src/__tests__/ChartUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ 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 @@ -806,6 +807,36 @@ 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) =>
(
convertToTimeChartConfig(
seriesLimitConfig,
teamSeriesLimit,
) as BuilderChartConfigWithDateRange
).seriesLimit;

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

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

it('passes a large team seriesLimit through unbounded', () => {
expect(seriesLimitOf(100000)).toBe(100000);
});
});

describe('convertToNumberChartConfig', () => {
Expand Down
7 changes: 4 additions & 3 deletions packages/app/src/components/DBTimeChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -287,9 +287,11 @@ function DBTimeChartComponent({
fillNulls,
} = useTimeChartSettings(config);

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

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

// Determine whether the config can be optimized with an MV, to determine whether
Expand All @@ -299,7 +301,6 @@ function DBTimeChartComponent({
const { data: mvOptimizationData } =
useMVOptimizationExplanation(builderQueriedConfig);

const { data: me, isLoading: isLoadingMe } = api.useMe();
const { data, isLoading, isError, error, isPlaceholderData, isSuccess } =
useQueriedChartConfig(queriedConfig, {
placeholderData: (prev: any) => prev,
Expand Down
8 changes: 4 additions & 4 deletions packages/app/src/components/SearchTotalCountChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@ export function useSearchTotalCount(
enableParallelQueries?: boolean;
} = {},
) {
const { data: me, isLoading: isLoadingMe } = api.useMe();

// queriedConfig, queryKey, and enableQueryChunking match DBTimeChart so that react query can de-dupe these queries.
const queriedConfig = useMemo(
() => convertToTimeChartConfig(config),
[config],
() => convertToTimeChartConfig(config, me?.team?.seriesLimit),
[config, me?.team?.seriesLimit],
);

const { data: me, isLoading: isLoadingMe } = api.useMe();
const {
data: totalCountData,
isLoading,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ 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 @@ -343,6 +344,16 @@ 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
1 change: 1 addition & 0 deletions packages/app/src/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const DEFAULT_SEARCH_ROW_LIMIT = 200;
export const DEFAULT_QUERY_TIMEOUT = 60; // max_execution_time, seconds
export const DEFAULT_FILTER_KEYS_FETCH_LIMIT = 20;
export const DEFAULT_FILTER_KEYS_FETCH_LIMIT_WITH_MVS = 100;
export const DEFAULT_SERIES_LIMIT = 100;

export function searchChartConfigDefaults(
team: any | undefined | null,
Expand Down
Loading
Loading