[ResponseOps][Alerting] Implement and onboard query delay mechanism for Alerting rules (#168735)

Resolves https://github.com/elastic/kibana/issues/167061

## Summary

This PR will merge the query delay feature branch in to main, and
includes the following PRs:

[[ResponseOps][Alerting] Onboard query delay mechanism for Alerting
rules](https://github.com/elastic/kibana/pull/167363)
[[ResponseOps][Alerting] Implement a query delay mechanism for Alerting
rules](https://github.com/elastic/kibana/pull/167433)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Ying Mao <ying.mao@elastic.co>
This commit is contained in:
Alexi Doak 2023-10-18 11:18:20 -07:00 committed by GitHub
parent 8284398023
commit 726558959f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
107 changed files with 3394 additions and 1120 deletions

View file

@ -0,0 +1,10 @@
/*
* 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 type { GetQueryDelaySettingsResponse } from './types/latest';
export type { GetQueryDelaySettingsResponse as GetQueryDelaySettingsResponseV1 } from './types/v1';

View file

@ -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 type { GetQueryDelaySettingsResponse } from './v1';

View file

@ -0,0 +1,11 @@
/*
* 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 { queryDelaySettingsResponseSchemaV1 } from '../../../response';
export type GetQueryDelaySettingsResponse = TypeOf<typeof queryDelaySettingsResponseSchemaV1>;

View file

@ -0,0 +1,18 @@
/*
* 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 { updateQueryDelaySettingsBodySchema } from './schemas/latest';
export type {
UpdateQueryDelaySettingsRequestBody,
UpdateQueryDelaySettingsResponse,
} from './types/latest';
export { updateQueryDelaySettingsBodySchema as updateQueryDelaySettingsBodySchemaV1 } from './schemas/v1';
export type {
UpdateQueryDelaySettingsRequestBody as UpdateQueryDelaySettingsRequestBodyV1,
UpdateQueryDelaySettingsResponse as UpdateQueryDelaySettingsResponseV1,
} from './types/v1';

View file

@ -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';

View file

@ -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 { schema } from '@kbn/config-schema';
export const updateQueryDelaySettingsBodySchema = schema.object({
delay: schema.number(),
});

View file

@ -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';

View file

@ -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 { queryDelaySettingsResponseSchemaV1 } from '../../../response';
import { updateQueryDelaySettingsBodySchemaV1 } from '..';
export type UpdateQueryDelaySettingsRequestBody = TypeOf<
typeof updateQueryDelaySettingsBodySchemaV1
>;
export type UpdateQueryDelaySettingsResponse = TypeOf<typeof queryDelaySettingsResponseSchemaV1>;

View file

@ -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 { queryDelaySettingsResponseSchema } from './schemas/latest';
export type { QueryDelaySettingsResponse } from './types/latest';
export { queryDelaySettingsResponseSchema as queryDelaySettingsResponseSchemaV1 } from './schemas/v1';
export type { QueryDelaySettingsResponse as QueryDelaySettingsResponseV1 } from './types/v1';

View file

@ -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';

View file

@ -0,0 +1,20 @@
/*
* 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';
export const queryDelaySettingsResponseBodySchema = schema.object({
delay: schema.number(),
created_by: schema.nullable(schema.string()),
updated_by: schema.nullable(schema.string()),
created_at: schema.string(),
updated_at: schema.string(),
});
export const queryDelaySettingsResponseSchema = schema.object({
body: queryDelaySettingsResponseBodySchema,
});

View file

@ -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';

View file

@ -0,0 +1,11 @@
/*
* 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 { queryDelaySettingsResponseSchemaV1 } from '..';
export type QueryDelaySettingsResponse = TypeOf<typeof queryDelaySettingsResponseSchemaV1>;

View file

@ -20,29 +20,51 @@ export interface RulesSettingsFlappingProperties {
export type RulesSettingsFlapping = RulesSettingsFlappingProperties &
RulesSettingsModificationMetadata;
export interface RulesSettingsQueryDelayProperties {
delay: number;
}
export type RulesSettingsQueryDelay = RulesSettingsQueryDelayProperties &
RulesSettingsModificationMetadata;
export interface RulesSettingsProperties {
flapping?: RulesSettingsFlappingProperties;
queryDelay?: RulesSettingsQueryDelayProperties;
}
export interface RulesSettings {
flapping: RulesSettingsFlapping;
flapping?: RulesSettingsFlapping;
queryDelay?: RulesSettingsQueryDelay;
}
export const MIN_LOOK_BACK_WINDOW = 2;
export const MAX_LOOK_BACK_WINDOW = 20;
export const MIN_STATUS_CHANGE_THRESHOLD = 2;
export const MAX_STATUS_CHANGE_THRESHOLD = 20;
export const MIN_QUERY_DELAY = 0;
export const MAX_QUERY_DELAY = 60;
export const RULES_SETTINGS_FEATURE_ID = 'rulesSettings';
export const ALL_FLAPPING_SETTINGS_SUB_FEATURE_ID = 'allFlappingSettings';
export const READ_FLAPPING_SETTINGS_SUB_FEATURE_ID = 'readFlappingSettings';
export const ALL_QUERY_DELAY_SETTINGS_SUB_FEATURE_ID = 'allQueryDelaySettings';
export const READ_QUERY_DELAY_SETTINGS_SUB_FEATURE_ID = 'readQueryDelaySettings';
export const API_PRIVILEGES = {
READ_FLAPPING_SETTINGS: 'read-flapping-settings',
WRITE_FLAPPING_SETTINGS: 'write-flapping-settings',
READ_QUERY_DELAY_SETTINGS: 'read-query-delay-settings',
WRITE_QUERY_DELAY_SETTINGS: 'write-query-delay-settings',
};
export const RULES_SETTINGS_SAVED_OBJECT_TYPE = 'rules-settings';
export const RULES_SETTINGS_SAVED_OBJECT_ID = 'rules-settings';
export const RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID = 'rules-settings';
export const RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID = 'query-delay-settings';
export const DEFAULT_LOOK_BACK_WINDOW = 20;
export const DEFAULT_STATUS_CHANGE_THRESHOLD = 4;
export const DEFAULT_QUERY_DELAY = 0;
export const DEFAULT_SERVERLESS_QUERY_DELAY = 15;
export const DEFAULT_FLAPPING_SETTINGS: RulesSettingsFlappingProperties = {
enabled: true,
@ -54,3 +76,10 @@ export const DISABLE_FLAPPING_SETTINGS: RulesSettingsFlappingProperties = {
...DEFAULT_FLAPPING_SETTINGS,
enabled: false,
};
export const DEFAULT_QUERY_DELAY_SETTINGS: RulesSettingsQueryDelayProperties = {
delay: DEFAULT_QUERY_DELAY,
};
export const DEFAULT_SERVERLESS_QUERY_DELAY_SETTINGS: RulesSettingsQueryDelayProperties = {
delay: DEFAULT_SERVERLESS_QUERY_DELAY,
};

View file

@ -0,0 +1,61 @@
/*
* 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 { loggingSystemMock } from '@kbn/core/server/mocks';
import { getTimeRange } from './get_time_range';
describe('getTimeRange', () => {
const logger = loggingSystemMock.create().get();
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2023-10-04T00:00:00.000Z'));
});
afterAll(() => {
jest.useRealTimers();
});
beforeEach(() => {
jest.resetAllMocks();
});
test('returns time range with no query delay', () => {
const { dateStart, dateEnd } = getTimeRange(logger, { delay: 0 }, '5m');
expect(dateStart).toBe('2023-10-03T23:55:00.000Z');
expect(dateEnd).toBe('2023-10-04T00:00:00.000Z');
expect(logger.debug).toHaveBeenCalledWith('Adjusting rule query time range by 0 seconds');
});
test('returns time range with a query delay', () => {
const { dateStart, dateEnd } = getTimeRange(logger, { delay: 45 }, '5m');
expect(dateStart).toBe('2023-10-03T23:54:15.000Z');
expect(dateEnd).toBe('2023-10-03T23:59:15.000Z');
expect(logger.debug).toHaveBeenCalledWith('Adjusting rule query time range by 45 seconds');
});
test('returns time range with no query delay and no time range', () => {
const { dateStart, dateEnd } = getTimeRange(logger, { delay: 0 });
expect(dateStart).toBe('2023-10-04T00:00:00.000Z');
expect(dateEnd).toBe('2023-10-04T00:00:00.000Z');
expect(logger.debug).toHaveBeenCalledWith('Adjusting rule query time range by 0 seconds');
});
test('returns time range with a query delay and no time range', () => {
const { dateStart, dateEnd } = getTimeRange(logger, { delay: 45 });
expect(dateStart).toBe('2023-10-03T23:59:15.000Z');
expect(dateEnd).toBe('2023-10-03T23:59:15.000Z');
expect(logger.debug).toHaveBeenCalledWith('Adjusting rule query time range by 45 seconds');
});
test('throws an error when the time window is invalid', () => {
expect(() => getTimeRange(logger, { delay: 45 }, '5k')).toThrowErrorMatchingInlineSnapshot(
`"Invalid format for windowSize: \\"5k\\""`
);
expect(logger.debug).not.toHaveBeenCalled();
});
});

View file

@ -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 { i18n } from '@kbn/i18n';
import { Logger } from '@kbn/logging';
import { parseDuration, RulesSettingsQueryDelayProperties } from '../../common';
export function getTimeRange(
logger: Logger,
queryDelaySettings: RulesSettingsQueryDelayProperties,
window?: string
) {
let timeWindow: number = 0;
if (window) {
try {
timeWindow = parseDuration(window);
} catch (err) {
throw new Error(
i18n.translate('xpack.alerting.invalidWindowSizeErrorMessage', {
defaultMessage: 'Invalid format for windowSize: "{window}"',
values: {
window,
},
})
);
}
}
logger.debug(`Adjusting rule query time range by ${queryDelaySettings.delay} seconds`);
const queryDelay = queryDelaySettings.delay * 1000;
const date = Date.now();
const dateStart = new Date(date - (timeWindow + queryDelay)).toISOString();
const dateEnd = new Date(date - queryDelay).toISOString();
return { dateStart, dateEnd };
}

View file

@ -190,6 +190,7 @@ export interface AlertingPluginsStart {
data: DataPluginStart;
dataViews: DataViewsPluginStart;
share: SharePluginStart;
serverless?: ServerlessPluginSetup;
}
export class AlertingPlugin {
@ -503,6 +504,7 @@ export class AlertingPlugin {
logger: this.logger,
savedObjectsService: core.savedObjects,
securityPluginStart: plugins.security,
isServerless: !!plugins.serverless,
});
maintenanceWindowClientFactory.initialize({

View file

@ -62,6 +62,8 @@ import { registerRulesValueSuggestionsRoute } from './suggestions/values_suggest
import { registerFieldsRoute } from './suggestions/fields_rules';
import { bulkGetMaintenanceWindowRoute } from './maintenance_window/apis/bulk_get/bulk_get_maintenance_windows_route';
import { registerAlertsValueSuggestionsRoute } from './suggestions/values_suggestion_alerts';
import { getQueryDelaySettingsRoute } from './rules_settings/apis/get/get_query_delay_settings';
import { updateQueryDelaySettingsRoute } from './rules_settings/apis/update/update_query_delay_settings';
export interface RouteOptions {
router: IRouter<AlertingRequestHandlerContext>;
@ -133,4 +135,6 @@ export function defineRoutes(opts: RouteOptions) {
bulkGetMaintenanceWindowRoute(router, licenseState);
getScheduleFrequencyRoute(router, licenseState);
bulkUntrackAlertRoute(router, licenseState);
getQueryDelaySettingsRoute(router, licenseState);
updateQueryDelaySettingsRoute(router, licenseState);
}

View file

@ -0,0 +1,72 @@
/*
* 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 { mockHandlerArguments } from '../../../_mock_handler_arguments';
import {
rulesSettingsClientMock,
RulesSettingsClientMock,
} from '../../../../rules_settings_client.mock';
import { getQueryDelaySettingsRoute } from './get_query_delay_settings';
let rulesSettingsClient: RulesSettingsClientMock;
jest.mock('../../../../lib/license_api_access', () => ({
verifyApiAccess: jest.fn(),
}));
beforeEach(() => {
jest.resetAllMocks();
rulesSettingsClient = rulesSettingsClientMock.create();
});
describe('getQueryDelaySettingsRoute', () => {
test('gets query delay settings', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
getQueryDelaySettingsRoute(router, licenseState);
const [config, handler] = router.get.mock.calls[0];
expect(config).toMatchInlineSnapshot(`
Object {
"options": Object {
"tags": Array [
"access:read-query-delay-settings",
],
},
"path": "/internal/alerting/rules/settings/_query_delay",
"validate": Object {},
}
`);
(rulesSettingsClient.queryDelay().get as jest.Mock).mockResolvedValue({
delay: 10,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const [context, req, res] = mockHandlerArguments({ rulesSettingsClient }, {}, ['ok']);
await handler(context, req, res);
expect(rulesSettingsClient.queryDelay().get).toHaveBeenCalledTimes(1);
expect(res.ok).toHaveBeenCalledWith({
body: expect.objectContaining({
delay: 10,
created_by: 'test name',
updated_by: 'test name',
created_at: expect.any(String),
updated_at: expect.any(String),
}),
});
});
});

View file

@ -0,0 +1,39 @@
/*
* 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 { ILicenseState } from '../../../../lib';
import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../../../../types';
import { verifyAccessAndContext } from '../../../lib';
import { API_PRIVILEGES } from '../../../../../common';
import { transformQueryDelaySettingsToResponseV1 } from '../../transforms';
import { GetQueryDelaySettingsResponseV1 } from '../../../../../common/routes/rules_settings/apis/get';
export const getQueryDelaySettingsRoute = (
router: IRouter<AlertingRequestHandlerContext>,
licenseState: ILicenseState
) => {
router.get(
{
path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/settings/_query_delay`,
validate: {},
options: {
tags: [`access:${API_PRIVILEGES.READ_QUERY_DELAY_SETTINGS}`],
},
},
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async function (context, req, res) {
const rulesSettingsClient = (await context.alerting).getRulesSettingsClient();
const queryDelaySettings = await rulesSettingsClient.queryDelay().get();
const response: GetQueryDelaySettingsResponseV1 =
transformQueryDelaySettingsToResponseV1(queryDelaySettings);
return res.ok(response);
})
)
);
};

View file

@ -0,0 +1,88 @@
/*
* 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 { mockHandlerArguments } from '../../../_mock_handler_arguments';
import {
rulesSettingsClientMock,
RulesSettingsClientMock,
} from '../../../../rules_settings_client.mock';
import { updateQueryDelaySettingsRoute } from './update_query_delay_settings';
let rulesSettingsClient: RulesSettingsClientMock;
jest.mock('../../../../lib/license_api_access', () => ({
verifyApiAccess: jest.fn(),
}));
beforeEach(() => {
jest.resetAllMocks();
rulesSettingsClient = rulesSettingsClientMock.create();
});
const mockQueryDelaySettings = {
delay: 10,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
describe('updateQueryDelaySettingsRoute', () => {
test('updates query delay settings', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
updateQueryDelaySettingsRoute(router, licenseState);
const [config, handler] = router.post.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(`"/internal/alerting/rules/settings/_query_delay"`);
expect(config.options).toMatchInlineSnapshot(`
Object {
"tags": Array [
"access:write-query-delay-settings",
],
}
`);
(rulesSettingsClient.queryDelay().get as jest.Mock).mockResolvedValue(mockQueryDelaySettings);
(rulesSettingsClient.queryDelay().update as jest.Mock).mockResolvedValue(
mockQueryDelaySettings
);
const updateResult = {
delay: 6,
};
const [context, req, res] = mockHandlerArguments(
{ rulesSettingsClient },
{
body: updateResult,
},
['ok']
);
await handler(context, req, res);
expect(rulesSettingsClient.queryDelay().update).toHaveBeenCalledTimes(1);
expect((rulesSettingsClient.queryDelay().update as jest.Mock).mock.calls[0])
.toMatchInlineSnapshot(`
Array [
Object {
"delay": 6,
},
]
`);
expect(res.ok).toHaveBeenCalledWith({
body: expect.objectContaining({
delay: 10,
}),
});
});
});

View file

@ -0,0 +1,49 @@
/*
* 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 { ILicenseState } from '../../../../lib';
import { verifyAccessAndContext } from '../../../lib';
import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../../../../types';
import { API_PRIVILEGES } from '../../../../../common';
import {
updateQueryDelaySettingsBodySchemaV1,
UpdateQueryDelaySettingsRequestBodyV1,
UpdateQueryDelaySettingsResponseV1,
} from '../../../../../common/routes/rules_settings/apis/update';
import { transformQueryDelaySettingsToResponseV1 } from '../../transforms';
export const updateQueryDelaySettingsRoute = (
router: IRouter<AlertingRequestHandlerContext>,
licenseState: ILicenseState
) => {
router.post(
{
path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/settings/_query_delay`,
validate: {
body: updateQueryDelaySettingsBodySchemaV1,
},
options: {
tags: [`access:${API_PRIVILEGES.WRITE_QUERY_DELAY_SETTINGS}`],
},
},
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async function (context, req, res) {
const rulesSettingsClient = (await context.alerting).getRulesSettingsClient();
const body: UpdateQueryDelaySettingsRequestBodyV1 = req.body;
const updatedQueryDelaySettings = await rulesSettingsClient.queryDelay().update(body);
const response: UpdateQueryDelaySettingsResponseV1 =
transformQueryDelaySettingsToResponseV1(updatedQueryDelaySettings);
return res.ok(response);
})
)
);
};

View file

@ -0,0 +1,10 @@
/*
* 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 { transformQueryDelaySettingsToResponse } from './transform_query_delay_settings_to_response/latest';
export { transformQueryDelaySettingsToResponse as transformQueryDelaySettingsToResponseV1 } from './transform_query_delay_settings_to_response/v1';

View file

@ -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';

View file

@ -0,0 +1,23 @@
/*
* 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 { RulesSettingsQueryDelay } from '../../../../../common';
import { QueryDelaySettingsResponseV1 } from '../../../../../common/routes/rules_settings/response';
export const transformQueryDelaySettingsToResponse = (
settings: RulesSettingsQueryDelay
): QueryDelaySettingsResponseV1 => {
return {
body: {
delay: settings.delay,
created_by: settings.createdBy,
updated_by: settings.updatedBy,
created_at: settings.createdAt,
updated_at: settings.updatedAt,
},
};
};

View file

@ -8,11 +8,14 @@
import {
RulesSettingsClientApi,
RulesSettingsFlappingClientApi,
RulesSettingsQueryDelayClientApi,
DEFAULT_FLAPPING_SETTINGS,
DEFAULT_QUERY_DELAY_SETTINGS,
} from './types';
export type RulesSettingsClientMock = jest.Mocked<RulesSettingsClientApi>;
export type RulesSettingsFlappingClientMock = jest.Mocked<RulesSettingsFlappingClientApi>;
export type RulesSettingsQueryDelayClientMock = jest.Mocked<RulesSettingsQueryDelayClientApi>;
// Warning: Becareful when resetting all mocks in tests as it would clear
// the mock return value on the flapping
@ -20,11 +23,18 @@ const createRulesSettingsClientMock = () => {
const flappingMocked: RulesSettingsFlappingClientMock = {
get: jest.fn().mockReturnValue(DEFAULT_FLAPPING_SETTINGS),
update: jest.fn(),
getSettings: jest.fn(),
createSettings: jest.fn(),
};
const queryDelayMocked: RulesSettingsQueryDelayClientMock = {
get: jest.fn().mockReturnValue(DEFAULT_QUERY_DELAY_SETTINGS),
update: jest.fn(),
getSettings: jest.fn(),
createSettings: jest.fn(),
};
const mocked: RulesSettingsClientMock = {
get: jest.fn(),
create: jest.fn(),
flapping: jest.fn().mockReturnValue(flappingMocked),
queryDelay: jest.fn().mockReturnValue(queryDelayMocked),
};
return mocked;
};

View file

@ -13,10 +13,11 @@ import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mock
import {
RULES_SETTINGS_FEATURE_ID,
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID,
RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID,
DEFAULT_FLAPPING_SETTINGS,
RulesSettings,
} from '../../../common';
import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server';
const mockDateString = '2019-02-12T21:01:22.479Z';
@ -39,13 +40,6 @@ const getMockRulesSettings = (): RulesSettings => {
const rulesSettingsFlappingClientParams: jest.Mocked<RulesSettingsFlappingClientConstructorOptions> =
{
logger: loggingSystemMock.create().get(),
getOrCreate: jest.fn().mockReturnValue({
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: getMockRulesSettings(),
references: [],
version: '123',
}),
getModificationMetadata: jest.fn(),
savedObjectsClient,
};
@ -58,9 +52,21 @@ const updatedMetadata = {
};
describe('RulesSettingsFlappingClient', () => {
beforeEach(() =>
rulesSettingsFlappingClientParams.getModificationMetadata.mockResolvedValue(updatedMetadata)
);
beforeEach(() => {
rulesSettingsFlappingClientParams.getModificationMetadata.mockResolvedValue(updatedMetadata);
savedObjectsClient.get.mockResolvedValue({
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: getMockRulesSettings(),
references: [],
version: '123',
});
});
afterEach(() => {
jest.clearAllMocks();
});
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date(mockDateString));
@ -119,7 +125,7 @@ describe('RulesSettingsFlappingClient', () => {
expect(savedObjectsClient.update).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID,
RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID,
{
flapping: expect.objectContaining({
enabled: false,
@ -192,4 +198,243 @@ describe('RulesSettingsFlappingClient', () => {
'Invalid values,lookBackWindow (10) must be equal to or greater than statusChangeThreshold (20).'
);
});
test('can create a new flapping settings saved object', async () => {
rulesSettingsFlappingClientParams.getModificationMetadata.mockResolvedValueOnce({
...updatedMetadata,
createdBy: 'test name',
updatedBy: 'test name',
});
const client = new RulesSettingsFlappingClient(rulesSettingsFlappingClientParams);
const mockAttributes = getMockRulesSettings();
savedObjectsClient.create.mockResolvedValueOnce({
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: mockAttributes,
references: [],
});
const result = await client.createSettings();
expect(savedObjectsClient.create).toHaveBeenCalledTimes(1);
expect(savedObjectsClient.create).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
{
flapping: expect.objectContaining({
enabled: mockAttributes.flapping?.enabled,
lookBackWindow: mockAttributes.flapping?.lookBackWindow,
statusChangeThreshold: mockAttributes.flapping?.statusChangeThreshold,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: expect.any(String),
updatedAt: expect.any(String),
}),
},
{
id: RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID,
overwrite: true,
}
);
expect(result.attributes).toEqual(mockAttributes);
});
test('can get existing flapping settings saved object', async () => {
const client = new RulesSettingsFlappingClient(rulesSettingsFlappingClientParams);
const mockAttributes = getMockRulesSettings();
savedObjectsClient.get.mockResolvedValueOnce({
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: mockAttributes,
references: [],
});
const result = await client.getSettings();
expect(result.attributes).toEqual(mockAttributes);
});
test('throws if there is no existing saved object to get', async () => {
const client = new RulesSettingsFlappingClient(rulesSettingsFlappingClientParams);
savedObjectsClient.get.mockRejectedValueOnce(
SavedObjectsErrorHelpers.createGenericNotFoundError(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID
)
);
await expect(client.getSettings()).rejects.toThrowError();
});
test('can persist flapping settings when saved object does not exist', async () => {
rulesSettingsFlappingClientParams.getModificationMetadata.mockResolvedValueOnce({
...updatedMetadata,
createdBy: 'test name',
updatedBy: 'test name',
});
const client = new RulesSettingsFlappingClient(rulesSettingsFlappingClientParams);
const mockAttributes = getMockRulesSettings();
savedObjectsClient.get.mockRejectedValueOnce(
SavedObjectsErrorHelpers.createGenericNotFoundError(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID
)
);
savedObjectsClient.create.mockResolvedValueOnce({
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: mockAttributes,
references: [],
});
const result = await client.get();
expect(savedObjectsClient.get).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID
);
expect(savedObjectsClient.create).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
{
flapping: expect.objectContaining({
enabled: mockAttributes.flapping?.enabled,
lookBackWindow: mockAttributes.flapping?.lookBackWindow,
statusChangeThreshold: mockAttributes.flapping?.statusChangeThreshold,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: expect.any(String),
updatedAt: expect.any(String),
}),
},
{
id: RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID,
overwrite: true,
}
);
expect(result).toEqual(mockAttributes.flapping);
});
test('can persist flapping settings when saved object already exists', async () => {
rulesSettingsFlappingClientParams.getModificationMetadata.mockResolvedValueOnce({
...updatedMetadata,
createdBy: 'test name',
updatedBy: 'test name',
});
const client = new RulesSettingsFlappingClient(rulesSettingsFlappingClientParams);
const mockAttributes = getMockRulesSettings();
savedObjectsClient.get.mockResolvedValueOnce({
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: mockAttributes,
references: [],
});
const result = await client.get();
expect(savedObjectsClient.get).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID
);
expect(savedObjectsClient.create).not.toHaveBeenCalled();
expect(result).toEqual(mockAttributes.flapping);
});
test('can update flapping settings when saved object does not exist', async () => {
rulesSettingsFlappingClientParams.getModificationMetadata.mockResolvedValueOnce({
...updatedMetadata,
createdBy: 'test name',
updatedBy: 'test name',
});
const client = new RulesSettingsFlappingClient(rulesSettingsFlappingClientParams);
const mockAttributes = getMockRulesSettings();
savedObjectsClient.get.mockRejectedValueOnce(
SavedObjectsErrorHelpers.createGenericNotFoundError(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID
)
);
const mockResolve = {
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: mockAttributes,
references: [],
version: '123',
};
savedObjectsClient.create.mockResolvedValueOnce(mockResolve);
savedObjectsClient.update.mockResolvedValueOnce({
...mockResolve,
attributes: {
flapping: {
...mockResolve.attributes.flapping,
enabled: false,
lookBackWindow: 5,
statusChangeThreshold: 5,
},
},
});
// Try to update with new values
const result = await client.update({
enabled: false,
lookBackWindow: 5,
statusChangeThreshold: 5,
});
// Tried to get first, but no results
expect(savedObjectsClient.get).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID
);
// So create a new entry
expect(savedObjectsClient.create).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
{
flapping: expect.objectContaining({
enabled: mockAttributes.flapping?.enabled,
lookBackWindow: mockAttributes.flapping?.lookBackWindow,
statusChangeThreshold: mockAttributes.flapping?.statusChangeThreshold,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: expect.any(String),
updatedAt: expect.any(String),
}),
},
{
id: RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID,
overwrite: true,
}
);
// Try to update with version
expect(savedObjectsClient.update).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID,
{
flapping: expect.objectContaining({
enabled: false,
lookBackWindow: 5,
statusChangeThreshold: 5,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: expect.any(String),
updatedAt: expect.any(String),
}),
},
{ version: '123' }
);
expect(result).toEqual(
expect.objectContaining({
enabled: false,
lookBackWindow: 5,
statusChangeThreshold: 5,
})
);
});
});

View file

@ -6,7 +6,12 @@
*/
import Boom from '@hapi/boom';
import { Logger, SavedObjectsClientContract, SavedObject } from '@kbn/core/server';
import {
Logger,
SavedObjectsClientContract,
SavedObject,
SavedObjectsErrorHelpers,
} from '@kbn/core/server';
import {
RulesSettings,
RulesSettingsFlapping,
@ -17,8 +22,11 @@ import {
MIN_STATUS_CHANGE_THRESHOLD,
MAX_STATUS_CHANGE_THRESHOLD,
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID,
RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID,
DEFAULT_FLAPPING_SETTINGS,
} from '../../../common';
import { retryIfConflicts } from '../../lib/retry_if_conflicts';
import { flappingSchema } from '../schemas';
const verifyFlappingSettings = (flappingSettings: RulesSettingsFlappingProperties) => {
const { lookBackWindow, statusChangeThreshold } = flappingSettings;
@ -48,30 +56,42 @@ const verifyFlappingSettings = (flappingSettings: RulesSettingsFlappingPropertie
export interface RulesSettingsFlappingClientConstructorOptions {
readonly logger: Logger;
readonly savedObjectsClient: SavedObjectsClientContract;
readonly getOrCreate: () => Promise<SavedObject<RulesSettings>>;
readonly getModificationMetadata: () => Promise<RulesSettingsModificationMetadata>;
}
export class RulesSettingsFlappingClient {
private readonly logger: Logger;
private readonly savedObjectsClient: SavedObjectsClientContract;
private readonly getOrCreate: () => Promise<SavedObject<RulesSettings>>;
private readonly getModificationMetadata: () => Promise<RulesSettingsModificationMetadata>;
constructor(options: RulesSettingsFlappingClientConstructorOptions) {
this.logger = options.logger;
this.savedObjectsClient = options.savedObjectsClient;
this.getOrCreate = options.getOrCreate;
this.getModificationMetadata = options.getModificationMetadata;
}
public async get(): Promise<RulesSettingsFlapping> {
const rulesSettings = await this.getOrCreate();
if (!rulesSettings.attributes.flapping) {
this.logger.error('Failed to get flapping rules setting for current space.');
throw new Error(
'Failed to get flapping rules setting for current space. Flapping settings are undefined'
);
}
return rulesSettings.attributes.flapping;
}
public async update(newFlappingProperties: RulesSettingsFlappingProperties) {
return await retryIfConflicts(
this.logger,
'ruleSettingsClient.flapping.update()',
async () => await this.updateWithOCC(newFlappingProperties)
);
}
private async updateWithOCC(newFlappingProperties: RulesSettingsFlappingProperties) {
try {
flappingSchema.validate(newFlappingProperties);
verifyFlappingSettings(newFlappingProperties);
} catch (e) {
this.logger.error(
@ -81,14 +101,16 @@ export class RulesSettingsFlappingClient {
}
const { attributes, version } = await this.getOrCreate();
const modificationMetadata = await this.getModificationMetadata();
if (!attributes.flapping) {
throw new Error('Flapping settings are undefined');
}
const modificationMetadata = await this.getModificationMetadata();
try {
const result = await this.savedObjectsClient.update(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID,
RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID,
{
...attributes,
flapping: {
...attributes.flapping,
...newFlappingProperties,
@ -107,4 +129,55 @@ export class RulesSettingsFlappingClient {
throw Boom.boomify(e, { message: errorMessage });
}
}
public async getSettings(): Promise<SavedObject<RulesSettings>> {
try {
return await this.savedObjectsClient.get<RulesSettings>(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID
);
} catch (e) {
this.logger.error(`Failed to get flapping rules setting for current space. Error: ${e}`);
throw e;
}
}
public async createSettings(): Promise<SavedObject<RulesSettings>> {
const modificationMetadata = await this.getModificationMetadata();
try {
return await this.savedObjectsClient.create<RulesSettings>(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
{
flapping: {
...DEFAULT_FLAPPING_SETTINGS,
...modificationMetadata,
},
},
{
id: RULES_SETTINGS_FLAPPING_SAVED_OBJECT_ID,
overwrite: true,
}
);
} catch (e) {
this.logger.error(`Failed to create flapping rules setting for current space. Error: ${e}`);
throw e;
}
}
/**
* Helper function to ensure that a rules-settings saved object always exists.
* Ensures the creation of the saved object is done lazily during retrieval.
*/
private async getOrCreate(): Promise<SavedObject<RulesSettings>> {
try {
return await this.getSettings();
} catch (e) {
if (SavedObjectsErrorHelpers.isNotFoundError(e)) {
this.logger.info('Creating new default flapping rules settings for current space.');
return await this.createSettings();
}
this.logger.error(`Failed to get flapping rules setting for current space. Error: ${e}`);
throw e;
}
}
}

View file

@ -7,3 +7,4 @@
export * from './rules_settings_client';
export * from './flapping/rules_settings_flapping_client';
export * from './query_delay/rules_settings_query_delay_client';

View file

@ -0,0 +1,385 @@
/*
* 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 { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server';
import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mocks';
import {
RULES_SETTINGS_FEATURE_ID,
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID,
RulesSettings,
DEFAULT_QUERY_DELAY_SETTINGS,
} from '../../../common';
import {
RulesSettingsQueryDelayClient,
RulesSettingsQueryDelayClientConstructorOptions,
} from './rules_settings_query_delay_client';
const mockDateString = '2019-02-12T21:01:22.479Z';
const savedObjectsClient = savedObjectsClientMock.create();
const getMockRulesSettings = (): RulesSettings => {
return {
queryDelay: {
delay: DEFAULT_QUERY_DELAY_SETTINGS.delay,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: '2023-03-24T00:00:00.000Z',
updatedAt: '2023-03-24T00:00:00.000Z',
},
};
};
const rulesSettingsQueryDelayClientParams: jest.Mocked<RulesSettingsQueryDelayClientConstructorOptions> =
{
logger: loggingSystemMock.create().get(),
isServerless: false,
getModificationMetadata: jest.fn(),
savedObjectsClient,
};
const updatedMetadata = {
createdAt: '2023-03-26T00:00:00.000Z',
updatedAt: '2023-03-26T00:00:00.000Z',
createdBy: 'updated-user',
updatedBy: 'updated-user',
};
describe('RulesSettingsQueryDelayClient', () => {
beforeEach(() => {
rulesSettingsQueryDelayClientParams.getModificationMetadata.mockResolvedValue(updatedMetadata);
savedObjectsClient.get.mockResolvedValue({
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: getMockRulesSettings(),
references: [],
version: '123',
});
});
afterEach(() => {
jest.clearAllMocks();
});
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date(mockDateString));
});
afterAll(() => {
jest.clearAllMocks();
jest.useRealTimers();
});
test('can get query delay settings', async () => {
const client = new RulesSettingsQueryDelayClient(rulesSettingsQueryDelayClientParams);
const result = await client.get();
expect(result).toEqual(
expect.objectContaining({
delay: DEFAULT_QUERY_DELAY_SETTINGS.delay,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: expect.any(String),
updatedAt: expect.any(String),
})
);
});
test('can update query delay settings', async () => {
const client = new RulesSettingsQueryDelayClient(rulesSettingsQueryDelayClientParams);
const mockResolve = {
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: getMockRulesSettings(),
references: [],
version: '123',
};
savedObjectsClient.update.mockResolvedValueOnce({
...mockResolve,
attributes: {
queryDelay: {
...mockResolve.attributes.queryDelay,
delay: 19,
},
},
});
const result = await client.update({
delay: 19,
});
expect(savedObjectsClient.update).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID,
{
queryDelay: expect.objectContaining({
delay: 19,
updatedAt: '2023-03-26T00:00:00.000Z',
updatedBy: 'updated-user',
createdBy: 'test name',
createdAt: '2023-03-24T00:00:00.000Z',
}),
},
{ version: '123' }
);
expect(result).toEqual(
expect.objectContaining({
delay: 19,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: expect.any(String),
updatedAt: expect.any(String),
})
);
});
test('throws if savedObjectsClient failed to update', async () => {
const client = new RulesSettingsQueryDelayClient(rulesSettingsQueryDelayClientParams);
savedObjectsClient.update.mockRejectedValueOnce(new Error('failed!!'));
await expect(
client.update({
delay: 19,
})
).rejects.toThrowError(
'savedObjectsClient errored trying to update query delay settings: failed!!'
);
});
test('throws if new query delay setting fails verification', async () => {
const client = new RulesSettingsQueryDelayClient(rulesSettingsQueryDelayClientParams);
await expect(
client.update({
delay: 200,
})
).rejects.toThrowError('Invalid query delay value, must be between 0 and 60, but got: 200.');
});
test('can create a new query delay settings saved object', async () => {
rulesSettingsQueryDelayClientParams.getModificationMetadata.mockResolvedValueOnce({
...updatedMetadata,
createdBy: 'test name',
updatedBy: 'test name',
});
const client = new RulesSettingsQueryDelayClient(rulesSettingsQueryDelayClientParams);
const mockAttributes = getMockRulesSettings();
savedObjectsClient.create.mockResolvedValueOnce({
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: mockAttributes,
references: [],
});
const result = await client.createSettings();
expect(savedObjectsClient.create).toHaveBeenCalledTimes(1);
expect(savedObjectsClient.create).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
{
queryDelay: expect.objectContaining({
delay: 0,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: expect.any(String),
updatedAt: expect.any(String),
}),
},
{
id: RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID,
overwrite: true,
}
);
expect(result.attributes).toEqual(mockAttributes);
});
test('can create a new query delay settings saved object with default serverless value', async () => {
rulesSettingsQueryDelayClientParams.getModificationMetadata.mockResolvedValueOnce({
...updatedMetadata,
createdBy: 'test name',
updatedBy: 'test name',
});
const client = new RulesSettingsQueryDelayClient({
...rulesSettingsQueryDelayClientParams,
isServerless: true,
});
const mockAttributes = getMockRulesSettings();
savedObjectsClient.create.mockResolvedValueOnce({
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: mockAttributes,
references: [],
});
const result = await client.createSettings();
expect(savedObjectsClient.create).toHaveBeenCalledTimes(1);
expect(savedObjectsClient.create).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
{
queryDelay: expect.objectContaining({
delay: 15,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: expect.any(String),
updatedAt: expect.any(String),
}),
},
{
id: RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID,
overwrite: true,
}
);
expect(result.attributes).toEqual(mockAttributes);
});
test('can get existing query delay settings saved object', async () => {
const client = new RulesSettingsQueryDelayClient(rulesSettingsQueryDelayClientParams);
const mockAttributes = getMockRulesSettings();
savedObjectsClient.get.mockResolvedValueOnce({
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: mockAttributes,
references: [],
});
const result = await client.getSettings();
expect(result.attributes).toEqual(mockAttributes);
});
test('throws if there is no existing saved object to get', async () => {
const client = new RulesSettingsQueryDelayClient(rulesSettingsQueryDelayClientParams);
savedObjectsClient.get.mockRejectedValueOnce(
SavedObjectsErrorHelpers.createGenericNotFoundError(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID
)
);
await expect(client.get()).rejects.toThrowError();
});
test('can persist query delay settings when saved object already exists', async () => {
rulesSettingsQueryDelayClientParams.getModificationMetadata.mockResolvedValueOnce({
...updatedMetadata,
createdBy: 'test name',
updatedBy: 'test name',
});
const client = new RulesSettingsQueryDelayClient(rulesSettingsQueryDelayClientParams);
const mockAttributes = getMockRulesSettings();
savedObjectsClient.get.mockResolvedValueOnce({
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: mockAttributes,
references: [],
});
const result = await client.get();
expect(savedObjectsClient.get).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID
);
expect(savedObjectsClient.create).not.toHaveBeenCalled();
expect(result).toEqual(mockAttributes.queryDelay);
});
test('can update query delay settings when saved object does not exist', async () => {
rulesSettingsQueryDelayClientParams.getModificationMetadata.mockResolvedValueOnce({
...updatedMetadata,
createdBy: 'test name',
updatedBy: 'test name',
});
const client = new RulesSettingsQueryDelayClient(rulesSettingsQueryDelayClientParams);
const mockAttributes = getMockRulesSettings();
savedObjectsClient.get.mockRejectedValueOnce(
SavedObjectsErrorHelpers.createGenericNotFoundError(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID
)
);
const mockResolve = {
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: mockAttributes,
references: [],
version: '123',
};
savedObjectsClient.create.mockResolvedValueOnce(mockResolve);
savedObjectsClient.update.mockResolvedValueOnce({
...mockResolve,
attributes: {
queryDelay: {
...mockResolve.attributes.queryDelay,
delay: 5,
},
},
});
// Try to update with new values
const result = await client.update({
delay: 5,
});
// Tried to get first, but no results
expect(savedObjectsClient.get).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID
);
// So create a new entry
expect(savedObjectsClient.create).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
{
queryDelay: expect.objectContaining({
delay: mockAttributes.queryDelay?.delay,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: expect.any(String),
updatedAt: expect.any(String),
}),
},
{
id: RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID,
overwrite: true,
}
);
// Try to update with version
expect(savedObjectsClient.update).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID,
{
queryDelay: expect.objectContaining({
delay: 5,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: expect.any(String),
updatedAt: expect.any(String),
}),
},
{ version: '123' }
);
expect(result).toEqual(
expect.objectContaining({
delay: 5,
})
);
});
});

View file

@ -0,0 +1,179 @@
/*
* 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 {
Logger,
SavedObjectsClientContract,
SavedObject,
SavedObjectsErrorHelpers,
} from '@kbn/core/server';
import {
RulesSettings,
RulesSettingsModificationMetadata,
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID,
RulesSettingsQueryDelayProperties,
MIN_QUERY_DELAY,
MAX_QUERY_DELAY,
RulesSettingsQueryDelay,
DEFAULT_SERVERLESS_QUERY_DELAY_SETTINGS,
DEFAULT_QUERY_DELAY_SETTINGS,
} from '../../../common';
import { retryIfConflicts } from '../../lib/retry_if_conflicts';
import { queryDelaySchema } from '../schemas';
const verifyQueryDelaySettings = (settings: RulesSettingsQueryDelayProperties) => {
const { delay } = settings;
if (delay < MIN_QUERY_DELAY || delay > MAX_QUERY_DELAY) {
throw Boom.badRequest(
`Invalid query delay value, must be between ${MIN_QUERY_DELAY} and ${MAX_QUERY_DELAY}, but got: ${delay}.`
);
}
};
export interface RulesSettingsQueryDelayClientConstructorOptions {
readonly logger: Logger;
readonly savedObjectsClient: SavedObjectsClientContract;
readonly isServerless: boolean;
readonly getModificationMetadata: () => Promise<RulesSettingsModificationMetadata>;
}
export class RulesSettingsQueryDelayClient {
private readonly logger: Logger;
private readonly savedObjectsClient: SavedObjectsClientContract;
private readonly isServerless: boolean;
private readonly getModificationMetadata: () => Promise<RulesSettingsModificationMetadata>;
constructor(options: RulesSettingsQueryDelayClientConstructorOptions) {
this.logger = options.logger;
this.savedObjectsClient = options.savedObjectsClient;
this.isServerless = options.isServerless;
this.getModificationMetadata = options.getModificationMetadata;
}
public async get(): Promise<RulesSettingsQueryDelay> {
const rulesSettings = await this.getOrCreate();
if (!rulesSettings.attributes.queryDelay) {
this.logger.error('Failed to get query delay rules setting for current space.');
throw new Error(
'Failed to get query delay rules setting for current space. Query delay settings are undefined'
);
}
return rulesSettings.attributes.queryDelay;
}
public async update(newQueryDelayProperties: RulesSettingsQueryDelayProperties) {
return await retryIfConflicts(
this.logger,
'ruleSettingsClient.queryDelay.update()',
async () => await this.updateWithOCC(newQueryDelayProperties)
);
}
private async updateWithOCC(newQueryDelayProperties: RulesSettingsQueryDelayProperties) {
try {
queryDelaySchema.validate(newQueryDelayProperties);
verifyQueryDelaySettings(newQueryDelayProperties);
} catch (e) {
this.logger.error(
`Failed to verify new query delay settings properties when updating. Error: ${e}`
);
throw e;
}
const { attributes, version } = await this.getOrCreate();
if (!attributes.queryDelay) {
throw new Error('Query delay settings are undefined');
}
const modificationMetadata = await this.getModificationMetadata();
try {
const result = await this.savedObjectsClient.update(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID,
{
queryDelay: {
...attributes.queryDelay,
...newQueryDelayProperties,
updatedAt: modificationMetadata.updatedAt,
updatedBy: modificationMetadata.updatedBy,
},
},
{
version,
}
);
if (!result.attributes.queryDelay) {
throw new Error('Query delay settings are undefined');
}
return result.attributes.queryDelay;
} catch (e) {
const errorMessage = 'savedObjectsClient errored trying to update query delay settings';
this.logger.error(`${errorMessage}: ${e}`);
throw Boom.boomify(e, { message: errorMessage });
}
}
public async getSettings(): Promise<SavedObject<RulesSettings>> {
try {
return await this.savedObjectsClient.get<RulesSettings>(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID
);
} catch (e) {
this.logger.error(`Failed to get query delay rules setting for current space. Error: ${e}`);
throw e;
}
}
public async createSettings(): Promise<SavedObject<RulesSettings>> {
const modificationMetadata = await this.getModificationMetadata();
const defaultQueryDelaySettings = this.isServerless
? DEFAULT_SERVERLESS_QUERY_DELAY_SETTINGS
: DEFAULT_QUERY_DELAY_SETTINGS;
try {
return await this.savedObjectsClient.create<RulesSettings>(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
{
queryDelay: {
...defaultQueryDelaySettings,
...modificationMetadata,
},
},
{
id: RULES_SETTINGS_QUERY_DELAY_SAVED_OBJECT_ID,
overwrite: true,
}
);
} catch (e) {
this.logger.error(
`Failed to create query delay rules setting for current space. Error: ${e}`
);
throw e;
}
}
/**
* Helper function to ensure that a rules-settings saved object always exists.
* Ensures the creation of the saved object is done lazily during retrieval.
*/
private async getOrCreate(): Promise<SavedObject<RulesSettings>> {
try {
return await this.getSettings();
} catch (e) {
if (SavedObjectsErrorHelpers.isNotFoundError(e)) {
this.logger.info('Creating new default query delay rules settings for current space.');
return await this.createSettings();
}
this.logger.error(`Failed to get query delay rules setting for current space. Error: ${e}`);
throw e;
}
}
}

View file

@ -11,16 +11,7 @@ import {
} from './rules_settings_client';
import { RulesSettingsFlappingClient } from './flapping/rules_settings_flapping_client';
import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mocks';
import { SavedObjectsErrorHelpers } from '@kbn/core/server';
import {
RULES_SETTINGS_FEATURE_ID,
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID,
DEFAULT_FLAPPING_SETTINGS,
RulesSettings,
} from '../../common';
const mockDateString = '2019-02-12T21:01:22.479Z';
import { RulesSettingsQueryDelayClient } from './query_delay/rules_settings_query_delay_client';
const savedObjectsClient = savedObjectsClientMock.create();
@ -28,258 +19,17 @@ const rulesSettingsClientParams: jest.Mocked<RulesSettingsClientConstructorOptio
logger: loggingSystemMock.create().get(),
getUserName: jest.fn(),
savedObjectsClient,
};
const getMockRulesSettings = (): RulesSettings => {
return {
flapping: {
enabled: DEFAULT_FLAPPING_SETTINGS.enabled,
lookBackWindow: DEFAULT_FLAPPING_SETTINGS.lookBackWindow,
statusChangeThreshold: DEFAULT_FLAPPING_SETTINGS.statusChangeThreshold,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
};
isServerless: false,
};
describe('RulesSettingsClient', () => {
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date(mockDateString));
});
afterAll(() => {
jest.useRealTimers();
});
beforeEach(() => {
jest.resetAllMocks();
rulesSettingsClientParams.getUserName.mockResolvedValue('test name');
});
test('can initialize correctly', async () => {
const client = new RulesSettingsClient(rulesSettingsClientParams);
expect(client.flapping()).toEqual(expect.any(RulesSettingsFlappingClient));
});
test('can create a new rules settings saved object', async () => {
const client = new RulesSettingsClient(rulesSettingsClientParams);
const mockAttributes = getMockRulesSettings();
savedObjectsClient.create.mockResolvedValueOnce({
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: mockAttributes,
references: [],
});
const result = await client.create();
expect(savedObjectsClient.create).toHaveBeenCalledTimes(1);
expect(savedObjectsClient.create).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
{
flapping: expect.objectContaining({
enabled: mockAttributes.flapping.enabled,
lookBackWindow: mockAttributes.flapping.lookBackWindow,
statusChangeThreshold: mockAttributes.flapping.statusChangeThreshold,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: expect.any(String),
updatedAt: expect.any(String),
}),
},
{
id: RULES_SETTINGS_SAVED_OBJECT_ID,
overwrite: true,
}
);
expect(result.attributes).toEqual(mockAttributes);
});
test('can get existing rules settings saved object', async () => {
const client = new RulesSettingsClient(rulesSettingsClientParams);
const mockAttributes = getMockRulesSettings();
savedObjectsClient.get.mockResolvedValueOnce({
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: mockAttributes,
references: [],
});
const result = await client.get();
expect(result.attributes).toEqual(mockAttributes);
});
test('throws if there is no existing saved object to get', async () => {
const client = new RulesSettingsClient(rulesSettingsClientParams);
savedObjectsClient.get.mockRejectedValueOnce(
SavedObjectsErrorHelpers.createGenericNotFoundError(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID
)
);
await expect(client.get()).rejects.toThrowError();
});
test('can persist flapping settings when saved object does not exist', async () => {
const client = new RulesSettingsClient(rulesSettingsClientParams);
const mockAttributes = getMockRulesSettings();
savedObjectsClient.get.mockRejectedValueOnce(
SavedObjectsErrorHelpers.createGenericNotFoundError(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID
)
);
savedObjectsClient.create.mockResolvedValueOnce({
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: mockAttributes,
references: [],
});
const result = await client.flapping().get();
expect(savedObjectsClient.get).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID
);
expect(savedObjectsClient.create).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
{
flapping: expect.objectContaining({
enabled: mockAttributes.flapping.enabled,
lookBackWindow: mockAttributes.flapping.lookBackWindow,
statusChangeThreshold: mockAttributes.flapping.statusChangeThreshold,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: expect.any(String),
updatedAt: expect.any(String),
}),
},
{
id: RULES_SETTINGS_SAVED_OBJECT_ID,
overwrite: true,
}
);
expect(result).toEqual(mockAttributes.flapping);
});
test('can persist flapping settings when saved object already exists', async () => {
const client = new RulesSettingsClient(rulesSettingsClientParams);
const mockAttributes = getMockRulesSettings();
savedObjectsClient.get.mockResolvedValueOnce({
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: mockAttributes,
references: [],
});
const result = await client.flapping().get();
expect(savedObjectsClient.get).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID
);
expect(savedObjectsClient.create).not.toHaveBeenCalled();
expect(result).toEqual(mockAttributes.flapping);
});
test('can update flapping settings when saved object does not exist', async () => {
const client = new RulesSettingsClient(rulesSettingsClientParams);
const mockAttributes = getMockRulesSettings();
savedObjectsClient.get.mockRejectedValueOnce(
SavedObjectsErrorHelpers.createGenericNotFoundError(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID
)
);
const mockResolve = {
id: RULES_SETTINGS_FEATURE_ID,
type: RULES_SETTINGS_SAVED_OBJECT_TYPE,
attributes: mockAttributes,
references: [],
version: '123',
};
savedObjectsClient.create.mockResolvedValueOnce(mockResolve);
savedObjectsClient.update.mockResolvedValueOnce({
...mockResolve,
attributes: {
flapping: {
...mockResolve.attributes.flapping,
enabled: false,
lookBackWindow: 5,
statusChangeThreshold: 5,
},
},
});
// Try to update with new values
const result = await client.flapping().update({
enabled: false,
lookBackWindow: 5,
statusChangeThreshold: 5,
});
// Tried to get first, but no results
expect(savedObjectsClient.get).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID
);
// So create a new entry
expect(savedObjectsClient.create).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
{
flapping: expect.objectContaining({
enabled: mockAttributes.flapping.enabled,
lookBackWindow: mockAttributes.flapping.lookBackWindow,
statusChangeThreshold: mockAttributes.flapping.statusChangeThreshold,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: expect.any(String),
updatedAt: expect.any(String),
}),
},
{
id: RULES_SETTINGS_SAVED_OBJECT_ID,
overwrite: true,
}
);
// Try to update with version
expect(savedObjectsClient.update).toHaveBeenCalledWith(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID,
{
flapping: expect.objectContaining({
enabled: false,
lookBackWindow: 5,
statusChangeThreshold: 5,
createdBy: 'test name',
updatedBy: 'test name',
createdAt: expect.any(String),
updatedAt: expect.any(String),
}),
},
{ version: '123' }
);
expect(result).toEqual(
expect.objectContaining({
enabled: false,
lookBackWindow: 5,
statusChangeThreshold: 5,
})
);
expect(client.queryDelay()).toEqual(expect.any(RulesSettingsQueryDelayClient));
});
});

View file

@ -5,24 +5,15 @@
* 2.0.
*/
import {
Logger,
SavedObjectsClientContract,
SavedObject,
SavedObjectsErrorHelpers,
} from '@kbn/core/server';
import { Logger, SavedObjectsClientContract } from '@kbn/core/server';
import { RulesSettingsFlappingClient } from './flapping/rules_settings_flapping_client';
import {
RulesSettings,
DEFAULT_FLAPPING_SETTINGS,
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID,
} from '../../common';
import { RulesSettingsQueryDelayClient } from './query_delay/rules_settings_query_delay_client';
export interface RulesSettingsClientConstructorOptions {
readonly logger: Logger;
readonly savedObjectsClient: SavedObjectsClientContract;
readonly getUserName: () => Promise<string | null>;
readonly isServerless: boolean;
}
export class RulesSettingsClient {
@ -30,16 +21,25 @@ export class RulesSettingsClient {
private readonly savedObjectsClient: SavedObjectsClientContract;
private readonly getUserName: () => Promise<string | null>;
private readonly _flapping: RulesSettingsFlappingClient;
private readonly _queryDelay: RulesSettingsQueryDelayClient;
private readonly isServerless: boolean;
constructor(options: RulesSettingsClientConstructorOptions) {
this.logger = options.logger;
this.savedObjectsClient = options.savedObjectsClient;
this.getUserName = options.getUserName;
this.isServerless = options.isServerless;
this._flapping = new RulesSettingsFlappingClient({
logger: this.logger,
savedObjectsClient: this.savedObjectsClient,
getOrCreate: this.getOrCreate.bind(this),
getModificationMetadata: this.getModificationMetadata.bind(this),
});
this._queryDelay = new RulesSettingsQueryDelayClient({
logger: this.logger,
savedObjectsClient: this.savedObjectsClient,
isServerless: this.isServerless,
getModificationMetadata: this.getModificationMetadata.bind(this),
});
}
@ -56,59 +56,11 @@ export class RulesSettingsClient {
};
}
public async get(): Promise<SavedObject<RulesSettings>> {
try {
return await this.savedObjectsClient.get<RulesSettings>(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
RULES_SETTINGS_SAVED_OBJECT_ID
);
} catch (e) {
this.logger.error(`Failed to get rules setting for current space. Error: ${e}`);
throw e;
}
}
public async create(): Promise<SavedObject<RulesSettings>> {
const modificationMetadata = await this.getModificationMetadata();
try {
return await this.savedObjectsClient.create<RulesSettings>(
RULES_SETTINGS_SAVED_OBJECT_TYPE,
{
flapping: {
...DEFAULT_FLAPPING_SETTINGS,
...modificationMetadata,
},
},
{
id: RULES_SETTINGS_SAVED_OBJECT_ID,
overwrite: true,
}
);
} catch (e) {
this.logger.error(`Failed to create rules setting for current space. Error: ${e}`);
throw e;
}
}
/**
* Helper function to ensure that a rules-settings saved object always exists.
* Ensures the creation of the saved object is done lazily during retrieval.
*/
private async getOrCreate(): Promise<SavedObject<RulesSettings>> {
try {
return await this.get();
} catch (e) {
if (SavedObjectsErrorHelpers.isNotFoundError(e)) {
this.logger.info('Creating new default rules settings for current space.');
return await this.create();
}
this.logger.error(`Failed to persist rules setting for current space. Error: ${e}`);
throw e;
}
}
public flapping(): RulesSettingsFlappingClient {
return this._flapping;
}
public queryDelay(): RulesSettingsQueryDelayClient {
return this._queryDelay;
}
}

View file

@ -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.
*/
import { schema } from '@kbn/config-schema';
export const flappingSchema = schema.object({
enabled: schema.boolean(),
lookBackWindow: schema.number(),
statusChangeThreshold: schema.number(),
});

View file

@ -0,0 +1,9 @@
/*
* 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 { flappingSchema } from './flapping_schema';
export { queryDelaySchema } from './query_delay_schema';

View file

@ -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 { schema } from '@kbn/config-schema';
export const queryDelaySchema = schema.object({
delay: schema.number(),
});

View file

@ -30,6 +30,7 @@ const securityPluginStart = securityMock.createStart();
const rulesSettingsClientFactoryParams: jest.Mocked<RulesSettingsClientFactoryOpts> = {
logger: loggingSystemMock.create().get(),
savedObjectsService,
isServerless: false,
};
beforeEach(() => {
@ -58,6 +59,7 @@ test('creates a rules settings client with proper constructor arguments when sec
logger: rulesSettingsClientFactoryParams.logger,
savedObjectsClient,
getUserName: expect.any(Function),
isServerless: false,
});
});
@ -80,6 +82,7 @@ test('creates a rules settings client with proper constructor arguments', async
logger: rulesSettingsClientFactoryParams.logger,
savedObjectsClient,
getUserName: expect.any(Function),
isServerless: false,
});
});
@ -106,6 +109,7 @@ test('creates an unauthorized rules settings client', async () => {
logger: rulesSettingsClientFactoryParams.logger,
savedObjectsClient,
getUserName: expect.any(Function),
isServerless: false,
});
});

View file

@ -18,6 +18,7 @@ import { RULES_SETTINGS_SAVED_OBJECT_TYPE } from '../common';
export interface RulesSettingsClientFactoryOpts {
logger: Logger;
savedObjectsService: SavedObjectsServiceStart;
isServerless: boolean;
securityPluginStart?: SecurityPluginStart;
}
@ -26,6 +27,7 @@ export class RulesSettingsClientFactory {
private logger!: Logger;
private savedObjectsService!: SavedObjectsServiceStart;
private securityPluginStart?: SecurityPluginStart;
private isServerless = false;
public initialize(options: RulesSettingsClientFactoryOpts) {
if (this.isInitialized) {
@ -35,6 +37,7 @@ export class RulesSettingsClientFactory {
this.logger = options.logger;
this.savedObjectsService = options.savedObjectsService;
this.securityPluginStart = options.securityPluginStart;
this.isServerless = options.isServerless;
}
private createRulesSettingsClient(request: KibanaRequest, withAuth: boolean) {
@ -54,6 +57,7 @@ export class RulesSettingsClientFactory {
const user = securityPluginStart.authc.getCurrentUser(request);
return user ? user.username : null;
},
isServerless: this.isServerless,
});
}

View file

@ -14,6 +14,8 @@ import {
ALL_FLAPPING_SETTINGS_SUB_FEATURE_ID,
API_PRIVILEGES,
RULES_SETTINGS_SAVED_OBJECT_TYPE,
ALL_QUERY_DELAY_SETTINGS_SUB_FEATURE_ID,
READ_QUERY_DELAY_SETTINGS_SUB_FEATURE_ID,
} from '../common';
export const rulesSettingsFeature: KibanaFeatureConfig = {
@ -87,5 +89,42 @@ export const rulesSettingsFeature: KibanaFeatureConfig = {
},
],
},
{
name: i18n.translate('xpack.alerting.feature.queryDelaySettingsSubFeatureName', {
defaultMessage: 'Query delay',
}),
privilegeGroups: [
{
groupType: 'mutually_exclusive',
privileges: [
{
api: [
API_PRIVILEGES.READ_QUERY_DELAY_SETTINGS,
API_PRIVILEGES.WRITE_QUERY_DELAY_SETTINGS,
],
name: 'All',
id: ALL_QUERY_DELAY_SETTINGS_SUB_FEATURE_ID,
includeIn: 'all',
savedObject: {
all: [RULES_SETTINGS_SAVED_OBJECT_TYPE],
read: [],
},
ui: ['writeQueryDelaySettingsUI', 'readQueryDelaySettingsUI'],
},
{
api: [API_PRIVILEGES.READ_QUERY_DELAY_SETTINGS],
name: 'Read',
id: READ_QUERY_DELAY_SETTINGS_SUB_FEATURE_ID,
includeIn: 'read',
savedObject: {
all: [],
read: [RULES_SETTINGS_SAVED_OBJECT_TYPE],
},
ui: ['readQueryDelaySettingsUI'],
},
],
},
],
},
],
};

View file

@ -80,6 +80,7 @@ import { RuleResultService } from '../monitoring/rule_result_service';
import { LegacyAlertsClient } from '../alerts_client';
import { IAlertsClient } from '../alerts_client/types';
import { MaintenanceWindow } from '../application/maintenance_window/types';
import { getTimeRange } from '../lib/get_time_range';
const FALLBACK_RETRY_INTERVAL = '5m';
const CONNECTIVITY_RETRY_INTERVAL = '5m';
@ -324,6 +325,7 @@ export class TaskRunner<
const rulesSettingsClient = this.context.getRulesSettingsClientWithRequest(fakeRequest);
const flappingSettings = await rulesSettingsClient.flapping().get();
const queryDelaySettings = await rulesSettingsClient.queryDelay().get();
const alertsClientParams = {
logger: this.logger,
@ -514,6 +516,8 @@ export class TaskRunner<
logger: this.logger,
flappingSettings,
...(maintenanceWindowIds.length ? { maintenanceWindowIds } : {}),
getTimeRange: (timeWindow) =>
getTimeRange(this.logger, queryDelaySettings, timeWindow),
})
);

View file

@ -28,7 +28,11 @@ import { Filter } from '@kbn/es-query';
import { RuleTypeRegistry as OrigruleTypeRegistry } from './rule_type_registry';
import { PluginSetupContract, PluginStartContract } from './plugin';
import { RulesClient } from './rules_client';
import { RulesSettingsClient, RulesSettingsFlappingClient } from './rules_settings_client';
import {
RulesSettingsClient,
RulesSettingsFlappingClient,
RulesSettingsQueryDelayClient,
} from './rules_settings_client';
import { MaintenanceWindowClient } from './maintenance_window_client';
export * from '../common';
import {
@ -135,6 +139,7 @@ export interface RuleExecutorOptions<
namespace?: string;
flappingSettings: RulesSettingsFlappingProperties;
maintenanceWindowIds?: string[];
getTimeRange: (timeWindow?: string) => { dateStart: string; dateEnd: string };
}
export interface RuleParamsAndRefs<Params extends RuleTypeParams> {
@ -372,6 +377,7 @@ export type RulesClientApi = PublicMethodsOf<RulesClient>;
export type RulesSettingsClientApi = PublicMethodsOf<RulesSettingsClient>;
export type RulesSettingsFlappingClientApi = PublicMethodsOf<RulesSettingsFlappingClient>;
export type RulesSettingsQueryDelayClientApi = PublicMethodsOf<RulesSettingsQueryDelayClient>;
export type MaintenanceWindowClientApi = PublicMethodsOf<MaintenanceWindowClient>;

View file

@ -103,7 +103,11 @@ describe('Transaction duration anomaly alert', () => {
ml,
});
const params = { anomalySeverityType: ML_ANOMALY_SEVERITY.MINOR };
const params = {
anomalySeverityType: ML_ANOMALY_SEVERITY.MINOR,
windowSize: 5,
windowUnit: 'm',
};
await executor({ params });

View file

@ -97,7 +97,13 @@ export function registerAnomalyRuleType({
producer: 'apm',
minimumLicenseRequired: 'basic',
isExportable: true,
executor: async ({ params, services, spaceId, startedAt }) => {
executor: async ({
params,
services,
spaceId,
startedAt,
getTimeRange,
}) => {
if (!ml) {
return { state: {} };
}
@ -144,12 +150,14 @@ export function registerAnomalyRuleType({
}
// start time must be at least 30, does like this to support rules created before this change where default was 15
const startTime = Math.min(
datemath.parse('now-30m')!.valueOf(),
const window =
datemath.parse('now-30m')!.valueOf() >
datemath
.parse(`now-${ruleParams.windowSize}${ruleParams.windowUnit}`)
?.valueOf() || 0
);
.parse(`now-${ruleParams.windowSize}${ruleParams.windowUnit}`)!
.valueOf()
? '30m'
: `${ruleParams.windowSize}${ruleParams.windowUnit}`;
const { dateStart } = getTimeRange(window);
const jobIds = mlJobs.map((job) => job.jobId);
const anomalySearchParams = {
@ -165,7 +173,7 @@ export function registerAnomalyRuleType({
{
range: {
timestamp: {
gte: startTime,
gte: dateStart,
format: 'epoch_millis',
},
},

View file

@ -104,6 +104,7 @@ export function registerErrorCountRuleType({
services,
spaceId,
startedAt,
getTimeRange,
}) => {
const allGroupByFields = getAllGroupByFields(
ApmRuleType.ErrorCount,
@ -131,6 +132,10 @@ export function registerErrorCountRuleType({
]
: [];
const { dateStart } = getTimeRange(
`${ruleParams.windowSize}${ruleParams.windowUnit}`
);
const searchParams = {
index: indices.error,
body: {
@ -142,7 +147,7 @@ export function registerErrorCountRuleType({
{
range: {
'@timestamp': {
gte: `now-${ruleParams.windowSize}${ruleParams.windowUnit}`,
gte: dateStart,
},
},
},

View file

@ -111,7 +111,12 @@ export function registerTransactionDurationRuleType({
producer: APM_SERVER_FEATURE_ID,
minimumLicenseRequired: 'basic',
isExportable: true,
executor: async ({ params: ruleParams, services, spaceId }) => {
executor: async ({
params: ruleParams,
services,
spaceId,
getTimeRange,
}) => {
const allGroupByFields = getAllGroupByFields(
ApmRuleType.TransactionDuration,
ruleParams.groupBy
@ -152,6 +157,10 @@ export function registerTransactionDurationRuleType({
]
: [];
const { dateStart } = getTimeRange(
`${ruleParams.windowSize}${ruleParams.windowUnit}`
);
const searchParams = {
index,
body: {
@ -163,7 +172,7 @@ export function registerTransactionDurationRuleType({
{
range: {
'@timestamp': {
gte: `now-${ruleParams.windowSize}${ruleParams.windowUnit}`,
gte: dateStart,
},
},
},

View file

@ -113,6 +113,7 @@ export function registerTransactionErrorRateRuleType({
spaceId,
params: ruleParams,
startedAt,
getTimeRange,
}) => {
const allGroupByFields = getAllGroupByFields(
ApmRuleType.TransactionErrorRate,
@ -154,6 +155,10 @@ export function registerTransactionErrorRateRuleType({
]
: [];
const { dateStart } = getTimeRange(
`${ruleParams.windowSize}${ruleParams.windowUnit}`
);
const searchParams = {
index,
body: {
@ -165,7 +170,7 @@ export function registerTransactionErrorRateRuleType({
{
range: {
'@timestamp': {
gte: `now-${ruleParams.windowSize}${ruleParams.windowUnit}`,
gte: dateStart,
},
},
},

View file

@ -95,6 +95,10 @@ export const createRuleTypeMocks = () => {
},
startedAt: new Date(),
flappingSettings: DEFAULT_FLAPPING_SETTINGS,
getTimeRange: () => {
const date = new Date(Date.now()).toISOString();
return { dateStart: date, dateEnd: date };
},
});
},
};

View file

@ -83,6 +83,10 @@ const mockOptions = {
},
logger,
flappingSettings: DEFAULT_FLAPPING_SETTINGS,
getTimeRange: () => {
const date = new Date().toISOString();
return { dateStart: date, dateEnd: date };
},
};
const setEvaluationResults = (response: Record<string, ConditionResult>) => {

View file

@ -73,6 +73,10 @@ const mockOptions = {
},
logger,
flappingSettings: DEFAULT_FLAPPING_SETTINGS,
getTimeRange: () => {
const date = STARTED_AT_MOCK_DATE.toISOString();
return { dateStart: date, dateEnd: date };
},
};
const setEvaluationResults = (response: Array<Record<string, Evaluation>>) => {

View file

@ -127,6 +127,10 @@ const mockOptions = {
},
logger,
flappingSettings: DEFAULT_FLAPPING_SETTINGS,
getTimeRange: () => {
const date = STARTED_AT_MOCK_DATE.toISOString();
return { dateStart: date, dateEnd: date };
},
};
const setEvaluationResults = (response: Array<Record<string, Evaluation>>) => {

View file

@ -127,6 +127,7 @@ export const createMetricThresholdExecutor = ({
executionId,
spaceId,
rule: { id: ruleId },
getTimeRange,
} = options;
const { criteria } = params;
@ -191,6 +192,8 @@ export const createMetricThresholdExecutor = ({
throw new Error('The selected data view does not have a timestamp field');
}
// Calculate initial start and end date with no time window, as each criteria has it's own time window
const { dateStart, dateEnd } = getTimeRange();
const alertResults = await evaluateRule(
services.scopedClusterClient.asCurrentUser,
params as EvaluatedRuleParams,
@ -199,8 +202,8 @@ export const createMetricThresholdExecutor = ({
compositeSize,
alertOnGroupDisappear,
logger,
{ end: dateEnd, start: dateStart },
state.lastRunTimestamp,
{ end: startedAt.valueOf() },
convertStringsToMissingGroupsRecord(previousMissingGroups)
);

View file

@ -11,123 +11,73 @@ import moment from 'moment';
import { createTimerange } from './create_timerange';
describe('createTimerange(interval, aggType, timeframe)', () => {
describe('without timeframe', () => {
describe('Basic Metric Aggs', () => {
it('should return a second range for last 1 second', () => {
const subject = createTimerange(1000, Aggregators.COUNT);
expect(subject.end - subject.start).toEqual(1000);
});
it('should return a minute range for last 1 minute', () => {
const subject = createTimerange(60000, Aggregators.COUNT);
expect(subject.end - subject.start).toEqual(60000);
});
it('should return 5 minute range for last 5 minutes', () => {
const subject = createTimerange(300000, Aggregators.COUNT);
expect(subject.end - subject.start).toEqual(300000);
});
it('should return a hour range for last 1 hour', () => {
const subject = createTimerange(3600000, Aggregators.COUNT);
expect(subject.end - subject.start).toEqual(3600000);
});
it('should return a day range for last 1 day', () => {
const subject = createTimerange(86400000, Aggregators.COUNT);
expect(subject.end - subject.start).toEqual(86400000);
});
const end = moment();
const timeframe = {
start: end.clone().toISOString(),
end: end.toISOString(),
};
describe('Basic Metric Aggs', () => {
it('should return a second range for last 1 second', () => {
const subject = createTimerange(1000, Aggregators.COUNT, timeframe);
expect(subject.end - subject.start).toEqual(1000);
});
describe('Rate Aggs', () => {
it('should return a 20 second range for last 1 second', () => {
const subject = createTimerange(1000, Aggregators.RATE);
expect(subject.end - subject.start).toEqual(1000 * 2);
});
it('should return a 5 minute range for last 1 minute', () => {
const subject = createTimerange(60000, Aggregators.RATE);
expect(subject.end - subject.start).toEqual(60000 * 2);
});
it('should return 25 minute range for last 5 minutes', () => {
const subject = createTimerange(300000, Aggregators.RATE);
expect(subject.end - subject.start).toEqual(300000 * 2);
});
it('should return 5 hour range for last hour', () => {
const subject = createTimerange(3600000, Aggregators.RATE);
expect(subject.end - subject.start).toEqual(3600000 * 2);
});
it('should return a 5 day range for last day', () => {
const subject = createTimerange(86400000, Aggregators.RATE);
expect(subject.end - subject.start).toEqual(86400000 * 2);
});
it('should return a minute range for last 1 minute', () => {
const subject = createTimerange(60000, Aggregators.COUNT, timeframe);
expect(subject.end - subject.start).toEqual(60000);
});
it('should return 5 minute range for last 5 minutes', () => {
const subject = createTimerange(300000, Aggregators.COUNT, timeframe);
expect(subject.end - subject.start).toEqual(300000);
});
it('should return a hour range for last 1 hour', () => {
const subject = createTimerange(3600000, Aggregators.COUNT, timeframe);
expect(subject.end - subject.start).toEqual(3600000);
});
it('should return a day range for last 1 day', () => {
const subject = createTimerange(86400000, Aggregators.COUNT, timeframe);
expect(subject.end - subject.start).toEqual(86400000);
});
});
describe('with full timeframe', () => {
describe('Basic Metric Aggs', () => {
it('should return 5 minute range when given 4 minute timeframe', () => {
const end = moment();
const timeframe = {
start: end.clone().subtract(4, 'minutes').valueOf(),
end: end.valueOf(),
};
const subject = createTimerange(300000, Aggregators.COUNT, timeframe);
expect(subject.end - subject.start).toEqual(300000);
});
it('should return 6 minute range when given 6 minute timeframe', () => {
const end = moment();
const timeframe = {
start: end.clone().subtract(6, 'minutes').valueOf(),
end: end.valueOf(),
};
const subject = createTimerange(300000, Aggregators.COUNT, timeframe);
expect(subject.end - subject.start).toEqual(360000);
});
describe('Rate Aggs', () => {
it('should return a 20 second range for last 1 second', () => {
const subject = createTimerange(1000, Aggregators.RATE, timeframe);
expect(subject.end - subject.start).toEqual(1000 * 2);
});
describe('Rate Aggs', () => {
it('should return 8 minute range when given 4 minute timeframe', () => {
const end = moment();
const timeframe = {
start: end.clone().subtract(4, 'minutes').valueOf(),
end: end.valueOf(),
};
const subject = createTimerange(300000, Aggregators.RATE, timeframe);
expect(subject.end - subject.start).toEqual(300000 * 2);
});
it('should return 12 minute range when given 6 minute timeframe', () => {
const end = moment();
const timeframe = {
start: end.clone().subtract(6, 'minutes').valueOf(),
end: end.valueOf(),
};
const subject = createTimerange(300000, Aggregators.RATE, timeframe);
expect(subject.end - subject.start).toEqual(300000 * 2);
});
it('should return a 5 minute range for last 1 minute', () => {
const subject = createTimerange(60000, Aggregators.RATE, timeframe);
expect(subject.end - subject.start).toEqual(60000 * 2);
});
it('should return 25 minute range for last 5 minutes', () => {
const subject = createTimerange(300000, Aggregators.RATE, timeframe);
expect(subject.end - subject.start).toEqual(300000 * 2);
});
it('should return 5 hour range for last hour', () => {
const subject = createTimerange(3600000, Aggregators.RATE, timeframe);
expect(subject.end - subject.start).toEqual(3600000 * 2);
});
it('should return a 5 day range for last day', () => {
const subject = createTimerange(86400000, Aggregators.RATE, timeframe);
expect(subject.end - subject.start).toEqual(86400000 * 2);
});
});
describe('with partial timeframe', () => {
describe('Basic Metric Aggs', () => {
it('should return 5 minute range for last 5 minutes', () => {
const end = moment();
const timeframe = {
end: end.valueOf(),
};
const subject = createTimerange(300000, Aggregators.AVERAGE, timeframe);
expect(subject).toEqual({
start: end.clone().subtract(5, 'minutes').valueOf(),
end: end.valueOf(),
});
});
describe('With lastPeriodEnd', () => {
it('should return a minute and 1 second range for last 1 second when the lastPeriodEnd is less than the timeframe start', () => {
const subject = createTimerange(
1000,
Aggregators.COUNT,
timeframe,
end.clone().subtract(1, 'minutes').valueOf()
);
expect(subject.end - subject.start).toEqual(61000);
});
describe('Rate Aggs', () => {
it('should return 10 minute range for last 5 minutes', () => {
const end = moment();
const timeframe = {
end: end.valueOf(),
};
const subject = createTimerange(300000, Aggregators.RATE, timeframe);
expect(subject).toEqual({
start: end
.clone()
.subtract(300 * 2, 'seconds')
.valueOf(),
end: end.valueOf(),
});
});
it('should return a second range for last 1 second when the lastPeriodEnd is not less than the timeframe start', () => {
const subject = createTimerange(
1000,
Aggregators.COUNT,
timeframe,
end.clone().add(2, 'seconds').valueOf()
);
expect(subject.end - subject.start).toEqual(1000);
});
});
});

View file

@ -11,21 +11,20 @@ import { Aggregators } from '../../../../../common/custom_threshold_rule/types';
export const createTimerange = (
interval: number,
aggType: Aggregators,
timeframe?: { end: number; start?: number },
timeframe: { end: string; start: string },
lastPeriodEnd?: number
) => {
const to = moment(timeframe ? timeframe.end : Date.now()).valueOf();
const end = moment(timeframe.end).valueOf();
let start = moment(timeframe.start).valueOf();
// Rate aggregations need 5 buckets worth of data
const minimumBuckets = aggType === Aggregators.RATE ? 2 : 1;
const calculatedFrom = lastPeriodEnd ? lastPeriodEnd - interval : to - interval * minimumBuckets;
start = start - interval * minimumBuckets;
// Use either the timeframe.start when the start is less then calculatedFrom
// OR use the calculatedFrom
const from =
timeframe && timeframe.start && timeframe.start <= calculatedFrom
? timeframe.start
: calculatedFrom;
// Use lastPeriodEnd - interval when it's less than start
if (lastPeriodEnd && lastPeriodEnd - interval < start) {
start = lastPeriodEnd - interval;
}
return { start: from, end: to };
return { start, end };
};

View file

@ -41,8 +41,8 @@ export const evaluateRule = async <Params extends EvaluatedRuleParams = Evaluate
compositeSize: number,
alertOnGroupDisappear: boolean,
logger: Logger,
timeframe: { start: string; end: string },
lastPeriodEnd?: number,
timeframe?: { start?: number; end: number },
missingGroups: MissingGroupsRecord[] = []
): Promise<Array<Record<string, Evaluation>>> => {
const { criteria, groupBy, searchConfiguration } = params;

View file

@ -97,6 +97,11 @@ function createFindResponse(sloList: SLO[]): SavedObjectsFindResponse<StoredSLO>
};
}
function getTimeRange() {
const date = new Date(Date.now()).toISOString();
return { dateStart: date, dateEnd: date };
}
describe('BurnRateRuleExecutor', () => {
let esClientMock: ElasticsearchClientMock;
let soClientMock: jest.Mocked<SavedObjectsClientContract>;
@ -178,6 +183,7 @@ describe('BurnRateRuleExecutor', () => {
spaceId: 'irrelevant',
state: {},
flappingSettings: DEFAULT_FLAPPING_SETTINGS,
getTimeRange,
})
).rejects.toThrowError();
});
@ -198,6 +204,7 @@ describe('BurnRateRuleExecutor', () => {
spaceId: 'irrelevant',
state: {},
flappingSettings: DEFAULT_FLAPPING_SETTINGS,
getTimeRange,
});
expect(esClientMock.search).not.toHaveBeenCalled();
@ -246,6 +253,7 @@ describe('BurnRateRuleExecutor', () => {
spaceId: 'irrelevant',
state: {},
flappingSettings: DEFAULT_FLAPPING_SETTINGS,
getTimeRange,
});
expect(alertWithLifecycleMock).not.toBeCalled();
@ -291,6 +299,7 @@ describe('BurnRateRuleExecutor', () => {
spaceId: 'irrelevant',
state: {},
flappingSettings: DEFAULT_FLAPPING_SETTINGS,
getTimeRange,
});
expect(alertWithLifecycleMock).not.toBeCalled();
@ -339,6 +348,7 @@ describe('BurnRateRuleExecutor', () => {
spaceId: 'irrelevant',
state: {},
flappingSettings: DEFAULT_FLAPPING_SETTINGS,
getTimeRange,
});
expect(alertWithLifecycleMock).toBeCalledWith({
@ -436,6 +446,7 @@ describe('BurnRateRuleExecutor', () => {
spaceId: 'irrelevant',
state: {},
flappingSettings: DEFAULT_FLAPPING_SETTINGS,
getTimeRange,
});
expect(alertWithLifecycleMock).toBeCalledWith({

View file

@ -19,7 +19,7 @@ import { LocatorPublic } from '@kbn/share-plugin/common';
import { upperCase } from 'lodash';
import { addSpaceIdToPath } from '@kbn/spaces-plugin/server';
import { ALL_VALUE } from '@kbn/slo-schema';
import { ALL_VALUE, toDurationUnit } from '@kbn/slo-schema';
import { AlertsLocatorParams, getAlertUrl } from '../../../../common';
import {
SLO_ID_FIELD,
@ -63,6 +63,7 @@ export const getRuleExecutor = ({
params,
startedAt,
spaceId,
getTimeRange,
}): ReturnType<
ExecutorType<
BurnRateRuleParams,
@ -88,7 +89,22 @@ export const getRuleExecutor = ({
return { state: {} };
}
const results = await evaluate(esClient.asCurrentUser, slo, params, startedAt);
const burnRateWindows = getBurnRateWindows(params.windows);
const longestLookbackWindow = burnRateWindows.reduce((acc, winDef) => {
return winDef.longDuration.isShorterThan(acc.longDuration) ? acc : winDef;
}, burnRateWindows[0]);
const { dateStart, dateEnd } = getTimeRange(
`${longestLookbackWindow.longDuration.value}${longestLookbackWindow.longDuration.unit}`
);
const results = await evaluate(
esClient.asCurrentUser,
slo,
params,
dateStart,
dateEnd,
burnRateWindows
);
if (results.length > 0) {
for (const result of results) {
@ -196,6 +212,19 @@ export const getRuleExecutor = ({
return { state: {} };
};
export function getBurnRateWindows(windows: WindowSchema[]) {
return windows.map((winDef) => {
return {
...winDef,
longDuration: new Duration(winDef.longWindow.value, toDurationUnit(winDef.longWindow.unit)),
shortDuration: new Duration(
winDef.shortWindow.value,
toDurationUnit(winDef.shortWindow.unit)
),
};
});
}
function getActionGroupName(id: string) {
switch (id) {
case HIGH_PRIORITY_ACTION.id:

View file

@ -8,8 +8,10 @@
import { createBurnRateRule } from '../fixtures/rule';
import { buildQuery } from './build_query';
import { createKQLCustomIndicator, createSLO } from '../../../../services/slo/fixtures/slo';
import { getBurnRateWindows } from '../executor';
const STARTED_AT = new Date('2023-01-01T00:00:00.000Z');
const DATE_START = '2022-12-29T00:00:00.000Z';
const DATE_END = '2023-01-01T00:00:00.000Z';
describe('buildQuery()', () => {
it('should return a valid query for occurrences', () => {
@ -18,7 +20,8 @@ describe('buildQuery()', () => {
indicator: createKQLCustomIndicator(),
});
const rule = createBurnRateRule(slo);
expect(buildQuery(STARTED_AT, slo, rule)).toMatchSnapshot();
const burnRateWindows = getBurnRateWindows(rule.windows);
expect(buildQuery(slo, DATE_START, DATE_END, burnRateWindows)).toMatchSnapshot();
});
it('should return a valid query with afterKey', () => {
const slo = createSLO({
@ -26,7 +29,12 @@ describe('buildQuery()', () => {
indicator: createKQLCustomIndicator(),
});
const rule = createBurnRateRule(slo);
expect(buildQuery(STARTED_AT, slo, rule, { instanceId: 'example' })).toMatchSnapshot();
const burnRateWindows = getBurnRateWindows(rule.windows);
expect(
buildQuery(slo, DATE_START, DATE_END, burnRateWindows, {
instanceId: 'example',
})
).toMatchSnapshot();
});
it('should return a valid query for timeslices', () => {
const slo = createSLO({
@ -35,6 +43,7 @@ describe('buildQuery()', () => {
budgetingMethod: 'timeslices',
});
const rule = createBurnRateRule(slo);
expect(buildQuery(STARTED_AT, slo, rule)).toMatchSnapshot();
const burnRateWindows = getBurnRateWindows(rule.windows);
expect(buildQuery(slo, DATE_START, DATE_END, burnRateWindows)).toMatchSnapshot();
});
});

View file

@ -7,10 +7,10 @@
import moment from 'moment';
import { timeslicesBudgetingMethodSchema } from '@kbn/slo-schema';
import { Duration, SLO, toDurationUnit, toMomentUnitOfTime } from '../../../../domain/models';
import { BurnRateRuleParams, WindowSchema } from '../types';
import { Duration, SLO, toMomentUnitOfTime } from '../../../../domain/models';
import { WindowSchema } from '../types';
type BurnRateWindowWithDuration = WindowSchema & {
export type BurnRateWindowWithDuration = WindowSchema & {
longDuration: Duration;
shortDuration: Duration;
};
@ -99,7 +99,11 @@ function buildWindowAgg(
};
}
function buildWindowAggs(startedAt: Date, slo: SLO, burnRateWindows: BurnRateWindowWithDuration[]) {
function buildWindowAggs(
startedAt: string,
slo: SLO,
burnRateWindows: BurnRateWindowWithDuration[]
) {
return burnRateWindows.reduce((acc, winDef, index) => {
const shortDateRange = getLookbackDateRange(startedAt, winDef.shortDuration);
const longDateRange = getLookbackDateRange(startedAt, winDef.longDuration);
@ -150,27 +154,12 @@ function buildEvaluation(burnRateWindows: BurnRateWindowWithDuration[]) {
}
export function buildQuery(
startedAt: Date,
slo: SLO,
params: BurnRateRuleParams,
dateStart: string,
dateEnd: string,
burnRateWindows: BurnRateWindowWithDuration[],
afterKey?: EvaluationAfterKey
) {
const burnRateWindows = params.windows.map((winDef) => {
return {
...winDef,
longDuration: new Duration(winDef.longWindow.value, toDurationUnit(winDef.longWindow.unit)),
shortDuration: new Duration(
winDef.shortWindow.value,
toDurationUnit(winDef.shortWindow.unit)
),
};
});
const longestLookbackWindow = burnRateWindows.reduce((acc, winDef) => {
return winDef.longDuration.isShorterThan(acc.longDuration) ? acc : winDef;
}, burnRateWindows[0]);
const longestDateRange = getLookbackDateRange(startedAt, longestLookbackWindow.longDuration);
return {
size: 0,
query: {
@ -181,8 +170,8 @@ export function buildQuery(
{
range: {
'@timestamp': {
gte: longestDateRange.from.toISOString(),
lt: longestDateRange.to.toISOString(),
gte: dateStart,
lt: dateEnd,
},
},
},
@ -197,7 +186,7 @@ export function buildQuery(
sources: [{ instanceId: { terms: { field: 'slo.instanceId' } } }],
},
aggs: {
...buildWindowAggs(startedAt, slo, burnRateWindows),
...buildWindowAggs(dateEnd, slo, burnRateWindows),
...buildEvaluation(burnRateWindows),
},
},
@ -205,9 +194,9 @@ export function buildQuery(
};
}
function getLookbackDateRange(startedAt: Date, duration: Duration): { from: Date; to: Date } {
function getLookbackDateRange(startedAt: string, duration: Duration): { from: Date; to: Date } {
const unit = toMomentUnitOfTime(duration.unit);
const now = moment(startedAt).startOf('minute');
const now = moment(startedAt);
const from = now.clone().subtract(duration.value, unit);
const to = now.clone();

View file

@ -12,6 +12,7 @@ import { BurnRateRuleParams } from '../types';
import { SLO_DESTINATION_INDEX_PATTERN } from '../../../../assets/constants';
import {
buildQuery,
BurnRateWindowWithDuration,
EvaluationAfterKey,
generateAboveThresholdKey,
generateBurnRateKey,
@ -65,12 +66,13 @@ export interface EvalutionAggResults {
async function queryAllResults(
esClient: ElasticsearchClient,
slo: SLO,
params: BurnRateRuleParams,
startedAt: Date,
dateStart: string,
dateEnd: string,
burnRateWindows: BurnRateWindowWithDuration[],
buckets: EvaluationBucket[] = [],
lastAfterKey?: { instanceId: string }
): Promise<EvaluationBucket[]> {
const queryAndAggs = buildQuery(startedAt, slo, params, lastAfterKey);
const queryAndAggs = buildQuery(slo, dateStart, dateEnd, burnRateWindows, lastAfterKey);
const results = await esClient.search<undefined, EvalutionAggResults>({
index: SLO_DESTINATION_INDEX_PATTERN,
...queryAndAggs,
@ -84,8 +86,9 @@ async function queryAllResults(
return queryAllResults(
esClient,
slo,
params,
startedAt,
dateStart,
dateEnd,
burnRateWindows,
[...buckets, ...results.aggregations.instances.buckets],
results.aggregations.instances.after_key
);
@ -95,9 +98,11 @@ export async function evaluate(
esClient: ElasticsearchClient,
slo: SLO,
params: BurnRateRuleParams,
startedAt: Date
dateStart: string,
dateEnd: string,
burnRateWindows: BurnRateWindowWithDuration[]
) {
const buckets = await queryAllResults(esClient, slo, params, startedAt);
const buckets = await queryAllResults(esClient, slo, dateStart, dateEnd, burnRateWindows);
return transformBucketToResults(buckets, params);
}

View file

@ -148,6 +148,10 @@ function createRule(shouldWriteAlerts: boolean = true) {
startedAt,
state,
flappingSettings: DEFAULT_FLAPPING_SETTINGS,
getTimeRange: () => {
const date = new Date(Date.now()).toISOString();
return { dateStart: date, dateEnd: date };
},
})) ?? {}) as Record<string, any>);
previousStartedAt = startedAt;

View file

@ -96,4 +96,8 @@ export const createDefaultAlertExecutorOptions = <
logger,
flappingSettings: DEFAULT_FLAPPING_SETTINGS,
...(maintenanceWindowIds ? { maintenanceWindowIds } : {}),
getTimeRange: () => {
const date = new Date(Date.now()).toISOString();
return { dateStart: date, dateEnd: date };
},
});

View file

@ -72,6 +72,10 @@ describe('legacyRules_notification_alert_type', () => {
},
logger,
flappingSettings: DEFAULT_FLAPPING_SETTINGS,
getTimeRange: () => {
const date = new Date('2019-12-14T16:40:33.400Z').toISOString();
return { dateStart: date, dateEnd: date };
},
};
alert = legacyRulesNotificationAlertType({

View file

@ -284,6 +284,10 @@ export const previewRulesRoute = async (
state: statePreview,
logger,
flappingSettings: DISABLE_FLAPPING_SETTINGS,
getTimeRange: () => {
const date = startedAt.toISOString();
return { dateStart: date, dateEnd: date };
},
})) as { state: TState });
const errors = loggedStatusChanges

View file

@ -7,8 +7,8 @@
import { of } from 'rxjs';
import { CoreSetup } from '@kbn/core/server';
import { executor, getSearchParams, getValidTimefieldSort, tryToParseAsDate } from './executor';
import { ExecutorOptions, OnlyEsQueryRuleParams } from './types';
import { executor, getValidTimefieldSort, tryToParseAsDate } from './executor';
import { ExecutorOptions } from './types';
import { Comparator } from '../../../common/comparator_types';
import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
import { loggerMock } from '@kbn/logging-mocks';
@ -117,6 +117,10 @@ describe('es_query executor', () => {
state: { latestTimestamp: undefined },
spaceId: 'default',
logger,
getTimeRange: () => {
const date = new Date(Date.now()).toISOString();
return { dateStart: date, dateEnd: date };
},
} as unknown as ExecutorOptions<EsQueryRuleParams>;
it('should throw error for invalid comparator', async () => {
@ -141,8 +145,6 @@ describe('es_query executor', () => {
],
truncated: false,
},
dateStart: new Date().toISOString(),
dateEnd: new Date().toISOString(),
});
await executor(coreMock, defaultExecutorOptions);
expect(mockFetchEsQuery).toHaveBeenCalledWith({
@ -157,6 +159,8 @@ describe('es_query executor', () => {
scopedClusterClient: scopedClusterClientMock,
logger,
},
dateStart: new Date().toISOString(),
dateEnd: new Date().toISOString(),
});
expect(mockFetchSearchSourceQuery).not.toHaveBeenCalled();
});
@ -173,8 +177,6 @@ describe('es_query executor', () => {
],
truncated: false,
},
dateStart: new Date().toISOString(),
dateEnd: new Date().toISOString(),
});
await executor(coreMock, {
...defaultExecutorOptions,
@ -191,6 +193,8 @@ describe('es_query executor', () => {
share: undefined,
},
spacePrefix: '',
dateStart: new Date().toISOString(),
dateEnd: new Date().toISOString(),
});
expect(mockFetchEsQuery).not.toHaveBeenCalled();
});
@ -207,8 +211,6 @@ describe('es_query executor', () => {
],
truncated: false,
},
dateStart: new Date().toISOString(),
dateEnd: new Date().toISOString(),
});
await executor(coreMock, {
...defaultExecutorOptions,
@ -222,10 +224,11 @@ describe('es_query executor', () => {
scopedClusterClient: scopedClusterClientMock,
logger,
share: undefined,
dataViews: undefined,
},
spacePrefix: '',
publicBaseUrl: 'https://localhost:5601',
dateStart: new Date().toISOString(),
dateEnd: new Date().toISOString(),
});
expect(mockFetchEsQuery).not.toHaveBeenCalled();
expect(mockFetchSearchSourceQuery).not.toHaveBeenCalled();
@ -243,8 +246,6 @@ describe('es_query executor', () => {
],
truncated: false,
},
dateStart: new Date().toISOString(),
dateEnd: new Date().toISOString(),
});
await executor(coreMock, {
...defaultExecutorOptions,
@ -269,8 +270,6 @@ describe('es_query executor', () => {
],
truncated: false,
},
dateStart: new Date().toISOString(),
dateEnd: new Date().toISOString(),
link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id',
});
await executor(coreMock, {
@ -343,8 +342,6 @@ describe('es_query executor', () => {
],
truncated: false,
},
dateStart: new Date().toISOString(),
dateEnd: new Date().toISOString(),
link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id',
});
await executor(coreMock, {
@ -491,8 +488,6 @@ describe('es_query executor', () => {
],
truncated: false,
},
dateStart: new Date().toISOString(),
dateEnd: new Date().toISOString(),
link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id',
});
await executor(coreMock, {
@ -568,8 +563,6 @@ describe('es_query executor', () => {
],
truncated: true,
},
dateStart: new Date().toISOString(),
dateEnd: new Date().toISOString(),
});
await executor(coreMock, {
...defaultExecutorOptions,
@ -611,8 +604,6 @@ describe('es_query executor', () => {
],
truncated: false,
},
dateStart: new Date().toISOString(),
dateEnd: new Date().toISOString(),
link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id',
});
await executor(coreMock, {
@ -673,8 +664,6 @@ describe('es_query executor', () => {
]);
mockFetchEsQuery.mockResolvedValueOnce({
parsedResults: { results: [], truncated: false },
dateStart: new Date().toISOString(),
dateEnd: new Date().toISOString(),
link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id',
});
await executor(coreMock, {
@ -771,8 +760,6 @@ describe('es_query executor', () => {
results: [],
truncated: false,
},
dateStart: new Date().toISOString(),
dateEnd: new Date().toISOString(),
link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id',
});
await executor(coreMock, {
@ -848,36 +835,4 @@ describe('es_query executor', () => {
expect(result).toEqual('2018-12-31T19:00:00.000Z');
});
});
describe('getSearchParams', () => {
it('should return search params correctly', () => {
const result = getSearchParams(defaultProps as OnlyEsQueryRuleParams);
expect(result.parsedQuery.query).toBe('test-query');
});
it('should throw invalid query error', () => {
expect(() =>
getSearchParams({ ...defaultProps, esQuery: '' } as OnlyEsQueryRuleParams)
).toThrow('invalid query specified: "" - query must be JSON');
});
it('should throw invalid query error due to missing query property', () => {
expect(() =>
getSearchParams({
...defaultProps,
esQuery: '{ "someProperty": "test-query" }',
} as OnlyEsQueryRuleParams)
).toThrow('invalid query specified: "{ "someProperty": "test-query" }" - query must be JSON');
});
it('should throw invalid window size error', () => {
expect(() =>
getSearchParams({
...defaultProps,
timeWindowSize: 5,
timeWindowUnit: 'r',
} as OnlyEsQueryRuleParams)
).toThrow('invalid format for windowSize: "5r"');
});
});
});

View file

@ -7,7 +7,6 @@
import { sha256 } from 'js-sha256';
import { i18n } from '@kbn/i18n';
import { CoreSetup } from '@kbn/core/server';
import { parseDuration } from '@kbn/alerting-plugin/server';
import { isGroupAggregation, UngroupedGroupId } from '@kbn/triggers-actions-ui-plugin/common';
import { ALERT_EVALUATION_VALUE, ALERT_REASON, ALERT_URL } from '@kbn/rule-data-utils';
@ -41,6 +40,7 @@ export async function executor(core: CoreSetup, options: ExecutorOptions<EsQuery
state,
spaceId,
logger,
getTimeRange,
} = options;
const { alertsClient, scopedClusterClient, searchSourceClient, share, dataViews } = services;
const currentTimestamp = new Date().toISOString();
@ -60,7 +60,9 @@ export async function executor(core: CoreSetup, options: ExecutorOptions<EsQuery
// avoid counting a document multiple times.
// latestTimestamp will be ignored if set for grouped queries
let latestTimestamp: string | undefined = tryToParseAsDate(state.latestTimestamp);
const { parsedResults, dateStart, dateEnd, link } = searchSourceRule
const { dateStart, dateEnd } = getTimeRange(`${params.timeWindowSize}${params.timeWindowUnit}`);
const { parsedResults, link } = searchSourceRule
? await fetchSearchSourceQuery({
ruleId,
alertLimit,
@ -73,6 +75,8 @@ export async function executor(core: CoreSetup, options: ExecutorOptions<EsQuery
logger,
dataViews,
},
dateStart,
dateEnd,
})
: esqlQueryRule
? await fetchEsqlQuery({
@ -85,8 +89,9 @@ export async function executor(core: CoreSetup, options: ExecutorOptions<EsQuery
share,
scopedClusterClient,
logger,
dataViews,
},
dateStart,
dateEnd,
})
: await fetchEsQuery({
ruleId,
@ -100,6 +105,8 @@ export async function executor(core: CoreSetup, options: ExecutorOptions<EsQuery
scopedClusterClient,
logger,
},
dateStart,
dateEnd,
});
const unmetGroupValues: Record<string, number> = {};
for (const result of parsedResults.results) {
@ -208,53 +215,6 @@ export async function executor(core: CoreSetup, options: ExecutorOptions<EsQuery
return { state: { latestTimestamp } };
}
function getInvalidWindowSizeError(windowValue: string) {
return i18n.translate('xpack.stackAlerts.esQuery.invalidWindowSizeErrorMessage', {
defaultMessage: 'invalid format for windowSize: "{windowValue}"',
values: {
windowValue,
},
});
}
function getInvalidQueryError(query: string) {
return i18n.translate('xpack.stackAlerts.esQuery.invalidQueryErrorMessage', {
defaultMessage: 'invalid query specified: "{query}" - query must be JSON',
values: {
query,
},
});
}
export function getSearchParams(queryParams: OnlyEsQueryRuleParams) {
const date = Date.now();
const { esQuery, timeWindowSize, timeWindowUnit } = queryParams;
let parsedQuery;
try {
parsedQuery = JSON.parse(esQuery);
} catch (err) {
throw new Error(getInvalidQueryError(esQuery));
}
if (parsedQuery && !parsedQuery.query) {
throw new Error(getInvalidQueryError(esQuery));
}
const window = `${timeWindowSize}${timeWindowUnit}`;
let timeWindow: number;
try {
timeWindow = parseDuration(window);
} catch (err) {
throw new Error(getInvalidWindowSizeError(window));
}
const dateStart = new Date(date - timeWindow).toISOString();
const dateEnd = new Date(date).toISOString();
return { parsedQuery, dateStart, dateEnd };
}
export function getValidTimefieldSort(
sortValues: Array<string | number | null> = []
): undefined | string {

View file

@ -10,7 +10,6 @@ import { Comparator } from '../../../../common/comparator_types';
import { fetchEsQuery } from './fetch_es_query';
import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
import { loggerMock } from '@kbn/logging-mocks';
import { getSearchParams } from './get_search_params';
jest.mock('@kbn/triggers-actions-ui-plugin/common', () => {
const actual = jest.requireActual('@kbn/triggers-actions-ui-plugin/common');
@ -56,7 +55,8 @@ describe('fetchEsQuery', () => {
};
it('should add time filter if timestamp if defined and excludeHitsFromPreviousRun is true', async () => {
const params = defaultParams;
const { dateStart, dateEnd } = getSearchParams(params);
const date = new Date().toISOString();
await fetchEsQuery({
ruleId: 'abc',
name: 'test-rule',
@ -65,6 +65,8 @@ describe('fetchEsQuery', () => {
services,
spacePrefix: '',
publicBaseUrl: '',
dateStart: date,
dateEnd: date,
});
expect(scopedClusterClientMock.asCurrentUser.search).toHaveBeenCalledWith(
{
@ -116,8 +118,8 @@ describe('fetchEsQuery', () => {
range: {
'@timestamp': {
format: 'strict_date_optional_time',
gte: dateStart,
lte: dateEnd,
gte: date,
lte: date,
},
},
},
@ -147,7 +149,8 @@ describe('fetchEsQuery', () => {
it('should not add time filter if timestamp is undefined', async () => {
const params = defaultParams;
const { dateStart, dateEnd } = getSearchParams(params);
const date = new Date().toISOString();
await fetchEsQuery({
ruleId: 'abc',
name: 'test-rule',
@ -156,6 +159,8 @@ describe('fetchEsQuery', () => {
services,
spacePrefix: '',
publicBaseUrl: '',
dateStart: date,
dateEnd: date,
});
expect(scopedClusterClientMock.asCurrentUser.search).toHaveBeenCalledWith(
{
@ -181,8 +186,8 @@ describe('fetchEsQuery', () => {
range: {
'@timestamp': {
format: 'strict_date_optional_time',
gte: dateStart,
lte: dateEnd,
gte: date,
lte: date,
},
},
},
@ -212,7 +217,8 @@ describe('fetchEsQuery', () => {
it('should not add time filter if excludeHitsFromPreviousRun is false', async () => {
const params = { ...defaultParams, excludeHitsFromPreviousRun: false };
const { dateStart, dateEnd } = getSearchParams(params);
const date = new Date().toISOString();
await fetchEsQuery({
ruleId: 'abc',
name: 'test-rule',
@ -221,6 +227,8 @@ describe('fetchEsQuery', () => {
services,
spacePrefix: '',
publicBaseUrl: '',
dateStart: date,
dateEnd: date,
});
expect(scopedClusterClientMock.asCurrentUser.search).toHaveBeenCalledWith(
{
@ -246,8 +254,8 @@ describe('fetchEsQuery', () => {
range: {
'@timestamp': {
format: 'strict_date_optional_time',
gte: dateStart,
lte: dateEnd,
gte: date,
lte: date,
},
},
},
@ -277,7 +285,8 @@ describe('fetchEsQuery', () => {
it('should set size: 0 and top hits size to size parameter if grouping alerts', async () => {
const params = { ...defaultParams, groupBy: 'top', termField: 'host.name', termSize: 10 };
const { dateStart, dateEnd } = getSearchParams(params);
const date = new Date().toISOString();
await fetchEsQuery({
ruleId: 'abc',
name: 'test-rule',
@ -286,6 +295,8 @@ describe('fetchEsQuery', () => {
services,
spacePrefix: '',
publicBaseUrl: '',
dateStart: date,
dateEnd: date,
});
expect(scopedClusterClientMock.asCurrentUser.search).toHaveBeenCalledWith(
{
@ -338,8 +349,8 @@ describe('fetchEsQuery', () => {
range: {
'@timestamp': {
format: 'strict_date_optional_time',
gte: dateStart,
lte: dateEnd,
gte: date,
lte: date,
},
},
},

View file

@ -16,7 +16,7 @@ import { ES_QUERY_ID } from '@kbn/rule-data-utils';
import { getComparatorScript } from '../../../../common';
import { OnlyEsQueryRuleParams } from '../types';
import { buildSortedEventsQuery } from '../../../../common/build_sorted_events_query';
import { getSearchParams } from './get_search_params';
import { getParsedQuery } from '../util';
export interface FetchEsQueryOpts {
ruleId: string;
@ -30,6 +30,8 @@ export interface FetchEsQueryOpts {
logger: Logger;
};
alertLimit?: number;
dateStart: string;
dateEnd: string;
}
/**
@ -44,17 +46,20 @@ export async function fetchEsQuery({
timestamp,
services,
alertLimit,
dateStart,
dateEnd,
}: FetchEsQueryOpts) {
const { scopedClusterClient, logger } = services;
const esClient = scopedClusterClient.asCurrentUser;
const isGroupAgg = isGroupAggregation(params.termField);
const isCountAgg = isCountAggregation(params.aggType);
const {
query,
fields,
// eslint-disable-next-line @typescript-eslint/naming-convention
parsedQuery: { query, fields, runtime_mappings, _source },
dateStart,
dateEnd,
} = getSearchParams(params);
runtime_mappings,
_source,
} = getParsedQuery(params);
const filter =
timestamp && params.excludeHitsFromPreviousRun
@ -136,8 +141,6 @@ export async function fetchEsQuery({
esResult: searchResult,
resultLimit: alertLimit,
}),
dateStart,
dateEnd,
link,
};
}

View file

@ -6,26 +6,15 @@
*/
import { OnlyEsqlQueryRuleParams } from '../types';
import { stubbedSavedObjectIndexPattern } from '@kbn/data-views-plugin/common/data_view.stub';
import { DataView } from '@kbn/data-views-plugin/common';
import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks';
import { Comparator } from '../../../../common/comparator_types';
import { getEsqlQuery } from './fetch_esql_query';
const createDataView = () => {
const id = 'test-id';
const {
type,
version,
attributes: { timeFieldName, fields, title },
} = stubbedSavedObjectIndexPattern(id);
const getTimeRange = () => {
const date = Date.now();
const dateStart = new Date(date - 300000).toISOString();
const dateEnd = new Date(date).toISOString();
return new DataView({
spec: { id, type, version, timeFieldName, fields: JSON.parse(fields), title },
fieldFormats: fieldFormatsMock,
shortDotsEnable: false,
metaFields: ['_id', '_type', '_score'],
});
return { dateStart, dateEnd };
};
const defaultParams: OnlyEsqlQueryRuleParams = {
@ -44,7 +33,6 @@ const defaultParams: OnlyEsqlQueryRuleParams = {
describe('fetchEsqlQuery', () => {
describe('getEsqlQuery', () => {
const dataViewMock = createDataView();
afterAll(() => {
jest.resetAllMocks();
});
@ -58,7 +46,8 @@ describe('fetchEsqlQuery', () => {
it('should generate the correct query', async () => {
const params = defaultParams;
const { query, dateStart, dateEnd } = getEsqlQuery(dataViewMock, params, undefined);
const { dateStart, dateEnd } = getTimeRange();
const query = getEsqlQuery(params, undefined, dateStart, dateEnd);
expect(query).toMatchInlineSnapshot(`
Object {
@ -80,13 +69,12 @@ describe('fetchEsqlQuery', () => {
"query": "from test",
}
`);
expect(dateStart).toMatch('2020-02-09T23:10:41.941Z');
expect(dateEnd).toMatch('2020-02-09T23:15:41.941Z');
});
it('should generate the correct query with the alertLimit', async () => {
const params = defaultParams;
const { query, dateStart, dateEnd } = getEsqlQuery(dataViewMock, params, 100);
const { dateStart, dateEnd } = getTimeRange();
const query = getEsqlQuery(params, 100, dateStart, dateEnd);
expect(query).toMatchInlineSnapshot(`
Object {
@ -108,8 +96,6 @@ describe('fetchEsqlQuery', () => {
"query": "from test | limit 100",
}
`);
expect(dateStart).toMatch('2020-02-09T23:10:41.941Z');
expect(dateEnd).toMatch('2020-02-09T23:15:41.941Z');
});
});
});

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import { DataView, DataViewsContract, getTime } from '@kbn/data-plugin/common';
import { parseAggregationResults } from '@kbn/triggers-actions-ui-plugin/common';
import { SharePluginStart } from '@kbn/share-plugin/server';
import { IScopedClusterClient, Logger } from '@kbn/core/server';
@ -22,8 +21,9 @@ export interface FetchEsqlQueryOpts {
logger: Logger;
scopedClusterClient: IScopedClusterClient;
share: SharePluginStart;
dataViews: DataViewsContract;
};
dateStart: string;
dateEnd: string;
}
export async function fetchEsqlQuery({
@ -33,14 +33,12 @@ export async function fetchEsqlQuery({
services,
spacePrefix,
publicBaseUrl,
dateStart,
dateEnd,
}: FetchEsqlQueryOpts) {
const { logger, scopedClusterClient, dataViews } = services;
const { logger, scopedClusterClient } = services;
const esClient = scopedClusterClient.asCurrentUser;
const dataView = await dataViews.create({
timeFieldName: params.timeField,
});
const { query, dateStart, dateEnd } = getEsqlQuery(dataView, params, alertLimit);
const query = getEsqlQuery(params, alertLimit, dateStart, dateEnd);
logger.debug(`ES|QL query rule (${ruleId}) query: ${JSON.stringify(query)}`);
@ -66,23 +64,15 @@ export async function fetchEsqlQuery({
},
resultLimit: alertLimit,
}),
dateStart,
dateEnd,
};
}
export const getEsqlQuery = (
dataView: DataView,
params: OnlyEsqlQueryRuleParams,
alertLimit: number | undefined
alertLimit: number | undefined,
dateStart: string,
dateEnd: string
) => {
const timeRange = {
from: `now-${params.timeWindowSize}${params.timeWindowUnit}`,
to: 'now',
};
const timerangeFilter = getTime(dataView, timeRange);
const dateStart = timerangeFilter?.query.range[params.timeField].gte;
const dateEnd = timerangeFilter?.query.range[params.timeField].lte;
const rangeFilter: unknown[] = [
{
range: {
@ -103,9 +93,5 @@ export const getEsqlQuery = (
},
},
};
return {
query,
dateStart,
dateEnd,
};
return query;
};

View file

@ -32,6 +32,14 @@ const createDataView = () => {
});
};
const getTimeRange = () => {
const date = Date.now();
const dateStart = new Date(date - 300000).toISOString();
const dateEnd = new Date(date).toISOString();
return { dateStart, dateEnd };
};
const defaultParams: OnlySearchSourceRuleParams = {
size: 100,
timeWindowSize: 5,
@ -65,11 +73,14 @@ describe('fetchSearchSourceQuery', () => {
const searchSourceInstance = createSearchSourceMock({ index: dataViewMock });
const { searchSource, dateStart, dateEnd } = updateSearchSource(
const { dateStart, dateEnd } = getTimeRange();
const searchSource = updateSearchSource(
searchSourceInstance,
dataViewMock,
params,
undefined
undefined,
dateStart,
dateEnd
);
const searchRequest = searchSource.getSearchRequestBody();
expect(searchRequest.size).toMatchInlineSnapshot(`100`);
@ -94,8 +105,6 @@ describe('fetchSearchSourceQuery', () => {
}
`);
expect(searchRequest.aggs).toMatchInlineSnapshot(`Object {}`);
expect(dateStart).toMatch('2020-02-09T23:10:41.941Z');
expect(dateEnd).toMatch('2020-02-09T23:15:41.941Z');
});
it('with latest timestamp in between the given time range ', async () => {
@ -103,11 +112,14 @@ describe('fetchSearchSourceQuery', () => {
const searchSourceInstance = createSearchSourceMock({ index: dataViewMock });
const { searchSource } = updateSearchSource(
const { dateStart, dateEnd } = getTimeRange();
const searchSource = updateSearchSource(
searchSourceInstance,
dataViewMock,
params,
'2020-02-09T23:12:41.941Z'
'2020-02-09T23:12:41.941Z',
dateStart,
dateEnd
);
const searchRequest = searchSource.getSearchRequestBody();
expect(searchRequest.size).toMatchInlineSnapshot(`100`);
@ -147,11 +159,14 @@ describe('fetchSearchSourceQuery', () => {
const searchSourceInstance = createSearchSourceMock({ index: dataViewMock });
const { searchSource } = updateSearchSource(
const { dateStart, dateEnd } = getTimeRange();
const searchSource = updateSearchSource(
searchSourceInstance,
dataViewMock,
params,
'2020-01-09T22:12:41.941Z'
'2020-01-09T22:12:41.941Z',
dateStart,
dateEnd
);
const searchRequest = searchSource.getSearchRequestBody();
expect(searchRequest.size).toMatchInlineSnapshot(`100`);
@ -183,11 +198,14 @@ describe('fetchSearchSourceQuery', () => {
const searchSourceInstance = createSearchSourceMock({ index: dataViewMock });
const { searchSource } = updateSearchSource(
const { dateStart, dateEnd } = getTimeRange();
const searchSource = updateSearchSource(
searchSourceInstance,
dataViewMock,
params,
'2020-02-09T23:12:41.941Z'
'2020-02-09T23:12:41.941Z',
dateStart,
dateEnd
);
const searchRequest = searchSource.getSearchRequestBody();
expect(searchRequest.size).toMatchInlineSnapshot(`100`);
@ -225,11 +243,14 @@ describe('fetchSearchSourceQuery', () => {
const searchSourceInstance = createSearchSourceMock({ index: dataViewMock });
const { searchSource } = updateSearchSource(
const { dateStart, dateEnd } = getTimeRange();
const searchSource = updateSearchSource(
searchSourceInstance,
dataViewMock,
params,
'2020-02-09T23:12:41.941Z'
'2020-02-09T23:12:41.941Z',
dateStart,
dateEnd
);
const searchRequest = searchSource.getSearchRequestBody();
expect(searchRequest.size).toMatchInlineSnapshot(`0`);

View file

@ -9,7 +9,6 @@ import { buildRangeFilter, Filter } from '@kbn/es-query';
import {
DataView,
DataViewsContract,
getTime,
ISearchSource,
ISearchStartSearchSource,
SortDirection,
@ -40,6 +39,8 @@ export interface FetchSearchSourceQueryOpts {
share: SharePluginStart;
dataViews: DataViewsContract;
};
dateStart: string;
dateEnd: string;
}
export async function fetchSearchSourceQuery({
@ -49,6 +50,8 @@ export async function fetchSearchSourceQuery({
latestTimestamp,
spacePrefix,
services,
dateStart,
dateEnd,
}: FetchSearchSourceQueryOpts) {
const { logger, searchSourceClient } = services;
const isGroupAgg = isGroupAggregation(params.termField);
@ -57,11 +60,13 @@ export async function fetchSearchSourceQuery({
const initialSearchSource = await searchSourceClient.create(params.searchConfiguration);
const index = initialSearchSource.getField('index') as DataView;
const { searchSource, dateStart, dateEnd } = updateSearchSource(
const searchSource = updateSearchSource(
initialSearchSource,
index,
params,
latestTimestamp,
dateStart,
dateEnd,
alertLimit
);
@ -87,8 +92,6 @@ export async function fetchSearchSourceQuery({
numMatches: Number(searchResult.hits.total),
searchResult,
parsedResults: parseAggregationResults({ isCountAgg, isGroupAgg, esResult: searchResult }),
dateStart,
dateEnd,
};
}
@ -97,6 +100,8 @@ export function updateSearchSource(
index: DataView,
params: OnlySearchSourceRuleParams,
latestTimestamp: string | undefined,
dateStart: string,
dateEnd: string,
alertLimit?: number
) {
const isGroupAgg = isGroupAggregation(params.termField);
@ -108,20 +113,19 @@ export function updateSearchSource(
searchSource.setField('size', isGroupAgg ? 0 : params.size);
const timeRange = {
from: `now-${params.timeWindowSize}${params.timeWindowUnit}`,
to: 'now',
};
const timerangeFilter = getTime(index, timeRange);
const dateStart = timerangeFilter?.query.range[timeFieldName].gte;
const dateEnd = timerangeFilter?.query.range[timeFieldName].lte;
const filters = [timerangeFilter];
const field = index.fields.find((f) => f.name === timeFieldName);
const filters = [
buildRangeFilter(
field!,
{ lte: dateEnd, gte: dateStart, format: 'strict_date_optional_time' },
index
),
];
if (params.excludeHitsFromPreviousRun) {
if (latestTimestamp && latestTimestamp > dateStart) {
// add additional filter for documents with a timestamp greater then
// the timestamp of the previous run, so that those documents are not counted twice
const field = index.fields.find((f) => f.name === timeFieldName);
const addTimeRangeField = buildRangeFilter(
field!,
{ gt: latestTimestamp, format: 'strict_date_optional_time' },
@ -159,11 +163,7 @@ export function updateSearchSource(
...(isGroupAgg ? { topHitsSize: params.size } : {}),
})
);
return {
searchSource: searchSourceChild,
dateStart,
dateEnd,
};
return searchSourceChild;
}
async function generateLink(

View file

@ -1,56 +0,0 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { parseDuration } from '@kbn/alerting-plugin/common';
import { OnlyEsQueryRuleParams } from '../types';
export function getSearchParams(queryParams: OnlyEsQueryRuleParams) {
const date = Date.now();
const { esQuery, timeWindowSize, timeWindowUnit } = queryParams;
let parsedQuery;
try {
parsedQuery = JSON.parse(esQuery);
} catch (err) {
throw new Error(getInvalidQueryError(esQuery));
}
if (parsedQuery && !parsedQuery.query) {
throw new Error(getInvalidQueryError(esQuery));
}
const window = `${timeWindowSize}${timeWindowUnit}`;
let timeWindow: number;
try {
timeWindow = parseDuration(window);
} catch (err) {
throw new Error(getInvalidWindowSizeError(window));
}
const dateStart = new Date(date - timeWindow).toISOString();
const dateEnd = new Date(date).toISOString();
return { parsedQuery, dateStart, dateEnd };
}
function getInvalidWindowSizeError(windowValue: string) {
return i18n.translate('xpack.stackAlerts.esQuery.invalidWindowSizeErrorMessage', {
defaultMessage: 'invalid format for windowSize: "{windowValue}"',
values: {
windowValue,
},
});
}
function getInvalidQueryError(query: string) {
return i18n.translate('xpack.stackAlerts.esQuery.invalidQueryErrorMessage', {
defaultMessage: 'invalid query specified: "{query}" - query must be JSON',
values: {
query,
},
});
}

View file

@ -10,8 +10,6 @@ import type { Writable } from '@kbn/utility-types';
import { RuleExecutorServices } from '@kbn/alerting-plugin/server';
import { RuleExecutorServicesMock, alertsMock } from '@kbn/alerting-plugin/server/mocks';
import { loggingSystemMock } from '@kbn/core/server/mocks';
import type { DataViewSpec } from '@kbn/data-views-plugin/common';
import { createStubDataView } from '@kbn/data-views-plugin/common/data_view.stub';
import { getRuleType } from './rule_type';
import { EsQueryRuleParams, EsQueryRuleState } from './rule_type_params';
import { ActionContext } from './action_context';
@ -566,31 +564,32 @@ describe('ruleType', () => {
});
describe('search source query', () => {
const dataViewMock = createStubDataView({
spec: {
id: 'test-id',
title: 'test-title',
timeFieldName: 'time-field',
fields: {
message: {
name: 'message',
type: 'string',
scripted: false,
searchable: false,
aggregatable: false,
readFromDocValues: false,
},
timestamp: {
name: 'timestamp',
type: 'date',
scripted: false,
searchable: true,
aggregatable: false,
readFromDocValues: false,
},
const dataViewMock = {
id: 'test-id',
title: 'test-title',
timeFieldName: 'timestamp',
fields: [
{
name: 'message',
type: 'string',
displayName: 'message',
scripted: false,
filterable: false,
aggregatable: false,
},
{
name: 'timestamp',
type: 'date',
displayName: 'timestamp',
scripted: false,
filterable: false,
aggregatable: false,
},
],
toSpec: () => {
return { id: 'test-id', title: 'test-title', timeFieldName: 'timestamp', fields: [] };
},
});
};
const defaultParams: OnlySearchSourceRuleParams = {
size: 100,
timeWindowSize: 5,
@ -632,9 +631,11 @@ describe('ruleType', () => {
const searchResult: ESSearchResponse<unknown, {}> = generateResults([]);
const ruleServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices();
(ruleServices.dataViews.create as jest.Mock).mockImplementationOnce((spec: DataViewSpec) =>
createStubDataView({ spec })
);
(ruleServices.dataViews.create as jest.Mock).mockResolvedValueOnce({
...dataViewMock.toSpec(),
toSpec: () => dataViewMock.toSpec(),
toMinimalSpec: () => dataViewMock.toSpec(),
});
(searchSourceInstanceMock.getField as jest.Mock).mockImplementation((name: string) => {
if (name === 'index') {
return dataViewMock;
@ -669,9 +670,11 @@ describe('ruleType', () => {
const params = { ...defaultParams, thresholdComparator: Comparator.GT_OR_EQ, threshold: [3] };
const ruleServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices();
(ruleServices.dataViews.create as jest.Mock).mockImplementationOnce((spec: DataViewSpec) =>
createStubDataView({ spec })
);
(ruleServices.dataViews.create as jest.Mock).mockResolvedValueOnce({
...dataViewMock.toSpec(),
toSpec: () => dataViewMock.toSpec(),
toMinimalSpec: () => dataViewMock.toSpec(),
});
(searchSourceInstanceMock.getField as jest.Mock).mockImplementation((name: string) => {
if (name === 'index') {
return dataViewMock;
@ -711,32 +714,6 @@ describe('ruleType', () => {
});
describe('ESQL query', () => {
const dataViewMock = {
id: 'test-id',
title: 'test-title',
timeFieldName: 'time-field',
fields: [
{
name: 'message',
type: 'string',
displayName: 'message',
scripted: false,
filterable: false,
aggregatable: false,
},
{
name: 'timestamp',
type: 'date',
displayName: 'timestamp',
scripted: false,
filterable: false,
aggregatable: false,
},
],
toSpec: () => {
return { id: 'test-id', title: 'test-title', timeFieldName: 'timestamp', fields: [] };
},
};
const defaultParams: OnlyEsqlQueryRuleParams = {
size: 100,
timeWindowSize: 5,
@ -777,12 +754,6 @@ describe('ruleType', () => {
it('rule executor handles no documents returned by ES', async () => {
const params = defaultParams;
const ruleServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices();
(ruleServices.dataViews.create as jest.Mock).mockResolvedValueOnce({
...dataViewMock.toSpec(),
toSpec: () => dataViewMock.toSpec(),
});
const searchResult = {
columns: [
{ name: 'timestamp', type: 'date' },
@ -801,12 +772,6 @@ describe('ruleType', () => {
it('rule executor schedule actions when condition met', async () => {
const params = defaultParams;
const ruleServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices();
(ruleServices.dataViews.create as jest.Mock).mockResolvedValueOnce({
...dataViewMock.toSpec(),
toSpec: () => dataViewMock.toSpec(),
});
const searchResult = {
columns: [
{ name: 'timestamp', type: 'date' },
@ -932,5 +897,9 @@ async function invokeExecutor({
},
logger,
flappingSettings: DEFAULT_FLAPPING_SETTINGS,
getTimeRange: () => {
const date = new Date(Date.now()).toISOString();
return { dateStart: date, dateEnd: date };
},
});
}

View file

@ -0,0 +1,51 @@
/*
* 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 { OnlyEsQueryRuleParams } from './types';
import { Comparator } from '../../../common/comparator_types';
import { getParsedQuery } from './util';
describe('es_query utils', () => {
const defaultProps = {
size: 3,
timeWindowSize: 5,
timeWindowUnit: 'm',
threshold: [],
thresholdComparator: '>=' as Comparator,
esQuery: '{ "query": "test-query" }',
index: ['test-index'],
timeField: '',
searchType: 'esQuery',
excludeHitsFromPreviousRun: true,
aggType: 'count',
groupBy: 'all',
searchConfiguration: {},
esqlQuery: { esql: 'test-query' },
};
describe('getParsedQuery', () => {
it('should return search params correctly', () => {
const parsedQuery = getParsedQuery(defaultProps as OnlyEsQueryRuleParams);
expect(parsedQuery.query).toBe('test-query');
});
it('should throw invalid query error', () => {
expect(() =>
getParsedQuery({ ...defaultProps, esQuery: '' } as OnlyEsQueryRuleParams)
).toThrow('invalid query specified: "" - query must be JSON');
});
it('should throw invalid query error due to missing query property', () => {
expect(() =>
getParsedQuery({
...defaultProps,
esQuery: '{ "someProperty": "test-query" }',
} as OnlyEsQueryRuleParams)
).toThrow('invalid query specified: "{ "someProperty": "test-query" }" - query must be JSON');
});
});
});

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { OnlyEsQueryRuleParams } from './types';
import { EsQueryRuleParams } from './rule_type_params';
export function isEsQueryRule(searchType: EsQueryRuleParams['searchType']) {
@ -18,3 +20,29 @@ export function isSearchSourceRule(searchType: EsQueryRuleParams['searchType'])
export function isEsqlQueryRule(searchType: EsQueryRuleParams['searchType']) {
return searchType === 'esqlQuery';
}
export function getParsedQuery(queryParams: OnlyEsQueryRuleParams) {
const { esQuery } = queryParams;
let parsedQuery;
try {
parsedQuery = JSON.parse(esQuery);
} catch (err) {
throw new Error(getInvalidQueryError(esQuery));
}
if (parsedQuery && !parsedQuery.query) {
throw new Error(getInvalidQueryError(esQuery));
}
return parsedQuery;
}
function getInvalidQueryError(query: string) {
return i18n.translate('xpack.stackAlerts.esQuery.invalidQueryErrorMessage', {
defaultMessage: 'invalid query specified: "{query}" - query must be JSON',
values: {
query,
},
});
}

View file

@ -20,6 +20,11 @@ import { DEFAULT_FLAPPING_SETTINGS } from '@kbn/alerting-plugin/common/rules_set
let fakeTimer: sinon.SinonFakeTimers;
function getTimeRange() {
const date = new Date(Date.now()).toISOString();
return { dateStart: date, dateEnd: date };
}
describe('ruleType', () => {
const logger = loggingSystemMock.create().get();
const data = {
@ -224,6 +229,7 @@ describe('ruleType', () => {
},
logger,
flappingSettings: DEFAULT_FLAPPING_SETTINGS,
getTimeRange,
});
expect(alertServices.alertsClient.report).toHaveBeenCalledWith({
@ -318,6 +324,7 @@ describe('ruleType', () => {
},
logger,
flappingSettings: DEFAULT_FLAPPING_SETTINGS,
getTimeRange,
});
expect(customAlertServices.alertFactory.create).not.toHaveBeenCalled();
@ -386,6 +393,7 @@ describe('ruleType', () => {
},
logger,
flappingSettings: DEFAULT_FLAPPING_SETTINGS,
getTimeRange,
});
expect(customAlertServices.alertFactory.create).not.toHaveBeenCalled();
@ -453,6 +461,7 @@ describe('ruleType', () => {
},
logger,
flappingSettings: DEFAULT_FLAPPING_SETTINGS,
getTimeRange,
});
expect(data.timeSeriesQuery).toHaveBeenCalledWith(

View file

@ -219,6 +219,7 @@ export function getRuleType(
services,
params,
logger,
getTimeRange,
} = options;
const { alertsClient, scopedClusterClient } = services;
@ -237,7 +238,8 @@ export function getRuleType(
}
const esClient = scopedClusterClient.asCurrentUser;
const date = new Date().toISOString();
const { dateStart, dateEnd } = getTimeRange(`${params.timeWindowSize}${params.timeWindowUnit}`);
// the undefined values below are for config-schema optional types
const queryParams: TimeSeriesQuery = {
index: params.index,
@ -247,8 +249,8 @@ export function getRuleType(
groupBy: params.groupBy,
termField: params.termField,
termSize: params.termSize,
dateStart: date,
dateEnd: date,
dateStart,
dateEnd,
timeWindowSize: params.timeWindowSize,
timeWindowUnit: params.timeWindowUnit,
interval: undefined,
@ -269,6 +271,7 @@ export function getRuleType(
TIME_SERIES_BUCKET_SELECTOR_FIELD
),
},
useCalculatedDateRange: false,
});
logger.debug(`rule ${ID}:${ruleId} "${name}" query result: ${JSON.stringify(result)}`);
@ -309,7 +312,7 @@ export function getRuleType(
)} ${params.threshold.join(' and ')}`;
const baseContext: BaseActionContext = {
date,
date: dateEnd,
group: alertId,
value,
conditions: humanFn,
@ -338,7 +341,7 @@ export function getRuleType(
const alertId = recoveredAlert.getId();
logger.debug(`setting context for recovered alert ${alertId}`);
const baseContext: BaseActionContext = {
date,
date: dateEnd,
value: unmetGroupValues[alertId] ?? 'unknown',
group: alertId,
conditions: `${agg} is NOT ${getHumanReadableComparator(

View file

@ -35063,7 +35063,6 @@
"xpack.stackAlerts.esQuery.invalidQueryErrorMessage": "recherche spécifiée non valide : \"{query}\" - la recherche doit être au format JSON",
"xpack.stackAlerts.esQuery.invalidTermSizeMaximumErrorMessage": "[termSize] : doit être inférieure ou égale à {maxGroups}",
"xpack.stackAlerts.esQuery.invalidThreshold2ErrorMessage": "[threshold] : requiert deux éléments pour le comparateur \"{thresholdComparator}\"",
"xpack.stackAlerts.esQuery.invalidWindowSizeErrorMessage": "format non valide pour windowSize : \"{windowValue}\"",
"xpack.stackAlerts.esQuery.ui.numQueryMatchesText": "La recherche correspondait à {count} documents dans le/la/les dernier(s)/dernière(s) {window}.",
"xpack.stackAlerts.esQuery.ui.queryError": "Erreur lors du test de la recherche : {message}",
"xpack.stackAlerts.esQuery.ui.testQueryGroupedResponse": "La recherche groupée correspondait à {groups} groupes dans le/la/les dernier(s)/dernière(s) {window}.",
@ -37791,7 +37790,6 @@
"xpack.triggersActionsUI.rulesSettings.modal.flappingDetectionDescription": "Détectez les alertes qui passent rapidement de l'état actif à l'état récupéré et réduisez le bruit non souhaité de ces alertes bagotantes.",
"xpack.triggersActionsUI.rulesSettings.modal.flappingOffLabel": "Désactivé",
"xpack.triggersActionsUI.rulesSettings.modal.flappingOnLabel": "Activé (recommandé)",
"xpack.triggersActionsUI.rulesSettings.modal.getRulesSettingsError": "Impossible de récupérer les paramètres des règles.",
"xpack.triggersActionsUI.rulesSettings.modal.saveButton": "Enregistrer",
"xpack.triggersActionsUI.rulesSettings.modal.title": "Paramètres de règle",
"xpack.triggersActionsUI.rulesSettings.modal.updateRulesSettingsFailure": "Impossible de mettre à jour les paramètres des règles.",

View file

@ -35062,7 +35062,6 @@
"xpack.stackAlerts.esQuery.invalidQueryErrorMessage": "無効なクエリが指定されました: \"{query}\" - クエリはJSONでなければなりません",
"xpack.stackAlerts.esQuery.invalidTermSizeMaximumErrorMessage": "[termSize]{maxGroups}以下でなければなりません",
"xpack.stackAlerts.esQuery.invalidThreshold2ErrorMessage": "[threshold]:「{thresholdComparator}」比較子の場合には2つの要素が必要です",
"xpack.stackAlerts.esQuery.invalidWindowSizeErrorMessage": "windowSizeの無効な形式\"{windowValue}\"",
"xpack.stackAlerts.esQuery.ui.numQueryMatchesText": "前回の{window}でクエリが{count}個のドキュメントと一致しました。",
"xpack.stackAlerts.esQuery.ui.queryError": "クエリのテストエラー:{message}",
"xpack.stackAlerts.esQuery.ui.testQueryGroupedResponse": "グループ化されたクエリは、直近の{window}件に{groups}グループと一致しました。",
@ -37782,7 +37781,6 @@
"xpack.triggersActionsUI.rulesSettings.modal.flappingDetectionDescription": "アクティブと回復済みの状態がすばやく切り替わるアラートを検出し、これらのフラップアラートに対する不要なノイズを低減します。",
"xpack.triggersActionsUI.rulesSettings.modal.flappingOffLabel": "オフ",
"xpack.triggersActionsUI.rulesSettings.modal.flappingOnLabel": "オン(推奨)",
"xpack.triggersActionsUI.rulesSettings.modal.getRulesSettingsError": "ルール設定を取得できませんでした。",
"xpack.triggersActionsUI.rulesSettings.modal.saveButton": "保存",
"xpack.triggersActionsUI.rulesSettings.modal.title": "ルール設定",
"xpack.triggersActionsUI.rulesSettings.modal.updateRulesSettingsFailure": "ルール設定を更新できませんでした。",

View file

@ -35056,7 +35056,6 @@
"xpack.stackAlerts.esQuery.invalidQueryErrorMessage": "指定的查询无效:“{query}”- 查询必须为 JSON",
"xpack.stackAlerts.esQuery.invalidTermSizeMaximumErrorMessage": "[termSize]:必须小于或等于 {maxGroups}",
"xpack.stackAlerts.esQuery.invalidThreshold2ErrorMessage": "[threshold]:对于“{thresholdComparator}”比较运算符,必须包含两个元素",
"xpack.stackAlerts.esQuery.invalidWindowSizeErrorMessage": "windowSize 的格式无效:“{windowValue}”",
"xpack.stackAlerts.esQuery.ui.numQueryMatchesText": "查询在过去 {window} 匹配 {count} 个文档。",
"xpack.stackAlerts.esQuery.ui.queryError": "测试查询时出错:{message}",
"xpack.stackAlerts.esQuery.ui.testQueryGroupedResponse": "过去 {window} 与 {groups} 个组匹配的分组查询。",
@ -37776,7 +37775,6 @@
"xpack.triggersActionsUI.rulesSettings.modal.flappingDetectionDescription": "检测在“活动”和“已恢复”状态之间快速切换的告警,并为这些摆动告警减少不必要噪音。",
"xpack.triggersActionsUI.rulesSettings.modal.flappingOffLabel": "关闭",
"xpack.triggersActionsUI.rulesSettings.modal.flappingOnLabel": "开(建议)",
"xpack.triggersActionsUI.rulesSettings.modal.getRulesSettingsError": "无法获取规则设置。",
"xpack.triggersActionsUI.rulesSettings.modal.saveButton": "保存",
"xpack.triggersActionsUI.rulesSettings.modal.title": "规则设置",
"xpack.triggersActionsUI.rulesSettings.modal.updateRulesSettingsFailure": "无法更新规则设置。",

View file

@ -8,19 +8,7 @@
import React, { memo } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiFormRowProps,
EuiIconTip,
EuiRange,
EuiRangeProps,
EuiSpacer,
EuiTitle,
EuiText,
EuiPanel,
} from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle, EuiText, EuiPanel } from '@elastic/eui';
import {
RulesSettingsFlappingProperties,
MIN_LOOK_BACK_WINDOW,
@ -28,7 +16,7 @@ import {
MAX_LOOK_BACK_WINDOW,
MAX_STATUS_CHANGE_THRESHOLD,
} from '@kbn/alerting-plugin/common';
import { useKibana } from '../../../common/lib/kibana';
import { RulesSettingsRange } from '../rules_settings_range';
type OnChangeKey = keyof Omit<RulesSettingsFlappingProperties, 'enabled'>;
@ -81,16 +69,6 @@ const getStatusChangeThresholdRuleRuns = (amount: number) => {
);
};
export interface RulesSettingsRangeProps {
label: EuiFormRowProps['label'];
labelPopoverText?: string;
min: number;
max: number;
value: number;
disabled?: EuiRangeProps['disabled'];
onChange?: EuiRangeProps['onChange'];
}
export const RulesSettingsFlappingTitle = () => {
return (
<EuiTitle size="xs">
@ -115,58 +93,19 @@ export const RulesSettingsFlappingDescription = () => {
);
};
export const RulesSettingsRange = memo((props: RulesSettingsRangeProps) => {
const { label, labelPopoverText, min, max, value, disabled, onChange, ...rest } = props;
const renderLabel = () => {
return (
<div>
{label}
&nbsp;
<EuiIconTip color="subdued" size="s" type="questionInCircle" content={labelPopoverText} />
</div>
);
};
return (
<EuiFormRow label={renderLabel()}>
<EuiRange
min={min}
max={max}
step={1}
value={value}
disabled={disabled}
onChange={onChange}
showLabels
showValue
{...rest}
/>
</EuiFormRow>
);
});
export interface RulesSettingsFlappingFormSectionProps {
flappingSettings: RulesSettingsFlappingProperties;
compressed?: boolean;
onChange: (key: OnChangeKey, value: number) => void;
canWrite: boolean;
}
export const RulesSettingsFlappingFormSection = memo(
(props: RulesSettingsFlappingFormSectionProps) => {
const { flappingSettings, compressed = false, onChange } = props;
const { flappingSettings, compressed = false, onChange, canWrite } = props;
const { lookBackWindow, statusChangeThreshold } = flappingSettings;
const {
application: { capabilities },
} = useKibana().services;
const {
rulesSettings: { writeFlappingSettingsUI },
} = capabilities;
const canWriteFlappingSettings = writeFlappingSettingsUI;
return (
<EuiFlexGroup direction="column">
{compressed && (
@ -193,7 +132,7 @@ export const RulesSettingsFlappingFormSection = memo(
onChange={(e) => onChange('lookBackWindow', parseInt(e.currentTarget.value, 10))}
label={lookBackWindowLabel}
labelPopoverText={lookBackWindowHelp}
disabled={!canWriteFlappingSettings}
disabled={!canWrite}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
@ -205,7 +144,7 @@ export const RulesSettingsFlappingFormSection = memo(
onChange={(e) => onChange('statusChangeThreshold', parseInt(e.currentTarget.value, 10))}
label={statusChangeThresholdLabel}
labelPopoverText={statusChangeThresholdHelp}
disabled={!canWriteFlappingSettings}
disabled={!canWrite}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>

View file

@ -0,0 +1,185 @@
/*
* 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 React, { memo } from 'react';
import { RulesSettingsFlappingProperties } from '@kbn/alerting-plugin/common';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiSpacer,
EuiSwitch,
EuiSwitchProps,
EuiPanel,
EuiText,
EuiEmptyPrompt,
} from '@elastic/eui';
import {
RulesSettingsFlappingFormSection,
RulesSettingsFlappingFormSectionProps,
RulesSettingsFlappingTitle,
} from './rules_settings_flapping_form_section';
const flappingDescription = i18n.translate(
'xpack.triggersActionsUI.rulesSettings.modal.flappingDetectionDescription',
{
defaultMessage:
'Detect alerts that switch quickly between active and recovered states and reduce unwanted noise for these flapping alerts.',
}
);
const flappingOnLabel = i18n.translate(
'xpack.triggersActionsUI.rulesSettings.modal.flappingOnLabel',
{
defaultMessage: 'On (recommended)',
}
);
const flappingOffLabel = i18n.translate(
'xpack.triggersActionsUI.rulesSettings.modal.flappingOffLabel',
{
defaultMessage: 'Off',
}
);
export const RulesSettingsFlappingErrorPrompt = memo(() => {
return (
<EuiEmptyPrompt
data-test-subj="rulesSettingsFlappingErrorPrompt"
color="danger"
iconType="warning"
title={
<h4>
<FormattedMessage
id="xpack.triggersActionsUI.rulesSettings.modal.flappingErrorPromptTitle"
defaultMessage="Unable to load your flapping settings"
/>
</h4>
}
body={
<p>
<FormattedMessage
id="xpack.triggersActionsUI.rulesSettings.modal.flappingErrorPromptBody"
defaultMessage="There was an error loading your flapping settings. Contact your administrator for help"
/>
</p>
}
/>
);
});
interface RulesSettingsFlappingFormLeftProps {
settings: RulesSettingsFlappingProperties;
onChange: EuiSwitchProps['onChange'];
isSwitchDisabled: boolean;
}
export const RulesSettingsFlappingFormLeft = memo((props: RulesSettingsFlappingFormLeftProps) => {
const { settings, onChange, isSwitchDisabled } = props;
return (
<EuiFlexItem>
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<EuiText color="subdued" size="s">
<p>{flappingDescription}</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSwitch
data-test-subj="rulesSettingsFlappingEnableSwitch"
label={settings!.enabled ? flappingOnLabel : flappingOffLabel}
checked={settings!.enabled}
disabled={isSwitchDisabled}
onChange={onChange}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
);
});
interface RulesSettingsFlappingFormRightProps {
settings: RulesSettingsFlappingProperties;
onChange: RulesSettingsFlappingFormSectionProps['onChange'];
canWrite: boolean;
}
export const RulesSettingsFlappingFormRight = memo((props: RulesSettingsFlappingFormRightProps) => {
const { settings, onChange, canWrite } = props;
if (!settings) {
return null;
}
if (!settings.enabled) {
return (
<EuiFlexItem data-test-subj="rulesSettingsFlappingOffPrompt">
<EuiPanel borderRadius="none" color="subdued" grow={false}>
<EuiText size="s">
<FormattedMessage
id="xpack.triggersActionsUI.rulesSettings.flapping.flappingSettingsOffDescription"
defaultMessage="Alert flapping detection is off. Alerts will be generated based on the rule interval, which might result in higher alert volumes."
/>
</EuiText>
</EuiPanel>
</EuiFlexItem>
);
}
return (
<EuiFlexItem>
<RulesSettingsFlappingFormSection
flappingSettings={settings}
onChange={onChange}
canWrite={canWrite}
/>
</EuiFlexItem>
);
});
export interface RulesSettingsFlappingSectionProps {
onChange: (key: keyof RulesSettingsFlappingProperties, value: number | boolean) => void;
settings: RulesSettingsFlappingProperties;
canShow: boolean | Readonly<{ [x: string]: boolean }>;
canWrite: boolean;
hasError: boolean;
}
export const RulesSettingsFlappingSection = memo((props: RulesSettingsFlappingSectionProps) => {
const { onChange, settings, hasError, canShow, canWrite } = props;
if (!canShow) {
return null;
}
if (hasError) {
return <RulesSettingsFlappingErrorPrompt />;
}
return (
<EuiForm data-test-subj="rulesSettingsFlappingSection">
<EuiFlexGroup>
<EuiFlexItem>
<RulesSettingsFlappingTitle />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiFlexGroup>
<RulesSettingsFlappingFormLeft
isSwitchDisabled={!canWrite}
settings={settings}
onChange={(e) => onChange('enabled', e.target.checked)}
/>
<RulesSettingsFlappingFormRight
settings={settings}
onChange={(key, value) => onChange(key, value)}
canWrite={canWrite}
/>
</EuiFlexGroup>
</EuiForm>
);
});

View file

@ -0,0 +1,123 @@
/*
* 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 React, { memo } from 'react';
import {
MAX_QUERY_DELAY,
MIN_QUERY_DELAY,
RulesSettingsQueryDelayProperties,
} from '@kbn/alerting-plugin/common';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiSpacer,
EuiText,
EuiEmptyPrompt,
EuiTitle,
} from '@elastic/eui';
import { RulesSettingsRange } from '../rules_settings_range';
const queryDelayDescription = i18n.translate(
'xpack.triggersActionsUI.rulesSettings.modal.queryDelayDescription',
{
defaultMessage:
'Delay all rule queries to mitigate the impact of index refresh intervals on data availability.',
}
);
const queryDelayLabel = i18n.translate('xpack.triggersActionsUI.rulesSettings.queryDelayLabel', {
defaultMessage: 'Query delay length (seconds)',
});
export const RulesSettingsQueryDelayErrorPrompt = memo(() => {
return (
<EuiEmptyPrompt
data-test-subj="rulesSettingsQueryDelayErrorPrompt"
color="danger"
iconType="warning"
title={
<h4>
<FormattedMessage
id="xpack.triggersActionsUI.rulesSettings.modal.queryDelayErrorPromptTitle"
defaultMessage="Unable to load your query delay settings"
/>
</h4>
}
body={
<p>
<FormattedMessage
id="xpack.triggersActionsUI.rulesSettings.modal.queryDelayErrorPromptBody"
defaultMessage="There was an error loading your query delay settings. Contact your administrator for help"
/>
</p>
}
/>
);
});
export const RulesSettingsQueryDelayTitle = () => {
return (
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.triggersActionsUI.rulesSettings.queryDelayTitle"
defaultMessage="Query delay"
/>
</h5>
</EuiTitle>
);
};
export interface RulesSettingsQueryDelaySectionProps {
onChange: (key: keyof RulesSettingsQueryDelayProperties, value: number | boolean) => void;
settings: RulesSettingsQueryDelayProperties;
canShow: boolean | Readonly<{ [x: string]: boolean }>;
canWrite: boolean;
hasError: boolean;
}
export const RulesSettingsQueryDelaySection = memo((props: RulesSettingsQueryDelaySectionProps) => {
const { onChange, settings, hasError, canShow, canWrite } = props;
if (!canShow) {
return null;
}
if (hasError) {
return <RulesSettingsQueryDelayErrorPrompt />;
}
return (
<EuiForm data-test-subj="rulesSettingsQueryDelaySection">
<EuiFlexGroup>
<EuiFlexItem>
<RulesSettingsQueryDelayTitle />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiText color="subdued" size="s">
<p>{queryDelayDescription}</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow>
<RulesSettingsRange
data-test-subj="queryDelayRangeInput"
min={MIN_QUERY_DELAY}
max={MAX_QUERY_DELAY}
value={settings.delay}
onChange={(e) => onChange('delay', parseInt(e.currentTarget.value, 10))}
label={queryDelayLabel}
disabled={!canWrite}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiForm>
);
});

View file

@ -36,6 +36,8 @@ withAllPermission.decorators = [
save: true,
readFlappingSettingsUI: true,
writeFlappingSettingsUI: true,
readQueryDelaySettingsUI: true,
writeQueryDelaySettingsUI: true,
},
}),
}}
@ -58,6 +60,8 @@ withReadPermission.decorators = [
save: false,
readFlappingSettingsUI: true,
writeFlappingSettingsUI: false,
readQueryDelaySettingsUI: true,
writeQueryDelaySettingsUI: false,
},
}),
}}
@ -80,6 +84,8 @@ withNoPermission.decorators = [
save: false,
readFlappingSettingsUI: false,
writeFlappingSettingsUI: false,
readQueryDelaySettingsUI: false,
writeQueryDelaySettingsUI: false,
},
}),
}}

View file

@ -11,18 +11,18 @@ import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { render, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { coreMock } from '@kbn/core/public/mocks';
import { RulesSettingsFlapping } from '@kbn/alerting-plugin/common';
import { RulesSettingsFlapping, RulesSettingsQueryDelay } from '@kbn/alerting-plugin/common';
import { RulesSettingsLink } from './rules_settings_link';
import { useKibana } from '../../../common/lib/kibana';
import { getFlappingSettings } from '../../lib/rule_api/get_flapping_settings';
import { updateFlappingSettings } from '../../lib/rule_api/update_flapping_settings';
import { getQueryDelaySettings } from '../../lib/rule_api/get_query_delay_settings';
jest.mock('../../../common/lib/kibana');
jest.mock('../../lib/rule_api/get_flapping_settings', () => ({
getFlappingSettings: jest.fn(),
}));
jest.mock('../../lib/rule_api/update_flapping_settings', () => ({
updateFlappingSettings: jest.fn(),
jest.mock('../../lib/rule_api/get_query_delay_settings', () => ({
getQueryDelaySettings: jest.fn(),
}));
const queryClient = new QueryClient({
@ -41,8 +41,8 @@ const mocks = coreMock.createSetup();
const getFlappingSettingsMock = getFlappingSettings as unknown as jest.MockedFunction<
typeof getFlappingSettings
>;
const updateFlappingSettingsMock = updateFlappingSettings as unknown as jest.MockedFunction<
typeof updateFlappingSettings
const getQueryDelaySettingsMock = getQueryDelaySettings as unknown as jest.MockedFunction<
typeof getQueryDelaySettings
>;
const mockFlappingSetting: RulesSettingsFlapping = {
@ -54,6 +54,13 @@ const mockFlappingSetting: RulesSettingsFlapping = {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const mockQueryDelaySetting: RulesSettingsQueryDelay = {
delay: 10,
createdBy: 'test user',
updatedBy: 'test user',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const RulesSettingsLinkWithProviders: React.FunctionComponent<{}> = () => (
<IntlProvider locale="en">
@ -77,10 +84,12 @@ describe('rules_settings_link', () => {
show: true,
writeFlappingSettingsUI: true,
readFlappingSettingsUI: true,
writeQueryDelaySettingsUI: true,
readQueryDelaySettingsUI: true,
},
};
getFlappingSettingsMock.mockResolvedValue(mockFlappingSetting);
updateFlappingSettingsMock.mockResolvedValue(mockFlappingSetting);
getQueryDelaySettingsMock.mockResolvedValue(mockQueryDelaySetting);
});
afterEach(() => {
@ -98,6 +107,58 @@ describe('rules_settings_link', () => {
expect(result.queryByTestId('rulesSettingsModal')).toBe(null);
});
test('renders the rules setting link correctly (readFlappingSettingsUI = true)', async () => {
const [
{
application: { capabilities },
},
] = await mocks.getStartServices();
useKibanaMock().services.application.capabilities = {
...capabilities,
rulesSettings: {
save: true,
show: true,
writeFlappingSettingsUI: true,
readFlappingSettingsUI: true,
writeQueryDelaySettingsUI: true,
readQueryDelaySettingsUI: false,
},
};
const result = render(<RulesSettingsLinkWithProviders />);
await waitFor(() => {
expect(result.getByText('Settings')).toBeInTheDocument();
});
expect(result.getByText('Settings')).not.toBeDisabled();
expect(result.queryByTestId('rulesSettingsModal')).toBe(null);
});
test('renders the rules setting link correctly (readQueryDelaySettingsUI = true)', async () => {
const [
{
application: { capabilities },
},
] = await mocks.getStartServices();
useKibanaMock().services.application.capabilities = {
...capabilities,
rulesSettings: {
save: true,
show: true,
writeFlappingSettingsUI: true,
readFlappingSettingsUI: false,
writeQueryDelaySettingsUI: true,
readQueryDelaySettingsUI: true,
},
};
const result = render(<RulesSettingsLinkWithProviders />);
await waitFor(() => {
expect(result.getByText('Settings')).toBeInTheDocument();
});
expect(result.getByText('Settings')).not.toBeDisabled();
expect(result.queryByTestId('rulesSettingsModal')).toBe(null);
});
test('clicking the settings link opens the rules settings modal', async () => {
const result = render(<RulesSettingsLinkWithProviders />);
await waitFor(() => {
@ -124,6 +185,8 @@ describe('rules_settings_link', () => {
show: false,
writeFlappingSettingsUI: true,
readFlappingSettingsUI: true,
writeQueryDelaySettingsUI: true,
readQueryDelaySettingsUI: true,
},
};
@ -139,6 +202,8 @@ describe('rules_settings_link', () => {
show: true,
writeFlappingSettingsUI: true,
readFlappingSettingsUI: false,
writeQueryDelaySettingsUI: true,
readQueryDelaySettingsUI: false,
},
};

View file

@ -17,9 +17,9 @@ export const RulesSettingsLink = () => {
application: { capabilities },
} = useKibana().services;
const { show, readFlappingSettingsUI } = capabilities.rulesSettings;
const { show, readFlappingSettingsUI, readQueryDelaySettingsUI } = capabilities.rulesSettings;
if (!show || !readFlappingSettingsUI) {
if (!show || (!readFlappingSettingsUI && !readQueryDelaySettingsUI)) {
return null;
}

View file

@ -12,11 +12,13 @@ import { render, fireEvent, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { coreMock } from '@kbn/core/public/mocks';
import { IToasts } from '@kbn/core/public';
import { RulesSettingsFlapping } from '@kbn/alerting-plugin/common';
import { RulesSettingsFlapping, RulesSettingsQueryDelay } from '@kbn/alerting-plugin/common';
import { RulesSettingsModal, RulesSettingsModalProps } from './rules_settings_modal';
import { useKibana } from '../../../common/lib/kibana';
import { getFlappingSettings } from '../../lib/rule_api/get_flapping_settings';
import { updateFlappingSettings } from '../../lib/rule_api/update_flapping_settings';
import { getQueryDelaySettings } from '../../lib/rule_api/get_query_delay_settings';
import { updateQueryDelaySettings } from '../../lib/rule_api/update_query_delay_settings';
jest.mock('../../../common/lib/kibana');
jest.mock('../../lib/rule_api/get_flapping_settings', () => ({
@ -25,6 +27,12 @@ jest.mock('../../lib/rule_api/get_flapping_settings', () => ({
jest.mock('../../lib/rule_api/update_flapping_settings', () => ({
updateFlappingSettings: jest.fn(),
}));
jest.mock('../../lib/rule_api/get_query_delay_settings', () => ({
getQueryDelaySettings: jest.fn(),
}));
jest.mock('../../lib/rule_api/update_query_delay_settings', () => ({
updateQueryDelaySettings: jest.fn(),
}));
const queryClient = new QueryClient({
defaultOptions: {
@ -45,6 +53,12 @@ const getFlappingSettingsMock = getFlappingSettings as unknown as jest.MockedFun
const updateFlappingSettingsMock = updateFlappingSettings as unknown as jest.MockedFunction<
typeof updateFlappingSettings
>;
const getQueryDelaySettingsMock = getQueryDelaySettings as unknown as jest.MockedFunction<
typeof getQueryDelaySettings
>;
const updateQueryDelaySettingsMock = updateQueryDelaySettings as unknown as jest.MockedFunction<
typeof updateQueryDelaySettings
>;
const mockFlappingSetting: RulesSettingsFlapping = {
enabled: true,
@ -55,6 +69,13 @@ const mockFlappingSetting: RulesSettingsFlapping = {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const mockQueryDelaySetting: RulesSettingsQueryDelay = {
delay: 10,
createdBy: 'test user',
updatedBy: 'test user',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const modalProps: RulesSettingsModalProps = {
isVisible: true,
@ -87,6 +108,8 @@ describe('rules_settings_modal', () => {
show: true,
writeFlappingSettingsUI: true,
readFlappingSettingsUI: true,
writeQueryDelaySettingsUI: true,
readQueryDelaySettingsUI: true,
},
};
@ -99,6 +122,8 @@ describe('rules_settings_modal', () => {
getFlappingSettingsMock.mockResolvedValue(mockFlappingSetting);
updateFlappingSettingsMock.mockResolvedValue(mockFlappingSetting);
getQueryDelaySettingsMock.mockResolvedValue(mockQueryDelaySetting);
updateQueryDelaySettingsMock.mockResolvedValue(mockQueryDelaySetting);
});
afterEach(() => {
@ -113,9 +138,9 @@ describe('rules_settings_modal', () => {
await waitFor(() => {
expect(result.queryByTestId('centerJustifiedSpinner')).toBe(null);
});
expect(result.getByTestId('rulesSettingsModalEnableSwitch').getAttribute('aria-checked')).toBe(
'true'
);
expect(
result.getByTestId('rulesSettingsFlappingEnableSwitch').getAttribute('aria-checked')
).toBe('true');
expect(result.getByTestId('lookBackWindowRangeInput').getAttribute('value')).toBe('10');
expect(result.getByTestId('statusChangeThresholdRangeInput').getAttribute('value')).toBe('10');
@ -190,6 +215,15 @@ describe('rules_settings_modal', () => {
expect(result.queryByTestId('centerJustifiedSpinner')).toBe(null);
});
const lookBackWindowInput = result.getByTestId('lookBackWindowRangeInput');
const statusChangeThresholdInput = result.getByTestId('statusChangeThresholdRangeInput');
fireEvent.change(lookBackWindowInput, { target: { value: 20 } });
fireEvent.change(statusChangeThresholdInput, { target: { value: 5 } });
expect(lookBackWindowInput.getAttribute('value')).toBe('20');
expect(statusChangeThresholdInput.getAttribute('value')).toBe('5');
// Try saving
userEvent.click(result.getByTestId('rulesSettingsModalSaveButton'));
await waitFor(() => {
@ -207,9 +241,9 @@ describe('rules_settings_modal', () => {
expect(result.queryByTestId('centerJustifiedSpinner')).toBe(null);
});
expect(result.queryByTestId('rulesSettingsModalFlappingOffPrompt')).toBe(null);
userEvent.click(result.getByTestId('rulesSettingsModalEnableSwitch'));
expect(result.queryByTestId('rulesSettingsModalFlappingOffPrompt')).not.toBe(null);
expect(result.queryByTestId('rulesSettingsFlappingOffPrompt')).toBe(null);
userEvent.click(result.getByTestId('rulesSettingsFlappingEnableSwitch'));
expect(result.queryByTestId('rulesSettingsFlappingOffPrompt')).not.toBe(null);
});
test('form elements are disabled when provided with insufficient write permissions', async () => {
@ -232,7 +266,7 @@ describe('rules_settings_modal', () => {
expect(result.queryByTestId('centerJustifiedSpinner')).toBe(null);
});
expect(result.getByTestId('rulesSettingsModalEnableSwitch')).toBeDisabled();
expect(result.getByTestId('rulesSettingsFlappingEnableSwitch')).toBeDisabled();
expect(result.getByTestId('lookBackWindowRangeInput')).toBeDisabled();
expect(result.getByTestId('statusChangeThresholdRangeInput')).toBeDisabled();
expect(result.getByTestId('rulesSettingsModalSaveButton')).toBeDisabled();
@ -259,6 +293,118 @@ describe('rules_settings_modal', () => {
expect(result.queryByTestId('centerJustifiedSpinner')).toBe(null);
});
expect(result.getByTestId('rulesSettingsErrorPrompt')).toBeInTheDocument();
expect(result.queryByTestId('rulesSettingsFlappingSection')).toBe(null);
});
test('renders query delay settings correctly', async () => {
const result = render(<RulesSettingsModalWithProviders {...modalProps} />);
expect(getQueryDelaySettingsMock).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(result.queryByTestId('centerJustifiedSpinner')).toBe(null);
});
expect(result.getByTestId('queryDelayRangeInput').getAttribute('value')).toBe('10');
expect(result.getByTestId('rulesSettingsModalCancelButton')).toBeInTheDocument();
expect(result.getByTestId('rulesSettingsModalSaveButton').getAttribute('disabled')).toBeFalsy();
});
test('can save query delay settings', async () => {
const result = render(<RulesSettingsModalWithProviders {...modalProps} />);
await waitFor(() => {
expect(result.queryByTestId('centerJustifiedSpinner')).toBe(null);
});
const queryDelayRangeInput = result.getByTestId('queryDelayRangeInput');
fireEvent.change(queryDelayRangeInput, { target: { value: 20 } });
expect(queryDelayRangeInput.getAttribute('value')).toBe('20');
// Try saving
userEvent.click(result.getByTestId('rulesSettingsModalSaveButton'));
await waitFor(() => {
expect(modalProps.setUpdatingRulesSettings).toHaveBeenCalledWith(true);
});
expect(modalProps.onClose).toHaveBeenCalledTimes(1);
expect(updateQueryDelaySettingsMock).toHaveBeenCalledWith(
expect.objectContaining({
queryDelaySettings: {
delay: 20,
},
})
);
expect(useKibanaMock().services.notifications.toasts.addSuccess).toHaveBeenCalledTimes(1);
expect(modalProps.setUpdatingRulesSettings).toHaveBeenCalledWith(true);
expect(modalProps.onSave).toHaveBeenCalledTimes(1);
});
test('handles errors when saving query delay settings', async () => {
updateQueryDelaySettingsMock.mockRejectedValue('failed!');
const result = render(<RulesSettingsModalWithProviders {...modalProps} />);
await waitFor(() => {
expect(result.queryByTestId('centerJustifiedSpinner')).toBe(null);
});
const queryDelayRangeInput = result.getByTestId('queryDelayRangeInput');
fireEvent.change(queryDelayRangeInput, { target: { value: 20 } });
expect(queryDelayRangeInput.getAttribute('value')).toBe('20');
// Try saving
userEvent.click(result.getByTestId('rulesSettingsModalSaveButton'));
await waitFor(() => {
expect(modalProps.setUpdatingRulesSettings).toHaveBeenCalledWith(true);
});
expect(modalProps.onClose).toHaveBeenCalledTimes(1);
expect(useKibanaMock().services.notifications.toasts.addDanger).toHaveBeenCalledTimes(1);
expect(modalProps.setUpdatingRulesSettings).toHaveBeenCalledWith(true);
expect(modalProps.onSave).toHaveBeenCalledTimes(1);
});
test('query delay form elements are disabled when provided with insufficient write permissions', async () => {
const [
{
application: { capabilities },
},
] = await mocks.getStartServices();
useKibanaMock().services.application.capabilities = {
...capabilities,
rulesSettings: {
save: true,
show: true,
writeQueryDelaySettingsUI: false,
readQueryDelaySettingsUI: true,
},
};
const result = render(<RulesSettingsModalWithProviders {...modalProps} />);
await waitFor(() => {
expect(result.queryByTestId('centerJustifiedSpinner')).toBe(null);
});
expect(result.getByTestId('queryDelayRangeInput')).toBeDisabled();
expect(result.getByTestId('rulesSettingsModalSaveButton')).toBeDisabled();
});
test('query delay form elements are not visible when provided with insufficient read permissions', async () => {
const [
{
application: { capabilities },
},
] = await mocks.getStartServices();
useKibanaMock().services.application.capabilities = {
...capabilities,
rulesSettings: {
save: true,
show: false,
writeQueryDelaySettingsUI: true,
readQueryDelaySettingsUI: false,
},
};
const result = render(<RulesSettingsModalWithProviders {...modalProps} />);
await waitFor(() => {
expect(result.queryByTestId('centerJustifiedSpinner')).toBe(null);
});
expect(result.queryByTestId('rulesSettingsQueryDelaySection')).toBe(null);
});
});

View file

@ -6,7 +6,11 @@
*/
import React, { memo, useState } from 'react';
import { RulesSettingsFlappingProperties } from '@kbn/alerting-plugin/common';
import {
RulesSettingsFlappingProperties,
RulesSettingsProperties,
RulesSettingsQueryDelayProperties,
} from '@kbn/alerting-plugin/common';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
@ -14,53 +18,22 @@ import {
EuiButtonEmpty,
EuiCallOut,
EuiHorizontalRule,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiModal,
EuiModalHeader,
EuiModalBody,
EuiModalFooter,
EuiModalHeaderTitle,
EuiSpacer,
EuiSwitch,
EuiSwitchProps,
EuiPanel,
EuiText,
EuiEmptyPrompt,
} from '@elastic/eui';
import { useKibana } from '../../../common/lib/kibana';
import {
RulesSettingsFlappingFormSection,
RulesSettingsFlappingFormSectionProps,
RulesSettingsFlappingTitle,
} from './rules_settings_flapping_form_section';
import { useGetFlappingSettings } from '../../hooks/use_get_flapping_settings';
import { useUpdateFlappingSettings } from '../../hooks/use_update_flapping_settings';
import { RulesSettingsFlappingSection } from './flapping/rules_settings_flapping_section';
import { RulesSettingsQueryDelaySection } from './query_delay/rules_settings_query_delay_section';
import { useGetQueryDelaySettings } from '../../hooks/use_get_query_delay_settings';
import { useUpdateRuleSettings } from '../../hooks/use_update_rules_settings';
import { CenterJustifiedSpinner } from '../center_justified_spinner';
const flappingDescription = i18n.translate(
'xpack.triggersActionsUI.rulesSettings.modal.flappingDetectionDescription',
{
defaultMessage:
'Detect alerts that switch quickly between active and recovered states and reduce unwanted noise for these flapping alerts.',
}
);
const flappingOnLabel = i18n.translate(
'xpack.triggersActionsUI.rulesSettings.modal.flappingOnLabel',
{
defaultMessage: 'On (recommended)',
}
);
const flappingOffLabel = i18n.translate(
'xpack.triggersActionsUI.rulesSettings.modal.flappingOffLabel',
{
defaultMessage: 'Off',
}
);
export const RulesSettingsErrorPrompt = memo(() => {
return (
<EuiEmptyPrompt
@ -87,70 +60,6 @@ export const RulesSettingsErrorPrompt = memo(() => {
);
});
interface RulesSettingsModalFormLeftProps {
settings: RulesSettingsFlappingProperties;
onChange: EuiSwitchProps['onChange'];
isSwitchDisabled: boolean;
}
export const RulesSettingsModalFormLeft = memo((props: RulesSettingsModalFormLeftProps) => {
const { settings, onChange, isSwitchDisabled } = props;
return (
<EuiFlexItem>
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<EuiText color="subdued" size="s">
<p>{flappingDescription}</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSwitch
data-test-subj="rulesSettingsModalEnableSwitch"
label={settings!.enabled ? flappingOnLabel : flappingOffLabel}
checked={settings!.enabled}
disabled={isSwitchDisabled}
onChange={onChange}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
);
});
interface RulesSettingsModalFormRightProps {
settings: RulesSettingsFlappingProperties;
onChange: RulesSettingsFlappingFormSectionProps['onChange'];
}
export const RulesSettingsModalFormRight = memo((props: RulesSettingsModalFormRightProps) => {
const { settings, onChange } = props;
if (!settings) {
return null;
}
if (!settings.enabled) {
return (
<EuiFlexItem data-test-subj="rulesSettingsModalFlappingOffPrompt">
<EuiPanel borderRadius="none" color="subdued" grow={false}>
<EuiText size="s">
<FormattedMessage
id="xpack.triggersActionsUI.rulesSettings.flapping.flappingSettingsOffDescription"
defaultMessage="Alert flapping detection is off. Alerts will be generated based on the rule interval, which might result in higher alert volumes."
/>
</EuiText>
</EuiPanel>
</EuiFlexItem>
);
}
return (
<EuiFlexItem>
<RulesSettingsFlappingFormSection flappingSettings={settings} onChange={onChange} />
</EuiFlexItem>
);
});
export interface RulesSettingsModalProps {
isVisible: boolean;
setUpdatingRulesSettings?: (isUpdating: boolean) => void;
@ -165,16 +74,27 @@ export const RulesSettingsModal = memo((props: RulesSettingsModalProps) => {
application: { capabilities },
} = useKibana().services;
const {
rulesSettings: { show, save, writeFlappingSettingsUI, readFlappingSettingsUI },
rulesSettings: {
show,
save,
writeFlappingSettingsUI,
readFlappingSettingsUI,
writeQueryDelaySettingsUI,
readQueryDelaySettingsUI,
},
} = capabilities;
const [settings, setSettings] = useState<RulesSettingsFlappingProperties>();
const [flappingSettings, setFlappingSettings] = useState<RulesSettingsFlappingProperties>();
const [hasFlappingChanged, setHasFlappingChanged] = useState<boolean>(false);
const { isLoading, isError: hasError } = useGetFlappingSettings({
const [queryDelaySettings, setQueryDelaySettings] = useState<RulesSettingsQueryDelayProperties>();
const [hasQueryDelayChanged, setHasQueryDelayChanged] = useState<boolean>(false);
const { isLoading: isFlappingLoading, isError: hasFlappingError } = useGetFlappingSettings({
enabled: isVisible,
onSuccess: (fetchedSettings) => {
if (!settings) {
setSettings({
if (!flappingSettings) {
setFlappingSettings({
enabled: fetchedSettings.enabled,
lookBackWindow: fetchedSettings.lookBackWindow,
statusChangeThreshold: fetchedSettings.statusChangeThreshold,
@ -183,7 +103,18 @@ export const RulesSettingsModal = memo((props: RulesSettingsModalProps) => {
},
});
const { mutate } = useUpdateFlappingSettings({
const { isLoading: isQueryDelayLoading, isError: hasQueryDelayError } = useGetQueryDelaySettings({
enabled: isVisible,
onSuccess: (fetchedSettings) => {
if (!queryDelaySettings) {
setQueryDelaySettings({
delay: fetchedSettings.delay,
});
}
},
});
const { mutate } = useUpdateRuleSettings({
onSave,
onClose,
setUpdatingRulesSettings,
@ -192,36 +123,56 @@ export const RulesSettingsModal = memo((props: RulesSettingsModalProps) => {
// In the future when we have more settings sub-features, we should
// disassociate the rule settings capabilities (save, show) from the
// sub-feature capabilities (writeXSettingsUI).
const canWriteFlappingSettings = save && writeFlappingSettingsUI && !hasError;
const canWriteFlappingSettings = save && writeFlappingSettingsUI && !hasFlappingError;
const canShowFlappingSettings = show && readFlappingSettingsUI;
const canWriteQueryDelaySettings = save && writeQueryDelaySettingsUI && !hasQueryDelayError;
const canShowQueryDelaySettings = show && readQueryDelaySettingsUI;
const handleSettingsChange = (
key: keyof RulesSettingsFlappingProperties,
value: number | boolean
setting: keyof RulesSettingsProperties,
key: keyof RulesSettingsFlappingProperties | keyof RulesSettingsQueryDelayProperties,
value: boolean | number
) => {
if (!settings) {
return;
if (setting === 'flapping') {
if (!flappingSettings) {
return;
}
const newSettings = {
...flappingSettings,
[key]: value,
};
setFlappingSettings({
...newSettings,
statusChangeThreshold: Math.min(
newSettings.lookBackWindow,
newSettings.statusChangeThreshold
),
});
setHasFlappingChanged(true);
}
const newSettings = {
...settings,
[key]: value,
};
setSettings({
...newSettings,
statusChangeThreshold: Math.min(
newSettings.lookBackWindow,
newSettings.statusChangeThreshold
),
});
if (setting === 'queryDelay') {
if (!queryDelaySettings) {
return;
}
const newSettings = {
...queryDelaySettings,
[key]: value,
};
setQueryDelaySettings(newSettings);
setHasQueryDelayChanged(true);
}
};
const handleSave = () => {
if (!settings) {
return;
const updatedSettings: RulesSettingsProperties = {};
if (canWriteFlappingSettings && hasFlappingChanged) {
updatedSettings.flapping = flappingSettings;
}
mutate(settings);
if (canWriteQueryDelaySettings && hasQueryDelayChanged) {
updatedSettings.queryDelay = queryDelaySettings;
}
mutate(updatedSettings);
};
if (!isVisible) {
@ -229,32 +180,36 @@ export const RulesSettingsModal = memo((props: RulesSettingsModalProps) => {
}
const maybeRenderForm = () => {
if (hasError || !canShowFlappingSettings) {
if (!canShowFlappingSettings && !canShowQueryDelaySettings) {
return <RulesSettingsErrorPrompt />;
}
if (!settings || isLoading) {
if (isFlappingLoading || isQueryDelayLoading) {
return <CenterJustifiedSpinner />;
}
return (
<EuiForm>
<EuiFlexGroup>
<EuiFlexItem>
<RulesSettingsFlappingTitle />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiFlexGroup>
<RulesSettingsModalFormLeft
isSwitchDisabled={!canWriteFlappingSettings}
settings={settings}
onChange={(e) => handleSettingsChange('enabled', e.target.checked)}
<>
{flappingSettings && (
<RulesSettingsFlappingSection
onChange={(key, value) => handleSettingsChange('flapping', key, value)}
settings={flappingSettings}
canWrite={canWriteFlappingSettings}
canShow={canShowFlappingSettings}
hasError={hasFlappingError}
/>
<RulesSettingsModalFormRight
settings={settings}
onChange={(key, value) => handleSettingsChange(key, value)}
/>
</EuiFlexGroup>
</EuiForm>
)}
{queryDelaySettings && (
<>
<EuiSpacer />
<RulesSettingsQueryDelaySection
onChange={(key, value) => handleSettingsChange('queryDelay', key, value)}
settings={queryDelaySettings}
canWrite={canWriteQueryDelaySettings}
canShow={canShowQueryDelaySettings}
hasError={hasQueryDelayError}
/>
</>
)}
</>
);
};
@ -291,7 +246,7 @@ export const RulesSettingsModal = memo((props: RulesSettingsModalProps) => {
fill
data-test-subj="rulesSettingsModalSaveButton"
onClick={handleSave}
disabled={!canWriteFlappingSettings}
disabled={!canWriteFlappingSettings && !canWriteQueryDelaySettings}
>
<FormattedMessage
id="xpack.triggersActionsUI.rulesSettings.modal.saveButton"

View file

@ -0,0 +1,51 @@
/*
* 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 React, { memo } from 'react';
import { EuiFormRow, EuiFormRowProps, EuiIconTip, EuiRange, EuiRangeProps } from '@elastic/eui';
export interface RulesSettingsRangeProps {
label: EuiFormRowProps['label'];
labelPopoverText?: string;
min: number;
max: number;
value: number;
disabled?: EuiRangeProps['disabled'];
onChange?: EuiRangeProps['onChange'];
}
export const RulesSettingsRange = memo((props: RulesSettingsRangeProps) => {
const { label, labelPopoverText, min, max, value, disabled, onChange, ...rest } = props;
const renderLabel = () => {
return (
<div>
{label}
&nbsp;
{labelPopoverText && (
<EuiIconTip color="subdued" size="s" type="questionInCircle" content={labelPopoverText} />
)}
</div>
);
};
return (
<EuiFormRow label={renderLabel()}>
<EuiRange
min={min}
max={max}
step={1}
value={value}
disabled={disabled}
onChange={onChange}
showLabels
showValue
{...rest}
/>
</EuiFormRow>
);
});

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { useQuery } from '@tanstack/react-query';
import { RulesSettingsFlapping } from '@kbn/alerting-plugin/common';
import { useKibana } from '../../common/lib/kibana';
@ -18,27 +17,15 @@ interface UseGetFlappingSettingsProps {
export const useGetFlappingSettings = (props: UseGetFlappingSettingsProps) => {
const { enabled, onSuccess } = props;
const {
http,
notifications: { toasts },
} = useKibana().services;
const { http } = useKibana().services;
const queryFn = () => {
return getFlappingSettings({ http });
};
const onErrorFn = () => {
toasts.addDanger(
i18n.translate('xpack.triggersActionsUI.rulesSettings.modal.getRulesSettingsError', {
defaultMessage: 'Failed to get rules Settings.',
})
);
};
const { data, isFetching, isError, isLoadingError, isLoading } = useQuery({
queryKey: ['getFlappingSettings'],
queryFn,
onError: onErrorFn,
onSuccess,
enabled,
refetchOnWindowFocus: false,

View file

@ -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 React from 'react';
import { renderHook } from '@testing-library/react-hooks/dom';
import { waitFor } from '@testing-library/dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useGetQueryDelaySettings } from './use_get_query_delay_settings';
jest.mock('../lib/rule_api/get_query_delay_settings', () => ({
getQueryDelaySettings: jest.fn(),
}));
const { getQueryDelaySettings } = jest.requireMock('../lib/rule_api/get_query_delay_settings');
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: 0,
},
},
});
const wrapper = ({ children }: { children: Node }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
describe('useGetQueryDelaySettings', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should call getQueryDelaySettings', async () => {
renderHook(() => useGetQueryDelaySettings({ enabled: true, onSuccess: () => {} }), {
wrapper,
});
await waitFor(() => {
expect(getQueryDelaySettings).toHaveBeenCalled();
});
});
it('should return isError = true if api fails', async () => {
getQueryDelaySettings.mockRejectedValue('This is an error.');
const { result } = renderHook(
() => useGetQueryDelaySettings({ enabled: true, onSuccess: () => {} }),
{
wrapper,
}
);
await waitFor(() => expect(result.current.isError).toBe(true));
});
});

View file

@ -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 { useQuery } from '@tanstack/react-query';
import { RulesSettingsQueryDelay } from '@kbn/alerting-plugin/common';
import { useKibana } from '../../common/lib/kibana';
import { getQueryDelaySettings } from '../lib/rule_api/get_query_delay_settings';
interface UseGetQueryDelaySettingsProps {
enabled: boolean;
onSuccess: (settings: RulesSettingsQueryDelay) => void;
}
export const useGetQueryDelaySettings = (props: UseGetQueryDelaySettingsProps) => {
const { enabled, onSuccess } = props;
const { http } = useKibana().services;
const queryFn = () => {
return getQueryDelaySettings({ http });
};
const { data, isFetching, isError, isLoadingError, isLoading } = useQuery({
queryKey: ['getQueryDelaySettings'],
queryFn,
onSuccess,
enabled,
refetchOnWindowFocus: false,
retry: false,
});
return {
isLoading: isLoading || isFetching,
isError: isError || isLoadingError,
data,
};
};

View file

@ -0,0 +1,109 @@
/*
* 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 React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, renderHook } from '@testing-library/react-hooks/dom';
import { waitFor } from '@testing-library/dom';
import { useUpdateRuleSettings } from './use_update_rules_settings';
const mockAddDanger = jest.fn();
const mockAddSuccess = jest.fn();
jest.mock('../../common/lib/kibana', () => {
const originalModule = jest.requireActual('../../common/lib/kibana');
return {
...originalModule,
useKibana: () => {
const { services } = originalModule.useKibana();
return {
services: {
...services,
notifications: { toasts: { addSuccess: mockAddSuccess, addDanger: mockAddDanger } },
},
};
},
};
});
jest.mock('../lib/rule_api/update_query_delay_settings', () => ({
updateQueryDelaySettings: jest.fn(),
}));
jest.mock('../lib/rule_api/update_flapping_settings', () => ({
updateFlappingSettings: jest.fn(),
}));
const { updateQueryDelaySettings } = jest.requireMock(
'../lib/rule_api/update_query_delay_settings'
);
const { updateFlappingSettings } = jest.requireMock('../lib/rule_api/update_flapping_settings');
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: 0,
},
},
});
const wrapper = ({ children }: { children: Node }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
describe('useUpdateRuleSettings', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should call onSuccess if api succeeds', async () => {
const { result } = renderHook(
() =>
useUpdateRuleSettings({
onSave: () => {},
onClose: () => {},
setUpdatingRulesSettings: () => {},
}),
{
wrapper,
}
);
await act(async () => {
await result.current.mutate({
flapping: { enabled: true, lookBackWindow: 3, statusChangeThreshold: 3 },
queryDelay: { delay: 2 },
});
});
await waitFor(() =>
expect(mockAddSuccess).toBeCalledWith('Rules settings updated successfully.')
);
});
it('should call onError if api fails', async () => {
updateQueryDelaySettings.mockRejectedValue('');
updateFlappingSettings.mockRejectedValue('');
const { result } = renderHook(
() =>
useUpdateRuleSettings({
onSave: () => {},
onClose: () => {},
setUpdatingRulesSettings: () => {},
}),
{
wrapper,
}
);
await act(async () => {
await result.current.mutate({
flapping: { enabled: true, lookBackWindow: 3, statusChangeThreshold: 3 },
queryDelay: { delay: 2 },
});
});
await waitFor(() => expect(mockAddDanger).toBeCalledWith('Failed to update rules settings.'));
});
});

View file

@ -7,17 +7,18 @@
import { i18n } from '@kbn/i18n';
import { useMutation } from '@tanstack/react-query';
import { RulesSettingsFlappingProperties } from '@kbn/alerting-plugin/common';
import { RulesSettingsProperties } from '@kbn/alerting-plugin/common';
import { useKibana } from '../../common/lib/kibana';
import { updateFlappingSettings } from '../lib/rule_api/update_flapping_settings';
import { updateQueryDelaySettings } from '../lib/rule_api/update_query_delay_settings';
interface UseUpdateFlappingSettingsProps {
interface UseUpdateRuleSettingsProps {
onClose: () => void;
onSave?: () => void;
setUpdatingRulesSettings?: (isUpdating: boolean) => void;
}
export const useUpdateFlappingSettings = (props: UseUpdateFlappingSettingsProps) => {
export const useUpdateRuleSettings = (props: UseUpdateRuleSettingsProps) => {
const { onSave, onClose, setUpdatingRulesSettings } = props;
const {
@ -25,8 +26,17 @@ export const useUpdateFlappingSettings = (props: UseUpdateFlappingSettingsProps)
notifications: { toasts },
} = useKibana().services;
const mutationFn = (flappingSettings: RulesSettingsFlappingProperties) => {
return updateFlappingSettings({ http, flappingSettings });
const mutationFn = async (settings: RulesSettingsProperties) => {
const updates = [];
if (settings.flapping) {
updates.push(updateFlappingSettings({ http, flappingSettings: settings.flapping }));
}
if (settings.queryDelay) {
updates.push(updateQueryDelaySettings({ http, queryDelaySettings: settings.queryDelay }));
}
return await Promise.all(updates);
};
return useMutation({

View file

@ -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.
*/
import { httpServiceMock } from '@kbn/core/public/mocks';
import { getQueryDelaySettings } from './get_query_delay_settings';
const http = httpServiceMock.createStartContract();
beforeEach(() => jest.resetAllMocks());
describe('getQueryDelaySettings', () => {
test('should call get query delay settings api', async () => {
const apiResponse = {
delay: 10,
};
http.get.mockResolvedValueOnce(apiResponse);
const result = await getQueryDelaySettings({ http });
expect(result).toEqual({ delay: 10 });
expect(http.get.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/internal/alerting/rules/settings/_query_delay",
]
`);
});
});

View file

@ -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 { HttpSetup } from '@kbn/core/public';
import { AsApiContract, RewriteRequestCase } from '@kbn/actions-plugin/common';
import { RulesSettingsQueryDelay } from '@kbn/alerting-plugin/common';
import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants';
const rewriteBodyRes: RewriteRequestCase<RulesSettingsQueryDelay> = ({ ...rest }: any) => ({
...rest,
});
export const getQueryDelaySettings = async ({ http }: { http: HttpSetup }) => {
const res = await http.get<AsApiContract<RulesSettingsQueryDelay>>(
`${INTERNAL_BASE_ALERTING_API_PATH}/rules/settings/_query_delay`
);
return rewriteBodyRes(res);
};

View file

@ -0,0 +1,33 @@
/*
* 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/public/mocks';
import { updateQueryDelaySettings } from './update_query_delay_settings';
const http = httpServiceMock.createStartContract();
beforeEach(() => jest.resetAllMocks());
describe('updateQueryDelaySettings', () => {
test('should call update query delay settings api', async () => {
const apiResponse = {
delay: 10,
};
http.post.mockResolvedValueOnce(apiResponse);
const result = await updateQueryDelaySettings({ http, queryDelaySettings: { delay: 10 } });
expect(result).toEqual({ delay: 10 });
expect(http.post.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/internal/alerting/rules/settings/_query_delay",
Object {
"body": "{\\"delay\\":10}",
},
]
`);
});
});

View file

@ -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 { HttpSetup } from '@kbn/core/public';
import {
RulesSettingsQueryDelay,
RulesSettingsQueryDelayProperties,
} from '@kbn/alerting-plugin/common';
import { AsApiContract, RewriteRequestCase } from '@kbn/actions-plugin/common';
import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants';
const rewriteBodyRes: RewriteRequestCase<RulesSettingsQueryDelay> = ({ ...rest }: any) => ({
...rest,
});
export const updateQueryDelaySettings = async ({
http,
queryDelaySettings,
}: {
http: HttpSetup;
queryDelaySettings: RulesSettingsQueryDelayProperties;
}) => {
let body: string;
try {
body = JSON.stringify({
delay: queryDelaySettings.delay,
});
} catch (e) {
throw new Error(`Unable to parse query delay settings update params: ${e}`);
}
const res = await http.post<AsApiContract<RulesSettingsQueryDelay>>(
`${INTERNAL_BASE_ALERTING_API_PATH}/rules/settings/_query_delay`,
{
body,
}
);
return rewriteBodyRes(res);
};

View file

@ -713,6 +713,28 @@ describe('timeSeriesQuery', () => {
{ ignore: [404], meta: true }
);
});
it('uses the passed in date parms when useCalculatedDateRange = false param is passed', async () => {
await timeSeriesQuery({
...params,
useCalculatedDateRange: false,
query: {
...params.query,
dateStart: '2023-10-12T00:00:00Z',
dateEnd: '2023-10-12T00:00:00Z',
},
});
// @ts-ignore
expect(esClient.search.mock.calls[0]![0].body.query.bool.filter[0]).toEqual({
range: {
'time-field': {
format: 'strict_date_time',
gte: '2023-10-12T00:00:00Z',
lt: '2023-10-12T00:00:00Z',
},
},
});
});
});
describe('getResultFromEs', () => {

View file

@ -33,12 +33,19 @@ export interface TimeSeriesQueryParameters {
esClient: ElasticsearchClient;
query: TimeSeriesQuery;
condition?: TimeSeriesCondition;
useCalculatedDateRange?: boolean;
}
export async function timeSeriesQuery(
params: TimeSeriesQueryParameters
): Promise<TimeSeriesResult> {
const { logger, esClient, query: queryParams, condition: conditionParams } = params;
const {
logger,
esClient,
query: queryParams,
condition: conditionParams,
useCalculatedDateRange = true,
} = params;
const {
index,
timeWindowSize,
@ -67,8 +74,8 @@ export async function timeSeriesQuery(
{
range: {
[timeField]: {
gte: dateRangeInfo.dateStart,
lt: dateRangeInfo.dateEnd,
gte: useCalculatedDateRange ? dateRangeInfo.dateStart : dateStart,
lt: useCalculatedDateRange ? dateRangeInfo.dateEnd : dateEnd,
format: 'strict_date_time',
},
},

Some files were not shown because too many files have changed in this diff Show more