Skip to content

Commit c29d0df

Browse files
authored
feat: Add categorical bar chart display type (#2606)
## Summary This PR adds a (categorical) bar chart component, in contrast to the current time-series bar chart component. This new display type is supported in chart explorer, dashboards, MCP, and external dashboards APIs. This PR also updates the bar and pie chart components to support the series limit option currently enabled for time series charts. For the bar/pie chart case however, this option simply applies an ORDER BY and LIMIT SQL clause, rather than the CTE which is required to compute the top-series across all time buckets in the time series case. ### Screenshots or video https://github.com/user-attachments/assets/bbe4b90c-f7ef-4557-bcd4-6e346a78ec71 ### How to test on Vercel preview This can mostly be tested in the preview environment in chart explorer or dashboards, though MCP and external API support can be tested locally. ### References - Linear Issue: - Related PRs: (supercedes) Closes #2005
1 parent e214567 commit c29d0df

41 files changed

Lines changed: 1751 additions & 187 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@hyperdx/common-utils": minor
3+
"@hyperdx/api": minor
4+
"@hyperdx/app": minor
5+
---
6+
7+
feat: Add categorical bar chart display type

‎.changeset/pie-bar-series-limit.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@hyperdx/common-utils": minor
3+
"@hyperdx/api": minor
4+
"@hyperdx/app": minor
5+
---
6+
7+
feat: Allow specifying a limit on pie and bar chart series

‎packages/api/openapi.json‎

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1866,6 +1866,61 @@
18661866
"numberFormat": {
18671867
"$ref": "#/components/schemas/NumberFormat",
18681868
"description": "Number formatting options for displayed values."
1869+
},
1870+
"limit": {
1871+
"type": "integer",
1872+
"minimum": 1,
1873+
"description": "Maximum number of slices (SQL LIMIT). The query keeps the groups with the largest aggregated values. Omit to fetch all groups.\n",
1874+
"example": 10
1875+
}
1876+
}
1877+
},
1878+
"CategoricalBarBuilderChartConfig": {
1879+
"type": "object",
1880+
"required": [
1881+
"displayType",
1882+
"sourceId",
1883+
"select"
1884+
],
1885+
"description": "Builder configuration for a categorical bar chart tile. Each bar represents one group value. Distinct from stacked_bar, which is a time-series chart.\n",
1886+
"properties": {
1887+
"displayType": {
1888+
"type": "string",
1889+
"enum": [
1890+
"bar"
1891+
],
1892+
"description": "Display type discriminator. Must be \"bar\" for categorical bar charts.",
1893+
"example": "bar"
1894+
},
1895+
"sourceId": {
1896+
"type": "string",
1897+
"description": "ID of the data source to query.",
1898+
"example": "65f5e4a3b9e77c001a111111"
1899+
},
1900+
"select": {
1901+
"type": "array",
1902+
"minItems": 1,
1903+
"maxItems": 1,
1904+
"description": "Exactly one aggregated value used to size each bar.",
1905+
"items": {
1906+
"$ref": "#/components/schemas/SelectItem"
1907+
}
1908+
},
1909+
"groupBy": {
1910+
"type": "string",
1911+
"maxLength": 10000,
1912+
"description": "Field expression to group results by (one bar per group value).",
1913+
"example": "service"
1914+
},
1915+
"numberFormat": {
1916+
"$ref": "#/components/schemas/NumberFormat",
1917+
"description": "Number formatting options for displayed values."
1918+
},
1919+
"limit": {
1920+
"type": "integer",
1921+
"minimum": 1,
1922+
"description": "Maximum number of bars (SQL LIMIT). The query keeps the groups with the largest aggregated values. Omit to fetch all groups.\n",
1923+
"example": 10
18691924
}
18701925
}
18711926
},
@@ -2252,6 +2307,30 @@
22522307
}
22532308
]
22542309
},
2310+
"CategoricalBarRawSqlChartConfig": {
2311+
"description": "Raw SQL configuration for a categorical bar chart.",
2312+
"allOf": [
2313+
{
2314+
"$ref": "#/components/schemas/RawSqlChartConfigBase"
2315+
},
2316+
{
2317+
"type": "object",
2318+
"required": [
2319+
"displayType"
2320+
],
2321+
"properties": {
2322+
"displayType": {
2323+
"type": "string",
2324+
"enum": [
2325+
"bar"
2326+
],
2327+
"description": "Display as a categorical bar chart.",
2328+
"example": "bar"
2329+
}
2330+
}
2331+
}
2332+
]
2333+
},
22552334
"LineChartConfig": {
22562335
"description": "Line chart. Omit configType for the builder variant (requires sourceId and select). Set configType to \"sql\" for the Raw SQL variant (requires connectionId and sqlTemplate).\n",
22572336
"oneOf": [
@@ -2543,8 +2622,25 @@
25432622
}
25442623
}
25452624
},
2625+
"CategoricalBarChartConfig": {
2626+
"description": "Categorical bar chart (one bar per group value; not a time series). Omit configType for the builder variant (requires sourceId and select). Set configType to \"sql\" for the Raw SQL variant (requires connectionId and sqlTemplate).\n",
2627+
"oneOf": [
2628+
{
2629+
"$ref": "#/components/schemas/CategoricalBarBuilderChartConfig"
2630+
},
2631+
{
2632+
"$ref": "#/components/schemas/CategoricalBarRawSqlChartConfig"
2633+
}
2634+
],
2635+
"discriminator": {
2636+
"propertyName": "configType",
2637+
"mapping": {
2638+
"sql": "#/components/schemas/CategoricalBarRawSqlChartConfig"
2639+
}
2640+
}
2641+
},
25462642
"TileConfig": {
2547-
"description": "Tile chart configuration. displayType is the primary discriminant and determines which variant group applies. For displayTypes that support both builder and Raw SQL modes (line, stacked_bar, table, number, pie), configType is the secondary discriminant: omit it for the builder variant or set it to \"sql\" for the Raw SQL variant. The heatmap, search, event_patterns, and markdown displayTypes only have a builder variant.\n",
2643+
"description": "Tile chart configuration. displayType is the primary discriminant and determines which variant group applies. For displayTypes that support both builder and Raw SQL modes (line, stacked_bar, table, number, pie, bar), configType is the secondary discriminant: omit it for the builder variant or set it to \"sql\" for the Raw SQL variant. The heatmap, search, event_patterns, and markdown displayTypes only have a builder variant.\n",
25482644
"oneOf": [
25492645
{
25502646
"$ref": "#/components/schemas/LineChartConfig"
@@ -2561,6 +2657,9 @@
25612657
{
25622658
"$ref": "#/components/schemas/PieChartConfig"
25632659
},
2660+
{
2661+
"$ref": "#/components/schemas/CategoricalBarChartConfig"
2662+
},
25642663
{
25652664
"$ref": "#/components/schemas/HeatmapChartConfig"
25662665
},
@@ -2582,6 +2681,7 @@
25822681
"table": "#/components/schemas/TableChartConfig",
25832682
"number": "#/components/schemas/NumberChartConfig",
25842683
"pie": "#/components/schemas/PieChartConfig",
2684+
"bar": "#/components/schemas/CategoricalBarChartConfig",
25852685
"heatmap": "#/components/schemas/HeatmapChartConfig",
25862686
"search": "#/components/schemas/SearchChartConfig",
25872687
"event_patterns": "#/components/schemas/EventPatternsChartConfig",

‎packages/api/src/mcp/__tests__/dashboards/queryTile.test.ts‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1+
import { SourceKind } from '@hyperdx/common-utils/dist/types';
2+
3+
import {
4+
bulkInsertLogs,
5+
DEFAULT_DATABASE,
6+
DEFAULT_LOGS_TABLE,
7+
} from '@/fixtures';
18
import { callTool, getFirstText } from '@/mcp/__tests__/mcpTestUtils';
9+
import { Source } from '@/models/source';
210

311
import { setupDashboardTests } from './setup';
412

@@ -192,4 +200,106 @@ describe('MCP Dashboard Tools - clickstack_query_tile', () => {
192200
expect(parsed.warnings).toBeUndefined();
193201
});
194202
});
203+
204+
describe('categorical (bar) tile series limit', () => {
205+
const now = new Date();
206+
const tenMinAgo = new Date(now.getTime() - 10 * 60 * 1000);
207+
208+
// Five distinct services with descending row counts, so a limit keeps a
209+
// strict, deterministic top-N subset.
210+
const SERVICE_COUNTS: [string, number][] = [
211+
['svc-a', 5],
212+
['svc-b', 4],
213+
['svc-c', 3],
214+
['svc-d', 2],
215+
['svc-e', 1],
216+
];
217+
218+
let logSourceId: string;
219+
220+
beforeEach(async () => {
221+
const logSource = await Source.create({
222+
kind: SourceKind.Log,
223+
team: ctx.team._id,
224+
from: {
225+
databaseName: DEFAULT_DATABASE,
226+
tableName: DEFAULT_LOGS_TABLE,
227+
},
228+
timestampValueExpression: 'Timestamp',
229+
connection: ctx.connection._id,
230+
name: 'Bar Limit Logs',
231+
bodyExpression: 'Body',
232+
severityTextExpression: 'SeverityText',
233+
});
234+
logSourceId = logSource._id.toString();
235+
236+
const logs: Parameters<typeof bulkInsertLogs>[0] = [];
237+
SERVICE_COUNTS.forEach(([serviceName, count], svcIdx) => {
238+
for (let i = 0; i < count; i++) {
239+
logs.push({
240+
Body: `bar limit log ${serviceName} ${i}`,
241+
ServiceName: serviceName,
242+
SeverityText: 'INFO',
243+
Timestamp: new Date(tenMinAgo.getTime() + (svcIdx * 100 + i) * 10),
244+
});
245+
}
246+
});
247+
await bulkInsertLogs(logs);
248+
});
249+
250+
const saveBarDashboard = async (limit?: number) => {
251+
const createResult = await callTool(
252+
ctx.client!,
253+
'clickstack_save_dashboard',
254+
{
255+
name: `Bar Limit Dashboard ${limit ?? 'none'}`,
256+
tiles: [
257+
{
258+
name: 'Bars by service',
259+
config: {
260+
displayType: 'bar',
261+
sourceId: logSourceId,
262+
select: [{ aggFn: 'count' }],
263+
groupBy: 'ServiceName',
264+
...(limit != null ? { limit } : {}),
265+
},
266+
},
267+
],
268+
},
269+
);
270+
expect(createResult.isError).toBeFalsy();
271+
return JSON.parse(getFirstText(createResult));
272+
};
273+
274+
const queryTileRows = async (dashboard: any) => {
275+
const result = await callTool(ctx.client!, 'clickstack_query_tile', {
276+
dashboardId: dashboard.id,
277+
tileId: dashboard.tiles[0].id,
278+
startTime: new Date(now.getTime() - 15 * 60 * 1000).toISOString(),
279+
endTime: new Date(now.getTime() + 60 * 1000).toISOString(),
280+
});
281+
expect(result.isError).toBeFalsy();
282+
const parsed = JSON.parse(getFirstText(result));
283+
return parsed.result.data as Record<string, string>[];
284+
};
285+
286+
it('returns every group when no limit is set', async () => {
287+
const dashboard = await saveBarDashboard();
288+
const rows = await queryTileRows(dashboard);
289+
expect(rows).toHaveLength(SERVICE_COUNTS.length);
290+
});
291+
292+
it('caps the bars to the series limit, keeping the largest groups', async () => {
293+
const dashboard = await saveBarDashboard(3);
294+
const rows = await queryTileRows(dashboard);
295+
296+
// The limit must actually reduce the result below the full set.
297+
expect(rows).toHaveLength(3);
298+
expect(rows.length).toBeLessThan(SERVICE_COUNTS.length);
299+
300+
// And it must keep the top-3 by count, not an arbitrary subset.
301+
const services = rows.map(r => r.ServiceName).sort();
302+
expect(services).toEqual(['svc-a', 'svc-b', 'svc-c']);
303+
});
304+
});
195305
});

‎packages/api/src/mcp/__tests__/dashboards/saveDashboard.test.ts‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,19 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => {
218218
groupBy: 'SpanName',
219219
},
220220
},
221+
{
222+
name: 'Bar',
223+
x: 12,
224+
y: 8,
225+
w: 6,
226+
h: 3,
227+
config: {
228+
displayType: 'bar',
229+
sourceId,
230+
select: [{ aggFn: 'count' }],
231+
groupBy: 'SpanName',
232+
},
233+
},
221234
{
222235
name: 'Notes',
223236
x: 0,
@@ -231,7 +244,7 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => {
231244

232245
expect(result.isError).toBeFalsy();
233246
const output = JSON.parse(getFirstText(result));
234-
expect(output.tiles).toHaveLength(5);
247+
expect(output.tiles).toHaveLength(6);
235248

236249
const tableTile = output.tiles.find(
237250
(t: { name: string }) => t.name === 'Table',
@@ -952,6 +965,15 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => {
952965
select: [{ aggFn: 'count' as const, alias: 'Requests' }],
953966
groupBy: 'SpanName',
954967
numberFormat,
968+
limit: 5,
969+
};
970+
const categoricalBarConfig = {
971+
displayType: 'bar' as const,
972+
sourceId,
973+
select: [{ aggFn: 'count' as const, alias: 'Requests' }],
974+
groupBy: 'SpanName',
975+
numberFormat,
976+
limit: 5,
955977
};
956978
const numberConfig = {
957979
displayType: 'number' as const,
@@ -977,13 +999,15 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => {
977999
{ name: 'Bar', config: barConfig },
9781000
{ name: 'Table', config: tableConfig },
9791001
{ name: 'Pie', config: pieConfig },
1002+
{ name: 'Categorical Bar', config: categoricalBarConfig },
9801003
{ name: 'Number', config: numberConfig },
9811004
];
9821005
const configByName: Record<string, object> = {
9831006
Line: lineConfig,
9841007
Bar: barConfig,
9851008
Table: tableConfig,
9861009
Pie: pieConfig,
1010+
'Categorical Bar': categoricalBarConfig,
9871011
Number: numberConfig,
9881012
};
9891013
const assertTiles = (output: { tiles: { name: string }[] }) => {

‎packages/api/src/mcp/__tests__/queryTool.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,20 @@ describe('MCP Query Tools', () => {
272272
expect(result.content).toHaveLength(1);
273273
});
274274

275+
it('should execute a bar query', async () => {
276+
const result = await callTool(client, 'clickstack_table', {
277+
sourceId: traceSource._id.toString(),
278+
select: [{ aggFn: 'count' }],
279+
shape: 'bar',
280+
groupBy: 'SpanName',
281+
startTime: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
282+
endTime: new Date().toISOString(),
283+
});
284+
285+
expect(result.isError).toBeFalsy();
286+
expect(result.content).toHaveLength(1);
287+
});
288+
275289
it('should auto-upgrade shape:"number" to "table" when select has multiple items', async () => {
276290
// This should NOT error — it should silently upgrade to table
277291
const result = await callTool(client, 'clickstack_table', {
@@ -306,6 +320,23 @@ describe('MCP Query Tools', () => {
306320
expect(result.content).toHaveLength(1);
307321
});
308322

323+
it('should auto-upgrade shape:"bar" to "table" when select has multiple items', async () => {
324+
const result = await callTool(client, 'clickstack_table', {
325+
sourceId: traceSource._id.toString(),
326+
select: [
327+
{ aggFn: 'count' },
328+
{ aggFn: 'sum', valueExpression: 'Duration' },
329+
],
330+
shape: 'bar',
331+
groupBy: 'SpanName',
332+
startTime: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
333+
endTime: new Date().toISOString(),
334+
});
335+
336+
expect(result.isError).toBeFalsy();
337+
expect(result.content).toHaveLength(1);
338+
});
339+
309340
it('should use default time range when not provided', async () => {
310341
const result = await callTool(client, 'clickstack_table', {
311342
sourceId: traceSource._id.toString(),

0 commit comments

Comments
 (0)