mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: Support alerts on Raw SQL Number Charts (#2114)
## Summary This PR extends alerting support to Raw SQL Number charts. Number charts 1. Do not use the interval parameter, and thus return one value for the entirety of the alert time range. 2. Are assumed to not be using a group-by (the value of the chart is taken from the first result row only). ### Screenshots or video <img width="1383" height="1071" alt="Screenshot 2026-04-14 at 12 54 18 PM" src="https://github.com/user-attachments/assets/e74c3ad8-c95a-4668-8332-86f66f7543ba" /> ### How to test locally or on Vercel To test locally, create a raw-sql based number chart, create an alert on it, and view the alert logs for the output. You can also run the following to run a "webhook" destination that echos what it receives, for testing notification content: ```bash npx http-echo-server ``` ### References - Linear Issue: Closes HDX-3987 - Related PRs:
This commit is contained in:
parent
6e9a553da2
commit
1fada918c7
15 changed files with 947 additions and 59 deletions
7
.changeset/early-ducks-grow.md
Normal file
7
.changeset/early-ducks-grow.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@hyperdx/common-utils": patch
|
||||
"@hyperdx/api": patch
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Support alerts on Raw SQL Number Charts
|
||||
|
|
@ -162,7 +162,7 @@
|
|||
},
|
||||
"tileId": {
|
||||
"type": "string",
|
||||
"description": "Tile ID for tile-based alerts. Must be a builder-type line/bar/number tile or a SQL-type line/bar tile.",
|
||||
"description": "Tile ID for tile-based alerts. Must be a line, stacked bar, or number type tile.",
|
||||
"nullable": true,
|
||||
"example": "65f5e4a3b9e77c001a901234"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ export const validateAlertInput = async (
|
|||
if (tile.config != null && isRawSqlSavedChartConfig(tile.config)) {
|
||||
if (!displayTypeSupportsRawSqlAlerts(tile.config.displayType)) {
|
||||
throw new Api400Error(
|
||||
'Alerts on Raw SQL tiles are only supported for Line or Stacked Bar display types',
|
||||
'Alerts on Raw SQL tiles are only supported for Line, Stacked Bar, or Number display types',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -547,6 +547,31 @@ export const makeRawSqlAlertTile = (opts?: {
|
|||
} satisfies RawSqlSavedChartConfig,
|
||||
});
|
||||
|
||||
export const RAW_SQL_NUMBER_ALERT_TEMPLATE = [
|
||||
'SELECT count() AS cnt',
|
||||
' FROM default.otel_logs',
|
||||
' WHERE Timestamp >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64})',
|
||||
' AND Timestamp < fromUnixTimestamp64Milli({endDateMilliseconds:Int64})',
|
||||
].join('');
|
||||
|
||||
export const makeRawSqlNumberAlertTile = (opts?: {
|
||||
id?: string;
|
||||
connectionId?: string;
|
||||
sqlTemplate?: string;
|
||||
}): Tile => ({
|
||||
id: opts?.id ?? randomMongoId(),
|
||||
x: 1,
|
||||
y: 1,
|
||||
w: 1,
|
||||
h: 1,
|
||||
config: {
|
||||
configType: 'sql',
|
||||
displayType: DisplayType.Number,
|
||||
sqlTemplate: opts?.sqlTemplate ?? RAW_SQL_NUMBER_ALERT_TEMPLATE,
|
||||
connection: opts?.connectionId ?? 'test-connection',
|
||||
} satisfies RawSqlSavedChartConfig,
|
||||
});
|
||||
|
||||
export const makeAlertInput = ({
|
||||
dashboardId,
|
||||
interval = '15m',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
getServer,
|
||||
makeAlertInput,
|
||||
makeRawSqlAlertTile,
|
||||
makeRawSqlNumberAlertTile,
|
||||
makeRawSqlTile,
|
||||
makeTile,
|
||||
randomMongoId,
|
||||
|
|
@ -579,9 +580,34 @@ describe('alerts router', () => {
|
|||
expect(alert.body.data.tileId).toBe(rawSqlTile.id);
|
||||
});
|
||||
|
||||
it('rejects creating an alert on a raw SQL number tile', async () => {
|
||||
it('allows creating an alert on a raw SQL number tile', async () => {
|
||||
const rawSqlTile = makeRawSqlNumberAlertTile();
|
||||
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 table tile', async () => {
|
||||
const rawSqlTile = makeRawSqlTile({
|
||||
displayType: DisplayType.Number,
|
||||
displayType: DisplayType.Table,
|
||||
sqlTemplate: RAW_SQL_ALERT_TEMPLATE,
|
||||
});
|
||||
const dashboard = await agent
|
||||
|
|
@ -630,10 +656,45 @@ describe('alerts router', () => {
|
|||
.expect(400);
|
||||
});
|
||||
|
||||
it('rejects updating an alert to reference a raw SQL number tile', async () => {
|
||||
it('allows updating an alert to reference a raw SQL number tile', async () => {
|
||||
const regularTile = makeTile();
|
||||
const rawSqlTile = makeRawSqlNumberAlertTile();
|
||||
const dashboard = await agent
|
||||
.post('/dashboards')
|
||||
.send({
|
||||
name: 'Test Dashboard',
|
||||
tiles: [regularTile, rawSqlTile],
|
||||
tags: [],
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const alert = await agent
|
||||
.post('/alerts')
|
||||
.send(
|
||||
makeAlertInput({
|
||||
dashboardId: dashboard.body.id,
|
||||
tileId: regularTile.id,
|
||||
webhookId: webhook._id.toString(),
|
||||
}),
|
||||
)
|
||||
.expect(200);
|
||||
|
||||
await agent
|
||||
.put(`/alerts/${alert.body.data._id}`)
|
||||
.send({
|
||||
...makeAlertInput({
|
||||
dashboardId: dashboard.body.id,
|
||||
tileId: rawSqlTile.id,
|
||||
webhookId: webhook._id.toString(),
|
||||
}),
|
||||
})
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('rejects updating an alert to reference a raw SQL table tile', async () => {
|
||||
const regularTile = makeTile();
|
||||
const rawSqlTile = makeRawSqlTile({
|
||||
displayType: DisplayType.Number,
|
||||
displayType: DisplayType.Table,
|
||||
sqlTemplate: RAW_SQL_ALERT_TEMPLATE,
|
||||
});
|
||||
const dashboard = await agent
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
getLoggedInAgent,
|
||||
getServer,
|
||||
RAW_SQL_ALERT_TEMPLATE,
|
||||
RAW_SQL_NUMBER_ALERT_TEMPLATE,
|
||||
} from '../../../fixtures';
|
||||
import { AlertSource, AlertThresholdType } from '../../../models/alert';
|
||||
import Alert from '../../../models/alert';
|
||||
|
|
@ -752,9 +753,39 @@ describe('External API Alerts', () => {
|
|||
expect(res.body.data.tileId).toBe(tileId);
|
||||
});
|
||||
|
||||
it('should reject creating an alert on a raw SQL number tile', async () => {
|
||||
it('should allow creating an alert on a raw SQL number tile', async () => {
|
||||
const webhook = await createTestWebhook();
|
||||
const { dashboard, tileId } = await createTestDashboardWithRawSqlTile();
|
||||
const { dashboard, tileId } = await createTestDashboardWithRawSqlTile({
|
||||
displayType: 'number',
|
||||
sqlTemplate: RAW_SQL_NUMBER_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 table tile', async () => {
|
||||
const webhook = await createTestWebhook();
|
||||
const { dashboard, tileId } = await createTestDashboardWithRawSqlTile({
|
||||
displayType: 'table',
|
||||
sqlTemplate: RAW_SQL_ALERT_TEMPLATE,
|
||||
});
|
||||
|
||||
const alertInput = {
|
||||
dashboardId: dashboard._id.toString(),
|
||||
|
|
@ -795,10 +826,13 @@ 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 number tile', async () => {
|
||||
it('should reject updating an alert to reference a raw SQL table tile', async () => {
|
||||
const { alert, webhook } = await createTestAlert();
|
||||
const { dashboard: rawSqlDashboard, tileId: rawSqlTileId } =
|
||||
await createTestDashboardWithRawSqlTile();
|
||||
await createTestDashboardWithRawSqlTile({
|
||||
displayType: 'table',
|
||||
sqlTemplate: RAW_SQL_ALERT_TEMPLATE,
|
||||
});
|
||||
|
||||
const updatePayload = {
|
||||
threshold: 200,
|
||||
|
|
|
|||
|
|
@ -3455,7 +3455,7 @@ describe('External API v2 Dashboards - new format', () => {
|
|||
h: 3,
|
||||
config: {
|
||||
configType: 'sql',
|
||||
displayType: 'number',
|
||||
displayType: 'table',
|
||||
connectionId: connection._id.toString(),
|
||||
sqlTemplate: 'SELECT count() FROM otel_logs WHERE {timeFilter}',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ import { alertSchema, objectIdSchema } from '@/utils/zod';
|
|||
* example: "65f5e4a3b9e77c001a567890"
|
||||
* tileId:
|
||||
* type: string
|
||||
* description: Tile ID for tile-based alerts. Must be a builder-type line/bar/number tile or a SQL-type line/bar tile.
|
||||
* description: Tile ID for tile-based alerts. Must be a line, stacked bar, or number type tile.
|
||||
* nullable: true
|
||||
* example: "65f5e4a3b9e77c001a901234"
|
||||
* savedSearchId:
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
getTestFixtureClickHouseClient,
|
||||
makeTile,
|
||||
RAW_SQL_ALERT_TEMPLATE,
|
||||
RAW_SQL_NUMBER_ALERT_TEMPLATE,
|
||||
} from '@/fixtures';
|
||||
import Alert, { AlertSource, AlertThresholdType } from '@/models/alert';
|
||||
import AlertHistory from '@/models/alertHistory';
|
||||
|
|
@ -2409,6 +2410,417 @@ describe('checkAlerts', () => {
|
|||
expect(lastValues.map(v => v.count)).toEqual([0, 2, 1]);
|
||||
});
|
||||
|
||||
it('TILE alert (raw SQL Number chart) - should trigger and resolve', 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');
|
||||
|
||||
await bulkInsertLogs([
|
||||
{
|
||||
ServiceName: 'api',
|
||||
Timestamp: new Date(eventMs),
|
||||
SeverityText: 'error',
|
||||
Body: 'Number chart alert test event 1',
|
||||
},
|
||||
{
|
||||
ServiceName: 'api',
|
||||
Timestamp: new Date(eventMs),
|
||||
SeverityText: 'error',
|
||||
Body: 'Number chart alert test event 2',
|
||||
},
|
||||
]);
|
||||
|
||||
const dashboard = await new Dashboard({
|
||||
name: 'Number Chart Dashboard',
|
||||
team: team._id,
|
||||
tiles: [
|
||||
{
|
||||
id: 'number1',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 4,
|
||||
config: {
|
||||
configType: 'sql',
|
||||
displayType: 'number',
|
||||
sqlTemplate: RAW_SQL_NUMBER_ALERT_TEMPLATE,
|
||||
connection: connection.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
}).save();
|
||||
|
||||
const tile = dashboard.tiles?.find((t: any) => t.id === 'number1');
|
||||
if (!tile) throw new Error('tile not found for Number chart test');
|
||||
|
||||
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: 'number1',
|
||||
},
|
||||
{
|
||||
taskType: AlertTaskType.TILE,
|
||||
tile,
|
||||
dashboard,
|
||||
},
|
||||
);
|
||||
|
||||
// Should trigger alert (2 events > threshold of 1)
|
||||
await processAlertAtTime(
|
||||
now,
|
||||
details,
|
||||
clickhouseClient,
|
||||
connection,
|
||||
alertProvider,
|
||||
teamWebhooksById,
|
||||
);
|
||||
expect((await Alert.findById(details.alert.id))!.state).toBe('ALERT');
|
||||
|
||||
// Check alert history
|
||||
const alertHistories = await AlertHistory.find({
|
||||
alert: details.alert.id,
|
||||
}).sort({ createdAt: 1 });
|
||||
|
||||
expect(alertHistories.length).toBe(1);
|
||||
expect(alertHistories[0].state).toBe('ALERT');
|
||||
expect(alertHistories[0].counts).toBe(1);
|
||||
expect(alertHistories[0].lastValues[0].count).toBe(2);
|
||||
|
||||
// Next window with no new data in range 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');
|
||||
|
||||
const allHistories = await AlertHistory.find({
|
||||
alert: details.alert.id,
|
||||
}).sort({ createdAt: 1 });
|
||||
expect(allHistories.length).toBe(2);
|
||||
expect(allHistories[1].state).toBe('OK');
|
||||
});
|
||||
|
||||
it('TILE alert (raw SQL Number chart) - no data returns zero value', async () => {
|
||||
const { team, webhook, connection, teamWebhooksById, clickhouseClient } =
|
||||
await setupSavedSearchAlertTest();
|
||||
|
||||
const now = new Date('2023-11-16T22:12:00.000Z');
|
||||
|
||||
// No logs inserted — empty table for this time range
|
||||
|
||||
const dashboard = await new Dashboard({
|
||||
name: 'Empty Number Chart Dashboard',
|
||||
team: team._id,
|
||||
tiles: [
|
||||
{
|
||||
id: 'number-empty',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 4,
|
||||
config: {
|
||||
configType: 'sql',
|
||||
displayType: 'number',
|
||||
sqlTemplate: RAW_SQL_NUMBER_ALERT_TEMPLATE,
|
||||
connection: connection.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
}).save();
|
||||
|
||||
const tile = dashboard.tiles?.find((t: any) => t.id === 'number-empty');
|
||||
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: 'number-empty',
|
||||
},
|
||||
{
|
||||
taskType: AlertTaskType.TILE,
|
||||
tile,
|
||||
dashboard,
|
||||
},
|
||||
);
|
||||
|
||||
await processAlertAtTime(
|
||||
now,
|
||||
details,
|
||||
clickhouseClient,
|
||||
connection,
|
||||
alertProvider,
|
||||
teamWebhooksById,
|
||||
);
|
||||
|
||||
// count() returns 0 for no matching rows, which is below threshold of 1
|
||||
expect((await Alert.findById(details.alert.id))!.state).toBe('OK');
|
||||
|
||||
const alertHistories = await AlertHistory.find({
|
||||
alert: details.alert.id,
|
||||
});
|
||||
expect(alertHistories.length).toBe(1);
|
||||
expect(alertHistories[0].state).toBe('OK');
|
||||
expect(alertHistories[0].lastValues[0].count).toBe(0);
|
||||
});
|
||||
|
||||
it('TILE alert (raw SQL Number chart) - threshold compares with 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) should be used for threshold comparison.
|
||||
const multiNumericSql = [
|
||||
'SELECT',
|
||||
" 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})',
|
||||
].join('');
|
||||
|
||||
const dashboard = await new Dashboard({
|
||||
name: 'Multi-Numeric Number Chart Dashboard',
|
||||
team: team._id,
|
||||
tiles: [
|
||||
{
|
||||
id: 'number-multi-numeric',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 4,
|
||||
config: {
|
||||
configType: 'sql',
|
||||
displayType: 'number',
|
||||
sqlTemplate: multiNumericSql,
|
||||
connection: connection.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
}).save();
|
||||
|
||||
const tile = dashboard.tiles?.find(
|
||||
(t: any) => t.id === 'number-multi-numeric',
|
||||
);
|
||||
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: 'number-multi-numeric',
|
||||
},
|
||||
{
|
||||
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 = 2), not error_count (1)
|
||||
expect(alertHistories[0].lastValues[0].count).toBe(2);
|
||||
});
|
||||
|
||||
it('TILE alert (raw SQL Number chart) - only first row is compared to threshold when query returns multiple rows', 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 3 events for 'web' and 1 event for 'api'.
|
||||
// With ORDER BY cnt DESC, the first row will be web (cnt=3),
|
||||
// the second row will be api (cnt=1).
|
||||
await bulkInsertLogs([
|
||||
{
|
||||
ServiceName: 'web',
|
||||
Timestamp: new Date(eventMs),
|
||||
SeverityText: 'error',
|
||||
Body: 'web event 1',
|
||||
},
|
||||
{
|
||||
ServiceName: 'web',
|
||||
Timestamp: new Date(eventMs),
|
||||
SeverityText: 'error',
|
||||
Body: 'web event 2',
|
||||
},
|
||||
{
|
||||
ServiceName: 'web',
|
||||
Timestamp: new Date(eventMs),
|
||||
SeverityText: 'error',
|
||||
Body: 'web event 3',
|
||||
},
|
||||
{
|
||||
ServiceName: 'api',
|
||||
Timestamp: new Date(eventMs),
|
||||
SeverityText: 'error',
|
||||
Body: 'api event 1',
|
||||
},
|
||||
]);
|
||||
|
||||
// SQL with GROUP BY that returns multiple rows.
|
||||
// ORDER BY cnt DESC ensures the first row is web (cnt=3).
|
||||
const groupBySql = [
|
||||
'SELECT ServiceName, count() AS cnt',
|
||||
' FROM default.otel_logs',
|
||||
' WHERE Timestamp >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64})',
|
||||
' AND Timestamp < fromUnixTimestamp64Milli({endDateMilliseconds:Int64})',
|
||||
' GROUP BY ServiceName',
|
||||
' ORDER BY cnt DESC',
|
||||
].join('');
|
||||
|
||||
const dashboard = await new Dashboard({
|
||||
name: 'Number Chart First Row Dashboard',
|
||||
team: team._id,
|
||||
tiles: [
|
||||
{
|
||||
id: 'number-first-row',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 4,
|
||||
config: {
|
||||
configType: 'sql',
|
||||
displayType: 'number',
|
||||
sqlTemplate: groupBySql,
|
||||
connection: connection.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
}).save();
|
||||
|
||||
const tile = dashboard.tiles?.find(
|
||||
(t: any) => t.id === 'number-first-row',
|
||||
);
|
||||
if (!tile) throw new Error('tile not found');
|
||||
|
||||
// Threshold of 2: first row web (cnt=3) exceeds it, second row api (cnt=1) does not.
|
||||
// Only the first row should be compared, so the alert should fire.
|
||||
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: 'number-first-row',
|
||||
},
|
||||
{
|
||||
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,
|
||||
});
|
||||
|
||||
// Number charts produce a single history (no per-group splitting)
|
||||
expect(alertHistories.length).toBe(1);
|
||||
expect(alertHistories[0].state).toBe('ALERT');
|
||||
// The value comes from the first row only (web cnt=3), not the second row (api cnt=1)
|
||||
expect(alertHistories[0].lastValues[0].count).toBe(3);
|
||||
});
|
||||
|
||||
it('Group-by alerts that resolve (missing data case)', async () => {
|
||||
const {
|
||||
team,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ import ms from 'ms';
|
|||
import * as config from '@/config';
|
||||
import { createAlert } from '@/controllers/alerts';
|
||||
import { createTeam } from '@/controllers/team';
|
||||
import { bulkInsertLogs, getServer } from '@/fixtures';
|
||||
import {
|
||||
bulkInsertLogs,
|
||||
getServer,
|
||||
RAW_SQL_NUMBER_ALERT_TEMPLATE,
|
||||
} from '@/fixtures';
|
||||
import Alert, { AlertSource, AlertThresholdType } from '@/models/alert';
|
||||
import AlertHistory from '@/models/alertHistory';
|
||||
import Connection from '@/models/connection';
|
||||
|
|
@ -858,4 +862,137 @@ describe('Single Invocation Alert Test', () => {
|
|||
expect(dashboard.tiles[1].config.name).toBe('Second Tile Name');
|
||||
expect(enhancedAlert.tileId).toBe('second-tile-id');
|
||||
});
|
||||
|
||||
it('should trigger alert for raw SQL Number chart tile', async () => {
|
||||
jest.spyOn(slack, 'postMessageToWebhook').mockResolvedValue(null as any);
|
||||
|
||||
const team = await createTeam({ name: 'Test Team' });
|
||||
|
||||
const connection = await Connection.create({
|
||||
team: team._id,
|
||||
name: 'Test Connection',
|
||||
host: config.CLICKHOUSE_HOST,
|
||||
username: config.CLICKHOUSE_USER,
|
||||
password: config.CLICKHOUSE_PASSWORD,
|
||||
});
|
||||
|
||||
const webhook = await new Webhook({
|
||||
team: team._id,
|
||||
service: 'slack',
|
||||
url: 'https://hooks.slack.com/services/test-number',
|
||||
name: 'Test Webhook',
|
||||
}).save();
|
||||
|
||||
const dashboard = await new Dashboard({
|
||||
name: 'Number Chart Alert Dashboard',
|
||||
team: team._id,
|
||||
tiles: [
|
||||
{
|
||||
id: 'number-tile-1',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 4,
|
||||
config: {
|
||||
configType: 'sql',
|
||||
displayType: 'number',
|
||||
sqlTemplate: RAW_SQL_NUMBER_ALERT_TEMPLATE,
|
||||
connection: connection.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
}).save();
|
||||
|
||||
const mockUserId = new mongoose.Types.ObjectId();
|
||||
const alert = await createAlert(
|
||||
team._id,
|
||||
{
|
||||
source: AlertSource.TILE,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: webhook._id.toString(),
|
||||
},
|
||||
interval: '5m',
|
||||
thresholdType: AlertThresholdType.ABOVE,
|
||||
threshold: 1,
|
||||
dashboardId: dashboard.id,
|
||||
tileId: 'number-tile-1',
|
||||
name: 'Number Chart Alert',
|
||||
},
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
const now = new Date('2023-11-16T22:12:00.000Z');
|
||||
const eventTime = new Date(now.getTime() - ms('3m'));
|
||||
|
||||
// Insert logs that should be counted by the Number chart query
|
||||
await bulkInsertLogs([
|
||||
{
|
||||
ServiceName: 'web',
|
||||
Timestamp: eventTime,
|
||||
SeverityText: 'error',
|
||||
Body: 'Number chart error 1',
|
||||
},
|
||||
{
|
||||
ServiceName: 'web',
|
||||
Timestamp: eventTime,
|
||||
SeverityText: 'error',
|
||||
Body: 'Number chart error 2',
|
||||
},
|
||||
{
|
||||
ServiceName: 'web',
|
||||
Timestamp: eventTime,
|
||||
SeverityText: 'error',
|
||||
Body: 'Number chart error 3',
|
||||
},
|
||||
]);
|
||||
|
||||
const enhancedAlert: any = await Alert.findById(alert.id).populate([
|
||||
'team',
|
||||
'savedSearch',
|
||||
]);
|
||||
|
||||
const tile = dashboard.tiles?.find((t: any) => t.id === 'number-tile-1');
|
||||
|
||||
const details: AlertDetails = {
|
||||
alert: enhancedAlert,
|
||||
source: undefined,
|
||||
taskType: AlertTaskType.TILE,
|
||||
tile: tile!,
|
||||
dashboard,
|
||||
previousMap: new Map(),
|
||||
};
|
||||
|
||||
const clickhouseClient = new ClickhouseClient({
|
||||
host: connection.host,
|
||||
username: connection.username,
|
||||
password: connection.password,
|
||||
});
|
||||
|
||||
await processAlert(
|
||||
now,
|
||||
details,
|
||||
clickhouseClient,
|
||||
connection.id,
|
||||
alertProvider,
|
||||
new Map([[webhook.id.toString(), webhook]]),
|
||||
);
|
||||
|
||||
// Verify alert state changed to ALERT
|
||||
expect((await Alert.findById(enhancedAlert.id))!.state).toBe('ALERT');
|
||||
|
||||
// Verify alert history was created
|
||||
const alertHistories = await AlertHistory.find({
|
||||
alert: alert.id,
|
||||
}).sort({ createdAt: 1 });
|
||||
|
||||
expect(alertHistories.length).toBe(1);
|
||||
expect(alertHistories[0].state).toBe('ALERT');
|
||||
expect(alertHistories[0].counts).toBe(1);
|
||||
expect(alertHistories[0].lastValues.length).toBe(1);
|
||||
expect(alertHistories[0].lastValues[0].count).toBe(3);
|
||||
|
||||
// Verify webhook was called
|
||||
expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { renderChartConfig } from '@hyperdx/common-utils/dist/core/renderChartCo
|
|||
import {
|
||||
aliasMapToWithClauses,
|
||||
displayTypeSupportsRawSqlAlerts,
|
||||
isTimeSeriesDisplayType,
|
||||
} from '@hyperdx/common-utils/dist/core/utils';
|
||||
import { timeBucketByGranularity } from '@hyperdx/common-utils/dist/core/utils';
|
||||
import {
|
||||
|
|
@ -89,13 +90,14 @@ export const alertHasGroupBy = (details: AlertDetails): boolean => {
|
|||
}
|
||||
|
||||
// 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.
|
||||
// group by (besides the group by on the interval), so we'll assume it might
|
||||
// in the case of time series charts, and assume it will not in the case of number charts.
|
||||
// Group name will just be blank if there are no group by values.
|
||||
if (
|
||||
details.taskType === AlertTaskType.TILE &&
|
||||
isRawSqlSavedChartConfig(details.tile.config)
|
||||
) {
|
||||
return true;
|
||||
return details.tile.config.displayType !== DisplayType.Number;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
|
@ -481,7 +483,7 @@ const getChartConfigFromAlert = (
|
|||
} else if (details.taskType === AlertTaskType.TILE) {
|
||||
const tile = details.tile;
|
||||
|
||||
// Raw SQL line/bar tiles: build a RawSqlChartConfig
|
||||
// Raw SQL tiles: build a RawSqlChartConfig
|
||||
if (isRawSqlSavedChartConfig(tile.config)) {
|
||||
if (displayTypeSupportsRawSqlAlerts(tile.config.displayType)) {
|
||||
return {
|
||||
|
|
@ -493,7 +495,10 @@ const getChartConfigFromAlert = (
|
|||
]),
|
||||
connection,
|
||||
dateRange,
|
||||
granularity: `${windowSizeInMins} minute`,
|
||||
// Only time-series charts use interval bucketing
|
||||
...(isTimeSeriesDisplayType(tile.config.displayType) && {
|
||||
granularity: `${windowSizeInMins} minute`,
|
||||
}),
|
||||
// Include source metadata for macro expansion ($__sourceTable)
|
||||
...(details.source && {
|
||||
from: details.source.from,
|
||||
|
|
@ -563,9 +568,21 @@ const getChartConfigFromAlert = (
|
|||
return undefined;
|
||||
};
|
||||
|
||||
type ResponseMetadata =
|
||||
| {
|
||||
type: 'time_series';
|
||||
timestampColumnName: string;
|
||||
valueColumnNames: Set<string>;
|
||||
}
|
||||
| {
|
||||
type: 'single_value';
|
||||
valueColumnNames: Set<string>;
|
||||
};
|
||||
|
||||
const getResponseMetadata = (
|
||||
chartConfig: ChartConfigWithOptDateRange,
|
||||
data: ResponseJSON<Record<string, string | number>>,
|
||||
) => {
|
||||
): ResponseMetadata | undefined => {
|
||||
if (!data?.meta) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -577,26 +594,36 @@ const getResponseMetadata = (
|
|||
jsType: clickhouse.convertCHDataTypeToJSType(m.type),
|
||||
})) ?? [];
|
||||
|
||||
const timestampColumnName = meta.find(
|
||||
m => m.jsType === clickhouse.JSDataType.Date,
|
||||
)?.name;
|
||||
const valueColumnNames = new Set(
|
||||
meta
|
||||
.filter(m => m.jsType === clickhouse.JSDataType.Number)
|
||||
.map(m => m.name),
|
||||
);
|
||||
|
||||
if (timestampColumnName == null) {
|
||||
logger.error({ meta }, 'Failed to find timestamp column');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (valueColumnNames.size === 0) {
|
||||
logger.error({ meta }, 'Failed to find value column');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { timestampColumnName, valueColumnNames };
|
||||
// Raw SQL charts with Number display type don't use interval parameters, so they cannot be treated as timeseries.
|
||||
// Number-type Builder Charts are rendered as time-series, to maintain legacy behavior for existing alerts.
|
||||
if (
|
||||
isRawSqlChartConfig(chartConfig) &&
|
||||
chartConfig.displayType === DisplayType.Number
|
||||
) {
|
||||
return { type: 'single_value', valueColumnNames };
|
||||
} else {
|
||||
const timestampColumnName = meta.find(
|
||||
m => m.jsType === clickhouse.JSDataType.Date,
|
||||
)?.name;
|
||||
|
||||
if (timestampColumnName == null) {
|
||||
logger.error({ meta }, 'Failed to find timestamp column');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { type: 'time_series', timestampColumnName, valueColumnNames };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -609,7 +636,7 @@ const getResponseMetadata = (
|
|||
*/
|
||||
const parseAlertData = (
|
||||
data: Record<string, string | number>,
|
||||
meta: { timestampColumnName: string; valueColumnNames: Set<string> },
|
||||
meta: ResponseMetadata,
|
||||
) => {
|
||||
let value: number | null = null;
|
||||
const extraFields: string[] = [];
|
||||
|
|
@ -617,7 +644,7 @@ const parseAlertData = (
|
|||
for (const [k, v] of Object.entries(data)) {
|
||||
if (meta.valueColumnNames.has(k)) {
|
||||
value = isString(v) ? parseInt(v) : v;
|
||||
} else if (k !== meta.timestampColumnName) {
|
||||
} else if (meta.type !== 'time_series' || k !== meta.timestampColumnName) {
|
||||
extraFields.push(`${k}:${v}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -877,18 +904,74 @@ export const processAlert = async (
|
|||
}
|
||||
};
|
||||
|
||||
const sendNotificationIfResolved = async (
|
||||
previousHistory: AggregatedAlertHistory | undefined,
|
||||
currentHistory: IAlertHistory,
|
||||
groupKey: string,
|
||||
) => {
|
||||
if (
|
||||
previousHistory?.state === AlertState.ALERT &&
|
||||
currentHistory.state === AlertState.OK
|
||||
) {
|
||||
const lastValue =
|
||||
currentHistory.lastValues[currentHistory.lastValues.length - 1];
|
||||
await trySendNotification({
|
||||
state: AlertState.OK,
|
||||
group: groupKey,
|
||||
totalCount: lastValue?.count || 0,
|
||||
startTime: lastValue?.startTime || nowInMinsRoundDown,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const meta = getResponseMetadata(chartConfig, checksData);
|
||||
if (!meta) {
|
||||
logger.error({ alertId: alert.id }, 'Failed to get response metadata');
|
||||
return;
|
||||
}
|
||||
|
||||
// single_value type (Raw SQL Number charts) returns a single value with no
|
||||
// timestamp column, and are assumed to not have groups.
|
||||
if (meta.type === 'single_value') {
|
||||
// Use the date range end as the alert timestamp.
|
||||
const alertTimestamp = dateRange[1];
|
||||
const history = getOrCreateHistory('');
|
||||
|
||||
// The value is taken from the last numeric column of the first row.
|
||||
// The value defaults to 0.
|
||||
const value =
|
||||
checksData.data.length > 0
|
||||
? (parseAlertData(checksData.data[0], meta).value ?? 0)
|
||||
: 0;
|
||||
|
||||
history.lastValues.push({ count: value, startTime: alertTimestamp });
|
||||
if (doesExceedThreshold(alert.thresholdType, alert.threshold, value)) {
|
||||
history.state = AlertState.ALERT;
|
||||
history.counts += 1;
|
||||
await trySendNotification({
|
||||
state: AlertState.ALERT,
|
||||
group: '',
|
||||
totalCount: value,
|
||||
startTime: alertTimestamp,
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-resolve
|
||||
const previous = previousMap.get(computeHistoryMapKey(alert.id, ''));
|
||||
await sendNotificationIfResolved(previous, history, '');
|
||||
|
||||
const historyRecords = Array.from(histories.values());
|
||||
await alertProvider.updateAlertState(alert.id, historyRecords);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Time-series path (Line/StackedBar charts) ──
|
||||
const expectedBuckets = timeBucketByGranularity(
|
||||
dateRange[0],
|
||||
dateRange[1],
|
||||
`${windowSizeInMins} minute`,
|
||||
);
|
||||
|
||||
const meta = getResponseMetadata(checksData);
|
||||
if (!meta) {
|
||||
logger.error({ alertId: alert.id }, 'Failed to get response metadata');
|
||||
return;
|
||||
}
|
||||
|
||||
// Group data by time bucket (grouped alerts may have multiple entries per time bucket)
|
||||
const checkDataByBucket = new Map<
|
||||
number,
|
||||
|
|
@ -915,7 +998,6 @@ export const processAlert = async (
|
|||
'No data returned from ClickHouse for time bucket',
|
||||
);
|
||||
|
||||
// Empty periods are filled with a 0 values.
|
||||
const zeroValueIsAlert = doesExceedThreshold(
|
||||
alert.thresholdType,
|
||||
alert.threshold,
|
||||
|
|
@ -1013,19 +1095,7 @@ export const processAlert = async (
|
|||
for (const [groupKey, history] of histories.entries()) {
|
||||
const previousKey = computeHistoryMapKey(alert.id, groupKey);
|
||||
const groupPrevious = previousMap.get(previousKey);
|
||||
|
||||
if (
|
||||
groupPrevious?.state === AlertState.ALERT &&
|
||||
history.state === AlertState.OK
|
||||
) {
|
||||
const lastValue = history.lastValues[history.lastValues.length - 1];
|
||||
await trySendNotification({
|
||||
state: AlertState.OK,
|
||||
group: groupKey,
|
||||
totalCount: lastValue?.count || 0,
|
||||
startTime: lastValue?.startTime || nowInMinsRoundDown,
|
||||
});
|
||||
}
|
||||
await sendNotificationIfResolved(groupPrevious, history, groupKey);
|
||||
}
|
||||
|
||||
// Save all history records and update alert state
|
||||
|
|
|
|||
|
|
@ -166,6 +166,11 @@ export default function RawSqlChartEditor({
|
|||
});
|
||||
}, [sources, connection]);
|
||||
|
||||
const alertTooltip =
|
||||
displayType === DisplayType.Number
|
||||
? 'The threshold will be evaluated against the last numeric column in the first query result'
|
||||
: 'The threshold will be evaluated against the last numeric column in each query result';
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group align="center" gap={0} justify="space-between">
|
||||
|
|
@ -247,7 +252,7 @@ export default function RawSqlChartEditor({
|
|||
onRemove={() => setValue('alert', undefined)}
|
||||
error={alertErrorMessage}
|
||||
warning={alertWarningMessage}
|
||||
tooltip="The threshold will be evaluated against the last numeric column in the query result"
|
||||
tooltip={alertTooltip}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -683,6 +683,60 @@ describe('validateChartForm', () => {
|
|||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not error when raw SQL Number chart has alert with date range params but no interval', () => {
|
||||
const setError = jest.fn();
|
||||
const errors = validateChartForm(
|
||||
makeForm({
|
||||
configType: 'sql',
|
||||
displayType: DisplayType.Number,
|
||||
sqlTemplate:
|
||||
'SELECT count() FROM logs WHERE ts >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64}) AND ts <= fromUnixTimestamp64Milli({endDateMilliseconds:Int64})',
|
||||
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('still requires interval params for raw SQL Line chart alerts', () => {
|
||||
const setError = jest.fn();
|
||||
const errors = validateChartForm(
|
||||
makeForm({
|
||||
configType: 'sql',
|
||||
displayType: DisplayType.Line,
|
||||
sqlTemplate:
|
||||
'SELECT count() FROM logs WHERE ts >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64}) AND ts <= fromUnixTimestamp64Milli({endDateMilliseconds:Int64})',
|
||||
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.',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ── Source validation ────────────────────────────────────────────────
|
||||
|
||||
it('errors when builder chart has no source', () => {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { DisplayType } from '@hyperdx/common-utils/dist/types';
|
||||
|
||||
import { AlertsPage } from '../page-objects/AlertsPage';
|
||||
import { DashboardPage } from '../page-objects/DashboardPage';
|
||||
import { SearchPage } from '../page-objects/SearchPage';
|
||||
|
|
@ -246,4 +248,69 @@ test.describe('Alert Creation', { tag: ['@alerts', '@full-stack'] }, () => {
|
|||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'should create an alert from a raw SQL Number dashboard tile and verify on the alerts page',
|
||||
{ tag: '@full-stack' },
|
||||
async ({ page }) => {
|
||||
const ts = Date.now();
|
||||
const tileName = `E2E Raw SQL Number Alert ${ts}`;
|
||||
const webhookName = `E2E Webhook Number ${ts}`;
|
||||
const webhookUrl = `https://example.com/number-${ts}`;
|
||||
|
||||
const sqlQuery = `SELECT count() AS cnt
|
||||
FROM $__sourceTable
|
||||
WHERE Timestamp >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64}) AND Timestamp < fromUnixTimestamp64Milli({endDateMilliseconds:Int64})
|
||||
`;
|
||||
|
||||
await test.step('Create a new dashboard', async () => {
|
||||
await dashboardPage.goto();
|
||||
await dashboardPage.createNewDashboard();
|
||||
});
|
||||
|
||||
await test.step('Add a raw SQL Number 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.setChartType(DisplayType.Number);
|
||||
await dashboardPage.chartEditor.switchToSqlMode();
|
||||
await dashboardPage.chartEditor.typeSqlQuery(sqlQuery);
|
||||
await dashboardPage.chartEditor.runQuery(false);
|
||||
});
|
||||
|
||||
await test.step('Enable and configure an alert on the raw SQL Number 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 });
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1021,7 +1021,9 @@ export function displayTypeSupportsRawSqlAlerts(
|
|||
displayType: DisplayType | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
displayType === DisplayType.Line || displayType === DisplayType.StackedBar
|
||||
displayType === DisplayType.Line ||
|
||||
displayType === DisplayType.StackedBar ||
|
||||
displayType === DisplayType.Number
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1054,13 +1056,19 @@ export function validateRawSqlForAlert(chartConfig: RawSqlChartConfig): {
|
|||
}
|
||||
|
||||
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.`,
|
||||
);
|
||||
|
||||
// Interval params are only required for time-series display types (Line, StackedBar).
|
||||
// Number charts don't use interval bucketing.
|
||||
if (isTimeSeriesDisplayType(chartConfig.displayType)) {
|
||||
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 =
|
||||
|
|
@ -1078,3 +1086,11 @@ export function validateRawSqlForAlert(chartConfig: RawSqlChartConfig): {
|
|||
return { errors, warnings };
|
||||
}
|
||||
}
|
||||
|
||||
export const isTimeSeriesDisplayType = (
|
||||
displayType: DisplayType | undefined,
|
||||
): boolean => {
|
||||
return (
|
||||
displayType === DisplayType.Line || displayType === DisplayType.StackedBar
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue