diff --git a/.changeset/expose-series-limit-external-api.md b/.changeset/expose-series-limit-external-api.md new file mode 100644 index 0000000000..7b15cb1c94 --- /dev/null +++ b/.changeset/expose-series-limit-external-api.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/api': patch +--- + +fix: Expose `seriesLimit` via external API and MCP \ No newline at end of file diff --git a/packages/api/openapi.json b/packages/api/openapi.json index 4c076f92fa..c2fbbb9aee 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -1642,6 +1642,12 @@ "type": "boolean", "description": "Overlay the equivalent previous time period for comparison.", "default": false + }, + "seriesLimit": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of series rendered (top-N by value). Omit for no limit.", + "example": 5 } } }, @@ -1700,6 +1706,12 @@ "numberFormat": { "$ref": "#/components/schemas/NumberFormat", "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.", + "example": 5 } } }, diff --git a/packages/api/src/mcp/__tests__/dashboards/patchDashboard.int.test.ts b/packages/api/src/mcp/__tests__/dashboards/patchDashboard.int.test.ts index f318568c07..f8f9d8eeef 100644 --- a/packages/api/src/mcp/__tests__/dashboards/patchDashboard.int.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards/patchDashboard.int.test.ts @@ -705,6 +705,61 @@ describe('MCP Dashboard Tools - clickstack_patch_dashboard', () => { }); }); + it('should round-trip seriesLimit: patch then get_dashboard_tile', async () => { + const sourceId = ctx.traceSource._id.toString(); + const createResult = await callTool( + ctx.client!, + 'clickstack_save_dashboard', + { + name: 'Series Limit Patch Test', + tiles: [ + { + name: 'Original', + config: { + displayType: 'line', + sourceId, + select: [{ aggFn: 'count' }], + groupBy: 'SpanName', + seriesLimit: 5, + }, + }, + ], + }, + ); + const created = JSON.parse(getFirstText(createResult)); + expect(created.tiles[0].config.seriesLimit).toBe(5); + const tileId = created.tiles[0].id; + + await callTool(ctx.client!, 'clickstack_patch_dashboard', { + dashboardId: created.id, + tileId, + tile: { + name: 'Patched', + config: { + displayType: 'stacked_bar', + sourceId, + select: [{ aggFn: 'count' }], + groupBy: 'SpanName', + seriesLimit: 3, + }, + }, + }); + + const getResult = await callTool( + ctx.client!, + 'clickstack_get_dashboard_tile', + { + dashboardId: created.id, + tileId, + }, + ); + + expect(getResult.isError).toBeFalsy(); + const tile = JSON.parse(getFirstText(getResult)); + expect(tile.config.displayType).toBe('stacked_bar'); + expect(tile.config.seriesLimit).toBe(3); + }); + describe('raw SQL macro warnings', () => { // Patching a tile to a macro-less raw SQL config succeeds (non-blocking) // but surfaces an advisory `warnings` array on the response. diff --git a/packages/api/src/mcp/__tests__/dashboards/saveDashboard.int.test.ts b/packages/api/src/mcp/__tests__/dashboards/saveDashboard.int.test.ts index 497696d202..c4240711a8 100644 --- a/packages/api/src/mcp/__tests__/dashboards/saveDashboard.int.test.ts +++ b/packages/api/src/mcp/__tests__/dashboards/saveDashboard.int.test.ts @@ -942,15 +942,19 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => { displayType: 'line' as const, sourceId, select: [{ aggFn: 'count' as const, alias: 'Requests' }], + groupBy: 'SpanName', numberFormat, compareToPreviousPeriod: true, fitYAxisToData: true, + seriesLimit: 5, }; const barConfig = { displayType: 'stacked_bar' as const, sourceId, select: [{ aggFn: 'count' as const, alias: 'Requests' }], + groupBy: 'SpanName', numberFormat, + seriesLimit: 3, }; const tableConfig = { displayType: 'table' as const, diff --git a/packages/api/src/mcp/tools/dashboards/schemas.ts b/packages/api/src/mcp/tools/dashboards/schemas.ts index e0e92e830f..75b85d89d9 100644 --- a/packages/api/src/mcp/tools/dashboards/schemas.ts +++ b/packages/api/src/mcp/tools/dashboards/schemas.ts @@ -47,6 +47,14 @@ const tileLevelNumberFormatDescription = 'Most useful: { output: "duration", factor: 0.000000001 } to auto-format nanosecond durations, ' + 'or { output: "number", mantissa: 2, thousandSeparated: true } for clean counts.'; +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.'; + +const seriesLimitSchema = z.number().int().positive().optional(); + const numberTileColorDescription = 'Static color for the displayed number, as a palette token such as ' + '"chart-blue", "chart-green", or "chart-success" (see the enum for the ' + @@ -534,6 +542,7 @@ const mcpLineTileSchema = mcpTileLayoutSchema.extend({ .describe( 'Scale the y-axis to the data range instead of starting at zero.', ), + seriesLimit: seriesLimitSchema.describe(timeChartSeriesLimitDescription), }), }); @@ -551,6 +560,7 @@ const mcpBarTileSchema = mcpTileLayoutSchema.extend({ numberFormat: mcpNumberFormatSchema .optional() .describe(tileLevelNumberFormatDescription), + seriesLimit: seriesLimitSchema.describe(timeChartSeriesLimitDescription), }), }); diff --git a/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts b/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts index d1b7323eb4..e860f3aba5 100644 --- a/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts +++ b/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts @@ -2593,6 +2593,109 @@ describe('External API v2 Dashboards - new format', () => { ); }); + it('persists seriesLimit on line and stacked_bar tiles through create and get', async () => { + // Regression test for HDX-4988: seriesLimit was silently dropped on + // line/stacked_bar tiles. Exercises both the input-schema acceptance + // and the internal<->external conversion round-trip. + const lineChart: ExternalDashboardTile = { + name: 'Line with series limit', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'line', + sourceId: traceSource._id.toString(), + select: [ + { + aggFn: 'count', + alias: 'Count', + where: '', + whereLanguage: 'sql', + }, + ], + groupBy: 'ServiceName', + seriesLimit: 20, + }, + }; + + const stackedBarChart: ExternalDashboardTile = { + name: 'Stacked bar with series limit', + x: 6, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'stacked_bar', + sourceId: traceSource._id.toString(), + select: [ + { + aggFn: 'count', + alias: 'Count', + where: '', + whereLanguage: 'sql', + }, + ], + groupBy: 'ServiceName', + seriesLimit: 7, + }, + }; + + // Act: create the dashboard + const createResponse = await authRequest('post', BASE_URL) + .send({ + name: 'Dashboard with series limits', + tiles: [lineChart, stackedBarChart], + }) + .expect(200); + + // Assert: create response echoes the seriesLimit back + expect(createResponse.body.data.tiles[0].config.seriesLimit).toBe(20); + expect(createResponse.body.data.tiles[1].config.seriesLimit).toBe(7); + + // Assert: the seriesLimit survives a round-trip through persistence (GET) + const { id } = createResponse.body.data; + const getResponse = await authRequest('get', `${BASE_URL}/${id}`).expect( + 200, + ); + expect(getResponse.body.data.tiles[0].config.seriesLimit).toBe(20); + expect(getResponse.body.data.tiles[1].config.seriesLimit).toBe(7); + }); + + it('omits seriesLimit on line/stacked_bar tiles when it is not provided', async () => { + const lineChart: ExternalDashboardTile = { + name: 'Line without series limit', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'line', + sourceId: traceSource._id.toString(), + select: [ + { + aggFn: 'count', + alias: 'Count', + where: '', + whereLanguage: 'sql', + }, + ], + groupBy: 'ServiceName', + }, + }; + + const response = await authRequest('post', BASE_URL) + .send({ + name: 'Dashboard without series limit', + tiles: [lineChart], + }) + .expect(200); + + expect(response.body.data.tiles[0].config).not.toHaveProperty( + 'seriesLimit', + ); + }); + it('omits orderBy on pie/bar tiles when it is not provided', async () => { const pieChart: ExternalDashboardTile = { name: 'Pie without order', @@ -3643,6 +3746,73 @@ describe('External API v2 Dashboards - new format', () => { ); }); + it('persists seriesLimit on line and stacked_bar tiles through update and get', async () => { + // Regression test for HDX-4988: the save path must not drop + // seriesLimit on line/stacked_bar tiles. + const dashboard = await createTestDashboard(); + const lineChart: ExternalDashboardTileWithId = { + id: new ObjectId().toString(), + name: 'Line with series limit', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'line', + sourceId: traceSource._id.toString(), + select: [ + { + aggFn: 'count', + alias: 'Count', + where: '', + whereLanguage: 'sql', + }, + ], + groupBy: 'ServiceName', + seriesLimit: 15, + }, + }; + const stackedBarChart: ExternalDashboardTileWithId = { + id: new ObjectId().toString(), + name: 'Stacked bar with series limit', + x: 6, + y: 0, + w: 6, + h: 3, + config: { + displayType: 'stacked_bar', + sourceId: traceSource._id.toString(), + select: [ + { + aggFn: 'count', + alias: 'Count', + where: '', + whereLanguage: 'sql', + }, + ], + groupBy: 'ServiceName', + seriesLimit: 3, + }, + }; + + const response = await authRequest('put', `${BASE_URL}/${dashboard._id}`) + .send({ + name: 'Dashboard with series limits', + tiles: [lineChart, stackedBarChart], + }) + .expect(200); + + expect(response.body.data.tiles[0].config.seriesLimit).toBe(15); + expect(response.body.data.tiles[1].config.seriesLimit).toBe(3); + + const getResponse = await authRequest( + 'get', + `${BASE_URL}/${dashboard._id}`, + ).expect(200); + expect(getResponse.body.data.tiles[0].config.seriesLimit).toBe(15); + expect(getResponse.body.data.tiles[1].config.seriesLimit).toBe(3); + }); + it('should update dashboard filters when provided', async () => { const dashboard = await createTestDashboard(); const filterId1 = new ObjectId().toString(); diff --git a/packages/api/src/routers/external-api/v2/dashboards.ts b/packages/api/src/routers/external-api/v2/dashboards.ts index 3bb6749daf..f3d92bed45 100644 --- a/packages/api/src/routers/external-api/v2/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/dashboards.ts @@ -629,6 +629,11 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * type: boolean * description: Overlay the equivalent previous time period for comparison. * default: false + * seriesLimit: + * type: integer + * minimum: 1 + * description: Maximum number of series rendered (top-N by value). Omit for no limit. + * example: 5 * * BarBuilderChartConfig: * type: object @@ -676,6 +681,11 @@ const EXTERNAL_DASHBOARD_PROJECTION = { * numberFormat: * $ref: '#/components/schemas/NumberFormat' * 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. + * example: 5 * * TableBuilderChartConfig: * type: object diff --git a/packages/api/src/routers/external-api/v2/utils/dashboards.ts b/packages/api/src/routers/external-api/v2/utils/dashboards.ts index 638efbcb1b..42522b0abe 100644 --- a/packages/api/src/routers/external-api/v2/utils/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/utils/dashboards.ts @@ -15,7 +15,6 @@ import { import { AggregateFunctionSchema, BuilderSavedChartConfig, - ChartPaletteToken, ColorCondition, DASHBOARD_MAX_CONTAINERS, DashboardContainer, @@ -300,6 +299,7 @@ const convertToExternalTileChartConfig = ( : [DEFAULT_SELECT_ITEM], compareToPreviousPeriod: config.compareToPreviousPeriod, numberFormat: config.numberFormat, + seriesLimit: config.seriesLimit ?? undefined, }; case DisplayType.StackedBar: return { @@ -316,6 +316,7 @@ const convertToExternalTileChartConfig = ( ? config.select.map(convertToExternalSelectItem) : [DEFAULT_SELECT_ITEM], numberFormat: config.numberFormat, + seriesLimit: config.seriesLimit ?? undefined, }; case DisplayType.Number: return { @@ -703,6 +704,7 @@ export function convertToInternalTileConfig( where: '', fillNulls: externalConfig.fillNulls === false ? false : undefined, seriesReturnType: externalConfig.asRatio ? 'ratio' : undefined, + seriesLimit: externalConfig.seriesLimit, name, } satisfies BuilderSavedChartConfig; break; diff --git a/packages/api/src/utils/zod.ts b/packages/api/src/utils/zod.ts index 42f11afeab..8c73d910d7 100644 --- a/packages/api/src/utils/zod.ts +++ b/packages/api/src/utils/zod.ts @@ -268,6 +268,7 @@ const externalDashboardLineChartConfigSchema = displayType: z.literal('line'), compareToPreviousPeriod: z.boolean().optional(), fitYAxisToData: z.boolean().optional(), + seriesLimit: z.number().int().positive().optional(), }); const externalDashboardLineRawSqlChartConfigSchema = @@ -282,6 +283,7 @@ const externalDashboardLineRawSqlChartConfigSchema = const externalDashboardBarChartConfigSchema = externalDashboardTimeChartConfigSchema.extend({ displayType: z.literal('stacked_bar'), + seriesLimit: z.number().int().positive().optional(), }); const externalDashboardBarRawSqlChartConfigSchema =