mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[ML] Fix alerting rule preview (#98907)
This commit is contained in:
parent
d9bc163603
commit
4686f442ee
6 changed files with 136 additions and 65 deletions
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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');
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue