Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ML] Adds anomaly description as an alert message for anomaly detection rule type #172473

Merged
merged 8 commits into from
Dec 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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', {
peteharverson marked this conversation as resolved.
Show resolved Hide resolved
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
Loading