mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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:
parent
0bfec14830
commit
085f30743e
27 changed files with 1437 additions and 139 deletions
7
.changeset/short-badgers-applaud.md
Normal file
7
.changeset/short-badgers-applaud.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@hyperdx/common-utils": patch
|
||||
"@hyperdx/api": patch
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Implement alerting for Raw SQL-based dashboard tiles
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}))`;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]),
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue