[ML] Add anomaly description as an alert message for anomaly detection rule type (#172473)

## Summary

Closes #136391 

Uses a description of the anomaly for the alert message for anomaly
detection alerting rules with the `record` result type. This messages is
used for example in the `Reason` field in the alert table and details
flyout.

<img width="753" alt="image"
src="072fe833-204b-4d38-bd3d-50d00015a43f">


### Checklist

- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
This commit is contained in:
Dima Arnautov 2023-12-05 20:04:36 +01:00 committed by GitHub
parent 3ff891003c
commit 50dabea70f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 161 additions and 85 deletions

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 { i18n } from '@kbn/i18n';
import { capitalize } from 'lodash';
import { getSeverity, type MlAnomaliesTableRecordExtended } from '@kbn/ml-anomaly-utils';
export function getAnomalyDescription(anomaly: MlAnomaliesTableRecordExtended): {
anomalyDescription: string;
mvDescription: string | undefined;
} {
const source = anomaly.source;
let anomalyDescription = i18n.translate('xpack.ml.anomalyDescription.anomalyInLabel', {
defaultMessage: '{anomalySeverity} anomaly in {anomalyDetector}',
values: {
anomalySeverity: capitalize(getSeverity(anomaly.severity).label),
anomalyDetector: anomaly.detector,
},
});
if (anomaly.entityName !== undefined) {
anomalyDescription += i18n.translate('xpack.ml.anomalyDescription.foundForLabel', {
defaultMessage: ' found for {anomalyEntityName} {anomalyEntityValue}',
values: {
anomalyEntityName: anomaly.entityName,
anomalyEntityValue: anomaly.entityValue,
},
});
}
if (
source.partition_field_name !== undefined &&
source.partition_field_name !== anomaly.entityName
) {
anomalyDescription += i18n.translate('xpack.ml.anomalyDescription.detectedInLabel', {
defaultMessage: ' detected in {sourcePartitionFieldName} {sourcePartitionFieldValue}',
values: {
sourcePartitionFieldName: source.partition_field_name,
sourcePartitionFieldValue: source.partition_field_value,
},
});
}
// Check for a correlatedByFieldValue in the source which will be present for multivariate analyses
// where the record is anomalous due to relationship with another 'by' field value.
let mvDescription: string = '';
if (source.correlated_by_field_value !== undefined) {
mvDescription = i18n.translate('xpack.ml.anomalyDescription.multivariateDescription', {
defaultMessage:
'multivariate correlations found in {sourceByFieldName}; ' +
'{sourceByFieldValue} is considered anomalous given {sourceCorrelatedByFieldValue}',
values: {
sourceByFieldName: source.by_field_name,
sourceByFieldValue: source.by_field_value,
sourceCorrelatedByFieldValue: source.correlated_by_field_value,
},
});
}
return {
anomalyDescription,
mvDescription,
};
}

View file

@ -15,8 +15,8 @@ import { i18n } from '@kbn/i18n';
// Returns an Object containing a text message and EuiIcon type to
// describe how the actual value compares to the typical.
export function getMetricChangeDescription(
actualProp: number[] | number,
typicalProp: number[] | number
actualProp: number[] | number | undefined,
typicalProp: number[] | number | undefined
) {
if (actualProp === undefined || typicalProp === undefined) {
return { iconType: 'empty', message: '' };

View file

@ -10,11 +10,9 @@
* of the anomalies table.
*/
import React, { FC, useState, useMemo } from 'react';
import React, { FC, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { capitalize } from 'lodash';
import {
EuiFlexGroup,
EuiFlexItem,
@ -27,17 +25,16 @@ import {
useEuiTheme,
} from '@elastic/eui';
import { isPopulatedObject } from '@kbn/ml-is-populated-object';
import { getSeverity, type MlAnomaliesTableRecordExtended } from '@kbn/ml-anomaly-utils';
import { type MlAnomaliesTableRecordExtended } from '@kbn/ml-anomaly-utils';
import { getAnomalyDescription } from '../../../../common/util/anomaly_description';
import { MAX_CHARS } from './anomalies_table_constants';
import type { CategoryDefinition } from '../../services/ml_api_service/results';
import { EntityCellFilter } from '../entity_cell';
import { ExplorerJob } from '../../explorer/explorer_utils';
import {
getInfluencersItems,
AnomalyExplanationDetails,
DetailsItems,
getInfluencersItems,
} from './anomaly_details_utils';
interface Props {
@ -166,56 +163,7 @@ const Contents: FC<{
};
const Description: FC<{ anomaly: MlAnomaliesTableRecordExtended }> = ({ anomaly }) => {
const source = anomaly.source;
let anomalyDescription = i18n.translate('xpack.ml.anomaliesTable.anomalyDetails.anomalyInLabel', {
defaultMessage: '{anomalySeverity} anomaly in {anomalyDetector}',
values: {
anomalySeverity: capitalize(getSeverity(anomaly.severity).label),
anomalyDetector: anomaly.detector,
},
});
if (anomaly.entityName !== undefined) {
anomalyDescription += i18n.translate('xpack.ml.anomaliesTable.anomalyDetails.foundForLabel', {
defaultMessage: ' found for {anomalyEntityName} {anomalyEntityValue}',
values: {
anomalyEntityName: anomaly.entityName,
anomalyEntityValue: anomaly.entityValue,
},
});
}
if (
source.partition_field_name !== undefined &&
source.partition_field_name !== anomaly.entityName
) {
anomalyDescription += i18n.translate('xpack.ml.anomaliesTable.anomalyDetails.detectedInLabel', {
defaultMessage: ' detected in {sourcePartitionFieldName} {sourcePartitionFieldValue}',
values: {
sourcePartitionFieldName: source.partition_field_name,
sourcePartitionFieldValue: source.partition_field_value,
},
});
}
// Check for a correlatedByFieldValue in the source which will be present for multivariate analyses
// where the record is anomalous due to relationship with another 'by' field value.
let mvDescription;
if (source.correlated_by_field_value !== undefined) {
mvDescription = i18n.translate(
'xpack.ml.anomaliesTable.anomalyDetails.multivariateDescription',
{
defaultMessage:
'multivariate correlations found in {sourceByFieldName}; ' +
'{sourceByFieldValue} is considered anomalous given {sourceCorrelatedByFieldValue}',
values: {
sourceByFieldName: source.by_field_name,
sourceByFieldValue: source.by_field_value,
sourceCorrelatedByFieldValue: source.correlated_by_field_value,
},
}
);
}
const { anomalyDescription, mvDescription } = getAnomalyDescription(anomaly);
return (
<>

View file

@ -10,7 +10,7 @@ import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui';
import { getMetricChangeDescription } from '../../formatters/metric_change_description';
import { getMetricChangeDescription } from '../../../../common/util/metric_change_description';
/*
* Component for rendering the description cell in the anomalies table, which provides a

View file

@ -13,7 +13,7 @@ export * from '../common/types/audit_message';
export * from '../common/util/validators';
export * from './application/formatters/metric_change_description';
export * from '../common/util/metric_change_description';
export * from './application/components/field_stats_flyout';
export * from './application/data_frame_analytics/common';

View file

@ -9,7 +9,7 @@ import Boom from '@hapi/boom';
import { i18n } from '@kbn/i18n';
import rison from '@kbn/rison';
import type { Duration } from 'moment/moment';
import { memoize, pick } from 'lodash';
import { capitalize, get, memoize, pick } from 'lodash';
import {
FIELD_FORMAT_IDS,
type IFieldFormat,
@ -22,9 +22,13 @@ import {
type MlAnomalyRecordDoc,
type MlAnomalyResultType,
ML_ANOMALY_RESULT_TYPE,
MlAnomaliesTableRecordExtended,
} from '@kbn/ml-anomaly-utils';
import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
import { ALERT_REASON, ALERT_URL } from '@kbn/rule-data-utils';
import { MlJob } from '@elastic/elasticsearch/lib/api/types';
import { getAnomalyDescription } from '../../../common/util/anomaly_description';
import { getMetricChangeDescription } from '../../../common/util/metric_change_description';
import type { MlClient } from '../ml_client';
import type {
MlAnomalyDetectionAlertParams,
@ -184,6 +188,8 @@ export function alertingServiceProvider(
) {
type FieldFormatters = AwaitReturnType<ReturnType<typeof getFormatters>>;
let jobs: MlJob[] = [];
/**
* Provides formatters based on the data view of the datafeed index pattern
* and set of default formatters for fallback.
@ -397,6 +403,72 @@ export function alertingServiceProvider(
return alertInstanceKey;
};
const getAlertMessage = (
resultType: MlAnomalyResultType,
source: Record<string, unknown>
): string => {
let message = i18n.translate('xpack.ml.alertTypes.anomalyDetectionAlertingRule.alertMessage', {
defaultMessage:
'Alerts are raised based on real-time scores. Remember that scores may be adjusted over time as data continues to be analyzed.',
});
if (resultType === ML_ANOMALY_RESULT_TYPE.RECORD) {
const recordSource = source as MlAnomalyRecordDoc;
const detectorsByJob = jobs.reduce((acc, job) => {
acc[job.job_id] = job.analysis_config.detectors.reduce((innterAcc, detector) => {
innterAcc[detector.detector_index!] = detector.detector_description;
return innterAcc;
}, {} as Record<number, string | undefined>);
return acc;
}, {} as Record<string, Record<number, string | undefined>>);
const detectorDescription = get(detectorsByJob, [
recordSource.job_id,
recordSource.detector_index,
]);
const record = {
source: recordSource,
detector: detectorDescription ?? recordSource.function_description,
severity: recordSource.record_score,
} as MlAnomaliesTableRecordExtended;
const entityName = getEntityFieldName(recordSource);
if (entityName !== undefined) {
record.entityName = entityName;
record.entityValue = getEntityFieldValue(recordSource);
}
const { anomalyDescription, mvDescription } = getAnomalyDescription(record);
const anomalyDescriptionSummary = `${anomalyDescription}${
mvDescription ? ` (${mvDescription})` : ''
}`;
let actual = recordSource.actual;
let typical = recordSource.typical;
if (
(!isDefined(actual) || !isDefined(typical)) &&
Array.isArray(recordSource.causes) &&
recordSource.causes.length === 1
) {
actual = recordSource.causes[0].actual;
typical = recordSource.causes[0].typical;
}
let metricChangeDescription = '';
if (isDefined(actual) && isDefined(typical)) {
metricChangeDescription = capitalize(getMetricChangeDescription(actual, typical).message);
}
message = `${anomalyDescriptionSummary}. ${
metricChangeDescription ? `${metricChangeDescription}.` : ''
}`;
}
return message;
};
/**
* Returns a callback for formatting elasticsearch aggregation response
* to the alert-as-data document.
@ -419,14 +491,10 @@ export function alertingServiceProvider(
const topAnomaly = requestedAnomalies[0];
const timestamp = topAnomaly._source.timestamp;
const message = getAlertMessage(resultType, topAnomaly._source);
return {
[ALERT_REASON]: i18n.translate(
'xpack.ml.alertTypes.anomalyDetectionAlertingRule.alertMessage',
{
defaultMessage:
'Alerts are raised based on real-time scores. Remember that scores may be adjusted over time as data continues to be analyzed.',
}
),
[ALERT_REASON]: message,
job_id: [...new Set(requestedAnomalies.map((h) => h._source.job_id))][0],
is_interim: requestedAnomalies.some((h) => h._source.is_interim),
anomaly_timestamp: timestamp,
@ -495,14 +563,12 @@ export function alertingServiceProvider(
const alertInstanceKey = getAlertInstanceKey(topAnomaly._source);
const timestamp = topAnomaly._source.timestamp;
const bucketSpanInSeconds = topAnomaly._source.bucket_span;
const message = getAlertMessage(resultType, topAnomaly._source);
return {
count: aggTypeResults.doc_count,
key: v.key,
message: i18n.translate('xpack.ml.alertTypes.anomalyDetectionAlertingRule.alertMessage', {
defaultMessage:
'Alerts are raised based on real-time scores. Remember that scores may be adjusted over time as data continues to be analyzed.',
}),
message,
alertInstanceKey,
jobIds: [...new Set(requestedAnomalies.map((h) => h._source.job_id))],
isInterim: requestedAnomalies.some((h) => h._source.is_interim),
@ -564,6 +630,8 @@ export function alertingServiceProvider(
// Extract jobs from group ids and make sure provided jobs assigned to a current space
const jobsResponse = (await mlClient.getJobs({ job_id: jobAndGroupIds.join(',') })).jobs;
jobs = jobsResponse;
if (jobsResponse.length === 0) {
// Probably assigned groups don't contain any jobs anymore.
throw new Error("Couldn't find the job with provided id");
@ -699,6 +767,9 @@ export function alertingServiceProvider(
// Extract jobs from group ids and make sure provided jobs assigned to a current space
const jobsResponse = (await mlClient.getJobs({ job_id: jobAndGroupIds.join(',') })).jobs;
// Cache jobs response
jobs = jobsResponse;
if (jobsResponse.length === 0) {
// Probably assigned groups don't contain any jobs anymore.
return;

View file

@ -24189,13 +24189,9 @@
"xpack.ml.annotationsTable.howToCreateAnnotationDescription": "Pour créer une annotation, ouvrir le {linkToSingleMetricView}",
"xpack.ml.anomaliesTable.anomalyDetails.anomalyDescriptionListMoreLinkText": "et {othersCount} en plus",
"xpack.ml.anomaliesTable.anomalyDetails.anomalyExplanationTitle": "Explication des anomalies {learnMoreLink}",
"xpack.ml.anomaliesTable.anomalyDetails.anomalyInLabel": "{anomalySeverity} anomalie dans {anomalyDetector}",
"xpack.ml.anomaliesTable.anomalyDetails.anomalyTimeRangeLabel": "{anomalyTime} à {anomalyEndTime}",
"xpack.ml.anomaliesTable.anomalyDetails.causeValuesDescription": "{causeEntityValue} (actuel {actualValue}, typique {typicalValue}, probabilité {probabilityValue})",
"xpack.ml.anomaliesTable.anomalyDetails.causeValuesTitle": "Valeurs {causeEntityName}",
"xpack.ml.anomaliesTable.anomalyDetails.detectedInLabel": " détecté dans {sourcePartitionFieldName} {sourcePartitionFieldValue}",
"xpack.ml.anomaliesTable.anomalyDetails.foundForLabel": " trouvé pour {anomalyEntityName} {anomalyEntityValue}",
"xpack.ml.anomaliesTable.anomalyDetails.multivariateDescription": "corrélations multi-variable trouvées dans {sourceByFieldName} ; {sourceByFieldValue} est considérée comme une anomalie étant donné {sourceCorrelatedByFieldValue}",
"xpack.ml.anomaliesTable.anomalyDetails.regexDescriptionTooltip": "L'expression normale qui est utilisée pour rechercher des valeurs correspondant à la catégorie (peut être tronquée à une limite de caractères max de {maxChars})",
"xpack.ml.anomaliesTable.anomalyDetails.termsDescriptionTooltip": "Une liste des jetons communs séparés par un espace correspondant aux valeurs de la catégorie (peut être tronquée à une limite de caractères max. de {maxChars})",
"xpack.ml.anomaliesTable.anomalyExplanationDetails.anomalyType.dip": "Baisse sur {anomalyLength, plural, one {# compartiment} many {# compartiments} other {# compartiments}}",

View file

@ -24204,13 +24204,9 @@
"xpack.ml.annotationsTable.howToCreateAnnotationDescription": "注釈を作成するには、{linkToSingleMetricView} を開きます",
"xpack.ml.anomaliesTable.anomalyDetails.anomalyDescriptionListMoreLinkText": "他{othersCount}件",
"xpack.ml.anomaliesTable.anomalyDetails.anomalyExplanationTitle": "異常の説明{learnMoreLink}",
"xpack.ml.anomaliesTable.anomalyDetails.anomalyInLabel": "{anomalyDetector} の {anomalySeverity} の異常",
"xpack.ml.anomaliesTable.anomalyDetails.anomalyTimeRangeLabel": "{anomalyTime}から{anomalyEndTime}",
"xpack.ml.anomaliesTable.anomalyDetails.causeValuesDescription": "{causeEntityValue} (実際値 {actualValue}、通常値 {typicalValue}、確率 {probabilityValue})",
"xpack.ml.anomaliesTable.anomalyDetails.causeValuesTitle": "{causeEntityName}値",
"xpack.ml.anomaliesTable.anomalyDetails.detectedInLabel": " {sourcePartitionFieldName} {sourcePartitionFieldValue} で検知",
"xpack.ml.anomaliesTable.anomalyDetails.foundForLabel": " {anomalyEntityName} {anomalyEntityValue}に対して見つかりました",
"xpack.ml.anomaliesTable.anomalyDetails.multivariateDescription": "{sourceByFieldName} で多変量相関が見つかりました; {sourceByFieldValue} は {sourceCorrelatedByFieldValue} のため異例とみなされます",
"xpack.ml.anomaliesTable.anomalyDetails.regexDescriptionTooltip": "カテゴリーが一致する値を検索するのに使用される正規表現です({maxChars}文字の制限で切り捨てられている可能性があります)",
"xpack.ml.anomaliesTable.anomalyDetails.termsDescriptionTooltip": "カテゴリーの値で一致している共通のトークンのスペース区切りのリストです({maxChars}文字の制限で切り捨てられている可能性があります)",
"xpack.ml.anomaliesTable.anomalyExplanationDetails.anomalyType.dip": "{anomalyLength, plural, other {#個のバケット}}でディップ",

View file

@ -24203,13 +24203,9 @@
"xpack.ml.annotationsTable.howToCreateAnnotationDescription": "要创建注释,请打开 {linkToSingleMetricView}",
"xpack.ml.anomaliesTable.anomalyDetails.anomalyDescriptionListMoreLinkText": "及另外 {othersCount} 个",
"xpack.ml.anomaliesTable.anomalyDetails.anomalyExplanationTitle": "异常解释 {learnMoreLink}",
"xpack.ml.anomaliesTable.anomalyDetails.anomalyInLabel": "{anomalyDetector} 中的 {anomalySeverity} 异常",
"xpack.ml.anomaliesTable.anomalyDetails.anomalyTimeRangeLabel": "{anomalyTime} 至 {anomalyEndTime}",
"xpack.ml.anomaliesTable.anomalyDetails.causeValuesDescription": "{causeEntityValue}(实际 {actualValue}典型 {typicalValue}可能性 {probabilityValue}",
"xpack.ml.anomaliesTable.anomalyDetails.causeValuesTitle": "{causeEntityName} 值",
"xpack.ml.anomaliesTable.anomalyDetails.detectedInLabel": " 在 {sourcePartitionFieldName} {sourcePartitionFieldValue} 检测到",
"xpack.ml.anomaliesTable.anomalyDetails.foundForLabel": " 已为 {anomalyEntityName} {anomalyEntityValue} 找到",
"xpack.ml.anomaliesTable.anomalyDetails.multivariateDescription": "{sourceByFieldName} 中找到多变量关联;如果{sourceCorrelatedByFieldValue}{sourceByFieldValue} 将被视为有异常",
"xpack.ml.anomaliesTable.anomalyDetails.regexDescriptionTooltip": "用于搜索匹配该类别的值(可能已截短至最大字符限制 {maxChars})的正则表达式",
"xpack.ml.anomaliesTable.anomalyDetails.termsDescriptionTooltip": "该类别的值(可能已截短至最大字符限制({maxChars})中匹配的常见令牌的空格分隔列表",
"xpack.ml.anomaliesTable.anomalyExplanationDetails.anomalyType.dip": "{anomalyLength, plural, other {# 个存储桶}}上出现谷值",