Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/expose-series-limit-external-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperdx/api': patch
---

fix: Expose `seriesLimit` via external API and MCP
12 changes: 12 additions & 0 deletions packages/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
},
Expand Down Expand Up @@ -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
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions packages/api/src/mcp/tools/dashboards/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ' +
Expand Down Expand Up @@ -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),
}),
});

Expand All @@ -551,6 +560,7 @@ const mcpBarTileSchema = mcpTileLayoutSchema.extend({
numberFormat: mcpNumberFormatSchema
.optional()
.describe(tileLevelNumberFormatDescription),
seriesLimit: seriesLimitSchema.describe(timeChartSeriesLimitDescription),
}),
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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();
Expand Down
10 changes: 10 additions & 0 deletions packages/api/src/routers/external-api/v2/dashboards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
import {
AggregateFunctionSchema,
BuilderSavedChartConfig,
ChartPaletteToken,
ColorCondition,
DASHBOARD_MAX_CONTAINERS,
DashboardContainer,
Expand Down Expand Up @@ -300,6 +299,7 @@ const convertToExternalTileChartConfig = (
: [DEFAULT_SELECT_ITEM],
compareToPreviousPeriod: config.compareToPreviousPeriod,
numberFormat: config.numberFormat,
seriesLimit: config.seriesLimit ?? undefined,
};
case DisplayType.StackedBar:
return {
Expand All @@ -316,6 +316,7 @@ const convertToExternalTileChartConfig = (
? config.select.map(convertToExternalSelectItem)
: [DEFAULT_SELECT_ITEM],
numberFormat: config.numberFormat,
seriesLimit: config.seriesLimit ?? undefined,
};
case DisplayType.Number:
return {
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading