Skip to content

Commit

Permalink
[ML] Add anomaly description as an alert message for anomaly detectio…
Browse files Browse the repository at this point in the history
…n 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="https://github.com/elastic/kibana/assets/7405507/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
  • Loading branch information
darnautov authored Dec 5, 2023
1 parent 3ff8910 commit 50dabea
Show file tree
Hide file tree
Showing 10 changed files with 161 additions and 85 deletions.
69 changes: 69 additions & 0 deletions x-pack/plugins/ml/common/util/anomaly_description.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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: '' };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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 (
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion x-pack/plugins/ml/public/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
95 changes: 83 additions & 12 deletions x-pack/plugins/ml/server/lib/alerts/alerting_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 0 additions & 4 deletions x-pack/plugins/translations/translations/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}",
Expand Down
4 changes: 0 additions & 4 deletions x-pack/plugins/translations/translations/ja-JP.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 {#個のバケット}}でディップ",
Expand Down
4 changes: 0 additions & 4 deletions x-pack/plugins/translations/translations/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 {# 个存储桶}}上出现谷值",
Expand Down

0 comments on commit 50dabea

Please sign in to comment.