|
| 1 | +import { SourceKind } from '@hyperdx/common-utils/dist/types'; |
| 2 | + |
| 3 | +import { |
| 4 | + bulkInsertLogs, |
| 5 | + DEFAULT_DATABASE, |
| 6 | + DEFAULT_LOGS_TABLE, |
| 7 | +} from '@/fixtures'; |
1 | 8 | import { callTool, getFirstText } from '@/mcp/__tests__/mcpTestUtils'; |
| 9 | +import { Source } from '@/models/source'; |
2 | 10 |
|
3 | 11 | import { setupDashboardTests } from './setup'; |
4 | 12 |
|
@@ -192,4 +200,106 @@ describe('MCP Dashboard Tools - clickstack_query_tile', () => { |
192 | 200 | expect(parsed.warnings).toBeUndefined(); |
193 | 201 | }); |
194 | 202 | }); |
| 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 | + }); |
195 | 305 | }); |
0 commit comments