Skip to content
Draft
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/configurable-trace-span-limit.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/api/src/models/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export default mongoose.model<ITeam>(
fieldMetadataDisabled: Boolean,
parallelizeWhenPossible: Boolean,
filterKeysFetchLimit: Number,
traceSpanLimit: Number,
},
{
timestamps: true,
Expand Down
54 changes: 54 additions & 0 deletions packages/app/src/UserPreferencesModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import * as React from 'react';
import {
Autocomplete,
Badge,
Button,
Divider,
Group,
Modal,
NumberInput,
Select,
Stack,
Switch,
Expand All @@ -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';

Expand Down Expand Up @@ -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 (
<>
<Divider label="Performance" labelPosition="left" mt="sm" />
<SettingContainer
label="Trace span limit"
description={
<>
Maximum spans fetched per trace in the waterfall view. Capped by the
team limit ({teamLimit.toLocaleString()}).
</>
}
>
<Group gap="xs">
<NumberInput
value={userPreferences.traceSpanLimit ?? ''}
onChange={value =>
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 && (
<Button
variant="subtle"
size="xs"
onClick={() => setUserPreference({ traceSpanLimit: undefined })}
>
Reset
</Button>
)}
</Group>
</SettingContainer>
</>
);
}

export const UserPreferencesModal = ({
opened,
onClose,
Expand Down Expand Up @@ -202,6 +254,8 @@ export const UserPreferencesModal = ({
/>
</SettingContainer>
)}

<TraceSpanLimitSetting />
</Stack>
</Modal>
);
Expand Down
30 changes: 28 additions & 2 deletions packages/app/src/components/DBTraceWaterfallChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -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';
Expand All @@ -63,6 +65,7 @@ import {
getSpanEventBody,
} from '@/source';
import { useFormatTime } from '@/useFormatTime';
import { useUserPreferences } from '@/useUserPreferences';
import {
CATEGORICAL_PALETTE_TOKENS,
COLORS,
Expand Down Expand Up @@ -161,6 +164,7 @@ function getConfig(
traceId: string,
hiddenRowExpression?: string,
hiddenRowExpressionLanguage: 'lucene' | 'sql' = 'lucene',
spanLimit: number = DEFAULT_TRACE_SPAN_LIMIT,
) {
const alias: Record<string, string> = {
Body: getTableBody(source),
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -337,6 +341,7 @@ export function useEventsAroundFocus({
enabled,
hiddenRowExpression,
hiddenRowExpressionLanguage = 'lucene',
spanLimit,
}: {
tableSource: TTraceSource | TLogSource;
focusDate: Date;
Expand All @@ -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(
() =>
Expand All @@ -354,8 +360,15 @@ export function useEventsAroundFocus({
traceId,
hiddenRowExpression,
hiddenRowExpressionLanguage,
spanLimit,
),
[tableSource, traceId, hiddenRowExpression, hiddenRowExpressionLanguage],
[
tableSource,
traceId,
hiddenRowExpression,
hiddenRowExpressionLanguage,
spanLimit,
],
);

const {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -674,6 +698,7 @@ export function DBTraceWaterfallChartContainer({
hiddenRowExpression: traceWhere ? `NOT (${traceWhere})` : undefined,
hiddenRowExpressionLanguage: traceFilterLanguage,
enabled: true,
spanLimit: effectiveSpanLimit,
});
const {
rows: logRowsData,
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -342,6 +343,18 @@ export default function TeamQueryConfigSection() {
type="boolean"
displayValue={value => (value ? 'Enabled' : 'Disabled')}
/>
<ClickhouseSettingForm
settingKey="traceSpanLimit"
label="Trace span limit"
tooltip="Maximum number of spans fetched per trace in the waterfall view. Higher values show more complete traces but may impact browser performance."
type="number"
defaultValue={DEFAULT_TRACE_SPAN_LIMIT}
placeholder={`default = ${DEFAULT_TRACE_SPAN_LIMIT.toLocaleString()}`}
min={1000}
max={500000}
displayValue={displayValueWithUnit('spans')}
description={`Team-wide ceiling for trace waterfall queries. Individual users can set a lower personal limit in Preferences. Default is ${DEFAULT_TRACE_SPAN_LIMIT.toLocaleString()}.`}
/>
</Stack>
</Card>
</Box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 = 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`.
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/useUserPreferences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/common-utils/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unbounded trace limit validation

When an authenticated team member submits an out-of-range traceSpanLimit directly to PATCH /clickhouse-settings, the unconstrained schema persists it and the waterfall uses it as a ClickHouse query limit, causing empty or failed queries or bypassing the intended 500,000-span resource ceiling.

Suggested change
traceSpanLimit: z.number().nullish(),
traceSpanLimit: z.number().int().min(1000).max(500000).nullish(),

Knowledge Base Used:

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

});
export type TeamClickHouseSettingsUpdate = z.infer<
typeof TeamClickHouseSettingsUpdateSchema
Expand Down
Loading