Skip to content

Commit 341e9cf

Browse files
authored
[ML] Anomaly Detection alert type (#89286)
* [ML] init ML alerts * [ML] job selector * [ML] move schema server-side * [ML] fix type 🤦‍ * [ML] severity selector * [ML] add alerting capabilities * [ML] add alerting capabilities * [ML] result type selector * [ML] time range selector * [ML] init alert preview endpoint * [ML] update SeveritySelector component * [ML] adjust the form * [ML] adjust the form * [ML] server-side, preview component * [ML] update defaultMessage * [ML] Anomaly explorer URL * [ML] validate preview interval * [ML] rename alert type * [ML] fix i18n * [ML] fix TS and mocks * [ML] update licence headers * [ML] add ts config references * [ML] init functional tests * [ML] functional test for creating anomaly detection alert * [ML] adjust bucket results query * [ML] fix messages * [ML] resolve functional tests related issues * [ML] fix result check * [ML] change preview layout * [ML] extend ml client types * [ML] add missing types, remove unused client variable * [ML] change to import type * [ML] handle preview error * [ML] move error callout * [ML] better error handling * [ML] add client-side validation * [ML] move fake request to the executor * [ML] revert ml client type changes, set response type manually * [ML] documentationUrl * [ML] add extra sentence for interim results * [ML] use publicBaseUrl * [ML] adjust the query * [ML] fix anomaly explorer url * [ML] adjust the alert params schema * [ML] remove default severity threshold for records and influencers * [ML] fix query with filter block * [ML] fix functional tests * [ML] remove isInterim check * [ML] remove redundant fragment * [ML] fix selected cells hook * [ML] set query string * [ML] support sample size by the preview endpoint * [ML] update counter * [ML] add check for the bucket span * [ML] fix effects * [ML] disable mlExplorerSwimlane * [ML] refactor functional tests to use setSliderValue * [ML] add assertTestIntervalValue * [ML] floor scores
1 parent 40570a6 commit 341e9cf

48 files changed

Lines changed: 2305 additions & 110 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

x-pack/plugins/alerts/server/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export interface AlertExecutorOptions<
105105
export interface ActionVariable {
106106
name: string;
107107
description: string;
108+
useWithTripleBracesInTemplates?: boolean;
108109
}
109110

110111
export type ExecutorType<
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { i18n } from '@kbn/i18n';
9+
import { ActionGroup } from '../../../alerts/common';
10+
import { MINIMUM_FULL_LICENSE } from '../license';
11+
import { PLUGIN_ID } from './app';
12+
13+
export const ML_ALERT_TYPES = {
14+
ANOMALY_DETECTION: 'xpack.ml.anomaly_detection_alert',
15+
} as const;
16+
17+
export type MlAlertType = typeof ML_ALERT_TYPES[keyof typeof ML_ALERT_TYPES];
18+
19+
export const ANOMALY_SCORE_MATCH_GROUP_ID = 'anomaly_score_match';
20+
export type AnomalyScoreMatchGroupId = typeof ANOMALY_SCORE_MATCH_GROUP_ID;
21+
export const THRESHOLD_MET_GROUP: ActionGroup<AnomalyScoreMatchGroupId> = {
22+
id: ANOMALY_SCORE_MATCH_GROUP_ID,
23+
name: i18n.translate('xpack.ml.anomalyDetectionAlert.actionGroupName', {
24+
defaultMessage: 'Anomaly score matched the condition',
25+
}),
26+
};
27+
28+
export const ML_ALERT_TYPES_CONFIG: Record<
29+
MlAlertType,
30+
{
31+
name: string;
32+
actionGroups: Array<ActionGroup<AnomalyScoreMatchGroupId>>;
33+
defaultActionGroupId: AnomalyScoreMatchGroupId;
34+
minimumLicenseRequired: string;
35+
producer: string;
36+
}
37+
> = {
38+
[ML_ALERT_TYPES.ANOMALY_DETECTION]: {
39+
name: i18n.translate('xpack.ml.anomalyDetectionAlert.name', {
40+
defaultMessage: 'Anomaly detection alert',
41+
}),
42+
actionGroups: [THRESHOLD_MET_GROUP],
43+
defaultActionGroupId: ANOMALY_SCORE_MATCH_GROUP_ID,
44+
minimumLicenseRequired: MINIMUM_FULL_LICENSE,
45+
producer: PLUGIN_ID,
46+
},
47+
};
48+
49+
export const ALERT_PREVIEW_SAMPLE_SIZE = 5;

x-pack/plugins/ml/common/constants/anomalies.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ export const SEVERITY_COLORS = {
3131
BLANK: '#ffffff',
3232
};
3333

34+
export const ANOMALY_RESULT_TYPE = {
35+
BUCKET: 'bucket',
36+
RECORD: 'record',
37+
INFLUENCER: 'influencer',
38+
} as const;
39+
3440
export const PARTITION_FIELDS = ['partition_field', 'over_field', 'by_field'] as const;
3541
export const JOB_ID = 'job_id';
3642
export const PARTITION_FIELD_VALUE = 'partition_field_value';

x-pack/plugins/ml/common/constants/app.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ export const PLUGIN_ICON_SOLUTION = 'logoKibana';
1313
export const ML_APP_NAME = i18n.translate('xpack.ml.navMenu.mlAppNameText', {
1414
defaultMessage: 'Machine Learning',
1515
});
16+
export const ML_BASE_PATH = '/api/ml';
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { AnomalyResultType } from './anomalies';
9+
import { ANOMALY_RESULT_TYPE } from '../constants/anomalies';
10+
import { AlertTypeParams } from '../../../alerts/common';
11+
12+
export type PreviewResultsKeys = 'record_results' | 'bucket_results' | 'influencer_results';
13+
export type TopHitsResultsKeys = 'top_record_hits' | 'top_bucket_hits' | 'top_influencer_hits';
14+
15+
export interface AlertExecutionResult {
16+
count: number;
17+
key: number;
18+
key_as_string: string;
19+
isInterim: boolean;
20+
jobIds: string[];
21+
timestamp: number;
22+
timestampEpoch: number;
23+
timestampIso8601: string;
24+
score: number;
25+
bucketRange: { start: string; end: string };
26+
topRecords: RecordAnomalyAlertDoc[];
27+
topInfluencers?: InfluencerAnomalyAlertDoc[];
28+
}
29+
30+
export interface PreviewResponse {
31+
count: number;
32+
results: AlertExecutionResult[];
33+
}
34+
35+
interface BaseAnomalyAlertDoc {
36+
result_type: AnomalyResultType;
37+
job_id: string;
38+
/**
39+
* Rounded score
40+
*/
41+
score: number;
42+
timestamp: number;
43+
is_interim: boolean;
44+
unique_key: string;
45+
}
46+
47+
export interface RecordAnomalyAlertDoc extends BaseAnomalyAlertDoc {
48+
result_type: typeof ANOMALY_RESULT_TYPE.RECORD;
49+
function: string;
50+
field_name: string;
51+
by_field_value: string | number;
52+
over_field_value: string | number;
53+
partition_field_value: string | number;
54+
}
55+
56+
export interface BucketAnomalyAlertDoc extends BaseAnomalyAlertDoc {
57+
result_type: typeof ANOMALY_RESULT_TYPE.BUCKET;
58+
start: number;
59+
end: number;
60+
timestamp_epoch: number;
61+
timestamp_iso8601: number;
62+
}
63+
64+
export interface InfluencerAnomalyAlertDoc extends BaseAnomalyAlertDoc {
65+
result_type: typeof ANOMALY_RESULT_TYPE.INFLUENCER;
66+
influencer_field_name: string;
67+
influencer_field_value: string | number;
68+
influencer_score: number;
69+
}
70+
71+
export type AlertHitDoc = RecordAnomalyAlertDoc | BucketAnomalyAlertDoc | InfluencerAnomalyAlertDoc;
72+
73+
export function isRecordAnomalyAlertDoc(arg: any): arg is RecordAnomalyAlertDoc {
74+
return arg.hasOwnProperty('result_type') && arg.result_type === ANOMALY_RESULT_TYPE.RECORD;
75+
}
76+
77+
export function isBucketAnomalyAlertDoc(arg: any): arg is BucketAnomalyAlertDoc {
78+
return arg.hasOwnProperty('result_type') && arg.result_type === ANOMALY_RESULT_TYPE.BUCKET;
79+
}
80+
81+
export function isInfluencerAnomalyAlertDoc(arg: any): arg is InfluencerAnomalyAlertDoc {
82+
return arg.hasOwnProperty('result_type') && arg.result_type === ANOMALY_RESULT_TYPE.INFLUENCER;
83+
}
84+
85+
export type MlAnomalyDetectionAlertParams = {
86+
jobSelection: {
87+
jobIds?: string[];
88+
groupIds?: string[];
89+
};
90+
severity: number;
91+
resultType: AnomalyResultType;
92+
} & AlertTypeParams;

x-pack/plugins/ml/common/types/anomalies.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* 2.0.
66
*/
77

8-
import { PARTITION_FIELDS } from '../constants/anomalies';
8+
import { PARTITION_FIELDS, ANOMALY_RESULT_TYPE } from '../constants/anomalies';
99

1010
export interface Influencer {
1111
influencer_field_name: string;
@@ -77,3 +77,5 @@ export interface AnomalyCategorizerStatsDoc {
7777
}
7878

7979
export type EntityFieldType = 'partition_field' | 'over_field' | 'by_field';
80+
81+
export type AnomalyResultType = typeof ANOMALY_RESULT_TYPE[keyof typeof ANOMALY_RESULT_TYPE];

x-pack/plugins/ml/common/types/capabilities.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import { KibanaRequest } from 'kibana/server';
99
import { PLUGIN_ID } from '../constants/app';
1010
import { ML_SAVED_OBJECT_TYPE } from './saved_objects';
11+
import { ML_ALERT_TYPES } from '../constants/alerts';
1112

1213
export const apmUserMlCapabilities = {
1314
canGetJobs: false,
@@ -106,6 +107,10 @@ export function getPluginPrivileges() {
106107
all: savedObjects,
107108
read: savedObjects,
108109
},
110+
alerting: {
111+
all: Object.values(ML_ALERT_TYPES),
112+
read: [],
113+
},
109114
},
110115
user: {
111116
...privilege,
@@ -117,6 +122,10 @@ export function getPluginPrivileges() {
117122
all: [],
118123
read: savedObjects,
119124
},
125+
alerting: {
126+
all: [],
127+
read: Object.values(ML_ALERT_TYPES),
128+
},
120129
},
121130
apmUser: {
122131
excludeFromBasePrivileges: true,

x-pack/plugins/ml/common/util/validators.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import { ALLOWED_DATA_UNITS } from '../constants/validation';
9+
import { parseInterval } from './parse_interval';
910

1011
/**
1112
* Provides a validator function for maximum allowed input length.
@@ -61,17 +62,17 @@ export function composeValidators(
6162
}
6263

6364
export function requiredValidator() {
64-
return (value: any) => {
65+
return <T extends string>(value: T) => {
6566
return value === '' || value === undefined || value === null ? { required: true } : null;
6667
};
6768
}
6869

69-
export type ValidationResult = object | null;
70+
export type ValidationResult = Record<string, any> | null;
7071

7172
export type MemoryInputValidatorResult = { invalidUnits: { allowedUnits: string } } | null;
7273

7374
export function memoryInputValidator(allowedUnits = ALLOWED_DATA_UNITS) {
74-
return (value: any) => {
75+
return <T>(value: T) => {
7576
if (typeof value !== 'string' || value === '') {
7677
return null;
7778
}
@@ -81,3 +82,16 @@ export function memoryInputValidator(allowedUnits = ALLOWED_DATA_UNITS) {
8182
: { invalidUnits: { allowedUnits: allowedUnits.join(', ') } };
8283
};
8384
}
85+
86+
export function timeIntervalInputValidator() {
87+
return (value: string) => {
88+
const r = parseInterval(value);
89+
if (r === null) {
90+
return {
91+
invalidTimeInterval: true,
92+
};
93+
}
94+
95+
return null;
96+
};
97+
}

x-pack/plugins/ml/kibana.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@
1717
"uiActions",
1818
"kibanaLegacy",
1919
"indexPatternManagement",
20-
"discover"
20+
"discover",
21+
"triggersActionsUi"
2122
],
2223
"optionalPlugins": [
24+
"alerts",
2325
"home",
2426
"security",
2527
"spaces",
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
9+
import { i18n } from '@kbn/i18n';
10+
import { FormattedMessage } from '@kbn/i18n/react';
11+
import { EuiComboBox, EuiComboBoxOptionOption, EuiComboBoxProps, EuiFormRow } from '@elastic/eui';
12+
import { JobId } from '../../common/types/anomaly_detection_jobs';
13+
import { MlApiServices } from '../application/services/ml_api_service';
14+
15+
interface JobSelection {
16+
jobIds?: JobId[];
17+
groupIds?: string[];
18+
}
19+
20+
export interface JobSelectorControlProps {
21+
jobSelection?: JobSelection;
22+
onSelectionChange: (jobSelection: JobSelection) => void;
23+
adJobsApiService: MlApiServices['jobs'];
24+
/**
25+
* Validation is handled by alerting framework
26+
*/
27+
errors: string[];
28+
}
29+
30+
export const JobSelectorControl: FC<JobSelectorControlProps> = ({
31+
jobSelection,
32+
onSelectionChange,
33+
adJobsApiService,
34+
errors,
35+
}) => {
36+
const [options, setOptions] = useState<Array<EuiComboBoxOptionOption<string>>>([]);
37+
const jobIds = useMemo(() => new Set(), []);
38+
const groupIds = useMemo(() => new Set(), []);
39+
40+
const fetchOptions = useCallback(async () => {
41+
try {
42+
const {
43+
jobIds: jobIdOptions,
44+
groupIds: groupIdOptions,
45+
} = await adJobsApiService.getAllJobAndGroupIds();
46+
47+
jobIdOptions.forEach((v) => {
48+
jobIds.add(v);
49+
});
50+
groupIdOptions.forEach((v) => {
51+
groupIds.add(v);
52+
});
53+
54+
setOptions([
55+
{
56+
label: i18n.translate('xpack.ml.jobSelector.jobOptionsLabel', {
57+
defaultMessage: 'Jobs',
58+
}),
59+
options: jobIdOptions.map((v) => ({ label: v })),
60+
},
61+
{
62+
label: i18n.translate('xpack.ml.jobSelector.groupOptionsLabel', {
63+
defaultMessage: 'Groups',
64+
}),
65+
options: groupIdOptions.map((v) => ({ label: v })),
66+
},
67+
]);
68+
} catch (e) {
69+
// TODO add error handling
70+
}
71+
}, [adJobsApiService]);
72+
73+
const onChange: EuiComboBoxProps<string>['onChange'] = useCallback(
74+
(selectedOptions) => {
75+
const selectedJobIds: JobId[] = [];
76+
const selectedGroupIds: string[] = [];
77+
selectedOptions.forEach(({ label }: { label: string }) => {
78+
if (jobIds.has(label)) {
79+
selectedJobIds.push(label);
80+
} else if (groupIds.has(label)) {
81+
selectedGroupIds.push(label);
82+
}
83+
});
84+
onSelectionChange({
85+
...(selectedJobIds.length > 0 ? { jobIds: selectedJobIds } : {}),
86+
...(selectedGroupIds.length > 0 ? { groupIds: selectedGroupIds } : {}),
87+
});
88+
},
89+
[jobIds, groupIds]
90+
);
91+
92+
useEffect(() => {
93+
fetchOptions();
94+
}, []);
95+
96+
const selectedOptions = Object.values(jobSelection ?? {})
97+
.flat()
98+
.map((v) => ({
99+
label: v,
100+
}));
101+
102+
return (
103+
<EuiFormRow
104+
fullWidth
105+
label={
106+
<FormattedMessage
107+
id="xpack.ml.jobSelector.formControlLabel"
108+
defaultMessage="Select jobs or groups"
109+
/>
110+
}
111+
isInvalid={!!errors?.length}
112+
error={errors}
113+
>
114+
<EuiComboBox<string>
115+
selectedOptions={selectedOptions}
116+
options={options}
117+
onChange={onChange}
118+
fullWidth
119+
data-test-subj={'mlAnomalyAlertJobSelection'}
120+
isInvalid={!!errors?.length}
121+
/>
122+
</EuiFormRow>
123+
);
124+
};

0 commit comments

Comments
 (0)