feat: Implement alerting for Raw SQL-based dashboard tiles (#2073)

## Summary

This PR implements alerting on Raw SQL-based line/bar charts.

- This is only available for line/bar charts with this change. Number charts will be added in a future PR.
- The threshold is compared to the _last_ numeric column in each result.
- The interval parameter must be used. This is required for line charts to function, and is used for zero-fill and other functionality within the alerts task. This limitation will be removed for Number chart alerts when those are implemented.
- Start and and end date should be used, but are not required because there are some potential use-cases where they may not be desirable.

### Screenshots or video

https://github.com/user-attachments/assets/e2d0cd6c-b040-4490-89af-6a51a7380647

Logs from Check-Alerts evaluating a raw-sql alert

<img width="1241" height="908" alt="Screenshot 2026-04-09 at 3 01 14 PM" src="https://github.com/user-attachments/assets/dbed4e5f-bf27-4179-b8e0-897cc19f3d3a" />

### How to test locally or on Vercel

This must be tested locally, as alerts are not enabled in the preview environment.

<details>
<summary>Query for the "anomaly detection" example</summary>

```sql
WITH buckets AS (
  SELECT
    $__timeInterval(Timestamp) AS ts,
    count() AS bucket_count
  FROM $__sourceTable
  WHERE TimestampTime >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64}) - toIntervalSecond($__interval_s * 30) -- Fetch 30 intervals back
    AND TimestampTime < fromUnixTimestamp64Milli({endDateMilliseconds:Int64})
    AND SeverityText = 'error'
  GROUP BY ts
  ORDER BY ts
  WITH FILL STEP toIntervalSecond($__interval_s)
),

anomaly_detection as (
  SELECT
    ts,
    bucket_count,
    avg(bucket_count) OVER (ORDER BY ts ROWS BETWEEN 30 PRECEDING AND 1 PRECEDING) as previous_30_avg, -- avg of previous 30 intervals
    stddevPop(bucket_count) OVER (ORDER BY ts ROWS BETWEEN 30 PRECEDING AND 1 PRECEDING) as previous_30_stddev, -- standard deviation of previous 30 intervals
    greatest(bucket_count - (previous_30_avg + 2 * previous_30_stddev), 0) as excess_over_2std -- compare bucket to avg + 2 stddev. clamp at 0.
  FROM buckets
)

SELECT ts, excess_over_2std 
FROM anomaly_detection
WHERE ts >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64}) AND ts < fromUnixTimestamp64Milli({endDateMilliseconds:Int64})
```

</details>

### References



- Linear Issue: HDX-1605
- Related PRs:
This commit is contained in:
Drew Davis 2026-04-13 13:58:22 -04:00 committed by GitHub
parent 0bfec14830
commit 085f30743e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1437 additions and 139 deletions

View file

@ -0,0 +1,7 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/api": patch
"@hyperdx/app": patch
---
feat: Implement alerting for Raw SQL-based dashboard tiles

View file

@ -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"
},

View file

@ -1,3 +1,7 @@
import {
displayTypeSupportsRawSqlAlerts,
validateRawSqlForAlert,
} from '@hyperdx/common-utils/dist/core/utils';
import { isRawSqlSavedChartConfig } from '@hyperdx/common-utils/dist/guards';
import { sign, verify } from 'jsonwebtoken';
import { groupBy } from 'lodash';
@ -82,7 +86,18 @@ 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',
);
}
const { errors } = validateRawSqlForAlert(tile.config);
if (errors.length > 0) {
throw new Api400Error(
`Raw SQL alert query is invalid: ${errors.join(', ')}`,
);
}
}
}

View file

@ -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';
@ -19,7 +18,7 @@ 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;
@ -27,9 +26,7 @@ function pickAlertsByTile(tiles: Tile[]) {
}
type TileForAlertSync = Pick<Tile, 'id'> & {
config?:
| Pick<BuilderSavedChartConfig, 'alert'>
| { alert?: IAlert | AlertDocument };
config?: Pick<SavedChartConfig, 'alert'> | { alert?: IAlert | AlertDocument };
};
function extractTileAlertData(tiles: TileForAlertSync[]): {
@ -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);

View file

@ -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,
@ -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,
});

View file

@ -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';
@ -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({
@ -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({

View file

@ -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';
@ -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 = [
@ -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',
},
},
@ -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();
@ -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();

View file

@ -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: [
@ -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: [
{
@ -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}',
},

View file

@ -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:

View file

@ -1,3 +1,4 @@
import { displayTypeSupportsRawSqlAlerts } from '@hyperdx/common-utils/dist/core/utils';
import { isRawSqlSavedChartConfig } from '@hyperdx/common-utils/dist/guards';
import { SearchConditionLanguageSchema as whereLanguageSchema } from '@hyperdx/common-utils/dist/types';
import express from 'express';
@ -2053,11 +2054,15 @@ router.put(
return res.sendStatus(404);
}
// Delete alerts for tiles that are now raw SQL (unsupported) or were removed
// Delete alerts for tiles that now do not support alerts
const newTileIdSet = new Set(internalTiles.map(t => t.id));
const tileIdsToDeleteAlerts = [
...internalTiles
.filter(tile => isRawSqlSavedChartConfig(tile.config))
.filter(
tile =>
isRawSqlSavedChartConfig(tile.config) &&
!displayTypeSupportsRawSqlAlerts(tile.config.displayType),
)
.map(tile => tile.id),
...[...existingTileIds].filter(id => !newTileIdSet.has(id)),
];

View file

@ -20,6 +20,7 @@ import {
getServer,
getTestFixtureClickHouseClient,
makeTile,
RAW_SQL_ALERT_TEMPLATE,
} from '@/fixtures';
import Alert, { AlertSource, AlertThresholdType } from '@/models/alert';
import AlertHistory from '@/models/alertHistory';
@ -1142,7 +1143,7 @@ describe('checkAlerts', () => {
const createAlertDetails = async (
team: ITeam,
source: ISource,
source: ISource | undefined,
alertConfig: Parameters<typeof createAlert>[1],
additionalDetails:
| {
@ -1154,7 +1155,7 @@ describe('checkAlerts', () => {
tile: Tile;
dashboard: IDashboard;
},
) => {
): Promise<AlertDetails> => {
const mockUserId = new mongoose.Types.ObjectId();
const alert = await createAlert(team._id, alertConfig, mockUserId);
@ -1163,14 +1164,19 @@ describe('checkAlerts', () => {
'savedSearch',
]);
const details = {
alert: enhancedAlert,
source,
previousMap: new Map(),
...additionalDetails,
} satisfies AlertDetails;
return details;
return additionalDetails.taskType === AlertTaskType.SAVED_SEARCH
? {
alert: enhancedAlert,
source: source!,
previousMap: new Map(),
...additionalDetails,
}
: {
alert: enhancedAlert,
source,
previousMap: new Map(),
...additionalDetails,
};
};
const processAlertAtTime = async (
@ -1825,6 +1831,584 @@ describe('checkAlerts', () => {
});
});
it('TILE alert (raw SQL line chart) - should trigger and resolve', async () => {
const { team, webhook, connection, teamWebhooksById, clickhouseClient } =
await setupSavedSearchAlertTest();
const now = new Date('2023-11-16T22:12:00.000Z');
// Send events in the last alert window 22:05 - 22:10
const eventMs = now.getTime() - ms('5m');
await bulkInsertLogs([
{
ServiceName: 'api',
Timestamp: new Date(eventMs),
SeverityText: 'error',
Body: 'Raw SQL alert test event 1',
},
{
ServiceName: 'api',
Timestamp: new Date(eventMs),
SeverityText: 'error',
Body: 'Raw SQL alert test event 2',
},
{
ServiceName: 'api',
Timestamp: new Date(eventMs),
SeverityText: 'error',
Body: 'Raw SQL alert test event 3',
},
]);
const dashboard = await new Dashboard({
name: 'Raw SQL Dashboard',
team: team._id,
tiles: [
{
id: 'rawsql1',
x: 0,
y: 0,
w: 6,
h: 4,
config: {
configType: 'sql',
displayType: 'line',
sqlTemplate: RAW_SQL_ALERT_TEMPLATE,
connection: connection.id,
},
},
],
}).save();
const tile = dashboard.tiles?.find((t: any) => t.id === 'rawsql1');
if (!tile) throw new Error('tile not found for raw SQL test');
const details = await createAlertDetails(
team,
undefined, // No source for raw SQL tiles
{
source: AlertSource.TILE,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
interval: '5m',
thresholdType: AlertThresholdType.ABOVE,
threshold: 1,
dashboardId: dashboard.id,
tileId: 'rawsql1',
},
{
taskType: AlertTaskType.TILE,
tile,
dashboard,
},
);
// should fetch 5m of logs and trigger alert
await processAlertAtTime(
now,
details,
clickhouseClient,
connection,
alertProvider,
teamWebhooksById,
);
expect((await Alert.findById(details.alert.id))!.state).toBe('ALERT');
// Next window with no data should resolve
const nextWindow = new Date('2023-11-16T22:16:00.000Z');
await processAlertAtTime(
nextWindow,
details,
clickhouseClient,
connection,
alertProvider,
teamWebhooksById,
);
expect((await Alert.findById(details.alert.id))!.state).toBe('OK');
// Check alert history
const alertHistories = await AlertHistory.find({
alert: details.alert.id,
}).sort({ createdAt: 1 });
expect(alertHistories.length).toBe(2);
expect(alertHistories[0].state).toBe('ALERT');
expect(alertHistories[0].counts).toBe(1);
expect(alertHistories[0].lastValues[0].count).toBeGreaterThanOrEqual(1);
expect(alertHistories[1].state).toBe('OK');
});
it('TILE alert (raw SQL) - multiple rows per time bucket from GROUP BY', async () => {
const { team, webhook, connection, teamWebhooksById, clickhouseClient } =
await setupSavedSearchAlertTest();
const now = new Date('2023-11-16T22:12:00.000Z');
const eventMs = now.getTime() - ms('5m');
// Insert logs from two different services in the same time bucket
await bulkInsertLogs([
{
ServiceName: 'web',
Timestamp: new Date(eventMs),
SeverityText: 'error',
Body: 'web error 1',
},
{
ServiceName: 'web',
Timestamp: new Date(eventMs),
SeverityText: 'error',
Body: 'web error 2',
},
{
ServiceName: 'worker',
Timestamp: new Date(eventMs),
SeverityText: 'error',
Body: 'worker error 1',
},
]);
// SQL query that groups by ServiceName — produces multiple rows per time bucket.
// Raw SQL alerts don't have explicit groupBy, so the alert system treats
// each row independently against the threshold within a single history record.
const groupedSqlTemplate = `
SELECT
toStartOfInterval(Timestamp, INTERVAL {intervalSeconds:Int64} second) AS ts,
ServiceName,
count() AS cnt
FROM default.otel_logs
WHERE Timestamp >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64})
AND Timestamp < fromUnixTimestamp64Milli({endDateMilliseconds:Int64})
GROUP BY ts, ServiceName
ORDER BY ts`;
const dashboard = await new Dashboard({
name: 'Raw SQL Grouped Dashboard',
team: team._id,
tiles: [
{
id: 'rawsql-grouped',
x: 0,
y: 0,
w: 6,
h: 4,
config: {
configType: 'sql',
displayType: 'line',
sqlTemplate: groupedSqlTemplate,
connection: connection.id,
},
},
],
}).save();
const tile = dashboard.tiles?.find((t: any) => t.id === 'rawsql-grouped');
if (!tile) throw new Error('tile not found');
const details = await createAlertDetails(
team,
undefined,
{
source: AlertSource.TILE,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
interval: '5m',
thresholdType: AlertThresholdType.ABOVE,
threshold: 2,
dashboardId: dashboard.id,
tileId: 'rawsql-grouped',
},
{
taskType: AlertTaskType.TILE,
tile,
dashboard,
},
);
await processAlertAtTime(
now,
details,
clickhouseClient,
connection,
alertProvider,
teamWebhooksById,
);
expect((await Alert.findById(details.alert.id))!.state).toBe('ALERT');
// Raw SQL alerts with GROUP BY produce separate history records per group.
// web=2 (meets threshold 2), worker=1 (below threshold 2).
const alertHistories = await AlertHistory.find({
alert: details.alert.id,
});
expect(alertHistories.length).toBe(2);
const webHistory = alertHistories.find(h =>
h.group?.includes('ServiceName:web'),
);
const workerHistory = alertHistories.find(h =>
h.group?.includes('ServiceName:worker'),
);
expect(webHistory).toBeDefined();
expect(webHistory!.state).toBe('ALERT');
expect(webHistory!.lastValues.map(v => v.count)).toEqual([2]);
expect(workerHistory).toBeDefined();
expect(workerHistory!.state).toBe('OK');
expect(workerHistory!.lastValues.map(v => v.count)).toEqual([1]);
});
it('TILE alert (raw SQL) - alert is evaluated using the last numeric column', async () => {
const { team, webhook, connection, teamWebhooksById, clickhouseClient } =
await setupSavedSearchAlertTest();
const now = new Date('2023-11-16T22:12:00.000Z');
const eventMs = now.getTime() - ms('5m');
// Insert 1 error and 2 warns so the two numeric columns differ:
// error_count = 1, warn_count = 2
await bulkInsertLogs([
{
ServiceName: 'api',
Timestamp: new Date(eventMs),
SeverityText: 'error',
Body: 'error log',
},
{
ServiceName: 'api',
Timestamp: new Date(eventMs),
SeverityText: 'warn',
Body: 'warn log 1',
},
{
ServiceName: 'api',
Timestamp: new Date(eventMs),
SeverityText: 'warn',
Body: 'warn log 2',
},
]);
// SQL query that returns multiple numeric columns (error_count, warn_count).
// The last numeric column (warn_count = 2) determines the alert.
const multiSeriesSqlTemplate = `
SELECT
toStartOfInterval(Timestamp, INTERVAL {intervalSeconds:Int64} second) AS ts,
countIf(SeverityText = 'error') AS error_count,
countIf(SeverityText = 'warn') AS warn_count
FROM default.otel_logs
WHERE Timestamp >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64})
AND Timestamp < fromUnixTimestamp64Milli({endDateMilliseconds:Int64})
GROUP BY ts
ORDER BY ts`;
const dashboard = await new Dashboard({
name: 'Raw SQL Multi-Series Dashboard',
team: team._id,
tiles: [
{
id: 'rawsql-multi',
x: 0,
y: 0,
w: 6,
h: 4,
config: {
configType: 'sql',
displayType: 'line',
sqlTemplate: multiSeriesSqlTemplate,
connection: connection.id,
},
},
],
}).save();
const tile = dashboard.tiles?.find((t: any) => t.id === 'rawsql-multi');
if (!tile) throw new Error('tile not found');
// Threshold of 2: error_count (1) does not meet it, warn_count (2) meets it.
// The alert should fire because the last numeric column (warn_count = 2)
// is the value used for threshold comparison.
const details = await createAlertDetails(
team,
undefined,
{
source: AlertSource.TILE,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
interval: '5m',
thresholdType: AlertThresholdType.ABOVE,
threshold: 2,
dashboardId: dashboard.id,
tileId: 'rawsql-multi',
},
{
taskType: AlertTaskType.TILE,
tile,
dashboard,
},
);
await processAlertAtTime(
now,
details,
clickhouseClient,
connection,
alertProvider,
teamWebhooksById,
);
expect((await Alert.findById(details.alert.id))!.state).toBe('ALERT');
const alertHistories = await AlertHistory.find({
alert: details.alert.id,
});
expect(alertHistories.length).toBe(1);
expect(alertHistories[0].state).toBe('ALERT');
// The value is from the last numeric column (warn_count), not error_count
expect(alertHistories[0].lastValues[0].count).toBe(2);
});
it('TILE alert (raw SQL with macros) - $__sourceTable, $__timeFilter, and $__timeInterval', async () => {
const {
team,
webhook,
connection,
source,
teamWebhooksById,
clickhouseClient,
} = await setupSavedSearchAlertTest();
const now = new Date('2023-11-16T22:12:00.000Z');
const eventMs = now.getTime() - ms('5m');
await bulkInsertLogs([
{
ServiceName: 'api',
Timestamp: new Date(eventMs),
SeverityText: 'error',
Body: 'macro test event 1',
},
{
ServiceName: 'api',
Timestamp: new Date(eventMs),
SeverityText: 'error',
Body: 'macro test event 2',
},
]);
// SQL query using all three macros:
// $__sourceTable resolves to `default`.`otel_logs` from the source
// $__timeFilter(Timestamp) resolves to date range params
// $__timeInterval(Timestamp) resolves to interval bucket expression
const macroSqlTemplate = [
'SELECT',
' $__timeInterval(Timestamp) AS ts,',
' count() AS cnt',
' FROM $__sourceTable',
' WHERE $__timeFilter(Timestamp)',
' GROUP BY ts',
' ORDER BY ts',
].join('\n');
const dashboard = await new Dashboard({
name: 'Raw SQL Macro Dashboard',
team: team._id,
tiles: [
{
id: 'rawsql-macros',
x: 0,
y: 0,
w: 6,
h: 4,
config: {
configType: 'sql',
displayType: 'line',
sqlTemplate: macroSqlTemplate,
connection: connection.id,
source: source.id,
},
},
],
}).save();
const tile = dashboard.tiles?.find((t: any) => t.id === 'rawsql-macros');
if (!tile) throw new Error('tile not found');
// Pass source so $__sourceTable macro can resolve
const details = await createAlertDetails(
team,
source,
{
source: AlertSource.TILE,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
interval: '5m',
thresholdType: AlertThresholdType.ABOVE,
threshold: 1,
dashboardId: dashboard.id,
tileId: 'rawsql-macros',
},
{
taskType: AlertTaskType.TILE,
tile,
dashboard,
},
);
await processAlertAtTime(
now,
details,
clickhouseClient,
connection,
alertProvider,
teamWebhooksById,
);
expect((await Alert.findById(details.alert.id))!.state).toBe('ALERT');
const alertHistories = await AlertHistory.find({
alert: details.alert.id,
});
expect(alertHistories.length).toBe(1);
expect(alertHistories[0].state).toBe('ALERT');
expect(alertHistories[0].lastValues[0].count).toBe(2);
});
it('TILE alert (raw SQL) - catches up on multiple missed windows', async () => {
const { team, webhook, connection, teamWebhooksById, clickhouseClient } =
await setupSavedSearchAlertTest();
// Scenario: 5m alert interval.
// Run 1 at 22:02 — evaluates [21:55-22:00), finds 0 events → OK
// Run 2 at 22:17 — catches up missed windows, evaluates
// [22:00-22:05) — 0 events (OK)
// [22:05-22:10) — 2 events (ALERT, exceeds threshold of 1)
// [22:10-22:15) — 1 event (ALERT, meets threshold of 1)
await bulkInsertLogs([
// Events in the 22:05-22:10 bucket
{
ServiceName: 'api',
Timestamp: new Date('2023-11-16T22:06:00.000Z'),
SeverityText: 'error',
Body: 'missed window event 1',
},
{
ServiceName: 'api',
Timestamp: new Date('2023-11-16T22:07:00.000Z'),
SeverityText: 'error',
Body: 'missed window event 2',
},
// Event in the 22:10-22:15 bucket
{
ServiceName: 'api',
Timestamp: new Date('2023-11-16T22:11:00.000Z'),
SeverityText: 'error',
Body: 'missed window event 3',
},
]);
const dashboard = await new Dashboard({
name: 'Raw SQL Catchup Dashboard',
team: team._id,
tiles: [
{
id: 'rawsql-catchup',
x: 0,
y: 0,
w: 6,
h: 4,
config: {
configType: 'sql',
displayType: 'line',
sqlTemplate: RAW_SQL_ALERT_TEMPLATE,
connection: connection.id,
},
},
],
}).save();
const tile = dashboard.tiles?.find((t: any) => t.id === 'rawsql-catchup');
if (!tile) throw new Error('tile not found');
const details = await createAlertDetails(
team,
undefined,
{
source: AlertSource.TILE,
channel: {
type: 'webhook',
webhookId: webhook._id.toString(),
},
interval: '5m',
thresholdType: AlertThresholdType.ABOVE,
threshold: 1,
dashboardId: dashboard.id,
tileId: 'rawsql-catchup',
},
{
taskType: AlertTaskType.TILE,
tile,
dashboard,
},
);
// Run 1 at 22:02 — evaluates [21:55-22:00), no events → OK history
await processAlertAtTime(
new Date('2023-11-16T22:02:00.000Z'),
details,
clickhouseClient,
connection,
alertProvider,
teamWebhooksById,
);
expect((await Alert.findById(details.alert.id))!.state).toBe('OK');
const firstRunHistories = await AlertHistory.find({
alert: details.alert.id,
});
expect(firstRunHistories.length).toBe(1);
expect(firstRunHistories[0].state).toBe('OK');
// Run 2 at 22:17 — catches up from 22:00, evaluates
// [22:00-22:05), [22:05-22:10), [22:10-22:15)
await processAlertAtTime(
new Date('2023-11-16T22:17:00.000Z'),
details,
clickhouseClient,
connection,
alertProvider,
teamWebhooksById,
);
expect((await Alert.findById(details.alert.id))!.state).toBe('ALERT');
const catchupHistories = await AlertHistory.find({
alert: details.alert.id,
createdAt: { $gt: new Date('2023-11-16T22:00:00.000Z') },
});
expect(catchupHistories.length).toBe(1);
expect(catchupHistories[0].state).toBe('ALERT');
// lastValues should contain entries for each evaluated bucket
// Bucket 22:00-22:05 has 0 events, 22:05-22:10 has 2, 22:10-22:15 has 1
const { lastValues } = catchupHistories[0];
expect(lastValues.length).toBe(3);
expect(lastValues.map(v => v.count)).toEqual([0, 2, 1]);
});
it('Group-by alerts that resolve (missing data case)', async () => {
const {
team,

View file

@ -14,21 +14,27 @@ import {
Metadata,
} from '@hyperdx/common-utils/dist/core/metadata';
import { renderChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig';
import { aliasMapToWithClauses } from '@hyperdx/common-utils/dist/core/utils';
import {
aliasMapToWithClauses,
displayTypeSupportsRawSqlAlerts,
} from '@hyperdx/common-utils/dist/core/utils';
import { timeBucketByGranularity } from '@hyperdx/common-utils/dist/core/utils';
import {
isBuilderChartConfig,
isBuilderSavedChartConfig,
isRawSqlChartConfig,
isRawSqlSavedChartConfig,
} from '@hyperdx/common-utils/dist/guards';
import {
BuilderChartConfigWithOptDateRange,
ChartConfigWithOptDateRange,
DisplayType,
getSampleWeightExpression,
pickSampleWeightExpressionProps,
SourceKind,
} from '@hyperdx/common-utils/dist/types';
import * as fns from 'date-fns';
import { isString } from 'lodash';
import { isString, pick } from 'lodash';
import { ObjectId } from 'mongoose';
import mongoose from 'mongoose';
import ms from 'ms';
@ -81,6 +87,16 @@ export const alertHasGroupBy = (details: AlertDetails): boolean => {
) {
return true;
}
// Without a reliable parser, it's difficult to tell if the raw sql contains a
// group by (besides the group by on the interval), so we'll assume it might.
// Group name will be blank if there is no group by values.
if (
details.taskType === AlertTaskType.TILE &&
isRawSqlSavedChartConfig(details.tile.config)
) {
return true;
}
return false;
};
@ -370,9 +386,10 @@ const shouldSkipAlertCheck = (
// Skip if ANY previous history for this alert was created in the current window
return Array.from(previousMap.entries()).some(([key, history]) => {
// For grouped alerts, check any key that starts with alertId prefix
// For non-grouped alerts, check exact match with alertId
// or matches the bare alertId (empty group key case).
// For non-grouped alerts, check exact match with alertId.
const isMatchingKey = hasGroupBy
? key.startsWith(alertKeyPrefix)
? key === alert.id || key.startsWith(alertKeyPrefix)
: key === alert.id;
return (
@ -394,11 +411,11 @@ const getAlertEvaluationDateRange = (
// Find the latest createdAt among all histories for this alert
let previousCreatedAt: Date | undefined;
if (hasGroupBy) {
// For grouped alerts, find the latest createdAt among all groups
// Use the latest to avoid checking from old groups that might no longer exist
// For grouped alerts, find the latest createdAt among all groups.
// Also check the bare alertId key for the empty group key case.
const alertKeyPrefix = getAlertKeyPrefix(alert.id);
for (const [key, history] of previousMap.entries()) {
if (key.startsWith(alertKeyPrefix)) {
if (key === alert.id || key.startsWith(alertKeyPrefix)) {
if (!previousCreatedAt || history.createdAt > previousCreatedAt) {
previousCreatedAt = history.createdAt;
}
@ -430,9 +447,10 @@ const getChartConfigFromAlert = (
connection: string,
dateRange: [Date, Date],
windowSizeInMins: number,
): BuilderChartConfigWithOptDateRange | undefined => {
const { alert, source } = details;
): ChartConfigWithOptDateRange | undefined => {
const { alert } = details;
if (details.taskType === AlertTaskType.SAVED_SEARCH) {
const { source } = details;
const savedSearch = details.savedSearch;
return {
connection,
@ -463,8 +481,40 @@ const getChartConfigFromAlert = (
} else if (details.taskType === AlertTaskType.TILE) {
const tile = details.tile;
// Alerts are not supported for raw sql based charts
if (isRawSqlSavedChartConfig(tile.config)) return undefined;
// Raw SQL line/bar tiles: build a RawSqlChartConfig
if (isRawSqlSavedChartConfig(tile.config)) {
if (displayTypeSupportsRawSqlAlerts(tile.config.displayType)) {
return {
...pick(tile.config, [
'configType',
'sqlTemplate',
'displayType',
'source',
]),
connection,
dateRange,
granularity: `${windowSizeInMins} minute`,
// Include source metadata for macro expansion ($__sourceTable)
...(details.source && {
from: details.source.from,
metricTables:
details.source.kind === SourceKind.Metric
? details.source.metricTables
: undefined,
}),
};
}
return undefined;
}
const { source } = details;
if (!source) {
logger.error(
{ alertId: alert.id },
'Source not found for builder tile alert',
);
return undefined;
}
// Doesn't work for metric alerts yet
if (
@ -549,6 +599,14 @@ const getResponseMetadata = (
return { timestampColumnName, valueColumnNames };
};
/**
* Parses the following from the given alert query result:
* - `value`: the numeric value to compare against the alert threshold, taken
* from the last column in the result which is included in valueColumnNames
* - `extraFields`: an array of strings representing the names and values of
* each column in the result which is neither the timestampColumnName nor a
* valueColumnName, formatted as "columnName:value".
*/
const parseAlertData = (
data: Record<string, string | number>,
meta: { timestampColumnName: string; valueColumnNames: Set<string> },
@ -575,7 +633,8 @@ export const processAlert = async (
alertProvider: AlertProvider,
teamWebhooksById: Map<string, IWebhook>,
) => {
const { alert, source, previousMap } = details;
const { alert, previousMap } = details;
const source = 'source' in details ? details.source : undefined;
try {
const windowSizeInMins = ms(alert.interval) / 60000;
const scheduleStartAt = normalizeScheduleStartAt({
@ -680,10 +739,18 @@ export const processAlert = async (
// so we render the saved search's select separately to discover aliases
// and inject them as WITH clauses into the alert query.
if (details.taskType === AlertTaskType.SAVED_SEARCH) {
if (!isBuilderChartConfig(chartConfig)) {
logger.error({
chartConfig,
message:
'Found non-builder chart config for saved search alert, cannot compute WITH clauses',
});
throw new Error('Expected builder chart config for saved search alert');
}
try {
const withClauses = await computeAliasWithClauses(
details.savedSearch,
source,
details.source,
metadata,
);
if (withClauses) {
@ -700,24 +767,34 @@ export const processAlert = async (
// Optimize chart config with materialized views, if available.
// materializedViews exists on Log and Trace sources.
const mvSource =
source.kind === SourceKind.Log || source.kind === SourceKind.Trace
source?.kind === SourceKind.Log || source?.kind === SourceKind.Trace
? source
: undefined;
const optimizedChartConfig = mvSource?.materializedViews?.length
? await tryOptimizeConfigWithMaterializedView(
chartConfig,
metadata,
clickhouseClient,
undefined,
mvSource,
)
: chartConfig;
const optimizedChartConfig =
isBuilderChartConfig(chartConfig) && mvSource?.materializedViews?.length
? await tryOptimizeConfigWithMaterializedView(
chartConfig,
metadata,
clickhouseClient,
undefined,
mvSource,
)
: chartConfig;
// Readonly = 2 means the query is readonly but can still specify query settings.
// This is done only for Raw SQL configs because it carries a minor risk of conflict with
// existing settings (which may have readonly = 1) and is not required for builder
// chart configs, which are always rendered as select statements.
const clickHouseSettings = isRawSqlChartConfig(optimizedChartConfig)
? { readonly: '2' }
: {};
// Query for alert data
const checksData = await clickhouseClient.queryChartConfig({
config: optimizedChartConfig,
metadata,
querySettings: source.querySettings,
opts: { clickhouse_settings: clickHouseSettings },
querySettings: source?.querySettings,
});
logger.info(

View file

@ -193,13 +193,14 @@ describe('DefaultAlertProvider', () => {
// Validate source is proper ISource object
const alertSource = result[0].alerts[0].source;
expect(alertSource.connection).toBe(connection.id); // Should be ObjectId, not populated IConnection
expect(alertSource.name).toBe('Test Source');
expect(alertSource.kind).toBe('log');
expect(alertSource.team).toBeDefined();
expect(alertSource.from?.databaseName).toBe('default');
expect(alertSource.from?.tableName).toBe('logs');
expect(alertSource.timestampValueExpression).toBe('timestamp');
expect(alertSource).toBeDefined();
expect(alertSource!.connection).toBe(connection.id); // Should be ObjectId, not populated IConnection
expect(alertSource!.name).toBe('Test Source');
expect(alertSource!.kind).toBe('log');
expect(alertSource!.team).toBeDefined();
expect(alertSource!.from?.databaseName).toBe('default');
expect(alertSource!.from?.tableName).toBe('logs');
expect(alertSource!.timestampValueExpression).toBe('timestamp');
// Ensure it's a plain object, not a mongoose document
expect((alertSource as any).toObject).toBeUndefined(); // mongoose documents have toObject method

View file

@ -1,4 +1,5 @@
import { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/node';
import { displayTypeSupportsRawSqlAlerts } from '@hyperdx/common-utils/dist/core/utils';
import { isRawSqlSavedChartConfig } from '@hyperdx/common-utils/dist/guards';
import { Tile } from '@hyperdx/common-utils/dist/types';
import mongoose from 'mongoose';
@ -108,13 +109,56 @@ async function getTileDetails(
}
if (isRawSqlSavedChartConfig(tile.config)) {
logger.warn({
tileId,
dashboardId: dashboard._id,
alertId: alert.id,
message: 'skipping alert with raw sql chart config, not supported',
});
return [];
if (!displayTypeSupportsRawSqlAlerts(tile.config.displayType)) {
logger.warn({
tileId,
dashboardId: dashboard._id,
alertId: alert.id,
message:
'skipping alert with raw sql chart config, only line/bar display types are supported',
});
return [];
}
// Raw SQL tiles store connection ID directly on the config
const connection = await Connection.findOne({
_id: tile.config.connection,
team: alert.team,
}).select('+password');
if (!connection) {
logger.error({
message: 'connection not found for raw sql tile',
connectionId: tile.config.connection,
tileId,
dashboardId: dashboard._id,
alertId: alert.id,
});
return [];
}
// Optionally look up source for filter/macro metadata
let source: ISource | undefined;
if (tile.config.source) {
const sourceDoc = await Source.findOne({
_id: tile.config.source,
team: alert.team,
});
if (sourceDoc) {
source = sourceDoc.toObject();
}
}
return [
connection,
{
alert,
source,
taskType: AlertTaskType.TILE,
tile,
dashboard,
},
];
}
const source = await Source.findOne({

View file

@ -32,15 +32,16 @@ export type PopulatedAlertChannel = { type: 'webhook' } & { channel: IWebhook };
// the are required when the type is set accordingly.
export type AlertDetails = {
alert: IAlert;
source: ISource;
previousMap: Map<string, AggregatedAlertHistory>; // Map of alertId||group -> history for group-by alerts
} & (
| {
taskType: AlertTaskType.SAVED_SEARCH;
source: ISource;
savedSearch: Omit<ISavedSearch, 'source'>;
}
| {
taskType: AlertTaskType.TILE;
source?: ISource;
tile: Tile;
dashboard: IDashboard;
}

View file

@ -3,8 +3,14 @@
"compilerOptions": {
"baseUrl": "./src",
"paths": {
"@/*": ["./*"]
"@/*": [
"./*"
]
},
"types": [
"jest",
"node"
],
"outDir": "build",
"isolatedModules": true,
"skipLibCheck": true,
@ -12,6 +18,12 @@
"strict": true,
"target": "ES2022"
},
"include": ["src", "migrations", "scripts"],
"exclude": ["node_modules"]
}
"include": [
"src",
"migrations",
"scripts"
],
"exclude": [
"node_modules"
]
}

View file

@ -19,7 +19,11 @@ import { ErrorBoundary } from 'react-error-boundary';
import RGL, { WidthProvider } from 'react-grid-layout';
import { useForm, useWatch } from 'react-hook-form';
import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata';
import { convertToDashboardTemplate } from '@hyperdx/common-utils/dist/core/utils';
import {
convertToDashboardTemplate,
displayTypeSupportsBuilderAlerts,
displayTypeSupportsRawSqlAlerts,
} from '@hyperdx/common-utils/dist/core/utils';
import {
isBuilderChartConfig,
isBuilderSavedChartConfig,
@ -294,9 +298,7 @@ const Tile = forwardRef(
const [hovered, setHovered] = useState(false);
const alert = isBuilderSavedChartConfig(chart.config)
? chart.config.alert
: undefined;
const alert = chart.config.alert;
const alertIndicatorColor = useMemo(() => {
if (!alert) {
return 'transparent';
@ -365,6 +367,10 @@ const Tile = forwardRef(
}, [filters, queriedConfig, source]);
const hoverToolbar = useMemo(() => {
const isRawSql = isRawSqlSavedChartConfig(chart.config);
const displayTypeSupportsAlerts = isRawSql
? displayTypeSupportsRawSqlAlerts(chart.config.displayType)
: displayTypeSupportsBuilderAlerts(chart.config.displayType);
return (
<Flex
gap="0px"
@ -373,30 +379,27 @@ const Tile = forwardRef(
my={4} // Margin to ensure that the Alert Indicator doesn't clip on non-Line/Bar display types
style={{ visibility: hovered ? 'visible' : 'hidden' }}
>
{(chart.config.displayType === DisplayType.Line ||
chart.config.displayType === DisplayType.StackedBar ||
chart.config.displayType === DisplayType.Number) &&
!isRawSqlSavedChartConfig(chart.config) && (
<Indicator
size={alert?.state === AlertState.OK ? 6 : 8}
zIndex={1}
color={alertIndicatorColor}
processing={alert?.state === AlertState.ALERT}
label={!alert && <span className="fs-8">+</span>}
mr={4}
>
<Tooltip label={alertTooltip} withArrow>
<ActionIcon
data-testid={`tile-alerts-button-${chart.id}`}
variant="subtle"
size="sm"
onClick={onEditClick}
>
<IconBell size={16} />
</ActionIcon>
</Tooltip>
</Indicator>
)}
{displayTypeSupportsAlerts && (
<Indicator
size={alert?.state === AlertState.OK ? 6 : 8}
zIndex={1}
color={alertIndicatorColor}
processing={alert?.state === AlertState.ALERT}
label={!alert && <span className="fs-8">+</span>}
mr={4}
>
<Tooltip label={alertTooltip} withArrow>
<ActionIcon
data-testid={`tile-alerts-button-${chart.id}`}
variant="subtle"
size="sm"
onClick={onEditClick}
>
<IconBell size={16} />
</ActionIcon>
</Tooltip>
</Indicator>
)}
<ActionIcon
data-testid={`tile-duplicate-button-${chart.id}`}

View file

@ -4,8 +4,13 @@ import {
TableConnection,
tcFromSource,
} from '@hyperdx/common-utils/dist/core/metadata';
import {
displayTypeSupportsRawSqlAlerts,
validateRawSqlForAlert,
} from '@hyperdx/common-utils/dist/core/utils';
import { MACRO_SUGGESTIONS } from '@hyperdx/common-utils/dist/macros';
import { QUERY_PARAMS_BY_DISPLAY_TYPE } from '@hyperdx/common-utils/dist/rawSqlParams';
import { RawSqlChartConfig } from '@hyperdx/common-utils/dist/types';
import {
DisplayType,
isLogSource,
@ -13,13 +18,16 @@ import {
isTraceSource,
} from '@hyperdx/common-utils/dist/types';
import { Box, Button, Group, Stack, Text, Tooltip } from '@mantine/core';
import { IconHelpCircle } from '@tabler/icons-react';
import { IconBell, IconHelpCircle } from '@tabler/icons-react';
import { TileAlertEditor } from '@/components/DBEditTimeChartForm/TileAlertEditor';
import { SQLEditorControlled } from '@/components/SQLEditor/SQLEditor';
import { type SQLCompletion } from '@/components/SQLEditor/utils';
import { IS_LOCAL_MODE } from '@/config';
import useResizable from '@/hooks/useResizable';
import { useSources } from '@/source';
import { getAllMetricTables, usePrevious } from '@/utils';
import { DEFAULT_TILE_ALERT } from '@/utils/alerts';
import { ConnectionSelectControlled } from '../ConnectionSelect';
import SourceSchemaPreview from '../SourceSchemaPreview';
@ -36,11 +44,15 @@ export default function RawSqlChartEditor({
setValue,
onOpenDisplaySettings,
isDashboardForm,
alert,
dashboardId,
}: {
control: Control<ChartEditorFormState>;
setValue: UseFormSetValue<ChartEditorFormState>;
onOpenDisplaySettings: () => void;
isDashboardForm: boolean;
alert: ChartEditorFormState['alert'];
dashboardId?: string;
}) {
const { size, startResize } = useResizable(20, 'bottom');
@ -49,8 +61,30 @@ export default function RawSqlChartEditor({
const displayType = useWatch({ control, name: 'displayType' });
const connection = useWatch({ control, name: 'connection' });
const source = useWatch({ control, name: 'source' });
const sqlTemplate = useWatch({ control, name: 'sqlTemplate' });
const sourceObject = sources?.find(s => s.id === source);
const rawSqlConfig = useMemo(
() =>
({
configType: 'sql',
sqlTemplate: sqlTemplate ?? '',
connection: connection ?? '',
from: sourceObject?.from,
displayType,
}) satisfies RawSqlChartConfig,
[sqlTemplate, connection, sourceObject?.from, displayType],
);
const { alertErrorMessage, alertWarningMessage } = useMemo(() => {
const { errors, warnings } = validateRawSqlForAlert(rawSqlConfig);
return {
alertErrorMessage: errors.length > 0 ? errors.join('. ') : undefined,
alertWarningMessage:
warnings.length > 0 ? warnings.join('. ') : undefined,
};
}, [rawSqlConfig]);
const prevSource = usePrevious(source);
const prevConnection = usePrevious(connection);
@ -168,6 +202,21 @@ export default function RawSqlChartEditor({
/>
</Group>
<Group gap="xs">
{displayTypeSupportsRawSqlAlerts(displayType) &&
dashboardId &&
!alert &&
!IS_LOCAL_MODE && (
<Button
variant="subtle"
data-testid="alert-button"
size="sm"
color={'gray'}
onClick={() => setValue('alert', DEFAULT_TILE_ALERT)}
>
<IconBell size={14} className="me-2" />
Add Alert
</Button>
)}
<Button
onClick={onOpenDisplaySettings}
size="compact-sm"
@ -190,6 +239,17 @@ export default function RawSqlChartEditor({
/>
<div className={resizeStyles.resizeYHandle} onMouseDown={startResize} />
</Box>
{alert && (
<TileAlertEditor
control={control}
setValue={setValue}
alert={alert}
onRemove={() => setValue('alert', undefined)}
error={alertErrorMessage}
warning={alertWarningMessage}
tooltip="The threshold will be evaluated against the last numeric column in the query result"
/>
)}
</Stack>
);
}

View file

@ -610,6 +610,79 @@ describe('validateChartForm', () => {
).toHaveLength(0);
});
it('errors when raw SQL chart has alert but SQL is missing time filters and interval', () => {
const setError = jest.fn();
const errors = validateChartForm(
makeForm({
configType: 'sql',
displayType: DisplayType.Line,
sqlTemplate: 'SELECT count() FROM logs',
connection: 'conn-1',
alert: {
interval: '1h',
threshold: 100,
thresholdType: 'above',
channel: { type: 'webhook' },
} as ChartEditorFormState['alert'],
}),
undefined,
setError,
);
expect(errors).toContainEqual(
expect.objectContaining({
path: 'sqlTemplate',
message:
'SQL used for alerts must include an interval parameter or macro.',
}),
);
});
it('does not error when raw SQL chart has alert and SQL includes required params', () => {
const setError = jest.fn();
const errors = validateChartForm(
makeForm({
configType: 'sql',
displayType: DisplayType.Line,
sqlTemplate:
'SELECT toStartOfInterval(ts, INTERVAL {intervalSeconds:Int64} SECOND) AS ts, count() FROM logs WHERE ts >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64}) AND ts <= fromUnixTimestamp64Milli({endDateMilliseconds:Int64}) GROUP BY ts',
connection: 'conn-1',
alert: {
interval: '1h',
threshold: 100,
thresholdType: 'above',
channel: { type: 'webhook' },
} as ChartEditorFormState['alert'],
}),
undefined,
setError,
);
expect(
errors.filter(
e => e.path === 'sqlTemplate' && e.message.includes('alert'),
),
).toHaveLength(0);
});
it('does not validate SQL template for alerts when no alert is configured', () => {
const setError = jest.fn();
const errors = validateChartForm(
makeForm({
configType: 'sql',
displayType: DisplayType.Line,
sqlTemplate: 'SELECT count() FROM logs',
connection: 'conn-1',
alert: undefined,
}),
undefined,
setError,
);
expect(
errors.filter(
e => e.path === 'sqlTemplate' && e.message.includes('alert'),
),
).toHaveLength(0);
});
// ── Source validation ────────────────────────────────────────────────
it('errors when builder chart has no source', () => {

View file

@ -1,5 +1,6 @@
import { omit, pick } from 'lodash';
import { Path, UseFormSetError } from 'react-hook-form';
import { validateRawSqlForAlert } from '@hyperdx/common-utils/dist/core/utils';
import {
isBuilderSavedChartConfig,
isRawSqlSavedChartConfig,
@ -75,6 +76,7 @@ export function convertFormStateToSavedChartConfig(
'compareToPreviousPeriod',
'fillNulls',
'alignDateRangeToGranularity',
'alert',
]),
sqlTemplate: form.sqlTemplate ?? '',
connection: form.connection ?? '',
@ -205,7 +207,7 @@ export const validateChartForm = (
if (
!isRawSqlChart &&
form.displayType !== DisplayType.Markdown &&
!form.source
(!form.source || !source)
) {
errors.push({ path: `source`, message: 'Source is required' });
}
@ -246,6 +248,24 @@ export const validateChartForm = (
});
}
// Validate raw SQL alert has required time filters and interval parameters
if (isRawSqlChart && form.alert) {
const config = {
configType: 'sql',
sqlTemplate: form.sqlTemplate ?? '',
connection: form.connection ?? '',
from: source?.from,
displayType: form.displayType,
} satisfies RawSqlChartConfig;
const { errors: alertErrors } = validateRawSqlForAlert(config);
if (alertErrors.length > 0) {
errors.push({
path: `sqlTemplate`,
message: alertErrors.join('. '),
});
}
}
// Validate number and pie charts only have one series
if (
!isRawSqlChart &&

View file

@ -2,6 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Controller, useFieldArray, useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata';
import {
displayTypeSupportsBuilderAlerts,
displayTypeSupportsRawSqlAlerts,
} from '@hyperdx/common-utils/dist/core/utils';
import { isRawSqlSavedChartConfig } from '@hyperdx/common-utils/dist/guards';
import {
ChartConfigWithDateRange,
@ -19,7 +23,7 @@ import {
Text,
Textarea,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useDisclosure, usePrevious } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import {
IconChartLine,
@ -147,10 +151,7 @@ export default function EditTimeChartForm({
const granularity = useWatch({ control, name: 'granularity' });
const configType = useWatch({ control, name: 'configType' });
const chartConfigAlert = !isRawSqlSavedChartConfig(chartConfig)
? chartConfig.alert
: undefined;
const chartConfigAlert = chartConfig.alert;
const isRawSqlInput =
configType === 'sql' && isRawSqlDisplayType(displayType);
@ -160,14 +161,18 @@ export default function EditTimeChartForm({
const activeTab = displayTypeToActiveTab(displayType);
// When switching display types, remove the alert if the new display type doesn't support alerts
const previousDisplayType = usePrevious(displayType);
useEffect(() => {
if (
displayType !== DisplayType.Line &&
displayType !== DisplayType.Number
) {
if (displayType === previousDisplayType) return;
const displayTypeSupportsAlerts =
configType === 'sql'
? displayTypeSupportsRawSqlAlerts(displayType)
: displayTypeSupportsBuilderAlerts(displayType);
if (!displayTypeSupportsAlerts) {
setValue('alert', undefined);
}
}, [displayType, setValue]);
}, [configType, displayType, previousDisplayType, setValue]);
const showGeneratedSql = TABS_WITH_GENERATED_SQL.has(activeTab);
@ -579,6 +584,8 @@ export default function EditTimeChartForm({
setValue={setValue}
onOpenDisplaySettings={openDisplaySettings}
isDashboardForm={isDashboardForm}
alert={alert}
dashboardId={dashboardId}
/>
) : (
<ChartEditorControls

View file

@ -6,6 +6,7 @@ import {
} from 'react-hook-form';
import {
ActionIcon,
Badge,
Box,
Collapse,
Group,
@ -17,7 +18,11 @@ import {
UnstyledButton,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconChevronDown, IconTrash } from '@tabler/icons-react';
import {
IconChevronDown,
IconHelpCircle,
IconTrash,
} from '@tabler/icons-react';
import { AlertChannelForm } from '@/components/Alerts';
import { AlertScheduleFields } from '@/components/AlertScheduleFields';
@ -35,11 +40,17 @@ export function TileAlertEditor({
setValue,
alert,
onRemove,
error,
warning,
tooltip,
}: {
control: Control<ChartEditorFormState>;
setValue: UseFormSetValue<ChartEditorFormState>;
alert: NonNullable<ChartEditorFormState['alert']>;
onRemove: () => void;
error?: string;
warning?: string;
tooltip?: string;
}) {
const [opened, { toggle }] = useDisclosure(true);
@ -59,7 +70,7 @@ export function TileAlertEditor({
<Paper data-testid="alert-details">
<Group justify="space-between" px="sm" pt="sm" pb={opened ? 0 : 'sm'}>
<UnstyledButton onClick={toggle}>
<Group gap="xs">
<Group gap="xs" mb="xs">
<IconChevronDown
size={14}
style={{
@ -67,9 +78,35 @@ export function TileAlertEditor({
transition: 'transform 200ms',
}}
/>
<Text size="sm" fw={500}>
Alert
</Text>
<Group gap={4} align="center">
<Text size="sm" fw={500} mt={2}>
Alert
</Text>
{tooltip && (
<Tooltip label={tooltip} withArrow>
<IconHelpCircle size={16} opacity={0.5} />
</Tooltip>
)}
{error && (
<Tooltip label={error} withArrow>
<Badge
color="var(--color-text-danger)"
size="xs"
variant="light"
ml="xs"
>
Invalid Query
</Badge>
</Tooltip>
)}
{warning && (
<Tooltip label={warning} withArrow>
<Badge color="yellow" size="xs" variant="light" ml="xs">
Warning
</Badge>
</Tooltip>
)}
</Group>
</Group>
</UnstyledButton>
<Tooltip label="Remove alert">

View file

@ -122,4 +122,128 @@ test.describe('Alert Creation', { tag: ['@alerts', '@full-stack'] }, () => {
});
},
);
test(
'should create an alert from a raw SQL dashboard tile and verify on the alerts page',
{ tag: '@full-stack' },
async ({ page }) => {
const ts = Date.now();
const tileName = `E2E Raw SQL Alert ${ts}`;
const webhookName = `E2E Webhook RawSQL ${ts}`;
const webhookUrl = `https://example.com/rawsql-${ts}`;
const sqlQuery = `SELECT toStartOfInterval(Timestamp, INTERVAL {intervalSeconds:Int64} second) AS ts, count() AS cnt
FROM $__sourceTable
WHERE Timestamp >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64}) AND Timestamp < fromUnixTimestamp64Milli({endDateMilliseconds:Int64})
GROUP BY ts ORDER BY ts
`;
await test.step('Create a new dashboard', async () => {
await dashboardPage.goto();
await dashboardPage.createNewDashboard();
});
await test.step('Add a raw SQL tile to the dashboard', async () => {
await dashboardPage.addTile();
await expect(dashboardPage.chartEditor.nameInput).toBeVisible();
await dashboardPage.chartEditor.waitForDataToLoad();
await dashboardPage.chartEditor.setChartName(tileName);
await dashboardPage.chartEditor.switchToSqlMode();
await dashboardPage.chartEditor.typeSqlQuery(sqlQuery);
await dashboardPage.chartEditor.runQuery();
});
await test.step('Enable and configure an alert on the raw SQL tile', async () => {
await expect(dashboardPage.chartEditor.alertButton).toBeVisible();
await dashboardPage.chartEditor.clickAddAlert();
await expect(
dashboardPage.chartEditor.addNewWebhookButton,
).toBeVisible();
await dashboardPage.chartEditor.addNewWebhookButton.click();
await expect(page.getByTestId('webhook-name-input')).toBeVisible();
await dashboardPage.chartEditor.webhookAlertModal.addWebhook(
'Generic',
webhookName,
webhookUrl,
);
await expect(page.getByTestId('alert-modal')).toBeHidden();
});
await test.step('Save the tile with the alert configured', async () => {
await dashboardPage.chartEditor.save();
await expect(dashboardPage.getTiles()).toHaveCount(1, {
timeout: 10000,
});
});
await test.step('Verify the alert is visible on the alerts page', async () => {
await alertsPage.goto();
await expect(alertsPage.pageContainer).toBeVisible();
await expect(
alertsPage.pageContainer
.getByRole('link')
.filter({ hasText: tileName }),
).toBeVisible({ timeout: 10000 });
});
},
);
test(
'should show validation error when saving a raw SQL alert without required interval param',
{ tag: '@full-stack' },
async ({ page }) => {
const ts = Date.now();
const tileName = `E2E Invalid SQL Alert ${ts}`;
const webhookName = `E2E Webhook Invalid ${ts}`;
const webhookUrl = `https://example.com/invalid-${ts}`;
// SQL query missing intervalSeconds, startDateMilliseconds / endDateMilliseconds
const invalidSqlQuery = `SELECT now() as ts, count() AS cnt
FROM $__sourceTable
GROUP BY ts ORDER BY ts
`;
await test.step('Create a new dashboard', async () => {
await dashboardPage.goto();
await dashboardPage.createNewDashboard();
});
await test.step('Add a raw SQL tile with an invalid query', async () => {
await dashboardPage.addTile();
await expect(dashboardPage.chartEditor.nameInput).toBeVisible();
await dashboardPage.chartEditor.waitForDataToLoad();
await dashboardPage.chartEditor.setChartName(tileName);
await dashboardPage.chartEditor.switchToSqlMode();
await dashboardPage.chartEditor.typeSqlQuery(invalidSqlQuery);
await dashboardPage.chartEditor.runQuery();
});
await test.step('Enable and configure an alert', async () => {
await expect(dashboardPage.chartEditor.alertButton).toBeVisible();
await dashboardPage.chartEditor.clickAddAlert();
await expect(
dashboardPage.chartEditor.addNewWebhookButton,
).toBeVisible();
await dashboardPage.chartEditor.addNewWebhookButton.click();
await expect(page.getByTestId('webhook-name-input')).toBeVisible();
await dashboardPage.chartEditor.webhookAlertModal.addWebhook(
'Generic',
webhookName,
webhookUrl,
);
await expect(page.getByTestId('alert-modal')).toBeHidden();
});
await test.step('Attempt to save and verify error notification', async () => {
await dashboardPage.chartEditor.saveBtn.click();
await expect(
page.getByText(
'SQL used for alerts must include an interval parameter or macro.',
),
).toBeVisible({ timeout: 5000 });
// The chart editor should still be open since saving was blocked
await expect(dashboardPage.chartEditor.nameInput).toBeVisible();
});
},
);
});

View file

@ -6,6 +6,8 @@ import { z } from 'zod';
export { default as objectHash } from 'object-hash';
import { isBuilderSavedChartConfig, isRawSqlSavedChartConfig } from '@/guards';
import { replaceMacros } from '@/macros';
import { QUERY_PARAMS, RawSqlQueryParam } from '@/rawSqlParams';
import {
BuilderChartConfig,
BuilderChartConfigWithDateRange,
@ -16,7 +18,9 @@ import {
DashboardSchema,
DashboardTemplateSchema,
DashboardWithoutId,
DisplayType,
QuerySettings,
RawSqlChartConfig,
SQLInterval,
TileTemplateSchema,
TSource,
@ -1012,3 +1016,65 @@ export function getDistributedTableArgs(
table: stripQuotes(splitArgs[2]),
};
}
export function displayTypeSupportsRawSqlAlerts(
displayType: DisplayType | undefined,
): boolean {
return (
displayType === DisplayType.Line || displayType === DisplayType.StackedBar
);
}
export function displayTypeSupportsBuilderAlerts(
displayType: DisplayType | undefined,
): boolean {
return (
displayType === DisplayType.Line ||
displayType === DisplayType.StackedBar ||
displayType === DisplayType.Number
);
}
export function validateRawSqlForAlert(chartConfig: RawSqlChartConfig): {
errors: string[];
warnings: string[];
} {
const errors: string[] = [];
const warnings: string[] = [];
try {
if (!isRawSqlSavedChartConfig(chartConfig)) {
return { errors, warnings };
}
if (!displayTypeSupportsRawSqlAlerts(chartConfig.displayType)) {
errors.push(
`Display type ${chartConfig.displayType} does not support raw SQL alerts.`,
);
}
const sql = replaceMacros(chartConfig);
const hasInterval =
sql.includes(QUERY_PARAMS[RawSqlQueryParam.intervalMilliseconds].name) ||
sql.includes(QUERY_PARAMS[RawSqlQueryParam.intervalSeconds].name);
if (!hasInterval) {
errors.push(
`SQL used for alerts must include an interval parameter or macro.`,
);
}
const hasTimeFilter =
sql.includes(QUERY_PARAMS[RawSqlQueryParam.startDateMilliseconds].name) &&
sql.includes(QUERY_PARAMS[RawSqlQueryParam.endDateMilliseconds].name);
if (!hasTimeFilter) {
warnings.push(
`SQL used for alerts should include start and end date parameters or macros.`,
);
}
return { errors, warnings };
} catch {
// replaceMacros will often fail as users type in the SQL template
return { errors, warnings };
}
}

View file

@ -1,5 +1,5 @@
import { splitAndTrimWithBracket } from './core/utils';
import { renderQueryParam } from './rawSqlParams';
import { RawSqlQueryParam, renderQueryParam } from './rawSqlParams';
import {
MetricsDataType,
MetricsDataTypeSchema,
@ -22,10 +22,11 @@ function expectArgs(
}
// Helpers to render ClickHouse time conversions using query params
const startMs = () => renderQueryParam('startDateMilliseconds');
const endMs = () => renderQueryParam('endDateMilliseconds');
const intervalS = () => renderQueryParam('intervalSeconds');
const intervalMs = () => renderQueryParam('intervalMilliseconds');
const startMs = () => renderQueryParam(RawSqlQueryParam.startDateMilliseconds);
const endMs = () => renderQueryParam(RawSqlQueryParam.endDateMilliseconds);
const intervalS = () => renderQueryParam(RawSqlQueryParam.intervalSeconds);
const intervalMs = () =>
renderQueryParam(RawSqlQueryParam.intervalMilliseconds);
const timeToDate = (msParam: string) =>
`toDate(fromUnixTimestamp64Milli(${msParam}))`;

View file

@ -1,4 +1,3 @@
import { ChSql } from './clickhouse';
import {
convertDateRangeToGranularityString,
convertGranularityToSeconds,
@ -23,7 +22,14 @@ const getIntervalSeconds = (config: RawSqlChartConfig & Partial<DateRange>) => {
return convertGranularityToSeconds(effectiveGranularity);
};
export const QUERY_PARAMS: Record<string, QueryParamDefinition> = {
export enum RawSqlQueryParam {
startDateMilliseconds = 'startDateMilliseconds',
endDateMilliseconds = 'endDateMilliseconds',
intervalSeconds = 'intervalSeconds',
intervalMilliseconds = 'intervalMilliseconds',
}
export const QUERY_PARAMS: Record<RawSqlQueryParam, QueryParamDefinition> = {
startDateMilliseconds: {
name: 'startDateMilliseconds',
type: 'Int64',

View file

@ -760,9 +760,18 @@ export type BuilderSavedChartConfig = z.infer<
typeof BuilderSavedChartConfigSchema
>;
const RawSqlSavedChartConfigSchema = RawSqlBaseChartConfigSchema.extend({
name: z.string().optional(),
});
const RawSqlSavedChartConfigWithoutAlertSchema =
RawSqlBaseChartConfigSchema.extend({
name: z.string().optional(),
});
const RawSqlSavedChartConfigSchema =
RawSqlSavedChartConfigWithoutAlertSchema.extend({
alert: z.union([
AlertBaseSchema.optional(),
ChartAlertBaseSchema.optional(),
]),
});
export const SavedChartConfigSchema = z.union([
BuilderSavedChartConfigSchema,
@ -788,7 +797,7 @@ export const TileSchema = z.object({
export const TileTemplateSchema = TileSchema.extend({
config: z.union([
BuilderSavedChartConfigWithoutAlertSchema,
RawSqlSavedChartConfigSchema,
RawSqlSavedChartConfigWithoutAlertSchema,
]),
});