Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/short-badgers-applaud.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/api": patch
"@hyperdx/app": patch
---

feat: Implement alerting for Raw SQL-based dashboard tiles
2 changes: 1 addition & 1 deletion packages/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@
},
"tileId": {
"type": "string",
"description": "Tile ID for tile-based alerts. May not be a Raw-SQL-based tile.",
"description": "Tile ID for tile-based alerts. Must be a builder-type line/bar/number tile or a SQL-type line/bar tile.",
"nullable": true,
"example": "65f5e4a3b9e77c001a901234"
},
Expand Down
16 changes: 15 additions & 1 deletion packages/api/src/controllers/alerts.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
displayTypeSupportsRawSqlAlerts,
isSqlTemplateValidForAlert,
} from '@hyperdx/common-utils/dist/core/utils';
import { isRawSqlSavedChartConfig } from '@hyperdx/common-utils/dist/guards';
import { sign, verify } from 'jsonwebtoken';
import { groupBy } from 'lodash';
Expand Down Expand Up @@ -82,7 +86,17 @@ export const validateAlertInput = async (
}

if (tile.config != null && isRawSqlSavedChartConfig(tile.config)) {
throw new Api400Error('Cannot create an alert on a raw SQL tile');
if (!displayTypeSupportsRawSqlAlerts(tile.config.displayType)) {
throw new Api400Error(
'Alerts on Raw SQL tiles are only supported for Line or Stacked Bar display types',
);
}

if (!isSqlTemplateValidForAlert(tile.config)) {
throw new Api400Error(
'Raw SQL alert queries must include time filters and interval parameters to be valid for alerts.',
);
}
}
}

Expand Down
13 changes: 4 additions & 9 deletions packages/api/src/controllers/dashboard.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { isBuilderSavedChartConfig } from '@hyperdx/common-utils/dist/guards';
import {
BuilderSavedChartConfig,
DashboardWithoutIdSchema,
SavedChartConfig,
Tile,
} from '@hyperdx/common-utils/dist/types';
import { map, partition, uniq } from 'lodash';
Expand All @@ -19,17 +18,15 @@ import Dashboard from '@/models/dashboard';

function pickAlertsByTile(tiles: Tile[]) {
return tiles.reduce((acc, tile) => {
if (isBuilderSavedChartConfig(tile.config) && tile.config.alert) {
if (tile.config.alert) {
acc[tile.id] = tile.config.alert;
}
return acc;
}, {});
}

type TileForAlertSync = Pick<Tile, 'id'> & {
config?:
| Pick<BuilderSavedChartConfig, 'alert'>
| { alert?: IAlert | AlertDocument };
config?: Pick<SavedChartConfig, 'alert'> | { alert?: IAlert | AlertDocument };
};

function extractTileAlertData(tiles: TileForAlertSync[]): {
Expand All @@ -55,9 +52,7 @@ async function syncDashboardAlerts(

const newTilesForAlertSync: TileForAlertSync[] = newTiles.map(t => ({
id: t.id,
config: isBuilderSavedChartConfig(t.config)
? { alert: t.config.alert }
: {},
config: { alert: t.config.alert },
}));
const { tileIds: newTileIds, tileIdsWithAlerts: newTileIdsWithAlerts } =
extractTileAlertData(newTilesForAlertSync);
Expand Down
38 changes: 35 additions & 3 deletions packages/api/src/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,39 @@ export const makeExternalTile = (opts?: {
},
});

export const makeRawSqlTile = (opts?: { id?: string }): Tile => ({
export const makeRawSqlTile = (opts?: {
id?: string;
displayType?: DisplayType;
sqlTemplate?: string;
connectionId?: string;
}): Tile => ({
id: opts?.id ?? randomMongoId(),
x: 1,
y: 1,
w: 1,
h: 1,
config: {
configType: 'sql',
displayType: opts?.displayType ?? DisplayType.Line,
sqlTemplate: opts?.sqlTemplate ?? 'SELECT 1',
connection: opts?.connectionId ?? 'test-connection',
} satisfies RawSqlSavedChartConfig,
});

export const RAW_SQL_ALERT_TEMPLATE = [
'SELECT toStartOfInterval(Timestamp, INTERVAL {intervalSeconds:Int64} second) AS ts,',
' count() AS cnt',
' FROM default.otel_logs',
' WHERE Timestamp >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64})',
' AND Timestamp < fromUnixTimestamp64Milli({endDateMilliseconds:Int64})',
' GROUP BY ts ORDER BY ts',
].join('');

export const makeRawSqlAlertTile = (opts?: {
id?: string;
connectionId?: string;
sqlTemplate?: string;
}): Tile => ({
id: opts?.id ?? randomMongoId(),
x: 1,
y: 1,
Expand All @@ -510,8 +542,8 @@ export const makeRawSqlTile = (opts?: { id?: string }): Tile => ({
config: {
configType: 'sql',
displayType: DisplayType.Line,
sqlTemplate: 'SELECT 1',
connection: 'test-connection',
sqlTemplate: opts?.sqlTemplate ?? RAW_SQL_ALERT_TEMPLATE,
connection: opts?.connectionId ?? 'test-connection',
} satisfies RawSqlSavedChartConfig,
});

Expand Down
68 changes: 64 additions & 4 deletions packages/api/src/routers/api/__tests__/alerts.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { DisplayType } from '@hyperdx/common-utils/dist/types';

import {
getLoggedInAgent,
getServer,
makeAlertInput,
makeRawSqlAlertTile,
makeRawSqlTile,
makeTile,
randomMongoId,
RAW_SQL_ALERT_TEMPLATE,
} from '@/fixtures';
import Alert, { AlertSource, AlertThresholdType } from '@/models/alert';
import Webhook, { WebhookDocument, WebhookService } from '@/models/webhook';
Expand Down Expand Up @@ -550,8 +554,36 @@ describe('alerts router', () => {
await agent.delete(`/alerts/${fakeId}/silenced`).expect(404); // Should fail
});

it('rejects creating an alert on a raw SQL tile', async () => {
const rawSqlTile = makeRawSqlTile();
it('allows creating an alert on a raw SQL line tile', async () => {
const rawSqlTile = makeRawSqlAlertTile();
const dashboard = await agent
.post('/dashboards')
.send({
name: 'Test Dashboard',
tiles: [rawSqlTile],
tags: [],
})
.expect(200);

const alert = await agent
.post('/alerts')
.send(
makeAlertInput({
dashboardId: dashboard.body.id,
tileId: rawSqlTile.id,
webhookId: webhook._id.toString(),
}),
)
.expect(200);
expect(alert.body.data.dashboard).toBe(dashboard.body.id);
expect(alert.body.data.tileId).toBe(rawSqlTile.id);
});

it('rejects creating an alert on a raw SQL number tile', async () => {
const rawSqlTile = makeRawSqlTile({
displayType: DisplayType.Number,
sqlTemplate: RAW_SQL_ALERT_TEMPLATE,
});
const dashboard = await agent
.post('/dashboards')
.send({
Expand All @@ -573,9 +605,37 @@ describe('alerts router', () => {
.expect(400);
});

it('rejects updating an alert to reference a raw SQL tile', async () => {
it('rejects creating an alert on a raw SQL tile without interval params', async () => {
const rawSqlTile = makeRawSqlTile({
sqlTemplate: 'SELECT count() FROM otel_logs',
});
const dashboard = await agent
.post('/dashboards')
.send({
name: 'Test Dashboard',
tiles: [rawSqlTile],
tags: [],
})
.expect(200);

await agent
.post('/alerts')
.send(
makeAlertInput({
dashboardId: dashboard.body.id,
tileId: rawSqlTile.id,
webhookId: webhook._id.toString(),
}),
)
.expect(400);
});

it('rejects updating an alert to reference a raw SQL number tile', async () => {
const regularTile = makeTile();
const rawSqlTile = makeRawSqlTile();
const rawSqlTile = makeRawSqlTile({
displayType: DisplayType.Number,
sqlTemplate: RAW_SQL_ALERT_TEMPLATE,
});
const dashboard = await agent
.post('/dashboards')
.send({
Expand Down
71 changes: 65 additions & 6 deletions packages/api/src/routers/external-api/__tests__/alerts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import _ from 'lodash';
import { ObjectId } from 'mongodb';
import request from 'supertest';

import { getLoggedInAgent, getServer } from '../../../fixtures';
import {
getLoggedInAgent,
getServer,
RAW_SQL_ALERT_TEMPLATE,
} from '../../../fixtures';
import { AlertSource, AlertThresholdType } from '../../../models/alert';
import Alert from '../../../models/alert';
import Dashboard from '../../../models/dashboard';
Expand Down Expand Up @@ -83,8 +87,13 @@ describe('External API Alerts', () => {
};

// Helper to create a dashboard with a raw SQL tile for testing
// Uses Number display type by default (not alertable) for rejection tests
const createTestDashboardWithRawSqlTile = async (
options: { teamId?: any } = {},
options: {
teamId?: any;
displayType?: string;
sqlTemplate?: string;
} = {},
) => {
const tileId = new ObjectId().toString();
const tiles = [
Expand All @@ -97,8 +106,8 @@ describe('External API Alerts', () => {
h: 3,
config: {
configType: 'sql',
displayType: 'line',
sqlTemplate: 'SELECT 1',
displayType: options.displayType ?? 'number',
sqlTemplate: options.sqlTemplate ?? 'SELECT 1',
connection: 'test-connection',
},
},
Expand Down Expand Up @@ -716,7 +725,34 @@ describe('External API Alerts', () => {
.expect(400);
});

it('should reject creating an alert on a raw SQL tile', async () => {
it('should allow creating an alert on a raw SQL line tile', async () => {
const webhook = await createTestWebhook();
const { dashboard, tileId } = await createTestDashboardWithRawSqlTile({
displayType: 'line',
sqlTemplate: RAW_SQL_ALERT_TEMPLATE,
});

const alertInput = {
dashboardId: dashboard._id.toString(),
tileId,
threshold: 100,
interval: '1h',
source: AlertSource.TILE,
thresholdType: AlertThresholdType.ABOVE,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
};

const res = await authRequest('post', ALERTS_BASE_URL)
.send(alertInput)
.expect(200);
expect(res.body.data.dashboardId).toBe(dashboard._id.toString());
expect(res.body.data.tileId).toBe(tileId);
});

it('should reject creating an alert on a raw SQL number tile', async () => {
const webhook = await createTestWebhook();
const { dashboard, tileId } = await createTestDashboardWithRawSqlTile();

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

it('should reject updating an alert to reference a raw SQL tile', async () => {
it('should reject creating an alert on a raw SQL tile without interval params', async () => {
const webhook = await createTestWebhook();
const { dashboard, tileId } = await createTestDashboardWithRawSqlTile({
displayType: 'line',
sqlTemplate: 'SELECT count() FROM otel_logs',
});

const alertInput = {
dashboardId: dashboard._id.toString(),
tileId,
threshold: 100,
interval: '1h',
source: AlertSource.TILE,
thresholdType: AlertThresholdType.ABOVE,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
};

await authRequest('post', ALERTS_BASE_URL).send(alertInput).expect(400);
});

it('should reject updating an alert to reference a raw SQL number tile', async () => {
const { alert, webhook } = await createTestAlert();
const { dashboard: rawSqlDashboard, tileId: rawSqlTileId } =
await createTestDashboardWithRawSqlTile();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3387,7 +3387,7 @@ describe('External API v2 Dashboards - new format', () => {
});
});

it('should delete alert when tile is updated from builder to raw SQL config', async () => {
it('should delete alert when tile is updated from builder to raw SQL config and the display type does not support alerts', async () => {
const tileId = new ObjectId().toString();
const dashboard = await createTestDashboard({
tiles: [
Expand All @@ -3399,7 +3399,7 @@ describe('External API v2 Dashboards - new format', () => {
w: 6,
h: 3,
config: {
displayType: 'line',
displayType: 'number',
source: traceSource._id.toString(),
select: [
{
Expand Down Expand Up @@ -3455,7 +3455,7 @@ describe('External API v2 Dashboards - new format', () => {
h: 3,
config: {
configType: 'sql',
displayType: 'line',
displayType: 'number',
connectionId: connection._id.toString(),
sqlTemplate: 'SELECT count() FROM otel_logs WHERE {timeFilter}',
},
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/routers/external-api/v2/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ import { alertSchema, objectIdSchema } from '@/utils/zod';
* example: "65f5e4a3b9e77c001a567890"
* tileId:
* type: string
* description: Tile ID for tile-based alerts. May not be a Raw-SQL-based tile.
* description: Tile ID for tile-based alerts. Must be a builder-type line/bar/number tile or a SQL-type line/bar tile.
* nullable: true
* example: "65f5e4a3b9e77c001a901234"
* savedSearchId:
Expand Down
Loading
Loading