Skip to content

Commit

Permalink
[Response Ops][Alerting] Provide the latest alert payload on reportin…
Browse files Browse the repository at this point in the history
…g an existing alert (elastic#174919)

## Summary

Closes elastic#169631 

- Updates the `report` method of the `AlertsClient` to optionally return
the latest alert payload for reported alert instance
- Updates the `kibana.alert.anomaly_score` field to an array type to
preserve an anomaly score history

### Checklist

- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [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

### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
  • Loading branch information
darnautov authored Jan 25, 2024
1 parent 1d2dc7c commit d497e4b
Show file tree
Hide file tree
Showing 12 changed files with 211 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ const MlAnomalyDetectionAlertRequired = rt.type({
});
// prettier-ignore
const MlAnomalyDetectionAlertOptional = rt.partial({
'kibana.alert.anomaly_score': schemaNumber,
'kibana.alert.anomaly_score': schemaNumberArray,
'kibana.alert.anomaly_timestamp': schemaDate,
'kibana.alert.is_interim': schemaBoolean,
'kibana.alert.top_influencers': rt.array(
Expand Down
93 changes: 93 additions & 0 deletions x-pack/plugins/alerting/server/alerts_client/alerts_client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2219,6 +2219,99 @@ describe('Alerts Client', () => {
],
});
});

test('should return undefined if an alert doc with provided id do not exist', async () => {
const mockAlertPayload = { count: 1, url: `https://url1` };
const alertInstanceId = 'existing_alert';
const alertSource = {
...mockAlertPayload,
[ALERT_INSTANCE_ID]: alertInstanceId,
};

clusterClient.search.mockResolvedValue({
took: 10,
timed_out: false,
_shards: { failed: 0, successful: 1, total: 1, skipped: 0 },
hits: {
total: { relation: 'eq', value: 1 },
hits: [
{
_id: 'abc',
_index: '.internal.alerts-test.alerts-default-000001',
_source: alertSource,
},
],
},
});

const alertsClient = new AlertsClient<{}, {}, {}, 'default', 'recovered'>(
alertsClientParams
);

await alertsClient.initializeExecution({
...defaultExecutionOpts,
activeAlertsFromState: {
[alertInstanceId]: {},
},
});

const { alertDoc } = alertsClient.report({
id: 'another_alert',
actionGroup: 'default',
state: {},
context: {},
});

expect(alertDoc).toBeUndefined();
});

test('should return a previous alert payload if one exists', async () => {
const mockAlertPayload = { count: 1, url: `https://url1` };
const alertInstanceId = 'existing_alert';
const alertSource = {
...mockAlertPayload,
[ALERT_INSTANCE_ID]: alertInstanceId,
};

clusterClient.search.mockResolvedValue({
took: 10,
timed_out: false,
_shards: { failed: 0, successful: 1, total: 1, skipped: 0 },
hits: {
total: { relation: 'eq', value: 1 },
hits: [
{
_id: 'abc',
_index: '.internal.alerts-test.alerts-default-000001',
_source: alertSource,
},
],
},
});

const alertsClient = new AlertsClient<
{ count: number; url: string },
{},
{},
'default',
'recovered'
>(alertsClientParams);

await alertsClient.initializeExecution({
...defaultExecutionOpts,
activeAlertsFromState: {
[alertInstanceId]: {},
},
});

// Report the same alert again
const { alertDoc } = alertsClient.report({
id: alertInstanceId,
actionGroup: 'default',
});

expect(alertDoc).toEqual(alertSource);
});
});

describe('setAlertData()', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ export class AlertsClient<
LegacyContext,
WithoutReservedActionGroups<ActionGroupIds, RecoveryActionGroupId>
>
): ReportedAlertData {
): ReportedAlertData<AlertData> {
const context = alert.context ? alert.context : ({} as LegacyContext);
const state = !isEmpty(alert.state) ? alert.state : null;

Expand All @@ -245,6 +245,7 @@ export class AlertsClient<
return {
uuid: legacyAlert.getUuid(),
start: legacyAlert.getStart() ?? this.startedAtString,
alertDoc: this.fetchedAlerts.data[alert.id],
};
}

Expand Down
7 changes: 5 additions & 2 deletions x-pack/plugins/alerting/server/alerts_client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,9 @@ export interface PublicAlertsClient<
Context extends AlertInstanceContext,
ActionGroupIds extends string
> {
report(alert: ReportedAlert<AlertData, State, Context, ActionGroupIds>): ReportedAlertData;
report(
alert: ReportedAlert<AlertData, State, Context, ActionGroupIds>
): ReportedAlertData<AlertData>;
setAlertData(alert: UpdateableAlert<AlertData, State, Context, ActionGroupIds>): void;
getAlertLimitValue: () => number;
setAlertLimitReached: (reached: boolean) => void;
Expand Down Expand Up @@ -178,9 +180,10 @@ export interface RecoveredAlertData<
hit?: AlertData;
}

export interface ReportedAlertData {
export interface ReportedAlertData<AlertData> {
uuid: string;
start: string | null;
alertDoc?: AlertData;
}

export type UpdateableAlert<
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ export function registerAlertsTableConfiguration(
}),
initialWidth: 150,
isSortable: true,
schema: 'numeric',
},
{
id: ALERT_START,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* 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 { EuiHealth } from '@elastic/eui';
import { type FieldFormatsRegistry } from '@kbn/field-formats-plugin/common';
import { fieldFormatsServiceMock } from '@kbn/field-formats-plugin/public/mocks';
import React from 'react';
import { ALERT_ANOMALY_SCORE } from '../../../common/constants/alerts';
import { getAlertFormatters } from './render_cell_value';

describe('getAlertFormatters', () => {
const fieldFormatsMock = fieldFormatsServiceMock.createStartContract();

const alertFormatter = getAlertFormatters(fieldFormatsMock as FieldFormatsRegistry);

test('format anomaly score correctly', () => {
expect(alertFormatter(ALERT_ANOMALY_SCORE, 50.3)).toEqual(
<EuiHealth color="#fba740" textSize="xs">
50
</EuiHealth>
);

expect(alertFormatter(ALERT_ANOMALY_SCORE, '50.3,89.6')).toEqual(
<EuiHealth color="#fe5050" textSize="xs">
89
</EuiHealth>
);

expect(alertFormatter(ALERT_ANOMALY_SCORE, '0.7')).toEqual(
<EuiHealth color="#d2e9f7" textSize="xs">
&lt; 1
</EuiHealth>
);

expect(alertFormatter(ALERT_ANOMALY_SCORE, '0')).toEqual(
<EuiHealth color="#d2e9f7" textSize="xs">
&lt; 1
</EuiHealth>
);

expect(alertFormatter(ALERT_ANOMALY_SCORE, '')).toEqual(
<EuiHealth color="#d2e9f7" textSize="xs">
&lt; 1
</EuiHealth>
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { isDefined } from '@kbn/ml-is-defined';
import { ALERT_DURATION, ALERT_END, ALERT_START } from '@kbn/rule-data-utils';
import type { GetRenderCellValue } from '@kbn/triggers-actions-ui-plugin/public';
import { FIELD_FORMAT_IDS, FieldFormatsRegistry } from '@kbn/field-formats-plugin/common';
import { getSeverityColor } from '@kbn/ml-anomaly-utils';
import { getFormattedSeverityScore, getSeverityColor } from '@kbn/ml-anomaly-utils';
import { EuiHealth } from '@elastic/eui';
import {
alertFieldNameMap,
Expand Down Expand Up @@ -74,7 +74,9 @@ export const getRenderCellValue = (fieldFormats: FieldFormatsRegistry): GetRende
export function getAlertFormatters(fieldFormats: FieldFormatsRegistry) {
const getFormatter = getFieldFormatterProvider(fieldFormats);

return (columnId: string, value: any): React.ReactElement => {
return (columnId: string, value: string | number | undefined): React.ReactElement => {
if (!isDefined(value)) return <>{'—'}</>;

switch (columnId) {
case ALERT_START:
case ALERT_END:
Expand All @@ -90,9 +92,16 @@ export function getAlertFormatters(fieldFormats: FieldFormatsRegistry) {
</>
);
case ALERT_ANOMALY_SCORE:
let latestValue: number;
if (typeof value === 'number') {
latestValue = value;
} else {
const resultValue: number[] = value.split(',').map(Number);
latestValue = resultValue.at(-1) as number;
}
return (
<EuiHealth textSize={'xs'} color={getSeverityColor(value)}>
{getFormatter(FIELD_FORMAT_IDS.NUMBER)(value)}
<EuiHealth textSize={'xs'} color={getSeverityColor(latestValue)}>
{getFormattedSeverityScore(latestValue)}
</EuiHealth>
);
default:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ export const getAlertFlyout =
<EuiPanel>
<EuiDescriptionList
listItems={columns.map((column) => {
const value = get(alert, column.id)?.[0];
const alertFieldValue = get(alert, column.id);
const value = (
Array.isArray(alertFieldValue) ? alertFieldValue.at(-1) : alertFieldValue
) as string;

return {
title: column.displayAsText as string,
Expand Down
4 changes: 2 additions & 2 deletions x-pack/plugins/ml/server/lib/alerts/alerting_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,7 @@ export function alertingServiceProvider(
job_id: [...new Set(requestedAnomalies.map((h) => h._source.job_id))][0],
is_interim: requestedAnomalies.some((h) => h._source.is_interim),
anomaly_timestamp: timestamp,
anomaly_score: topAnomaly._source[getScoreFields(resultType, useInitialScore)],
anomaly_score: [topAnomaly._source[getScoreFields(resultType, useInitialScore)]],
top_records: v.record_results.top_record_hits.hits.hits.map((h) => {
const { actual, typical } = getTypicalAndActualValues(h._source);
return pick<RecordAnomalyAlertDoc>(
Expand Down Expand Up @@ -1015,7 +1015,7 @@ export function alertingServiceProvider(
'xpack.ml.alertTypes.anomalyDetectionAlertingRule.recoveredReason',
{
defaultMessage:
'No anomalies have been found in the concecutive bucket after the alert was triggered.',
'No anomalies have been found in the consecutive bucket after the alert was triggered.',
}
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { i18n } from '@kbn/i18n';
import { takeRight } from 'lodash';
import { DEFAULT_APP_CATEGORIES, KibanaRequest } from '@kbn/core/server';
import type {
ActionGroup,
Expand Down Expand Up @@ -57,7 +58,7 @@ export type AnomalyDetectionAlertBaseContext = AlertInstanceContext & {
// Flattened alert payload for alert-as-data
export type AnomalyDetectionAlertPayload = {
job_id: string;
anomaly_score?: number;
anomaly_score?: number[];
is_interim?: boolean;
anomaly_timestamp?: number;
top_records?: any;
Expand Down Expand Up @@ -91,6 +92,8 @@ export type AnomalyScoreMatchGroupId = typeof ANOMALY_SCORE_MATCH_GROUP_ID;

export const ANOMALY_DETECTION_AAD_INDEX_NAME = 'ml.anomaly-detection';

const ANOMALY_SCORE_HISTORY_LIMIT = 20;

export const ANOMALY_DETECTION_AAD_CONFIG: IRuleTypeAlerts<MlAnomalyDetectionAlert> = {
context: ANOMALY_DETECTION_AAD_INDEX_NAME,
mappings: {
Expand All @@ -100,7 +103,7 @@ export const ANOMALY_DETECTION_AAD_CONFIG: IRuleTypeAlerts<MlAnomalyDetectionAle
array: false,
required: true,
},
[ALERT_ANOMALY_SCORE]: { type: ES_FIELD_TYPES.DOUBLE, array: false, required: false },
[ALERT_ANOMALY_SCORE]: { type: ES_FIELD_TYPES.DOUBLE, array: true, required: false },
[ALERT_ANOMALY_IS_INTERIM]: { type: ES_FIELD_TYPES.BOOLEAN, array: false, required: false },
[ALERT_ANOMALY_TIMESTAMP]: { type: ES_FIELD_TYPES.DATE, array: false, required: false },
[ALERT_TOP_RECORDS]: {
Expand Down Expand Up @@ -264,20 +267,42 @@ export function registerAnomalyDetectionAlertType({
const { isHealthy, name, context, payload } = executionResult;

if (!isHealthy) {
alertsClient.report({
const { alertDoc } = alertsClient.report({
id: name,
actionGroup: ANOMALY_SCORE_MATCH_GROUP_ID,
});

let resultPayload = {
[ALERT_URL]: payload[ALERT_URL],
[ALERT_REASON]: payload[ALERT_REASON],
[ALERT_ANOMALY_DETECTION_JOB_ID]: payload.job_id,
[ALERT_ANOMALY_SCORE]: payload.anomaly_score,
[ALERT_ANOMALY_IS_INTERIM]: payload.is_interim,
[ALERT_ANOMALY_TIMESTAMP]: payload.anomaly_timestamp,
[ALERT_TOP_RECORDS]: payload.top_records,
[ALERT_TOP_INFLUENCERS]: payload.top_influencers,
[ALERT_ANOMALY_SCORE]: payload.anomaly_score,
};

if (alertDoc) {
let anomalyScore = alertDoc[ALERT_ANOMALY_SCORE] ?? [];
if (typeof anomalyScore === 'number') {
// alert doc has been created before 8.13 with the latest anomaly score only
anomalyScore = [anomalyScore];
}
resultPayload = {
...resultPayload,
[ALERT_ANOMALY_SCORE]: takeRight(
[...anomalyScore, ...(payload.anomaly_score ?? [])],
ANOMALY_SCORE_HISTORY_LIMIT
),
};
}

alertsClient.setAlertData({
id: name,
context,
payload: {
[ALERT_URL]: payload[ALERT_URL],
[ALERT_REASON]: payload[ALERT_REASON],
[ALERT_ANOMALY_DETECTION_JOB_ID]: payload.job_id,
[ALERT_ANOMALY_SCORE]: payload.anomaly_score,
[ALERT_ANOMALY_IS_INTERIM]: payload.is_interim,
[ALERT_ANOMALY_TIMESTAMP]: payload.anomaly_timestamp,
[ALERT_TOP_RECORDS]: payload.top_records,
[ALERT_TOP_INFLUENCERS]: payload.top_influencers,
},
payload: resultPayload,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export default function alertTests({ getService }: FtrProviderContext) {
expect(doc._source['kibana.alert.url']).to.contain(
'/s/space1/app/ml/explorer/?_g=(ml%3A(jobIds%3A!(rt-anomaly-mean-value))'
);
expect(doc._source['kibana.alert.anomaly_score'][0]).to.be.above(0);
}
});

Expand Down

0 comments on commit d497e4b

Please sign in to comment.