Skip to content

Commit 228b845

Browse files
committed
feat: Implement alerting for Raw SQL-based dashboard tiles
1 parent 337ebff commit 228b845

File tree

31 files changed

+1579
-293
lines changed

31 files changed

+1579
-293
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@hyperdx/common-utils": patch
3+
"@hyperdx/api": patch
4+
"@hyperdx/app": patch
5+
---
6+
7+
feat: Implement alerting for Raw SQL-based dashboard tiles

packages/api/openapi.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@
162162
},
163163
"tileId": {
164164
"type": "string",
165-
"description": "Tile ID for tile-based alerts. May not be a Raw-SQL-based tile.",
165+
"description": "Tile ID for tile-based alerts. Must be a builder-type line/bar/number tile or a SQL-type line/bar tile.",
166166
"nullable": true,
167167
"example": "65f5e4a3b9e77c001a901234"
168168
},

packages/api/src/controllers/alerts.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import {
2+
displayTypeSupportsRawSqlAlerts,
3+
isSqlTemplateValidForAlert,
4+
} from '@hyperdx/common-utils/dist/core/utils';
15
import { isRawSqlSavedChartConfig } from '@hyperdx/common-utils/dist/guards';
26
import { sign, verify } from 'jsonwebtoken';
37
import { groupBy } from 'lodash';
@@ -82,7 +86,17 @@ export const validateAlertInput = async (
8286
}
8387

8488
if (tile.config != null && isRawSqlSavedChartConfig(tile.config)) {
85-
throw new Api400Error('Cannot create an alert on a raw SQL tile');
89+
if (!displayTypeSupportsRawSqlAlerts(tile.config.displayType)) {
90+
throw new Api400Error(
91+
'Alerts on Raw SQL tiles are only supported for Line or Stacked Bar display types',
92+
);
93+
}
94+
95+
if (!isSqlTemplateValidForAlert(tile.config)) {
96+
throw new Api400Error(
97+
'Raw SQL alert queries must include time filters and interval parameters to be valid for alerts.',
98+
);
99+
}
86100
}
87101
}
88102

packages/api/src/controllers/dashboard.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
import { isBuilderSavedChartConfig } from '@hyperdx/common-utils/dist/guards';
1+
import {
2+
isBuilderSavedChartConfig,
3+
isRawSqlSavedChartConfig,
4+
} from '@hyperdx/common-utils/dist/guards';
25
import {
36
BuilderSavedChartConfig,
47
DashboardWithoutIdSchema,
8+
RawSqlSavedChartConfig,
59
Tile,
610
} from '@hyperdx/common-utils/dist/types';
711
import { map, partition, uniq } from 'lodash';
@@ -19,7 +23,7 @@ import Dashboard from '@/models/dashboard';
1923

2024
function pickAlertsByTile(tiles: Tile[]) {
2125
return tiles.reduce((acc, tile) => {
22-
if (isBuilderSavedChartConfig(tile.config) && tile.config.alert) {
26+
if (tile.config.alert) {
2327
acc[tile.id] = tile.config.alert;
2428
}
2529
return acc;
@@ -29,6 +33,7 @@ function pickAlertsByTile(tiles: Tile[]) {
2933
type TileForAlertSync = Pick<Tile, 'id'> & {
3034
config?:
3135
| Pick<BuilderSavedChartConfig, 'alert'>
36+
| Pick<RawSqlSavedChartConfig, 'alert'>
3237
| { alert?: IAlert | AlertDocument };
3338
};
3439

@@ -55,9 +60,7 @@ async function syncDashboardAlerts(
5560

5661
const newTilesForAlertSync: TileForAlertSync[] = newTiles.map(t => ({
5762
id: t.id,
58-
config: isBuilderSavedChartConfig(t.config)
59-
? { alert: t.config.alert }
60-
: {},
63+
config: { alert: t.config.alert },
6164
}));
6265
const { tileIds: newTileIds, tileIdsWithAlerts: newTileIdsWithAlerts } =
6366
extractTileAlertData(newTilesForAlertSync);

packages/api/src/fixtures.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -501,7 +501,39 @@ export const makeExternalTile = (opts?: {
501501
},
502502
});
503503

504-
export const makeRawSqlTile = (opts?: { id?: string }): Tile => ({
504+
export const makeRawSqlTile = (opts?: {
505+
id?: string;
506+
displayType?: DisplayType;
507+
sqlTemplate?: string;
508+
connectionId?: string;
509+
}): Tile => ({
510+
id: opts?.id ?? randomMongoId(),
511+
x: 1,
512+
y: 1,
513+
w: 1,
514+
h: 1,
515+
config: {
516+
configType: 'sql',
517+
displayType: opts?.displayType ?? DisplayType.Line,
518+
sqlTemplate: opts?.sqlTemplate ?? 'SELECT 1',
519+
connection: opts?.connectionId ?? 'test-connection',
520+
} satisfies RawSqlSavedChartConfig,
521+
});
522+
523+
export const RAW_SQL_ALERT_TEMPLATE = [
524+
'SELECT toStartOfInterval(Timestamp, INTERVAL {intervalSeconds:Int64} second) AS ts,',
525+
' count() AS cnt',
526+
' FROM default.otel_logs',
527+
' WHERE Timestamp >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64})',
528+
' AND Timestamp < fromUnixTimestamp64Milli({endDateMilliseconds:Int64})',
529+
' GROUP BY ts ORDER BY ts',
530+
].join('');
531+
532+
export const makeRawSqlAlertTile = (opts?: {
533+
id?: string;
534+
connectionId?: string;
535+
sqlTemplate?: string;
536+
}): Tile => ({
505537
id: opts?.id ?? randomMongoId(),
506538
x: 1,
507539
y: 1,
@@ -510,8 +542,8 @@ export const makeRawSqlTile = (opts?: { id?: string }): Tile => ({
510542
config: {
511543
configType: 'sql',
512544
displayType: DisplayType.Line,
513-
sqlTemplate: 'SELECT 1',
514-
connection: 'test-connection',
545+
sqlTemplate: opts?.sqlTemplate ?? RAW_SQL_ALERT_TEMPLATE,
546+
connection: opts?.connectionId ?? 'test-connection',
515547
} satisfies RawSqlSavedChartConfig,
516548
});
517549

packages/api/src/routers/api/__tests__/alerts.test.ts

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1+
import { DisplayType } from '@hyperdx/common-utils/dist/types';
2+
13
import {
24
getLoggedInAgent,
35
getServer,
46
makeAlertInput,
7+
makeRawSqlAlertTile,
58
makeRawSqlTile,
69
makeTile,
710
randomMongoId,
11+
RAW_SQL_ALERT_TEMPLATE,
812
} from '@/fixtures';
913
import Alert, { AlertSource, AlertThresholdType } from '@/models/alert';
1014
import Webhook, { WebhookDocument, WebhookService } from '@/models/webhook';
@@ -550,8 +554,36 @@ describe('alerts router', () => {
550554
await agent.delete(`/alerts/${fakeId}/silenced`).expect(404); // Should fail
551555
});
552556

553-
it('rejects creating an alert on a raw SQL tile', async () => {
554-
const rawSqlTile = makeRawSqlTile();
557+
it('allows creating an alert on a raw SQL line tile', async () => {
558+
const rawSqlTile = makeRawSqlAlertTile();
559+
const dashboard = await agent
560+
.post('/dashboards')
561+
.send({
562+
name: 'Test Dashboard',
563+
tiles: [rawSqlTile],
564+
tags: [],
565+
})
566+
.expect(200);
567+
568+
const alert = await agent
569+
.post('/alerts')
570+
.send(
571+
makeAlertInput({
572+
dashboardId: dashboard.body.id,
573+
tileId: rawSqlTile.id,
574+
webhookId: webhook._id.toString(),
575+
}),
576+
)
577+
.expect(200);
578+
expect(alert.body.data.dashboard).toBe(dashboard.body.id);
579+
expect(alert.body.data.tileId).toBe(rawSqlTile.id);
580+
});
581+
582+
it('rejects creating an alert on a raw SQL number tile', async () => {
583+
const rawSqlTile = makeRawSqlTile({
584+
displayType: DisplayType.Number,
585+
sqlTemplate: RAW_SQL_ALERT_TEMPLATE,
586+
});
555587
const dashboard = await agent
556588
.post('/dashboards')
557589
.send({
@@ -573,9 +605,37 @@ describe('alerts router', () => {
573605
.expect(400);
574606
});
575607

576-
it('rejects updating an alert to reference a raw SQL tile', async () => {
608+
it('rejects creating an alert on a raw SQL tile without interval params', async () => {
609+
const rawSqlTile = makeRawSqlTile({
610+
sqlTemplate: 'SELECT count() FROM otel_logs',
611+
});
612+
const dashboard = await agent
613+
.post('/dashboards')
614+
.send({
615+
name: 'Test Dashboard',
616+
tiles: [rawSqlTile],
617+
tags: [],
618+
})
619+
.expect(200);
620+
621+
await agent
622+
.post('/alerts')
623+
.send(
624+
makeAlertInput({
625+
dashboardId: dashboard.body.id,
626+
tileId: rawSqlTile.id,
627+
webhookId: webhook._id.toString(),
628+
}),
629+
)
630+
.expect(400);
631+
});
632+
633+
it('rejects updating an alert to reference a raw SQL number tile', async () => {
577634
const regularTile = makeTile();
578-
const rawSqlTile = makeRawSqlTile();
635+
const rawSqlTile = makeRawSqlTile({
636+
displayType: DisplayType.Number,
637+
sqlTemplate: RAW_SQL_ALERT_TEMPLATE,
638+
});
579639
const dashboard = await agent
580640
.post('/dashboards')
581641
.send({

packages/api/src/routers/external-api/__tests__/alerts.test.ts

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import _ from 'lodash';
22
import { ObjectId } from 'mongodb';
33
import request from 'supertest';
44

5-
import { getLoggedInAgent, getServer } from '../../../fixtures';
5+
import {
6+
getLoggedInAgent,
7+
getServer,
8+
RAW_SQL_ALERT_TEMPLATE,
9+
} from '../../../fixtures';
610
import { AlertSource, AlertThresholdType } from '../../../models/alert';
711
import Alert from '../../../models/alert';
812
import Dashboard from '../../../models/dashboard';
@@ -83,8 +87,13 @@ describe('External API Alerts', () => {
8387
};
8488

8589
// Helper to create a dashboard with a raw SQL tile for testing
90+
// Uses Number display type by default (not alertable) for rejection tests
8691
const createTestDashboardWithRawSqlTile = async (
87-
options: { teamId?: any } = {},
92+
options: {
93+
teamId?: any;
94+
displayType?: string;
95+
sqlTemplate?: string;
96+
} = {},
8897
) => {
8998
const tileId = new ObjectId().toString();
9099
const tiles = [
@@ -97,8 +106,8 @@ describe('External API Alerts', () => {
97106
h: 3,
98107
config: {
99108
configType: 'sql',
100-
displayType: 'line',
101-
sqlTemplate: 'SELECT 1',
109+
displayType: options.displayType ?? 'number',
110+
sqlTemplate: options.sqlTemplate ?? 'SELECT 1',
102111
connection: 'test-connection',
103112
},
104113
},
@@ -716,7 +725,34 @@ describe('External API Alerts', () => {
716725
.expect(400);
717726
});
718727

719-
it('should reject creating an alert on a raw SQL tile', async () => {
728+
it('should allow creating an alert on a raw SQL line tile', async () => {
729+
const webhook = await createTestWebhook();
730+
const { dashboard, tileId } = await createTestDashboardWithRawSqlTile({
731+
displayType: 'line',
732+
sqlTemplate: RAW_SQL_ALERT_TEMPLATE,
733+
});
734+
735+
const alertInput = {
736+
dashboardId: dashboard._id.toString(),
737+
tileId,
738+
threshold: 100,
739+
interval: '1h',
740+
source: AlertSource.TILE,
741+
thresholdType: AlertThresholdType.ABOVE,
742+
channel: {
743+
type: 'webhook',
744+
webhookId: webhook._id.toString(),
745+
},
746+
};
747+
748+
const res = await authRequest('post', ALERTS_BASE_URL)
749+
.send(alertInput)
750+
.expect(200);
751+
expect(res.body.data.dashboardId).toBe(dashboard._id.toString());
752+
expect(res.body.data.tileId).toBe(tileId);
753+
});
754+
755+
it('should reject creating an alert on a raw SQL number tile', async () => {
720756
const webhook = await createTestWebhook();
721757
const { dashboard, tileId } = await createTestDashboardWithRawSqlTile();
722758

@@ -736,7 +772,30 @@ describe('External API Alerts', () => {
736772
await authRequest('post', ALERTS_BASE_URL).send(alertInput).expect(400);
737773
});
738774

739-
it('should reject updating an alert to reference a raw SQL tile', async () => {
775+
it('should reject creating an alert on a raw SQL tile without interval params', async () => {
776+
const webhook = await createTestWebhook();
777+
const { dashboard, tileId } = await createTestDashboardWithRawSqlTile({
778+
displayType: 'line',
779+
sqlTemplate: 'SELECT count() FROM otel_logs',
780+
});
781+
782+
const alertInput = {
783+
dashboardId: dashboard._id.toString(),
784+
tileId,
785+
threshold: 100,
786+
interval: '1h',
787+
source: AlertSource.TILE,
788+
thresholdType: AlertThresholdType.ABOVE,
789+
channel: {
790+
type: 'webhook',
791+
webhookId: webhook._id.toString(),
792+
},
793+
};
794+
795+
await authRequest('post', ALERTS_BASE_URL).send(alertInput).expect(400);
796+
});
797+
798+
it('should reject updating an alert to reference a raw SQL number tile', async () => {
740799
const { alert, webhook } = await createTestAlert();
741800
const { dashboard: rawSqlDashboard, tileId: rawSqlTileId } =
742801
await createTestDashboardWithRawSqlTile();

packages/api/src/routers/external-api/v2/alerts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ import { alertSchema, objectIdSchema } from '@/utils/zod';
9595
* example: "65f5e4a3b9e77c001a567890"
9696
* tileId:
9797
* type: string
98-
* description: Tile ID for tile-based alerts. May not be a Raw-SQL-based tile.
98+
* description: Tile ID for tile-based alerts. Must be a builder-type line/bar/number tile or a SQL-type line/bar tile.
9999
* nullable: true
100100
* example: "65f5e4a3b9e77c001a901234"
101101
* savedSearchId:

packages/api/src/routers/external-api/v2/dashboards.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { isRawSqlSavedChartConfig } from '@hyperdx/common-utils/dist/guards';
1+
import {
2+
displayTypeSupportsBuilderAlerts,
3+
displayTypeSupportsRawSqlAlerts,
4+
} from '@hyperdx/common-utils/dist/core/utils';
25
import { SearchConditionLanguageSchema as whereLanguageSchema } from '@hyperdx/common-utils/dist/types';
36
import express from 'express';
47
import { uniq } from 'lodash';
@@ -2053,11 +2056,15 @@ router.put(
20532056
return res.sendStatus(404);
20542057
}
20552058

2056-
// Delete alerts for tiles that are now raw SQL (unsupported) or were removed
2059+
// Delete alerts for tiles that now do not support alerts
20572060
const newTileIdSet = new Set(internalTiles.map(t => t.id));
20582061
const tileIdsToDeleteAlerts = [
20592062
...internalTiles
2060-
.filter(tile => isRawSqlSavedChartConfig(tile.config))
2063+
.filter(tile =>
2064+
'configType' in tile.config && tile.config.configType === 'sql'
2065+
? !displayTypeSupportsRawSqlAlerts(tile.config.displayType)
2066+
: !displayTypeSupportsBuilderAlerts(tile.config.displayType),
2067+
)
20612068
.map(tile => tile.id),
20622069
...[...existingTileIds].filter(id => !newTileIdSet.has(id)),
20632070
];

0 commit comments

Comments
 (0)