Refactor action messaging to report on No Data state (#64365)

This commit is contained in:
Zacqary Adam Xeper 2020-04-30 10:51:05 -05:00 committed by GitHub
parent d3ba5b5a55
commit c131cb341b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 239 additions and 54 deletions

View file

@ -13,6 +13,9 @@ import {
EuiText,
EuiFormRow,
EuiButtonEmpty,
EuiCheckbox,
EuiToolTip,
EuiIcon,
EuiFieldSearch,
} from '@elastic/eui';
import { IFieldType } from 'src/plugins/data/public';
@ -57,6 +60,7 @@ interface Props {
groupBy?: string;
filterQuery?: string;
sourceId?: string;
alertOnNoData?: boolean;
};
alertsContext: AlertsContextValue<AlertContextMeta>;
setAlertParams(key: string, value: any): void;
@ -282,6 +286,28 @@ export const Expressions: React.FC<Props> = props => {
</EuiButtonEmpty>
</div>
<EuiSpacer size={'m'} />
<EuiCheckbox
id="metrics-alert-no-data-toggle"
label={
<>
{i18n.translate('xpack.infra.metrics.alertFlyout.alertOnNoData', {
defaultMessage: "Alert me if there's no data",
})}{' '}
<EuiToolTip
content={i18n.translate('xpack.infra.metrics.alertFlyout.noDataHelpText', {
defaultMessage:
'Enable this to trigger the action if the metric(s) do not report any data over the expected time period, or if the alert fails to query Elasticsearch',
})}
>
<EuiIcon type="questionInCircle" color="subdued" />
</EuiToolTip>
</>
}
checked={alertParams.alertOnNoData}
onChange={e => setAlertParams('alertOnNoData', e.target.checked)}
/>
<EuiSpacer size={'m'} />
<EuiFormRow

View file

@ -23,10 +23,10 @@ export function getAlertType(): AlertTypeModel {
defaultActionMessage: i18n.translate(
'xpack.infra.metrics.alerting.threshold.defaultActionMessage',
{
defaultMessage: `\\{\\{alertName\\}\\} - \\{\\{context.group\\}\\}
defaultMessage: `\\{\\{alertName\\}\\} - \\{\\{context.group\\}\\} is in a state of \\{\\{context.alertState\\}\\}
\\{\\{context.metricOf.condition0\\}\\} has crossed a threshold of \\{\\{context.thresholdOf.condition0\\}\\}
Current value is \\{\\{context.valueOf.condition0\\}\\}
Reason:
\\{\\{context.reason\\}\\}
`,
}
),

View file

@ -0,0 +1,109 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { Comparator, AlertStates } from './types';
export const DOCUMENT_COUNT_I18N = i18n.translate(
'xpack.infra.metrics.alerting.threshold.documentCount',
{
defaultMessage: 'Document count',
}
);
export const stateToAlertMessage = {
[AlertStates.ALERT]: i18n.translate('xpack.infra.metrics.alerting.threshold.alertState', {
defaultMessage: 'ALERT',
}),
[AlertStates.NO_DATA]: i18n.translate('xpack.infra.metrics.alerting.threshold.noDataState', {
defaultMessage: 'NO DATA',
}),
[AlertStates.ERROR]: i18n.translate('xpack.infra.metrics.alerting.threshold.errorState', {
defaultMessage: 'ERROR',
}),
// TODO: Implement recovered message state
[AlertStates.OK]: i18n.translate('xpack.infra.metrics.alerting.threshold.okState', {
defaultMessage: 'OK [Recovered]',
}),
};
const comparatorToI18n = (comparator: Comparator, threshold: number[], currentValue: number) => {
const gtText = i18n.translate('xpack.infra.metrics.alerting.threshold.gtComparator', {
defaultMessage: 'greater than',
});
const ltText = i18n.translate('xpack.infra.metrics.alerting.threshold.ltComparator', {
defaultMessage: 'less than',
});
const eqText = i18n.translate('xpack.infra.metrics.alerting.threshold.eqComparator', {
defaultMessage: 'equal to',
});
switch (comparator) {
case Comparator.BETWEEN:
return i18n.translate('xpack.infra.metrics.alerting.threshold.betweenComparator', {
defaultMessage: 'between',
});
case Comparator.OUTSIDE_RANGE:
return i18n.translate('xpack.infra.metrics.alerting.threshold.outsideRangeComparator', {
defaultMessage: 'not between',
});
case Comparator.GT:
return gtText;
case Comparator.LT:
return ltText;
case Comparator.GT_OR_EQ:
case Comparator.LT_OR_EQ:
if (threshold[0] === currentValue) return eqText;
else if (threshold[0] < currentValue) return ltText;
return gtText;
}
};
const thresholdToI18n = ([a, b]: number[]) => {
if (typeof b === 'undefined') return a;
return i18n.translate('xpack.infra.metrics.alerting.threshold.thresholdRange', {
defaultMessage: '{a} and {b}',
values: { a, b },
});
};
export const buildFiredAlertReason: (alertResult: {
metric: string;
comparator: Comparator;
threshold: number[];
currentValue: number;
}) => string = ({ metric, comparator, threshold, currentValue }) =>
i18n.translate('xpack.infra.metrics.alerting.threshold.firedAlertReason', {
defaultMessage:
'{metric} is {comparator} a threshold of {threshold} (current value is {currentValue})',
values: {
metric,
comparator: comparatorToI18n(comparator, threshold, currentValue),
threshold: thresholdToI18n(threshold),
currentValue,
},
});
export const buildNoDataAlertReason: (alertResult: {
metric: string;
timeSize: number;
timeUnit: string;
}) => string = ({ metric, timeSize, timeUnit }) =>
i18n.translate('xpack.infra.metrics.alerting.threshold.noDataAlertReason', {
defaultMessage: '{metric} has reported no data over the past {interval}',
values: {
metric,
interval: `${timeSize}${timeUnit}`,
},
});
export const buildErrorAlertReason = (metric: string) =>
i18n.translate('xpack.infra.metrics.alerting.threshold.errorAlertReason', {
defaultMessage: 'Elasticsearch failed when attempting to query data for {metric}',
values: {
metric,
},
});

View file

@ -34,6 +34,8 @@ services.callCluster.mockImplementation(async (_: string, { body, index }: any)
}
if (metric === 'test.metric.2') {
return mocks.alternateMetricResponse;
} else if (metric === 'test.metric.3') {
return mocks.emptyMetricResponse;
}
return mocks.basicMetricResponse;
});
@ -161,9 +163,9 @@ describe('The metric threshold alert type', () => {
await execute(Comparator.GT, [0.75]);
const { action } = mostRecentAction(instanceID);
expect(action.group).toBe('*');
expect(action.valueOf.condition0).toBe(1);
expect(action.thresholdOf.condition0).toStrictEqual([0.75]);
expect(action.metricOf.condition0).toBe('test.metric.1');
expect(action.reason).toContain('current value is 1');
expect(action.reason).toContain('threshold of 0.75');
expect(action.reason).toContain('test.metric.1');
});
test('fetches the index pattern dynamically', async () => {
await execute(Comparator.LT, [17], 'alternate');
@ -271,12 +273,14 @@ describe('The metric threshold alert type', () => {
const instanceID = 'test-*';
await execute(Comparator.GT_OR_EQ, [1.0], [3.0]);
const { action } = mostRecentAction(instanceID);
expect(action.valueOf.condition0).toBe(1);
expect(action.valueOf.condition1).toBe(3.5);
expect(action.thresholdOf.condition0).toStrictEqual([1.0]);
expect(action.thresholdOf.condition1).toStrictEqual([3.0]);
expect(action.metricOf.condition0).toBe('test.metric.1');
expect(action.metricOf.condition1).toBe('test.metric.2');
const reasons = action.reason.split('\n');
expect(reasons.length).toBe(2);
expect(reasons[0]).toContain('test.metric.1');
expect(reasons[1]).toContain('test.metric.2');
expect(reasons[0]).toContain('current value is 1');
expect(reasons[1]).toContain('current value is 3.5');
expect(reasons[0]).toContain('threshold of 1');
expect(reasons[1]).toContain('threshold of 3');
});
});
describe('querying with the count aggregator', () => {
@ -305,4 +309,32 @@ describe('The metric threshold alert type', () => {
expect(getState(instanceID).alertState).toBe(AlertStates.OK);
});
});
describe("querying a metric that hasn't reported data", () => {
const instanceID = 'test-*';
const execute = (alertOnNoData: boolean) =>
executor({
services,
params: {
criteria: [
{
...baseCriterion,
comparator: Comparator.GT,
threshold: 1,
metric: 'test.metric.3',
},
],
alertOnNoData,
},
});
test('sends a No Data alert when configured to do so', async () => {
await execute(true);
expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id);
expect(getState(instanceID).alertState).toBe(AlertStates.NO_DATA);
});
test('does not send a No Data alert when not configured to do so', async () => {
await execute(false);
expect(mostRecentAction(instanceID)).toBe(undefined);
expect(getState(instanceID).alertState).toBe(AlertStates.NO_DATA);
});
});
});

View file

@ -12,6 +12,13 @@ import { createAfterKeyHandler } from '../../../utils/create_afterkey_handler';
import { getAllCompositeData } from '../../../utils/get_all_composite_data';
import { networkTraffic } from '../../../../common/inventory_models/shared/metrics/snapshot/network_traffic';
import { MetricExpressionParams, Comparator, Aggregators, AlertStates } from './types';
import {
buildErrorAlertReason,
buildFiredAlertReason,
buildNoDataAlertReason,
DOCUMENT_COUNT_I18N,
stateToAlertMessage,
} from './messages';
import { AlertServices, AlertExecutorOptions } from '../../../../../alerting/server';
import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds';
import { getDateHistogramOffset } from '../../snapshot/query_helpers';
@ -258,24 +265,14 @@ const comparatorMap = {
[Comparator.LT_OR_EQ]: (a: number, [b]: number[]) => a <= b,
};
const mapToConditionsLookup = (
list: any[],
mapFn: (value: any, index: number, array: any[]) => unknown
) =>
list
.map(mapFn)
.reduce(
(result: Record<string, any>, value, i) => ({ ...result, [`condition${i}`]: value }),
{}
);
export const createMetricThresholdExecutor = (alertUUID: string) =>
async function({ services, params }: AlertExecutorOptions) {
const { criteria, groupBy, filterQuery, sourceId } = params as {
const { criteria, groupBy, filterQuery, sourceId, alertOnNoData } = params as {
criteria: MetricExpressionParams[];
groupBy: string | undefined;
filterQuery: string | undefined;
sourceId?: string;
alertOnNoData: boolean;
};
const alertResults = await Promise.all(
@ -286,9 +283,11 @@ export const createMetricThresholdExecutor = (alertUUID: string) =>
const { threshold, comparator } = criterion;
const comparisonFunction = comparatorMap[comparator];
return mapValues(currentValues, value => ({
...criterion,
metric: criterion.metric ?? DOCUMENT_COUNT_I18N,
currentValue: value,
shouldFire:
value !== undefined && value !== null && comparisonFunction(value, threshold),
currentValue: value,
isNoData: value === null,
isError: value === undefined,
}));
@ -306,23 +305,43 @@ export const createMetricThresholdExecutor = (alertUUID: string) =>
// whole alert is in a No Data/Error state
const isNoData = alertResults.some(result => result[group].isNoData);
const isError = alertResults.some(result => result[group].isError);
if (shouldAlertFire) {
const nextState = isError
? AlertStates.ERROR
: isNoData
? AlertStates.NO_DATA
: shouldAlertFire
? AlertStates.ALERT
: AlertStates.OK;
let reason;
if (nextState === AlertStates.ALERT) {
reason = alertResults.map(result => buildFiredAlertReason(result[group])).join('\n');
}
if (alertOnNoData) {
if (nextState === AlertStates.NO_DATA) {
reason = alertResults
.filter(result => result[group].isNoData)
.map(result => buildNoDataAlertReason(result[group]))
.join('\n');
} else if (nextState === AlertStates.ERROR) {
reason = alertResults
.filter(result => result[group].isError)
.map(result => buildErrorAlertReason(result[group].metric))
.join('\n');
}
}
if (reason) {
alertInstance.scheduleActions(FIRED_ACTIONS.id, {
group,
valueOf: mapToConditionsLookup(alertResults, result => result[group].currentValue),
thresholdOf: mapToConditionsLookup(criteria, criterion => criterion.threshold),
metricOf: mapToConditionsLookup(criteria, criterion => criterion.metric),
alertState: stateToAlertMessage[nextState],
reason,
});
}
// Future use: ability to fetch display current alert state
alertInstance.replaceState({
alertState: isError
? AlertStates.ERROR
: isNoData
? AlertStates.NO_DATA
: shouldAlertFire
? AlertStates.ALERT
: AlertStates.OK,
alertState: nextState,
});
}
};

View file

@ -55,27 +55,18 @@ export async function registerMetricThresholdAlertType(
}
);
const valueOfActionVariableDescription = i18n.translate(
'xpack.infra.metrics.alerting.threshold.alerting.valueOfActionVariableDescription',
const alertStateActionVariableDescription = i18n.translate(
'xpack.infra.metrics.alerting.threshold.alerting.alertStateActionVariableDescription',
{
defaultMessage:
'Record of the current value of the watched metric; grouped by condition, i.e valueOf.condition0, valueOf.condition1, etc.',
defaultMessage: 'Current state of the alert',
}
);
const thresholdOfActionVariableDescription = i18n.translate(
'xpack.infra.metrics.alerting.threshold.alerting.thresholdOfActionVariableDescription',
const reasonActionVariableDescription = i18n.translate(
'xpack.infra.metrics.alerting.threshold.alerting.reasonActionVariableDescription',
{
defaultMessage:
'Record of the alerting threshold; grouped by condition, i.e thresholdOf.condition0, thresholdOf.condition1, etc.',
}
);
const metricOfActionVariableDescription = i18n.translate(
'xpack.infra.metrics.alerting.threshold.alerting.metricOfActionVariableDescription',
{
defaultMessage:
'Record of the watched metric; grouped by condition, i.e metricOf.condition0, metricOf.condition1, etc.',
'A description of why the alert is in this state, including which metrics have crossed which thresholds',
}
);
@ -88,6 +79,7 @@ export async function registerMetricThresholdAlertType(
groupBy: schema.maybe(schema.string()),
filterQuery: schema.maybe(schema.string()),
sourceId: schema.string(),
alertOnNoData: schema.maybe(schema.boolean()),
}),
},
defaultActionGroupId: FIRED_ACTIONS.id,
@ -96,9 +88,8 @@ export async function registerMetricThresholdAlertType(
actionVariables: {
context: [
{ name: 'group', description: groupActionVariableDescription },
{ name: 'valueOf', description: valueOfActionVariableDescription },
{ name: 'thresholdOf', description: thresholdOfActionVariableDescription },
{ name: 'metricOf', description: metricOfActionVariableDescription },
{ name: 'alertState', description: alertStateActionVariableDescription },
{ name: 'reason', description: reasonActionVariableDescription },
],
},
});

View file

@ -53,6 +53,14 @@ export const alternateMetricResponse = {
},
};
export const emptyMetricResponse = {
aggregations: {
aggregatedIntervals: {
buckets: [],
},
},
};
export const basicCompositeResponse = {
aggregations: {
groupings: {