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
6 changes: 6 additions & 0 deletions .changeset/chilled-drinks-pay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/api": patch
---

fix: Add sourceId to MCP Raw SQL Tile schema
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,101 @@ describe('MCP Dashboard Tools - clickstack_patch_dashboard', () => {
expect(getFirstText(result)).toContain('source');
});

it('should reject patching a tile to raw SQL with $__filters but no sourceId', async () => {
const sourceId = ctx.traceSource._id.toString();
const connectionId = ctx.connection._id.toString();
const createResult = await callTool(
ctx.client!,
'clickstack_save_dashboard',
{
name: 'SQL Macro Patch Test',
tiles: [
{
name: 'Valid Tile',
config: {
displayType: 'line',
sourceId,
select: [{ aggFn: 'count' }],
},
},
],
},
);
const created = JSON.parse(getFirstText(createResult));
const tileId = created.tiles[0].id;

const result = await callTool(ctx.client!, 'clickstack_patch_dashboard', {
dashboardId: created.id,
tileId,
tile: {
name: 'Filtered SQL',
config: {
configType: 'sql',
displayType: 'table',
connectionId,
sqlTemplate:
'SELECT ServiceName, count() AS c FROM otel_traces WHERE $__timeFilter(Timestamp) AND $__filters GROUP BY ServiceName LIMIT 10',
},
},
});

expect(result.isError).toBe(true);
const text = getFirstText(result);
expect(text).toContain('sourceId');
expect(text).toContain('$__filters');
expect(text).toContain('Filtered SQL');

// The original tile must be untouched in the database.
const dashboard = await Dashboard.findById(created.id);
const persistedTile = dashboard?.tiles?.find(
(t: { id: string }) => t.id === tileId,
);
expect(persistedTile?.config?.name).toBe('Valid Tile');
});

it('should allow patching a tile to raw SQL with $__filters when a sourceId is set', async () => {
const sourceId = ctx.traceSource._id.toString();
const connectionId = ctx.connection._id.toString();
const createResult = await callTool(
ctx.client!,
'clickstack_save_dashboard',
{
name: 'SQL Macro Patch Test (valid)',
tiles: [
{
name: 'Valid Tile',
config: {
displayType: 'line',
sourceId,
select: [{ aggFn: 'count' }],
},
},
],
},
);
const created = JSON.parse(getFirstText(createResult));

const result = await callTool(ctx.client!, 'clickstack_patch_dashboard', {
dashboardId: created.id,
tileId: created.tiles[0].id,
tile: {
name: 'Filtered SQL',
config: {
configType: 'sql',
displayType: 'table',
connectionId,
sourceId,
sqlTemplate:
'SELECT ServiceName, count() AS c FROM otel_traces WHERE $__timeFilter(Timestamp) AND $__filters GROUP BY ServiceName LIMIT 10',
},
},
});

expect(result.isError).toBeFalsy();
const output = JSON.parse(getFirstText(result));
expect(output.patchedTile.config.sourceId).toBe(sourceId);
});

it('should not modify other tiles in the database (positional update)', async () => {
const sourceId = ctx.traceSource._id.toString();
const createResult = await callTool(
Expand Down
109 changes: 109 additions & 0 deletions packages/api/src/mcp/__tests__/dashboards/saveDashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,115 @@ describe('MCP Dashboard Tools - clickstack_save_dashboard', () => {
expect(output.tiles).toHaveLength(1);
});

it('should persist sourceId on a raw SQL tile that uses $__filters', async () => {
const connectionId = ctx.connection._id.toString();
const sourceId = ctx.traceSource._id.toString();
const result = await callTool(ctx.client!, 'clickstack_save_dashboard', {
name: 'SQL Dashboard with filters',
tiles: [
{
name: 'Filtered SQL',
config: {
configType: 'sql',
displayType: 'table',
connectionId,
sourceId,
sqlTemplate:
'SELECT ServiceName, count() AS c FROM otel_traces WHERE $__timeFilter(Timestamp) AND $__filters GROUP BY ServiceName LIMIT 10',
},
},
],
});

expect(result.isError).toBeFalsy();
const output = JSON.parse(getFirstText(result));
expect(output.tiles).toHaveLength(1);
// sourceId must round-trip so the $__filters macro can resolve.
expect(output.tiles[0].config.sourceId).toBe(sourceId);

const dashboard = await Dashboard.findById(output.id);
expect(dashboard?.tiles?.[0]?.config?.source?.toString()).toBe(sourceId);
});

it('should reject a raw SQL tile that uses $__filters without a sourceId', async () => {
const connectionId = ctx.connection._id.toString();
const result = await callTool(ctx.client!, 'clickstack_save_dashboard', {
name: 'SQL Dashboard missing source',
tiles: [
{
name: 'Filtered SQL',
config: {
configType: 'sql',
displayType: 'table',
connectionId,
sqlTemplate:
'SELECT ServiceName, count() AS c FROM otel_traces WHERE $__timeFilter(Timestamp) AND $__filters GROUP BY ServiceName LIMIT 10',
},
},
],
});

expect(result.isError).toBe(true);
const text = getFirstText(result);
expect(text).toContain('sourceId');
expect(text).toContain('$__filters');
expect(text).toContain('Filtered SQL');

// Nothing should have been persisted.
expect(await Dashboard.countDocuments({})).toBe(0);
});

it('should reject a raw SQL tile that uses $__sourceTable without a sourceId', async () => {
const connectionId = ctx.connection._id.toString();
const result = await callTool(ctx.client!, 'clickstack_save_dashboard', {
name: 'SQL Dashboard missing source',
tiles: [
{
name: 'Source Table SQL',
config: {
configType: 'sql',
displayType: 'table',
connectionId,
sqlTemplate:
'SELECT ServiceName, count() AS c FROM $__sourceTable WHERE $__timeFilter(Timestamp) GROUP BY ServiceName LIMIT 10',
},
},
],
});

expect(result.isError).toBe(true);
const text = getFirstText(result);
expect(text).toContain('sourceId');
expect(text).toContain('$__sourceTable');
expect(text).toContain('Source Table SQL');

// Nothing should have been persisted.
expect(await Dashboard.countDocuments({})).toBe(0);
});

it('should allow a raw SQL tile without a sourceId when it does not use $__filters', async () => {
const connectionId = ctx.connection._id.toString();
const result = await callTool(ctx.client!, 'clickstack_save_dashboard', {
name: 'Multi-table SQL Dashboard',
tiles: [
{
name: 'Joined SQL',
config: {
configType: 'sql',
displayType: 'table',
connectionId,
sqlTemplate:
'SELECT t.ServiceName, count() AS c FROM otel_traces t JOIN otel_logs l ON t.TraceId = l.TraceId GROUP BY t.ServiceName LIMIT 10',
},
},
],
});

expect(result.isError).toBeFalsy();
const output = JSON.parse(getFirstText(result));
expect(output.tiles).toHaveLength(1);
});

it('should create a dashboard with a heatmap tile on a Trace source', async () => {
const sourceId = ctx.traceSource._id.toString();
const result = await callTool(ctx.client!, 'clickstack_save_dashboard', {
Expand Down
169 changes: 169 additions & 0 deletions packages/api/src/mcp/__tests__/dashboards/validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import mongoose from 'mongoose';

import type { ExternalDashboardTileWithId } from '@/utils/zod';

import {
getRawSqlMissingSourceError,
getRawSqlTilesMissingRequiredSource,
} from '../../tools/dashboards/validation';

const connectionId = new mongoose.Types.ObjectId().toString();
const sourceId = new mongoose.Types.ObjectId().toString();

function makeSqlTile(overrides: {
name?: string;
sqlTemplate: string;
sourceId?: string;
}): ExternalDashboardTileWithId {
return {
id: 'sql-tile',
x: 0,
y: 0,
w: 12,
h: 4,
name: overrides.name ?? 'SQL Tile',
config: {
configType: 'sql',
displayType: 'table',
connectionId,
sqlTemplate: overrides.sqlTemplate,
...(overrides.sourceId ? { sourceId: overrides.sourceId } : {}),
},
} as ExternalDashboardTileWithId;
}

describe('getRawSqlTilesMissingRequiredSource', () => {
it('flags a raw SQL tile that uses $__filters without a sourceId', () => {
expect(
getRawSqlTilesMissingRequiredSource([
makeSqlTile({
name: 'Errors by Service',
sqlTemplate:
'SELECT count() FROM otel_traces WHERE $__timeFilter(Timestamp) AND $__filters',
}),
]),
).toEqual([{ tile: 'Errors by Service', macros: ['$__filters'] }]);
});

it('flags a raw SQL tile that uses $__sourceTable without a sourceId', () => {
expect(
getRawSqlTilesMissingRequiredSource([
makeSqlTile({
name: 'Span Count',
sqlTemplate:
'SELECT count() FROM $__sourceTable WHERE $__timeFilter(Timestamp)',
}),
]),
).toEqual([{ tile: 'Span Count', macros: ['$__sourceTable'] }]);
});

it('reports every source-dependent macro used by a tile', () => {
expect(
getRawSqlTilesMissingRequiredSource([
makeSqlTile({
name: 'Both Macros',
sqlTemplate:
'SELECT count() FROM $__sourceTable WHERE $__timeFilter(Timestamp) AND $__filters',
}),
]),
).toEqual([
{ tile: 'Both Macros', macros: ['$__filters', '$__sourceTable'] },
]);
});

it('does not flag a raw SQL tile that sets a sourceId', () => {
expect(
getRawSqlTilesMissingRequiredSource([
makeSqlTile({
sourceId,
sqlTemplate:
'SELECT count() FROM $__sourceTable WHERE $__timeFilter(Timestamp) AND $__filters',
}),
]),
).toEqual([]);
});

it('does not flag a raw SQL tile that uses no source-dependent macro', () => {
// A multi-table query that legitimately omits sourceId and references
// neither $__filters nor $__sourceTable must not be rejected.
expect(
getRawSqlTilesMissingRequiredSource([
makeSqlTile({
sqlTemplate:
'SELECT count() FROM otel_traces t JOIN otel_logs l ON t.TraceId = l.TraceId WHERE $__timeFilter(t.Timestamp) LIMIT 10',
}),
]),
).toEqual([]);
});

it('falls back to a positional label when the tile has no name', () => {
expect(
getRawSqlTilesMissingRequiredSource([
makeSqlTile({
name: '',
sqlTemplate: 'SELECT count() FROM otel_traces WHERE $__filters',
}),
]),
).toEqual([{ tile: 'tile #1', macros: ['$__filters'] }]);
});

it('ignores builder (non-SQL) tiles entirely', () => {
const builderTile = {
id: 'builder',
x: 0,
y: 0,
w: 12,
h: 4,
name: 'Line',
config: {
displayType: 'line',
sourceId,
select: [{ aggFn: 'count', valueExpression: '', where: '' }],
},
} as unknown as ExternalDashboardTileWithId;
expect(getRawSqlTilesMissingRequiredSource([builderTile])).toEqual([]);
});

it('collects every offending tile across a mixed set', () => {
expect(
getRawSqlTilesMissingRequiredSource([
makeSqlTile({ name: 'A', sqlTemplate: 'SELECT 1 WHERE $__filters' }),
makeSqlTile({
name: 'B',
sourceId,
sqlTemplate: 'SELECT 1 WHERE $__filters',
}),
makeSqlTile({
name: 'C',
sqlTemplate: 'SELECT 1 FROM $__sourceTable',
}),
]),
).toEqual([
{ tile: 'A', macros: ['$__filters'] },
{ tile: 'C', macros: ['$__sourceTable'] },
]);
});
});

describe('getRawSqlMissingSourceError', () => {
it('returns null when all tiles are valid', () => {
expect(
getRawSqlMissingSourceError([
makeSqlTile({ sqlTemplate: 'SELECT 1 LIMIT 1' }),
]),
).toBeNull();
});

it('builds a message naming the offending tiles and macros', () => {
const message = getRawSqlMissingSourceError([
makeSqlTile({
name: 'Errors by Service',
sqlTemplate: 'SELECT count() FROM otel_traces WHERE $__filters',
}),
]);
expect(message).toContain('sourceId');
expect(message).toContain('$__filters');
expect(message).toContain('$__sourceTable');
expect(message).toContain('Errors by Service (uses $__filters)');
});
});
Loading
Loading