[Logs UI] [Alerting] "Group by" functionality (#68250)
- Add "group by" functionality to logs alerts
This commit is contained in:
parent
a40e58e898
commit
ceb8595151
|
@ -4,6 +4,8 @@
|
||||||
* you may not use this file except in compliance with the Elastic License.
|
* you may not use this file except in compliance with the Elastic License.
|
||||||
*/
|
*/
|
||||||
import { i18n } from '@kbn/i18n';
|
import { i18n } from '@kbn/i18n';
|
||||||
|
import * as rt from 'io-ts';
|
||||||
|
import { commonSearchSuccessResponseFieldsRT } from '../../utils/elasticsearch_runtime_types';
|
||||||
|
|
||||||
export const LOG_DOCUMENT_COUNT_ALERT_TYPE_ID = 'logs.alert.document.count';
|
export const LOG_DOCUMENT_COUNT_ALERT_TYPE_ID = 'logs.alert.document.count';
|
||||||
|
|
||||||
|
@ -20,6 +22,19 @@ export enum Comparator {
|
||||||
NOT_MATCH_PHRASE = 'does not match phrase',
|
NOT_MATCH_PHRASE = 'does not match phrase',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ComparatorRT = rt.keyof({
|
||||||
|
[Comparator.GT]: null,
|
||||||
|
[Comparator.GT_OR_EQ]: null,
|
||||||
|
[Comparator.LT]: null,
|
||||||
|
[Comparator.LT_OR_EQ]: null,
|
||||||
|
[Comparator.EQ]: null,
|
||||||
|
[Comparator.NOT_EQ]: null,
|
||||||
|
[Comparator.MATCH]: null,
|
||||||
|
[Comparator.NOT_MATCH]: null,
|
||||||
|
[Comparator.MATCH_PHRASE]: null,
|
||||||
|
[Comparator.NOT_MATCH_PHRASE]: null,
|
||||||
|
});
|
||||||
|
|
||||||
// Maps our comparators to i18n strings, some comparators have more specific wording
|
// Maps our comparators to i18n strings, some comparators have more specific wording
|
||||||
// depending on the field type the comparator is being used with.
|
// depending on the field type the comparator is being used with.
|
||||||
export const ComparatorToi18nMap = {
|
export const ComparatorToi18nMap = {
|
||||||
|
@ -74,22 +89,78 @@ export enum AlertStates {
|
||||||
ERROR,
|
ERROR,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentCount {
|
const DocumentCountRT = rt.type({
|
||||||
comparator: Comparator;
|
comparator: ComparatorRT,
|
||||||
value: number;
|
value: rt.number,
|
||||||
}
|
});
|
||||||
|
|
||||||
export interface Criterion {
|
export type DocumentCount = rt.TypeOf<typeof DocumentCountRT>;
|
||||||
field: string;
|
|
||||||
comparator: Comparator;
|
|
||||||
value: string | number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LogDocumentCountAlertParams {
|
const CriterionRT = rt.type({
|
||||||
count: DocumentCount;
|
field: rt.string,
|
||||||
criteria: Criterion[];
|
comparator: ComparatorRT,
|
||||||
timeUnit: 's' | 'm' | 'h' | 'd';
|
value: rt.union([rt.string, rt.number]),
|
||||||
timeSize: number;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
export type TimeUnit = 's' | 'm' | 'h' | 'd';
|
export type Criterion = rt.TypeOf<typeof CriterionRT>;
|
||||||
|
|
||||||
|
const TimeUnitRT = rt.union([rt.literal('s'), rt.literal('m'), rt.literal('h'), rt.literal('d')]);
|
||||||
|
export type TimeUnit = rt.TypeOf<typeof TimeUnitRT>;
|
||||||
|
|
||||||
|
export const LogDocumentCountAlertParamsRT = rt.intersection([
|
||||||
|
rt.type({
|
||||||
|
count: DocumentCountRT,
|
||||||
|
criteria: rt.array(CriterionRT),
|
||||||
|
timeUnit: TimeUnitRT,
|
||||||
|
timeSize: rt.number,
|
||||||
|
}),
|
||||||
|
rt.partial({
|
||||||
|
groupBy: rt.array(rt.string),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type LogDocumentCountAlertParams = rt.TypeOf<typeof LogDocumentCountAlertParamsRT>;
|
||||||
|
|
||||||
|
export const UngroupedSearchQueryResponseRT = rt.intersection([
|
||||||
|
commonSearchSuccessResponseFieldsRT,
|
||||||
|
rt.type({
|
||||||
|
hits: rt.type({
|
||||||
|
total: rt.type({
|
||||||
|
value: rt.number,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type UngroupedSearchQueryResponse = rt.TypeOf<typeof UngroupedSearchQueryResponseRT>;
|
||||||
|
|
||||||
|
export const GroupedSearchQueryResponseRT = rt.intersection([
|
||||||
|
commonSearchSuccessResponseFieldsRT,
|
||||||
|
rt.type({
|
||||||
|
aggregations: rt.type({
|
||||||
|
groups: rt.intersection([
|
||||||
|
rt.type({
|
||||||
|
buckets: rt.array(
|
||||||
|
rt.type({
|
||||||
|
key: rt.record(rt.string, rt.string),
|
||||||
|
doc_count: rt.number,
|
||||||
|
filtered_results: rt.type({
|
||||||
|
doc_count: rt.number,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
rt.partial({
|
||||||
|
after_key: rt.record(rt.string, rt.string),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
hits: rt.type({
|
||||||
|
total: rt.type({
|
||||||
|
value: rt.number,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type GroupedSearchQueryResponse = rt.TypeOf<typeof GroupedSearchQueryResponseRT>;
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
/*
|
||||||
|
* 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 * as rt from 'io-ts';
|
||||||
|
|
||||||
|
export const commonSearchSuccessResponseFieldsRT = rt.type({
|
||||||
|
_shards: rt.type({
|
||||||
|
total: rt.number,
|
||||||
|
successful: rt.number,
|
||||||
|
skipped: rt.number,
|
||||||
|
failed: rt.number,
|
||||||
|
}),
|
||||||
|
timed_out: rt.boolean,
|
||||||
|
took: rt.number,
|
||||||
|
});
|
|
@ -22,6 +22,7 @@ import { DocumentCount } from './document_count';
|
||||||
import { Criteria } from './criteria';
|
import { Criteria } from './criteria';
|
||||||
import { useSourceId } from '../../../../containers/source_id';
|
import { useSourceId } from '../../../../containers/source_id';
|
||||||
import { LogSourceProvider, useLogSourceContext } from '../../../../containers/logs/log_source';
|
import { LogSourceProvider, useLogSourceContext } from '../../../../containers/logs/log_source';
|
||||||
|
import { GroupByExpression } from '../../shared/group_by_expression/group_by_expression';
|
||||||
|
|
||||||
export interface ExpressionCriteria {
|
export interface ExpressionCriteria {
|
||||||
field?: string;
|
field?: string;
|
||||||
|
@ -121,7 +122,6 @@ export const Editor: React.FC<Props> = (props) => {
|
||||||
const { setAlertParams, alertParams, errors } = props;
|
const { setAlertParams, alertParams, errors } = props;
|
||||||
const [hasSetDefaults, setHasSetDefaults] = useState<boolean>(false);
|
const [hasSetDefaults, setHasSetDefaults] = useState<boolean>(false);
|
||||||
const { sourceStatus } = useLogSourceContext();
|
const { sourceStatus } = useLogSourceContext();
|
||||||
|
|
||||||
useMount(() => {
|
useMount(() => {
|
||||||
for (const [key, value] of Object.entries({ ...DEFAULT_EXPRESSION, ...alertParams })) {
|
for (const [key, value] of Object.entries({ ...DEFAULT_EXPRESSION, ...alertParams })) {
|
||||||
setAlertParams(key, value);
|
setAlertParams(key, value);
|
||||||
|
@ -140,6 +140,17 @@ export const Editor: React.FC<Props> = (props) => {
|
||||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||||
}, [sourceStatus]);
|
}, [sourceStatus]);
|
||||||
|
|
||||||
|
const groupByFields = useMemo(() => {
|
||||||
|
if (sourceStatus?.logIndexFields) {
|
||||||
|
return sourceStatus.logIndexFields.filter((field) => {
|
||||||
|
return field.type === 'string' && field.aggregatable;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||||
|
}, [sourceStatus]);
|
||||||
|
|
||||||
const updateCount = useCallback(
|
const updateCount = useCallback(
|
||||||
(countParams) => {
|
(countParams) => {
|
||||||
const nextCountParams = { ...alertParams.count, ...countParams };
|
const nextCountParams = { ...alertParams.count, ...countParams };
|
||||||
|
@ -172,6 +183,13 @@ export const Editor: React.FC<Props> = (props) => {
|
||||||
[setAlertParams]
|
[setAlertParams]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const updateGroupBy = useCallback(
|
||||||
|
(groups: string[]) => {
|
||||||
|
setAlertParams('groupBy', groups);
|
||||||
|
},
|
||||||
|
[setAlertParams]
|
||||||
|
);
|
||||||
|
|
||||||
const addCriterion = useCallback(() => {
|
const addCriterion = useCallback(() => {
|
||||||
const nextCriteria = alertParams?.criteria
|
const nextCriteria = alertParams?.criteria
|
||||||
? [...alertParams.criteria, DEFAULT_CRITERIA]
|
? [...alertParams.criteria, DEFAULT_CRITERIA]
|
||||||
|
@ -219,6 +237,12 @@ export const Editor: React.FC<Props> = (props) => {
|
||||||
errors={errors as { [key: string]: string[] }}
|
errors={errors as { [key: string]: string[] }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<GroupByExpression
|
||||||
|
selectedGroups={alertParams.groupBy}
|
||||||
|
onChange={updateGroupBy}
|
||||||
|
fields={groupByFields}
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<EuiButtonEmpty
|
<EuiButtonEmpty
|
||||||
color={'primary'}
|
color={'primary'}
|
||||||
|
|
|
@ -22,7 +22,7 @@ export function getAlertType(): AlertTypeModel {
|
||||||
defaultActionMessage: i18n.translate(
|
defaultActionMessage: i18n.translate(
|
||||||
'xpack.infra.logs.alerting.threshold.defaultActionMessage',
|
'xpack.infra.logs.alerting.threshold.defaultActionMessage',
|
||||||
{
|
{
|
||||||
defaultMessage: `\\{\\{context.matchingDocuments\\}\\} log entries have matched the following conditions: \\{\\{context.conditions\\}\\}`,
|
defaultMessage: `\\{\\{#context.group\\}\\}\\{\\{context.group\\}\\} - \\{\\{/context.group\\}\\}\\{\\{context.matchingDocuments\\}\\} log entries have matched the following conditions: \\{\\{context.conditions\\}\\}`,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
requiresAppContext: false,
|
requiresAppContext: false,
|
||||||
|
|
|
@ -0,0 +1,85 @@
|
||||||
|
/*
|
||||||
|
* 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 React, { useState, useMemo } from 'react';
|
||||||
|
import { IFieldType } from 'src/plugins/data/public';
|
||||||
|
import { i18n } from '@kbn/i18n';
|
||||||
|
import {
|
||||||
|
EuiPopoverTitle,
|
||||||
|
EuiFlexItem,
|
||||||
|
EuiFlexGroup,
|
||||||
|
EuiPopover,
|
||||||
|
EuiExpression,
|
||||||
|
} from '@elastic/eui';
|
||||||
|
import { GroupBySelector } from './selector';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedGroups?: string[];
|
||||||
|
fields: IFieldType[];
|
||||||
|
onChange: (groupBy: string[]) => void;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_GROUP_BY_LABEL = i18n.translate('xpack.infra.alerting.alertFlyout.groupByLabel', {
|
||||||
|
defaultMessage: 'Group By',
|
||||||
|
});
|
||||||
|
|
||||||
|
const EVERYTHING_PLACEHOLDER = i18n.translate(
|
||||||
|
'xpack.infra.alerting.alertFlyout.groupBy.placeholder',
|
||||||
|
{
|
||||||
|
defaultMessage: 'Nothing (ungrouped)',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const GroupByExpression: React.FC<Props> = ({
|
||||||
|
selectedGroups = [],
|
||||||
|
fields,
|
||||||
|
label,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
|
||||||
|
const expressionValue = useMemo(() => {
|
||||||
|
return selectedGroups.length > 0 ? selectedGroups.join(', ') : EVERYTHING_PLACEHOLDER;
|
||||||
|
}, [selectedGroups]);
|
||||||
|
|
||||||
|
const labelProp = label ?? DEFAULT_GROUP_BY_LABEL;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EuiFlexGroup gutterSize="s">
|
||||||
|
<EuiFlexItem grow={false}>
|
||||||
|
<EuiPopover
|
||||||
|
id="groupByExpression"
|
||||||
|
button={
|
||||||
|
<EuiExpression
|
||||||
|
description={labelProp}
|
||||||
|
uppercase={true}
|
||||||
|
value={expressionValue}
|
||||||
|
isActive={isPopoverOpen}
|
||||||
|
onClick={() => setIsPopoverOpen(true)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
isOpen={isPopoverOpen}
|
||||||
|
closePopover={() => setIsPopoverOpen(false)}
|
||||||
|
ownFocus
|
||||||
|
panelPaddingSize="s"
|
||||||
|
anchorPosition="downLeft"
|
||||||
|
>
|
||||||
|
<div style={{ zIndex: 11000 }}>
|
||||||
|
<EuiPopoverTitle>{labelProp}</EuiPopoverTitle>
|
||||||
|
<GroupBySelector
|
||||||
|
selectedGroups={selectedGroups}
|
||||||
|
onChange={onChange}
|
||||||
|
fields={fields}
|
||||||
|
label={labelProp}
|
||||||
|
placeholder={EVERYTHING_PLACEHOLDER}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</EuiPopover>
|
||||||
|
</EuiFlexItem>
|
||||||
|
</EuiFlexGroup>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,56 @@
|
||||||
|
/*
|
||||||
|
* 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 { EuiComboBox } from '@elastic/eui';
|
||||||
|
import React, { useCallback, useMemo } from 'react';
|
||||||
|
import { IFieldType } from 'src/plugins/data/public';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedGroups?: string[];
|
||||||
|
onChange: (groupBy: string[]) => void;
|
||||||
|
fields: IFieldType[];
|
||||||
|
label: string;
|
||||||
|
placeholder: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GroupBySelector = ({
|
||||||
|
onChange,
|
||||||
|
fields,
|
||||||
|
selectedGroups = [],
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
}: Props) => {
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(selectedOptions: Array<{ label: string }>) => {
|
||||||
|
const groupBy = selectedOptions.map((option) => option.label);
|
||||||
|
onChange(groupBy);
|
||||||
|
},
|
||||||
|
[onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const formattedSelectedGroups = useMemo(() => {
|
||||||
|
return selectedGroups.map((group) => ({ label: group }));
|
||||||
|
}, [selectedGroups]);
|
||||||
|
|
||||||
|
const options = useMemo(() => {
|
||||||
|
return fields.filter((field) => field.aggregatable).map((field) => ({ label: field.name }));
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minWidth: '300px' }}>
|
||||||
|
<EuiComboBox
|
||||||
|
placeholder={placeholder}
|
||||||
|
aria-label={label}
|
||||||
|
fullWidth
|
||||||
|
singleSelection={false}
|
||||||
|
selectedOptions={formattedSelectedGroups}
|
||||||
|
options={options}
|
||||||
|
onChange={handleChange}
|
||||||
|
isClearable={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -60,6 +60,7 @@ export interface InfraDatabaseSearchResponse<Hit = {}, Aggregations = undefined>
|
||||||
skipped: number;
|
skipped: number;
|
||||||
failed: number;
|
failed: number;
|
||||||
};
|
};
|
||||||
|
timed_out: boolean;
|
||||||
aggregations?: Aggregations;
|
aggregations?: Aggregations;
|
||||||
hits: {
|
hits: {
|
||||||
total: {
|
total: {
|
||||||
|
|
|
@ -55,7 +55,7 @@ services.alertInstanceFactory.mockImplementation((instanceId: string) => {
|
||||||
* Helper functions
|
* Helper functions
|
||||||
*/
|
*/
|
||||||
function getAlertState(instanceId: string): AlertStates {
|
function getAlertState(instanceId: string): AlertStates {
|
||||||
const alert = alertInstances.get(instanceId);
|
const alert = alertInstances.get(`${instanceId}-*`);
|
||||||
if (alert) {
|
if (alert) {
|
||||||
return alert.state.alertState;
|
return alert.state.alertState;
|
||||||
} else {
|
} else {
|
||||||
|
@ -73,11 +73,26 @@ const executor = (createLogThresholdExecutor('test', libsMock) as unknown) as (o
|
||||||
|
|
||||||
// Wrapper to test
|
// Wrapper to test
|
||||||
type Comparison = [number, Comparator, number];
|
type Comparison = [number, Comparator, number];
|
||||||
|
|
||||||
async function callExecutor(
|
async function callExecutor(
|
||||||
[value, comparator, threshold]: Comparison,
|
[value, comparator, threshold]: Comparison,
|
||||||
criteria: Criterion[] = []
|
criteria: Criterion[] = []
|
||||||
) {
|
) {
|
||||||
services.callCluster.mockImplementationOnce(async (..._) => ({ count: value }));
|
services.callCluster.mockImplementationOnce(async (..._) => ({
|
||||||
|
_shards: {
|
||||||
|
total: 1,
|
||||||
|
successful: 1,
|
||||||
|
skipped: 0,
|
||||||
|
failed: 0,
|
||||||
|
},
|
||||||
|
timed_out: false,
|
||||||
|
took: 123456789,
|
||||||
|
hits: {
|
||||||
|
total: {
|
||||||
|
value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
return await executor({
|
return await executor({
|
||||||
services,
|
services,
|
||||||
|
@ -90,222 +105,427 @@ async function callExecutor(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Comparators trigger alerts correctly', () => {
|
describe('Ungrouped alerts', () => {
|
||||||
it('does not alert when counts do not reach the threshold', async () => {
|
describe('Comparators trigger alerts correctly', () => {
|
||||||
await callExecutor([0, Comparator.GT, 1]);
|
it('does not alert when counts do not reach the threshold', async () => {
|
||||||
expect(getAlertState('test')).toBe(AlertStates.OK);
|
await callExecutor([0, Comparator.GT, 1]);
|
||||||
|
expect(getAlertState('test')).toBe(AlertStates.OK);
|
||||||
|
|
||||||
await callExecutor([0, Comparator.GT_OR_EQ, 1]);
|
await callExecutor([0, Comparator.GT_OR_EQ, 1]);
|
||||||
expect(getAlertState('test')).toBe(AlertStates.OK);
|
expect(getAlertState('test')).toBe(AlertStates.OK);
|
||||||
|
|
||||||
await callExecutor([1, Comparator.LT, 0]);
|
await callExecutor([1, Comparator.LT, 0]);
|
||||||
expect(getAlertState('test')).toBe(AlertStates.OK);
|
expect(getAlertState('test')).toBe(AlertStates.OK);
|
||||||
|
|
||||||
await callExecutor([1, Comparator.LT_OR_EQ, 0]);
|
await callExecutor([1, Comparator.LT_OR_EQ, 0]);
|
||||||
expect(getAlertState('test')).toBe(AlertStates.OK);
|
expect(getAlertState('test')).toBe(AlertStates.OK);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('alerts when counts reach the threshold', async () => {
|
it('alerts when counts reach the threshold', async () => {
|
||||||
await callExecutor([2, Comparator.GT, 1]);
|
await callExecutor([2, Comparator.GT, 1]);
|
||||||
expect(getAlertState('test')).toBe(AlertStates.ALERT);
|
expect(getAlertState('test')).toBe(AlertStates.ALERT);
|
||||||
|
|
||||||
await callExecutor([1, Comparator.GT_OR_EQ, 1]);
|
await callExecutor([1, Comparator.GT_OR_EQ, 1]);
|
||||||
expect(getAlertState('test')).toBe(AlertStates.ALERT);
|
expect(getAlertState('test')).toBe(AlertStates.ALERT);
|
||||||
|
|
||||||
await callExecutor([1, Comparator.LT, 2]);
|
await callExecutor([1, Comparator.LT, 2]);
|
||||||
expect(getAlertState('test')).toBe(AlertStates.ALERT);
|
expect(getAlertState('test')).toBe(AlertStates.ALERT);
|
||||||
|
|
||||||
await callExecutor([2, Comparator.LT_OR_EQ, 2]);
|
await callExecutor([2, Comparator.LT_OR_EQ, 2]);
|
||||||
expect(getAlertState('test')).toBe(AlertStates.ALERT);
|
expect(getAlertState('test')).toBe(AlertStates.ALERT);
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Comparators create the correct ES queries', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
services.callCluster.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Works with `Comparator.EQ`', async () => {
|
|
||||||
await callExecutor(
|
|
||||||
[2, Comparator.GT, 1], // Not relevant
|
|
||||||
[{ field: 'foo', comparator: Comparator.EQ, value: 'bar' }]
|
|
||||||
);
|
|
||||||
|
|
||||||
const query = services.callCluster.mock.calls[0][1]!;
|
|
||||||
expect(query.body).toMatchObject({
|
|
||||||
query: {
|
|
||||||
bool: {
|
|
||||||
must: [{ term: { foo: { value: 'bar' } } }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('works with `Comparator.NOT_EQ`', async () => {
|
describe('Comparators create the correct ES queries', () => {
|
||||||
await callExecutor(
|
beforeEach(() => {
|
||||||
[2, Comparator.GT, 1], // Not relevant
|
services.callCluster.mockReset();
|
||||||
[{ field: 'foo', comparator: Comparator.NOT_EQ, value: 'bar' }]
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const query = services.callCluster.mock.calls[0][1]!;
|
it('Works with `Comparator.EQ`', async () => {
|
||||||
expect(query.body).toMatchObject({
|
await callExecutor(
|
||||||
query: {
|
[2, Comparator.GT, 1], // Not relevant
|
||||||
bool: {
|
[{ field: 'foo', comparator: Comparator.EQ, value: 'bar' }]
|
||||||
must_not: [{ term: { foo: { value: 'bar' } } }],
|
);
|
||||||
|
|
||||||
|
const query = services.callCluster.mock.calls[0][1]!;
|
||||||
|
|
||||||
|
expect(query.body).toMatchObject({
|
||||||
|
track_total_hits: true,
|
||||||
|
query: {
|
||||||
|
bool: {
|
||||||
|
filter: [
|
||||||
|
{
|
||||||
|
range: {
|
||||||
|
'@timestamp': {
|
||||||
|
format: 'epoch_millis',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
term: {
|
||||||
|
foo: {
|
||||||
|
value: 'bar',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
size: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with `Comparator.NOT_EQ`', async () => {
|
||||||
|
await callExecutor(
|
||||||
|
[2, Comparator.GT, 1], // Not relevant
|
||||||
|
[{ field: 'foo', comparator: Comparator.NOT_EQ, value: 'bar' }]
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = services.callCluster.mock.calls[0][1]!;
|
||||||
|
|
||||||
|
expect(query.body).toMatchObject({
|
||||||
|
track_total_hits: true,
|
||||||
|
query: {
|
||||||
|
bool: {
|
||||||
|
filter: [
|
||||||
|
{
|
||||||
|
range: {
|
||||||
|
'@timestamp': {
|
||||||
|
format: 'epoch_millis',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
must_not: [
|
||||||
|
{
|
||||||
|
term: {
|
||||||
|
foo: {
|
||||||
|
value: 'bar',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
size: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with `Comparator.MATCH`', async () => {
|
||||||
|
await callExecutor(
|
||||||
|
[2, Comparator.GT, 1], // Not relevant
|
||||||
|
[{ field: 'foo', comparator: Comparator.MATCH, value: 'bar' }]
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = services.callCluster.mock.calls[0][1]!;
|
||||||
|
|
||||||
|
expect(query.body).toMatchObject({
|
||||||
|
track_total_hits: true,
|
||||||
|
query: {
|
||||||
|
bool: {
|
||||||
|
filter: [
|
||||||
|
{
|
||||||
|
range: {
|
||||||
|
'@timestamp': {
|
||||||
|
format: 'epoch_millis',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: {
|
||||||
|
foo: 'bar',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
size: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with `Comparator.NOT_MATCH`', async () => {
|
||||||
|
await callExecutor(
|
||||||
|
[2, Comparator.GT, 1], // Not relevant
|
||||||
|
[{ field: 'foo', comparator: Comparator.NOT_MATCH, value: 'bar' }]
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = services.callCluster.mock.calls[0][1]!;
|
||||||
|
|
||||||
|
expect(query.body).toMatchObject({
|
||||||
|
track_total_hits: true,
|
||||||
|
query: {
|
||||||
|
bool: {
|
||||||
|
filter: [
|
||||||
|
{
|
||||||
|
range: {
|
||||||
|
'@timestamp': {
|
||||||
|
format: 'epoch_millis',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
must_not: [
|
||||||
|
{
|
||||||
|
match: {
|
||||||
|
foo: 'bar',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
size: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with `Comparator.MATCH_PHRASE`', async () => {
|
||||||
|
await callExecutor(
|
||||||
|
[2, Comparator.GT, 1], // Not relevant
|
||||||
|
[{ field: 'foo', comparator: Comparator.MATCH_PHRASE, value: 'bar' }]
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = services.callCluster.mock.calls[0][1]!;
|
||||||
|
|
||||||
|
expect(query.body).toMatchObject({
|
||||||
|
track_total_hits: true,
|
||||||
|
query: {
|
||||||
|
bool: {
|
||||||
|
filter: [
|
||||||
|
{
|
||||||
|
range: {
|
||||||
|
'@timestamp': {
|
||||||
|
format: 'epoch_millis',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match_phrase: {
|
||||||
|
foo: 'bar',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
size: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with `Comparator.NOT_MATCH_PHRASE`', async () => {
|
||||||
|
await callExecutor(
|
||||||
|
[2, Comparator.GT, 1], // Not relevant
|
||||||
|
[{ field: 'foo', comparator: Comparator.NOT_MATCH_PHRASE, value: 'bar' }]
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = services.callCluster.mock.calls[0][1]!;
|
||||||
|
|
||||||
|
expect(query.body).toMatchObject({
|
||||||
|
track_total_hits: true,
|
||||||
|
query: {
|
||||||
|
bool: {
|
||||||
|
filter: [
|
||||||
|
{
|
||||||
|
range: {
|
||||||
|
'@timestamp': {
|
||||||
|
format: 'epoch_millis',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
must_not: [
|
||||||
|
{
|
||||||
|
match_phrase: {
|
||||||
|
foo: 'bar',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
size: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with `Comparator.GT`', async () => {
|
||||||
|
await callExecutor(
|
||||||
|
[2, Comparator.GT, 1], // Not relevant
|
||||||
|
[{ field: 'foo', comparator: Comparator.GT, value: 1 }]
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = services.callCluster.mock.calls[0][1]!;
|
||||||
|
|
||||||
|
expect(query.body).toMatchObject({
|
||||||
|
track_total_hits: true,
|
||||||
|
query: {
|
||||||
|
bool: {
|
||||||
|
filter: [
|
||||||
|
{
|
||||||
|
range: {
|
||||||
|
'@timestamp': {
|
||||||
|
format: 'epoch_millis',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
range: {
|
||||||
|
foo: {
|
||||||
|
gt: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
size: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with `Comparator.GT_OR_EQ`', async () => {
|
||||||
|
await callExecutor(
|
||||||
|
[2, Comparator.GT, 1], // Not relevant
|
||||||
|
[{ field: 'foo', comparator: Comparator.GT_OR_EQ, value: 1 }]
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = services.callCluster.mock.calls[0][1]!;
|
||||||
|
|
||||||
|
expect(query.body).toMatchObject({
|
||||||
|
track_total_hits: true,
|
||||||
|
query: {
|
||||||
|
bool: {
|
||||||
|
filter: [
|
||||||
|
{
|
||||||
|
range: {
|
||||||
|
'@timestamp': {
|
||||||
|
format: 'epoch_millis',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
range: {
|
||||||
|
foo: {
|
||||||
|
gte: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
size: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with `Comparator.LT`', async () => {
|
||||||
|
await callExecutor(
|
||||||
|
[2, Comparator.GT, 1], // Not relevant
|
||||||
|
[{ field: 'foo', comparator: Comparator.LT, value: 1 }]
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = services.callCluster.mock.calls[0][1]!;
|
||||||
|
|
||||||
|
expect(query.body).toMatchObject({
|
||||||
|
track_total_hits: true,
|
||||||
|
query: {
|
||||||
|
bool: {
|
||||||
|
filter: [
|
||||||
|
{
|
||||||
|
range: {
|
||||||
|
'@timestamp': {
|
||||||
|
format: 'epoch_millis',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
range: {
|
||||||
|
foo: {
|
||||||
|
lt: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
size: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with `Comparator.LT_OR_EQ`', async () => {
|
||||||
|
await callExecutor(
|
||||||
|
[2, Comparator.GT, 1], // Not relevant
|
||||||
|
[{ field: 'foo', comparator: Comparator.LT_OR_EQ, value: 1 }]
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = services.callCluster.mock.calls[0][1]!;
|
||||||
|
|
||||||
|
expect(query.body).toMatchObject({
|
||||||
|
track_total_hits: true,
|
||||||
|
query: {
|
||||||
|
bool: {
|
||||||
|
filter: [
|
||||||
|
{
|
||||||
|
range: {
|
||||||
|
'@timestamp': {
|
||||||
|
format: 'epoch_millis',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
range: {
|
||||||
|
foo: {
|
||||||
|
lte: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
size: 0,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('works with `Comparator.MATCH`', async () => {
|
describe('Multiple criteria create the right ES query', () => {
|
||||||
await callExecutor(
|
beforeEach(() => {
|
||||||
[2, Comparator.GT, 1], // Not relevant
|
services.callCluster.mockReset();
|
||||||
[{ field: 'foo', comparator: Comparator.MATCH, value: 'bar' }]
|
|
||||||
);
|
|
||||||
|
|
||||||
const query = services.callCluster.mock.calls[0][1]!;
|
|
||||||
expect(query.body).toMatchObject({
|
|
||||||
query: {
|
|
||||||
bool: {
|
|
||||||
must: [{ match: { foo: 'bar' } }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
it('works', async () => {
|
||||||
|
await callExecutor(
|
||||||
|
[2, Comparator.GT, 1], // Not relevant
|
||||||
|
[
|
||||||
|
{ field: 'foo', comparator: Comparator.EQ, value: 'bar' },
|
||||||
|
{ field: 'http.status', comparator: Comparator.LT, value: 400 },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
it('works with `Comparator.NOT_MATCH`', async () => {
|
const query = services.callCluster.mock.calls[0][1]!;
|
||||||
await callExecutor(
|
|
||||||
[2, Comparator.GT, 1], // Not relevant
|
|
||||||
[{ field: 'foo', comparator: Comparator.NOT_MATCH, value: 'bar' }]
|
|
||||||
);
|
|
||||||
|
|
||||||
const query = services.callCluster.mock.calls[0][1]!;
|
expect(query.body).toMatchObject({
|
||||||
expect(query.body).toMatchObject({
|
track_total_hits: true,
|
||||||
query: {
|
query: {
|
||||||
bool: {
|
bool: {
|
||||||
must_not: [{ match: { foo: 'bar' } }],
|
filter: [
|
||||||
|
{
|
||||||
|
range: {
|
||||||
|
'@timestamp': {
|
||||||
|
format: 'epoch_millis',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
term: {
|
||||||
|
foo: {
|
||||||
|
value: 'bar',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
range: {
|
||||||
|
'http.status': {
|
||||||
|
lt: 400,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
size: 0,
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it('works with `Comparator.MATCH_PHRASE`', async () => {
|
|
||||||
await callExecutor(
|
|
||||||
[2, Comparator.GT, 1], // Not relevant
|
|
||||||
[{ field: 'foo', comparator: Comparator.MATCH_PHRASE, value: 'bar' }]
|
|
||||||
);
|
|
||||||
|
|
||||||
const query = services.callCluster.mock.calls[0][1]!;
|
|
||||||
expect(query.body).toMatchObject({
|
|
||||||
query: {
|
|
||||||
bool: {
|
|
||||||
must: [{ match_phrase: { foo: 'bar' } }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('works with `Comparator.NOT_MATCH_PHRASE`', async () => {
|
|
||||||
await callExecutor(
|
|
||||||
[2, Comparator.GT, 1], // Not relevant
|
|
||||||
[{ field: 'foo', comparator: Comparator.NOT_MATCH_PHRASE, value: 'bar' }]
|
|
||||||
);
|
|
||||||
|
|
||||||
const query = services.callCluster.mock.calls[0][1]!;
|
|
||||||
expect(query.body).toMatchObject({
|
|
||||||
query: {
|
|
||||||
bool: {
|
|
||||||
must_not: [{ match_phrase: { foo: 'bar' } }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('works with `Comparator.GT`', async () => {
|
|
||||||
await callExecutor(
|
|
||||||
[2, Comparator.GT, 1], // Not relevant
|
|
||||||
[{ field: 'foo', comparator: Comparator.GT, value: 1 }]
|
|
||||||
);
|
|
||||||
|
|
||||||
const query = services.callCluster.mock.calls[0][1]!;
|
|
||||||
expect(query.body).toMatchObject({
|
|
||||||
query: {
|
|
||||||
bool: {
|
|
||||||
must: [{ range: { foo: { gt: 1 } } }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('works with `Comparator.GT_OR_EQ`', async () => {
|
|
||||||
await callExecutor(
|
|
||||||
[2, Comparator.GT, 1], // Not relevant
|
|
||||||
[{ field: 'foo', comparator: Comparator.GT_OR_EQ, value: 1 }]
|
|
||||||
);
|
|
||||||
|
|
||||||
const query = services.callCluster.mock.calls[0][1]!;
|
|
||||||
expect(query.body).toMatchObject({
|
|
||||||
query: {
|
|
||||||
bool: {
|
|
||||||
must: [{ range: { foo: { gte: 1 } } }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('works with `Comparator.LT`', async () => {
|
|
||||||
await callExecutor(
|
|
||||||
[2, Comparator.GT, 1], // Not relevant
|
|
||||||
[{ field: 'foo', comparator: Comparator.LT, value: 1 }]
|
|
||||||
);
|
|
||||||
|
|
||||||
const query = services.callCluster.mock.calls[0][1]!;
|
|
||||||
expect(query.body).toMatchObject({
|
|
||||||
query: {
|
|
||||||
bool: {
|
|
||||||
must: [{ range: { foo: { lt: 1 } } }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('works with `Comparator.LT_OR_EQ`', async () => {
|
|
||||||
await callExecutor(
|
|
||||||
[2, Comparator.GT, 1], // Not relevant
|
|
||||||
[{ field: 'foo', comparator: Comparator.LT_OR_EQ, value: 1 }]
|
|
||||||
);
|
|
||||||
|
|
||||||
const query = services.callCluster.mock.calls[0][1]!;
|
|
||||||
expect(query.body).toMatchObject({
|
|
||||||
query: {
|
|
||||||
bool: {
|
|
||||||
must: [{ range: { foo: { lte: 1 } } }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Multiple criteria create the right ES query', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
services.callCluster.mockReset();
|
|
||||||
});
|
|
||||||
it('works', async () => {
|
|
||||||
await callExecutor(
|
|
||||||
[2, Comparator.GT, 1], // Not relevant
|
|
||||||
[
|
|
||||||
{ field: 'foo', comparator: Comparator.EQ, value: 'bar' },
|
|
||||||
{ field: 'http.status', comparator: Comparator.LT, value: 400 },
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const query = services.callCluster.mock.calls[0][1]!;
|
|
||||||
expect(query.body).toMatchObject({
|
|
||||||
query: {
|
|
||||||
bool: {
|
|
||||||
must: [{ term: { foo: { value: 'bar' } } }, { range: { 'http.status': { lt: 400 } } }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -11,10 +11,19 @@ import {
|
||||||
Comparator,
|
Comparator,
|
||||||
LogDocumentCountAlertParams,
|
LogDocumentCountAlertParams,
|
||||||
Criterion,
|
Criterion,
|
||||||
|
GroupedSearchQueryResponseRT,
|
||||||
|
UngroupedSearchQueryResponseRT,
|
||||||
|
UngroupedSearchQueryResponse,
|
||||||
|
GroupedSearchQueryResponse,
|
||||||
|
LogDocumentCountAlertParamsRT,
|
||||||
} from '../../../../common/alerting/logs/types';
|
} from '../../../../common/alerting/logs/types';
|
||||||
import { InfraBackendLibs } from '../../infra_types';
|
import { InfraBackendLibs } from '../../infra_types';
|
||||||
import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds';
|
import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds';
|
||||||
import { InfraSource } from '../../../../common/http_api/source_api';
|
import { InfraSource } from '../../../../common/http_api/source_api';
|
||||||
|
import { decodeOrThrow } from '../../../../common/runtime_types';
|
||||||
|
|
||||||
|
const UNGROUPED_FACTORY_KEY = '*';
|
||||||
|
const COMPOSITE_GROUP_SIZE = 40;
|
||||||
|
|
||||||
const checkValueAgainstComparatorMap: {
|
const checkValueAgainstComparatorMap: {
|
||||||
[key: string]: (a: number, b: number) => boolean;
|
[key: string]: (a: number, b: number) => boolean;
|
||||||
|
@ -25,37 +34,42 @@ const checkValueAgainstComparatorMap: {
|
||||||
[Comparator.LT_OR_EQ]: (a: number, b: number) => a <= b,
|
[Comparator.LT_OR_EQ]: (a: number, b: number) => a <= b,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createLogThresholdExecutor = (alertUUID: string, libs: InfraBackendLibs) =>
|
export const createLogThresholdExecutor = (alertId: string, libs: InfraBackendLibs) =>
|
||||||
async function ({ services, params }: AlertExecutorOptions) {
|
async function ({ services, params }: AlertExecutorOptions) {
|
||||||
const { count, criteria } = params as LogDocumentCountAlertParams;
|
|
||||||
const { alertInstanceFactory, savedObjectsClient, callCluster } = services;
|
const { alertInstanceFactory, savedObjectsClient, callCluster } = services;
|
||||||
const { sources } = libs;
|
const { sources } = libs;
|
||||||
|
const { groupBy } = params;
|
||||||
|
|
||||||
const sourceConfiguration = await sources.getSourceConfiguration(savedObjectsClient, 'default');
|
const sourceConfiguration = await sources.getSourceConfiguration(savedObjectsClient, 'default');
|
||||||
const indexPattern = sourceConfiguration.configuration.logAlias;
|
const indexPattern = sourceConfiguration.configuration.logAlias;
|
||||||
|
const alertInstance = alertInstanceFactory(alertId);
|
||||||
const alertInstance = alertInstanceFactory(alertUUID);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const query = getESQuery(
|
const validatedParams = decodeOrThrow(LogDocumentCountAlertParamsRT)(params);
|
||||||
params as LogDocumentCountAlertParams,
|
|
||||||
sourceConfiguration.configuration
|
|
||||||
);
|
|
||||||
const result = await getResults(query, indexPattern, callCluster);
|
|
||||||
|
|
||||||
if (checkValueAgainstComparatorMap[count.comparator](result.count, count.value)) {
|
const query =
|
||||||
alertInstance.scheduleActions(FIRED_ACTIONS.id, {
|
groupBy && groupBy.length > 0
|
||||||
matchingDocuments: result.count,
|
? getGroupedESQuery(validatedParams, sourceConfiguration.configuration, indexPattern)
|
||||||
conditions: createConditionsMessage(criteria),
|
: getUngroupedESQuery(validatedParams, sourceConfiguration.configuration, indexPattern);
|
||||||
});
|
|
||||||
|
|
||||||
alertInstance.replaceState({
|
if (!query) {
|
||||||
alertState: AlertStates.ALERT,
|
throw new Error('ES query could not be built from the provided alert params');
|
||||||
});
|
}
|
||||||
|
|
||||||
|
if (groupBy && groupBy.length > 0) {
|
||||||
|
processGroupByResults(
|
||||||
|
await getGroupedResults(query, callCluster),
|
||||||
|
validatedParams,
|
||||||
|
alertInstanceFactory,
|
||||||
|
alertId
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
alertInstance.replaceState({
|
processUngroupedResults(
|
||||||
alertState: AlertStates.OK,
|
await getUngroupedResults(query, callCluster),
|
||||||
});
|
validatedParams,
|
||||||
|
alertInstanceFactory,
|
||||||
|
alertId
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alertInstance.replaceState({
|
alertInstance.replaceState({
|
||||||
|
@ -66,27 +80,82 @@ export const createLogThresholdExecutor = (alertUUID: string, libs: InfraBackend
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getESQuery = (
|
const processUngroupedResults = (
|
||||||
|
results: UngroupedSearchQueryResponse,
|
||||||
params: LogDocumentCountAlertParams,
|
params: LogDocumentCountAlertParams,
|
||||||
sourceConfiguration: InfraSource['configuration']
|
alertInstanceFactory: AlertExecutorOptions['services']['alertInstanceFactory'],
|
||||||
): object => {
|
alertId: string
|
||||||
|
) => {
|
||||||
|
const { count, criteria } = params;
|
||||||
|
|
||||||
|
const alertInstance = alertInstanceFactory(`${alertId}-${UNGROUPED_FACTORY_KEY}`);
|
||||||
|
const documentCount = results.hits.total.value;
|
||||||
|
|
||||||
|
if (checkValueAgainstComparatorMap[count.comparator](documentCount, count.value)) {
|
||||||
|
alertInstance.scheduleActions(FIRED_ACTIONS.id, {
|
||||||
|
matchingDocuments: documentCount,
|
||||||
|
conditions: createConditionsMessage(criteria),
|
||||||
|
group: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
alertInstance.replaceState({
|
||||||
|
alertState: AlertStates.ALERT,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
alertInstance.replaceState({
|
||||||
|
alertState: AlertStates.OK,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ReducedGroupByResults {
|
||||||
|
name: string;
|
||||||
|
documentCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processGroupByResults = (
|
||||||
|
results: GroupedSearchQueryResponse['aggregations']['groups']['buckets'],
|
||||||
|
params: LogDocumentCountAlertParams,
|
||||||
|
alertInstanceFactory: AlertExecutorOptions['services']['alertInstanceFactory'],
|
||||||
|
alertId: string
|
||||||
|
) => {
|
||||||
|
const { count, criteria } = params;
|
||||||
|
|
||||||
|
const groupResults = results.reduce<ReducedGroupByResults[]>((acc, groupBucket) => {
|
||||||
|
const groupName = Object.values(groupBucket.key).join(', ');
|
||||||
|
const groupResult = { name: groupName, documentCount: groupBucket.filtered_results.doc_count };
|
||||||
|
return [...acc, groupResult];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
groupResults.forEach((group) => {
|
||||||
|
const alertInstance = alertInstanceFactory(`${alertId}-${group.name}`);
|
||||||
|
const documentCount = group.documentCount;
|
||||||
|
|
||||||
|
if (checkValueAgainstComparatorMap[count.comparator](documentCount, count.value)) {
|
||||||
|
alertInstance.scheduleActions(FIRED_ACTIONS.id, {
|
||||||
|
matchingDocuments: documentCount,
|
||||||
|
conditions: createConditionsMessage(criteria),
|
||||||
|
group: group.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
alertInstance.replaceState({
|
||||||
|
alertState: AlertStates.ALERT,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
alertInstance.replaceState({
|
||||||
|
alertState: AlertStates.OK,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildFiltersFromCriteria = (params: LogDocumentCountAlertParams, timestampField: string) => {
|
||||||
const { timeSize, timeUnit, criteria } = params;
|
const { timeSize, timeUnit, criteria } = params;
|
||||||
const interval = `${timeSize}${timeUnit}`;
|
const interval = `${timeSize}${timeUnit}`;
|
||||||
const intervalAsSeconds = getIntervalInSeconds(interval);
|
const intervalAsSeconds = getIntervalInSeconds(interval);
|
||||||
|
const intervalAsMs = intervalAsSeconds * 1000;
|
||||||
const to = Date.now();
|
const to = Date.now();
|
||||||
const from = to - intervalAsSeconds * 1000;
|
const from = to - intervalAsMs;
|
||||||
|
|
||||||
const rangeFilters = [
|
|
||||||
{
|
|
||||||
range: {
|
|
||||||
[sourceConfiguration.fields.timestamp]: {
|
|
||||||
gte: from,
|
|
||||||
lte: to,
|
|
||||||
format: 'epoch_millis',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const positiveComparators = getPositiveComparators();
|
const positiveComparators = getPositiveComparators();
|
||||||
const negativeComparators = getNegativeComparators();
|
const negativeComparators = getNegativeComparators();
|
||||||
|
@ -101,17 +170,121 @@ const getESQuery = (
|
||||||
// Negative assertions (things that "must not" match)
|
// Negative assertions (things that "must not" match)
|
||||||
const mustNotFilters = buildFiltersForCriteria(negativeCriteria);
|
const mustNotFilters = buildFiltersForCriteria(negativeCriteria);
|
||||||
|
|
||||||
const query = {
|
const rangeFilter = {
|
||||||
query: {
|
range: {
|
||||||
bool: {
|
[timestampField]: {
|
||||||
filter: [...rangeFilters],
|
gte: from,
|
||||||
...(mustFilters.length > 0 && { must: mustFilters }),
|
lte: to,
|
||||||
...(mustNotFilters.length > 0 && { must_not: mustNotFilters }),
|
format: 'epoch_millis',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return query;
|
// For group by scenarios we'll pad the time range by 1 x the interval size on the left (lte) and right (gte), this is so
|
||||||
|
// a wider net is cast to "capture" the groups. This is to account for scenarios where we want ascertain if
|
||||||
|
// there were "no documents" (less than 1 for example). In these cases we may be missing documents to build the groups
|
||||||
|
// and match / not match the criteria.
|
||||||
|
const groupedRangeFilter = {
|
||||||
|
range: {
|
||||||
|
[timestampField]: {
|
||||||
|
gte: from - intervalAsMs,
|
||||||
|
lte: to + intervalAsMs,
|
||||||
|
format: 'epoch_millis',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { rangeFilter, groupedRangeFilter, mustFilters, mustNotFilters };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGroupedESQuery = (
|
||||||
|
params: LogDocumentCountAlertParams,
|
||||||
|
sourceConfiguration: InfraSource['configuration'],
|
||||||
|
index: string
|
||||||
|
): object | undefined => {
|
||||||
|
const { groupBy } = params;
|
||||||
|
|
||||||
|
if (!groupBy || !groupBy.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestampField = sourceConfiguration.fields.timestamp;
|
||||||
|
|
||||||
|
const { rangeFilter, groupedRangeFilter, mustFilters, mustNotFilters } = buildFiltersFromCriteria(
|
||||||
|
params,
|
||||||
|
timestampField
|
||||||
|
);
|
||||||
|
|
||||||
|
const aggregations = {
|
||||||
|
groups: {
|
||||||
|
composite: {
|
||||||
|
size: COMPOSITE_GROUP_SIZE,
|
||||||
|
sources: groupBy.map((field, groupIndex) => ({
|
||||||
|
[`group-${groupIndex}-${field}`]: {
|
||||||
|
terms: { field },
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
aggregations: {
|
||||||
|
filtered_results: {
|
||||||
|
filter: {
|
||||||
|
bool: {
|
||||||
|
// Scope the inner filtering back to the unpadded range
|
||||||
|
filter: [rangeFilter, ...mustFilters],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
query: {
|
||||||
|
bool: {
|
||||||
|
filter: [groupedRangeFilter],
|
||||||
|
...(mustNotFilters.length > 0 && { must_not: mustNotFilters }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aggregations,
|
||||||
|
size: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
allowNoIndices: true,
|
||||||
|
ignoreUnavailable: true,
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUngroupedESQuery = (
|
||||||
|
params: LogDocumentCountAlertParams,
|
||||||
|
sourceConfiguration: InfraSource['configuration'],
|
||||||
|
index: string
|
||||||
|
): object => {
|
||||||
|
const { rangeFilter, mustFilters, mustNotFilters } = buildFiltersFromCriteria(
|
||||||
|
params,
|
||||||
|
sourceConfiguration.fields.timestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
// Ensure we accurately track the hit count for the ungrouped case, otherwise we can only ensure accuracy up to 10,000.
|
||||||
|
track_total_hits: true,
|
||||||
|
query: {
|
||||||
|
bool: {
|
||||||
|
filter: [rangeFilter, ...mustFilters],
|
||||||
|
...(mustNotFilters.length > 0 && { must_not: mustNotFilters }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
size: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
allowNoIndices: true,
|
||||||
|
ignoreUnavailable: true,
|
||||||
|
body,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type SupportedESQueryTypes = 'term' | 'match' | 'match_phrase' | 'range';
|
type SupportedESQueryTypes = 'term' | 'match' | 'match_phrase' | 'range';
|
||||||
|
@ -145,7 +318,6 @@ const buildCriterionQuery = (criterion: Criterion): Filter | undefined => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
break;
|
|
||||||
case 'match': {
|
case 'match': {
|
||||||
return {
|
return {
|
||||||
match: {
|
match: {
|
||||||
|
@ -221,15 +393,31 @@ const getQueryMappingForComparator = (comparator: Comparator) => {
|
||||||
return queryMappings[comparator];
|
return queryMappings[comparator];
|
||||||
};
|
};
|
||||||
|
|
||||||
const getResults = async (
|
const getUngroupedResults = async (query: object, callCluster: AlertServices['callCluster']) => {
|
||||||
query: object,
|
return decodeOrThrow(UngroupedSearchQueryResponseRT)(await callCluster('search', query));
|
||||||
index: string,
|
};
|
||||||
callCluster: AlertServices['callCluster']
|
|
||||||
) => {
|
const getGroupedResults = async (query: object, callCluster: AlertServices['callCluster']) => {
|
||||||
return await callCluster('count', {
|
let compositeGroupBuckets: GroupedSearchQueryResponse['aggregations']['groups']['buckets'] = [];
|
||||||
body: query,
|
let lastAfterKey: GroupedSearchQueryResponse['aggregations']['groups']['after_key'] | undefined;
|
||||||
index,
|
|
||||||
});
|
while (true) {
|
||||||
|
const queryWithAfterKey: any = { ...query };
|
||||||
|
queryWithAfterKey.body.aggregations.groups.composite.after = lastAfterKey;
|
||||||
|
const groupResponse: GroupedSearchQueryResponse = decodeOrThrow(GroupedSearchQueryResponseRT)(
|
||||||
|
await callCluster('search', queryWithAfterKey)
|
||||||
|
);
|
||||||
|
compositeGroupBuckets = [
|
||||||
|
...compositeGroupBuckets,
|
||||||
|
...groupResponse.aggregations.groups.buckets,
|
||||||
|
];
|
||||||
|
lastAfterKey = groupResponse.aggregations.groups.after_key;
|
||||||
|
if (groupResponse.aggregations.groups.buckets.length < COMPOSITE_GROUP_SIZE) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return compositeGroupBuckets;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createConditionsMessage = (criteria: LogDocumentCountAlertParams['criteria']) => {
|
const createConditionsMessage = (criteria: LogDocumentCountAlertParams['criteria']) => {
|
||||||
|
|
|
@ -28,6 +28,13 @@ const conditionsActionVariableDescription = i18n.translate(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const groupByActionVariableDescription = i18n.translate(
|
||||||
|
'xpack.infra.logs.alerting.threshold.groupByActionVariableDescription',
|
||||||
|
{
|
||||||
|
defaultMessage: 'The name of the group responsible for triggering the alert',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const countSchema = schema.object({
|
const countSchema = schema.object({
|
||||||
value: schema.number(),
|
value: schema.number(),
|
||||||
comparator: schema.oneOf([
|
comparator: schema.oneOf([
|
||||||
|
@ -75,6 +82,7 @@ export async function registerLogThresholdAlertType(
|
||||||
criteria: schema.arrayOf(criteriaSchema),
|
criteria: schema.arrayOf(criteriaSchema),
|
||||||
timeUnit: schema.string(),
|
timeUnit: schema.string(),
|
||||||
timeSize: schema.number(),
|
timeSize: schema.number(),
|
||||||
|
groupBy: schema.maybe(schema.arrayOf(schema.string())),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
defaultActionGroupId: FIRED_ACTIONS.id,
|
defaultActionGroupId: FIRED_ACTIONS.id,
|
||||||
|
@ -84,6 +92,7 @@ export async function registerLogThresholdAlertType(
|
||||||
context: [
|
context: [
|
||||||
{ name: 'matchingDocuments', description: documentCountActionVariableDescription },
|
{ name: 'matchingDocuments', description: documentCountActionVariableDescription },
|
||||||
{ name: 'conditions', description: conditionsActionVariableDescription },
|
{ name: 'conditions', description: conditionsActionVariableDescription },
|
||||||
|
{ name: 'group', description: groupByActionVariableDescription },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
producer: 'logs',
|
producer: 'logs',
|
||||||
|
|
Loading…
Reference in a new issue