Skip to content

Commit 01973c0

Browse files
authored
[9.0] Alerts created within a Maintenance Windows trigger actions after the MW expires (#219797) (#220935)
# Backport This will backport the following commits from `main` to `9.0`: - [Alerts created within a Maintenance Windows trigger actions after the MW expires (#219797)](#219797) <!--- Backport version: 9.6.6 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) <!--BACKPORT [{"author":{"name":"Ersin Erdal","email":"[email protected]"},"sourceCommit":{"committedDate":"2025-05-05T23:26:43Z","message":"Alerts created within a Maintenance Windows trigger actions after the MW expires (#219797)\n\nResolves: #215634\n\nThis PR removes the expired maintenance window ids from the\n`alert.meta.maintenanceWindowIds` list of the active and the recovered\nalerts.\nSo the actions for those alerts would be triggered once the MW expires.\n\n## To verify:\nCreate a Maintenance Window that lasts a couple of minutes.\nCreate a rule that generates an alert.\nActions for the alert should not be triggered while the MW is active.\nWait for the MW to expire, an action for the alert should be triggered\nfor the alert.\nChange the rule to make the alert recovered, a recevored action for the\nalert should be triggered as well.\n\nCreate another MW with a filter.\nDo the same tests with an action with `summary-of-alerts` config.\nNote: [this PR](#219793) should be\nmerged to be able to test an MW with filters.","sha":"b748de163e79a79ddbad7354fbd71e41276b2303","branchLabelMapping":{"^v9.1.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:enhancement","Team:ResponseOps","backport:version","v9.1.0","v8.19.0","v8.17.7","v8.18.2","v9.0.2"],"title":"Alerts created within a Maintenance Windows trigger actions after the MW expires","number":219797,"url":"https://github.com/elastic/kibana/pull/219797","mergeCommit":{"message":"Alerts created within a Maintenance Windows trigger actions after the MW expires (#219797)\n\nResolves: #215634\n\nThis PR removes the expired maintenance window ids from the\n`alert.meta.maintenanceWindowIds` list of the active and the recovered\nalerts.\nSo the actions for those alerts would be triggered once the MW expires.\n\n## To verify:\nCreate a Maintenance Window that lasts a couple of minutes.\nCreate a rule that generates an alert.\nActions for the alert should not be triggered while the MW is active.\nWait for the MW to expire, an action for the alert should be triggered\nfor the alert.\nChange the rule to make the alert recovered, a recevored action for the\nalert should be triggered as well.\n\nCreate another MW with a filter.\nDo the same tests with an action with `summary-of-alerts` config.\nNote: [this PR](#219793) should be\nmerged to be able to test an MW with filters.","sha":"b748de163e79a79ddbad7354fbd71e41276b2303"}},"sourceBranch":"main","suggestedTargetBranches":["8.17","8.18","9.0"],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/219797","number":219797,"mergeCommit":{"message":"Alerts created within a Maintenance Windows trigger actions after the MW expires (#219797)\n\nResolves: #215634\n\nThis PR removes the expired maintenance window ids from the\n`alert.meta.maintenanceWindowIds` list of the active and the recovered\nalerts.\nSo the actions for those alerts would be triggered once the MW expires.\n\n## To verify:\nCreate a Maintenance Window that lasts a couple of minutes.\nCreate a rule that generates an alert.\nActions for the alert should not be triggered while the MW is active.\nWait for the MW to expire, an action for the alert should be triggered\nfor the alert.\nChange the rule to make the alert recovered, a recevored action for the\nalert should be triggered as well.\n\nCreate another MW with a filter.\nDo the same tests with an action with `summary-of-alerts` config.\nNote: [this PR](#219793) should be\nmerged to be able to test an MW with filters.","sha":"b748de163e79a79ddbad7354fbd71e41276b2303"}},{"branch":"8.19","label":"v8.19.0","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"url":"https://github.com/elastic/kibana/pull/220177","number":220177,"state":"MERGED","mergeCommit":{"sha":"ade3f8fc875061b324359198fad0b5d41625c2a1","message":"[8.19] Alerts created within a Maintenance Windows trigger actions after the MW expires (#219797) (#220177)\n\n# Backport\n\nThis will backport the following commits from `main` to `8.19`:\n- [Alerts created within a Maintenance Windows trigger actions after the\nMW expires (#219797)](https://github.com/elastic/kibana/pull/219797)\n\n\n\n### Questions ?\nPlease refer to the [Backport tool\ndocumentation](https://github.com/sorenlouv/backport)\n\n\n\nCo-authored-by: Ersin Erdal <[email protected]>"}},{"branch":"8.17","label":"v8.17.7","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"8.18","label":"v8.18.2","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"9.0","label":"v9.0.2","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"}]}] BACKPORT-->
1 parent 966c70f commit 01973c0

File tree

4 files changed

+119
-39
lines changed

4 files changed

+119
-39
lines changed

x-pack/platform/plugins/shared/alerting/server/alerts_client/legacy_alerts_client.test.ts

Lines changed: 41 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ describe('Legacy Alerts Client', () => {
371371
});
372372
});
373373

374-
test('processAlerts() should set maintenance windows IDs on new alerts', async () => {
374+
test('processAlerts() should set maintenance windows IDs on new alerts and remove the expired maintenance windows from the active and recovered alerts', async () => {
375375
maintenanceWindowsService.getMaintenanceWindows.mockReturnValue({
376376
maintenanceWindows: [
377377
{
@@ -386,37 +386,57 @@ describe('Legacy Alerts Client', () => {
386386
eventStartTime: new Date().toISOString(),
387387
eventEndTime: new Date().toISOString(),
388388
status: MaintenanceWindowStatus.Running,
389-
id: 'test-id2',
389+
id: 'test-id5',
390390
},
391391
],
392-
maintenanceWindowsWithoutScopedQueryIds: ['test-id1', 'test-id2'],
392+
maintenanceWindowsWithoutScopedQueryIds: ['test-id1', 'test-id5'],
393393
});
394+
395+
const activeAlert = {
396+
state: {},
397+
meta: {
398+
uuid: 'bar',
399+
maintenanceWindowIds: ['test-id1', 'test-id2'],
400+
},
401+
};
402+
403+
const recoveredAlert = {
404+
state: {},
405+
meta: {
406+
uuid: 'ghi',
407+
maintenanceWindowIds: ['test-id1', `test-id3`],
408+
},
409+
};
410+
394411
(processAlerts as jest.Mock).mockReturnValue({
395412
newAlerts: {
396413
'1': new Alert<AlertInstanceContext, AlertInstanceContext>('1', testAlert1),
397414
},
398415
activeAlerts: {
399-
'2': new Alert<AlertInstanceContext, AlertInstanceContext>('2', testAlert2),
416+
'2': new Alert<AlertInstanceContext, AlertInstanceContext>('2', activeAlert),
417+
},
418+
recoveredAlerts: {
419+
'3': new Alert<AlertInstanceContext, AlertInstanceContext>('3', recoveredAlert),
400420
},
401421
currentRecoveredAlerts: {},
402-
recoveredAlerts: {},
403422
});
423+
404424
(trimRecoveredAlerts as jest.Mock).mockReturnValue({
405425
trimmedAlertsRecovered: {},
406426
earlyRecoveredAlerts: {},
407427
});
428+
408429
(getAlertsForNotification as jest.Mock).mockReturnValue({
409430
newAlerts: {
410431
'1': new Alert<AlertInstanceContext, AlertInstanceContext>('1', testAlert1),
411432
},
412433
activeAlerts: {
413-
'2': new Alert<AlertInstanceContext, AlertInstanceContext>('2', testAlert2),
434+
'2': new Alert<AlertInstanceContext, AlertInstanceContext>('2', activeAlert),
414435
},
415-
currentActiveAlerts: {
416-
'2': new Alert<AlertInstanceContext, AlertInstanceContext>('2', testAlert2),
436+
recoveredAlerts: {
437+
'3': new Alert<AlertInstanceContext, AlertInstanceContext>('3', recoveredAlert),
417438
},
418439
currentRecoveredAlerts: {},
419-
recoveredAlerts: {},
420440
});
421441
const alertsClient = new LegacyAlertsClient({
422442
alertingEventLogger,
@@ -430,7 +450,8 @@ describe('Legacy Alerts Client', () => {
430450
await alertsClient.initializeExecution({
431451
...defaultExecutionOpts,
432452
activeAlertsFromState: {
433-
'2': testAlert2,
453+
'2': activeAlert,
454+
'3': recoveredAlert,
434455
},
435456
});
436457

@@ -447,27 +468,16 @@ describe('Legacy Alerts Client', () => {
447468
spaceId: 'space1',
448469
});
449470

450-
expect(getAlertsForNotification).toHaveBeenCalledWith(
451-
{
452-
enabled: true,
453-
lookBackWindow: 20,
454-
statusChangeThreshold: 4,
455-
},
456-
'default',
457-
5,
458-
{
459-
'1': new Alert<AlertInstanceContext, AlertInstanceContext>('1', {
460-
...testAlert1,
461-
meta: { ...testAlert1.meta, maintenanceWindowIds: ['test-id1', 'test-id2'] },
462-
}),
463-
},
464-
{
465-
'2': new Alert<AlertInstanceContext, AlertInstanceContext>('2', testAlert2),
466-
},
467-
{},
468-
{},
469-
null
470-
);
471+
expect(alertsClient.getProcessedAlerts('new')['1'].getMaintenanceWindowIds()).toEqual([
472+
'test-id1',
473+
'test-id5',
474+
]);
475+
expect(alertsClient.getProcessedAlerts('active')['2'].getMaintenanceWindowIds()).toEqual([
476+
'test-id1',
477+
]);
478+
expect(alertsClient.getProcessedAlerts('recovered')['3'].getMaintenanceWindowIds()).toEqual([
479+
'test-id1',
480+
]);
471481
});
472482

473483
test('isTrackedAlert() should return true if alert was active in a previous execution, false otherwise', async () => {

x-pack/platform/plugins/shared/alerting/server/alerts_client/legacy_alerts_client.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,10 @@ import {
3333
TrackedAlerts,
3434
} from './types';
3535
import { DEFAULT_MAX_ALERTS } from '../config';
36-
import { UntypedNormalizedRuleType } from '../rule_type_registry';
37-
import { MaintenanceWindowsService } from '../task_runner/maintenance_windows';
38-
import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger';
36+
import type { UntypedNormalizedRuleType } from '../rule_type_registry';
37+
import type { MaintenanceWindowsService } from '../task_runner/maintenance_windows';
38+
import type { MaintenanceWindow } from '../application/maintenance_window/types';
39+
import type { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger';
3940

4041
export interface LegacyAlertsClientParams {
4142
alertingEventLogger: AlertingEventLogger;
@@ -175,14 +176,20 @@ export class LegacyAlertsClient<
175176
keys(processedAlertsActive).length > 0 ||
176177
keys(processedAlertsRecovered).length > 0
177178
) {
178-
const { maintenanceWindowsWithoutScopedQueryIds } =
179+
const { maintenanceWindowsWithoutScopedQueryIds, maintenanceWindows } =
179180
await this.options.maintenanceWindowsService.getMaintenanceWindows({
180181
eventLogger: this.options.alertingEventLogger,
181182
request: this.options.request,
182183
ruleTypeCategory: this.options.ruleType.category,
183184
spaceId: this.options.spaceId,
184185
});
185186

187+
this.removeExpiredMaintenanceWindows({
188+
processedAlertsActive,
189+
processedAlertsRecovered,
190+
maintenanceWindows,
191+
});
192+
186193
for (const id in processedAlertsNew) {
187194
if (Object.hasOwn(processedAlertsNew, id)) {
188195
processedAlertsNew[id].setMaintenanceWindowIds(maintenanceWindowsWithoutScopedQueryIds);
@@ -283,4 +290,35 @@ export class LegacyAlertsClient<
283290
public async setAlertStatusToUntracked() {
284291
return;
285292
}
293+
public getTrackedExecutions() {
294+
return new Set([]);
295+
}
296+
297+
private removeExpiredMaintenanceWindows({
298+
processedAlertsActive,
299+
processedAlertsRecovered,
300+
maintenanceWindows,
301+
}: {
302+
processedAlertsActive: Record<string, Alert<State, Context, ActionGroupIds>>;
303+
processedAlertsRecovered: Record<string, Alert<State, Context, RecoveryActionGroupId>>;
304+
maintenanceWindows: MaintenanceWindow[];
305+
}) {
306+
const maintenanceWindowIds = maintenanceWindows.map((mw) => mw.id);
307+
308+
const clearMws = (
309+
alerts: Record<string, Alert<State, Context, ActionGroupIds | RecoveryActionGroupId>>
310+
) => {
311+
for (const id in alerts) {
312+
if (Object.hasOwn(alerts, id)) {
313+
const existingMaintenanceWindowIds = alerts[id].getMaintenanceWindowIds();
314+
const activeMaintenanceWindowIds = existingMaintenanceWindowIds.filter((mw) => {
315+
return maintenanceWindowIds.includes(mw);
316+
});
317+
alerts[id].setMaintenanceWindowIds(activeMaintenanceWindowIds);
318+
}
319+
}
320+
};
321+
clearMws(processedAlertsActive);
322+
clearMws(processedAlertsRecovered);
323+
}
286324
}

x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/maintenance_window_flows.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
getRuleEvents,
1818
expectNoActionsFired,
1919
runSoon,
20+
expectActionsFired,
2021
} from './test_helpers';
2122

2223
// eslint-disable-next-line import/no-default-export
@@ -214,7 +215,7 @@ export default function maintenanceWindowFlowsTests({ getService }: FtrProviderC
214215
});
215216
});
216217

217-
it('alerts triggered within a MW should not fire actions if active or recovered outside a MW', async () => {
218+
it('alerts triggered within a MW should fire actions if still active or recoveres after the MW expired', async () => {
218219
const pattern = {
219220
instance: [true, true, false, true],
220221
};
@@ -278,10 +279,11 @@ export default function maintenanceWindowFlowsTests({ getService }: FtrProviderC
278279
getService,
279280
});
280281

281-
await expectNoActionsFired({
282+
await expectActionsFired({
282283
id: rule.id,
283284
supertest,
284285
retry,
286+
expectedNumberOfActions: 1,
285287
});
286288

287289
// Run again - recovered
@@ -298,10 +300,11 @@ export default function maintenanceWindowFlowsTests({ getService }: FtrProviderC
298300
getService,
299301
});
300302

301-
await expectNoActionsFired({
303+
await expectActionsFired({
302304
id: rule.id,
303305
supertest,
304306
retry,
307+
expectedNumberOfActions: 2,
305308
});
306309

307310
// Run again - active again, this time fire the action since its a new alert instance
@@ -312,7 +315,7 @@ export default function maintenanceWindowFlowsTests({ getService }: FtrProviderC
312315
});
313316
await getRuleEvents({
314317
id: rule.id,
315-
action: 1,
318+
action: 3,
316319
activeInstance: 3,
317320
recoveredInstance: 1,
318321
retry,

x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/test_helpers.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,35 @@ export const expectNoActionsFired = async ({
213213
expect(actionEvents.length).eql(0);
214214
};
215215

216+
export const expectActionsFired = async ({
217+
id,
218+
supertest,
219+
retry,
220+
expectedNumberOfActions,
221+
}: {
222+
id: string;
223+
supertest: SuperTestAgent;
224+
retry: RetryService;
225+
expectedNumberOfActions: number;
226+
}) => {
227+
const events = await retry.try(async () => {
228+
const { body: result } = await supertest
229+
.get(`${getUrlPrefix(Spaces.space1.id)}/_test/event_log/alert/${id}/_find?per_page=5000`)
230+
.expect(200);
231+
232+
if (!result.total) {
233+
throw new Error('no events found yet');
234+
}
235+
return result.data as IValidatedEvent[];
236+
});
237+
238+
const actionEvents = events.filter((event) => {
239+
return event?.event?.action === 'execute-action';
240+
});
241+
242+
expect(actionEvents.length).eql(expectedNumberOfActions);
243+
};
244+
216245
export const runSoon = async ({
217246
id,
218247
supertest,

0 commit comments

Comments
 (0)