diff --git a/x-pack/plugins/infra/public/alerting/inventory/index.ts b/x-pack/plugins/infra/public/alerting/inventory/index.ts index b66bd94553d3c..7d370c7106cb7 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/index.ts +++ b/x-pack/plugins/infra/public/alerting/inventory/index.ts @@ -12,16 +12,18 @@ import { METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '../../../server/lib/alerting/inventory_metric_threshold/types'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { AlertTypeModel } from '../../../../triggers_actions_ui/public/types'; + +import { ObservabilityRuleTypeModel } from '../../../../observability/public'; + import { AlertTypeParams } from '../../../../alerting/common'; import { validateMetricThreshold } from './components/validation'; +import { formatReason } from './rule_data_formatters'; interface InventoryMetricAlertTypeParams extends AlertTypeParams { criteria: InventoryMetricConditions[]; } -export function createInventoryMetricAlertType(): AlertTypeModel { +export function createInventoryMetricAlertType(): ObservabilityRuleTypeModel { return { id: METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, description: i18n.translate('xpack.infra.metrics.inventory.alertFlyout.alertDescription', { @@ -44,5 +46,6 @@ Reason: } ), requiresAppContext: false, + format: formatReason, }; } diff --git a/x-pack/plugins/infra/public/alerting/inventory/rule_data_formatters.ts b/x-pack/plugins/infra/public/alerting/inventory/rule_data_formatters.ts new file mode 100644 index 0000000000000..1d8414d6abd23 --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/inventory/rule_data_formatters.ts @@ -0,0 +1,27 @@ +/* + * 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 { ALERT_ID } from '@kbn/rule-data-utils'; +import { ObservabilityRuleTypeFormatter } from '../../../../observability/public'; + +export const formatReason: ObservabilityRuleTypeFormatter = ({ fields }) => { + const groupName = fields[ALERT_ID]; + const reason = i18n.translate('xpack.infra.metrics.alerting.inventory.alertReasonDescription', { + defaultMessage: 'Inventory alert for {groupName}.', // TEMP reason message, will be deleted once we index the reason field + values: { + groupName, + }, + }); + + const link = '/app/metrics/inventory'; + + return { + reason, + link, + }; +}; diff --git a/x-pack/plugins/infra/public/plugin.ts b/x-pack/plugins/infra/public/plugin.ts index d5951d9ec9915..0eaeea60c63bf 100644 --- a/x-pack/plugins/infra/public/plugin.ts +++ b/x-pack/plugins/infra/public/plugin.ts @@ -34,12 +34,15 @@ export class Plugin implements InfraClientPluginClass { registerFeatures(pluginsSetup.home); } - pluginsSetup.triggersActionsUi.alertTypeRegistry.register(createInventoryMetricAlertType()); + pluginsSetup.observability.observabilityRuleTypeRegistry.register( + createInventoryMetricAlertType() + ); + pluginsSetup.observability.observabilityRuleTypeRegistry.register( createLogThresholdAlertType() ); - pluginsSetup.triggersActionsUi.alertTypeRegistry.register(createMetricThresholdAlertType()); + pluginsSetup.triggersActionsUi.alertTypeRegistry.register(createMetricThresholdAlertType()); pluginsSetup.observability.dashboard.register({ appName: 'infra_logs', hasData: getLogsHasDataFetcher(core.getStartServices), diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts index 7a890ac14482a..025bc54e11cc9 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts @@ -12,12 +12,13 @@ import { getCustomMetricLabel } from '../../../../common/formatters/get_custom_m import { toMetricOpt } from '../../../../common/snapshot_metric_i18n'; import { AlertStates, InventoryMetricConditions } from './types'; import { + ActionGroupIdsOf, ActionGroup, AlertInstanceContext, AlertInstanceState, RecoveredActionGroup, } from '../../../../../alerting/common'; -import { AlertExecutorOptions } from '../../../../../alerting/server'; +import { AlertInstance, AlertTypeState } from '../../../../../alerting/server'; import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types'; import { InfraBackendLibs } from '../../infra_types'; import { METRIC_FORMATTERS } from '../../../../common/formatters/snapshot_metric_formats'; @@ -30,7 +31,6 @@ import { stateToAlertMessage, } from '../common/messages'; import { evaluateCondition } from './evaluate_condition'; -import { InventoryMetricThresholdAllowedActionGroups } from './register_inventory_metric_threshold_alert_type'; interface InventoryMetricThresholdParams { criteria: InventoryMetricConditions[]; @@ -40,145 +40,163 @@ interface InventoryMetricThresholdParams { alertOnNoData?: boolean; } -export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) => async ({ - services, - params, -}: AlertExecutorOptions< - /** - * TODO: Remove this use of `any` by utilizing a proper type - */ - Record, - Record, - AlertInstanceState, - AlertInstanceContext, +type InventoryMetricThresholdAllowedActionGroups = ActionGroupIdsOf< + typeof FIRED_ACTIONS | typeof WARNING_ACTIONS +>; + +export type InventoryMetricThresholdAlertTypeParams = Record; +export type InventoryMetricThresholdAlertTypeState = AlertTypeState; // no specific state used +export type InventoryMetricThresholdAlertInstanceState = AlertInstanceState; // no specific state used +export type InventoryMetricThresholdAlertInstanceContext = AlertInstanceContext; // no specific instance context used + +type InventoryMetricThresholdAlertInstance = AlertInstance< + InventoryMetricThresholdAlertInstanceState, + InventoryMetricThresholdAlertInstanceContext, InventoryMetricThresholdAllowedActionGroups ->) => { - const { - criteria, - filterQuery, - sourceId, - nodeType, - alertOnNoData, - } = params as InventoryMetricThresholdParams; - - if (criteria.length === 0) throw new Error('Cannot execute an alert with 0 conditions'); - - const source = await libs.sources.getSourceConfiguration( - services.savedObjectsClient, - sourceId || 'default' - ); - - const logQueryFields = await libs - .getLogQueryFields( - sourceId || 'default', - services.savedObjectsClient, - services.scopedClusterClient.asCurrentUser - ) - .catch(() => undefined); - - const compositeSize = libs.configuration.inventory.compositeSize; - - const results = await Promise.all( - criteria.map((condition) => - evaluateCondition({ - condition, - nodeType, - source, - logQueryFields, - esClient: services.scopedClusterClient.asCurrentUser, - compositeSize, - filterQuery, - }) - ) - ); - - const inventoryItems = Object.keys(first(results)!); - for (const item of inventoryItems) { - // AND logic; all criteria must be across the threshold - const shouldAlertFire = results.every((result) => - // Grab the result of the most recent bucket - last(result[item].shouldFire) +>; +type InventoryMetricThresholdAlertInstanceFactory = ( + id: string, + threshold?: number | undefined, + value?: number | undefined +) => InventoryMetricThresholdAlertInstance; + +export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) => + libs.metricsRules.createLifecycleRuleExecutor< + InventoryMetricThresholdAlertTypeParams, + InventoryMetricThresholdAlertTypeState, + InventoryMetricThresholdAlertInstanceState, + InventoryMetricThresholdAlertInstanceContext, + InventoryMetricThresholdAllowedActionGroups + >(async ({ services, params }) => { + const { + criteria, + filterQuery, + sourceId, + nodeType, + alertOnNoData, + } = params as InventoryMetricThresholdParams; + if (criteria.length === 0) throw new Error('Cannot execute an alert with 0 conditions'); + const { alertWithLifecycle, savedObjectsClient } = services; + const alertInstanceFactory: InventoryMetricThresholdAlertInstanceFactory = (id) => + alertWithLifecycle({ + id, + fields: {}, + }); + + const source = await libs.sources.getSourceConfiguration( + savedObjectsClient, + sourceId || 'default' ); - const shouldAlertWarn = results.every((result) => last(result[item].shouldWarn)); - - // AND logic; because we need to evaluate all criteria, if one of them reports no data then the - // whole alert is in a No Data/Error state - const isNoData = results.some((result) => last(result[item].isNoData)); - const isError = results.some((result) => result[item].isError); - - const nextState = isError - ? AlertStates.ERROR - : isNoData - ? AlertStates.NO_DATA - : shouldAlertFire - ? AlertStates.ALERT - : shouldAlertWarn - ? AlertStates.WARNING - : AlertStates.OK; - - let reason; - if (nextState === AlertStates.ALERT || nextState === AlertStates.WARNING) { - reason = results - .map((result) => - buildReasonWithVerboseMetricName( - result[item], - buildFiredAlertReason, - nextState === AlertStates.WARNING - ) - ) - .join('\n'); - /* - * Custom recovery actions aren't yet available in the alerting framework - * Uncomment the code below once they've been implemented - * Reference: https://github.com/elastic/kibana/issues/87048 - */ - // } else if (nextState === AlertStates.OK && prevState?.alertState === AlertStates.ALERT) { - // reason = results - // .map((result) => buildReasonWithVerboseMetricName(result[item], buildRecoveredAlertReason)) - // .join('\n'); - } - if (alertOnNoData) { - if (nextState === AlertStates.NO_DATA) { - reason = results - .filter((result) => result[item].isNoData) - .map((result) => buildReasonWithVerboseMetricName(result[item], buildNoDataAlertReason)) - .join('\n'); - } else if (nextState === AlertStates.ERROR) { + + const logQueryFields = await libs + .getLogQueryFields( + sourceId || 'default', + services.savedObjectsClient, + services.scopedClusterClient.asCurrentUser + ) + .catch(() => undefined); + + const compositeSize = libs.configuration.inventory.compositeSize; + const results = await Promise.all( + criteria.map((condition) => + evaluateCondition({ + condition, + nodeType, + source, + logQueryFields, + esClient: services.scopedClusterClient.asCurrentUser, + compositeSize, + filterQuery, + }) + ) + ); + const inventoryItems = Object.keys(first(results)!); + for (const item of inventoryItems) { + // AND logic; all criteria must be across the threshold + const shouldAlertFire = results.every((result) => { + // Grab the result of the most recent bucket + return last(result[item].shouldFire); + }); + const shouldAlertWarn = results.every((result) => last(result[item].shouldWarn)); + + // AND logic; because we need to evaluate all criteria, if one of them reports no data then the + // whole alert is in a No Data/Error state + const isNoData = results.some((result) => last(result[item].isNoData)); + const isError = results.some((result) => result[item].isError); + + const nextState = isError + ? AlertStates.ERROR + : isNoData + ? AlertStates.NO_DATA + : shouldAlertFire + ? AlertStates.ALERT + : shouldAlertWarn + ? AlertStates.WARNING + : AlertStates.OK; + let reason; + if (nextState === AlertStates.ALERT || nextState === AlertStates.WARNING) { reason = results - .filter((result) => result[item].isError) - .map((result) => buildReasonWithVerboseMetricName(result[item], buildErrorAlertReason)) + .map((result) => + buildReasonWithVerboseMetricName( + result[item], + buildFiredAlertReason, + nextState === AlertStates.WARNING + ) + ) .join('\n'); - } - } - if (reason) { - const actionGroupId = - nextState === AlertStates.OK - ? RecoveredActionGroup.id - : nextState === AlertStates.WARNING - ? WARNING_ACTIONS.id - : FIRED_ACTIONS.id; - const alertInstance = services.alertInstanceFactory(`${item}`); - alertInstance.scheduleActions( - /** - * TODO: We're lying to the compiler here as explicitly calling `scheduleActions` on - * the RecoveredActionGroup isn't allowed + /* + * Custom recovery actions aren't yet available in the alerting framework + * Uncomment the code below once they've been implemented + * Reference: https://github.com/elastic/kibana/issues/87048 */ - (actionGroupId as unknown) as InventoryMetricThresholdAllowedActionGroups, - { - group: item, - alertState: stateToAlertMessage[nextState], - reason, - timestamp: moment().toISOString(), - value: mapToConditionsLookup(results, (result) => - formatMetric(result[item].metric, result[item].currentValue) - ), - threshold: mapToConditionsLookup(criteria, (c) => c.threshold), - metric: mapToConditionsLookup(criteria, (c) => c.metric), + // } else if (nextState === AlertStates.OK && prevState?.alertState === AlertStates.ALERT) { + // reason = results + // .map((result) => buildReasonWithVerboseMetricName(result[item], buildRecoveredAlertReason)) + // .join('\n'); + } + if (alertOnNoData) { + if (nextState === AlertStates.NO_DATA) { + reason = results + .filter((result) => result[item].isNoData) + .map((result) => buildReasonWithVerboseMetricName(result[item], buildNoDataAlertReason)) + .join('\n'); + } else if (nextState === AlertStates.ERROR) { + reason = results + .filter((result) => result[item].isError) + .map((result) => buildReasonWithVerboseMetricName(result[item], buildErrorAlertReason)) + .join('\n'); } - ); + } + if (reason) { + const actionGroupId = + nextState === AlertStates.OK + ? RecoveredActionGroup.id + : nextState === AlertStates.WARNING + ? WARNING_ACTIONS.id + : FIRED_ACTIONS.id; + + const alertInstance = alertInstanceFactory(`${item}`); + alertInstance.scheduleActions( + /** + * TODO: We're lying to the compiler here as explicitly calling `scheduleActions` on + * the RecoveredActionGroup isn't allowed + */ + (actionGroupId as unknown) as InventoryMetricThresholdAllowedActionGroups, + { + group: item, + alertState: stateToAlertMessage[nextState], + reason, + timestamp: moment().toISOString(), + value: mapToConditionsLookup(results, (result) => + formatMetric(result[item].metric, result[item].currentValue) + ), + threshold: mapToConditionsLookup(criteria, (c) => c.threshold), + metric: mapToConditionsLookup(criteria, (c) => c.metric), + } + ); + } } - } -}; + }); const buildReasonWithVerboseMetricName = ( resultItem: any, diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts index 5410353ac46a0..5d516f3591419 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts @@ -7,12 +7,7 @@ import { schema } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; -import { - AlertType, - AlertInstanceState, - AlertInstanceContext, - ActionGroupIdsOf, -} from '../../../../../alerting/server'; +import { PluginSetupContract } from '../../../../../alerting/server'; import { createInventoryMetricThresholdExecutor, FIRED_ACTIONS, @@ -51,56 +46,45 @@ const condition = schema.object({ ), }); -export type InventoryMetricThresholdAllowedActionGroups = ActionGroupIdsOf< - typeof FIRED_ACTIONS | typeof WARNING_ACTIONS ->; - -export const registerMetricInventoryThresholdAlertType = ( +export async function registerMetricInventoryThresholdAlertType( + alertingPlugin: PluginSetupContract, libs: InfraBackendLibs -): AlertType< - /** - * TODO: Remove this use of `any` by utilizing a proper type - */ - Record, - never, // Only use if defining useSavedObjectReferences hook - Record, - AlertInstanceState, - AlertInstanceContext, - InventoryMetricThresholdAllowedActionGroups -> => ({ - id: METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, - name: i18n.translate('xpack.infra.metrics.inventory.alertName', { - defaultMessage: 'Inventory', - }), - validate: { - params: schema.object( - { - criteria: schema.arrayOf(condition), - nodeType: schema.string(), - filterQuery: schema.maybe( - schema.string({ validate: validateIsStringElasticsearchJSONFilter }) - ), - sourceId: schema.string(), - alertOnNoData: schema.maybe(schema.boolean()), - }, - { unknowns: 'allow' } - ), - }, - defaultActionGroupId: FIRED_ACTIONS_ID, - actionGroups: [FIRED_ACTIONS, WARNING_ACTIONS], - producer: 'infrastructure', - minimumLicenseRequired: 'basic', - isExportable: true, - executor: createInventoryMetricThresholdExecutor(libs), - actionVariables: { - context: [ - { name: 'group', description: groupActionVariableDescription }, - { name: 'alertState', description: alertStateActionVariableDescription }, - { name: 'reason', description: reasonActionVariableDescription }, - { name: 'timestamp', description: timestampActionVariableDescription }, - { name: 'value', description: valueActionVariableDescription }, - { name: 'metric', description: metricActionVariableDescription }, - { name: 'threshold', description: thresholdActionVariableDescription }, - ], - }, -}); +) { + alertingPlugin.registerType({ + id: METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, + name: i18n.translate('xpack.infra.metrics.inventory.alertName', { + defaultMessage: 'Inventory', + }), + validate: { + params: schema.object( + { + criteria: schema.arrayOf(condition), + nodeType: schema.string(), + filterQuery: schema.maybe( + schema.string({ validate: validateIsStringElasticsearchJSONFilter }) + ), + sourceId: schema.string(), + alertOnNoData: schema.maybe(schema.boolean()), + }, + { unknowns: 'allow' } + ), + }, + defaultActionGroupId: FIRED_ACTIONS_ID, + actionGroups: [FIRED_ACTIONS, WARNING_ACTIONS], + producer: 'infrastructure', + minimumLicenseRequired: 'basic', + isExportable: true, + executor: createInventoryMetricThresholdExecutor(libs), + actionVariables: { + context: [ + { name: 'group', description: groupActionVariableDescription }, + { name: 'alertState', description: alertStateActionVariableDescription }, + { name: 'reason', description: reasonActionVariableDescription }, + { name: 'timestamp', description: timestampActionVariableDescription }, + { name: 'value', description: valueActionVariableDescription }, + { name: 'metric', description: metricActionVariableDescription }, + { name: 'threshold', description: thresholdActionVariableDescription }, + ], + }, + }); +} diff --git a/x-pack/plugins/infra/server/lib/alerting/register_alert_types.ts b/x-pack/plugins/infra/server/lib/alerting/register_alert_types.ts index b5e6f714de77e..d7df2afd8038b 100644 --- a/x-pack/plugins/infra/server/lib/alerting/register_alert_types.ts +++ b/x-pack/plugins/infra/server/lib/alerting/register_alert_types.ts @@ -21,10 +21,9 @@ const registerAlertTypes = ( ) => { if (alertingPlugin) { alertingPlugin.registerType(registerMetricThresholdAlertType(libs)); - alertingPlugin.registerType(registerMetricInventoryThresholdAlertType(libs)); alertingPlugin.registerType(registerMetricAnomalyAlertType(libs, ml)); - const registerFns = [registerLogThresholdAlertType]; + const registerFns = [registerLogThresholdAlertType, registerMetricInventoryThresholdAlertType]; registerFns.forEach((fn) => { fn(alertingPlugin, libs); });