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