Skip to content
Open
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
15 changes: 15 additions & 0 deletions .changeset/dashboard-time-chart-series-limit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@hyperdx/common-utils': minor
'@hyperdx/app': minor
'@hyperdx/api': minor
---

Cap high-cardinality time-chart series to protect the browser from rendering
thousands of lines at once. Time charts now materialize and draw a bounded
number of series per tile, with escape hatches to reveal the rest on demand: a
"+N more" affordance in the hover and pinned tooltips, and a "load all series"
action that lifts the cap for a chart. Tooltips also cap how many rows they
render per frame so a wide bucket can't mount thousands of popovers. The
external dashboards API exposes the per-tile series limit as a three-state value
across tile types — omit for the default cap, 0 for unlimited, or a positive N
for the top N
16 changes: 8 additions & 8 deletions packages/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1645,8 +1645,8 @@
},
"seriesLimit": {
"type": "integer",
"minimum": 1,
"description": "Maximum number of series rendered (top-N by value). Omit for no limit.",
"minimum": 0,
"description": "Maximum number of series rendered (top-N by value). Omit to use the default render cap, set 0 for unlimited, or a positive N to keep the top N series.\n",
"example": 5
}
}
Expand Down Expand Up @@ -1709,8 +1709,8 @@
},
"seriesLimit": {
"type": "integer",
"minimum": 1,
"description": "Maximum number of series rendered (top-N by value). Omit for no limit.",
"minimum": 0,
"description": "Maximum number of series rendered (top-N by value). Omit to use the default render cap, set 0 for unlimited, or a positive N to keep the top N series.",
"example": 5
}
}
Expand Down Expand Up @@ -1887,8 +1887,8 @@
},
"limit": {
"type": "integer",
"minimum": 1,
"description": "Maximum number of slices (SQL LIMIT). Without a custom \"orderBy\" the query keeps the groups with the largest aggregated values; with an \"orderBy\" it keeps the first slices in that order. Omit to fetch all groups.\n",
"minimum": 0,
"description": "Maximum number of slices (SQL LIMIT). Without a custom \"orderBy\" the query keeps the groups with the largest aggregated values; with an \"orderBy\" it keeps the first slices in that order. Omit or set 0 to fetch all groups.\n",
"example": 10
}
}
Expand Down Expand Up @@ -1942,8 +1942,8 @@
},
"limit": {
"type": "integer",
"minimum": 1,
"description": "Maximum number of bars (SQL LIMIT). Without a custom \"orderBy\" the query keeps the groups with the largest aggregated values; with an \"orderBy\" it keeps the first bars in that order. Omit to fetch all groups.\n",
"minimum": 0,
"description": "Maximum number of bars (SQL LIMIT). Without a custom \"orderBy\" the query keeps the groups with the largest aggregated values; with an \"orderBy\" it keeps the first bars in that order. Omit or set 0 to fetch all groups.\n",
"example": 10
}
}
Expand Down
13 changes: 7 additions & 6 deletions packages/api/src/mcp/tools/dashboards/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,10 @@ const timeChartSeriesLimitDescription =
'Maximum number of series to fetch (the "Series Limit" display setting). ' +
'Keeps the top-N groups by aggregated value over the queried range and ' +
'drops the rest. Requires `groupBy`; ignored on a chart without one. ' +
'Omit to fetch every series.';
'Three-state: omit to apply the default render cap, 0 for unlimited, or a ' +
'positive N to keep the top N.';

const seriesLimitSchema = z.number().int().positive().optional();
const seriesLimitSchema = z.number().int().nonnegative().optional();

const numberTileColorDescription =
'Static color for the displayed number, as a palette token such as ' +
Expand Down Expand Up @@ -661,12 +662,12 @@ const mcpPieTileSchema = mcpTileLayoutSchema.extend({
limit: z
.number()
.int()
.positive()
.nonnegative()
.optional()
.describe(
'Maximum number of slices (SQL LIMIT). Without a custom `orderBy`, keeps ' +
'the top-N groups by the aggregated value, descending; with an `orderBy` ' +
'keeps the first N in that order. Omit to fetch all groups.',
'keeps the first N in that order. Omit or set 0 to fetch all groups.',
),
}),
});
Expand Down Expand Up @@ -703,12 +704,12 @@ const mcpCategoricalBarTileSchema = mcpTileLayoutSchema.extend({
limit: z
.number()
.int()
.positive()
.nonnegative()
.optional()
.describe(
'Maximum number of bars (SQL LIMIT). Without a custom `orderBy`, keeps ' +
'the top-N groups by the aggregated value, descending; with an `orderBy` ' +
'keeps the first N in that order. Omit to fetch all groups.',
'keeps the first N in that order. Omit or set 0 to fetch all groups.',
),
}),
});
Expand Down
24 changes: 15 additions & 9 deletions packages/api/src/routers/external-api/v2/dashboards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,8 +631,11 @@ const EXTERNAL_DASHBOARD_PROJECTION = {
* default: false
* seriesLimit:
* type: integer
* minimum: 1
* description: Maximum number of series rendered (top-N by value). Omit for no limit.
* minimum: 0
* description: >
* Maximum number of series rendered (top-N by value). Omit to use
* the default render cap, set 0 for unlimited, or a positive N to
* keep the top N series.
* example: 5
*
* BarBuilderChartConfig:
Expand Down Expand Up @@ -683,8 +686,11 @@ const EXTERNAL_DASHBOARD_PROJECTION = {
* description: Number formatting options for displayed values.
* seriesLimit:
* type: integer
* minimum: 1
* description: Maximum number of series rendered (top-N by value). Omit for no limit.
* minimum: 0
* description: >-
* Maximum number of series rendered (top-N by value). Omit to use
* the default render cap, set 0 for unlimited, or a positive N to
* keep the top N series.
* example: 5
*
* TableBuilderChartConfig:
Expand Down Expand Up @@ -832,12 +838,12 @@ const EXTERNAL_DASHBOARD_PROJECTION = {
* description: Number formatting options for displayed values.
* limit:
* type: integer
* minimum: 1
* minimum: 0
* description: >
* Maximum number of slices (SQL LIMIT). Without a custom "orderBy"
* the query keeps the groups with the largest aggregated values;
* with an "orderBy" it keeps the first slices in that order. Omit
* to fetch all groups.
* or set 0 to fetch all groups.
* example: 10
*
* CategoricalBarBuilderChartConfig:
Expand Down Expand Up @@ -885,12 +891,12 @@ const EXTERNAL_DASHBOARD_PROJECTION = {
* description: Number formatting options for displayed values.
* limit:
* type: integer
* minimum: 1
* minimum: 0
* description: >
* Maximum number of bars (SQL LIMIT). Without a custom "orderBy"
* the query keeps the groups with the largest aggregated values;
* with an "orderBy" it keeps the first bars in that order. Omit to
* fetch all groups.
* with an "orderBy" it keeps the first bars in that order. Omit or
* set 0 to fetch all groups.
* example: 10
*
* HeatmapSelectItem:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -399,4 +399,50 @@ describe('convertToExternalDashboard orphan-ref heal', () => {
const ext = convertToExternalDashboard(doc);
expect(ext.tiles.map(t => t.id)).toEqual(['normal-tile']);
});

// seriesLimit is three-state (matching the internal schema): omitted =
// default cap, 0 = unlimited, positive N = top-N. All three must survive a
// GET->PUT round-trip: 0 and N pass through, null maps to absent (the
// default-cap state).
describe.each([DisplayType.Line, DisplayType.StackedBar])(
'seriesLimit serialization for %s tiles',
displayType => {
function readSeriesLimit(seriesLimit: number | null): unknown {
const doc = makeDoc({
tiles: [
makeTile({
id: 'series-limit-tile',
config: {
displayType,
source: new mongoose.Types.ObjectId().toString(),
name: 'Series limit tile',
select: [{ aggFn: 'count', valueExpression: '' }],
where: '',
seriesLimit,
},
}),
],
});
// Round-trip through JSON to observe what the wire body actually
// carries: `undefined` fields are dropped, so an omitted seriesLimit
// reads back as `undefined` here.
const wire = JSON.parse(
JSON.stringify(convertToExternalDashboard(doc)),
);
return wire.tiles[0].config.seriesLimit;
}

it('round-trips 0 (unlimited) rather than dropping it', () => {
expect(readSeriesLimit(0)).toBe(0);
});

it('emits seriesLimit as absent when stored as null (default cap)', () => {
expect(readSeriesLimit(null)).toBeUndefined();
});

it('passes a positive seriesLimit through unchanged', () => {
expect(readSeriesLimit(25)).toBe(25);
});
},
);
});
8 changes: 8 additions & 0 deletions packages/api/src/routers/external-api/v2/utils/dashboards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,8 @@ const convertToExternalTileChartConfig = (
: [DEFAULT_SELECT_ITEM],
compareToPreviousPeriod: config.compareToPreviousPeriod,
numberFormat: config.numberFormat,
// Three-state passthrough: 0 (unlimited) and positive N round-trip;
// null/undefined map to absent (the default-cap state).
seriesLimit: config.seriesLimit ?? undefined,
};
case DisplayType.StackedBar:
Expand All @@ -316,6 +318,8 @@ const convertToExternalTileChartConfig = (
? config.select.map(convertToExternalSelectItem)
: [DEFAULT_SELECT_ITEM],
numberFormat: config.numberFormat,
// Three-state passthrough: 0 (unlimited) and positive N round-trip;
// null/undefined map to absent (the default-cap state).
seriesLimit: config.seriesLimit ?? undefined,
};
case DisplayType.Number:
Expand Down Expand Up @@ -353,6 +357,8 @@ const convertToExternalTileChartConfig = (
groupBy: stringValueOrDefault(config.groupBy, undefined),
orderBy: stringValueOrDefault(config.orderBy, undefined),
numberFormat: config.numberFormat,
// Three-state passthrough: 0 (unlimited) and positive N round-trip;
// null/undefined map to absent (the default-cap state).
limit: config.seriesLimit ?? undefined,
};
case DisplayType.Bar:
Expand All @@ -365,6 +371,8 @@ const convertToExternalTileChartConfig = (
groupBy: stringValueOrDefault(config.groupBy, undefined),
orderBy: stringValueOrDefault(config.orderBy, undefined),
numberFormat: config.numberFormat,
// Three-state passthrough: 0 (unlimited) and positive N round-trip;
// null/undefined map to absent (the default-cap state).
limit: config.seriesLimit ?? undefined,
};
case DisplayType.Table:
Expand Down
14 changes: 10 additions & 4 deletions packages/api/src/utils/zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,9 @@ const externalDashboardLineChartConfigSchema =
displayType: z.literal('line'),
compareToPreviousPeriod: z.boolean().optional(),
fitYAxisToData: z.boolean().optional(),
seriesLimit: z.number().int().positive().optional(),
// Three-state, matching the internal SharedChartSettingsSchema.seriesLimit:
// omitted = default render cap, 0 = unlimited, positive N = top-N by peak.
seriesLimit: z.number().int().nonnegative().optional(),
});

const externalDashboardLineRawSqlChartConfigSchema =
Expand All @@ -283,7 +285,9 @@ const externalDashboardLineRawSqlChartConfigSchema =
const externalDashboardBarChartConfigSchema =
externalDashboardTimeChartConfigSchema.extend({
displayType: z.literal('stacked_bar'),
seriesLimit: z.number().int().positive().optional(),
// Three-state, matching the internal SharedChartSettingsSchema.seriesLimit:
// omitted = default render cap, 0 = unlimited, positive N = top-N by peak.
seriesLimit: z.number().int().nonnegative().optional(),
});

const externalDashboardBarRawSqlChartConfigSchema =
Expand Down Expand Up @@ -375,7 +379,8 @@ const externalDashboardPieChartConfigSchema = z.object({
groupBy: z.string().max(10000).optional(),
orderBy: z.string().max(10000).optional(),
numberFormat: NumberFormatSchema.optional(),
limit: z.number().int().positive().optional(),
// Three-state: omitted = default cap, 0 = unlimited, positive N = top-N.
limit: z.number().int().nonnegative().optional(),
});

const externalDashboardCategoricalBarChartConfigSchema = z.object({
Expand All @@ -385,7 +390,8 @@ const externalDashboardCategoricalBarChartConfigSchema = z.object({
groupBy: z.string().max(10000).optional(),
orderBy: z.string().max(10000).optional(),
numberFormat: NumberFormatSchema.optional(),
limit: z.number().int().positive().optional(),
// Three-state: omitted = default cap, 0 = unlimited, positive N = top-N.
limit: z.number().int().nonnegative().optional(),
});

// Heatmap charts use a dedicated select item schema because they carry the
Expand Down
Loading