From fafc78fa0db4b7ed35f7ed224135d7bb6bd403c8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 11 Aug 2026 07:30:12 +0000 Subject: [PATCH] feat: make max spans per trace limit configurable (team + user preference) Add traceSpanLimit to TeamClickHouseSettings (team-level ceiling) and UserPreferences (per-user override clamped to team ceiling). The limit binds query-side: getConfig() in DBTraceWaterfallChart now accepts a spanLimit parameter instead of hard-coding 50000. The effective limit is computed as min(userPreference, teamSetting) with the current 50000 as the default when neither is set. Team admins can configure the setting under ClickHouse Client Settings (min 1000, max 500000). Individual users can set a personal preference in the Preferences modal under a new Performance section. Closes HDX-5082 Co-authored-by: Mike Shi --- .changeset/configurable-trace-span-limit.md | 7 +++ packages/api/src/models/team.ts | 1 + packages/app/src/UserPreferencesModal.tsx | 54 +++++++++++++++++++ .../src/components/DBTraceWaterfallChart.tsx | 30 ++++++++++- .../TeamSettings/TeamQueryConfigSection.tsx | 13 +++++ .../__tests__/DBTraceWaterfallChart.test.tsx | 15 ++++++ packages/app/src/defaults.ts | 1 + packages/app/src/useUserPreferences.tsx | 2 + packages/common-utils/src/types.ts | 2 + 9 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 .changeset/configurable-trace-span-limit.md diff --git a/.changeset/configurable-trace-span-limit.md b/.changeset/configurable-trace-span-limit.md new file mode 100644 index 0000000000..bfca1b7931 --- /dev/null +++ b/.changeset/configurable-trace-span-limit.md @@ -0,0 +1,7 @@ +--- +'@hyperdx/app': minor +'@hyperdx/api': minor +'@hyperdx/common-utils': minor +--- + +Make the max spans per trace limit configurable via a team-level setting and an optional per-user preference. The team setting sets the ceiling (admin-configurable under ClickHouse Client Settings), and individual users can set a lower personal limit in Preferences. The current default of 50,000 spans is preserved for teams and users that don't change it. diff --git a/packages/api/src/models/team.ts b/packages/api/src/models/team.ts index 1954e0ac7a..24425f0108 100644 --- a/packages/api/src/models/team.ts +++ b/packages/api/src/models/team.ts @@ -46,6 +46,7 @@ export default mongoose.model( fieldMetadataDisabled: Boolean, parallelizeWhenPossible: Boolean, filterKeysFetchLimit: Number, + traceSpanLimit: Number, }, { timestamps: true, diff --git a/packages/app/src/UserPreferencesModal.tsx b/packages/app/src/UserPreferencesModal.tsx index 578e784bbb..b09dcfc148 100644 --- a/packages/app/src/UserPreferencesModal.tsx +++ b/packages/app/src/UserPreferencesModal.tsx @@ -2,9 +2,11 @@ import * as React from 'react'; import { Autocomplete, Badge, + Button, Divider, Group, Modal, + NumberInput, Select, Stack, Switch, @@ -15,6 +17,8 @@ import { IconFlask } from '@tabler/icons-react'; import { OPTIONS_FONTS } from './config/fonts'; import { useAppTheme } from './theme/ThemeProvider'; +import api from './api'; +import { DEFAULT_TRACE_SPAN_LIMIT } from './defaults'; import { isValidThemeName, themes } from './theme'; import { UserPreferences, useUserPreferences } from './useUserPreferences'; @@ -54,6 +58,54 @@ const SettingContainer = ({ ); }; +function TraceSpanLimitSetting() { + const { userPreferences, setUserPreference } = useUserPreferences(); + const { data: me } = api.useMe(); + const teamLimit = me?.team?.traceSpanLimit ?? DEFAULT_TRACE_SPAN_LIMIT; + + return ( + <> + + + Maximum spans fetched per trace in the waterfall view. Capped by the + team limit ({teamLimit.toLocaleString()}). + + } + > + + + setUserPreference({ + traceSpanLimit: + value === '' || value === 0 ? undefined : Number(value), + }) + } + placeholder={`${teamLimit.toLocaleString()} (team default)`} + min={1000} + max={teamLimit} + step={1000} + size="sm" + style={{ flex: 1 }} + /> + {userPreferences.traceSpanLimit != null && ( + + )} + + + + ); +} + export const UserPreferencesModal = ({ opened, onClose, @@ -202,6 +254,8 @@ export const UserPreferencesModal = ({ /> )} + + ); diff --git a/packages/app/src/components/DBTraceWaterfallChart.tsx b/packages/app/src/components/DBTraceWaterfallChart.tsx index 77275c47e8..81a38d3c2a 100644 --- a/packages/app/src/components/DBTraceWaterfallChart.tsx +++ b/packages/app/src/components/DBTraceWaterfallChart.tsx @@ -43,6 +43,7 @@ import { IconLogs, } from '@tabler/icons-react'; +import api from '@/api'; import { ContactSupportText } from '@/components/ContactSupportText'; import { ErrorCollapse } from '@/components/Error/ErrorCollapse'; import SearchWhereInput, { @@ -53,6 +54,7 @@ import { TimelineMinimap, type TimelineViewportController, } from '@/components/TimelineChart'; +import { DEFAULT_TRACE_SPAN_LIMIT } from '@/defaults'; import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery'; import useRowWhere, { WithClause } from '@/hooks/useRowWhere'; import useWaterfallSearchState from '@/hooks/useWaterfallSearchState'; @@ -63,6 +65,7 @@ import { getSpanEventBody, } from '@/source'; import { useFormatTime } from '@/useFormatTime'; +import { useUserPreferences } from '@/useUserPreferences'; import { CATEGORICAL_PALETTE_TOKENS, COLORS, @@ -161,6 +164,7 @@ function getConfig( traceId: string, hiddenRowExpression?: string, hiddenRowExpressionLanguage: 'lucene' | 'sql' = 'lucene', + spanLimit: number = DEFAULT_TRACE_SPAN_LIMIT, ) { const alias: Record = { Body: getTableBody(source), @@ -302,7 +306,7 @@ function getConfig( from: source.from, timestampValueExpression: source.timestampValueExpression, where: `${alias.TraceId} = ${SqlString.escape(traceId)}`, - limit: { limit: 50000 }, + limit: { limit: spanLimit }, connection: source.connection, }; return { config, alias, type: source.kind }; @@ -337,6 +341,7 @@ export function useEventsAroundFocus({ enabled, hiddenRowExpression, hiddenRowExpressionLanguage = 'lucene', + spanLimit, }: { tableSource: TTraceSource | TLogSource; focusDate: Date; @@ -346,6 +351,7 @@ export function useEventsAroundFocus({ /** An expression (in `hiddenRowExpressionLanguage`) that identifies rows to be hidden. Hidden rows will be returned with a `__hdx_hidden: true` column. */ hiddenRowExpression?: string; hiddenRowExpressionLanguage?: 'lucene' | 'sql'; + spanLimit?: number; }) { const { config, alias, type } = useMemo( () => @@ -354,8 +360,15 @@ export function useEventsAroundFocus({ traceId, hiddenRowExpression, hiddenRowExpressionLanguage, + spanLimit, ), - [tableSource, traceId, hiddenRowExpression, hiddenRowExpressionLanguage], + [ + tableSource, + traceId, + hiddenRowExpression, + hiddenRowExpressionLanguage, + spanLimit, + ], ); const { @@ -594,6 +607,17 @@ export function DBTraceWaterfallChartContainer({ controlsExtra?: ReactNode; }) { const formatTime = useFormatTime(); + const { data: me } = api.useMe(); + const { userPreferences } = useUserPreferences(); + + const effectiveSpanLimit = useMemo(() => { + const teamLimit = me?.team?.traceSpanLimit ?? DEFAULT_TRACE_SPAN_LIMIT; + const userLimit = userPreferences.traceSpanLimit; + if (userLimit != null && userLimit > 0) { + return Math.min(userLimit, teamLimit); + } + return teamLimit; + }, [me?.team?.traceSpanLimit, userPreferences.traceSpanLimit]); const { traceWhere, @@ -674,6 +698,7 @@ export function DBTraceWaterfallChartContainer({ hiddenRowExpression: traceWhere ? `NOT (${traceWhere})` : undefined, hiddenRowExpressionLanguage: traceFilterLanguage, enabled: true, + spanLimit: effectiveSpanLimit, }); const { rows: logRowsData, @@ -691,6 +716,7 @@ export function DBTraceWaterfallChartContainer({ hiddenRowExpression: logWhere ? `NOT (${logWhere})` : undefined, hiddenRowExpressionLanguage: logFilterLanguage, enabled: logTableSource ? true : false, // disable fire query if logSource is not exist + spanLimit: effectiveSpanLimit, }); const isFetching = traceIsFetching || logIsFetching; diff --git a/packages/app/src/components/TeamSettings/TeamQueryConfigSection.tsx b/packages/app/src/components/TeamSettings/TeamQueryConfigSection.tsx index e685112003..e46ebae2cc 100644 --- a/packages/app/src/components/TeamSettings/TeamQueryConfigSection.tsx +++ b/packages/app/src/components/TeamSettings/TeamQueryConfigSection.tsx @@ -23,6 +23,7 @@ import { DEFAULT_FILTER_KEYS_FETCH_LIMIT, DEFAULT_QUERY_TIMEOUT, DEFAULT_SEARCH_ROW_LIMIT, + DEFAULT_TRACE_SPAN_LIMIT, } from '@/defaults'; import { useBrandDisplayName } from '@/theme/ThemeProvider'; @@ -342,6 +343,18 @@ export default function TeamQueryConfigSection() { type="boolean" displayValue={value => (value ? 'Enabled' : 'Disabled')} /> + diff --git a/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx b/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx index b689f9f87a..2efd9ca083 100644 --- a/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx +++ b/packages/app/src/components/__tests__/DBTraceWaterfallChart.test.tsx @@ -75,6 +75,21 @@ jest.mock('../DBRowDataPanel', () => ({ getJSONColumnNames: jest.fn().mockReturnValue([]), })); +jest.mock('@/api', () => ({ + __esModule: true, + default: { + useMe: () => ({ data: { team: {} } }), + }, +})); + +jest.mock('@/useUserPreferences', () => ({ + __esModule: true, + useUserPreferences: () => ({ + userPreferences: {}, + setUserPreference: jest.fn(), + }), +})); + // Lightweight stub: the real SearchWhereInput renders SearchInputV2, which // pulls in useMe()/metadata hooks that require a QueryClientProvider not present // in this harness. We only care that the right inputs render, so stub it to a diff --git a/packages/app/src/defaults.ts b/packages/app/src/defaults.ts index 5894d8b78d..1f27cb6d91 100644 --- a/packages/app/src/defaults.ts +++ b/packages/app/src/defaults.ts @@ -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 = 100; export const DEFAULT_SERIES_LIMIT = 100; +export const DEFAULT_TRACE_SPAN_LIMIT = 50000; // Default ceiling on distinct series the time-chart transform materializes, // across all config types, when a tile has no explicit `seriesLimit`. diff --git a/packages/app/src/useUserPreferences.tsx b/packages/app/src/useUserPreferences.tsx index 8d82240263..f44b21e501 100644 --- a/packages/app/src/useUserPreferences.tsx +++ b/packages/app/src/useUserPreferences.tsx @@ -12,6 +12,8 @@ export type UserPreferences = { colorMode: ColorModePreference; font: 'IBM Plex Mono' | 'Roboto Mono' | 'Inter' | 'Roboto'; expandSidebarHeader?: boolean; + /** Per-user max spans per trace override (clamped to team ceiling at query time). */ + traceSpanLimit?: number; }; // Legacy type for migration diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index e171fa9d3e..67f3b6d9ac 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -1719,6 +1719,7 @@ export const TeamClickHouseSettingsSchema = z.object({ metadataMaxRowsToRead: z.number().optional(), parallelizeWhenPossible: z.boolean().optional(), filterKeysFetchLimit: z.number().optional(), + traceSpanLimit: z.number().optional(), }); /** Accepts null to unset (reset to default) a setting. */ @@ -1729,6 +1730,7 @@ export const TeamClickHouseSettingsUpdateSchema = z.object({ metadataMaxRowsToRead: z.number().nullish(), parallelizeWhenPossible: z.boolean().nullish(), filterKeysFetchLimit: z.number().nullish(), + traceSpanLimit: z.number().nullish(), }); export type TeamClickHouseSettingsUpdate = z.infer< typeof TeamClickHouseSettingsUpdateSchema