[ML] Transforms: use health information for alerting rule (#152561)

This commit is contained in:
Dima Arnautov 2023-03-07 15:49:56 +01:00 committed by GitHub
parent ffc808033f
commit 56a785198a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 325 additions and 70 deletions

View file

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

View file

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

View file

@ -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,
},
});
});
});

View file

@ -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,
},
};
}

View file

@ -67,13 +67,17 @@ export function getTransformHealthRuleType(): RuleTypeModel<TransformHealthRuleP
Transform ID: \\{\\{transform_id\\}\\}
\\{\\{#description\\}\\}Transform description: \\{\\{description\\}\\}
\\{\\{/description\\}\\}\\{\\{#transform_state\\}\\}Transform state: \\{\\{transform_state\\}\\}
\\{\\{/transform_state\\}\\}\\{\\{#failure_reason\\}\\}Failure reason: \\{\\{failure_reason\\}\\}
\\{\\{/transform_state\\}\\}\\{\\{#health_status\\}\\}Transform health status: \\{\\{health_status\\}\\}
\\{\\{/health_status\\}\\}\\{\\{#issues\\}\\}Issue: \\{\\{issue\\}\\}
Issue count: \\{\\{count\\}\\}
\\{\\{#details\\}\\}Issue details: \\{\\{details\\}\\}
\\{\\{/details\\}\\}\\{\\{#first_occurrence\\}\\}First occurrence: \\{\\{first_occurrence\\}\\}
\\{\\{/first_occurrence\\}\\}
\\{\\{/issues\\}\\}\\{\\{#failure_reason\\}\\}Failure reason: \\{\\{failure_reason\\}\\}
\\{\\{/failure_reason\\}\\}\\{\\{#notification_message\\}\\}Notification message: \\{\\{notification_message\\}\\}
\\{\\{/notification_message\\}\\}\\{\\{#node_name\\}\\}Node name: \\{\\{node_name\\}\\}
\\{\\{/node_name\\}\\}\\{\\{#timestamp\\}\\}Timestamp: \\{\\{timestamp\\}\\}
\\{\\{/timestamp\\}\\}\\{\\{#error_messages\\}\\}Error message: \\{\\{message\\}\\}
\\{\\{/error_messages\\}\\}
\\{\\{/timestamp\\}\\}
\\{\\{/context.results\\}\\}
`,
}

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { FC, useCallback } from 'react';
import React, { FC, useCallback, useMemo } from 'react';
import { EuiDescribedFormGroup, EuiForm, EuiFormRow, EuiSpacer, EuiSwitch } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
@ -22,10 +22,19 @@ interface TestsSelectionControlProps {
errors?: string[];
}
const disabledChecks = new Set<keyof Exclude<TransformHealthRuleTestsConfig, null | undefined>>([
'errorMessages',
]);
export const TestsSelectionControl: FC<TestsSelectionControlProps> = 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<Exclude<TransformHealthRuleTestsConfig, undefined>>) => {
onChange({
@ -43,35 +52,37 @@ export const TestsSelectionControl: FC<TestsSelectionControlProps> = React.memo(
Object.entries(uiConfig) as Array<
[TransformHealthTests, typeof uiConfig[TransformHealthTests]]
>
).map(([name, conf], i) => {
return (
<EuiDescribedFormGroup
key={name}
title={<h4>{TRANSFORM_HEALTH_CHECK_NAMES[name]?.name}</h4>}
description={TRANSFORM_HEALTH_CHECK_NAMES[name]?.description}
fullWidth
gutterSize={'s'}
>
<EuiFormRow>
<EuiSwitch
label={
<FormattedMessage
id="xpack.transform.alertTypes.transformHealth.testsSelection.enableTestLabel"
defaultMessage="Enable"
/>
}
onChange={updateCallback.bind(null, {
[name]: {
...uiConfig[name],
enabled: !uiConfig[name].enabled,
},
})}
checked={uiConfig[name].enabled}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
);
})}
)
.filter(([name]) => !disabledChecks.has(name) || initConfig[name].enabled)
.map(([name, conf], i) => {
return (
<EuiDescribedFormGroup
key={name}
title={<h4>{TRANSFORM_HEALTH_CHECK_NAMES[name]?.name}</h4>}
description={TRANSFORM_HEALTH_CHECK_NAMES[name]?.description}
fullWidth
gutterSize={'s'}
>
<EuiFormRow>
<EuiSwitch
label={
<FormattedMessage
id="xpack.transform.alertTypes.transformHealth.testsSelection.enableTestLabel"
defaultMessage="Enable"
/>
}
onChange={updateCallback.bind(null, {
[name]: {
...uiConfig[name],
enabled: !uiConfig[name].enabled,
},
})}
checked={uiConfig[name].enabled}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
);
})}
</EuiForm>
<EuiSpacer size="l" />
</>

View file

@ -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<TransformIssue> = {
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);

View file

@ -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 }),
})

View file

@ -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<string, Transform>();
/**
@ -90,6 +97,42 @@ export function transformHealthServiceProvider(
return resultTransformIds;
};
const getTransformStats = memoize(async (transformIds: string[]): Promise<TransformStats[]> => {
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<TransformStateReportResponse[]> {
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;
},

View file

@ -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() {}
}

View file

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

View file

@ -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 {