[ML] Fix alerting rule preview (#98907)

This commit is contained in:
Dima Arnautov 2021-04-30 20:03:05 +02:00 committed by GitHub
parent d9bc163603
commit 4686f442ee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 136 additions and 65 deletions

View file

@ -13,18 +13,22 @@ import { parseInterval } from '../../common/util/parse_interval';
import { CombinedJobWithStats } from '../../common/types/anomaly_detection_jobs';
import { DATAFEED_STATE } from '../../common/constants/states';
import { MlAnomalyDetectionAlertParams } from '../../common/types/alerts';
import { MlAnomalyAlertTriggerProps } from './ml_anomaly_alert_trigger';
import { TOP_N_BUCKETS_COUNT } from '../../common/constants/alerts';
interface ConfigValidatorProps {
alertInterval: string;
jobConfigs: CombinedJobWithStats[];
alertParams: MlAnomalyDetectionAlertParams;
alertNotifyWhen: MlAnomalyAlertTriggerProps['alertNotifyWhen'];
maxNumberOfBuckets?: number;
}
/**
* Validated alert configuration
*/
export const ConfigValidator: FC<ConfigValidatorProps> = React.memo(
({ jobConfigs = [], alertInterval, alertParams }) => {
({ jobConfigs = [], alertInterval, alertParams, alertNotifyWhen, maxNumberOfBuckets }) => {
if (jobConfigs.length === 0) return null;
const alertIntervalInSeconds = parseInterval(alertInterval)!.asSeconds();
@ -41,49 +45,81 @@ export const ConfigValidator: FC<ConfigValidatorProps> = React.memo(
const configContainsIssues = isAlertIntervalTooHigh || jobWithoutStartedDatafeed.length > 0;
if (!configContainsIssues) return null;
const notifyWhenWarning =
alertNotifyWhen === 'onActiveAlert' &&
lookbackIntervalInSeconds &&
alertIntervalInSeconds < lookbackIntervalInSeconds;
const bucketSpanDuration = parseInterval(jobConfigs[0].analysis_config.bucket_span);
const notificationDuration = bucketSpanDuration
? Math.ceil(bucketSpanDuration.asMinutes()) *
Math.min(
alertParams.topNBuckets ?? TOP_N_BUCKETS_COUNT,
maxNumberOfBuckets ?? TOP_N_BUCKETS_COUNT
)
: undefined;
return (
<>
<EuiSpacer size={'m'} />
<EuiCallOut
title={
<FormattedMessage
id="xpack.ml.alertConditionValidation.title"
defaultMessage="Alert condition contains the following issues:"
/>
}
color="warning"
size={'s'}
>
<ul>
{isAlertIntervalTooHigh ? (
<li>
{configContainsIssues ? (
<>
<EuiCallOut
title={
<FormattedMessage
id="xpack.ml.alertConditionValidation.alertIntervalTooHighMessage"
defaultMessage="The check interval is greater than the lookback interval. Reduce it to {lookbackInterval} to avoid potentially missing notifications."
values={{
lookbackInterval: alertParams.lookbackInterval,
}}
id="xpack.ml.alertConditionValidation.title"
defaultMessage="Alert condition contains the following issues:"
/>
</li>
) : null}
}
color="warning"
size={'s'}
>
<ul>
{isAlertIntervalTooHigh ? (
<li>
<FormattedMessage
id="xpack.ml.alertConditionValidation.alertIntervalTooHighMessage"
defaultMessage="The check interval is greater than the lookback interval. Reduce it to {lookbackInterval} to avoid potentially missing notifications."
values={{
lookbackInterval: alertParams.lookbackInterval,
}}
/>
</li>
) : null}
{jobWithoutStartedDatafeed.length > 0 ? (
<li>
{jobWithoutStartedDatafeed.length > 0 ? (
<li>
<FormattedMessage
id="xpack.ml.alertConditionValidation.stoppedDatafeedJobsMessage"
defaultMessage="The datafeed is not started for the following {count, plural, one {job} other {jobs}}: {jobIds}."
values={{
count: jobWithoutStartedDatafeed.length,
jobIds: jobWithoutStartedDatafeed.join(', '),
}}
/>
</li>
) : null}
</ul>
</EuiCallOut>
<EuiSpacer size={'m'} />
</>
) : null}
{notifyWhenWarning ? (
<>
<EuiCallOut
title={
<FormattedMessage
id="xpack.ml.alertConditionValidation.stoppedDatafeedJobsMessage"
defaultMessage="The datafeed is not started for the following {count, plural, one {job} other {jobs}}: {jobIds}."
values={{
count: jobWithoutStartedDatafeed.length,
jobIds: jobWithoutStartedDatafeed.join(', '),
}}
id="xpack.ml.alertConditionValidation.notifyWhenWarning"
defaultMessage="Expect to receive duplicate notifications about the same anomaly for up to {notificationDuration, plural, one {# minute} other {# minutes}}. Increase the check interval or switch to notify only on status change to avoid duplicate notifications."
values={{ notificationDuration }}
/>
</li>
) : null}
</ul>
</EuiCallOut>
<EuiSpacer size={'m'} />
}
color="warning"
size={'s'}
/>
<EuiSpacer size={'m'} />
</>
) : null}
</>
);
}

View file

@ -25,7 +25,7 @@ export const InterimResultsControl: FC<InterimResultsControlProps> = React.memo(
defaultMessage="Include interim results"
/>
}
checked={value}
checked={value ?? false}
onChange={onChange.bind(null, !value)}
/>
</EuiFormRow>

View file

@ -29,17 +29,10 @@ import { CombinedJobWithStats } from '../../common/types/anomaly_detection_jobs'
import { AdvancedSettings } from './advanced_settings';
import { getLookbackInterval, getTopNBuckets } from '../../common/util/alerts';
import { isDefined } from '../../common/types/guards';
import { AlertTypeParamsExpressionProps } from '../../../triggers_actions_ui/public';
import { parseInterval } from '../../common/util/parse_interval';
interface MlAnomalyAlertTriggerProps {
alertParams: MlAnomalyDetectionAlertParams;
setAlertParams: <T extends keyof MlAnomalyDetectionAlertParams>(
key: T,
value: MlAnomalyDetectionAlertParams[T]
) => void;
setAlertProperty: (prop: string, update: Partial<MlAnomalyDetectionAlertParams>) => void;
errors: Record<keyof MlAnomalyDetectionAlertParams, string[]>;
alertInterval: string;
}
export type MlAnomalyAlertTriggerProps = AlertTypeParamsExpressionProps<MlAnomalyDetectionAlertParams>;
const MlAnomalyAlertTrigger: FC<MlAnomalyAlertTriggerProps> = ({
alertParams,
@ -47,6 +40,7 @@ const MlAnomalyAlertTrigger: FC<MlAnomalyAlertTriggerProps> = ({
setAlertProperty,
errors,
alertInterval,
alertNotifyWhen,
}) => {
const {
services: { http },
@ -116,6 +110,8 @@ const MlAnomalyAlertTrigger: FC<MlAnomalyAlertTriggerProps> = ({
includeInterim: false,
// Preserve job selection
jobSelection,
lookbackInterval: undefined,
topNBuckets: undefined,
});
}
});
@ -142,6 +138,20 @@ const MlAnomalyAlertTrigger: FC<MlAnomalyAlertTriggerProps> = ({
};
}, [alertParams, advancedSettings]);
const maxNumberOfBuckets = useMemo(() => {
if (jobConfigs.length === 0) return;
const bucketDuration = parseInterval(jobConfigs[0].analysis_config.bucket_span);
const lookbackIntervalDuration = advancedSettings.lookbackInterval
? parseInterval(advancedSettings.lookbackInterval)
: null;
if (lookbackIntervalDuration && bucketDuration) {
return Math.ceil(lookbackIntervalDuration.asSeconds() / bucketDuration.asSeconds());
}
}, [jobConfigs, advancedSettings]);
return (
<EuiForm data-test-subj={'mlAnomalyAlertForm'}>
<EuiFlexGroup gutterSize={'none'} justifyContent={'flexEnd'}>
@ -164,13 +174,15 @@ const MlAnomalyAlertTrigger: FC<MlAnomalyAlertTriggerProps> = ({
jobsAndGroupIds={jobsAndGroupIds}
adJobsApiService={adJobsApiService}
onChange={useCallback(onAlertParamChange('jobSelection'), [])}
errors={errors.jobSelection}
errors={Array.isArray(errors.jobSelection) ? errors.jobSelection : []}
/>
<ConfigValidator
jobConfigs={jobConfigs}
alertInterval={alertInterval}
alertNotifyWhen={alertNotifyWhen}
alertParams={resultParams}
maxNumberOfBuckets={maxNumberOfBuckets}
/>
<ResultTypeSelector

View file

@ -109,6 +109,7 @@ export const PreviewAlertCondition: FC<PreviewAlertConditionProps> = ({
const sampleSize = ALERT_PREVIEW_SAMPLE_SIZE;
const [lookBehindInterval, setLookBehindInterval] = useState<string>();
const [lastQueryInterval, setLastQueryInterval] = useState<string>();
const [areResultsVisible, setAreResultVisible] = useState<boolean>(true);
const [previewError, setPreviewError] = useState<Error | undefined>();
const [previewResponse, setPreviewResponse] = useState<PreviewResponse | undefined>();
@ -135,6 +136,7 @@ export const PreviewAlertCondition: FC<PreviewAlertConditionProps> = ({
sampleSize,
});
setPreviewResponse(response);
setLastQueryInterval(lookBehindInterval);
setPreviewError(undefined);
} catch (e) {
setPreviewResponse(undefined);
@ -165,7 +167,7 @@ export const PreviewAlertCondition: FC<PreviewAlertConditionProps> = ({
label={
<FormattedMessage
id="xpack.ml.previewAlert.intervalLabel"
defaultMessage="Check the alert condition with an interval"
defaultMessage="Check the rule condition with an interval"
/>
}
isInvalid={isInvalid}
@ -173,7 +175,7 @@ export const PreviewAlertCondition: FC<PreviewAlertConditionProps> = ({
>
<EuiFieldText
placeholder="15d, 6m"
value={lookBehindInterval}
value={lookBehindInterval ?? ''}
onChange={(e) => {
setLookBehindInterval(e.target.value);
}}
@ -220,10 +222,10 @@ export const PreviewAlertCondition: FC<PreviewAlertConditionProps> = ({
<strong>
<FormattedMessage
id="xpack.ml.previewAlert.previewMessage"
defaultMessage="Triggers {alertsCount, plural, one {# time} other {# times}} in the last {interval}"
defaultMessage="Found {alertsCount, plural, one {# anomaly} other {# anomalies}} in the last {interval}."
values={{
alertsCount: previewResponse.count,
interval: lookBehindInterval,
interval: lastQueryInterval,
}}
/>
</strong>

View file

@ -29,6 +29,7 @@ import { resolveMaxTimeInterval } from '../../../common/util/job_utils';
import { isDefined } from '../../../common/types/guards';
import { getTopNBuckets, resolveLookbackInterval } from '../../../common/util/alerts';
import type { DatafeedsService } from '../../models/job_service/datafeeds';
import { getEntityFieldName, getEntityFieldValue } from '../../../common/util/anomaly_utils';
type AggResultsResponse = { key?: number } & {
[key in PreviewResultsKeys]: {
@ -104,12 +105,20 @@ export function alertingServiceProvider(mlClient: MlClient, datafeedsService: Da
* @param resultType
* @param severity
*/
const getResultTypeAggRequest = (resultType: AnomalyResultType, severity: number) => {
const getResultTypeAggRequest = (
resultType: AnomalyResultType,
severity: number,
useInitialScore?: boolean
) => {
const influencerScoreField = `${useInitialScore ? 'initial_' : ''}influencer_score`;
const recordScoreField = `${useInitialScore ? 'initial_' : ''}record_score`;
const bucketScoreField = `${useInitialScore ? 'initial_' : ''}anomaly_score`;
return {
influencer_results: {
filter: {
range: {
influencer_score: {
[influencerScoreField]: {
gte: resultType === ANOMALY_RESULT_TYPE.INFLUENCER ? severity : 0,
},
},
@ -119,7 +128,7 @@ export function alertingServiceProvider(mlClient: MlClient, datafeedsService: Da
top_hits: {
sort: [
{
influencer_score: {
[influencerScoreField]: {
order: 'desc',
},
},
@ -141,7 +150,7 @@ export function alertingServiceProvider(mlClient: MlClient, datafeedsService: Da
score: {
script: {
lang: 'painless',
source: 'Math.floor(doc["influencer_score"].value)',
source: `Math.floor(doc["${influencerScoreField}"].value)`,
},
},
unique_key: {
@ -159,7 +168,7 @@ export function alertingServiceProvider(mlClient: MlClient, datafeedsService: Da
record_results: {
filter: {
range: {
record_score: {
[recordScoreField]: {
gte: resultType === ANOMALY_RESULT_TYPE.RECORD ? severity : 0,
},
},
@ -169,7 +178,7 @@ export function alertingServiceProvider(mlClient: MlClient, datafeedsService: Da
top_hits: {
sort: [
{
record_score: {
[recordScoreField]: {
order: 'desc',
},
},
@ -198,7 +207,7 @@ export function alertingServiceProvider(mlClient: MlClient, datafeedsService: Da
score: {
script: {
lang: 'painless',
source: 'Math.floor(doc["record_score"].value)',
source: `Math.floor(doc["${recordScoreField}"].value)`,
},
},
unique_key: {
@ -217,7 +226,7 @@ export function alertingServiceProvider(mlClient: MlClient, datafeedsService: Da
bucket_results: {
filter: {
range: {
anomaly_score: {
[bucketScoreField]: {
gt: severity,
},
},
@ -227,7 +236,7 @@ export function alertingServiceProvider(mlClient: MlClient, datafeedsService: Da
top_hits: {
sort: [
{
anomaly_score: {
[bucketScoreField]: {
order: 'desc',
},
},
@ -247,7 +256,7 @@ export function alertingServiceProvider(mlClient: MlClient, datafeedsService: Da
score: {
script: {
lang: 'painless',
source: 'Math.floor(doc["anomaly_score"].value)',
source: `Math.floor(doc["${bucketScoreField}"].value)`,
},
},
unique_key: {
@ -273,6 +282,18 @@ export function alertingServiceProvider(mlClient: MlClient, datafeedsService: Da
return source.job_id;
};
const getRecordKey = (source: AnomalyRecordDoc): string => {
let alertInstanceKey = `${source.job_id}_${source.timestamp}`;
const fieldName = getEntityFieldName(source);
const fieldValue = getEntityFieldValue(source);
const entity =
fieldName !== undefined && fieldValue !== undefined ? `_${fieldName}_${fieldValue}` : '';
alertInstanceKey += `_${source.detector_index}_${source.function}${entity}`;
return alertInstanceKey;
};
const getResultsFormatter = (resultType: AnomalyResultType) => {
const resultsLabel = getAggResultsLabel(resultType);
return (v: AggResultsResponse): AlertExecutionResult | undefined => {
@ -306,7 +327,7 @@ export function alertingServiceProvider(mlClient: MlClient, datafeedsService: Da
return {
...h._source,
score: h.fields.score[0],
unique_key: h.fields.unique_key[0],
unique_key: getRecordKey(h._source),
};
}) as RecordAnomalyAlertDoc[],
topInfluencers: v.influencer_results.top_influencer_hits.hits.hits.map((h) => {
@ -404,11 +425,11 @@ export function alertingServiceProvider(mlClient: MlClient, datafeedsService: Da
alerts_over_time: {
date_histogram: {
field: 'timestamp',
fixed_interval: lookBackTimeInterval,
fixed_interval: `${maxBucket}s`,
// Ignore empty buckets
min_doc_count: 1,
},
aggs: getResultTypeAggRequest(params.resultType, params.severity),
aggs: getResultTypeAggRequest(params.resultType, params.severity, true),
},
}
: getResultTypeAggRequest(params.resultType, params.severity),

View file

@ -119,7 +119,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
await ml.alerting.assertPreviewButtonState(false);
await ml.alerting.setTestInterval('2y');
await ml.alerting.assertPreviewButtonState(true);
await ml.alerting.checkPreview('Triggers 2 times in the last 2y');
await ml.alerting.checkPreview('Found 13 anomalies in the last 2y.');
await ml.testExecution.logTestStep('should create an alert');
await pageObjects.triggersActionsUI.setAlertName('ml-test-alert');