[Transform] Transforms health alerting rule type (#112277)

This commit is contained in:
Dima Arnautov 2021-10-06 18:27:24 +02:00 committed by GitHub
parent f8611470e6
commit 6da1323ff5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 1047 additions and 19 deletions

View file

@ -283,6 +283,8 @@ export class DocLinksService {
},
transforms: {
guide: `${ELASTICSEARCH_DOCS}transforms.html`,
// TODO add valid docs URL
alertingRules: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-configuring-alerts.html`,
},
visualize: {
guide: `${KIBANA_DOCS}dashboard.html`,

View file

@ -17,6 +17,7 @@ const byTypeSchema: MakeSchemaFrom<AlertsUsage>['count_by_type'] = {
// Built-in
'__index-threshold': { type: 'long' },
'__es-query': { type: 'long' },
transform_health: { type: 'long' },
// APM
apm__error_rate: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention
apm__transaction_error_rate: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention
@ -45,8 +46,8 @@ const byTypeSchema: MakeSchemaFrom<AlertsUsage>['count_by_type'] = {
// Maps
'__geo-containment': { type: 'long' },
// ML
xpack_ml_anomaly_detection_alert: { type: 'long' },
xpack_ml_anomaly_detection_jobs_health: { type: 'long' },
xpack__ml__anomaly_detection_alert: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention
xpack__ml__anomaly_detection_jobs_health: { type: 'long' }, // eslint-disable-line @typescript-eslint/naming-convention
};
export function createAlertsUsageCollector(

View file

@ -128,6 +128,7 @@ export class MonitoringPlugin
for (const alert of alerts) {
plugins.alerting?.registerType(alert.getRuleType());
}
const config = createConfig(this.initializerContext.config.get<TypeOf<typeof configSchema>>());
// Register collector objects for stats to show up in the APIs

View file

@ -32,10 +32,15 @@ describe('Stack Alerts Feature Privileges', () => {
BUILT_IN_ALERTS_FEATURE.privileges?.all?.alerting?.rule?.all ?? [];
const typesInFeaturePrivilegeRead =
BUILT_IN_ALERTS_FEATURE.privileges?.read?.alerting?.rule?.read ?? [];
expect(alertingSetup.registerType.mock.calls.length).toEqual(typesInFeaturePrivilege.length);
expect(alertingSetup.registerType.mock.calls.length).toEqual(typesInFeaturePrivilegeAll.length);
// transform alerting rule is initialized during the transform plugin setup
expect(alertingSetup.registerType.mock.calls.length).toEqual(
typesInFeaturePrivilegeRead.length
typesInFeaturePrivilege.length - 1
);
expect(alertingSetup.registerType.mock.calls.length).toEqual(
typesInFeaturePrivilegeAll.length - 1
);
expect(alertingSetup.registerType.mock.calls.length).toEqual(
typesInFeaturePrivilegeRead.length - 1
);
alertingSetup.registerType.mock.calls.forEach((call) => {

View file

@ -12,6 +12,9 @@ import { GEO_CONTAINMENT_ID as GeoContainment } from './alert_types/geo_containm
import { ES_QUERY_ID as ElasticsearchQuery } from './alert_types/es_query/alert_type';
import { STACK_ALERTS_FEATURE_ID } from '../common';
import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server';
import { TRANSFORM_RULE_TYPE } from '../../transform/common';
const TransformHealth = TRANSFORM_RULE_TYPE.TRANSFORM_HEALTH;
export const BUILT_IN_ALERTS_FEATURE: KibanaFeatureConfig = {
id: STACK_ALERTS_FEATURE_ID,
@ -23,7 +26,7 @@ export const BUILT_IN_ALERTS_FEATURE: KibanaFeatureConfig = {
management: {
insightsAndAlerting: ['triggersActions'],
},
alerting: [IndexThreshold, GeoContainment, ElasticsearchQuery],
alerting: [IndexThreshold, GeoContainment, ElasticsearchQuery, TransformHealth],
privileges: {
all: {
app: [],
@ -33,10 +36,10 @@ export const BUILT_IN_ALERTS_FEATURE: KibanaFeatureConfig = {
},
alerting: {
rule: {
all: [IndexThreshold, GeoContainment, ElasticsearchQuery],
all: [IndexThreshold, GeoContainment, ElasticsearchQuery, TransformHealth],
},
alert: {
all: [IndexThreshold, GeoContainment, ElasticsearchQuery],
all: [IndexThreshold, GeoContainment, ElasticsearchQuery, TransformHealth],
},
},
savedObject: {
@ -54,10 +57,10 @@ export const BUILT_IN_ALERTS_FEATURE: KibanaFeatureConfig = {
},
alerting: {
rule: {
read: [IndexThreshold, GeoContainment, ElasticsearchQuery],
read: [IndexThreshold, GeoContainment, ElasticsearchQuery, TransformHealth],
},
alert: {
read: [IndexThreshold, GeoContainment, ElasticsearchQuery],
read: [IndexThreshold, GeoContainment, ElasticsearchQuery, TransformHealth],
},
},
savedObject: {

View file

@ -19,6 +19,7 @@
{ "path": "../triggers_actions_ui/tsconfig.json" },
{ "path": "../../../src/plugins/kibana_react/tsconfig.json" },
{ "path": "../../../src/plugins/saved_objects/tsconfig.json" },
{ "path": "../../../src/plugins/data/tsconfig.json" }
{ "path": "../../../src/plugins/data/tsconfig.json" },
{ "path": "../transform/tsconfig.json" }
]
}

View file

@ -163,6 +163,9 @@
"__es-query": {
"type": "long"
},
"transform_health": {
"type": "long"
},
"apm__error_rate": {
"type": "long"
},
@ -226,10 +229,10 @@
"__geo-containment": {
"type": "long"
},
"xpack_ml_anomaly_detection_alert": {
"xpack__ml__anomaly_detection_alert": {
"type": "long"
},
"xpack_ml_anomaly_detection_jobs_health": {
"xpack__ml__anomaly_detection_jobs_health": {
"type": "long"
}
}
@ -245,6 +248,9 @@
"__es-query": {
"type": "long"
},
"transform_health": {
"type": "long"
},
"apm__error_rate": {
"type": "long"
},
@ -308,10 +314,10 @@
"__geo-containment": {
"type": "long"
},
"xpack_ml_anomaly_detection_alert": {
"xpack__ml__anomaly_detection_alert": {
"type": "long"
},
"xpack_ml_anomaly_detection_jobs_health": {
"xpack__ml__anomaly_detection_jobs_health": {
"type": "long"
}
}

View file

@ -8,6 +8,7 @@
import { i18n } from '@kbn/i18n';
import { LicenseType } from '../../licensing/common/types';
import { TransformHealthTests } from './types/alerting';
export const DEFAULT_REFRESH_INTERVAL_MS = 30000;
export const MINIMUM_REFRESH_INTERVAL_MS = 1000;
@ -108,3 +109,26 @@ export const TRANSFORM_FUNCTION = {
} as const;
export type TransformFunction = typeof TRANSFORM_FUNCTION[keyof typeof TRANSFORM_FUNCTION];
export const TRANSFORM_RULE_TYPE = {
TRANSFORM_HEALTH: 'transform_health',
} as const;
export const ALL_TRANSFORMS_SELECTION = '*';
export const TRANSFORM_HEALTH_CHECK_NAMES: Record<
TransformHealthTests,
{ name: string; description: string }
> = {
notStarted: {
name: i18n.translate('xpack.transform.alertTypes.transformHealth.notStartedCheckName', {
defaultMessage: 'Transform is not started',
}),
description: i18n.translate(
'xpack.transform.alertTypes.transformHealth.notStartedCheckDescription',
{
defaultMessage: 'Get alerts when the transform is not started or is not indexing data.',
}
),
},
};

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 { TRANSFORM_RULE_TYPE } from './constants';

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 type { AlertTypeParams } from '../../../alerting/common';
export type TransformHealthRuleParams = {
includeTransforms?: string[];
excludeTransforms?: string[] | null;
testsConfig?: {
notStarted?: {
enabled: boolean;
} | null;
} | null;
} & AlertTypeParams;
export type TransformHealthRuleTestsConfig = TransformHealthRuleParams['testsConfig'];
export type TransformHealthTests = keyof Exclude<TransformHealthRuleTestsConfig, null | undefined>;

View file

@ -20,3 +20,7 @@ export function dictionaryToArray<TValue>(dict: Dictionary<TValue>): TValue[] {
export type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]>;
};
export function isDefined<T>(argument: T | undefined | null): argument is T {
return argument !== undefined && argument !== null;
}

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 { TransformHealthRuleTestsConfig } from '../types/alerting';
export function getResultTestConfig(config: TransformHealthRuleTestsConfig) {
return {
notStarted: {
enabled: config?.notStarted?.enabled ?? true,
},
};
}

View file

@ -10,11 +10,14 @@
"management",
"features",
"savedObjects",
"share"
"share",
"triggersActionsUi",
"fieldFormats"
],
"optionalPlugins": [
"security",
"usageCollection"
"usageCollection",
"alerting"
],
"configPath": ["xpack", "transform"],
"requiredBundles": [
@ -24,6 +27,9 @@
"kibanaReact",
"ml"
],
"extraPublicDirs": [
"common"
],
"owner": {
"name": "Machine Learning UI",
"githubTeam": "ml-ui"

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 { getTransformHealthRuleType } from './transform_health_rule_type';

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 { getTransformHealthRuleType } from './register_transform_health_rule';

View file

@ -0,0 +1,66 @@
/*
* 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 { lazy } from 'react';
import { i18n } from '@kbn/i18n';
import { TRANSFORM_RULE_TYPE } from '../../../common';
import type { TransformHealthRuleParams } from '../../../common/types/alerting';
import type { AlertTypeModel } from '../../../../triggers_actions_ui/public';
export function getTransformHealthRuleType(): AlertTypeModel<TransformHealthRuleParams> {
return {
id: TRANSFORM_RULE_TYPE.TRANSFORM_HEALTH,
description: i18n.translate('xpack.transform.alertingRuleTypes.transformHealth.description', {
defaultMessage: 'Alert when transforms experience operational issues.',
}),
iconClass: 'bell',
documentationUrl(docLinks) {
return docLinks.links.transforms.alertingRules;
},
alertParamsExpression: lazy(() => import('./transform_health_rule_trigger')),
validate: (alertParams: TransformHealthRuleParams) => {
const validationResult = {
errors: {
includeTransforms: new Array<string>(),
} as Record<keyof TransformHealthRuleParams, string[]>,
};
if (!alertParams.includeTransforms?.length) {
validationResult.errors.includeTransforms?.push(
i18n.translate(
'xpack.transform.alertTypes.transformHealth.includeTransforms.errorMessage',
{
defaultMessage: 'At least one transform has to be selected',
}
)
);
}
return validationResult;
},
requiresAppContext: false,
defaultActionMessage: i18n.translate(
'xpack.transform.alertTypes.transformHealth.defaultActionMessage',
{
defaultMessage: `[\\{\\{rule.name\\}\\}] Transform health check result:
\\{\\{context.message\\}\\}
\\{\\{#context.results\\}\\}
Transform ID: \\{\\{transform_id\\}\\}
\\{\\{#description\\}\\}Transform description: \\{\\{description\\}\\}
\\{\\{/description\\}\\}\\{\\{#transform_state\\}\\}Transform state: \\{\\{transform_state\\}\\}
\\{\\{/transform_state\\}\\}\\{\\{#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\\}\\}
\\{\\{/context.results\\}\\}
`,
}
),
};
}

View file

@ -0,0 +1,81 @@
/*
* 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, { FC, useCallback } from 'react';
import { EuiDescribedFormGroup, EuiForm, EuiFormRow, EuiSpacer, EuiSwitch } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import {
TransformHealthRuleTestsConfig,
TransformHealthTests,
} from '../../../common/types/alerting';
import { getResultTestConfig } from '../../../common/utils/alerts';
import { TRANSFORM_HEALTH_CHECK_NAMES } from '../../../common/constants';
interface TestsSelectionControlProps {
config: TransformHealthRuleTestsConfig;
onChange: (update: TransformHealthRuleTestsConfig) => void;
errors?: string[];
}
export const TestsSelectionControl: FC<TestsSelectionControlProps> = React.memo(
({ config, onChange, errors }) => {
const uiConfig = getResultTestConfig(config);
const updateCallback = useCallback(
(update: Partial<Exclude<TransformHealthRuleTestsConfig, undefined>>) => {
onChange({
...(config ?? {}),
...update,
});
},
[onChange, config]
);
return (
<>
<EuiForm component="div" isInvalid={!!errors?.length} error={errors}>
{(
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
disabled
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

@ -0,0 +1,132 @@
/*
* 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 { EuiForm, EuiSpacer } from '@elastic/eui';
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import type { AlertTypeParamsExpressionProps } from '../../../../triggers_actions_ui/public';
import type { TransformHealthRuleParams } from '../../../common/types/alerting';
import { TestsSelectionControl } from './tests_selection_control';
import { TransformSelectorControl } from './transform_selector_control';
import { useApi } from '../../app/hooks';
import { useToastNotifications } from '../../app/app_dependencies';
import { GetTransformsResponseSchema } from '../../../common/api_schemas/transforms';
import { ALL_TRANSFORMS_SELECTION } from '../../../common/constants';
export type TransformHealthRuleTriggerProps =
AlertTypeParamsExpressionProps<TransformHealthRuleParams>;
const TransformHealthRuleTrigger: FC<TransformHealthRuleTriggerProps> = ({
alertParams,
setAlertParams,
errors,
}) => {
const formErrors = Object.values(errors).flat();
const isFormInvalid = formErrors.length > 0;
const api = useApi();
const toast = useToastNotifications();
const [transformOptions, setTransformOptions] = useState<string[]>([]);
const onAlertParamChange = useCallback(
<T extends keyof TransformHealthRuleParams>(param: T) =>
(update: TransformHealthRuleParams[T]) => {
setAlertParams(param, update);
},
[setAlertParams]
);
useEffect(
function fetchTransforms() {
let unmounted = false;
api
.getTransforms()
.then((r) => {
if (!unmounted) {
setTransformOptions(
(r as GetTransformsResponseSchema).transforms.filter((v) => v.sync).map((v) => v.id)
);
}
})
.catch((e) => {
toast.addError(e, {
title: i18n.translate(
'xpack.transform.alertingRuleTypes.transformHealth.fetchErrorMessage',
{
defaultMessage: 'Unable to fetch transforms',
}
),
});
});
return () => {
unmounted = true;
};
},
[api, toast]
);
const excludeTransformOptions = useMemo(() => {
if (alertParams.includeTransforms?.some((v) => v === ALL_TRANSFORMS_SELECTION)) {
return transformOptions;
}
return null;
}, [transformOptions, alertParams.includeTransforms]);
return (
<EuiForm
data-test-subj={'transformHealthAlertingRuleForm'}
invalidCallout={'none'}
error={formErrors}
isInvalid={isFormInvalid}
>
<TransformSelectorControl
label={
<FormattedMessage
id="xpack.transform.alertTypes.transformHealth.includeTransformsLabel"
defaultMessage="Include transforms"
/>
}
options={transformOptions}
selectedOptions={alertParams.includeTransforms ?? []}
onChange={onAlertParamChange('includeTransforms')}
allowSelectAll
errors={errors.includeTransforms as string[]}
/>
<EuiSpacer size="m" />
{!!excludeTransformOptions?.length || !!alertParams.excludeTransforms?.length ? (
<>
<TransformSelectorControl
label={
<FormattedMessage
id="xpack.transform.alertTypes.transformHealth.excludeTransformsLabel"
defaultMessage="Exclude transforms"
/>
}
options={excludeTransformOptions ?? []}
selectedOptions={alertParams.excludeTransforms ?? []}
onChange={onAlertParamChange('excludeTransforms')}
/>
<EuiSpacer size="m" />
</>
) : null}
<TestsSelectionControl
config={alertParams.testsConfig}
onChange={onAlertParamChange('testsConfig')}
errors={Array.isArray(errors.testsConfig) ? errors.testsConfig : []}
/>
</EuiForm>
);
};
// Default export is required for React.lazy loading
// eslint-disable-next-line import/no-default-export
export default TransformHealthRuleTrigger;

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiComboBox, EuiComboBoxProps, EuiFormRow } from '@elastic/eui';
import React, { FC, useMemo } from 'react';
import { ALL_TRANSFORMS_SELECTION } from '../../../common/constants';
import { isDefined } from '../../../common/types/common';
export interface TransformSelectorControlProps {
label?: string | JSX.Element;
errors?: string[];
onChange: (transformSelection: string[]) => void;
selectedOptions: string[];
options: string[];
allowSelectAll?: boolean;
}
function convertToEuiOptions(values: string[]) {
return values.map((v) => ({ value: v, label: v }));
}
export const TransformSelectorControl: FC<TransformSelectorControlProps> = ({
label,
errors,
onChange,
selectedOptions,
options,
allowSelectAll = false,
}) => {
const onSelectionChange: EuiComboBoxProps<string>['onChange'] = ((selectionUpdate) => {
if (!selectionUpdate?.length) {
onChange([]);
return;
}
if (selectionUpdate[selectionUpdate.length - 1].value === ALL_TRANSFORMS_SELECTION) {
onChange([ALL_TRANSFORMS_SELECTION]);
return;
}
onChange(
selectionUpdate
.slice(selectionUpdate[0].value === ALL_TRANSFORMS_SELECTION ? 1 : 0)
.map((v) => v.value)
.filter(isDefined)
);
}) as Exclude<EuiComboBoxProps<string>['onChange'], undefined>;
const selectedOptionsEui = useMemo(() => convertToEuiOptions(selectedOptions), [selectedOptions]);
const optionsEui = useMemo(() => {
return convertToEuiOptions(allowSelectAll ? [ALL_TRANSFORMS_SELECTION, ...options] : options);
}, [options, allowSelectAll]);
return (
<EuiFormRow fullWidth label={label} isInvalid={!!errors?.length} error={errors}>
<EuiComboBox<string>
singleSelection={false}
selectedOptions={selectedOptionsEui}
options={optionsEui}
onChange={onSelectionChange}
fullWidth
data-test-subj={'transformSelection'}
isInvalid={!!errors?.length}
/>
</EuiFormRow>
);
};

View file

@ -12,3 +12,5 @@ import { TransformUiPlugin } from './plugin';
export const plugin = () => {
return new TransformUiPlugin();
};
export { getTransformHealthRuleType } from './alerting';

View file

@ -14,6 +14,9 @@ import type { SavedObjectsStart } from 'src/plugins/saved_objects/public';
import type { ManagementSetup } from 'src/plugins/management/public';
import type { SharePluginStart } from 'src/plugins/share/public';
import { registerFeature } from './register_feature';
import type { PluginSetupContract as AlertingSetup } from '../../alerting/public';
import type { TriggersAndActionsUIPublicPluginSetup } from '../../triggers_actions_ui/public';
import { getTransformHealthRuleType } from './alerting';
export interface PluginsDependencies {
data: DataPublicPluginStart;
@ -21,11 +24,13 @@ export interface PluginsDependencies {
home: HomePublicPluginSetup;
savedObjects: SavedObjectsStart;
share: SharePluginStart;
alerting?: AlertingSetup;
triggersActionsUi?: TriggersAndActionsUIPublicPluginSetup;
}
export class TransformUiPlugin {
public setup(coreSetup: CoreSetup<PluginsDependencies>, pluginsSetup: PluginsDependencies): void {
const { management, home } = pluginsSetup;
const { management, home, triggersActionsUi } = pluginsSetup;
// Register management section
const esSection = management.sections.section.data;
@ -41,6 +46,10 @@ export class TransformUiPlugin {
},
});
registerFeature(home);
if (triggersActionsUi) {
triggersActionsUi.ruleTypeRegistry.register(getTransformHealthRuleType());
}
}
public start() {}

View file

@ -10,3 +10,5 @@ import { PluginInitializerContext } from 'src/core/server';
import { TransformServerPlugin } from './plugin';
export const plugin = (ctx: PluginInitializerContext) => new TransformServerPlugin(ctx);
export { registerTransformHealthRuleType } from './lib/alerting';

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.
*/
export {
getTransformHealthRuleType,
registerTransformHealthRuleType,
} from './transform_health_rule_type';

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.
*/
export {
getTransformHealthRuleType,
registerTransformHealthRuleType,
} from './register_transform_health_rule_type';

View file

@ -0,0 +1,121 @@
/*
* 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 'src/core/server';
import type {
ActionGroup,
AlertInstanceContext,
AlertInstanceState,
AlertTypeState,
} from '../../../../../alerting/common';
import { PLUGIN, TRANSFORM_RULE_TYPE } from '../../../../common/constants';
import { transformHealthRuleParams, TransformHealthRuleParams } from './schema';
import { AlertType } from '../../../../../alerting/server';
import { transformHealthServiceProvider } from './transform_health_service';
import type { PluginSetupContract as AlertingSetup } from '../../../../../alerting/server';
export interface BaseResponse {
transform_id: string;
description?: string;
}
export interface NotStartedTransformResponse extends BaseResponse {
transform_state: string;
node_name?: string;
}
export type TransformHealthResult = NotStartedTransformResponse;
export type TransformHealthAlertContext = {
results: TransformHealthResult[];
message: string;
} & AlertInstanceContext;
export const TRANSFORM_ISSUE = 'transform_issue';
export type TransformIssue = typeof TRANSFORM_ISSUE;
export const TRANSFORM_ISSUE_DETECTED: ActionGroup<TransformIssue> = {
id: TRANSFORM_ISSUE,
name: i18n.translate('xpack.transform.alertingRuleTypes.transformHealth.actionGroupName', {
defaultMessage: 'Issue detected',
}),
};
interface RegisterParams {
logger: Logger;
alerting: AlertingSetup;
}
export function registerTransformHealthRuleType(params: RegisterParams) {
const { alerting } = params;
alerting.registerType(getTransformHealthRuleType());
}
export function getTransformHealthRuleType(): AlertType<
TransformHealthRuleParams,
never,
AlertTypeState,
AlertInstanceState,
TransformHealthAlertContext,
TransformIssue
> {
return {
id: TRANSFORM_RULE_TYPE.TRANSFORM_HEALTH,
name: i18n.translate('xpack.transform.alertingRuleTypes.transformHealth.name', {
defaultMessage: 'Transform health',
}),
actionGroups: [TRANSFORM_ISSUE_DETECTED],
defaultActionGroupId: TRANSFORM_ISSUE,
validate: { params: transformHealthRuleParams },
actionVariables: {
context: [
{
name: 'results',
description: i18n.translate(
'xpack.transform.alertTypes.transformHealth.alertContext.resultsDescription',
{
defaultMessage: 'Rule execution results',
}
),
},
{
name: 'message',
description: i18n.translate(
'xpack.transform.alertTypes.transformHealth.alertContext.messageDescription',
{
defaultMessage: 'Alert info message',
}
),
},
],
},
producer: 'stackAlerts',
minimumLicenseRequired: PLUGIN.MINIMUM_LICENSE_REQUIRED,
isExportable: true,
async executor(options) {
const {
services: { scopedClusterClient, alertInstanceFactory },
params,
} = options;
const transformHealthService = transformHealthServiceProvider(
scopedClusterClient.asInternalUser
);
const executionResult = await transformHealthService.getHealthChecksResults(params);
if (executionResult.length > 0) {
executionResult.forEach(({ name: alertInstanceName, context }) => {
const alertInstance = alertInstanceFactory(alertInstanceName);
alertInstance.scheduleActions(TRANSFORM_ISSUE, context);
});
}
},
};
}

View file

@ -0,0 +1,24 @@
/*
* 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, TypeOf } from '@kbn/config-schema';
export const transformHealthRuleParams = schema.object({
includeTransforms: schema.arrayOf(schema.string()),
excludeTransforms: schema.nullable(schema.arrayOf(schema.string(), { defaultValue: [] })),
testsConfig: schema.nullable(
schema.object({
notStarted: schema.nullable(
schema.object({
enabled: schema.boolean({ defaultValue: true }),
})
),
})
),
});
export type TransformHealthRuleParams = TypeOf<typeof transformHealthRuleParams>;

View file

@ -0,0 +1,135 @@
/*
* 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 { ElasticsearchClient } from 'kibana/server';
import { i18n } from '@kbn/i18n';
import type { Transform as EsTransform } from '@elastic/elasticsearch/api/types';
import { TransformHealthRuleParams } from './schema';
import {
ALL_TRANSFORMS_SELECTION,
TRANSFORM_HEALTH_CHECK_NAMES,
} from '../../../../common/constants';
import { getResultTestConfig } from '../../../../common/utils/alerts';
import {
NotStartedTransformResponse,
TransformHealthAlertContext,
} from './register_transform_health_rule_type';
interface TestResult {
name: string;
context: TransformHealthAlertContext;
}
// @ts-ignore FIXME update types in the elasticsearch client
type Transform = EsTransform & { id: string; description?: string; sync: object };
export function transformHealthServiceProvider(esClient: ElasticsearchClient) {
const transformsDict = new Map<string, Transform>();
/**
* Resolves result transform selection.
* @param includeTransforms
* @param excludeTransforms
*/
const getResultsTransformIds = async (
includeTransforms: string[],
excludeTransforms: string[] | null
): Promise<string[]> => {
const includeAll = includeTransforms.some((id) => id === ALL_TRANSFORMS_SELECTION);
// Fetch transforms to make sure assigned transforms exists.
const transformsResponse = (
await esClient.transform.getTransform({
...(includeAll ? {} : { transform_id: includeTransforms.join(',') }),
allow_no_match: true,
size: 1000,
})
).body.transforms as Transform[];
let resultTransformIds: string[] = [];
transformsResponse.forEach((t) => {
transformsDict.set(t.id, t);
if (t.sync) {
resultTransformIds.push(t.id);
}
});
if (excludeTransforms && excludeTransforms.length > 0) {
const excludeIdsSet = new Set(excludeTransforms);
resultTransformIds = resultTransformIds.filter((id) => !excludeIdsSet.has(id));
}
return resultTransformIds;
};
return {
/**
* Returns report about not started transform
* @param transformIds
*/
async getNotStartedTransformsReport(
transformIds: string[]
): Promise<NotStartedTransformResponse[]> {
const transformsStats = (
await esClient.transform.getTransformStats({
transform_id: transformIds.join(','),
})
).body.transforms;
return transformsStats
.filter((t) => t.state !== 'started' && t.state !== 'indexing')
.map((t) => ({
transform_id: t.id,
description: transformsDict.get(t.id)?.description,
transform_state: t.state,
node_name: t.node?.name,
}));
},
/**
* Returns results of the transform health checks
* @param params
*/
async getHealthChecksResults(params: TransformHealthRuleParams) {
const transformIds = await getResultsTransformIds(
params.includeTransforms,
params.excludeTransforms
);
const testsConfig = getResultTestConfig(params.testsConfig);
const result: TestResult[] = [];
if (testsConfig.notStarted.enabled) {
const response = await this.getNotStartedTransformsReport(transformIds);
if (response.length > 0) {
const count = response.length;
const transformsString = response.map((t) => t.transform_id).join(', ');
result.push({
name: TRANSFORM_HEALTH_CHECK_NAMES.notStarted.name,
context: {
results: response,
message: i18n.translate(
'xpack.transform.alertTypes.transformHealth.notStartedMessage',
{
defaultMessage:
'{count, plural, one {Transform} other {Transforms}} {transformsString} {count, plural, one {is} other {are}} not started.',
values: { count, transformsString },
}
),
},
});
}
}
return result;
},
};
}
export type TransformHealthService = ReturnType<typeof transformHealthServiceProvider>;

View file

@ -13,6 +13,7 @@ import { LicenseType } from '../../licensing/common/types';
import { Dependencies } from './types';
import { ApiRoutes } from './routes';
import { License } from './services';
import { registerTransformHealthRuleType } from './lib/alerting';
const basicLicense: LicenseType = 'basic';
@ -38,7 +39,7 @@ export class TransformServerPlugin implements Plugin<{}, void, any, any> {
setup(
{ http, getStartServices, elasticsearch }: CoreSetup,
{ licensing, features }: Dependencies
{ licensing, features, alerting }: Dependencies
): {} {
const router = http.createRouter();
@ -75,6 +76,10 @@ export class TransformServerPlugin implements Plugin<{}, void, any, any> {
license: this.license,
});
if (alerting) {
registerTransformHealthRuleType({ alerting, logger: this.logger });
}
return {};
}

View file

@ -9,10 +9,12 @@ import { IRouter } from 'src/core/server';
import { LicensingPluginSetup } from '../../licensing/server';
import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server';
import { License } from './services';
import type { AlertingPlugin } from '../../alerting/server';
export interface Dependencies {
licensing: LicensingPluginSetup;
features: FeaturesPluginSetup;
alerting?: AlertingPlugin['setup'];
}
export interface RouteDependencies {

View file

@ -35,6 +35,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC
loadTestFile(require.resolve('./alerts_space1'));
loadTestFile(require.resolve('./alerts_default_space'));
loadTestFile(require.resolve('./builtin_alert_types'));
loadTestFile(require.resolve('./transform_rule_types'));
loadTestFile(require.resolve('./mustache_templates.ts'));
loadTestFile(require.resolve('./notify_when'));
loadTestFile(require.resolve('./ephemeral'));

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 { FtrProviderContext } from '../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function alertingTests({ loadTestFile }: FtrProviderContext) {
describe('transform alert rule types', function () {
this.tags('dima');
loadTestFile(require.resolve('./transform_health'));
});
}

View file

@ -0,0 +1,206 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../../../common/ftr_provider_context';
import {
ES_TEST_INDEX_NAME,
ESTestIndexTool,
getUrlPrefix,
ObjectRemover,
} from '../../../../../common/lib';
import { Spaces } from '../../../../scenarios';
import { PutTransformsRequestSchema } from '../../../../../../../plugins/transform/common/api_schemas/transforms';
const ACTION_TYPE_ID = '.index';
const ALERT_TYPE_ID = 'transform_health';
const ES_TEST_INDEX_SOURCE = 'transform-alert:transform-health';
const ES_TEST_INDEX_REFERENCE = '-na-';
const ES_TEST_OUTPUT_INDEX_NAME = `${ES_TEST_INDEX_NAME}-ts-output`;
const ALERT_INTERVAL_SECONDS = 3;
interface CreateAlertParams {
name: string;
includeTransforms: string[];
excludeTransforms?: string[] | null;
testsConfig?: {
notStarted?: {
enabled: boolean;
} | null;
} | null;
}
export function generateDestIndex(transformId: string): string {
return `user-${transformId}`;
}
export function generateTransformConfig(transformId: string): PutTransformsRequestSchema {
const destinationIndex = generateDestIndex(transformId);
return {
source: { index: ['ft_farequote'] },
pivot: {
group_by: { airline: { terms: { field: 'airline' } } },
aggregations: { '@timestamp.value_count': { value_count: { field: '@timestamp' } } },
},
dest: { index: destinationIndex },
sync: {
time: { field: '@timestamp' },
},
};
}
// eslint-disable-next-line import/no-default-export
export default function alertTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const retry = getService('retry');
const es = getService('es');
const log = getService('log');
const transform = getService('transform');
const esTestIndexTool = new ESTestIndexTool(es, retry);
const esTestIndexToolOutput = new ESTestIndexTool(es, retry, ES_TEST_OUTPUT_INDEX_NAME);
describe('alert', async () => {
const objectRemover = new ObjectRemover(supertest);
let actionId: string;
const transformId = 'test_transform_01';
const destinationIndex = generateDestIndex(transformId);
beforeEach(async () => {
await esTestIndexTool.destroy();
await esTestIndexTool.setup();
await esTestIndexToolOutput.destroy();
await esTestIndexToolOutput.setup();
await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote');
await transform.testResources.setKibanaTimeZoneToUTC();
actionId = await createAction();
await transform.api.createIndices(destinationIndex);
await createTransform(transformId);
});
afterEach(async () => {
await objectRemover.removeAll();
await esTestIndexTool.destroy();
await esTestIndexToolOutput.destroy();
await transform.api.cleanTransformIndices();
});
it('runs correctly', async () => {
await createAlert({
name: 'Test all transforms',
includeTransforms: ['*'],
});
await stopTransform(transformId);
log.debug('Checking created alert instances...');
const docs = await waitForDocs(1);
for (const doc of docs) {
const { name, message } = doc._source.params;
expect(name).to.be('Test all transforms');
expect(message).to.be('Transform test_transform_01 is not started.');
}
});
async function waitForDocs(count: number): Promise<any[]> {
return await esTestIndexToolOutput.waitForDocs(
ES_TEST_INDEX_SOURCE,
ES_TEST_INDEX_REFERENCE,
count
);
}
async function createTransform(id: string) {
const config = generateTransformConfig(id);
await transform.api.createAndRunTransform(id, config);
}
async function createAlert(params: CreateAlertParams): Promise<string> {
log.debug(`Creating an alerting rule "${params.name}"...`);
const action = {
id: actionId,
group: 'transform_issue',
params: {
documents: [
{
source: ES_TEST_INDEX_SOURCE,
reference: ES_TEST_INDEX_REFERENCE,
params: {
name: '{{{alertName}}}',
message: '{{{context.message}}}',
},
},
],
},
};
const { status, body: createdAlert } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send({
name: params.name,
consumer: 'alerts',
enabled: true,
rule_type_id: ALERT_TYPE_ID,
schedule: { interval: `${ALERT_INTERVAL_SECONDS}s` },
actions: [action],
notify_when: 'onActiveAlert',
params: {
includeTransforms: params.includeTransforms,
},
});
// will print the error body, if an error occurred
// if (statusCode !== 200) console.log(createdAlert);
expect(status).to.be(200);
const alertId = createdAlert.id;
objectRemover.add(Spaces.space1.id, alertId, 'rule', 'alerting');
return alertId;
}
async function stopTransform(id: string) {
await transform.api.stopTransform(id);
}
async function createAction(): Promise<string> {
log.debug('Creating an action...');
// @ts-ignore
const { statusCode, body: createdAction } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector`)
.set('kbn-xsrf', 'foo')
.send({
name: 'index action for transform health FT',
connector_type_id: ACTION_TYPE_ID,
config: {
index: ES_TEST_OUTPUT_INDEX_NAME,
},
secrets: {},
});
expect(statusCode).to.be(200);
log.debug(`Action with id "${createdAction.id}" has been created.`);
const resultId = createdAction.id;
objectRemover.add(Spaces.space1.id, resultId, 'connector', 'actions');
return resultId;
}
});
}

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FtrProviderContext } from '../../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function alertingTests({ loadTestFile }: FtrProviderContext) {
describe('transform_health', function () {
loadTestFile(require.resolve('./alert'));
});
}

View file

@ -234,6 +234,11 @@ export function TransformAPIProvider({ getService }: FtrProviderContext) {
await esSupertest.post(`/_transform/${transformId}/_start`).expect(200);
},
async stopTransform(transformId: string) {
log.debug(`Stopping transform '${transformId}' ...`);
await esSupertest.post(`/_transform/${transformId}/_stop`).expect(200);
},
async createAndRunTransform(transformId: string, transformConfig: PutTransformsRequestSchema) {
await this.createTransform(transformId, transformConfig);
await this.startTransform(transformId);