fix: use correct title in alert on dashboards (#1109)

Uses the title from the alerting tile instead of the first tile on a dashboard.

Ref: HDX-2270
This commit is contained in:
Dan Hable 2025-08-27 12:40:29 -05:00 committed by GitHub
parent 174372018e
commit f800fd1374
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 225 additions and 2 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/api": patch
---
Fixes alert title used on dashboards with multiple tiles

View file

@ -159,6 +159,7 @@ describe('checkAlerts', () => {
value: 10,
};
const testTile = makeTile({ id: 'test-tile-id' });
const defaultChartView: AlertMessageTemplateDefaultView = {
alert: {
thresholdType: AlertThresholdType.ABOVE,
@ -169,12 +170,13 @@ describe('checkAlerts', () => {
webhookId: 'fake-webhook-id',
},
interval: '1m',
tileId: 'test-tile-id',
},
dashboard: {
_id: new mongoose.Types.ObjectId(),
id: 'id-123',
name: 'My Dashboard',
tiles: [makeTile()],
tiles: [testTile],
team: 'team-123' as any,
tags: ['test'],
},

View file

@ -10,11 +10,16 @@ import { bulkInsertLogs, getServer } from '@/fixtures';
import Alert, { AlertSource, AlertThresholdType } from '@/models/alert';
import AlertHistory from '@/models/alertHistory';
import Connection from '@/models/connection';
import Dashboard from '@/models/dashboard';
import { SavedSearch } from '@/models/savedSearch';
import { Source } from '@/models/source';
import Webhook from '@/models/webhook';
import { processAlert } from '@/tasks/checkAlerts';
import { AlertTaskType, loadProvider } from '@/tasks/providers';
import {
AlertMessageTemplateDefaultView,
buildAlertMessageTemplateTitle,
} from '@/tasks/template';
import * as slack from '@/utils/slack';
describe('Single Invocation Alert Test', () => {
@ -243,4 +248,210 @@ describe('Single Invocation Alert Test', () => {
expect(messageBody).toContain('to=');
expect(messageBody).toContain('isLive=false');
});
it('should use correct tile name in alert title when alerting tile is not first', async () => {
// Mock slack webhook to avoid external calls
jest.spyOn(slack, 'postMessageToWebhook').mockResolvedValue(null as any);
// Create team
const team = await createTeam({ name: 'Test Team' });
const now = new Date('2023-11-16T22:12:00.000Z');
const eventMs = now.getTime() - ms('5m');
// Insert logs that will trigger the alert
await bulkInsertLogs([
{
ServiceName: 'api',
Timestamp: new Date(eventMs),
SeverityText: 'error',
Body: 'Test error message',
},
{
ServiceName: 'api',
Timestamp: new Date(eventMs),
SeverityText: 'error',
Body: 'Test error message',
},
{
ServiceName: 'api',
Timestamp: new Date(eventMs),
SeverityText: 'error',
Body: 'Test error message',
},
]);
// Create connection
const connection = await Connection.create({
team: team._id,
name: 'Test Connection',
host: config.CLICKHOUSE_HOST,
username: config.CLICKHOUSE_USER,
password: config.CLICKHOUSE_PASSWORD,
});
// Create source
const source = await Source.create({
kind: 'log',
team: team._id,
from: {
databaseName: 'default',
tableName: 'otel_logs',
},
timestampValueExpression: 'Timestamp',
connection: connection.id,
name: 'Test Logs',
});
// Create dashboard with multiple tiles - the alerting tile is NOT the first one
const dashboard = await new Dashboard({
name: 'Multi-Tile Dashboard',
team: team._id,
tiles: [
{
id: 'first-tile-id',
x: 0,
y: 0,
w: 6,
h: 4,
config: {
name: 'First Tile Name', // This should NOT appear in alert title
select: [
{
aggFn: 'count',
aggCondition: 'ServiceName:api',
valueExpression: '',
aggConditionLanguage: 'lucene',
},
],
where: '',
displayType: 'line',
granularity: 'auto',
source: source.id,
groupBy: '',
},
},
{
id: 'second-tile-id',
x: 6,
y: 0,
w: 6,
h: 4,
config: {
name: 'Second Tile Name', // This SHOULD appear in alert title
select: [
{
aggFn: 'count',
aggCondition: 'SeverityText:error',
valueExpression: '',
aggConditionLanguage: 'lucene',
},
],
where: '',
displayType: 'line',
granularity: 'auto',
source: source.id,
groupBy: '',
},
},
],
}).save();
// Create webhook
const webhook = await new Webhook({
team: team._id,
service: 'slack',
url: 'https://hooks.slack.com/services/test123',
name: 'Test Webhook',
}).save();
// Create alert that references the SECOND tile (not the first)
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, // Low threshold to trigger alert
dashboardId: dashboard.id,
tileId: 'second-tile-id', // Alert references second tile, NOT first
},
mockUserId,
);
// Get enhanced alert with populated relations
const enhancedAlert: any = await Alert.findById(alert.id).populate([
'team',
'dashboard',
]);
// Find the tile we're alerting on (should be the second tile)
const tile = dashboard.tiles?.find((t: any) => t.id === 'second-tile-id');
if (!tile) throw new Error('Second tile not found for multi-tile test');
// Set up alert processing details (like existing tile tests)
const details: any = {
alert: enhancedAlert,
source,
conn: connection,
taskType: AlertTaskType.TILE,
tile,
dashboard,
};
const clickhouseClient = new clickhouse.ClickhouseClient({
host: connection.host,
username: connection.username,
password: connection.password,
});
const mockMetadata = {
getColumn: jest.fn().mockImplementation(({ column }) => {
const columnMap = {
ServiceName: { name: 'ServiceName', type: 'String' },
Timestamp: { name: 'Timestamp', type: 'DateTime' },
SeverityText: { name: 'SeverityText', type: 'String' },
Body: { name: 'Body', type: 'String' },
};
return Promise.resolve(columnMap[column]);
}),
};
// Mock the getMetadata function
jest.mock('@hyperdx/common-utils/dist/metadata', () => ({
...jest.requireActual('@hyperdx/common-utils/dist/metadata'),
getMetadata: jest.fn().mockReturnValue(mockMetadata),
}));
// Process alert - this triggers the webhook with the title
await processAlert(
now,
details,
clickhouseClient,
connection.id,
alertProvider,
);
// Get the webhook call to inspect the title
expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(1);
const webhookCall = (slack.postMessageToWebhook as jest.Mock).mock.calls[0];
const messageText = webhookCall[1].text;
// This assertion SHOULD pass but WILL FAIL due to the bug
// The bug causes buildAlertMessageTemplateTitle to use dashboard.tiles[0] (First Tile Name)
// instead of finding the correct tile by ID (Second Tile Name)
expect(messageText).toContain('Second Tile Name'); // Should find the correct tile
expect(messageText).not.toContain('First Tile Name'); // Should NOT use first tile
// Verify our test setup is correct
expect(dashboard.tiles).toHaveLength(2);
expect(dashboard.tiles[0].config.name).toBe('First Tile Name');
expect(dashboard.tiles[1].config.name).toBe('Second Tile Name');
expect(enhancedAlert.tileId).toBe('second-tile-id');
});
});

View file

@ -303,7 +303,12 @@ export const buildAlertMessageTemplateTitle = ({
if (dashboard == null) {
throw new Error(`Source is ${alert.source} but dashboard is null`);
}
const tile = dashboard.tiles[0];
const tile = dashboard.tiles.find(t => t.id === alert.tileId);
if (!tile) {
throw new Error(
`Tile with id ${alert.tileId} not found in dashboard ${dashboard.name}`,
);
}
return template
? handlebars.compile(template)(view)
: `Alert for "${tile.config.name}" in "${dashboard.name}" - ${value} ${