From 56a785198a8df9ddd15c9ce5267e84e52666c499 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Tue, 7 Mar 2023 15:49:56 +0100 Subject: [PATCH] [ML] Transforms: use health information for alerting rule (#152561) --- x-pack/plugins/transform/common/constants.ts | 11 ++ .../transform/common/types/alerting.ts | 6 + .../transform/common/utils/alerts.test.ts | 108 +++++++++++++++++ .../plugins/transform/common/utils/alerts.ts | 15 ++- .../register_transform_health_rule.ts | 12 +- .../tests_selection_control.tsx | 71 ++++++----- .../register_transform_health_rule_type.ts | 31 +++-- .../transform_health_rule_type/schema.ts | 5 + .../transform_health_service.ts | 113 +++++++++++++++--- x-pack/plugins/transform/server/plugin.ts | 12 +- .../transform/server/routes/api/transforms.ts | 8 +- x-pack/plugins/transform/server/types.ts | 3 + 12 files changed, 325 insertions(+), 70 deletions(-) create mode 100644 x-pack/plugins/transform/common/utils/alerts.test.ts diff --git a/x-pack/plugins/transform/common/constants.ts b/x-pack/plugins/transform/common/constants.ts index 3bf8d5c97cc0..7c1077126c6e 100644 --- a/x-pack/plugins/transform/common/constants.ts +++ b/x-pack/plugins/transform/common/constants.ts @@ -190,6 +190,17 @@ export const TRANSFORM_HEALTH_CHECK_NAMES: Record< } ), }, + healthCheck: { + name: i18n.translate('xpack.transform.alertTypes.transformHealth.healthCheckName', { + defaultMessage: 'Unhealthy transform', + }), + description: i18n.translate( + 'xpack.transform.alertTypes.transformHealth.healthCheckDescription', + { + defaultMessage: 'Get alerts if a transform health status is not green.', + } + ), + }, }; // Transform API default values https://www.elastic.co/guide/en/elasticsearch/reference/current/put-transform.html diff --git a/x-pack/plugins/transform/common/types/alerting.ts b/x-pack/plugins/transform/common/types/alerting.ts index aa08db9a8ce1..4e4d5a7407c5 100644 --- a/x-pack/plugins/transform/common/types/alerting.ts +++ b/x-pack/plugins/transform/common/types/alerting.ts @@ -14,9 +14,15 @@ export type TransformHealthRuleParams = { notStarted?: { enabled: boolean; } | null; + /** + * @deprecated replaced in favor of healthCheck in 8.8 + */ errorMessages?: { enabled: boolean; } | null; + healthCheck?: { + enabled: boolean; + } | null; } | null; } & RuleTypeParams; diff --git a/x-pack/plugins/transform/common/utils/alerts.test.ts b/x-pack/plugins/transform/common/utils/alerts.test.ts new file mode 100644 index 000000000000..ae4cc5815f38 --- /dev/null +++ b/x-pack/plugins/transform/common/utils/alerts.test.ts @@ -0,0 +1,108 @@ +/* + * 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 { getResultTestConfig } from './alerts'; + +describe('getResultTestConfig', () => { + test('provides default config for new rule', () => { + expect(getResultTestConfig(undefined)).toEqual({ + healthCheck: { + enabled: true, + }, + notStarted: { + enabled: true, + }, + errorMessages: { + enabled: false, + }, + }); + }); + + test('provides config for rule created with default settings', () => { + expect(getResultTestConfig(null)).toEqual({ + healthCheck: { + enabled: true, + }, + notStarted: { + enabled: true, + }, + errorMessages: { + enabled: false, + }, + }); + }); + + test('completes already defined config', () => { + expect( + getResultTestConfig({ + healthCheck: null, + notStarted: null, + errorMessages: { + enabled: false, + }, + }) + ).toEqual({ + healthCheck: { + enabled: false, + }, + notStarted: { + enabled: true, + }, + errorMessages: { + enabled: false, + }, + }); + }); + + test('sets healthCheck based on the errorMessages', () => { + expect( + getResultTestConfig({ + healthCheck: null, + notStarted: null, + errorMessages: { + enabled: true, + }, + }) + ).toEqual({ + healthCheck: { + enabled: false, + }, + notStarted: { + enabled: true, + }, + errorMessages: { + enabled: true, + }, + }); + }); + + test('preserves complete config', () => { + expect( + getResultTestConfig({ + healthCheck: { + enabled: false, + }, + notStarted: { + enabled: true, + }, + errorMessages: { + enabled: true, + }, + }) + ).toEqual({ + healthCheck: { + enabled: false, + }, + notStarted: { + enabled: true, + }, + errorMessages: { + enabled: true, + }, + }); + }); +}); diff --git a/x-pack/plugins/transform/common/utils/alerts.ts b/x-pack/plugins/transform/common/utils/alerts.ts index 88c6fc64a35b..c02626f0d3f4 100644 --- a/x-pack/plugins/transform/common/utils/alerts.ts +++ b/x-pack/plugins/transform/common/utils/alerts.ts @@ -8,12 +8,25 @@ import type { TransformHealthRuleTestsConfig } from '../types/alerting'; export function getResultTestConfig(config: TransformHealthRuleTestsConfig) { + let healthCheckEnabled = true; + + if (typeof config?.healthCheck?.enabled === 'boolean') { + healthCheckEnabled = config?.healthCheck?.enabled; + } else if (typeof config?.errorMessages?.enabled === 'boolean') { + // if errorMessages test has been explicitly enabled / disabled, + // also disabled the healthCheck test + healthCheckEnabled = false; + } + return { notStarted: { enabled: config?.notStarted?.enabled ?? true, }, errorMessages: { - enabled: config?.errorMessages?.enabled ?? true, + enabled: config?.errorMessages?.enabled ?? false, + }, + healthCheck: { + enabled: healthCheckEnabled, }, }; } diff --git a/x-pack/plugins/transform/public/alerting/transform_health_rule_type/register_transform_health_rule.ts b/x-pack/plugins/transform/public/alerting/transform_health_rule_type/register_transform_health_rule.ts index dfbe7e3154bf..39075710b8c5 100644 --- a/x-pack/plugins/transform/public/alerting/transform_health_rule_type/register_transform_health_rule.ts +++ b/x-pack/plugins/transform/public/alerting/transform_health_rule_type/register_transform_health_rule.ts @@ -67,13 +67,17 @@ export function getTransformHealthRuleType(): RuleTypeModel>([ + 'errorMessages', +]); + export const TestsSelectionControl: FC = React.memo( ({ config, onChange, errors }) => { const uiConfig = getResultTestConfig(config); + const initConfig = useMemo(() => { + return uiConfig; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const updateCallback = useCallback( (update: Partial>) => { onChange({ @@ -43,35 +52,37 @@ export const TestsSelectionControl: FC = React.memo( Object.entries(uiConfig) as Array< [TransformHealthTests, typeof uiConfig[TransformHealthTests]] > - ).map(([name, conf], i) => { - return ( - {TRANSFORM_HEALTH_CHECK_NAMES[name]?.name}} - description={TRANSFORM_HEALTH_CHECK_NAMES[name]?.description} - fullWidth - gutterSize={'s'} - > - - - } - onChange={updateCallback.bind(null, { - [name]: { - ...uiConfig[name], - enabled: !uiConfig[name].enabled, - }, - })} - checked={uiConfig[name].enabled} - /> - - - ); - })} + ) + .filter(([name]) => !disabledChecks.has(name) || initConfig[name].enabled) + .map(([name, conf], i) => { + return ( + {TRANSFORM_HEALTH_CHECK_NAMES[name]?.name}} + description={TRANSFORM_HEALTH_CHECK_NAMES[name]?.description} + fullWidth + gutterSize={'s'} + > + + + } + onChange={updateCallback.bind(null, { + [name]: { + ...uiConfig[name], + enabled: !uiConfig[name].enabled, + }, + })} + checked={uiConfig[name].enabled} + /> + + + ); + })} diff --git a/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/register_transform_health_rule_type.ts b/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/register_transform_health_rule_type.ts index 7698a11927fc..9128c91c1f96 100644 --- a/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/register_transform_health_rule_type.ts +++ b/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/register_transform_health_rule_type.ts @@ -15,25 +15,28 @@ import type { } from '@kbn/alerting-plugin/common'; import { RuleType } from '@kbn/alerting-plugin/server'; import type { PluginSetupContract as AlertingSetup } from '@kbn/alerting-plugin/server'; -import { PLUGIN, TRANSFORM_RULE_TYPE } from '../../../../common/constants'; +import { FieldFormatsStart } from '@kbn/field-formats-plugin/server'; +import { PLUGIN, type TransformHealth, TRANSFORM_RULE_TYPE } from '../../../../common/constants'; import { transformHealthRuleParams, TransformHealthRuleParams } from './schema'; import { transformHealthServiceProvider } from './transform_health_service'; -export interface BaseResponse { +export interface BaseTransformAlertResponse { transform_id: string; description?: string; + health_status: TransformHealth; + issues?: Array<{ issue: string; details?: string; count: number; first_occurrence?: string }>; } -export interface NotStartedTransformResponse extends BaseResponse { +export interface TransformStateReportResponse extends BaseTransformAlertResponse { transform_state: string; node_name?: string; } -export interface ErrorMessagesTransformResponse extends BaseResponse { +export interface ErrorMessagesTransformResponse extends BaseTransformAlertResponse { error_messages: Array<{ message: string; timestamp: number; node_name?: string }>; } -export type TransformHealthResult = NotStartedTransformResponse | ErrorMessagesTransformResponse; +export type TransformHealthResult = TransformStateReportResponse | ErrorMessagesTransformResponse; export type TransformHealthAlertContext = { results: TransformHealthResult[]; @@ -54,14 +57,17 @@ export const TRANSFORM_ISSUE_DETECTED: ActionGroup = { interface RegisterParams { logger: Logger; alerting: AlertingSetup; + getFieldFormatsStart: () => FieldFormatsStart; } export function registerTransformHealthRuleType(params: RegisterParams) { const { alerting } = params; - alerting.registerType(getTransformHealthRuleType()); + alerting.registerType(getTransformHealthRuleType(params.getFieldFormatsStart)); } -export function getTransformHealthRuleType(): RuleType< +export function getTransformHealthRuleType( + getFieldFormatsStart: () => FieldFormatsStart +): RuleType< TransformHealthRuleParams, never, RuleTypeState, @@ -105,14 +111,19 @@ export function getTransformHealthRuleType(): RuleType< doesSetRecoveryContext: true, async executor(options) { const { - services: { scopedClusterClient, alertFactory }, + services: { scopedClusterClient, alertFactory, uiSettingsClient }, params, } = options; - const transformHealthService = transformHealthServiceProvider( - scopedClusterClient.asCurrentUser + const fieldFormatsRegistry = await getFieldFormatsStart().fieldFormatServiceFactory( + uiSettingsClient ); + const transformHealthService = transformHealthServiceProvider({ + esClient: scopedClusterClient.asCurrentUser, + fieldFormatsRegistry, + }); + const executionResult = await transformHealthService.getHealthChecksResults(params); const unhealthyTests = executionResult.filter(({ isHealthy }) => !isHealthy); diff --git a/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/schema.ts b/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/schema.ts index e98d6edd294a..5c487927c846 100644 --- a/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/schema.ts +++ b/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/schema.ts @@ -18,6 +18,11 @@ export const transformHealthRuleParams = schema.object({ }) ), errorMessages: schema.nullable( + schema.object({ + enabled: schema.boolean({ defaultValue: false }), + }) + ), + healthCheck: schema.nullable( schema.object({ enabled: schema.boolean({ defaultValue: true }), }) diff --git a/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/transform_health_service.ts b/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/transform_health_service.ts index 435e30cf8c84..5c218a40abb4 100644 --- a/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/transform_health_service.ts +++ b/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/transform_health_service.ts @@ -8,8 +8,10 @@ import { ElasticsearchClient } from '@kbn/core/server'; import { i18n } from '@kbn/i18n'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { keyBy, partition } from 'lodash'; +import { keyBy, memoize, partition } from 'lodash'; import type { RulesClient } from '@kbn/alerting-plugin/server'; +import { FIELD_FORMAT_IDS, FieldFormatsRegistry } from '@kbn/field-formats-plugin/common'; +import { TransformStats } from '../../../../common/types/transform_stats'; import { TransformHealthRuleParams } from './schema'; import { ALL_TRANSFORMS_SELECTION, @@ -18,9 +20,9 @@ import { TRANSFORM_STATE, } from '../../../../common/constants'; import { getResultTestConfig } from '../../../../common/utils/alerts'; -import { +import type { ErrorMessagesTransformResponse, - NotStartedTransformResponse, + TransformStateReportResponse, TransformHealthAlertContext, } from './register_transform_health_rule_type'; import type { TransformHealthAlertRule } from '../../../../common/types/alerting'; @@ -41,10 +43,15 @@ type Transform = estypes.TransformGetTransformTransformSummary & { type TransformWithAlertingRules = Transform & { alerting_rules: TransformHealthAlertRule[] }; -export function transformHealthServiceProvider( - esClient: ElasticsearchClient, - rulesClient?: RulesClient -) { +export function transformHealthServiceProvider({ + esClient, + rulesClient, + fieldFormatsRegistry, +}: { + esClient: ElasticsearchClient; + rulesClient?: RulesClient; + fieldFormatsRegistry?: FieldFormatsRegistry; +}) { const transformsDict = new Map(); /** @@ -90,6 +97,42 @@ export function transformHealthServiceProvider( return resultTransformIds; }; + const getTransformStats = memoize(async (transformIds: string[]): Promise => { + return ( + await esClient.transform.getTransformStats({ + transform_id: transformIds.join(','), + }) + ).transforms as TransformStats[]; + }); + + function baseTransformAlertResponseFormatter( + transformStats: TransformStats + ): TransformStateReportResponse { + const dateFormatter = fieldFormatsRegistry!.deserialize({ id: FIELD_FORMAT_IDS.DATE }); + + return { + transform_id: transformStats.id, + description: transformsDict.get(transformStats.id)?.description, + transform_state: transformStats.state, + node_name: transformStats.node?.name, + health_status: transformStats.health.status, + ...(transformStats.health.issues + ? { + issues: transformStats.health.issues.map((issue) => { + return { + issue: issue.issue, + details: issue.details, + count: issue.count, + ...(issue.first_occurrence + ? { first_occurrence: dateFormatter.convert(issue.first_occurrence) } + : {}), + }; + }), + } + : {}), + }; + } + return { /** * Returns report about not started transforms @@ -99,20 +142,11 @@ export function transformHealthServiceProvider( */ async getTransformsStateReport( transformIds: string[] - ): Promise<[NotStartedTransformResponse[], NotStartedTransformResponse[]]> { - const transformsStats = ( - await esClient.transform.getTransformStats({ - transform_id: transformIds.join(','), - }) - ).transforms; + ): Promise<[TransformStateReportResponse[], TransformStateReportResponse[]]> { + const transformsStats = await getTransformStats(transformIds); return partition( - transformsStats.map((t) => ({ - transform_id: t.id, - description: transformsDict.get(t.id)?.description, - transform_state: t.state, - node_name: t.node?.name, - })), + transformsStats.map(baseTransformAlertResponseFormatter), (t) => t.transform_state !== TRANSFORM_STATE.STARTED && t.transform_state !== TRANSFORM_STATE.INDEXING @@ -192,6 +226,19 @@ export function transformHealthServiceProvider( }) .filter((v) => failedTransforms.has(v.transform_id)); }, + /** + * Returns report about unhealthy transforms + * @param transformIds + */ + async getUnhealthyTransformsReport( + transformIds: string[] + ): Promise { + const transformsStats = await getTransformStats(transformIds); + + return transformsStats + .filter((t) => t.health.status !== 'green') + .map(baseTransformAlertResponseFormatter); + }, /** * Returns results of the transform health checks * @param params @@ -271,6 +318,34 @@ export function transformHealthServiceProvider( }); } + if (testsConfig.healthCheck.enabled) { + const response = await this.getUnhealthyTransformsReport(transformIds); + const isHealthy = response.length === 0; + const count = response.length; + const transformsString = response.map((t) => t.transform_id).join(', '); + result.push({ + isHealthy, + name: TRANSFORM_HEALTH_CHECK_NAMES.healthCheck.name, + context: { + results: isHealthy ? [] : response, + message: isHealthy + ? i18n.translate( + 'xpack.transform.alertTypes.transformHealth.healthCheckRecoveryMessage', + { + defaultMessage: + '{count, plural, one {Transform} other {Transforms}} {transformsString} {count, plural, one {is} other {are}} healthy.', + values: { count, transformsString }, + } + ) + : i18n.translate('xpack.transform.alertTypes.transformHealth.healthCheckMessage', { + defaultMessage: + '{count, plural, one {Transform} other {Transforms}} {transformsString} {count, plural, one {is} other {are}} unhealthy.', + values: { count, transformsString }, + }), + }, + }); + } + return result; }, diff --git a/x-pack/plugins/transform/server/plugin.ts b/x-pack/plugins/transform/server/plugin.ts index 3eab84ca0b5f..fdcc4d606a25 100644 --- a/x-pack/plugins/transform/server/plugin.ts +++ b/x-pack/plugins/transform/server/plugin.ts @@ -31,6 +31,8 @@ export class TransformServerPlugin implements Plugin<{}, void, any, any> { private readonly license: License; private readonly logger: Logger; + private fieldFormatsStart: PluginStartDependencies['fieldFormats'] | null = null; + constructor(initContext: PluginInitializerContext) { this.logger = initContext.logger.get(); this.apiRoutes = new ApiRoutes(); @@ -78,13 +80,19 @@ export class TransformServerPlugin implements Plugin<{}, void, any, any> { }); if (alerting) { - registerTransformHealthRuleType({ alerting, logger: this.logger }); + registerTransformHealthRuleType({ + alerting, + logger: this.logger, + getFieldFormatsStart: () => this.fieldFormatsStart!, + }); } return {}; } - start(core: CoreStart, plugins: PluginStartDependencies) {} + start(core: CoreStart, plugins: PluginStartDependencies) { + this.fieldFormatsStart = plugins.fieldFormats; + } stop() {} } diff --git a/x-pack/plugins/transform/server/routes/api/transforms.ts b/x-pack/plugins/transform/server/routes/api/transforms.ts index 580f0f54aabf..36db7b9a03e3 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms.ts @@ -97,10 +97,10 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { const alerting = await ctx.alerting; if (alerting) { - const transformHealthService = transformHealthServiceProvider( - esClient.asCurrentUser, - alerting.getRulesClient() - ); + const transformHealthService = transformHealthServiceProvider({ + esClient: esClient.asCurrentUser, + rulesClient: alerting.getRulesClient(), + }); // @ts-ignore await transformHealthService.populateTransformsWithAssignedRules(body.transforms); diff --git a/x-pack/plugins/transform/server/types.ts b/x-pack/plugins/transform/server/types.ts index 26bdf02acb5c..3a748314521a 100644 --- a/x-pack/plugins/transform/server/types.ts +++ b/x-pack/plugins/transform/server/types.ts @@ -10,16 +10,19 @@ import { PluginStart as DataViewsServerPluginStart } from '@kbn/data-views-plugi import { LicensingPluginSetup } from '@kbn/licensing-plugin/server'; import { PluginSetupContract as FeaturesPluginSetup } from '@kbn/features-plugin/server'; import type { AlertingPlugin } from '@kbn/alerting-plugin/server'; +import { FieldFormatsSetup, FieldFormatsStart } from '@kbn/field-formats-plugin/server'; import { License } from './services'; export interface PluginSetupDependencies { licensing: LicensingPluginSetup; features: FeaturesPluginSetup; alerting?: AlertingPlugin['setup']; + fieldFormats: FieldFormatsSetup; } export interface PluginStartDependencies { dataViews: DataViewsServerPluginStart; + fieldFormats: FieldFormatsStart; } export interface RouteDependencies {