diff --git a/x-pack/platform/packages/private/security/authorization_core/src/privileges/feature_privilege_builder/alerting.ts b/x-pack/platform/packages/private/security/authorization_core/src/privileges/feature_privilege_builder/alerting.ts index 65c330b94c462..ffb3be1686bb2 100644 --- a/x-pack/platform/packages/private/security/authorization_core/src/privileges/feature_privilege_builder/alerting.ts +++ b/x-pack/platform/packages/private/security/authorization_core/src/privileges/feature_privilege_builder/alerting.ts @@ -28,6 +28,7 @@ const readOperations: Record = { 'getRuleExecutionKPI', 'getBackfill', 'findBackfill', + 'findGaps', ], alert: ['get', 'find', 'getAuthorizedAlertsIndices', 'getAlertSummary'], }; diff --git a/x-pack/platform/plugins/shared/alerting/common/constants/gap_status.ts b/x-pack/platform/plugins/shared/alerting/common/constants/gap_status.ts new file mode 100644 index 0000000000000..e4b1995312dc7 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/common/constants/gap_status.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const gapStatus = { + UNFILLED: 'unfilled', + FILLED: 'filled', + PARTIALLY_FILLED: 'partially_filled', +} as const; + +export type GapStatus = (typeof gapStatus)[keyof typeof gapStatus]; diff --git a/x-pack/platform/plugins/shared/alerting/common/constants/index.ts b/x-pack/platform/plugins/shared/alerting/common/constants/index.ts index 0acc25785d194..afdfcd60d18c1 100644 --- a/x-pack/platform/plugins/shared/alerting/common/constants/index.ts +++ b/x-pack/platform/plugins/shared/alerting/common/constants/index.ts @@ -13,3 +13,5 @@ export { MAX_SCHEDULE_BACKFILL_LOOKBACK_WINDOW_MS, } from './backfill'; export { PLUGIN } from './plugin'; +export { gapStatus } from './gap_status'; +export type { GapStatus } from './gap_status'; diff --git a/x-pack/platform/plugins/shared/alerting/common/index.ts b/x-pack/platform/plugins/shared/alerting/common/index.ts index 0740858784a2c..82c6eca266337 100644 --- a/x-pack/platform/plugins/shared/alerting/common/index.ts +++ b/x-pack/platform/plugins/shared/alerting/common/index.ts @@ -92,6 +92,12 @@ export const INTERNAL_ALERTING_BACKFILL_FIND_API_PATH = export const INTERNAL_ALERTING_BACKFILL_SCHEDULE_API_PATH = `${INTERNAL_ALERTING_BACKFILL_API_PATH}/_schedule` as const; +export const INTERNAL_ALERTING_GAPS_API_PATH = + `${INTERNAL_BASE_ALERTING_API_PATH}/rules/gaps` as const; + +export const INTERNAL_ALERTING_GAPS_FIND_API_PATH = + `${INTERNAL_ALERTING_GAPS_API_PATH}/_find` as const; + export const ALERTING_FEATURE_ID = 'alerts'; export const MONITORING_HISTORY_LIMIT = 200; export const ENABLE_MAINTENANCE_WINDOWS = true; diff --git a/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/find/index.ts b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/find/index.ts new file mode 100644 index 0000000000000..e4127a01f535e --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/find/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { findGapsBodySchema, findGapsResponseSchema } from './schemas/latest'; +export type { FindGapsRequestQuery, FindGapsResponseBody, FindGapsResponse } from './types/latest'; + +export { + findGapsBodySchema as findGapsBodySchemaV1, + findGapsResponseSchema as findGapsResponseSchemaV1, +} from './schemas/v1'; +export type { + FindGapsRequestQuery as FindGapsRequestQueryV1, + FindGapsResponseBody as FindGapsResponseBodyV1, + FindGapsResponse as FindGapsResponseV1, +} from './types/v1'; diff --git a/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/find/schemas/latest.ts b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/find/schemas/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/find/schemas/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/find/schemas/v1.ts b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/find/schemas/v1.ts new file mode 100644 index 0000000000000..c69e5fc78dba7 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/find/schemas/v1.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { schema } from '@kbn/config-schema'; +import { gapsResponseSchemaV1 } from '../../../response'; + +export const findGapsBodySchema = schema.object( + { + end: schema.maybe(schema.string()), + page: schema.number({ defaultValue: 1, min: 1 }), + per_page: schema.number({ defaultValue: 10, min: 0 }), + rule_id: schema.maybe(schema.string()), + start: schema.maybe(schema.string()), + sort_field: schema.maybe( + schema.oneOf([ + schema.literal('@timestamp'), + schema.literal('status'), + schema.literal('total_gap_duration_ms'), + ]) + ), + sort_order: schema.maybe(schema.oneOf([schema.literal('asc'), schema.literal('desc')])), + statuses: schema.maybe( + schema.arrayOf( + schema.oneOf([ + schema.literal('partially_filled'), + schema.literal('unfilled'), + schema.literal('filled'), + ]) + ) + ), + }, + { + validate({ start, end }) { + if (start) { + const parsedStart = Date.parse(start); + if (isNaN(parsedStart)) { + return `[start]: query start must be valid date`; + } + } + if (end) { + const parsedEnd = Date.parse(end); + if (isNaN(parsedEnd)) { + return `[end]: query end must be valid date`; + } + } + }, + } +); + +export const findGapsResponseSchema = schema.object({ + page: schema.number(), + per_page: schema.number(), + total: schema.number(), + data: schema.arrayOf(gapsResponseSchemaV1), +}); diff --git a/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/find/types/latest.ts b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/find/types/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/find/types/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/find/types/v1.ts b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/find/types/v1.ts new file mode 100644 index 0000000000000..7834443652869 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/apis/find/types/v1.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TypeOf } from '@kbn/config-schema'; +import { findQuerySchemaV1, findResponseSchemaV1 } from '..'; + +export type FindGapsRequestQuery = TypeOf; +export type FindGapsResponseBody = TypeOf; + +export interface FindGapsResponse { + body: FindGapsResponseBody; +} diff --git a/x-pack/platform/plugins/shared/alerting/common/routes/gaps/response/index.ts b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/response/index.ts new file mode 100644 index 0000000000000..ac7d9fe1e9163 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/response/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { gapsResponseSchema, errorResponseSchema } from './schemas/latest'; +export type { GapsResponse } from './types/latest'; + +export { + gapsResponseSchema as gapsResponseSchemaV1, + errorResponseSchema as errorResponseSchemaV1, +} from './schemas/v1'; + +export type { GapsResponse as GapsResponseV1, ErrorResponse as ErrorResponseV1 } from './types/v1'; diff --git a/x-pack/platform/plugins/shared/alerting/common/routes/gaps/response/schemas/latest.ts b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/response/schemas/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/response/schemas/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/platform/plugins/shared/alerting/common/routes/gaps/response/schemas/v1.ts b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/response/schemas/v1.ts new file mode 100644 index 0000000000000..def7d14c9055e --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/response/schemas/v1.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { gapStatus } from '../../../../constants'; + +export const gapStatusSchema = schema.oneOf([ + schema.literal(gapStatus.UNFILLED), + schema.literal(gapStatus.FILLED), + schema.literal(gapStatus.PARTIALLY_FILLED), +]); + +export const rangeSchema = schema.object({ + lte: schema.string(), + gte: schema.string(), +}); + +export const rangeListSchema = schema.arrayOf(rangeSchema); + +export const gapsResponseSchema = schema.object({ + '@timestamp': schema.string(), + _id: schema.string(), + status: gapStatusSchema, + range: rangeSchema, + in_progress_intervals: rangeListSchema, + filled_intervals: rangeListSchema, + unfilled_intervals: rangeListSchema, + total_gap_duration_ms: schema.number(), + filled_duration_ms: schema.number(), + unfilled_duration_ms: schema.number(), + in_progress_duration_ms: schema.number(), +}); + +export const errorResponseSchema = schema.object({ + error: schema.object({ + message: schema.string(), + status: schema.maybe(schema.number()), + }), +}); diff --git a/x-pack/platform/plugins/shared/alerting/common/routes/gaps/response/types/latest.ts b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/response/types/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/response/types/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/platform/plugins/shared/alerting/common/routes/gaps/response/types/v1.ts b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/response/types/v1.ts new file mode 100644 index 0000000000000..3b538a97b0136 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/common/routes/gaps/response/types/v1.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TypeOf } from '@kbn/config-schema'; +import { gapsResponseSchemaV1, errorResponseSchemaV1 } from '..'; + +export type GapsResponse = TypeOf; +export type ErrorResponse = TypeOf; diff --git a/x-pack/platform/plugins/shared/alerting/server/alerts_service/lib/retry_transient_es_errors.test.ts b/x-pack/platform/plugins/shared/alerting/server/alerts_service/lib/retry_transient_es_errors.test.ts index 3a59b07556f9b..8ee707bc96b37 100644 --- a/x-pack/platform/plugins/shared/alerting/server/alerts_service/lib/retry_transient_es_errors.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/alerts_service/lib/retry_transient_es_errors.test.ts @@ -92,4 +92,27 @@ describe('retryTransientErrors', () => { await expect(retryTransientEsErrors(esCallMock, { logger })).rejects.toThrow(error); expect(esCallMock).toHaveBeenCalledTimes(1); }); + + it('retries with additional status codes when provided', async () => { + const customStatusCode = 409; // Conflict version + const error = new EsErrors.ResponseError({ + statusCode: customStatusCode, + meta: {} as any, + warnings: [], + body: 'Conflict Version', + }); + const esCallMock = jest.fn().mockRejectedValueOnce(error).mockResolvedValue('success'); + + expect( + await retryTransientEsErrors(esCallMock, { + logger, + additionalRetryableStatusCodes: [customStatusCode], + }) + ).toEqual('success'); + expect(esCallMock).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn.mock.calls[0][0]).toMatch( + `Retrying Elasticsearch operation after [2s] due to error: ResponseError: Conflict Version` + ); + }); }); diff --git a/x-pack/platform/plugins/shared/alerting/server/alerts_service/lib/retry_transient_es_errors.ts b/x-pack/platform/plugins/shared/alerting/server/alerts_service/lib/retry_transient_es_errors.ts index 2df03e65690f1..04ae07078e944 100644 --- a/x-pack/platform/plugins/shared/alerting/server/alerts_service/lib/retry_transient_es_errors.ts +++ b/x-pack/platform/plugins/shared/alerting/server/alerts_service/lib/retry_transient_es_errors.ts @@ -16,11 +16,12 @@ const retryResponseStatuses = [ 410, // Gone ]; -const isRetryableError = (e: Error) => +const isRetryableError = (e: Error, additionalRetryableStatusCodes: number[]) => e instanceof EsErrors.NoLivingConnectionsError || e instanceof EsErrors.ConnectionError || e instanceof EsErrors.TimeoutError || - (e instanceof EsErrors.ResponseError && retryResponseStatuses.includes(e?.statusCode!)); + (e instanceof EsErrors.ResponseError && + [...retryResponseStatuses, ...additionalRetryableStatusCodes].includes(e?.statusCode!)); const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -29,15 +30,17 @@ export const retryTransientEsErrors = async ( { logger, attempt = 0, + additionalRetryableStatusCodes = [], }: { logger: Logger; attempt?: number; + additionalRetryableStatusCodes?: number[]; } ): Promise => { try { return await esCall(); } catch (e) { - if (attempt < MAX_ATTEMPTS && isRetryableError(e)) { + if (attempt < MAX_ATTEMPTS && isRetryableError(e, additionalRetryableStatusCodes)) { const retryCount = attempt + 1; const retryDelaySec: number = Math.min(Math.pow(2, retryCount), 30); // 2s, 4s, 8s, 16s, 30s, 30s, 30s... @@ -49,7 +52,11 @@ export const retryTransientEsErrors = async ( // delay with some randomness await delay(retryDelaySec * 1000 * Math.random()); - return retryTransientEsErrors(esCall, { logger, attempt: retryCount }); + return retryTransientEsErrors(esCall, { + logger, + attempt: retryCount, + additionalRetryableStatusCodes, + }); } throw e; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/delete/delete_backfill.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/delete/delete_backfill.test.ts index b0879613d069f..e102d0eb0d6a5 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/delete/delete_backfill.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/delete/delete_backfill.test.ts @@ -28,6 +28,11 @@ import { AD_HOC_RUN_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; import { SavedObject } from '@kbn/core-saved-objects-api-server'; import { AdHocRunSO } from '../../../../data/ad_hoc_run/types'; import { SavedObjectsErrorHelpers } from '@kbn/core/server'; +import { updateGaps } from '../../../../lib/rule_gaps/update/update_gaps'; + +jest.mock('../../../../lib/rule_gaps/update/update_gaps', () => ({ + updateGaps: jest.fn(), +})); const kibanaVersion = 'v8.0.0'; const taskManager = taskManagerMock.createStart(); @@ -159,7 +164,10 @@ describe('deleteBackfill()', () => { }); expect(unsecuredSavedObjectsClient.delete).toHaveBeenLastCalledWith( AD_HOC_RUN_SAVED_OBJECT_TYPE, - '1' + '1', + { + refresh: 'wait_for', + } ); expect(taskManager.removeIfExists).toHaveBeenCalledWith('1'); expect(logger.error).not.toHaveBeenCalled(); @@ -167,6 +175,35 @@ describe('deleteBackfill()', () => { expect(result).toEqual({}); }); + test('should call updateGaps with correct parameters when deleting backfill', async () => { + const mockEventLogClient = { mockEventLogClient: true } as any; + rulesClientParams.getEventLogClient.mockResolvedValue(mockEventLogClient); + + await rulesClient.deleteBackfill('1'); + + const updateGapsCall = (updateGaps as jest.Mock).mock.calls[0][0]; + expect(updateGapsCall.ruleId).toBe('abc'); + expect(updateGapsCall.start).toEqual(new Date('2023-10-19T15:07:40.011Z')); + expect(updateGapsCall.end).toBeInstanceOf(Date); + expect(updateGapsCall.backfillSchedule).toEqual([ + { + interval: '12h', + status: adHocRunStatus.PENDING, + runAt: '2023-10-20T03:07:40.011Z', + }, + { + interval: '12h', + status: adHocRunStatus.PENDING, + runAt: '2023-10-20T15:07:40.011Z', + }, + ]); + expect(updateGapsCall.savedObjectsRepository).toBe(internalSavedObjectsRepository); + expect(updateGapsCall.logger).toBe(logger); + expect(updateGapsCall.eventLogClient).toBe(mockEventLogClient); + expect(updateGapsCall.shouldRefetchAllBackfills).toBe(true); + expect(updateGapsCall.backfillClient).toBe(backfillClient); + }); + describe('error handling', () => { test('should retry if conflict error', async () => { unsecuredSavedObjectsClient.delete.mockImplementationOnce(() => { diff --git a/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/delete/delete_backfill.ts b/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/delete/delete_backfill.ts index d223f944305c7..9a13609dcdd72 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/delete/delete_backfill.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/delete/delete_backfill.ts @@ -15,6 +15,8 @@ import { AdHocRunAuditAction, adHocRunAuditEvent, } from '../../../../rules_client/common/audit_events'; +import { transformAdHocRunToBackfillResult } from '../../transforms'; +import { updateGaps } from '../../../../lib/rule_gaps/update/update_gaps'; export async function deleteBackfill(context: RulesClientContext, id: string): Promise<{}> { return await retryIfConflicts( @@ -82,12 +84,34 @@ async function deleteWithOCC(context: RulesClientContext, { id }: { id: string } }) ); + const backfillResult = transformAdHocRunToBackfillResult(result); + // delete the saved object const removeResult = await context.unsecuredSavedObjectsClient.delete( AD_HOC_RUN_SAVED_OBJECT_TYPE, - id + id, + { + refresh: 'wait_for', + } ); + if ('rule' in backfillResult) { + const eventLogClient = await context.getEventLogClient(); + + await updateGaps({ + ruleId: backfillResult.rule.id, + start: new Date(backfillResult.start), + end: backfillResult.end ? new Date(backfillResult.end) : new Date(), + backfillSchedule: backfillResult.schedule, + savedObjectsRepository: context.internalSavedObjectsRepository, + logger: context.logger, + eventLogClient, + eventLogger: context.eventLogger, + shouldRefetchAllBackfills: true, + backfillClient: context.backfillClient, + }); + } + // remove the associated task await context.taskManager.removeIfExists(id); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/schedule/schedule_backfill.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/schedule/schedule_backfill.test.ts index a0a79421c7358..54f36fed3c27a 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/schedule/schedule_backfill.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/schedule/schedule_backfill.test.ts @@ -24,6 +24,8 @@ import { fromKueryExpression } from '@kbn/es-query'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { asyncForEach } from '@kbn/std'; import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { eventLoggerMock } from '@kbn/event-log-plugin/server/event_logger.mock'; +import { eventLogClientMock } from '@kbn/event-log-plugin/server/event_log_client.mock'; import { ConstructorOptions, RulesClient } from '../../../../rules_client'; import { ScheduleBackfillParam } from './types'; import { adHocRunStatus } from '../../../../../common/constants'; @@ -39,6 +41,8 @@ const actionsAuthorization = actionsAuthorizationMock.create(); const auditLogger = auditLoggerMock.create(); const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); const backfillClient = backfillClientMock.create(); +const eventLogger = eventLoggerMock.create(); +const eventLogClient = eventLogClientMock.create(); const filter = fromKueryExpression( '((alert.attributes.alertTypeId:myType and alert.attributes.consumer:myApp) or (alert.attributes.alertTypeId:myOtherType and alert.attributes.consumer:myApp) or (alert.attributes.alertTypeId:myOtherType and alert.attributes.consumer:myOtherApp))' @@ -71,6 +75,7 @@ const rulesClientParams: jest.Mocked = { isSystemAction: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), uiSettings: uiSettingsServiceMock.createStartContract(), + eventLogger, }; const fakeRuleName = 'fakeRuleName'; @@ -249,6 +254,8 @@ describe('scheduleBackfill()', () => { test('should successfully schedule backfill', async () => { const mockData = [getMockData(), getMockData({ ruleId: '2', end: '2023-11-17T08:00:00.000Z' })]; + rulesClientParams.getEventLogClient.mockResolvedValue(eventLogClient); + const result = await rulesClient.scheduleBackfill(mockData); expect(authorization.getFindAuthorizationFilter).toHaveBeenCalledWith({ @@ -401,77 +408,82 @@ describe('scheduleBackfill()', () => { message: 'User has scheduled backfill for rule [id=2] [name=fakeRuleName]', }); - expect(backfillClient.bulkQueue).toHaveBeenCalledWith({ - auditLogger, - params: mockData, - ruleTypeRegistry, - unsecuredSavedObjectsClient, - spaceId: 'default', - rules: [ - { - id: existingDecryptedRule1.id, - legacyId: null, - actions: existingDecryptedRule1.attributes.actions, - alertTypeId: existingDecryptedRule1.attributes.alertTypeId, - apiKey: existingDecryptedRule1.attributes.apiKey, - apiKeyCreatedByUser: existingDecryptedRule1.attributes.apiKeyCreatedByUser, - consumer: existingDecryptedRule1.attributes.consumer, - createdAt: new Date(existingDecryptedRule1.attributes.createdAt), - createdBy: existingDecryptedRule1.attributes.createdBy, - enabled: true, - executionStatus: { - ...existingDecryptedRule1.attributes.executionStatus, - lastExecutionDate: new Date( - existingDecryptedRule1.attributes.executionStatus.lastExecutionDate - ), + expect(backfillClient.bulkQueue).toHaveBeenCalledWith( + expect.objectContaining({ + auditLogger, + params: mockData, + ruleTypeRegistry, + unsecuredSavedObjectsClient, + spaceId: 'default', + rules: [ + { + id: existingDecryptedRule1.id, + legacyId: null, + actions: existingDecryptedRule1.attributes.actions, + alertTypeId: existingDecryptedRule1.attributes.alertTypeId, + apiKey: existingDecryptedRule1.attributes.apiKey, + apiKeyCreatedByUser: existingDecryptedRule1.attributes.apiKeyCreatedByUser, + consumer: existingDecryptedRule1.attributes.consumer, + createdAt: new Date(existingDecryptedRule1.attributes.createdAt), + createdBy: existingDecryptedRule1.attributes.createdBy, + enabled: true, + executionStatus: { + ...existingDecryptedRule1.attributes.executionStatus, + lastExecutionDate: new Date( + existingDecryptedRule1.attributes.executionStatus.lastExecutionDate + ), + }, + muteAll: existingDecryptedRule1.attributes.muteAll, + mutedInstanceIds: existingDecryptedRule1.attributes.mutedInstanceIds, + name: existingDecryptedRule1.attributes.name, + notifyWhen: existingDecryptedRule1.attributes.notifyWhen, + params: existingDecryptedRule1.attributes.params, + revision: existingDecryptedRule1.attributes.revision, + schedule: existingDecryptedRule1.attributes.schedule, + scheduledTaskId: existingDecryptedRule1.attributes.scheduledTaskId, + snoozeSchedule: existingDecryptedRule1.attributes.snoozeSchedule, + systemActions: existingDecryptedRule1.attributes.systemActions, + tags: existingDecryptedRule1.attributes.tags, + throttle: existingDecryptedRule1.attributes.throttle, + updatedAt: new Date(existingDecryptedRule1.attributes.updatedAt), }, - muteAll: existingDecryptedRule1.attributes.muteAll, - mutedInstanceIds: existingDecryptedRule1.attributes.mutedInstanceIds, - name: existingDecryptedRule1.attributes.name, - notifyWhen: existingDecryptedRule1.attributes.notifyWhen, - params: existingDecryptedRule1.attributes.params, - revision: existingDecryptedRule1.attributes.revision, - schedule: existingDecryptedRule1.attributes.schedule, - scheduledTaskId: existingDecryptedRule1.attributes.scheduledTaskId, - snoozeSchedule: existingDecryptedRule1.attributes.snoozeSchedule, - systemActions: existingDecryptedRule1.attributes.systemActions, - tags: existingDecryptedRule1.attributes.tags, - throttle: existingDecryptedRule1.attributes.throttle, - updatedAt: new Date(existingDecryptedRule1.attributes.updatedAt), - }, - { - id: existingDecryptedRule2.id, - legacyId: null, - actions: existingDecryptedRule2.attributes.actions, - alertTypeId: existingDecryptedRule2.attributes.alertTypeId, - apiKey: existingDecryptedRule2.attributes.apiKey, - apiKeyCreatedByUser: existingDecryptedRule2.attributes.apiKeyCreatedByUser, - consumer: existingDecryptedRule2.attributes.consumer, - createdAt: new Date(existingDecryptedRule2.attributes.createdAt), - createdBy: existingDecryptedRule2.attributes.createdBy, - enabled: true, - executionStatus: { - ...existingDecryptedRule2.attributes.executionStatus, - lastExecutionDate: new Date( - existingDecryptedRule2.attributes.executionStatus.lastExecutionDate - ), + { + id: existingDecryptedRule2.id, + legacyId: null, + actions: existingDecryptedRule2.attributes.actions, + alertTypeId: existingDecryptedRule2.attributes.alertTypeId, + apiKey: existingDecryptedRule2.attributes.apiKey, + apiKeyCreatedByUser: existingDecryptedRule2.attributes.apiKeyCreatedByUser, + consumer: existingDecryptedRule2.attributes.consumer, + createdAt: new Date(existingDecryptedRule2.attributes.createdAt), + createdBy: existingDecryptedRule2.attributes.createdBy, + enabled: true, + executionStatus: { + ...existingDecryptedRule2.attributes.executionStatus, + lastExecutionDate: new Date( + existingDecryptedRule2.attributes.executionStatus.lastExecutionDate + ), + }, + muteAll: existingDecryptedRule2.attributes.muteAll, + mutedInstanceIds: existingDecryptedRule2.attributes.mutedInstanceIds, + name: existingDecryptedRule2.attributes.name, + notifyWhen: existingDecryptedRule2.attributes.notifyWhen, + params: existingDecryptedRule2.attributes.params, + revision: existingDecryptedRule2.attributes.revision, + schedule: existingDecryptedRule2.attributes.schedule, + scheduledTaskId: existingDecryptedRule2.attributes.scheduledTaskId, + snoozeSchedule: existingDecryptedRule2.attributes.snoozeSchedule, + systemActions: existingDecryptedRule2.attributes.systemActions, + tags: existingDecryptedRule2.attributes.tags, + throttle: existingDecryptedRule2.attributes.throttle, + updatedAt: new Date(existingDecryptedRule2.attributes.updatedAt), }, - muteAll: existingDecryptedRule2.attributes.muteAll, - mutedInstanceIds: existingDecryptedRule2.attributes.mutedInstanceIds, - name: existingDecryptedRule2.attributes.name, - notifyWhen: existingDecryptedRule2.attributes.notifyWhen, - params: existingDecryptedRule2.attributes.params, - revision: existingDecryptedRule2.attributes.revision, - schedule: existingDecryptedRule2.attributes.schedule, - scheduledTaskId: existingDecryptedRule2.attributes.scheduledTaskId, - snoozeSchedule: existingDecryptedRule2.attributes.snoozeSchedule, - systemActions: existingDecryptedRule2.attributes.systemActions, - tags: existingDecryptedRule2.attributes.tags, - throttle: existingDecryptedRule2.attributes.throttle, - updatedAt: new Date(existingDecryptedRule2.attributes.updatedAt), - }, - ], - }); + ], + eventLogClient, + internalSavedObjectsRepository, + eventLogger, + }) + ); expect(result).toEqual(mockBulkQueueResult); }); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/schedule/schedule_backfill.ts b/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/schedule/schedule_backfill.ts index 2dec1a78e3171..0f3decd6f1dde 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/schedule/schedule_backfill.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/backfill/methods/schedule/schedule_backfill.ts @@ -164,5 +164,8 @@ export async function scheduleBackfill( ruleTypeRegistry: context.ruleTypeRegistry, spaceId: context.spaceId, unsecuredSavedObjectsClient: context.unsecuredSavedObjectsClient, + eventLogClient: await context.getEventLogClient(), + internalSavedObjectsRepository: context.internalSavedObjectsRepository, + eventLogger: context.eventLogger, }); } diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/find_gaps/find_gaps.test.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/find_gaps/find_gaps.test.ts new file mode 100644 index 0000000000000..7ea2a14872225 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/find_gaps/find_gaps.test.ts @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { findGaps } from './find_gaps'; +import { getRule } from '../get/get_rule'; +import { loggerMock } from '@kbn/logging-mocks'; +import { eventLogClientMock } from '@kbn/event-log-plugin/server/event_log_client.mock'; +import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; +import { ReadOperations, AlertingAuthorizationEntity } from '../../../../authorization'; +import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; + +jest.mock('../get/get_rule'); +jest.mock('../../../../lib/rule_gaps/find_gaps'); + +const mockRule = { + id: '1', + name: 'Test Rule', + alertTypeId: 'test-type', + consumer: 'test-consumer', + enabled: true, + tags: [], + actions: [], + schedule: { interval: '1m' }, + createdAt: new Date(), + updatedAt: new Date(), + params: {}, + executionStatus: { + status: 'ok' as const, + lastExecutionDate: new Date(), + }, + notifyWhen: 'onActiveAlert' as const, + muteAll: false, + mutedInstanceIds: [], + updatedBy: null, + createdBy: null, + apiKeyOwner: null, + throttle: null, + legacyId: null, + revision: 1, +}; + +describe('findGaps', () => { + const mockedGetRule = getRule as jest.MockedFunction; + const auditLogger = auditLoggerMock.create(); + let context: any; + + const params = { + ruleId: '1', + page: 1, + perPage: 10, + start: '2024-01-01T00:00:00.000Z', + end: '2024-01-02T00:00:00.000Z', + }; + + beforeEach(() => { + context = { + authorization: { + ensureAuthorized: jest.fn(), + }, + logger: loggerMock.create(), + auditLogger, + getEventLogClient: jest.fn().mockResolvedValue(eventLogClientMock.create()), + }; + (auditLogger.log as jest.Mock).mockClear(); + mockedGetRule.mockResolvedValue(mockRule); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('authorization', () => { + it('should authorize and find gaps successfully', async () => { + await findGaps(context, params); + + expect(context.authorization.ensureAuthorized).toHaveBeenCalledWith({ + ruleTypeId: mockRule.alertTypeId, + consumer: mockRule.consumer, + operation: ReadOperations.FindGaps, + entity: AlertingAuthorizationEntity.Rule, + }); + }); + + it('should throw error when not authorized', async () => { + const authError = new Error('Unauthorized'); + context.authorization.ensureAuthorized.mockRejectedValue(authError); + + await expect(findGaps(context, params)).rejects.toThrow(authError); + }); + }); + + describe('auditLogger', () => { + it('logs audit event when finding gaps successfully', async () => { + await findGaps(context, params); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'rule_find_gaps', + outcome: 'success', + }), + kibana: { saved_object: { id: '1', type: RULE_SAVED_OBJECT_TYPE, name: 'Test Rule' } }, + }) + ); + }); + + it('logs audit event when not authorized to find gaps', async () => { + const authError = new Error('Unauthorized'); + context.authorization.ensureAuthorized.mockRejectedValue(authError); + + await expect(findGaps(context, params)).rejects.toThrow(authError); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'rule_find_gaps', + outcome: 'failure', + }), + kibana: { saved_object: { id: '1', type: RULE_SAVED_OBJECT_TYPE, name: 'Test Rule' } }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); + + describe('error handling', () => { + it('should handle and wrap errors from getRule', async () => { + const error = new Error('Rule not found'); + mockedGetRule.mockRejectedValue(error); + + await expect(findGaps(context, params)).rejects.toThrow('Failed to find gaps'); + expect(context.logger.error).toHaveBeenCalled(); + }); + + it('should handle errors from findGaps implementation', async () => { + const error = new Error('Failed to find gaps'); + context.getEventLogClient.mockRejectedValue(error); + + await expect(findGaps(context, params)).rejects.toThrow('Failed to find gaps'); + expect(context.logger.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/find_gaps/find_gaps.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/find_gaps/find_gaps.ts new file mode 100644 index 0000000000000..801b054577404 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/find_gaps/find_gaps.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; + +import { ReadOperations, AlertingAuthorizationEntity } from '../../../../authorization'; + +import { RulesClientContext } from '../../../../rules_client'; +import { findGaps as _findGaps } from '../../../../lib/rule_gaps/find_gaps'; +import { FindGapsParams } from '../../../../lib/rule_gaps/types'; +import { getRule } from '../get/get_rule'; +import { SanitizedRuleWithLegacyId } from '../../../../types'; +import { ruleAuditEvent, RuleAuditAction } from '../../../../rules_client/common/audit_events'; +import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; + +export async function findGaps(context: RulesClientContext, params: FindGapsParams) { + try { + const rule = (await getRule(context, { + id: params.ruleId, + includeLegacyId: true, + })) as SanitizedRuleWithLegacyId; + try { + // Make sure user has access to this rule + await context.authorization.ensureAuthorized({ + ruleTypeId: rule.alertTypeId, + consumer: rule.consumer, + operation: ReadOperations.FindGaps, + entity: AlertingAuthorizationEntity.Rule, + }); + } catch (error) { + context.auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.FIND_GAPS, + savedObject: { type: RULE_SAVED_OBJECT_TYPE, id: rule.id, name: rule.name }, + error, + }) + ); + throw error; + } + + const eventLogClient = await context.getEventLogClient(); + const gaps = await _findGaps({ + params, + eventLogClient, + logger: context.logger, + }); + + context.auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.FIND_GAPS, + savedObject: { type: RULE_SAVED_OBJECT_TYPE, id: rule.id, name: rule.name }, + }) + ); + + return gaps; + } catch (err) { + const errorMessage = `Failed to find gaps`; + context.logger.error(`${errorMessage} - ${err}`); + throw Boom.boomify(err, { message: errorMessage }); + } +} diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/find_gaps/index.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/find_gaps/index.ts new file mode 100644 index 0000000000000..54703c4abdcad --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/methods/find_gaps/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { findGaps } from './find_gaps'; diff --git a/x-pack/platform/plugins/shared/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.ts b/x-pack/platform/plugins/shared/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.ts index 3b4bc7d07900f..3d2f25691b086 100644 --- a/x-pack/platform/plugins/shared/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.ts +++ b/x-pack/platform/plugins/shared/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.ts @@ -26,9 +26,7 @@ const INITIAL_LAST_RUN_METRICS = { total_alerts_detected: null, total_alerts_created: null, gap_duration_s: null, - // TODO: should initialised field, after inermidiate release - // gap_range: null, - // unfilled_gaps_ms: null, + gap_range: null, }; const transformEsExecutionStatus = ( diff --git a/x-pack/platform/plugins/shared/alerting/server/authorization/types.ts b/x-pack/platform/plugins/shared/alerting/server/authorization/types.ts index 77357456c74b6..5950ed3f259ba 100644 --- a/x-pack/platform/plugins/shared/alerting/server/authorization/types.ts +++ b/x-pack/platform/plugins/shared/alerting/server/authorization/types.ts @@ -21,6 +21,7 @@ export enum ReadOperations { GetRuleExecutionKPI = 'getRuleExecutionKPI', GetBackfill = 'getBackfill', FindBackfill = 'findBackfill', + FindGaps = 'findGaps', } export enum WriteOperations { diff --git a/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.mock.ts b/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.mock.ts index 36919208b608d..2a4ea82eb54f8 100644 --- a/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.mock.ts +++ b/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.mock.ts @@ -9,6 +9,7 @@ const createBackfillClientMock = () => { return { bulkQueue: jest.fn(), deleteBackfillForRules: jest.fn(), + findOverlappingBackfills: jest.fn(), }; }); }; diff --git a/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.test.ts b/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.test.ts index 771f5a4db34b9..83e069ad05ecc 100644 --- a/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.test.ts @@ -8,7 +8,7 @@ import { adHocRunStatus } from '../../common/constants'; import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { SavedObject, SavedObjectsBulkResponse } from '@kbn/core/server'; -import { savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { savedObjectsClientMock, savedObjectsRepositoryMock } from '@kbn/core/server/mocks'; import { ScheduleBackfillParam } from '../application/backfill/methods/schedule/types'; import { RuleDomain } from '../application/rule/types'; import { ruleTypeRegistryMock } from '../rule_type_registry.mock'; @@ -22,6 +22,12 @@ import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; import { TaskRunnerFactory } from '../task_runner'; import { TaskPriority } from '@kbn/task-manager-plugin/server'; import { UntypedNormalizedRuleType } from '../rule_type_registry'; +import { eventLogClientMock, eventLoggerMock } from '@kbn/event-log-plugin/server/mocks'; +import { updateGaps } from '../lib/rule_gaps/update/update_gaps'; + +jest.mock('../lib/rule_gaps/update/update_gaps', () => ({ + updateGaps: jest.fn(), +})); const logger = loggingSystemMock.create().get(); const taskManagerSetup = taskManagerMock.createSetup(); @@ -29,6 +35,9 @@ const taskManagerStart = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); const auditLogger = auditLoggerMock.create(); +const eventLogClient = eventLogClientMock.create(); +const eventLogger = eventLoggerMock.create(); +const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); function getMockData(overwrites: Record = {}): ScheduleBackfillParam { return { @@ -173,9 +182,23 @@ function getBulkCreateParam( } const mockCreatePointInTimeFinderAsInternalUser = ( - response = { + response: { + saved_objects: Array<{ + id: string; + type: string; + attributes: AdHocRunSO; + references?: Array<{ id: string; name: string; type: string }>; + version?: string; + }>; + } = { saved_objects: [ - { id: 'abc', type: AD_HOC_RUN_SAVED_OBJECT_TYPE, attributes: getMockAdHocRunAttributes() }, + { + id: 'abc', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: getMockAdHocRunAttributes(), + references: [{ id: '1', name: 'rule', type: RULE_SAVED_OBJECT_TYPE }], + version: '1', + }, ], } ) => { @@ -189,7 +212,6 @@ const mockCreatePointInTimeFinderAsInternalUser = ( describe('BackfillClient', () => { let backfillClient: BackfillClient; - beforeAll(() => { jest.useFakeTimers().setSystemTime(new Date('2024-01-30T00:00:00.000Z')); }); @@ -277,6 +299,9 @@ describe('BackfillClient', () => { ruleTypeRegistry, spaceId: 'default', unsecuredSavedObjectsClient, + eventLogClient, + internalSavedObjectsRepository, + eventLogger, }); const bulkCreateParams = [ @@ -389,6 +414,9 @@ describe('BackfillClient', () => { ruleTypeRegistry, spaceId: 'default', unsecuredSavedObjectsClient, + eventLogClient, + internalSavedObjectsRepository, + eventLogger, }); const bulkCreateParams = [ @@ -482,6 +510,9 @@ describe('BackfillClient', () => { ruleTypeRegistry, spaceId: 'default', unsecuredSavedObjectsClient, + eventLogClient, + internalSavedObjectsRepository, + eventLogger, }); const bulkCreateParams = [ @@ -597,6 +628,9 @@ describe('BackfillClient', () => { ruleTypeRegistry, spaceId: 'default', unsecuredSavedObjectsClient, + eventLogClient, + internalSavedObjectsRepository, + eventLogger, }); expect(auditLogger.log).toHaveBeenCalledTimes(5); expect(auditLogger.log).toHaveBeenNthCalledWith(1, { @@ -745,6 +779,9 @@ describe('BackfillClient', () => { ruleTypeRegistry, spaceId: 'default', unsecuredSavedObjectsClient, + eventLogClient, + internalSavedObjectsRepository, + eventLogger, }); expect(unsecuredSavedObjectsClient.bulkCreate).not.toHaveBeenCalled(); @@ -847,6 +884,9 @@ describe('BackfillClient', () => { ruleTypeRegistry, spaceId: 'default', unsecuredSavedObjectsClient, + eventLogClient, + internalSavedObjectsRepository, + eventLogger, }); expect(taskManagerStart.bulkSchedule).not.toHaveBeenCalled(); @@ -884,6 +924,127 @@ describe('BackfillClient', () => { }, ]); }); + + test('should call updateGaps with correct params for each backfill', async () => { + const mockData = [getMockData(), getMockData({ ruleId: '2' })]; + const rule1 = getMockRule(); + const rule2 = getMockRule({ id: '2' }); + const mockRules = [rule1, rule2]; + + const mockAttributes = getMockAdHocRunAttributes(); + const bulkCreateResult = { + saved_objects: [ + getBulkCreateParam('abc', '1', mockAttributes), + getBulkCreateParam('def', '2', mockAttributes), + ], + }; + + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce(bulkCreateResult); + + await backfillClient.bulkQueue({ + auditLogger, + params: mockData, + rules: mockRules, + ruleTypeRegistry, + spaceId: 'default', + unsecuredSavedObjectsClient, + eventLogClient, + internalSavedObjectsRepository, + eventLogger, + }); + + expect(updateGaps).toHaveBeenCalledTimes(2); + expect(updateGaps).toHaveBeenNthCalledWith(1, { + backfillSchedule: mockAttributes.schedule, + ruleId: '1', + start: new Date(mockAttributes.start), + end: new Date(), + eventLogger, + eventLogClient, + savedObjectsRepository: internalSavedObjectsRepository, + logger, + backfillClient, + }); + expect(updateGaps).toHaveBeenNthCalledWith(2, { + backfillSchedule: mockAttributes.schedule, + ruleId: '2', + start: new Date(mockAttributes.start), + end: new Date(), + eventLogger, + eventLogClient, + savedObjectsRepository: internalSavedObjectsRepository, + logger, + backfillClient, + }); + }); + + test('should handle updateGaps errors gracefully', async () => { + const mockData = [getMockData()]; + const rule1 = getMockRule(); + const mockRules = [rule1]; + + const mockAttributes = getMockAdHocRunAttributes(); + const bulkCreateResult = { + saved_objects: [getBulkCreateParam('abc', '1', mockAttributes)], + }; + + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce(bulkCreateResult); + (updateGaps as jest.Mock).mockRejectedValueOnce(new Error('Failed to update gaps')); + + await backfillClient.bulkQueue({ + auditLogger, + params: mockData, + rules: mockRules, + ruleTypeRegistry, + spaceId: 'default', + unsecuredSavedObjectsClient, + eventLogClient, + internalSavedObjectsRepository, + eventLogger, + }); + + expect(logger.warn).toHaveBeenCalledWith('Error updating gaps for backfill jobs: abc'); + }); + + test('should call updateGaps with end date from backfill param if provided', async () => { + const endDate = '2024-02-01T00:00:00.000Z'; + const mockData = [getMockData({ end: endDate })]; + const rule1 = getMockRule(); + const mockRules = [rule1]; + + const mockAttributes = getMockAdHocRunAttributes({ + overwrites: { end: endDate }, + }); + const bulkCreateResult = { + saved_objects: [getBulkCreateParam('abc', '1', mockAttributes)], + }; + + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce(bulkCreateResult); + + await backfillClient.bulkQueue({ + auditLogger, + params: mockData, + rules: mockRules, + ruleTypeRegistry, + spaceId: 'default', + unsecuredSavedObjectsClient, + eventLogClient, + internalSavedObjectsRepository, + eventLogger, + }); + + expect(updateGaps).toHaveBeenCalledWith({ + backfillSchedule: mockAttributes.schedule, + ruleId: '1', + start: new Date(mockAttributes.start), + end: new Date(endDate), + eventLogger, + eventLogClient, + savedObjectsRepository: internalSavedObjectsRepository, + logger, + backfillClient, + }); + }); }); describe('deleteBackfillForRules()', () => { @@ -1190,4 +1351,97 @@ describe('BackfillClient', () => { ); }); }); + + describe('findOverlappingBackfills()', () => { + test('should find overlapping backfills', async () => { + const mockSavedObjectsRepository = savedObjectsRepositoryMock.create(); + const mockStart = new Date('2024-01-01T00:00:00.000Z'); + const mockEnd = new Date('2024-01-02T00:00:00.000Z'); + + mockCreatePointInTimeFinderAsInternalUser({ + saved_objects: [ + { + id: 'abc', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: getMockAdHocRunAttributes(), + references: [{ id: '1', name: 'rule', type: RULE_SAVED_OBJECT_TYPE }], + version: '1', + }, + { + id: 'def', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: getMockAdHocRunAttributes(), + references: [{ id: '1', name: 'rule', type: RULE_SAVED_OBJECT_TYPE }], + version: '1', + }, + ], + }); + + mockSavedObjectsRepository.createPointInTimeFinder = + unsecuredSavedObjectsClient.createPointInTimeFinder; + + const result = await backfillClient.findOverlappingBackfills({ + ruleId: '1', + start: mockStart, + end: mockEnd, + savedObjectsRepository: mockSavedObjectsRepository, + }); + + expect(mockSavedObjectsRepository.createPointInTimeFinder).toHaveBeenCalledWith({ + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + perPage: 100, + hasReference: [{ id: '1', type: RULE_SAVED_OBJECT_TYPE }], + filter: ` + ad_hoc_run_params.attributes.start <= "${mockEnd.toISOString()}" and + ad_hoc_run_params.attributes.end >= "${mockStart.toISOString()}" + `, + }); + + expect(result).toHaveLength(2); + expect('id' in result[0] && result[0].id).toBe('abc'); + expect('id' in result[1] && result[1].id).toBe('def'); + }); + + test('should handle errors and close finder', async () => { + const mockSavedObjectsRepository = savedObjectsRepositoryMock.create(); + const mockStart = new Date('2024-01-01T00:00:00.000Z'); + const mockEnd = new Date('2024-01-02T00:00:00.000Z'); + + mockSavedObjectsRepository.createPointInTimeFinder.mockResolvedValue({ + close: jest.fn(), + *find() { + throw new Error('Failed to find'); + }, + }); + + await expect( + backfillClient.findOverlappingBackfills({ + ruleId: '1', + start: mockStart, + end: mockEnd, + savedObjectsRepository: mockSavedObjectsRepository, + }) + ).rejects.toThrow('Failed to find'); + }); + + test('should return empty array when no overlapping backfills found', async () => { + const mockSavedObjectsRepository = savedObjectsRepositoryMock.create(); + const mockStart = new Date('2024-01-01T00:00:00.000Z'); + const mockEnd = new Date('2024-01-02T00:00:00.000Z'); + + mockCreatePointInTimeFinderAsInternalUser({ saved_objects: [] }); + + mockSavedObjectsRepository.createPointInTimeFinder = + unsecuredSavedObjectsClient.createPointInTimeFinder; + + const result = await backfillClient.findOverlappingBackfills({ + ruleId: '1', + start: mockStart, + end: mockEnd, + savedObjectsRepository: mockSavedObjectsRepository, + }); + + expect(result).toHaveLength(0); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.ts b/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.ts index 48b5e49c428c0..1747fd87e6814 100644 --- a/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.ts +++ b/x-pack/platform/plugins/shared/alerting/server/backfill_client/backfill_client.ts @@ -6,6 +6,7 @@ */ import { + ISavedObjectsRepository, Logger, SavedObject, SavedObjectReference, @@ -22,6 +23,7 @@ import { TaskManagerStartContract, TaskPriority, } from '@kbn/task-manager-plugin/server'; +import { IEventLogger, IEventLogClient } from '@kbn/event-log-plugin/server'; import { isNumber } from 'lodash'; import { ScheduleBackfillError, @@ -42,6 +44,7 @@ import { AD_HOC_RUN_SAVED_OBJECT_TYPE, RULE_SAVED_OBJECT_TYPE } from '../saved_o import { TaskRunnerFactory } from '../task_runner'; import { RuleTypeRegistry } from '../types'; import { createBackfillError } from './lib'; +import { updateGaps } from '../lib/rule_gaps/update/update_gaps'; export const BACKFILL_TASK_TYPE = 'ad_hoc_run-backfill'; @@ -59,6 +62,9 @@ interface BulkQueueOpts { ruleTypeRegistry: RuleTypeRegistry; spaceId: string; unsecuredSavedObjectsClient: SavedObjectsClientContract; + eventLogClient: IEventLogClient; + internalSavedObjectsRepository: ISavedObjectsRepository; + eventLogger: IEventLogger | undefined; } interface DeleteBackfillForRulesOpts { @@ -92,6 +98,9 @@ export class BackfillClient { ruleTypeRegistry, spaceId, unsecuredSavedObjectsClient, + eventLogClient, + internalSavedObjectsRepository, + eventLogger, }: BulkQueueOpts): Promise { const adHocSOsToCreate: Array> = []; @@ -211,10 +220,11 @@ export class BackfillClient { // Build array of tasks to schedule const adHocTasksToSchedule: TaskInstance[] = []; + const backfullsToSchedule: Backfill[] = []; createSOResult.forEach((result: ScheduleBackfillResult) => { if (!(result as ScheduleBackfillError).error) { const createdSO = result as Backfill; - + backfullsToSchedule.push(createdSO); const ruleTypeTimeout = ruleTypeRegistry.get(createdSO.rule.alertTypeId).ruleTaskTimeout; adHocTasksToSchedule.push({ id: createdSO.id, @@ -229,6 +239,28 @@ export class BackfillClient { } }); + try { + for (const backfill of backfullsToSchedule) { + await updateGaps({ + backfillSchedule: backfill.schedule, + ruleId: backfill.rule.id, + start: new Date(backfill.start), + end: backfill?.end ? new Date(backfill.end) : new Date(), + eventLogger, + eventLogClient, + savedObjectsRepository: internalSavedObjectsRepository, + logger: this.logger, + backfillClient: this, + }); + } + } catch { + this.logger.warn( + `Error updating gaps for backfill jobs: ${backfullsToSchedule + .map((backfill) => backfill.id) + .join(', ')}` + ); + } + if (adHocTasksToSchedule.length > 0) { const taskManager = await this.taskManagerStartPromise; await taskManager.bulkSchedule(adHocTasksToSchedule); @@ -299,6 +331,43 @@ export class BackfillClient { ); } } + + public async findOverlappingBackfills({ + ruleId, + start, + end, + savedObjectsRepository, + }: { + ruleId: string; + start: Date; + end: Date; + savedObjectsRepository: ISavedObjectsRepository; + }) { + const adHocRuns: Array> = []; + + // Create a point in time finder for efficient pagination + const adHocRunFinder = await savedObjectsRepository.createPointInTimeFinder({ + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + perPage: 100, + hasReference: [{ id: ruleId, type: RULE_SAVED_OBJECT_TYPE }], + filter: ` + ad_hoc_run_params.attributes.start <= "${end.toISOString()}" and + ad_hoc_run_params.attributes.end >= "${start.toISOString()}" + `, + }); + + try { + // Collect all results using async iterator + for await (const response of adHocRunFinder.find()) { + adHocRuns.push(...response.saved_objects); + } + } finally { + // Make sure we always close the finder + await adHocRunFinder.close(); + } + + return adHocRuns.map((data) => transformAdHocRunToBackfillResult(data)); + } } function getRuleOrError( diff --git a/x-pack/platform/plugins/shared/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts b/x-pack/platform/plugins/shared/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts index da9f54567d886..1f118229c02f5 100644 --- a/x-pack/platform/plugins/shared/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts @@ -6,7 +6,7 @@ */ import { eventLoggerMock } from '@kbn/event-log-plugin/server/event_logger.mock'; -import { IEvent, SAVED_OBJECT_REL_PRIMARY } from '@kbn/event-log-plugin/server'; +import { IEvent, SAVED_OBJECT_REL_PRIMARY, InternalFields } from '@kbn/event-log-plugin/server'; import { ActionsCompletion } from '@kbn/alerting-state-types'; import { AlertingEventLogger, @@ -36,6 +36,7 @@ import { TaskRunnerTimerSpan } from '../../task_runner/task_runner_timer'; import { schema } from '@kbn/config-schema'; import { RULE_SAVED_OBJECT_TYPE } from '../..'; import { AD_HOC_RUN_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { GapBase } from '../rule_gaps/types'; const mockNow = '2020-01-01T02:00:00.000Z'; const eventLogger = eventLoggerMock.create(); @@ -1476,14 +1477,66 @@ describe('AlertingEventLogger', () => { }; alertingEventLogger.reportGap({ gap: range }); - const event = createGapRecord(ruleContext, ruleData, [alertSO], { - status: 'unfilled', + const gap: GapBase = { + status: 'unfilled' as const, range, - }); + filled_intervals: [], + unfilled_intervals: [range], + in_progress_intervals: [], + total_gap_duration_ms: 3600000, + filled_duration_ms: 0, + unfilled_duration_ms: 3600000, + in_progress_duration_ms: 0, + }; + const event = createGapRecord(ruleContext, ruleData, [alertSO], gap); expect(eventLogger.logEvent).toHaveBeenCalledWith(event); }); }); + + describe('updateGap()', () => { + const mockInternalFields: InternalFields = { + _id: 'test-id', + _index: 'test-index', + _seq_no: 1, + _primary_term: 1, + }; + + const mockGap: GapBase = { + status: 'filled' as const, + range: { + gte: '2022-05-05T15:59:54.480Z', + lte: '2022-05-05T16:59:54.480Z', + }, + filled_intervals: [ + { + gte: '2022-05-05T15:59:54.480Z', + lte: '2022-05-05T16:59:54.480Z', + }, + ], + unfilled_intervals: [], + in_progress_intervals: [], + total_gap_duration_ms: 3600000, + filled_duration_ms: 3600000, + unfilled_duration_ms: 0, + in_progress_duration_ms: 0, + }; + + test('should call eventLogger.updateEvent with correct parameters', async () => { + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); + await alertingEventLogger.updateGap({ internalFields: mockInternalFields, gap: mockGap }); + + expect(eventLogger.updateEvent).toHaveBeenCalledWith(mockInternalFields, { + kibana: { + alert: { + rule: { + gap: mockGap, + }, + }, + }, + }); + }); + }); }); describe('helper functions', () => { diff --git a/x-pack/platform/plugins/shared/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts b/x-pack/platform/plugins/shared/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts index 90749ebb0bb88..26304687843c2 100644 --- a/x-pack/platform/plugins/shared/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts +++ b/x-pack/platform/plugins/shared/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts @@ -10,6 +10,7 @@ import { IEventLogger, millisToNanos, SAVED_OBJECT_REL_PRIMARY, + InternalFields, } from '@kbn/event-log-plugin/server'; import { EVENT_LOG_ACTIONS } from '../../plugin'; import { UntypedNormalizedRuleType } from '../../rule_type_registry'; @@ -18,6 +19,8 @@ import { TaskRunnerTimings } from '../../task_runner/task_runner_timer'; import { AlertInstanceState, RuleExecutionStatus } from '../../types'; import { createAlertEventLogRecordObject } from '../create_alert_event_log_record_object'; import { RuleRunMetrics } from '../rule_run_metrics_store'; +import { Gap } from '../rule_gaps/gap'; +import { GapBase } from '../rule_gaps/types'; // 1,000,000 nanoseconds in 1 millisecond const Millis2Nanos = 1000 * 1000; @@ -408,13 +411,37 @@ export class AlertingEventLogger { throw new Error('AlertingEventLogger not initialized'); } + const gapToReport = new Gap({ + range: gap, + }); + this.eventLogger.logEvent( - createGapRecord(this.context, this.ruleData, this.relatedSavedObjects, { - status: 'unfilled', - range: gap, - }) + createGapRecord( + this.context, + this.ruleData, + this.relatedSavedObjects, + gapToReport.getEsObject() + ) ); } + + public async updateGap({ + internalFields, + gap, + }: { + internalFields: InternalFields; + gap: GapBase; + }): Promise { + return this.eventLogger.updateEvent(internalFields, { + kibana: { + alert: { + rule: { + gap, + }, + }, + }, + }); + } } export function createAlertRecord( diff --git a/x-pack/platform/plugins/shared/alerting/server/lib/monitoring.ts b/x-pack/platform/plugins/shared/alerting/server/lib/monitoring.ts index b25592b88bdb0..cd0b300f8f8eb 100644 --- a/x-pack/platform/plugins/shared/alerting/server/lib/monitoring.ts +++ b/x-pack/platform/plugins/shared/alerting/server/lib/monitoring.ts @@ -21,8 +21,7 @@ const INITIAL_LAST_RUN_METRICS: RuleMonitoringLastRunMetrics = { total_alerts_detected: null, total_alerts_created: null, gap_duration_s: null, - // TODO: should initialised field, after inermidiate release - // gap_range: null, + gap_range: null, }; export const getDefaultMonitoring = (timestamp: string): RawRuleMonitoring => { diff --git a/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/find_gaps.test.ts b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/find_gaps.test.ts new file mode 100644 index 0000000000000..9a00c00d1bfc7 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/find_gaps.test.ts @@ -0,0 +1,316 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { buildGapsFilter, findGaps, findAllGaps } from './find_gaps'; +import { gapStatus } from '../../../common/constants/gap_status'; +import { loggerMock } from '@kbn/logging-mocks'; +import { eventLogClientMock } from '@kbn/event-log-plugin/server/event_log_client.mock'; +import { Gap } from './gap'; + +const createMockGapEvent = () => ({ + _id: 'test-id', + _index: 'test-index', + _seq_no: 1, + _primary_term: 1, + '@timestamp': '2024-01-01T00:00:00.000Z', + event: { + start: '2024-01-01', + end: '2024-01-02', + action: 'gap', + }, + kibana: { + alert: { + rule: { + gap: { + range: { gte: '2024-01-01', lte: '2024-01-02' }, + filled_intervals: [], + in_progress_intervals: [], + }, + }, + }, + }, +}); + +describe('buildGapsFilter', () => { + it('should build base filter when no params provided', () => { + expect(buildGapsFilter({})).toBe('event.action: gap AND event.provider: alerting'); + }); + + it('should build filter with range', () => { + expect(buildGapsFilter({ start: '2024-01-01', end: '2024-01-02' })).toBe( + 'event.action: gap AND event.provider: alerting AND ' + + 'kibana.alert.rule.gap.range <= "2024-01-02" AND kibana.alert.rule.gap.range >= "2024-01-01"' + ); + }); + + it('should build filter with statuses', () => { + expect(buildGapsFilter({ statuses: [gapStatus.UNFILLED] })).toBe( + 'event.action: gap AND event.provider: alerting AND ' + + '(kibana.alert.rule.gap.status : unfilled)' + ); + }); + + it('should build filter with range and statuses', () => { + expect( + buildGapsFilter({ + start: '2024-01-01', + end: '2024-01-02', + statuses: [gapStatus.UNFILLED, gapStatus.PARTIALLY_FILLED], + }) + ).toBe( + 'event.action: gap AND event.provider: alerting AND ' + + 'kibana.alert.rule.gap.range <= "2024-01-02" AND kibana.alert.rule.gap.range >= "2024-01-01" AND ' + + '(kibana.alert.rule.gap.status : unfilled OR kibana.alert.rule.gap.status : partially_filled)' + ); + }); +}); + +describe('findGaps', () => { + const mockLogger = loggerMock.create(); + const mockEventLogClient = eventLogClientMock.create(); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should call findEventsBySavedObjectIds with correct parameters', async () => { + mockEventLogClient.findEventsBySavedObjectIds.mockResolvedValue({ + total: 0, + data: [], + page: 1, + per_page: 10, + }); + + await findGaps({ + eventLogClient: mockEventLogClient, + logger: mockLogger, + params: { + ruleId: 'test-rule', + start: '2024-01-01', + end: '2024-01-02', + page: 1, + perPage: 10, + statuses: [gapStatus.UNFILLED], + }, + }); + + expect(mockEventLogClient.findEventsBySavedObjectIds).toHaveBeenCalledWith( + 'alert', + ['test-rule'], + expect.objectContaining({ + filter: expect.stringContaining('event.action: gap'), + sort: [{ sort_field: '@timestamp', sort_order: 'desc' }], + page: 1, + per_page: 10, + }) + ); + }); + + it('should handle custom sort field', async () => { + mockEventLogClient.findEventsBySavedObjectIds.mockResolvedValue({ + total: 0, + data: [], + page: 1, + per_page: 10, + }); + + await findGaps({ + eventLogClient: mockEventLogClient, + logger: mockLogger, + params: { + ruleId: 'test-rule', + page: 1, + perPage: 10, + sortField: 'total_gap_duration_ms', + sortOrder: 'asc', + }, + }); + + expect(mockEventLogClient.findEventsBySavedObjectIds).toHaveBeenCalledWith( + 'alert', + ['test-rule'], + expect.objectContaining({ + sort: [{ sort_field: 'kibana.alert.rule.gap.total_gap_duration_ms', sort_order: 'asc' }], + }) + ); + }); + + it('should transform response data to Gap objects', async () => { + const mockResponse = { + total: 1, + data: [createMockGapEvent()], + page: 1, + per_page: 10, + }; + + mockEventLogClient.findEventsBySavedObjectIds.mockResolvedValue(mockResponse); + + const result = await findGaps({ + eventLogClient: mockEventLogClient, + logger: mockLogger, + params: { ruleId: 'test-rule', page: 1, perPage: 10 }, + }); + + expect(result.data[0]).toBeInstanceOf(Gap); + expect(result.data[0].range).toEqual({ + gte: new Date('2024-01-01'), + lte: new Date('2024-01-02'), + }); + expect(result.data[0].filledIntervals).toEqual([]); + expect(result.data[0].inProgressIntervals).toEqual([]); + }); + + it('should handle errors and log them', async () => { + const error = new Error('Test error'); + mockEventLogClient.findEventsBySavedObjectIds.mockRejectedValue(error); + + await expect( + findGaps({ + eventLogClient: mockEventLogClient, + logger: mockLogger, + params: { ruleId: 'test-rule', page: 1, perPage: 10 }, + }) + ).rejects.toThrow(error); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to find gaps for rule test-rule') + ); + }); +}); + +describe('findAllGaps', () => { + const mockLogger = loggerMock.create(); + const mockEventLogClient = eventLogClientMock.create(); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should fetch all pages of gaps', async () => { + mockEventLogClient.findEventsBySavedObjectIds + .mockResolvedValueOnce({ + total: 15000, + data: Array(10000).fill(createMockGapEvent()), + page: 1, + per_page: 10000, + }) + .mockResolvedValueOnce({ + total: 15000, + data: Array(5000).fill(createMockGapEvent()), + page: 2, + per_page: 10000, + }); + + const result = await findAllGaps({ + eventLogClient: mockEventLogClient, + logger: mockLogger, + params: { + ruleId: 'test-rule', + start: new Date('2024-01-01'), + end: new Date('2024-01-02'), + }, + }); + + expect(result).toHaveLength(15000); + expect(mockEventLogClient.findEventsBySavedObjectIds).toHaveBeenCalledTimes(2); + }); + + it('should stop fetching when no more data', async () => { + const createMockGapEvent = () => ({ + _id: 'test-id', + _index: 'test-index', + _seq_no: 1, + _primary_term: 1, + '@timestamp': '2024-01-01T00:00:00.000Z', + event: { + start: '2024-01-01', + end: '2024-01-02', + action: 'gap', + }, + kibana: { + alert: { + rule: { + gap: { + range: { gte: '2024-01-01', lte: '2024-01-02' }, + filled_intervals: [], + in_progress_intervals: [], + }, + }, + }, + }, + }); + + mockEventLogClient.findEventsBySavedObjectIds.mockResolvedValue({ + total: 50, + data: Array(50).fill(createMockGapEvent()), + page: 1, + per_page: 10000, + }); + + const result = await findAllGaps({ + eventLogClient: mockEventLogClient, + logger: mockLogger, + params: { + ruleId: 'test-rule', + start: new Date('2024-01-01'), + end: new Date('2024-01-02'), + }, + }); + + expect(result).toHaveLength(50); + expect(mockEventLogClient.findEventsBySavedObjectIds).toHaveBeenCalledTimes(1); + }); + + it('should handle errors during pagination', async () => { + const createMockGapEvent = () => ({ + _id: 'test-id', + _index: 'test-index', + _seq_no: 1, + _primary_term: 1, + '@timestamp': '2024-01-01T00:00:00.000Z', + event: { + start: '2024-01-01', + end: '2024-01-02', + action: 'gap', + }, + kibana: { + alert: { + rule: { + gap: { + range: { gte: '2024-01-01', lte: '2024-01-02' }, + filled_intervals: [], + in_progress_intervals: [], + }, + }, + }, + }, + }); + + mockEventLogClient.findEventsBySavedObjectIds + .mockResolvedValueOnce({ + total: 15000, + data: Array(10000).fill(createMockGapEvent()), + page: 1, + per_page: 10000, + }) + .mockRejectedValueOnce(new Error('Pagination failed')); + + await expect( + findAllGaps({ + eventLogClient: mockEventLogClient, + logger: mockLogger, + params: { + ruleId: 'test-rule', + start: new Date('2024-01-01'), + end: new Date('2024-01-02'), + }, + }) + ).rejects.toThrow('Pagination failed'); + + expect(mockLogger.error).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/find_gaps.ts b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/find_gaps.ts new file mode 100644 index 0000000000000..e189c75674bc3 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/find_gaps.ts @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IEventLogClient } from '@kbn/event-log-plugin/server'; +import { Logger } from '@kbn/core/server'; +import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { FindGapsParams } from './types'; +import { Gap } from './gap'; +import { transformToGap } from './transforms/transform_to_gap'; +import { GapStatus } from '../../../common/constants/gap_status'; + +export const buildGapsFilter = ({ + start, + end, + statuses, +}: { + start?: string; + end?: string; + statuses?: GapStatus[]; +}) => { + const baseFilter = 'event.action: gap AND event.provider: alerting'; + + const rangeFilter = + end && start + ? `kibana.alert.rule.gap.range <= "${end}" AND kibana.alert.rule.gap.range >= "${start}"` + : null; + + const statusesFilter = statuses?.length + ? `(${statuses.map((status) => `kibana.alert.rule.gap.status : ${status}`).join(' OR ')})` + : null; + + return [baseFilter, rangeFilter, statusesFilter].filter(Boolean).join(' AND '); +}; + +export const findGaps = async ({ + eventLogClient, + logger, + params, +}: { + eventLogClient: IEventLogClient; + logger: Logger; + params: FindGapsParams; +}): Promise<{ + total: number; + data: Gap[]; + page: number; + perPage: number; +}> => { + const { ruleId, start, end, page, perPage, statuses, sortField, sortOrder } = params; + + try { + const filter = buildGapsFilter({ start, end, statuses }); + + const getField = (field?: string) => { + if (field === '@timestamp' || !field) { + return '@timestamp'; + } + return `kibana.alert.rule.gap.${field}`; + }; + + const gapsResponse = await eventLogClient.findEventsBySavedObjectIds( + RULE_SAVED_OBJECT_TYPE, + [ruleId], + { + filter, + sort: [ + { + sort_field: getField(sortField), + sort_order: sortOrder ?? 'desc', + }, + ], + page, + per_page: perPage, + } + ); + + return { + total: gapsResponse.total, + data: transformToGap(gapsResponse), + page: gapsResponse.page, + perPage: gapsResponse.per_page, + }; + } catch (err) { + logger.error(`Failed to find gaps for rule ${ruleId}: ${err.message}`); + throw err; + } +}; + +export const findAllGaps = async ({ + eventLogClient, + logger, + params, +}: { + eventLogClient: IEventLogClient; + logger: Logger; + params: { + ruleId: string; + start: Date; + end: Date; + statuses?: GapStatus[]; + }; +}): Promise => { + const { ruleId, start, end, statuses } = params; + const allGaps: Gap[] = []; + let currentPage = 1; + const perPage = 10000; + + while (true) { + const { data } = await findGaps({ + eventLogClient, + logger, + params: { + ruleId, + start: start.toISOString(), + end: end.toISOString(), + page: currentPage, + perPage, + statuses, + }, + }); + + allGaps.push(...data); + + if (data.length === 0 || data.length < perPage) { + break; + } + + currentPage++; + } + + return allGaps; +}; diff --git a/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/gap/index.test.ts b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/gap/index.test.ts new file mode 100644 index 0000000000000..1c13a40de75b9 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/gap/index.test.ts @@ -0,0 +1,218 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Gap } from '.'; // Adjust this import path as needed +import { gapStatus } from '../../../../common/constants'; // Adjust as needed +import { Interval, StringInterval } from '../types'; // Adjust as needed + +// Helper function to create Interval objects from ISO strings +function toInterval(gte: string, lte: string): Interval { + return { + gte: new Date(gte), + lte: new Date(lte), + }; +} + +describe('Gap Class Tests', () => { + const HOUR_MS = 60 * 60 * 1000; + const baseRange: StringInterval = { + gte: '2024-01-01T00:00:00.000Z', + lte: '2024-01-01T01:00:00.000Z', + }; + + it('initializes with no filled or in-progress intervals', () => { + const gap = new Gap({ range: baseRange }); + expect(gap.status).toBe(gapStatus.UNFILLED); + expect(gap.filledIntervals).toHaveLength(0); + expect(gap.inProgressIntervals).toHaveLength(0); + expect(gap.unfilledIntervals).toHaveLength(1); + expect(gap.unfilledIntervals[0].gte.getTime()).toBe(new Date(baseRange.gte).getTime()); + expect(gap.unfilledIntervals[0].lte.getTime()).toBe(new Date(baseRange.lte).getTime()); + expect(gap.totalGapDurationMs).toBe(HOUR_MS); + expect(gap.unfilledGapDurationMs).toBe(HOUR_MS); + }); + + it('initializes fully filled gap', () => { + const gap = new Gap({ range: baseRange, filledIntervals: [baseRange] }); + expect(gap.filledIntervals).toHaveLength(1); + expect(gap.filledGapDurationMs).toBe(HOUR_MS); + expect(gap.unfilledGapDurationMs).toBe(0); + expect(gap.inProgressIntervals).toHaveLength(0); + expect(gap.status).toBe(gapStatus.FILLED); + }); + + it('initializes partially filled', () => { + const partialFill: StringInterval = { + gte: '2024-01-01T00:15:00.000Z', + lte: '2024-01-01T00:30:00.000Z', + }; + const gap = new Gap({ range: baseRange, filledIntervals: [partialFill] }); + const filledDuration = (30 - 15) * 60 * 1000; // 15 min + + expect(gap.filledGapDurationMs).toBe(filledDuration); + expect(gap.unfilledGapDurationMs).toBe(HOUR_MS - filledDuration); + expect(gap.inProgressGapDurationMs).toBe(0); + expect(gap.status).toBe(gapStatus.PARTIALLY_FILLED); + }); + + it('initializes with in-progress intervals only', () => { + const inProgress: StringInterval = { + gte: '2024-01-01T00:40:00.000Z', + lte: '2024-01-01T00:50:00.000Z', + }; + const gap = new Gap({ range: baseRange, inProgressIntervals: [inProgress] }); + + const inProgressDuration = (50 - 40) * 60 * 1000; // 10 min + expect(gap.inProgressGapDurationMs).toBe(inProgressDuration); + expect(gap.filledGapDurationMs).toBe(0); + expect(gap.unfilledGapDurationMs).toBe(HOUR_MS - inProgressDuration); + expect(gap.status).toBe(gapStatus.PARTIALLY_FILLED); + }); + + it('handles intervals that extend beyond the range', () => { + const extendedInterval: Interval = { + gte: new Date('2023-12-31T23:50:00.000Z'), + lte: new Date('2024-01-01T01:10:00.000Z'), + }; + const gap = new Gap({ range: baseRange }); + gap.fillGap(extendedInterval); + // Extended interval should effectively fill only the baseRange + expect(gap.filledGapDurationMs).toBe(HOUR_MS); + expect(gap.status).toBe(gapStatus.FILLED); + }); + + it('filling the gap after initialization updates the state', () => { + const gap = new Gap({ range: baseRange }); + const oneMinute = toInterval('2024-01-01T00:00:00.000Z', '2024-01-01T00:01:00.000Z'); + + gap.fillGap(oneMinute); + expect(gap.filledGapDurationMs).toBe(60 * 1000); + expect(gap.unfilledGapDurationMs).toBe(HOUR_MS - 60 * 1000); + expect(gap.status).toBe(gapStatus.PARTIALLY_FILLED); + }); + + it('addInProgress intervals do not overlap with already filled', () => { + const filled: StringInterval = { + gte: '2024-01-01T00:00:00.000Z', + lte: '2024-01-01T00:10:00.000Z', + }; + const gap = new Gap({ range: baseRange, filledIntervals: [filled] }); + const filledDuration = 10 * 60 * 1000; + + // Add in-progress that overlaps from 00:05 - 00:15 + const inProgressOverlap = toInterval('2024-01-01T00:05:00.000Z', '2024-01-01T00:15:00.000Z'); + gap.addInProgress(inProgressOverlap); + + // Should adjust to only non-filled portion: 00:10 - 00:15 (5 min) + expect(gap.inProgressIntervals).toHaveLength(1); + expect(gap.inProgressGapDurationMs).toBe(5 * 60 * 1000); + expect(gap.filledGapDurationMs).toBe(filledDuration); + expect(gap.status).toBe(gapStatus.PARTIALLY_FILLED); + }); + + it('subsequent fill operations remove portions of in-progress intervals', () => { + const gap = new Gap({ range: baseRange }); + // Add in-progress for 00:20 - 00:30 + gap.addInProgress(toInterval('2024-01-01T00:20:00.000Z', '2024-01-01T00:30:00.000Z')); + expect(gap.inProgressGapDurationMs).toBe(10 * 60 * 1000); + + // Fill 5 min of that (00:25 - 00:30) + gap.fillGap(toInterval('2024-01-01T00:25:00.000Z', '2024-01-01T00:30:00.000Z')); + expect(gap.filledGapDurationMs).toBe(5 * 60 * 1000); + // In-progress now only 00:20 - 00:25 + expect(gap.inProgressGapDurationMs).toBe(5 * 60 * 1000); + expect(gap.status).toBe(gapStatus.PARTIALLY_FILLED); + }); + + it('filling entire gap in multiple steps leads to FILLED status', () => { + const gap = new Gap({ range: baseRange }); + + // Fill first half + gap.fillGap(toInterval('2024-01-01T00:00:00.000Z', '2024-01-01T00:30:00.000Z')); + expect(gap.status).toBe(gapStatus.PARTIALLY_FILLED); + + // Fill second half + gap.fillGap(toInterval('2024-01-01T00:30:00.000Z', '2024-01-01T01:00:00.000Z')); + expect(gap.status).toBe(gapStatus.FILLED); + expect(gap.filledGapDurationMs).toBe(HOUR_MS); + }); + + it('returns correct state via getState()', () => { + const filled: StringInterval = { + gte: '2024-01-01T00:10:00.000Z', + lte: '2024-01-01T00:20:00.000Z', + }; + const inProgress: StringInterval = { + gte: '2024-01-01T00:20:00.000Z', + lte: '2024-01-01T00:30:00.000Z', + }; + const gap = new Gap({ + range: baseRange, + filledIntervals: [filled], + inProgressIntervals: [inProgress], + }); + + const state = gap.getState(); + expect(state.range).toEqual(baseRange); + expect(state.filledIntervals).toHaveLength(1); + expect(state.inProgressIntervals).toHaveLength(1); + expect(state.unfilledIntervals).not.toHaveLength(0); + expect(state.status).toBe(gapStatus.PARTIALLY_FILLED); + expect(state.totalGapDurationMs).toBe(HOUR_MS); + }); + + it('returns correct ES object via getEsObject()', () => { + const filled: StringInterval = { + gte: '2024-01-01T00:10:00.000Z', + lte: '2024-01-01T00:20:00.000Z', + }; + const gap = new Gap({ range: baseRange, filledIntervals: [filled] }); + + const esObject = gap.getEsObject(); + expect(esObject.range).toEqual(baseRange); + expect(esObject.filled_intervals).toHaveLength(1); + expect(esObject.in_progress_intervals).toHaveLength(0); + expect(esObject.unfilled_intervals).toHaveLength(2); + expect(esObject.status).toBe(gapStatus.PARTIALLY_FILLED); + expect(esObject.total_gap_duration_ms).toBe(HOUR_MS); + expect(esObject.filled_duration_ms).toBe(10 * 60 * 1000); + }); + + it('returns correct ES object via getEsObject() after filling', () => { + const gap = new Gap({ range: baseRange }); + + gap.addInProgress({ + gte: new Date('2024-01-01T00:10:00.000Z'), + lte: new Date('2024-01-01T00:20:00.000Z'), + }); + gap.fillGap({ + gte: new Date('2024-01-01T00:40:00.000Z'), + lte: new Date('2024-01-01T00:50:00.000Z'), + }); + + const esObject = gap.getEsObject(); + expect(esObject.range).toEqual(baseRange); + expect(esObject.filled_intervals).toHaveLength(1); + expect(esObject.in_progress_intervals).toHaveLength(1); + expect(esObject.unfilled_intervals).toHaveLength(3); + expect(esObject.status).toBe(gapStatus.PARTIALLY_FILLED); + expect(esObject.total_gap_duration_ms).toBe(HOUR_MS); + expect(esObject.in_progress_duration_ms).toBe(10 * 60 * 1000); + expect(esObject.filled_duration_ms).toBe(10 * 60 * 1000); + }); + + it('handles internalFields information if provided', () => { + const internalFields = { + _id: 'test_id', + _index: 'test_index', + _seq_no: 1, + _primary_term: 1, + }; + const gap = new Gap({ range: baseRange, internalFields }); + expect(gap.internalFields).toEqual(internalFields); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/gap/index.ts b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/gap/index.ts new file mode 100644 index 0000000000000..766c7dc734c79 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/gap/index.ts @@ -0,0 +1,168 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { InternalFields } from '@kbn/event-log-plugin/server/es/cluster_client_adapter'; +import { GapStatus, gapStatus } from '../../../../common/constants'; + +import { Interval, StringInterval, GapBase } from '../types'; + +import { + mergeIntervals, + subtractAllIntervals, + sumIntervalsDuration, + intervalDuration, + subtractIntervals, + normalizeInterval, + denormalizeInterval, + clipInterval, +} from './interval_utils'; + +interface GapConstructorParams { + timestamp?: string; + range: StringInterval; + filledIntervals?: StringInterval[]; + inProgressIntervals?: StringInterval[]; + internalFields?: InternalFields; +} + +export class Gap { + private _range: Interval; + private _filledIntervals: Interval[]; + private _inProgressIntervals: Interval[]; + private _internalFields?: InternalFields; + private _timestamp?: string; + + constructor({ + timestamp, + range, + filledIntervals = [], + inProgressIntervals = [], + internalFields, + }: GapConstructorParams) { + this._range = normalizeInterval(range); + this._filledIntervals = mergeIntervals(filledIntervals.map(normalizeInterval)); + this._inProgressIntervals = mergeIntervals(inProgressIntervals.map(normalizeInterval)); + if (internalFields) { + this._internalFields = internalFields; + } + if (timestamp) { + this._timestamp = timestamp; + } + } + + public fillGap(interval: Interval): void { + const clipedInterval = clipInterval(interval, this.range); + if (clipedInterval === null) return; + + const newFilledIntervals = mergeIntervals([...this.filledIntervals, clipedInterval]); + this._filledIntervals = newFilledIntervals; + const newInProgressIntervals = subtractAllIntervals( + this.inProgressIntervals, + newFilledIntervals + ); + this._inProgressIntervals = newInProgressIntervals; + } + + public addInProgress(interval: Interval): void { + const clipedInterval = clipInterval(interval, this.range); + if (clipedInterval === null) return; + + const inProgressIntervals = subtractAllIntervals([clipedInterval], this.filledIntervals); + this._inProgressIntervals = mergeIntervals([ + ...this.inProgressIntervals, + ...inProgressIntervals, + ]); + } + + public get range() { + return this._range; + } + + public get filledIntervals() { + return this._filledIntervals; + } + + public get inProgressIntervals() { + return this._inProgressIntervals; + } + + public get timestamp() { + return this._timestamp; + } + + /** + * unfilled = range - (filled + inProgress) + */ + public get unfilledIntervals(): Interval[] { + const combined = mergeIntervals([...this.filledIntervals, ...this.inProgressIntervals]); + return subtractIntervals(this.range, combined); + } + + public get totalGapDurationMs(): number { + return intervalDuration(this.range); + } + + public get filledGapDurationMs(): number { + return sumIntervalsDuration(this.filledIntervals); + } + + public get unfilledGapDurationMs(): number { + return sumIntervalsDuration(this.unfilledIntervals); + } + + public get inProgressGapDurationMs(): number { + return sumIntervalsDuration(this.inProgressIntervals); + } + + public get status(): GapStatus { + if (this.unfilledGapDurationMs === this.totalGapDurationMs) { + return gapStatus.UNFILLED; + } else if (this.unfilledGapDurationMs === 0 && this.inProgressGapDurationMs === 0) { + return gapStatus.FILLED; + } else { + return gapStatus.PARTIALLY_FILLED; + } + } + + public resetInProgressIntervals(): void { + this._inProgressIntervals = []; + } + + public get internalFields() { + return this._internalFields; + } + + public getState() { + return { + range: denormalizeInterval(this.range), + filledIntervals: this.filledIntervals.map(denormalizeInterval), + inProgressIntervals: this.inProgressIntervals.map(denormalizeInterval), + unfilledIntervals: this.unfilledIntervals.map(denormalizeInterval), + status: this.status, + totalGapDurationMs: this.totalGapDurationMs, + filledDurationMs: this.filledGapDurationMs, + unfilledDurationMs: this.unfilledGapDurationMs, + inProgressDurationMs: this.inProgressGapDurationMs, + }; + } + + /** + * Returns the gap object for es + */ + public getEsObject(): GapBase { + return { + range: denormalizeInterval(this.range), + filled_intervals: this.filledIntervals.map(denormalizeInterval), + in_progress_intervals: this.inProgressIntervals.map(denormalizeInterval), + unfilled_intervals: this.unfilledIntervals.map(denormalizeInterval), + status: this.status, + total_gap_duration_ms: this.totalGapDurationMs, + filled_duration_ms: this.filledGapDurationMs, + unfilled_duration_ms: this.unfilledGapDurationMs, + in_progress_duration_ms: this.inProgressGapDurationMs, + }; + } +} diff --git a/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/gap/interval_utils.ts b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/gap/interval_utils.ts new file mode 100644 index 0000000000000..fc80efc26a9fa --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/gap/interval_utils.ts @@ -0,0 +1,225 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Interval, StringInterval } from '../types'; + +/** + * Finds the overlapping portion of two intervals, if any. + * + * @param interval1 - The first interval. + * @param interval2 - The second interval. + * @returns The overlapping interval if it exists, otherwise null. + * + * @example + * const i1: Interval = { gte: new Date('2023-10-19T12:00:00Z'), lte: new Date('2023-10-19T12:30:00Z') }; + * const i2: Interval = { gte: new Date('2023-10-19T12:15:00Z'), lte: new Date('2023-10-19T12:45:00Z') }; + * Overlap: 12:15 - 12:30 + * const overlap = getOverlap(i1, i2); + * overlap = { gte: 2023-10-19T12:15:00Z, lte: 2023-10-19T12:30:00Z } + */ +export const getOverlap = (interval1: Interval, interval2: Interval): Interval | null => { + const start = new Date(Math.max(interval1.gte.getTime(), interval2.gte.getTime())); + const end = new Date(Math.min(interval1.lte.getTime(), interval2.lte.getTime())); + return start <= end ? { gte: start, lte: end } : null; +}; + +/** + * Merges a list of possibly overlapping intervals into a minimal set of non-overlapping intervals. + * + * @param intervals - An array of intervals to merge. + * @returns A new array of merged intervals with no overlaps. + * + * @example + * const intervals: Interval[] = [ + * { gte: new Date('2023-10-19T12:00:00Z'), lte: new Date('2023-10-19T12:10:00Z') }, + * { gte: new Date('2023-10-19T12:05:00Z'), lte: new Date('2023-10-19T12:15:00Z') } + * ]; + * // Merge into one interval: 12:00 - 12:15 + * const merged = mergeIntervals(intervals); + * // merged = [{ gte: 2023-10-19T12:00:00Z, lte: 2023-10-19T12:15:00Z }] + */ +export const mergeIntervals = (intervals: Interval[]): Interval[] => { + if (!intervals.length) return []; + + const sorted = intervals + .map((interval) => ({ ...interval })) + .sort((a, b) => a.gte.getTime() - b.gte.getTime()); + + return sorted.reduce((merged, current, index) => { + if (index === 0) { + merged.push(current); + return merged; + } + + const last = merged[merged.length - 1]; + if (last.lte.getTime() >= current.gte.getTime()) { + // Overlapping, merge intervals by extending the last interval's end time + last.lte = new Date(Math.max(last.lte.getTime(), current.lte.getTime())); + } else { + // No overlap, just add the current interval + merged.push(current); + } + return merged; + }, [] as Interval[]); +}; + +/** + * Subtracts a list of intervals from a single base interval, returning any remaining parts that do not overlap. + * + * @param base - The base interval. + * @param intervalsToSubtract - Intervals to remove from the base. + * @returns An array of intervals representing the remainder of the base interval. + * + * @example + * const base: Interval = { gte: new Date('2023-10-19T12:00:00Z'), lte: new Date('2023-10-19T13:00:00Z') }; + * const toSubtract: Interval[] = [{ gte: new Date('2023-10-19T12:20:00Z'), lte: new Date('2023-10-19T12:30:00Z') }]; + * // Result splits the base into two: 12:00-12:20 and 12:30-13:00 + * const result = subtractIntervals(base, toSubtract); + * // result = [ + * // { gte: 2023-10-19T12:00:00Z, lte: 2023-10-19T12:20:00Z }, + * // { gte: 2023-10-19T12:30:00Z, lte: 2023-10-19T13:00:00Z } + * // ] + */ +export const subtractIntervals = (base: Interval, intervalsToSubtract: Interval[]): Interval[] => { + let result: Interval[] = [base]; + for (const toSubtract of intervalsToSubtract) { + result = result.flatMap((current) => { + const overlap = getOverlap(current, toSubtract); + if (!overlap) return [current]; + + const remainder: Interval[] = []; + const currentStart = current.gte.getTime(); + const currentEnd = current.lte.getTime(); + const overlapStart = overlap.gte.getTime(); + const overlapEnd = overlap.lte.getTime(); + + if (overlapStart > currentStart) { + remainder.push({ gte: new Date(currentStart), lte: new Date(overlapStart) }); + } + + if (overlapEnd < currentEnd) { + remainder.push({ gte: new Date(overlapEnd), lte: new Date(currentEnd) }); + } + + return remainder; + }); + } + return result; +}; + +/** + * Applies subtractIntervals to each interval in an array and merges the result, + * effectively removing intervalsToSubtract from all given intervals. + * + * @param intervals - The original intervals from which to subtract. + * @param intervalsToSubtract - The intervals to remove. + * @returns An array of intervals representing what remains after all subtractions. + * + * @example + * const intervals: Interval[] = [ + * { gte: new Date('2023-10-19T12:00:00Z'), lte: new Date('2023-10-19T12:30:00Z') }, + * { gte: new Date('2023-10-19T12:40:00Z'), lte: new Date('2023-10-19T13:00:00Z') } + * ]; + * const toSubtract: Interval[] = [{ gte: new Date('2023-10-19T12:20:00Z'), lte: new Date('2023-10-19T12:45:00Z') }]; + * // For the first interval: removing 12:20-12:30 leaves 12:00-12:20 + * // For the second interval: removing 12:20-12:45 overlaps partially, leaving 12:45-13:00 + * // After merging, result: + * // [ + * // { gte: 2023-10-19T12:00:00Z, lte: 2023-10-19T12:20:00Z }, + * // { gte: 2023-10-19T12:45:00Z, lte: 2023-10-19T13:00:00Z } + * // ] + * const result = subtractAllIntervals(intervals, toSubtract); + */ +export const subtractAllIntervals = ( + intervals: Interval[], + intervalsToSubtract: Interval[] +): Interval[] => { + const result: Interval[] = []; + for (const interval of intervals) { + const subtracted = subtractIntervals(interval, intervalsToSubtract); + result.push(...subtracted); + } + return mergeIntervals(result); +}; + +/** + * Calculates the duration of a single interval in milliseconds. + * + * @param interval - The interval. + * @returns The duration in milliseconds. + * + * @example + * const i: Interval = { gte: new Date('2023-10-19T12:00:00Z'), lte: new Date('2023-10-19T12:10:00Z') }; + * const duration = intervalDuration(i); + * // duration = 600000 ms (10 minutes) + */ +export const intervalDuration = (interval: Interval): number => { + return Math.max(0, interval.lte.getTime() - interval.gte.getTime()); +}; + +/** + * Sums the durations of multiple intervals in milliseconds. + * + * @param intervals - An array of intervals. + * @returns The total combined duration in milliseconds. + * + * @example + * const intervals: Interval[] = [ + * { gte: new Date('2023-10-19T12:00:00Z'), lte: new Date('2023-10-19T12:10:00Z') }, + * { gte: new Date('2023-10-19T12:15:00Z'), lte: new Date('2023-10-19T12:20:00Z') } + * ]; + * // First interval: 10 min, Second interval: 5 min + * // Total: 15 min = 900000 ms + * const totalDuration = sumIntervalsDuration(intervals); + */ +export const sumIntervalsDuration = (intervals: Interval[]): number => { + return intervals.reduce((sum, interval) => sum + intervalDuration(interval), 0); +}; + +/** + * Converts a String-based interval into a date-based interval + * @param interval + * @returns + */ +export const normalizeInterval = (interval: StringInterval): Interval => { + const gte = new Date(interval.gte); + const lte = new Date(interval.lte); + return { gte, lte }; +}; + +/** + * Converts a Date-based interval into a string-based interval. + * + * @param interval - An Interval with gte and lte as Date objects. + * @returns A ConstructorInterval with gte and lte as ISO strings. + */ +export const denormalizeInterval = (interval: Interval): StringInterval => { + return { + gte: interval.gte.toISOString(), + lte: interval.lte.toISOString(), + }; +}; + +/** + * Clips an interval to ensure it falls within the given boundary range. + * If there's no overlap, returns null. + */ +export const clipInterval = (interval: Interval, boundary: Interval): Interval | null => { + const gte = interval.gte.getTime(); + const lte = interval.lte.getTime(); + const boundaryGte = boundary.gte.getTime(); + const boundaryLte = boundary.lte.getTime(); + + const clippedGte = Math.max(gte, boundaryGte); + const clippedLte = Math.min(lte, boundaryLte); + + if (clippedGte >= clippedLte) { + return null; + } + + return { gte: new Date(clippedGte), lte: new Date(clippedLte) }; +}; diff --git a/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/schemas/index.ts b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/schemas/index.ts new file mode 100644 index 0000000000000..646d4b14bbc62 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/schemas/index.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { gapStatus } from '../../../../common/constants'; + +export const gapStatusSchema = schema.oneOf([ + schema.literal(gapStatus.UNFILLED), + schema.literal(gapStatus.FILLED), + schema.literal(gapStatus.PARTIALLY_FILLED), +]); + +export const rangeSchema = schema.object({ + lte: schema.string(), + gte: schema.string(), +}); + +export const rangeListSchema = schema.arrayOf(rangeSchema); + +export const gapBaseSchema = schema.object({ + status: gapStatusSchema, + range: rangeSchema, + in_progress_intervals: rangeListSchema, + filled_intervals: rangeListSchema, + unfilled_intervals: rangeListSchema, + total_gap_duration_ms: schema.number(), + filled_duration_ms: schema.number(), + unfilled_duration_ms: schema.number(), + in_progress_duration_ms: schema.number(), +}); + +export const findGapsParamsSchema = schema.object( + { + end: schema.maybe(schema.string()), + page: schema.number({ defaultValue: 1, min: 1 }), + perPage: schema.number({ defaultValue: 10, min: 0 }), + ruleId: schema.string(), + start: schema.maybe(schema.string()), + sortField: schema.maybe( + schema.oneOf([ + schema.literal('@timestamp'), + schema.literal('total_gap_duration_ms'), + schema.literal('status'), + ]) + ), + sortOrder: schema.maybe(schema.oneOf([schema.literal('asc'), schema.literal('desc')])), + statuses: schema.maybe(schema.arrayOf(gapStatusSchema)), + }, + { + validate({ start, end }) { + if (start) { + const parsedStart = Date.parse(start); + if (isNaN(parsedStart)) { + return `[start]: query start must be valid date`; + } + } + if (end) { + const parsedEnd = Date.parse(end); + if (isNaN(parsedEnd)) { + return `[end]: query end must be valid date`; + } + } + }, + } +); diff --git a/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/transforms/transform_to_gap.test.ts b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/transforms/transform_to_gap.test.ts new file mode 100644 index 0000000000000..79f05f2e4bced --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/transforms/transform_to_gap.test.ts @@ -0,0 +1,155 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { QueryEventsBySavedObjectResult } from '@kbn/event-log-plugin/server'; +import { transformToGap } from './transform_to_gap'; +import { Gap } from '../gap'; + +describe('transformToGap', () => { + const timestamp = '2023-01-01T00:00:00.000Z'; + const validInterval = { + gte: '2023-01-01T00:00:00.000Z', + lte: '2023-01-01T01:00:00.000Z', + }; + + const createMockEvent = (overrides = {}): QueryEventsBySavedObjectResult => ({ + total: 1, + data: [ + { + '@timestamp': timestamp, + _id: 'test-id', + _index: 'test-index', + _seq_no: 1, + _primary_term: 1, + kibana: { + alert: { + rule: { + gap: { + range: validInterval, + filled_intervals: [validInterval], + in_progress_intervals: [validInterval], + }, + }, + }, + }, + ...overrides, + }, + ], + page: 1, + per_page: 10, + }); + + it('transforms valid event to Gap object', () => { + const events = createMockEvent(); + const result = transformToGap(events); + + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(Gap); + expect(result[0]).toEqual( + new Gap({ + timestamp, + range: validInterval, + filledIntervals: [validInterval], + inProgressIntervals: [validInterval], + internalFields: { + _id: 'test-id', + _index: 'test-index', + _seq_no: 1, + _primary_term: 1, + }, + }) + ); + }); + + it('filters out invalid gaps (missing range)', () => { + const events = createMockEvent({ + kibana: { + alert: { + rule: { + gap: { + range: undefined, + }, + }, + }, + }, + }); + const result = transformToGap(events); + expect(result).toHaveLength(0); + }); + + it('filters out invalid gaps (invalid range)', () => { + const events = createMockEvent({ + kibana: { + alert: { + rule: { + gap: { + range: { gte: undefined, lte: '2023-01-01T01:00:00.000Z' }, + }, + }, + }, + }, + }); + const result = transformToGap(events); + expect(result).toHaveLength(0); + }); + + it('handles missing timestamp', () => { + const events = createMockEvent({ + '@timestamp': undefined, + }); + const result = transformToGap(events); + expect(result).toHaveLength(0); + }); + + it('handles missing intervals', () => { + const events = createMockEvent({ + kibana: { + alert: { + rule: { + gap: { + range: validInterval, + filled_intervals: undefined, + in_progress_intervals: undefined, + }, + }, + }, + }, + }); + const result = transformToGap(events); + + expect(result).toHaveLength(1); + expect(result[0].filledIntervals).toEqual([]); + expect(result[0].inProgressIntervals).toEqual([]); + }); + + it('filters out invalid intervals while keeping valid ones', () => { + const events = createMockEvent({ + kibana: { + alert: { + rule: { + gap: { + range: validInterval, + filled_intervals: [ + validInterval, + { gte: undefined, lte: '2023-01-01T01:00:00.000Z' }, + ], + in_progress_intervals: [ + validInterval, + { gte: '2023-01-01T00:00:00.000Z', lte: undefined }, + ], + }, + }, + }, + }, + }); + const result = transformToGap(events); + + expect(result).toHaveLength(1); + expect(result[0].filledIntervals).toHaveLength(1); + expect(result[0].inProgressIntervals).toHaveLength(1); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/transforms/transform_to_gap.ts b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/transforms/transform_to_gap.ts new file mode 100644 index 0000000000000..f93963e34b4c0 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/transforms/transform_to_gap.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { QueryEventsBySavedObjectResult } from '@kbn/event-log-plugin/server'; +import { Gap } from '../gap'; +import { StringInterval } from '../types'; + +type PotenialInterval = { lte?: string; gte?: string } | undefined; + +const validateInterval = (interval: PotenialInterval): StringInterval | null => { + if (!interval?.gte || !interval?.lte) return null; + + return { + lte: interval.lte, + gte: interval.gte, + }; +}; + +const validateIntervals = (intervals: PotenialInterval[] | undefined): StringInterval[] => + (intervals?.map(validateInterval)?.filter((interval) => interval !== null) as StringInterval[]) ?? + []; + +/** + * Transforms event log results into Gap objects + * Filters out invalid gaps/gaps intervals + */ +export const transformToGap = (events: QueryEventsBySavedObjectResult): Gap[] => { + return events?.data + ?.map((doc) => { + const gap = doc?.kibana?.alert?.rule?.gap; + if (!gap) return null; + + const range = validateInterval(gap.range); + + if (!range || !doc['@timestamp']) return null; + + const filledIntervals = validateIntervals(gap?.filled_intervals); + const inProgressIntervals = validateIntervals(gap?.in_progress_intervals); + + return new Gap({ + timestamp: doc['@timestamp'], + range, + filledIntervals, + inProgressIntervals, + internalFields: { + _id: doc._id, + _index: doc._index, + _seq_no: doc._seq_no, + _primary_term: doc._primary_term, + }, + }); + }) + .filter((gap): gap is Gap => gap !== null); +}; diff --git a/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/types/index.ts b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/types/index.ts new file mode 100644 index 0000000000000..aaf2ff68a376d --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/types/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TypeOf } from '@kbn/config-schema'; +import { gapBaseSchema, findGapsParamsSchema } from '../schemas'; + +export type GapBase = TypeOf; +export type FindGapsParams = TypeOf; + +export interface Interval { + gte: Date; + lte: Date; +} + +export interface StringInterval { + gte: string; + lte: string; +} diff --git a/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/update/calculate_gaps_state.test.ts b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/update/calculate_gaps_state.test.ts new file mode 100644 index 0000000000000..91e99a836d438 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/update/calculate_gaps_state.test.ts @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { savedObjectsRepositoryMock } from '@kbn/core/server/mocks'; +import { backfillClientMock } from '../../../backfill_client/backfill_client.mock'; +import { calculateGapStateFromAllBackfills } from './calculate_gaps_state'; +import { Gap } from '../gap'; +import { adHocRunStatus } from '../../../../common/constants'; + +describe('calculateGapStateFromAllBackfills', () => { + const mockSavedObjectsRepository = savedObjectsRepositoryMock.create(); + const mockBackfillClient = backfillClientMock.create(); + + beforeEach(() => { + jest.resetAllMocks(); + mockBackfillClient.findOverlappingBackfills.mockResolvedValue([]); + }); + + it('should calculate gap state', async () => { + const testGap = new Gap({ + range: { + gte: '2024-01-01T00:00:00.000Z', + lte: '2024-01-01T01:00:00.000Z', + }, + }); + + await calculateGapStateFromAllBackfills({ + gap: testGap, + savedObjectsRepository: mockSavedObjectsRepository, + ruleId: 'test-rule-id', + backfillClient: mockBackfillClient, + }); + + expect(mockBackfillClient.findOverlappingBackfills).toHaveBeenCalledWith({ + ruleId: 'test-rule-id', + start: testGap.range.gte, + end: testGap.range.lte, + savedObjectsRepository: mockSavedObjectsRepository, + }); + }); + + it('should reset in-progress intervals before processing backfills', async () => { + const testGap = new Gap({ + range: { + gte: '2024-01-01T00:00:00.000Z', + lte: '2024-01-01T01:00:00.000Z', + }, + inProgressIntervals: [ + { + gte: '2024-01-01T00:00:00.000Z', + lte: '2024-01-01T00:30:00.000Z', + }, + ], + }); + + const spy = jest.spyOn(testGap, 'resetInProgressIntervals'); + + await calculateGapStateFromAllBackfills({ + gap: testGap, + savedObjectsRepository: mockSavedObjectsRepository, + ruleId: 'test-rule-id', + backfillClient: mockBackfillClient, + }); + + expect(spy).toHaveBeenCalled(); + expect(testGap.inProgressIntervals).toHaveLength(0); + }); + + it('should update gap with backfill schedules from overlapping backfills', async () => { + const testGap = new Gap({ + range: { + gte: '2024-01-01T00:00:00.000Z', + lte: '2024-01-01T01:00:00.000Z', + }, + }); + + mockBackfillClient.findOverlappingBackfills.mockResolvedValueOnce([ + { + schedule: [ + { + runAt: '2024-01-01T00:15:00.000Z', + interval: '15m', + status: adHocRunStatus.RUNNING, + }, + { + runAt: '2024-01-01T00:20:00.000Z', + interval: '15m', + status: adHocRunStatus.PENDING, + }, + ], + }, + ]); + + const updatedGap = await calculateGapStateFromAllBackfills({ + gap: testGap, + savedObjectsRepository: mockSavedObjectsRepository, + ruleId: 'test-rule-id', + backfillClient: mockBackfillClient, + }); + + expect(updatedGap.inProgressIntervals).toHaveLength(1); + expect(updatedGap.inProgressIntervals[0]).toEqual({ + gte: new Date('2024-01-01T00:00:00.000Z'), + lte: new Date('2024-01-01T00:20:00.000Z'), + }); + }); + + it('should filter out backfills with an error', async () => { + const testGap = new Gap({ + range: { + gte: '2024-01-01T00:00:00.000Z', + lte: '2024-01-01T01:00:00.000Z', + }, + }); + + mockBackfillClient.findOverlappingBackfills.mockResolvedValueOnce([ + { error: 'Some error' }, + { + schedule: [ + { + runAt: '2024-01-01T00:15:00.000Z', + interval: '15m', + status: adHocRunStatus.RUNNING, + }, + ], + }, + ]); + + const updatedGap = await calculateGapStateFromAllBackfills({ + gap: testGap, + savedObjectsRepository: mockSavedObjectsRepository, + ruleId: 'test-rule-id', + backfillClient: mockBackfillClient, + }); + + expect(updatedGap.inProgressIntervals).toHaveLength(1); + expect(updatedGap.inProgressIntervals[0]).toEqual({ + gte: new Date('2024-01-01T00:00:00.000Z'), + lte: new Date('2024-01-01T00:15:00.000Z'), + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/update/calculate_gaps_state.ts b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/update/calculate_gaps_state.ts new file mode 100644 index 0000000000000..af175c8b46f0a --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/update/calculate_gaps_state.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ISavedObjectsRepository } from '@kbn/core/server'; +import { Gap } from '../gap'; +import { updateGapFromSchedule } from './update_gap_from_schedule'; +import { BackfillClient } from '../../../backfill_client/backfill_client'; + +/** + * Find all overlapping backfill tasks and update the gap status accordingly + */ +export const calculateGapStateFromAllBackfills = async ({ + gap, + savedObjectsRepository, + ruleId, + backfillClient, +}: { + gap: Gap; + savedObjectsRepository: ISavedObjectsRepository; + ruleId: string; + backfillClient: BackfillClient; +}): Promise => { + const transformedBackfills = await backfillClient.findOverlappingBackfills({ + ruleId, + start: gap.range.gte, + end: gap.range.lte, + savedObjectsRepository, + }); + + gap.resetInProgressIntervals(); + for (const backfill of transformedBackfills) { + if ('error' in backfill) { + continue; + } + gap = updateGapFromSchedule({ + gap, + backfillSchedule: backfill?.schedule ?? [], + }); + } + + return gap; +}; diff --git a/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/update/update_gap_from_schedule.test.ts b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/update/update_gap_from_schedule.test.ts new file mode 100644 index 0000000000000..068541c54a0fe --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/update/update_gap_from_schedule.test.ts @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Gap } from '../gap'; +import { updateGapFromSchedule } from './update_gap_from_schedule'; +import { adHocRunStatus } from '../../../../common/constants'; + +describe('updateGapFromSchedule', () => { + const createTestGap = () => + new Gap({ + range: { + gte: '2024-01-01T00:00:00.000Z', + lte: '2024-01-01T01:00:00.000Z', + }, + }); + + describe('schedule processing', () => { + it('should handle empty schedule', () => { + const gap = createTestGap(); + const updatedGap = updateGapFromSchedule({ + gap, + backfillSchedule: [], + }); + + expect(updatedGap.filledIntervals).toHaveLength(0); + expect(updatedGap.inProgressIntervals).toHaveLength(0); + }); + + it('should update filled intervals for completed backfills', () => { + const gap = createTestGap(); + const updatedGap = updateGapFromSchedule({ + gap, + backfillSchedule: [ + { + runAt: '2024-01-01T00:15:00.000Z', + interval: '15m', + status: adHocRunStatus.COMPLETE, + }, + ], + }); + + expect(updatedGap.filledIntervals).toHaveLength(1); + expect(updatedGap.filledIntervals[0]).toEqual({ + gte: new Date('2024-01-01T00:00:00.000Z'), + lte: new Date('2024-01-01T00:15:00.000Z'), + }); + }); + + it('should update in-progress intervals for running backfills', () => { + const gap = createTestGap(); + const updatedGap = updateGapFromSchedule({ + gap, + backfillSchedule: [ + { + runAt: '2024-01-01T00:15:00.000Z', + interval: '15m', + status: adHocRunStatus.RUNNING, + }, + ], + }); + + expect(updatedGap.inProgressIntervals).toHaveLength(1); + expect(updatedGap.inProgressIntervals[0]).toEqual({ + gte: new Date('2024-01-01T00:00:00.000Z'), + lte: new Date('2024-01-01T00:15:00.000Z'), + }); + }); + }); + + describe('multiple intervals handling', () => { + it('should handle overlapping intervals', () => { + const gap = createTestGap(); + const updatedGap = updateGapFromSchedule({ + gap, + backfillSchedule: [ + { + runAt: '2024-01-01T00:15:00.000Z', + interval: '15m', + status: adHocRunStatus.COMPLETE, + }, + { + runAt: '2024-01-01T00:20:00.000Z', + interval: '15m', + status: adHocRunStatus.COMPLETE, + }, + ], + }); + + expect(updatedGap.filledIntervals).toHaveLength(1); + expect(updatedGap.filledIntervals[0]).toEqual({ + gte: new Date('2024-01-01T00:00:00.000Z'), + lte: new Date('2024-01-01T00:20:00.000Z'), + }); + }); + + it('should handle mixed status intervals', () => { + const gap = createTestGap(); + const updatedGap = updateGapFromSchedule({ + gap, + backfillSchedule: [ + { + runAt: '2024-01-01T00:15:00.000Z', + interval: '15m', + status: adHocRunStatus.COMPLETE, + }, + { + runAt: '2024-01-01T00:30:00.000Z', + interval: '15m', + status: adHocRunStatus.RUNNING, + }, + { + runAt: '2024-01-01T00:45:00.000Z', + interval: '15m', + status: adHocRunStatus.PENDING, + }, + ], + }); + + expect(updatedGap.filledIntervals).toHaveLength(1); + expect(updatedGap.inProgressIntervals).toHaveLength(1); + expect(updatedGap.filledIntervals[0]).toEqual({ + gte: new Date('2024-01-01T00:00:00.000Z'), + lte: new Date('2024-01-01T00:15:00.000Z'), + }); + expect(updatedGap.inProgressIntervals[0]).toEqual({ + gte: new Date('2024-01-01T00:15:00.000Z'), + lte: new Date('2024-01-01T00:45:00.000Z'), + }); + }); + }); + + describe('edge cases', () => { + it('should handle intervals outside gap range', () => { + const gap = createTestGap(); + const updatedGap = updateGapFromSchedule({ + gap, + backfillSchedule: [ + { + runAt: '2024-01-01T02:00:00.000Z', // Outside gap range + interval: '15m', + status: adHocRunStatus.COMPLETE, + }, + ], + }); + + expect(updatedGap.filledIntervals).toHaveLength(0); + expect(updatedGap.inProgressIntervals).toHaveLength(0); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/update/update_gap_from_schedule.ts b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/update/update_gap_from_schedule.ts new file mode 100644 index 0000000000000..5b15a754a8e11 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/update/update_gap_from_schedule.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Gap } from '../gap'; +import { adHocRunStatus } from '../../../../common/constants'; +import { parseDuration } from '../../../../common'; +import { BackfillSchedule } from '../../../application/backfill/result/types'; + +export const updateGapFromSchedule = ({ + gap, + backfillSchedule, +}: { + gap: Gap; + backfillSchedule: BackfillSchedule[]; +}) => { + for (const scheduleItem of backfillSchedule) { + const runAt = new Date(scheduleItem.runAt).getTime(); + const intervalDuration = parseDuration(scheduleItem.interval); + const from = runAt - intervalDuration; + const to = runAt; + const scheduleInterval = { + gte: new Date(from), + lte: new Date(to), + }; + if ( + scheduleItem.status === adHocRunStatus.PENDING || + scheduleItem.status === adHocRunStatus.RUNNING + ) { + gap.addInProgress(scheduleInterval); + } else if (scheduleItem.status === adHocRunStatus.COMPLETE) { + gap.fillGap(scheduleInterval); + } + } + + return gap; +}; diff --git a/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/update/update_gaps.test.ts b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/update/update_gaps.test.ts new file mode 100644 index 0000000000000..1bae4aa0d2e1d --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/update/update_gaps.test.ts @@ -0,0 +1,223 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { updateGaps } from './update_gaps'; +import { findAllGaps } from '../find_gaps'; +import { updateGapFromSchedule } from './update_gap_from_schedule'; +import { calculateGapStateFromAllBackfills } from './calculate_gaps_state'; +import { backfillClientMock } from '../../../backfill_client/backfill_client.mock'; + +import { loggerMock } from '@kbn/logging-mocks'; +import { eventLoggerMock } from '@kbn/event-log-plugin/server/event_logger.mock'; +import { eventLogClientMock } from '@kbn/event-log-plugin/server/event_log_client.mock'; +import { savedObjectsRepositoryMock } from '@kbn/core/server/mocks'; +import { Gap } from '../gap'; +import { adHocRunStatus } from '../../../../common/constants'; +import { errors as EsErrors } from '@elastic/elasticsearch'; + +jest.mock('../find_gaps'); +jest.mock('./update_gap_from_schedule'); +jest.mock('./calculate_gaps_state'); + +describe('updateGaps', () => { + const mockLogger = loggerMock.create(); + const mockEventLogger = eventLoggerMock.create(); + const mockEventLogClient = eventLogClientMock.create(); + const mockSavedObjectsRepository = savedObjectsRepositoryMock.create(); + const mockBackfillClient = backfillClientMock.create(); + + const createTestGap = () => + new Gap({ + range: { + gte: '2024-01-01T00:00:00.000Z', + lte: '2024-01-01T01:00:00.000Z', + }, + internalFields: { + _id: 'test-id', + _index: 'test-index', + _seq_no: 1, + _primary_term: 1, + }, + }); + + beforeEach(() => { + jest.resetAllMocks(); + (findAllGaps as jest.Mock).mockResolvedValue([]); + }); + + describe('updateGaps', () => { + it('should orchestrate the gap update process', async () => { + const testGap = createTestGap(); + (findAllGaps as jest.Mock).mockResolvedValue([testGap]); + + await updateGaps({ + ruleId: 'test-rule-id', + start: new Date('2024-01-01T00:00:00.000Z'), + end: new Date('2024-01-01T01:00:00.000Z'), + eventLogger: mockEventLogger, + eventLogClient: mockEventLogClient, + logger: mockLogger, + savedObjectsRepository: mockSavedObjectsRepository, + backfillClient: mockBackfillClient, + }); + + expect(findAllGaps).toHaveBeenCalledWith({ + eventLogClient: mockEventLogClient, + logger: mockLogger, + params: { + ruleId: 'test-rule-id', + start: expect.any(Date), + end: expect.any(Date), + statuses: ['partially_filled', 'unfilled'], + }, + }); + expect(mockEventLogger.updateEvent).toHaveBeenCalled(); + }); + + it('should handle multiple gaps in the time range', async () => { + const gaps = [ + createTestGap(), + new Gap({ + range: { + gte: '2024-01-01T01:00:00.000Z', + lte: '2024-01-01T02:00:00.000Z', + }, + internalFields: { + _id: 'test-id-2', + _index: 'test-index', + _seq_no: 2, + _primary_term: 1, + }, + }), + ]; + (findAllGaps as jest.Mock).mockResolvedValue(gaps); + + await updateGaps({ + ruleId: 'test-rule-id', + start: new Date('2024-01-01T00:00:00.000Z'), + end: new Date('2024-01-01T02:00:00.000Z'), + eventLogger: mockEventLogger, + eventLogClient: mockEventLogClient, + logger: mockLogger, + savedObjectsRepository: mockSavedObjectsRepository, + backfillClient: mockBackfillClient, + }); + + expect(mockEventLogger.updateEvent).toHaveBeenCalledTimes(2); + }); + }); + + describe('error handling', () => { + it('should handle findAllGaps errors', async () => { + (findAllGaps as jest.Mock).mockRejectedValue(new Error('Find gaps failed')); + + await updateGaps({ + ruleId: 'test-rule-id', + start: new Date('2024-01-01T00:00:00.000Z'), + end: new Date('2024-01-01T01:00:00.000Z'), + eventLogger: mockEventLogger, + eventLogClient: mockEventLogClient, + logger: mockLogger, + savedObjectsRepository: mockSavedObjectsRepository, + backfillClient: mockBackfillClient, + }); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to update gaps for rule test-rule-id') + ); + }); + + it('should retry on conflict errors', async () => { + const testGap = createTestGap(); + (findAllGaps as jest.Mock).mockResolvedValue([testGap]); + + const conflictError = new EsErrors.ResponseError({ + statusCode: 409, + body: { error: { type: 'version_conflict_engine_exception' } }, + headers: {}, + meta: {} as any, + warnings: [], + }); + + mockEventLogger.updateEvent + .mockRejectedValueOnce(conflictError) + .mockRejectedValueOnce(conflictError) + .mockResolvedValue(); + + await updateGaps({ + ruleId: 'test-rule-id', + start: new Date('2024-01-01T00:00:00.000Z'), + end: new Date('2024-01-01T01:00:00.000Z'), + eventLogger: mockEventLogger, + eventLogClient: mockEventLogClient, + logger: mockLogger, + savedObjectsRepository: mockSavedObjectsRepository, + backfillClient: mockBackfillClient, + }); + + expect(mockEventLogger.updateEvent).toHaveBeenCalledTimes(3); + }); + }); + + describe('backfill handling', () => { + beforeEach(() => { + (updateGapFromSchedule as jest.Mock).mockImplementation((params) => params.gap); + (calculateGapStateFromAllBackfills as jest.Mock).mockImplementation((params) => params.gap); + }); + + it('should handle direct schedule updates', async () => { + const testGap = createTestGap(); + (findAllGaps as jest.Mock).mockResolvedValue([testGap]); + + const backfillSchedule = [ + { + runAt: '2024-01-01T00:30:00.000Z', + interval: '30m', + status: adHocRunStatus.COMPLETE, + }, + ]; + + await updateGaps({ + ruleId: 'test-rule-id', + start: new Date('2024-01-01T00:00:00.000Z'), + end: new Date('2024-01-01T01:00:00.000Z'), + eventLogger: mockEventLogger, + eventLogClient: mockEventLogClient, + logger: mockLogger, + savedObjectsRepository: mockSavedObjectsRepository, + backfillSchedule, + backfillClient: mockBackfillClient, + }); + + expect(updateGapFromSchedule).toHaveBeenCalledWith({ + gap: testGap, + backfillSchedule, + }); + expect(calculateGapStateFromAllBackfills).not.toHaveBeenCalled(); + }); + + it('should trigger refetch when shouldRefetchAllBackfills is true', async () => { + const testGap = createTestGap(); + (findAllGaps as jest.Mock).mockResolvedValue([testGap]); + + await updateGaps({ + ruleId: 'test-rule-id', + start: new Date('2024-01-01T00:00:00.000Z'), + end: new Date('2024-01-01T01:00:00.000Z'), + eventLogger: mockEventLogger, + eventLogClient: mockEventLogClient, + logger: mockLogger, + savedObjectsRepository: mockSavedObjectsRepository, + shouldRefetchAllBackfills: true, + backfillClient: mockBackfillClient, + }); + + expect(updateGapFromSchedule).not.toHaveBeenCalled(); + expect(calculateGapStateFromAllBackfills).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/update/update_gaps.ts b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/update/update_gaps.ts new file mode 100644 index 0000000000000..458fcc2607a90 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/lib/rule_gaps/update/update_gaps.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Logger, ISavedObjectsRepository } from '@kbn/core/server'; +import { IEventLogClient, IEventLogger } from '@kbn/event-log-plugin/server'; +import { BackfillClient } from '../../../backfill_client/backfill_client'; +import { AlertingEventLogger } from '../../alerting_event_logger/alerting_event_logger'; +import { findAllGaps } from '../find_gaps'; +import { retryTransientEsErrors } from '../../../alerts_service/lib/retry_transient_es_errors'; +import { gapStatus } from '../../../../common/constants'; +import { BackfillSchedule } from '../../../application/backfill/result/types'; +import { adHocRunStatus } from '../../../../common/constants'; +import { calculateGapStateFromAllBackfills } from './calculate_gaps_state'; +import { updateGapFromSchedule } from './update_gap_from_schedule'; + +interface UpdateGapsParams { + ruleId: string; + backfillSchedule?: BackfillSchedule[]; + start: Date; + end: Date; + eventLogger?: IEventLogger; + eventLogClient: IEventLogClient; + logger: Logger; + savedObjectsRepository: ISavedObjectsRepository; + shouldRefetchAllBackfills?: boolean; + backfillClient: BackfillClient; +} + +const CONFLICT_STATUS_CODE = 409; + +export const updateGaps = async (params: UpdateGapsParams) => { + const { ruleId, start, end, logger } = params; + try { + await retryTransientEsErrors(() => _updateGaps(params), { + logger, + additionalRetryableStatusCodes: [CONFLICT_STATUS_CODE], + }); + } catch (e) { + logger.error( + `Failed to update gaps for rule ${ruleId} from: ${start.toISOString()} to: ${end.toISOString()}: ${ + e.message + }` + ); + } +}; + +/** + * Find all gaps for this date range and ruleId + * Then add the filled interval to the gaps + * Then update the gaps in the event log + */ +const _updateGaps = async (params: UpdateGapsParams) => { + const { + ruleId, + start, + end, + logger, + eventLogClient, + eventLogger, + backfillSchedule, + savedObjectsRepository, + shouldRefetchAllBackfills, + backfillClient, + } = params; + + if (!eventLogger) { + throw new Error('Event logger is required'); + } + + const alertingEventLogger = new AlertingEventLogger(eventLogger); + + const allGaps = await findAllGaps({ + eventLogClient, + logger, + params: { + ruleId, + start, + end, + statuses: [gapStatus.PARTIALLY_FILLED, gapStatus.UNFILLED], + }, + }); + + for (const gap of allGaps) { + const hasFailedBackfillTask = backfillSchedule?.some( + (scheduleItem) => + scheduleItem.status === adHocRunStatus.ERROR || + scheduleItem.status === adHocRunStatus.TIMEOUT + ); + + // add filled and in progress intervals from schedule + if (backfillSchedule && !hasFailedBackfillTask) { + updateGapFromSchedule({ + gap, + backfillSchedule, + }); + } + + // reset in progress intervals and refetch all backfills, to calculate the correct state + // if there failed backfill task, or we remove backfill, we can't just remove in progress intervals from the gap + // we need to refetch all backfills to calculate in-progress intervals + if (hasFailedBackfillTask || !backfillSchedule || shouldRefetchAllBackfills) { + await calculateGapStateFromAllBackfills({ + gap, + savedObjectsRepository, + ruleId, + backfillClient, + }); + } + + const esGap = gap.getEsObject(); + const internalFields = gap.internalFields; + + if (internalFields) { + try { + await alertingEventLogger.updateGap({ + internalFields, + gap: esGap, + }); + } catch (e) { + if (e.statusCode === CONFLICT_STATUS_CODE) { + logger.error('Failed to udpate gap, because of conflicting versions'); + } + throw e; + } + } + } +}; diff --git a/x-pack/platform/plugins/shared/alerting/server/plugin.ts b/x-pack/platform/plugins/shared/alerting/server/plugin.ts index 5c0c6dda41663..c9d9f0164cdd4 100644 --- a/x-pack/platform/plugins/shared/alerting/server/plugin.ts +++ b/x-pack/platform/plugins/shared/alerting/server/plugin.ts @@ -533,6 +533,7 @@ export class AlertingPlugin { securityPluginStart: plugins.security, internalSavedObjectsRepository: core.savedObjects.createInternalRepository([ RULE_SAVED_OBJECT_TYPE, + AD_HOC_RUN_SAVED_OBJECT_TYPE, ]), encryptedSavedObjectsClient, spaceIdToNamespace, @@ -623,6 +624,7 @@ export class AlertingPlugin { spaceIdToNamespace, uiSettings: core.uiSettings, usageCounter: this.usageCounter, + getEventLogClient: (request: KibanaRequest) => plugins.eventLog.getClient(request), isServerless: this.isServerless, }); diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/find/find_gaps_route.test.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/find/find_gaps_route.test.ts new file mode 100644 index 0000000000000..94aa10e715f24 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/find/find_gaps_route.test.ts @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { httpServiceMock } from '@kbn/core/server/mocks'; +import { licenseStateMock } from '../../../../lib/license_state.mock'; +import { verifyApiAccess } from '../../../../lib/license_api_access'; +import { mockHandlerArguments } from '../../../_mock_handler_arguments'; +import { transformRequestV1, transformResponseV1 } from './transforms'; +import { rulesClientMock } from '../../../../rules_client.mock'; +import { findGapsRoute } from './find_gaps_route'; +import { Gap } from '../../../../lib/rule_gaps/gap'; + +const rulesClient = rulesClientMock.create(); + +jest.mock('../../../../lib/license_api_access', () => ({ + verifyApiAccess: jest.fn(), +})); + +describe('findGapsRoute', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + const mockFindOptions = { + rule_ids: ['abc'], + start: '2023-11-16T08:00:00.000Z', + end: '2023-11-17T08:00:00.000Z', + page: 1, + per_page: 10, + }; + + const createMockGap = () => { + const gap = new Gap({ + timestamp: '2024-01-30T00:00:00.000Z', + range: { + gte: '2023-11-16T08:00:00.000Z', + lte: '2023-11-16T20:00:00.000Z', + }, + meta: { + _id: 'gap-1', + _index: 'test-index', + _seq_no: 1, + _primary_term: 1, + }, + }); + return gap; + }; + + const mockFindResult = { + page: 1, + perPage: 10, + total: 1, + data: [createMockGap()], + }; + + test('should find gaps with the proper parameters', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + findGapsRoute(router, licenseState); + + rulesClient.findGaps.mockResolvedValueOnce(mockFindResult); + const [config, handler] = router.post.mock.calls[0]; + const [context, req, res] = mockHandlerArguments({ rulesClient }, { body: mockFindOptions }); + + expect(config.path).toEqual('/internal/alerting/rules/gaps/_find'); + + await handler(context, req, res); + + expect(rulesClient.findGaps).toHaveBeenLastCalledWith(transformRequestV1(mockFindOptions)); + expect(res.ok).toHaveBeenLastCalledWith({ + body: transformResponseV1(mockFindResult), + }); + }); + + test('ensures the license allows for finding gaps', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + findGapsRoute(router, licenseState); + + rulesClient.findGaps.mockResolvedValueOnce(mockFindResult); + const [, handler] = router.post.mock.calls[0]; + const [context, req, res] = mockHandlerArguments({ rulesClient }, { body: mockFindOptions }); + await handler(context, req, res); + expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); + }); + + test('ensures the license check prevents finding gaps when appropriate', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + findGapsRoute(router, licenseState); + + (verifyApiAccess as jest.Mock).mockImplementation(() => { + throw new Error('Failure'); + }); + const [, handler] = router.post.mock.calls[0]; + const [context, req, res] = mockHandlerArguments({ rulesClient }, { body: mockFindOptions }); + await expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: Failure]`); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/find/find_gaps_route.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/find/find_gaps_route.ts new file mode 100644 index 0000000000000..034e0944bd40a --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/find/find_gaps_route.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { IRouter } from '@kbn/core/server'; +import { + findGapsBodySchemaV1, + FindGapsRequestQueryV1, + FindGapsResponseV1, +} from '../../../../../common/routes/gaps/apis/find'; +import { ILicenseState } from '../../../../lib'; +import { verifyAccessAndContext } from '../../../lib'; +import { + AlertingRequestHandlerContext, + INTERNAL_ALERTING_GAPS_FIND_API_PATH, +} from '../../../../types'; +import { transformRequestV1, transformResponseV1 } from './transforms'; + +export const findGapsRoute = ( + router: IRouter, + licenseState: ILicenseState +) => { + router.post( + { + path: INTERNAL_ALERTING_GAPS_FIND_API_PATH, + validate: { + body: findGapsBodySchemaV1, + }, + options: { + access: 'internal', + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const alertingContext = await context.alerting; + const rulesClient = await alertingContext.getRulesClient(); + const query: FindGapsRequestQueryV1 = req.body; + const result = await rulesClient.findGaps(transformRequestV1(query)); + const response: FindGapsResponseV1 = { + body: transformResponseV1(result), + }; + return res.ok(response); + }) + ) + ); +}; diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/find/transforms/index.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/find/transforms/index.ts new file mode 100644 index 0000000000000..2eab64276e020 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/find/transforms/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { transformRequest } from './transform_request/latest'; +export { transformResponse } from './transform_response/latest'; + +export { transformRequest as transformRequestV1 } from './transform_request/v1'; +export { transformResponse as transformResponseV1 } from './transform_response/v1'; diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/find/transforms/transform_request/latest.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/find/transforms/transform_request/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/find/transforms/transform_request/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/find/transforms/transform_request/v1.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/find/transforms/transform_request/v1.ts new file mode 100644 index 0000000000000..76d2c470b43cd --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/find/transforms/transform_request/v1.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +/* eslint-disable @typescript-eslint/naming-convention */ + +import { FindGapsRequestQueryV1 } from '../../../../../../../common/routes/gaps/apis/find'; +import { FindGapsParams } from '../../../../../../lib/rule_gaps/types'; + +export const transformRequest = ({ + page, + per_page, + rule_id, + start, + end, + sort_field, + sort_order, + statuses, +}: FindGapsRequestQueryV1): FindGapsParams => ({ + ruleId: rule_id, + end, + page, + perPage: per_page, + statuses, + start, + sortField: sort_field, + sortOrder: sort_order, +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/find/transforms/transform_response/latest.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/find/transforms/transform_response/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/find/transforms/transform_response/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/find/transforms/transform_response/v1.ts b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/find/transforms/transform_response/v1.ts new file mode 100644 index 0000000000000..0ec42b426584b --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting/server/routes/gaps/apis/find/transforms/transform_response/v1.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FindGapsResponseBodyV1 } from '../../../../../../../common/routes/gaps/apis/find'; + +import { Gap } from '../../../../../../lib/rule_gaps/gap'; + +export const transformResponse = ({ + page, + perPage, + total, + data: gapsData, +}: { + page: number; + perPage: number; + total: number; + data: Gap[]; +}): FindGapsResponseBodyV1 => ({ + page, + per_page: perPage, + total, + data: gapsData.map((gap) => ({ + _id: gap?.internalFields?._id, + ...gap.getEsObject(), + '@timestamp': gap.timestamp, + })), +}); diff --git a/x-pack/platform/plugins/shared/alerting/server/routes/index.ts b/x-pack/platform/plugins/shared/alerting/server/routes/index.ts index 58c6cda9f3b12..fa895c764d4a6 100644 --- a/x-pack/platform/plugins/shared/alerting/server/routes/index.ts +++ b/x-pack/platform/plugins/shared/alerting/server/routes/index.ts @@ -72,6 +72,9 @@ import { getBackfillRoute } from './backfill/apis/get/get_backfill_route'; import { findBackfillRoute } from './backfill/apis/find/find_backfill_route'; import { deleteBackfillRoute } from './backfill/apis/delete/delete_backfill_route'; +// Gaps API +import { findGapsRoute } from './gaps/apis/find/find_gaps_route'; + export interface RouteOptions { router: IRouter; licenseState: ILicenseState; @@ -146,6 +149,9 @@ export function defineRoutes(opts: RouteOptions) { findBackfillRoute(router, licenseState); deleteBackfillRoute(router, licenseState); + // Gaps APIs + findGapsRoute(router, licenseState); + // Other APIs registerFieldsRoute(router, licenseState); getScheduleFrequencyRoute(router, licenseState); diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client.mock.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client.mock.ts index c7577656306d0..13847ac1bea9e 100644 --- a/x-pack/platform/plugins/shared/alerting/server/rules_client.mock.ts +++ b/x-pack/platform/plugins/shared/alerting/server/rules_client.mock.ts @@ -56,6 +56,7 @@ const createRulesClientMock = () => { clone: jest.fn(), getScheduleFrequency: jest.fn(), bulkUntrackAlerts: jest.fn(), + findGaps: jest.fn(), }; return mocked; }; diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client/common/audit_events.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client/common/audit_events.ts index 66bba16c2805c..7d61fad2f47f7 100644 --- a/x-pack/platform/plugins/shared/alerting/server/rules_client/common/audit_events.ts +++ b/x-pack/platform/plugins/shared/alerting/server/rules_client/common/audit_events.ts @@ -36,6 +36,8 @@ export enum RuleAuditAction { RUN_SOON = 'rule_run_soon', UNTRACK_ALERT = 'rule_alert_untrack', SCHEDULE_BACKFILL = 'rule_schedule_backfill', + FIND_GAPS = 'rule_find_gaps', + FILL_GAPS = 'rule_fill_gaps', } export enum AdHocRunAuditAction { @@ -97,6 +99,8 @@ const ruleEventVerbs: Record = { 'scheduling backfill for', 'scheduled backfill for', ], + rule_find_gaps: ['find gaps for', 'finding gaps for', 'found gaps for'], + rule_fill_gaps: ['fill gaps for', 'filling gaps for', 'filled gaps for'], }; const adHocRunEventVerbs: Record = { @@ -132,6 +136,8 @@ const ruleEventTypes: Record> = rule_get_global_execution_kpi: 'access', rule_alert_untrack: 'change', rule_schedule_backfill: 'access', + rule_find_gaps: 'access', + rule_fill_gaps: 'change', }; const adHocRunEventTypes: Record> = { diff --git a/x-pack/platform/plugins/shared/alerting/server/rules_client/rules_client.ts b/x-pack/platform/plugins/shared/alerting/server/rules_client/rules_client.ts index 4c86469f11a29..9a1b6a9e8de5e 100644 --- a/x-pack/platform/plugins/shared/alerting/server/rules_client/rules_client.ts +++ b/x-pack/platform/plugins/shared/alerting/server/rules_client/rules_client.ts @@ -76,6 +76,8 @@ import { deleteBackfill } from '../application/backfill/methods/delete'; import { FindBackfillParams } from '../application/backfill/methods/find/types'; import { DisableRuleParams } from '../application/rule/methods/disable'; import { EnableRuleParams } from '../application/rule/methods/enable_rule'; +import { findGaps } from '../application/rule/methods/find_gaps'; +import { FindGapsParams } from '../lib/rule_gaps/types'; export type ConstructorOptions = Omit< RulesClientContext, @@ -207,4 +209,6 @@ export class RulesClient { public getTags = (params: RuleTagsParams) => getRuleTags(this.context, params); public getScheduleFrequency = () => getScheduleFrequency(this.context); + + public findGaps = (params: FindGapsParams) => findGaps(this.context, params); } diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/ad_hoc_task_runner.test.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/ad_hoc_task_runner.test.ts index ae6467d0dcbf8..7af3bdee961ef 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/ad_hoc_task_runner.test.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/ad_hoc_task_runner.test.ts @@ -23,7 +23,7 @@ import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { DataViewsServerPluginStart } from '@kbn/data-views-plugin/server'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; import { IEventLogger } from '@kbn/event-log-plugin/server'; -import { eventLoggerMock } from '@kbn/event-log-plugin/server/mocks'; +import { eventLogClientMock, eventLoggerMock } from '@kbn/event-log-plugin/server/mocks'; import { SharePluginStart } from '@kbn/share-plugin/server'; import { ConcreteTaskInstance, TaskStatus } from '@kbn/task-manager-plugin/server'; import { usageCountersServiceMock } from '@kbn/usage-collection-plugin/server/usage_counters/usage_counters_service.mock'; @@ -93,7 +93,9 @@ import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; import { ConnectorAdapterRegistry } from '../connector_adapters/connector_adapter_registry'; import { rulesSettingsServiceMock } from '../rules_settings/rules_settings_service.mock'; import { maintenanceWindowsServiceMock } from './maintenance_windows/maintenance_windows_service.mock'; +import { updateGaps } from '../lib/rule_gaps/update/update_gaps'; +jest.mock('../lib/rule_gaps/update/update_gaps'); const UUID = '5f6aa57d-3e22-484e-bae8-cbed868f4d28'; jest.mock('uuid', () => ({ @@ -149,6 +151,7 @@ const ruleTypeRegistry = ruleTypeRegistryMock.create(); const savedObjectsService = savedObjectsServiceMock.createInternalStartContract(); const services = alertsMock.createRuleExecutorServices(); const uiSettingsService = uiSettingsServiceMock.createStartContract(); +const eventLogClient = eventLogClientMock.create(); const taskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType = { actionsConfigMap: { default: { max: 1000 } }, @@ -176,6 +179,7 @@ const taskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType uiSettings: uiSettingsService, usageCounter: mockUsageCounter, isServerless: false, + getEventLogClient: jest.fn(), }; const mockedTaskInstance: ConcreteTaskInstance = { @@ -226,6 +230,7 @@ describe('Ad Hoc Task Runner', () => { let schedule4: AdHocRunSchedule; let schedule5: AdHocRunSchedule; let alertingEventLoggerInitializer: ContextOpts; + const mockUpdateGaps = updateGaps as jest.MockedFunction; beforeAll(() => { fakeTimer = sinon.useFakeTimers(); @@ -652,6 +657,9 @@ describe('Ad Hoc Task Runner', () => { }); test('should delete ad hoc run SO and not return a new runAt date when all schedules have been processed ', async () => { + taskRunnerFactoryInitializerParams.getEventLogClient = jest + .fn() + .mockResolvedValue(eventLogClient); ruleTypeWithAlerts.executor.mockImplementation( async ({ services: executorServices, @@ -744,6 +752,24 @@ describe('Ad Hoc Task Runner', () => { { refresh: false, namespace: undefined } ); + expect(mockUpdateGaps).toHaveBeenCalledWith({ + ruleId: RULE_ID, + start: new Date(mockedAdHocRunSO.attributes.start), + end: mockedAdHocRunSO.attributes.end ? new Date(mockedAdHocRunSO.attributes.end) : new Date(), + eventLogger: taskRunnerFactoryInitializerParams.eventLogger, + eventLogClient, + logger: taskRunnerFactoryInitializerParams.logger, + backfillSchedule: [ + { ...schedule1, status: adHocRunStatus.COMPLETE }, + { ...schedule2, status: adHocRunStatus.TIMEOUT }, + { ...schedule3, status: adHocRunStatus.ERROR }, + { ...schedule4, status: adHocRunStatus.COMPLETE }, + { ...schedule5, status: adHocRunStatus.COMPLETE }, + ], + savedObjectsRepository: internalSavedObjectsRepository, + backfillClient: taskRunnerFactoryInitializerParams.backfillClient, + }); + testAlertingEventLogCalls({ status: 'ok', backfillRunAt: schedule5.runAt, diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/ad_hoc_task_runner.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/ad_hoc_task_runner.ts index c9932820ff808..010b48d276339 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/ad_hoc_task_runner.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/ad_hoc_task_runner.ts @@ -52,6 +52,7 @@ import { import { RuleRunMetrics, RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; import { getEsErrorMessage } from '../lib/errors'; import { Result, isOk, asOk, asErr } from '../lib/result_type'; +import { updateGaps } from '../lib/rule_gaps/update/update_gaps'; interface ConstructorParams { context: TaskRunnerContext; @@ -74,6 +75,7 @@ export class AdHocTaskRunner implements CancellableTask { private readonly taskInstance: ConcreteTaskInstance; private adHocRunSchedule: AdHocRunSchedule[] = []; + private adHocRange: { start: string; end: string | undefined } | null = null; private alertingEventLogger: AlertingEventLogger; private cancelled: boolean = false; private logger: Logger; @@ -98,6 +100,7 @@ export class AdHocTaskRunner implements CancellableTask { private stackTraceLog: RuleRunnerErrorStackTraceLog | null = null; private taskRunning: AdHocTaskRunningHandler; private timer: TaskRunnerTimer; + private apiKeyToUse: string | null = null; constructor({ context, internalSavedObjectsRepository, taskInstance }: ConstructorParams) { this.context = context; @@ -318,7 +321,8 @@ export class AdHocTaskRunner implements CancellableTask { ); } - const { rule, apiKeyToUse, schedule } = adHocRunData; + const { rule, apiKeyToUse, schedule, start, end } = adHocRunData; + this.apiKeyToUse = apiKeyToUse; let ruleType: UntypedNormalizedRuleType; try { @@ -383,6 +387,7 @@ export class AdHocTaskRunner implements CancellableTask { // Determine which schedule entry we're going to run // Find the first index where the status is pending this.adHocRunSchedule = schedule; + this.adHocRange = { start, end }; this.scheduleToRunIndex = (this.adHocRunSchedule ?? []).findIndex( (s: AdHocRunSchedule) => s.status === adHocRunStatus.PENDING ); @@ -491,6 +496,7 @@ export class AdHocTaskRunner implements CancellableTask { // Capture how long it took for the rule to run after being claimed this.timer.setDuration(TaskRunnerTimerSpan.TotalRunDuration, startedAt); } + return { executionStatus, executionMetrics }; }); this.alertingEventLogger.done({ @@ -584,6 +590,8 @@ export class AdHocTaskRunner implements CancellableTask { async cleanup() { if (!this.shouldDeleteTask) return; + await this.updateGapsAfterBackfillComplete(); + try { await this.internalSavedObjectsRepository.delete( AD_HOC_RUN_SAVED_OBJECT_TYPE, @@ -600,4 +608,30 @@ export class AdHocTaskRunner implements CancellableTask { ); } } + + private async updateGapsAfterBackfillComplete() { + if (!this.shouldDeleteTask) return; + + if (this.scheduleToRunIndex < 0 || !this.adHocRange) return null; + + const fakeRequest = getFakeKibanaRequest( + this.context, + this.taskInstance.params.spaceId, + this.apiKeyToUse + ); + + const eventLogClient = await this.context.getEventLogClient(fakeRequest); + + return updateGaps({ + ruleId: this.ruleId, + start: new Date(this.adHocRange.start), + end: this.adHocRange.end ? new Date(this.adHocRange.end) : new Date(), + eventLogger: this.context.eventLogger, + eventLogClient, + logger: this.logger, + backfillSchedule: this.adHocRunSchedule, + savedObjectsRepository: this.internalSavedObjectsRepository, + backfillClient: this.context.backfillClient, + }); + } } diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/task_runner.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/task_runner.ts index 01a8523f7b746..d03e98dbbb8a3 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/task_runner.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/task_runner.ts @@ -629,6 +629,7 @@ export class TaskRunner< )} - ${JSON.stringify(lastRun)}` ); } + await this.updateRuleSavedObjectPostRun(ruleId, { executionStatus: ruleExecutionStatusToRaw(executionStatus), nextRun, diff --git a/x-pack/platform/plugins/shared/alerting/server/task_runner/types.ts b/x-pack/platform/plugins/shared/alerting/server/task_runner/types.ts index f3e08aff2c3b7..872ca2e2ea153 100644 --- a/x-pack/platform/plugins/shared/alerting/server/task_runner/types.ts +++ b/x-pack/platform/plugins/shared/alerting/server/task_runner/types.ts @@ -21,7 +21,7 @@ import { ActionsClient } from '@kbn/actions-plugin/server/actions_client'; import { PluginStart as DataPluginStart } from '@kbn/data-plugin/server'; import { PluginStart as DataViewsPluginStart } from '@kbn/data-views-plugin/server'; import { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; -import { IEventLogger } from '@kbn/event-log-plugin/server'; +import { IEventLogClient, IEventLogger } from '@kbn/event-log-plugin/server'; import { SharePluginStart } from '@kbn/share-plugin/server'; import { UsageCounter } from '@kbn/usage-collection-plugin/server'; import { IAlertsClient } from '../alerts_client/types'; @@ -179,5 +179,6 @@ export interface TaskRunnerContext { spaceIdToNamespace: SpaceIdToNamespaceFunction; uiSettings: UiSettingsServiceStart; usageCounter?: UsageCounter; + getEventLogClient: (request: KibanaRequest) => IEventLogClient; isServerless: boolean; } diff --git a/x-pack/platform/plugins/shared/event_log/server/es/cluster_client_adapter.mock.ts b/x-pack/platform/plugins/shared/event_log/server/es/cluster_client_adapter.mock.ts index c416fcb0f7bf6..2f5d6da914d02 100644 --- a/x-pack/platform/plugins/shared/event_log/server/es/cluster_client_adapter.mock.ts +++ b/x-pack/platform/plugins/shared/event_log/server/es/cluster_client_adapter.mock.ts @@ -28,6 +28,7 @@ const createClusterClientMock = () => { aggregateEventsBySavedObjects: jest.fn(), aggregateEventsWithAuthFilter: jest.fn(), shutdown: jest.fn(), + updateDocument: jest.fn(), }; return mock; }; diff --git a/x-pack/platform/plugins/shared/event_log/server/es/cluster_client_adapter.test.ts b/x-pack/platform/plugins/shared/event_log/server/es/cluster_client_adapter.test.ts index 1d8105d2be49b..a8145261c10e6 100644 --- a/x-pack/platform/plugins/shared/event_log/server/es/cluster_client_adapter.test.ts +++ b/x-pack/platform/plugins/shared/event_log/server/es/cluster_client_adapter.test.ts @@ -15,6 +15,7 @@ import { AggregateEventsOptionsBySavedObjectFilter, AggregateEventsWithAuthFilter, getQueryBodyWithAuthFilter, + Doc, } from './cluster_client_adapter'; import { AggregateOptionsType, queryOptionsSchema } from '../event_log_client'; import { delay } from '../lib/delay'; @@ -733,7 +734,15 @@ describe('queryEventsBySavedObject', () => { test('should call cluster with correct options', async () => { clusterClient.search.mockResponse({ hits: { - hits: [{ _index: 'index-name-00001', _id: '1', _source: { foo: 'bar' } }], + hits: [ + { + _index: 'index-name-00001', + _id: '1', + _source: { foo: 'bar' }, + _seq_no: 1, + _primary_term: 1, + }, + ], total: { relation: 'eq', value: 1 }, }, took: 0, @@ -766,6 +775,7 @@ describe('queryEventsBySavedObject', () => { expect(query).toEqual({ index: 'index-name', track_total_hits: true, + seq_no_primary_term: true, body: { size: 6, from: 12, @@ -777,7 +787,7 @@ describe('queryEventsBySavedObject', () => { page: 3, per_page: 6, total: 1, - data: [{ foo: 'bar' }], + data: [{ foo: 'bar', _id: '1', _index: 'index-name-00001', _seq_no: 1, _primary_term: 1 }], }); }); }); @@ -2291,6 +2301,85 @@ describe('getQueryBodyWithAuthFilter', () => { }); }); +describe('updateDocument', () => { + test('should successfully update document with meta information', async () => { + const doc = { + body: { foo: 'updated' }, + index: 'test-index', + internalFields: { + _id: 'test-id', + _index: 'test-index', + _seq_no: 1, + _primary_term: 1, + }, + }; + + clusterClient.update.mockResponse({ + _index: 'test-index', + _id: 'test-id', + _version: 2, + result: 'updated', + _shards: { + total: 2, + successful: 1, + failed: 0, + }, + _seq_no: 2, + _primary_term: 1, + }); + + await clusterClientAdapter.updateDocument(doc as unknown as Required); + + expect(clusterClient.update).toHaveBeenCalledWith({ + doc: doc.body, + id: doc.internalFields._id, + index: doc.internalFields._index, + if_primary_term: doc.internalFields._primary_term, + if_seq_no: doc.internalFields._seq_no, + refresh: 'wait_for', + }); + }); + + test('should throw error if internal fields information is missing', async () => { + const doc = { + body: { foo: 'updated' }, + index: 'test-index', + }; + + await expect( + clusterClientAdapter.updateDocument(doc as unknown as Required) + ).rejects.toThrowErrorMatchingInlineSnapshot('"Internal fields are required"'); + + expect(logger.error).toHaveBeenCalledWith( + `error updating event: "Internal fields are required"; docs: ${JSON.stringify(doc)}` + ); + }); + + test('should throw error when update fails', async () => { + const doc = { + body: { foo: 'updated' }, + index: 'test-index', + internalFields: { + _id: 'test-id', + _index: 'test-index', + _seq_no: 1, + _primary_term: 1, + }, + }; + + const error = new Error('Update failed'); + clusterClient.update.mockRejectedValue(error); + + await expect( + clusterClientAdapter.updateDocument(doc as unknown as Required) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Update failed"`); + + expect(logger.error).toHaveBeenCalledWith( + `error updating event: "Update failed"; docs: ${JSON.stringify(doc)}` + ); + }); +}); + type RetryableFunction = () => boolean; const RETRY_UNTIL_DEFAULT_COUNT = 20; diff --git a/x-pack/platform/plugins/shared/event_log/server/es/cluster_client_adapter.ts b/x-pack/platform/plugins/shared/event_log/server/es/cluster_client_adapter.ts index 1eb0f482e1d0b..18dbc02690d37 100644 --- a/x-pack/platform/plugins/shared/event_log/server/es/cluster_client_adapter.ts +++ b/x-pack/platform/plugins/shared/event_log/server/es/cluster_client_adapter.ts @@ -13,6 +13,7 @@ import { Logger, ElasticsearchClient } from '@kbn/core/server'; import util from 'util'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { fromKueryExpression, toElasticsearchQuery, KueryNode, nodeBuilder } from '@kbn/es-query'; +import { long } from '@elastic/elasticsearch/lib/api/types'; import { IEvent, IValidatedEvent, SAVED_OBJECT_REL_PRIMARY } from '../types'; import { AggregateOptionsType, FindOptionsType, QueryOptionsType } from '../event_log_client'; import { ParsedIndexAlias } from './init'; @@ -23,9 +24,17 @@ export const EVENT_BUFFER_LENGTH = 100; export type IClusterClientAdapter = PublicMethodsOf; +export interface InternalFields { + _id: string; + _index: string; + _seq_no: number; + _primary_term: number; +} + export interface Doc { index: string; body: IEvent; + internalFields?: InternalFields; } type Wait = () => Promise; @@ -37,11 +46,18 @@ export interface ConstructorOpts { wait: Wait; } +type IValidatedEventInternalDocInfo = IValidatedEvent & { + _id: estypes.Id; + _index: estypes.IndexName; + _seq_no: estypes.SequenceNumber; + _primary_term: long; +}; + export interface QueryEventsBySavedObjectResult { page: number; per_page: number; total: number; - data: IValidatedEvent[]; + data: IValidatedEventInternalDocInfo[]; } interface QueryOptionsEventsBySavedObjectFilter { @@ -97,7 +113,13 @@ type AliasAny = any; const LEGACY_ID_CUTOFF_VERSION = '8.0.0'; -export class ClusterClientAdapter { +export class ClusterClientAdapter< + TDoc extends { + body: AliasAny; + index: string; + internalFields?: InternalFields; + } = Doc +> { private readonly logger: Logger; private readonly elasticsearchClientPromise: Promise; private readonly docBuffer$: Subject; @@ -135,6 +157,26 @@ export class ClusterClientAdapter) { + const esClient = await this.elasticsearchClientPromise; + try { + if (!doc.internalFields) { + throw new Error('Internal fields are required'); + } + await esClient.update({ + doc: doc.body, + id: doc.internalFields._id, + index: doc.internalFields._index, + if_primary_term: doc.internalFields._primary_term, + if_seq_no: doc.internalFields._seq_no, + refresh: 'wait_for', + }); + } catch (e) { + this.logger.error(`error updating event: "${e.message}"; docs: ${JSON.stringify(doc)}`); + throw e; + } + } + public indexDocument(doc: TDoc): void { this.docBuffer$.next(doc); } @@ -407,20 +449,27 @@ export class ClusterClientAdapter ({ [s.sort_field]: { order: s.sort_order } })) as estypes.Sort } : {}), }; - try { const { hits: { hits, total }, } = await esClient.search({ index, track_total_hits: true, + seq_no_primary_term: true, body, }); + return { page, per_page: perPage, total: isNumber(total) ? total : total!.value, - data: hits.map((hit) => hit._source), + data: hits.map((hit) => ({ + ...hit._source, + _id: hit._id!, + _index: hit._index, + _seq_no: hit._seq_no!, + _primary_term: hit._primary_term!, + })), }; } catch (err) { throw new Error( @@ -459,12 +508,19 @@ export class ClusterClientAdapter hit._source), + data: hits.map((hit) => ({ + ...hit._source, + _id: hit._id!, + _index: hit._index, + _seq_no: hit._seq_no!, + _primary_term: hit._primary_term!, + })), }; } catch (err) { throw new Error( diff --git a/x-pack/platform/plugins/shared/event_log/server/event_log_client.test.ts b/x-pack/platform/plugins/shared/event_log/server/event_log_client.test.ts index 5abd44061eb4d..190046777bc65 100644 --- a/x-pack/platform/plugins/shared/event_log/server/event_log_client.test.ts +++ b/x-pack/platform/plugins/shared/event_log/server/event_log_client.test.ts @@ -285,6 +285,10 @@ describe('EventLogStart', () => { function fakeEvent(overrides = {}) { return merge( { + _id: '1', + _index: '1', + _seq_no: 1, + _primary_term: 1, event: { provider: 'actions', action: 'execute', diff --git a/x-pack/platform/plugins/shared/event_log/server/event_log_client.ts b/x-pack/platform/plugins/shared/event_log/server/event_log_client.ts index 4ffcfb6cef84e..79e20a98ecfd3 100644 --- a/x-pack/platform/plugins/shared/event_log/server/event_log_client.ts +++ b/x-pack/platform/plugins/shared/event_log/server/event_log_client.ts @@ -40,6 +40,8 @@ const sortSchema = schema.object({ schema.literal('event.duration'), schema.literal('event.action'), schema.literal('message'), + schema.literal('kibana.alert.rule.gap.status'), + schema.literal('kibana.alert.rule.gap.total_gap_duration_ms'), ]), sort_order: schema.oneOf([schema.literal('asc'), schema.literal('desc')]), }); diff --git a/x-pack/platform/plugins/shared/event_log/server/event_logger.mock.ts b/x-pack/platform/plugins/shared/event_log/server/event_logger.mock.ts index 837eac7ec2b5e..ed243d98548f1 100644 --- a/x-pack/platform/plugins/shared/event_log/server/event_logger.mock.ts +++ b/x-pack/platform/plugins/shared/event_log/server/event_logger.mock.ts @@ -10,6 +10,7 @@ import { IEventLogger } from './types'; const createEventLoggerMock = () => { const mock: jest.Mocked = { logEvent: jest.fn(), + updateEvent: jest.fn(), startTiming: jest.fn(), stopTiming: jest.fn(), }; diff --git a/x-pack/platform/plugins/shared/event_log/server/event_logger.ts b/x-pack/platform/plugins/shared/event_log/server/event_logger.ts index 0c674ab805a6c..249cc6b155eed 100644 --- a/x-pack/platform/plugins/shared/event_log/server/event_logger.ts +++ b/x-pack/platform/plugins/shared/event_log/server/event_logger.ts @@ -23,7 +23,7 @@ import { EventSchema, } from './types'; import { SAVED_OBJECT_REL_PRIMARY } from './types'; -import { Doc } from './es/cluster_client_adapter'; +import { Doc, InternalFields } from './es/cluster_client_adapter'; type SystemLogger = Plugin['systemLogger']; @@ -107,6 +107,24 @@ export class EventLogger implements IEventLogger { logEventDoc(this.systemLogger, doc); } } + + async updateEvent(internalFields: InternalFields, event: IEvent): Promise { + const doc: Required = { + index: this.esContext.esNames.dataStream, + body: event, + internalFields, + }; + + if (this.eventLogService.isIndexingEntries()) { + const result = await updateEventDoc(this.esContext, doc); + + if (this.eventLogService.isLoggingEntries()) { + logUpdateEventDoc(this.systemLogger, doc); + } + + return result; + } + } } // return the epoch millis of the start date, or null; may be NaN if garbage @@ -161,6 +179,14 @@ function logEventDoc(logger: Logger, doc: Doc): void { logger.info(`event logged: ${JSON.stringify(doc.body)}`); } +function logUpdateEventDoc(logger: Logger, doc: Doc): void { + logger.info(`event updated: ${JSON.stringify(doc.body)}`); +} + function indexEventDoc(esContext: EsContext, doc: Doc): void { esContext.esAdapter.indexDocument(doc); } + +async function updateEventDoc(esContext: EsContext, doc: Required): Promise { + return esContext.esAdapter.updateDocument(doc); +} diff --git a/x-pack/platform/plugins/shared/event_log/server/index.ts b/x-pack/platform/plugins/shared/event_log/server/index.ts index e070f0cf0c940..b33ad15e2bc1c 100644 --- a/x-pack/platform/plugins/shared/event_log/server/index.ts +++ b/x-pack/platform/plugins/shared/event_log/server/index.ts @@ -19,11 +19,10 @@ export type { IEventLogClient, QueryEventsBySavedObjectResult, AggregateEventsBySavedObjectResult, + InternalFields, } from './types'; export { SAVED_OBJECT_REL_PRIMARY } from './types'; -export { ClusterClientAdapter } from './es/cluster_client_adapter'; - export { createReadySignal } from './lib/ready_signal'; export const config: PluginConfigDescriptor = { diff --git a/x-pack/platform/plugins/shared/event_log/server/types.ts b/x-pack/platform/plugins/shared/event_log/server/types.ts index 7287da0c0fd6e..bfabe1c9467ba 100644 --- a/x-pack/platform/plugins/shared/event_log/server/types.ts +++ b/x-pack/platform/plugins/shared/event_log/server/types.ts @@ -16,11 +16,13 @@ import { AggregateOptionsType, FindOptionsType } from './event_log_client'; import { AggregateEventsBySavedObjectResult, QueryEventsBySavedObjectResult, + InternalFields, } from './es/cluster_client_adapter'; export type { QueryEventsBySavedObjectResult, AggregateEventsBySavedObjectResult, + InternalFields, } from './es/cluster_client_adapter'; import { SavedObjectProvider } from './saved_object_provider_registry'; @@ -47,7 +49,7 @@ export interface IEventLogService { } export interface IEventLogClientService { - getClient(request: KibanaRequest): IEventLogClient; + getClient(request: KibanaRequest | string): IEventLogClient; } export interface IEventLogClient { @@ -83,4 +85,5 @@ export interface IEventLogger { logEvent(properties: IEvent): void; startTiming(event: IEvent, startTime?: Date): void; stopTiming(event: IEvent): void; + updateEvent(internalFields: InternalFields, event: IEvent): Promise; } diff --git a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/plugin.ts b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/plugin.ts index 62b6d6594b06c..7635950f23abc 100644 --- a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/plugin.ts @@ -92,7 +92,7 @@ export class FixturePlugin implements Plugin, - { features, actions, alerting, ruleRegistry }: FixtureSetupDeps + { features, actions, alerting, ruleRegistry, eventLog }: FixtureSetupDeps ) { features.registerKibanaFeature({ id: 'alertsFixture', @@ -134,7 +134,13 @@ export class FixturePlugin implements Plugin, taskManagerStart: Promise, notificationsStart: Promise, - { logger }: { logger: Logger } + { logger, eventLogger }: { logger: Logger; eventLogger: IEventLogger } ) { const router = core.http.createRouter(); router.get( @@ -589,4 +591,143 @@ export function defineRoutes( } } ); + + router.post( + { + path: '/_test/report_gap', + validate: { + body: schema.object({ + ruleId: schema.string(), + start: schema.string(), + end: schema.string(), + spaceId: schema.string(), + }), + }, + }, + async ( + context: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise> => { + const [, { eventLog }] = await core.getStartServices(); + + const eventLogClient = eventLog.getClient(req); + + const getAmountOfGaps = async () => { + try { + const gaps = await eventLogClient.findEventsBySavedObjectIds('alert', [req.body.ruleId], { + filter: 'event.action: gap', + }); + return gaps.total; + } catch (err) { + return 0; + } + }; + + const amountOfGaps = await getAmountOfGaps(); + + const alertingEventLogger = new AlertingEventLogger(eventLogger); + + // mocked ruleData to initialize alert event logger + alertingEventLogger.initialize({ + context: { + savedObjectId: req.body.ruleId, + spaceId: req.body.spaceId, + savedObjectType: 'alert', + executionId: '123', + taskScheduledAt: new Date(), + namespace: req.body.spaceId, + }, + runDate: new Date(), + ruleData: { + id: req.body.ruleId, + consumer: 'siem', + type: { + id: req.body.ruleId, + name: 'My test rule', + actionGroups: [], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + executor: async () => ({ state: {} }), + category: 'test', + producer: 'alerts', + cancelAlertsOnRuleTimeout: true, + ruleTaskTimeout: '5m', + recoveryActionGroup: { + id: 'customRecovered', + name: 'Custom Recovered', + }, + autoRecoverAlerts: true, + validate: { + params: { validate: (params) => params }, + }, + alerts: { + context: 'test', + mappings: { fieldMap: { field: { type: 'keyword', required: false } } }, + }, + validLegacyConsumers: [], + }, + }, + }); + + await alertingEventLogger.reportGap({ + gap: { + lte: req.body.end, + gte: req.body.start, + }, + }); + + try { + await pRetry( + async () => { + const newAmountOfGaps = await getAmountOfGaps(); + if (newAmountOfGaps === amountOfGaps + 1) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 5000)); + throw new Error('Amount of gaps did not increase'); + }, + { retries: 5 } + ); + return res.ok({ body: { ok: true } }); + } catch (err) { + return res.customError({ + statusCode: 500, + body: { message: 'Amount of gaps did not increase' }, + }); + } + } + ); + + router.post( + { + path: '/_test/event_log/update_document', + validate: { + body: schema.object({ + _id: schema.string(), + _index: schema.string(), + _seq_no: schema.number(), + _primary_term: schema.number(), + fieldsToUpdate: schema.any(), + }), + }, + }, + async ( + context: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ) => { + const result = await eventLogger.updateEvent( + { + _id: req.body._id, + _index: req.body._index, + _seq_no: req.body._seq_no, + _primary_term: req.body._primary_term, + }, + req.body.fieldsToUpdate + ); + return res.ok({ body: { ok: true, result } }); + } + ); } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/gap/find.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/gap/find.ts new file mode 100644 index 0000000000000..df1c87d93e4d7 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/gap/find.ts @@ -0,0 +1,305 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import expect from '@kbn/expect'; +import { UserAtSpaceScenarios } from '../../../../scenarios'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { + getUrlPrefix, + ObjectRemover, + getTestRuleData, + getUnauthorizedErrorMessage, +} from '../../../../../common/lib'; + +// eslint-disable-next-line import/no-default-export +export default function findGapsTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('find gaps', () => { + const objectRemover = new ObjectRemover(supertest); + const searchStart = '2024-01-01T00:00:00.000Z'; + const searchEnd = '2024-01-31T00:00:00.000Z'; + const gap1Start = '2024-01-05T00:00:00.000Z'; + const gap1End = '2024-01-06T00:00:00.000Z'; + const gap2Start = '2024-01-15T00:00:00.000Z'; + const gap2End = '2024-01-16T00:00:00.000Z'; + + afterEach(async () => { + await objectRemover.removeAll(); + }); + + function getRule(overwrites = {}) { + return getTestRuleData({ + rule_type_id: 'test.patternFiringAutoRecoverFalse', + params: { + pattern: { + instance: [true, false, true], + }, + }, + schedule: { interval: '12h' }, + ...overwrites, + }); + } + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + const apiOptions = { + spaceId: space.id, + username: user.username, + password: user.password, + }; + + describe('find gaps with request body', () => { + it('should handle finding gaps with various parameters', async () => { + // Create 2 rules + const rresponse1 = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getRule()) + .expect(200); + const ruleId1 = rresponse1.body.id; + objectRemover.add(apiOptions.spaceId, ruleId1, 'rule', 'alerting'); + + const rresponse2 = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getRule()) + .expect(200); + const ruleId2 = rresponse2.body.id; + objectRemover.add(apiOptions.spaceId, ruleId2, 'rule', 'alerting'); + + // Create gaps for both rules + await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/_test/report_gap`) + .set('kbn-xsrf', 'foo') + .send({ + ruleId: ruleId1, + start: gap1Start, + end: gap1End, + spaceId: apiOptions.spaceId, + }); + + await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/_test/report_gap`) + .set('kbn-xsrf', 'foo') + .send({ + ruleId: ruleId1, + start: gap2Start, + end: gap2End, + spaceId: apiOptions.spaceId, + }); + + await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/_test/report_gap`) + .set('kbn-xsrf', 'foo') + .send({ + ruleId: ruleId2, + start: gap2Start, + end: gap2End, + spaceId: apiOptions.spaceId, + }); + + // Test cases for finding gaps + const testCases = [ + // Find gaps for rule 1 + { + body: { + rule_id: ruleId1, + start: searchStart, + end: searchEnd, + }, + expectedTotal: 2, + description: 'should find gaps for rule 1', + }, + // Find gaps for rule 2 + { + body: { + rule_id: ruleId2, + start: searchStart, + end: searchEnd, + }, + expectedTotal: 1, + description: 'should find gaps for rule 2', + }, + // Test pagination + { + body: { + rule_id: ruleId1, + start: searchStart, + end: searchEnd, + per_page: 1, + page: 1, + }, + expectedTotal: 2, + expectedPerPage: 1, + description: 'should return first page of gaps', + }, + // Test response schema and data validation + { + body: { + rule_id: ruleId1, + start: searchStart, + end: searchEnd, + }, + expectedTotal: 2, + validate: (response: any) => { + expect(response.body).to.have.keys(['page', 'per_page', 'total', 'data']); + const data = response.body.data; + expect(data[0].range.gte).to.eql(gap2Start); + expect(data[0].range.lte).to.eql(gap2End); + expect(data[0].filled_intervals).to.eql([]); + expect(data[0].in_progress_intervals).to.eql([]); + expect(data[0].unfilled_intervals).to.eql([ + { + start: gap2Start, + end: gap2End, + }, + ]); + expect(data[0].status).to.eql('unfilled'); + expect(data[0].total_gap_duration_ms).to.eql(86400000); + expect(data[0].filled_duration_ms).to.eql(0); + expect(data[0].unfilled_duration_ms).to.eql(86400000); + expect(data[0].in_progress_duration_ms).to.eql(0); + + expect(data[0]).to.have.keys('_id', '@timestamp'); + }, + description: 'should return correct response schema', + }, + // Test sorting order validation + { + body: { + rule_id: ruleId1, + start: searchStart, + end: searchEnd, + sort_field: '@timestamp', + sort_order: 'desc', + }, + expectedTotal: 2, + validate: (response: any) => { + const timestamps = response.body.data.map((gap: any) => gap['@timestamp']); + expect(timestamps[0]).to.be.greaterThan(timestamps[1]); + }, + description: 'should return gaps sorted by @timestamp in descending order', + }, + // Test non-overlapping date range + { + body: { + rule_id: ruleId1, + start: '2024-02-01T00:00:00.000Z', + end: '2024-02-28T00:00:00.000Z', + }, + expectedTotal: 0, + description: 'should return no gaps for non-overlapping date range', + }, + // Test partially overlapping ranges + { + body: { + rule_id: ruleId1, + start: '2024-01-05T11:30:00.000Z', + end: '2024-01-05T12:30:00.000Z', + }, + expectedTotal: 1, + validate: (response: any) => { + expect(response.body.data[0].range.gte).to.equal(gap1Start); + expect(response.body.data[0].range.lte).to.equal(gap1End); + }, + description: 'should return gap when search range partially overlaps', + }, + ]; + + for (const testCase of testCases) { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/gaps/_find`) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password) + .send(testCase.body); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Failed to find gaps: ${getUnauthorizedErrorMessage( + 'get', + 'test.patternFiringAutoRecoverFalse', + 'alertsFixture' + )}`, + statusCode: 403, + }); + break; + + case 'global_read at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + expect(response.body.total).to.eql(testCase.expectedTotal); + if (testCase.expectedPerPage) { + expect(response.body.per_page).to.eql(testCase.expectedPerPage); + } + // Execute additional validations if present + if (testCase.validate) { + testCase.validate(response); + } + break; + + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + } + }); + }); + + describe('find gaps with invalid parameters', () => { + it('should handle invalid parameters appropriately', async () => { + const invalidBodies = [ + { + body: {}, + expectedError: + 'Failed to find gaps: Error validating get data - [id]: expected value of type [string] but got [undefined]', + }, + { + body: { + rule_id: '1', + start: 'invalid-date', + end: searchEnd, + }, + expectedError: '[request body]: [start]: query start must be valid date', + }, + { + body: { + rule_id: '1', + start: searchStart, + end: 'invalid-date', + }, + expectedError: '[request body]: [end]: query end must be valid date', + }, + ]; + + for (const { body, expectedError } of invalidBodies) { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/gaps/_find`) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password) + .send(body); + + expect(response.statusCode).to.eql(400); + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: expectedError, + }); + } + }); + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/gap/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/gap/index.ts new file mode 100644 index 0000000000000..fb61386cf5452 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/gap/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function gapsTests({ loadTestFile }: FtrProviderContext) { + describe('rule gaps', () => { + loadTestFile(require.resolve('./find')); + loadTestFile(require.resolve('./update_gaps')); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/gap/update_gaps.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/gap/update_gaps.ts new file mode 100644 index 0000000000000..9576dbd495f72 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/gap/update_gaps.ts @@ -0,0 +1,541 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import moment from 'moment'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE } from '@kbn/alerting-plugin/server/saved_objects'; +import { SuperuserAtSpace1 } from '../../../../scenarios'; +import { getUrlPrefix, ObjectRemover, getTestRuleData } from '../../../../../common/lib'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { getEventLog } from '../../../../../common/lib/get_event_log'; + +// eslint-disable-next-line import/no-default-export +export default function updateGapsTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const retry = getService('retry'); + + describe('update gaps', () => { + const objectRemover = new ObjectRemover(supertest); + const gapStart = moment().subtract(14, 'days').startOf('day').toISOString(); + const gapEnd = moment().subtract(13, 'days').startOf('day').toISOString(); + + afterEach(async () => { + await objectRemover.removeAll(); + }); + + function getRule(overwrites = {}) { + return getTestRuleData({ + rule_type_id: 'test.patternFiringAutoRecoverFalse', + params: { + pattern: { + instance: [true, false, true], + }, + }, + schedule: { interval: '12h' }, + ...overwrites, + }); + } + + async function waitForBackfillComplete(backfillId: string, spaceId: string) { + await retry.try(async () => { + const response = await supertest + .get(`${getUrlPrefix(spaceId)}/internal/alerting/rules/backfill/${backfillId}`) + .set('kbn-xsrf', 'foo'); + + expect(response.statusCode).to.eql(404); + }); + } + + it('should update gap status after backfill execution', async () => { + const { space } = SuperuserAtSpace1; + + // Create a rule + const ruleResponse = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getRule()) + .expect(200); + const ruleId = ruleResponse.body.id; + objectRemover.add(space.id, ruleId, 'rule', 'alerting'); + + // Report a gap + await supertest + .post(`${getUrlPrefix(space.id)}/_test/report_gap`) + .set('kbn-xsrf', 'foo') + .send({ + ruleId, + start: gapStart, + end: gapEnd, + spaceId: space.id, + }); + + // Verify gap exists and is unfilled + const initialGapResponse = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/gaps/_find`) + .set('kbn-xsrf', 'foo') + .send({ + rule_id: ruleId, + start: gapStart, + end: gapEnd, + }); + + expect(initialGapResponse.statusCode).to.eql(200); + expect(initialGapResponse.body.total).to.eql(1); + const initialGap = initialGapResponse.body.data[0]; + expect(initialGap.status).to.eql('unfilled'); + expect(initialGap.unfilled_duration_ms).to.eql(86400000); + expect(initialGap.unfilled_intervals).to.have.length(1); + expect(initialGap.unfilled_intervals[0].gte).to.eql(gapStart); + expect(initialGap.unfilled_intervals[0].lte).to.eql(gapEnd); + expect(initialGap.filled_intervals).to.have.length(0); + expect(initialGap.in_progress_intervals).to.have.length(0); + + // Schedule backfill for the gap period + const scheduleResponse = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .send([{ rule_id: ruleId, start: gapStart, end: gapEnd }]); + + expect(scheduleResponse.statusCode).to.eql(200); + const backfillId = scheduleResponse.body[0].id; + + // Wait for backfill to complete and verify all executions + await waitForBackfillComplete(backfillId, space.id); + + // Verify gap is now filled + const finalGapResponse = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/gaps/_find`) + .set('kbn-xsrf', 'foo') + .send({ + rule_id: ruleId, + start: gapStart, + end: gapEnd, + }); + + expect(finalGapResponse.statusCode).to.eql(200); + expect(finalGapResponse.body.total).to.eql(1); + const finalGap = finalGapResponse.body.data[0]; + expect(finalGap.status).to.eql('filled'); + expect(finalGap.filled_duration_ms).to.eql(86400000); + expect(finalGap.unfilled_duration_ms).to.eql(0); + expect(finalGap.filled_intervals).to.have.length(1); + expect(finalGap.filled_intervals[0].gte).to.eql(gapStart); + expect(finalGap.filled_intervals[0].lte).to.eql(gapEnd); + expect(finalGap.unfilled_intervals).to.have.length(0); + expect(finalGap.in_progress_intervals).to.have.length(0); + }); + + it('should mark intervals as in_progress immediately after scheduling backfill', async () => { + const { space } = SuperuserAtSpace1; + + // Create a rule with timeout pattern to ensure it stays in progress + const ruleResponse = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getRule({ + params: { + pattern: { + instance: ['timeout'], + }, + }, + }) + ) + .expect(200); + const ruleId = ruleResponse.body.id; + objectRemover.add(space.id, ruleId, 'rule', 'alerting'); + + // Report a gap + await supertest + .post(`${getUrlPrefix(space.id)}/_test/report_gap`) + .set('kbn-xsrf', 'foo') + .send({ + ruleId, + start: gapStart, + end: gapEnd, + spaceId: space.id, + }); + + // Schedule backfill + const scheduleResponse = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .send([{ rule_id: ruleId, start: gapStart, end: gapEnd }]); + + expect(scheduleResponse.statusCode).to.eql(200); + + // Verify intervals are marked as in_progress immediately + await retry.try(async () => { + const inProgressResponse = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/gaps/_find`) + .set('kbn-xsrf', 'foo') + .send({ + rule_id: ruleId, + start: gapStart, + end: gapEnd, + }); + + expect(inProgressResponse.statusCode).to.eql(200); + expect(inProgressResponse.body.total).to.eql(1); + const gap = inProgressResponse.body.data[0]; + expect(gap.status).to.eql('partially_filled'); + expect(gap.in_progress_intervals).to.have.length(1); + expect(gap.in_progress_intervals[0].gte).to.eql(gapStart); + expect(gap.in_progress_intervals[0].lte).to.eql(gapEnd); + }); + + // Wait for timeout event + await retry.try(async () => { + const events = await getEventLog({ + getService, + spaceId: space.id, + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + id: scheduleResponse.body[0].id, + provider: 'alerting', + actions: new Map([['execute-timeout', { equal: 1 }]]), + }); + expect(events.length).to.eql(1); + }); + + await waitForBackfillComplete(scheduleResponse.body[0].id, space.id); + + await retry.try(async () => { + // Verify in-progress intervals are removed after timeout + const finalGapResponse = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/gaps/_find`) + .set('kbn-xsrf', 'foo') + .send({ + rule_id: ruleId, + start: gapStart, + end: gapEnd, + }); + + expect(finalGapResponse.statusCode).to.eql(200); + expect(finalGapResponse.body.total).to.eql(1); + const finalGap = finalGapResponse.body.data[0]; + expect(finalGap.in_progress_intervals).to.have.length(0); + expect(finalGap.status).to.eql('unfilled'); + expect(finalGap.unfilled_intervals).to.have.length(1); + expect(finalGap.unfilled_intervals[0].gte).to.eql(gapStart); + expect(finalGap.unfilled_intervals[0].lte).to.eql(gapEnd); + expect(finalGap.filled_intervals).to.have.length(0); + }); + }); + + it('should handle partial gap filling when backfill overlaps', async () => { + const { space } = SuperuserAtSpace1; + + // Create a rule + const ruleResponse = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getRule()) + .expect(200); + const ruleId = ruleResponse.body.id; + objectRemover.add(space.id, ruleId, 'rule', 'alerting'); + + // Report a gap + await supertest + .post(`${getUrlPrefix(space.id)}/_test/report_gap`) + .set('kbn-xsrf', 'foo') + .send({ + ruleId, + start: gapStart, + end: gapEnd, + spaceId: space.id, + }); + + // Schedule backfill for only part of the gap + const partialStart = moment(gapStart).add(12, 'hours').toISOString(); + const partialEnd = moment(gapEnd).add(6, 'hours').toISOString(); + + const scheduleResponse = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .send([{ rule_id: ruleId, start: partialStart, end: partialEnd }]); + + expect(scheduleResponse.statusCode).to.eql(200); + const backfillId = scheduleResponse.body[0].id; + + // Wait for backfill to complete + await waitForBackfillComplete(backfillId, space.id); + + // Verify gap is partially filled + const finalGapResponse = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/gaps/_find`) + .set('kbn-xsrf', 'foo') + .send({ + rule_id: ruleId, + start: gapStart, + end: gapEnd, + }); + + expect(finalGapResponse.statusCode).to.eql(200); + expect(finalGapResponse.body.total).to.eql(1); + const gap = finalGapResponse.body.data[0]; + expect(gap.status).to.eql('partially_filled'); + expect(gap.filled_duration_ms).to.eql(12 * 60 * 60 * 1000); + expect(gap.filled_intervals[0].gte).to.eql(partialStart); + expect(gap.filled_intervals[0].lte).to.eql(gapEnd); + expect(gap.unfilled_intervals[0].gte).to.eql(gapStart); + expect(gap.unfilled_intervals[0].lte).to.eql(partialStart); + expect(gap.unfilled_duration_ms).to.be.eql(12 * 60 * 60 * 1000); + expect(gap.unfilled_intervals).to.have.length(1); + expect(gap.filled_intervals).to.have.length(1); + expect(gap.in_progress_intervals).to.have.length(0); + }); + + it('should fill gap with multiple backfills', async () => { + const { space } = SuperuserAtSpace1; + + // Create a rule + const ruleResponse = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getRule()) + .expect(200); + const ruleId = ruleResponse.body.id; + objectRemover.add(space.id, ruleId, 'rule', 'alerting'); + + // Report a gap + await supertest + .post(`${getUrlPrefix(space.id)}/_test/report_gap`) + .set('kbn-xsrf', 'foo') + .send({ + ruleId, + start: gapStart, + end: gapEnd, + spaceId: space.id, + }); + + // Schedule two backfills that together cover the entire gap + const firstHalfEnd = moment(gapStart).add(12, 'hours').toISOString(); + const secondHalfStart = firstHalfEnd; + + const scheduleResponse = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .send([ + { rule_id: ruleId, start: gapStart, end: firstHalfEnd }, + { rule_id: ruleId, start: secondHalfStart, end: gapEnd }, + ]); + + expect(scheduleResponse.statusCode).to.eql(200); + expect(scheduleResponse.body).to.have.length(2); + + // Wait for both backfills to complete + await Promise.all( + scheduleResponse.body.map((result: { id: string }) => + waitForBackfillComplete(result.id, space.id) + ) + ); + + // Verify gap is completely filled + const finalGapResponse = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/gaps/_find`) + .set('kbn-xsrf', 'foo') + .send({ + rule_id: ruleId, + start: gapStart, + end: gapEnd, + }); + + expect(finalGapResponse.statusCode).to.eql(200); + expect(finalGapResponse.body.total).to.eql(1); + const finalGap = finalGapResponse.body.data[0]; + expect(finalGap.status).to.eql('filled'); + expect(finalGap.filled_duration_ms).to.eql(86400000); + expect(finalGap.unfilled_duration_ms).to.eql(0); + expect(finalGap.filled_intervals).to.have.length(1); + expect(finalGap.filled_intervals[0].gte).to.eql(gapStart); + expect(finalGap.filled_intervals[0].lte).to.eql(gapEnd); + expect(finalGap.unfilled_intervals).to.have.length(0); + expect(finalGap.in_progress_intervals).to.have.length(0); + }); + + it('should update gap status after backfill is deleted', async () => { + const { space } = SuperuserAtSpace1; + + // Create a rule with timeout pattern to ensure we can catch it in progress + const ruleResponse = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getRule({ + params: { + pattern: { + instance: ['timeout'], + }, + }, + }) + ) + .expect(200); + const ruleId = ruleResponse.body.id; + objectRemover.add(space.id, ruleId, 'rule', 'alerting'); + + // Report a gap + await supertest + .post(`${getUrlPrefix(space.id)}/_test/report_gap`) + .set('kbn-xsrf', 'foo') + .send({ + ruleId, + start: gapStart, + end: gapEnd, + spaceId: space.id, + }); + + // Schedule backfill + const scheduleResponse = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .send([{ rule_id: ruleId, start: gapStart, end: gapEnd }]); + + expect(scheduleResponse.statusCode).to.eql(200); + const backfillId = scheduleResponse.body[0].id; + + // Wait for in-progress state + await retry.try(async () => { + const inProgressResponse = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/gaps/_find`) + .set('kbn-xsrf', 'foo') + .send({ + rule_id: ruleId, + start: gapStart, + end: gapEnd, + }); + + expect(inProgressResponse.statusCode).to.eql(200); + expect(inProgressResponse.body.data[0].status).to.eql('partially_filled'); + expect(inProgressResponse.body.data[0].in_progress_intervals).to.have.length(1); + }); + + // Delete backfill while in progress + await supertest + .delete(`${getUrlPrefix(space.id)}/internal/alerting/rules/backfill/${backfillId}`) + .set('kbn-xsrf', 'foo') + .expect(204); + + // Verify gap status is updated + const finalGapResponse = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/gaps/_find`) + .set('kbn-xsrf', 'foo') + .send({ + rule_id: ruleId, + start: gapStart, + end: gapEnd, + }); + + expect(finalGapResponse.statusCode).to.eql(200); + expect(finalGapResponse.body.total).to.eql(1); + const gap = finalGapResponse.body.data[0]; + expect(gap.status).to.eql('unfilled'); + expect(gap.in_progress_intervals).to.have.length(0); + expect(gap.unfilled_intervals).to.have.length(1); + expect(gap.unfilled_intervals[0].gte).to.eql(gapStart); + expect(gap.unfilled_intervals[0].lte).to.eql(gapEnd); + expect(gap.filled_intervals).to.have.length(0); + }); + + it('should handle task failures', async () => { + const { space } = SuperuserAtSpace1; + + // Create a rule that always errors + const ruleResponse = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getRule({ + params: { + pattern: { + instance: ['error'], + }, + }, + }) + ) + .expect(200); + const ruleId = ruleResponse.body.id; + objectRemover.add(space.id, ruleId, 'rule', 'alerting'); + + // Report a gap + await supertest + .post(`${getUrlPrefix(space.id)}/_test/report_gap`) + .set('kbn-xsrf', 'foo') + .send({ + ruleId, + start: gapStart, + end: gapEnd, + spaceId: space.id, + }); + + // Schedule backfill + const scheduleResponse = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .send([{ rule_id: ruleId, start: gapStart, end: gapEnd }]); + + expect(scheduleResponse.statusCode).to.eql(200); + + await retry.try(async () => { + const firstGapResponse = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/gaps/_find`) + .set('kbn-xsrf', 'foo') + .send({ + rule_id: ruleId, + start: gapStart, + end: gapEnd, + }); + + const firstGap = firstGapResponse.body.data[0]; + expect(firstGap.status).to.eql('partially_filled'); + expect(firstGap.unfilled_intervals).to.have.length(0); + expect(firstGap.in_progress_intervals).to.have.length(1); + expect(firstGap.in_progress_intervals[0].gte).to.eql(gapStart); + expect(firstGap.in_progress_intervals[0].lte).to.eql(gapEnd); + expect(firstGap.filled_intervals).to.have.length(0); + }); + + const backfillId = scheduleResponse.body[0].id; + + // Wait for task failure event + await retry.try(async () => { + const events = await getEventLog({ + getService, + spaceId: space.id, + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + id: backfillId, + provider: 'alerting', + actions: new Map([['execute-backfill', { equal: 1 }]]), + }); + expect(events.length).to.eql(1); + expect(events[0]?.event?.outcome).to.eql('failure'); + expect(events[0]?.error?.message).to.eql('rule executor error'); + }); + + await waitForBackfillComplete(backfillId, space.id); + + // Verify gap status is updated + const finalGapResponse = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/gaps/_find`) + .set('kbn-xsrf', 'foo') + .send({ + rule_id: ruleId, + start: gapStart, + end: gapEnd, + }); + + expect(finalGapResponse.statusCode).to.eql(200); + expect(finalGapResponse.body.total).to.eql(1); + const gap = finalGapResponse.body.data[0]; + expect(gap.status).to.eql('unfilled'); + expect(gap.in_progress_intervals).to.have.length(0); + expect(gap.unfilled_intervals).to.have.length(1); + expect(gap.unfilled_intervals[0].gte).to.eql(gapStart); + expect(gap.unfilled_intervals[0].lte).to.eql(gapEnd); + expect(gap.filled_intervals).to.have.length(0); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/index.ts index 777479a631ec9..b1d66191d990f 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/index.ts @@ -23,6 +23,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./backfill')); loadTestFile(require.resolve('./find')); loadTestFile(require.resolve('./find_internal')); + loadTestFile(require.resolve('./gap')); }); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/event_log.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/event_log.ts index 2aa82d4c5cce0..1c2ba31c98608 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/event_log.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/event_log.ts @@ -2026,6 +2026,102 @@ export default function eventLogTests({ getService }: FtrProviderContext) { expect(get(executeEvents[5], ACTION_PATH)).to.be(0); expect(get(executeEvents[5], DELAYED_PATH)).to.be(1); }); + + it('should update event log document fields', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + connector_type_id: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + // Create a rule that will generate events + const response = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.patternFiring', + schedule: { interval: '1s' }, + throttle: null, + params: { + pattern: { + instance: [true, false], + }, + }, + actions: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + ], + }) + ); + + expect(response.status).to.eql(200); + const alertId = response.body.id; + objectRemover.add(space.id, alertId, 'rule', 'alerting'); + + // Get the events and find one to update + const events = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: space.id, + type: 'alert', + id: alertId, + provider: 'alerting', + actions: new Map([['execute', { gte: 1 }]]), + }); + }); + + expect(events.length).to.be.greaterThan(0); + const eventToUpdate = events[0]; + + // Prepare the update + const fieldsToUpdate = { + event: { kind: 'test_update' }, + new_field: 'Updated test message', + }; + + // Call the update API + const updateResponse = await supertest + .post(`${getUrlPrefix(space.id)}/_test/event_log/update_document`) + .set('kbn-xsrf', 'foo') + .send({ + _id: eventToUpdate._id, + _index: eventToUpdate._index, + _seq_no: eventToUpdate._seq_no, + _primary_term: eventToUpdate._primary_term, + fieldsToUpdate, + }) + .expect(200); + + expect(updateResponse.body.ok).to.be(true); + + // Verify the update by getting the event again + const updatedEvents = await retry.try(async () => { + const newResponse = await getEventLog({ + getService, + spaceId: space.id, + type: 'alert', + id: alertId, + provider: 'alerting', + actions: new Map([['execute', { gte: 1 }]]), + }); + + const updatedEvent = newResponse.find((event) => event._id === eventToUpdate._id); + expect(updatedEvent).to.be.ok(); + expect(updatedEvent.event.kind).to.be('test_update'); + expect(updatedEvent.new_field).to.be('Updated test message'); + + return response; + }); + }); }); } });