From 2fefd1325e3bde24b1cb29c4cfe46878141c2873 Mon Sep 17 00:00:00 2001 From: Kerry Gallagher Date: Mon, 7 Sep 2020 13:58:10 +0100 Subject: [PATCH] [Logs UI] Update alert executor tests (#75764) (#76863) * Update executor unit tests --- .../log_threshold_chart_preview.ts | 4 +- .../log_threshold_executor.test.ts | 1026 +++++++++-------- .../log_threshold/log_threshold_executor.ts | 108 +- 3 files changed, 626 insertions(+), 512 deletions(-) diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_chart_preview.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_chart_preview.ts index 026f003463ef27..71115ad3a5745d 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_chart_preview.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_chart_preview.ts @@ -50,8 +50,8 @@ export async function getChartPreviewData( const { rangeFilter } = buildFiltersFromCriteria(expandedAlertParams, timestampField); const query = isGrouped - ? getGroupedESQuery(expandedAlertParams, sourceConfiguration.configuration, indexPattern) - : getUngroupedESQuery(expandedAlertParams, sourceConfiguration.configuration, indexPattern); + ? getGroupedESQuery(expandedAlertParams, timestampField, indexPattern) + : getUngroupedESQuery(expandedAlertParams, timestampField, indexPattern); if (!query) { throw new Error('ES query could not be built from the provided alert params'); diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.test.ts index 940afd72f6c73c..f730513991a781 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.test.ts @@ -4,527 +4,617 @@ * you may not use this file except in compliance with the Elastic License. */ -import { createLogThresholdExecutor } from './log_threshold_executor'; +import { + getPositiveComparators, + getNegativeComparators, + queryMappings, + buildFiltersFromCriteria, + getUngroupedESQuery, + getGroupedESQuery, + processUngroupedResults, + processGroupByResults, +} from './log_threshold_executor'; import { Comparator, AlertStates, LogDocumentCountAlertParams, Criterion, + UngroupedSearchQueryResponse, + GroupedSearchQueryResponse, } from '../../../../common/alerting/logs/types'; -import { AlertExecutorOptions } from '../../../../../alerts/server'; -import { - alertsMock, - AlertInstanceMock, - AlertServicesMock, -} from '../../../../../alerts/server/mocks'; -import { libsMock } from './mocks'; - -interface AlertTestInstance { - instance: AlertInstanceMock; - actionQueue: any[]; - state: any; -} - -/* - * Mocks - */ -const alertInstances = new Map(); - -const services: AlertServicesMock = alertsMock.createAlertServices(); -services.alertInstanceFactory.mockImplementation((instanceId: string) => { - const alertInstance: AlertTestInstance = { - instance: alertsMock.createAlertInstanceFactory(), - actionQueue: [], - state: {}, - }; - alertInstance.instance.replaceState.mockImplementation((newState: any) => { - alertInstance.state = newState; - return alertInstance.instance; - }); - alertInstance.instance.scheduleActions.mockImplementation((id: string, action: any) => { - alertInstance.actionQueue.push({ id, action }); - return alertInstance.instance; - }); - - alertInstances.set(instanceId, alertInstance); - - return alertInstance.instance; -}); - -/* - * Helper functions - */ -function getAlertState(): AlertStates { - const alert = alertInstances.get('*'); - if (alert) { - return alert.state.alertState; - } else { - throw new Error('Could not find alert instance'); - } -} - -/* - * Executor instance (our test subject) - */ -const executor = (createLogThresholdExecutor(libsMock) as unknown) as (opts: { - params: LogDocumentCountAlertParams; - services: { callCluster: AlertExecutorOptions['params']['callCluster'] }; -}) => Promise; - -// Wrapper to test -type Comparison = [number, Comparator, number]; - -async function callExecutor( - [value, comparator, threshold]: Comparison, - criteria: Criterion[] = [] -) { - services.callCluster.mockImplementationOnce(async (..._) => ({ - _shards: { - total: 1, - successful: 1, - skipped: 0, - failed: 0, - }, - timed_out: false, - took: 123456789, - hits: { - total: { - value, - }, - }, - })); - - return await executor({ - services, - params: { - count: { value: threshold, comparator }, - timeSize: 1, - timeUnit: 'm', - criteria, - }, - }); -} - -describe('Ungrouped alerts', () => { - describe('Comparators trigger alerts correctly', () => { - it('does not alert when counts do not reach the threshold', async () => { - await callExecutor([0, Comparator.GT, 1]); - expect(getAlertState()).toBe(AlertStates.OK); - - await callExecutor([0, Comparator.GT_OR_EQ, 1]); - expect(getAlertState()).toBe(AlertStates.OK); - - await callExecutor([1, Comparator.LT, 0]); - expect(getAlertState()).toBe(AlertStates.OK); - - await callExecutor([1, Comparator.LT_OR_EQ, 0]); - expect(getAlertState()).toBe(AlertStates.OK); +import { alertsMock } from '../../../../../alerts/server/mocks'; + +// Mocks // +const numericField = { + field: 'numericField', + value: 10, +}; + +const keywordField = { + field: 'keywordField', + value: 'error', +}; + +const textField = { + field: 'textField', + value: 'Something went wrong', +}; + +const positiveCriteria: Criterion[] = [ + { ...numericField, comparator: Comparator.GT }, + { ...numericField, comparator: Comparator.GT_OR_EQ }, + { ...numericField, comparator: Comparator.LT }, + { ...numericField, comparator: Comparator.LT_OR_EQ }, + { ...keywordField, comparator: Comparator.EQ }, + { ...textField, comparator: Comparator.MATCH }, + { ...textField, comparator: Comparator.MATCH_PHRASE }, +]; + +const negativeCriteria: Criterion[] = [ + { ...keywordField, comparator: Comparator.NOT_EQ }, + { ...textField, comparator: Comparator.NOT_MATCH }, + { ...textField, comparator: Comparator.NOT_MATCH_PHRASE }, +]; + +const baseAlertParams: Pick = { + count: { + comparator: Comparator.GT, + value: 5, + }, + timeSize: 5, + timeUnit: 'm', +}; + +const TIMESTAMP_FIELD = '@timestamp'; +const FILEBEAT_INDEX = 'filebeat-*'; + +describe('Log threshold executor', () => { + describe('Comparators', () => { + test('Correctly categorises positive comparators', () => { + expect(getPositiveComparators().length).toBe(7); }); - it('alerts when counts reach the threshold', async () => { - await callExecutor([2, Comparator.GT, 1]); - expect(getAlertState()).toBe(AlertStates.ALERT); - - await callExecutor([1, Comparator.GT_OR_EQ, 1]); - expect(getAlertState()).toBe(AlertStates.ALERT); - - await callExecutor([1, Comparator.LT, 2]); - expect(getAlertState()).toBe(AlertStates.ALERT); - - await callExecutor([2, Comparator.LT_OR_EQ, 2]); - expect(getAlertState()).toBe(AlertStates.ALERT); + test('Correctly categorises negative comparators', () => { + expect(getNegativeComparators().length).toBe(3); }); - }); - describe('Comparators create the correct ES queries', () => { - beforeEach(() => { - services.callCluster.mockReset(); + test('There is a query mapping for every comparator', () => { + const comparators = [...getPositiveComparators(), ...getNegativeComparators()]; + expect(Object.keys(queryMappings).length).toBe(comparators.length); }); - - 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({ - track_total_hits: true, - query: { - bool: { - filter: [ - { - range: { - '@timestamp': { - format: 'epoch_millis', - }, - }, - }, - { - term: { - foo: { - value: 'bar', - }, - }, - }, - ], + }); + describe('Criteria filter building', () => { + test('Handles positive criteria', () => { + const alertParams: LogDocumentCountAlertParams = { + ...baseAlertParams, + criteria: positiveCriteria, + }; + const filters = buildFiltersFromCriteria(alertParams, TIMESTAMP_FIELD); + expect(filters.mustFilters).toEqual([ + { + range: { + numericField: { + gt: 10, + }, }, }, - 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', - }, - }, - }, - ], + { + range: { + numericField: { + gte: 10, + }, }, }, - 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', - }, - }, - ], + { + range: { + numericField: { + lt: 10, + }, }, }, - 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', - }, - }, - ], + { + range: { + numericField: { + lte: 10, + }, }, }, - 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', - }, - }, - ], + { + term: { + keywordField: { + value: 'error', + }, }, }, - 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', - }, - }, - ], + { + match: { + textField: 'Something went wrong', }, }, - 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, - }, - }, - }, - ], + { + match_phrase: { + textField: 'Something went wrong', }, }, - 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, - }, - }, - }, - ], + test('Handles negative criteria', () => { + const alertParams: LogDocumentCountAlertParams = { + ...baseAlertParams, + criteria: negativeCriteria, + }; + const filters = buildFiltersFromCriteria(alertParams, TIMESTAMP_FIELD); + + expect(filters.mustNotFilters).toEqual([ + { + term: { + keywordField: { + value: 'error', + }, }, }, - size: 0, - }); + { + match: { + textField: 'Something went wrong', + }, + }, + { + match_phrase: { + textField: 'Something went wrong', + }, + }, + ]); }); - it('works with `Comparator.LT`', async () => { - await callExecutor( - [2, Comparator.GT, 1], // Not relevant - [{ field: 'foo', comparator: Comparator.LT, value: 1 }] - ); + test('Handles time range', () => { + const alertParams: LogDocumentCountAlertParams = { ...baseAlertParams, criteria: [] }; + const filters = buildFiltersFromCriteria(alertParams, TIMESTAMP_FIELD); + expect(typeof filters.rangeFilter.range[TIMESTAMP_FIELD].gte).toBe('number'); + expect(typeof filters.rangeFilter.range[TIMESTAMP_FIELD].lte).toBe('number'); + expect(filters.rangeFilter.range[TIMESTAMP_FIELD].format).toBe('epoch_millis'); - const query = services.callCluster.mock.calls[0][1]!; + expect(typeof filters.groupedRangeFilter.range[TIMESTAMP_FIELD].gte).toBe('number'); + expect(typeof filters.groupedRangeFilter.range[TIMESTAMP_FIELD].lte).toBe('number'); + expect(filters.groupedRangeFilter.range[TIMESTAMP_FIELD].format).toBe('epoch_millis'); + }); + }); - expect(query.body).toMatchObject({ - track_total_hits: true, - query: { - bool: { - filter: [ - { - range: { - '@timestamp': { - format: 'epoch_millis', + describe('ES queries', () => { + describe('Query generation', () => { + test('Correctly generates ungrouped queries', () => { + const alertParams: LogDocumentCountAlertParams = { + ...baseAlertParams, + criteria: [...positiveCriteria, ...negativeCriteria], + }; + const query = getUngroupedESQuery(alertParams, TIMESTAMP_FIELD, FILEBEAT_INDEX); + expect(query).toEqual({ + index: 'filebeat-*', + allowNoIndices: true, + ignoreUnavailable: true, + body: { + track_total_hits: true, + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: expect.any(Number), + lte: expect.any(Number), + format: 'epoch_millis', + }, + }, }, - }, - }, - { - range: { - foo: { - lt: 1, + { + range: { + numericField: { + gt: 10, + }, + }, }, - }, + { + range: { + numericField: { + gte: 10, + }, + }, + }, + { + range: { + numericField: { + lt: 10, + }, + }, + }, + { + range: { + numericField: { + lte: 10, + }, + }, + }, + { + term: { + keywordField: { + value: 'error', + }, + }, + }, + { + match: { + textField: 'Something went wrong', + }, + }, + { + match_phrase: { + textField: 'Something went wrong', + }, + }, + ], + must_not: [ + { + term: { + keywordField: { + value: 'error', + }, + }, + }, + { + match: { + textField: 'Something went wrong', + }, + }, + { + match_phrase: { + textField: 'Something went wrong', + }, + }, + ], }, - ], + }, + size: 0, }, - }, - 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', + test('Correctly generates grouped queries', () => { + const alertParams: LogDocumentCountAlertParams = { + ...baseAlertParams, + groupBy: ['host.name'], + criteria: [...positiveCriteria, ...negativeCriteria], + }; + const query = getGroupedESQuery(alertParams, TIMESTAMP_FIELD, FILEBEAT_INDEX); + expect(query).toEqual({ + index: 'filebeat-*', + allowNoIndices: true, + ignoreUnavailable: true, + body: { + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: expect.any(Number), + lte: expect.any(Number), + format: 'epoch_millis', + }, + }, }, - }, + ], + must_not: [ + { + term: { + keywordField: { + value: 'error', + }, + }, + }, + { + match: { + textField: 'Something went wrong', + }, + }, + { + match_phrase: { + textField: 'Something went wrong', + }, + }, + ], }, - { - range: { - foo: { - lte: 1, + }, + aggregations: { + groups: { + composite: { + size: 40, + sources: [ + { + 'group-0-host.name': { + terms: { + field: 'host.name', + }, + }, + }, + ], + }, + aggregations: { + filtered_results: { + filter: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: expect.any(Number), + lte: expect.any(Number), + format: 'epoch_millis', + }, + }, + }, + { + range: { + numericField: { + gt: 10, + }, + }, + }, + { + range: { + numericField: { + gte: 10, + }, + }, + }, + { + range: { + numericField: { + lt: 10, + }, + }, + }, + { + range: { + numericField: { + lte: 10, + }, + }, + }, + { + term: { + keywordField: { + value: 'error', + }, + }, + }, + { + match: { + textField: 'Something went wrong', + }, + }, + { + match_phrase: { + textField: 'Something went wrong', + }, + }, + ], + }, + }, }, }, }, - ], + }, + size: 0, }, - }, - size: 0, + }); }); }); }); - describe('Multiple criteria create the right ES query', () => { - beforeEach(() => { - services.callCluster.mockReset(); + describe('Results processors', () => { + describe('Can process ungrouped results', () => { + test('It handles the OK state correctly', () => { + const alertInstanceUpdaterMock = jest.fn(); + const alertParams = { + ...baseAlertParams, + criteria: [positiveCriteria[0]], + }; + const results = { + hits: { + total: { + value: 2, + }, + }, + } as UngroupedSearchQueryResponse; + processUngroupedResults( + results, + alertParams, + alertsMock.createAlertInstanceFactory, + alertInstanceUpdaterMock + ); + // First call, second argument + expect(alertInstanceUpdaterMock.mock.calls[0][1]).toBe(AlertStates.OK); + // First call, third argument + expect(alertInstanceUpdaterMock.mock.calls[0][2]).toBe(undefined); + }); + + test('It handles the ALERT state correctly', () => { + const alertInstanceUpdaterMock = jest.fn(); + const alertParams = { + ...baseAlertParams, + criteria: [positiveCriteria[0]], + }; + const results = { + hits: { + total: { + value: 10, + }, + }, + } as UngroupedSearchQueryResponse; + processUngroupedResults( + results, + alertParams, + alertsMock.createAlertInstanceFactory, + alertInstanceUpdaterMock + ); + // First call, second argument + expect(alertInstanceUpdaterMock.mock.calls[0][1]).toBe(AlertStates.ALERT); + // First call, third argument + expect(alertInstanceUpdaterMock.mock.calls[0][2]).toEqual([ + { + actionGroup: 'logs.threshold.fired', + context: { + conditions: ' numericField more than 10', + group: null, + matchingDocuments: 10, + }, + }, + ]); + }); }); - 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]!; + describe('Can process grouped results', () => { + test('It handles the OK state correctly', () => { + const alertInstanceUpdaterMock = jest.fn(); + const alertParams = { + ...baseAlertParams, + criteria: [positiveCriteria[0]], + groupBy: ['host.name', 'event.dataset'], + }; + const results = [ + { + key: { + 'host.name': 'i-am-a-host-name', + 'event.dataset': 'i-am-a-dataset', + }, + doc_count: 100, + filtered_results: { + doc_count: 1, + }, + }, + { + key: { + 'host.name': 'i-am-a-host-name', + 'event.dataset': 'i-am-a-dataset', + }, + doc_count: 100, + filtered_results: { + doc_count: 2, + }, + }, + { + key: { + 'host.name': 'i-am-a-host-name', + 'event.dataset': 'i-am-a-dataset', + }, + doc_count: 100, + filtered_results: { + doc_count: 3, + }, + }, + ] as GroupedSearchQueryResponse['aggregations']['groups']['buckets']; + processGroupByResults( + results, + alertParams, + alertsMock.createAlertInstanceFactory, + alertInstanceUpdaterMock + ); + expect(alertInstanceUpdaterMock.mock.calls.length).toBe(3); + // First call, second argument + expect(alertInstanceUpdaterMock.mock.calls[0][1]).toBe(AlertStates.OK); + // First call, third argument + expect(alertInstanceUpdaterMock.mock.calls[0][2]).toBe(undefined); + + // Second call, second argument + expect(alertInstanceUpdaterMock.mock.calls[1][1]).toBe(AlertStates.OK); + // Second call, third argument + expect(alertInstanceUpdaterMock.mock.calls[1][2]).toBe(undefined); + + // Third call, second argument + expect(alertInstanceUpdaterMock.mock.calls[2][1]).toBe(AlertStates.OK); + // Third call, third argument + expect(alertInstanceUpdaterMock.mock.calls[2][2]).toBe(undefined); + }); - expect(query.body).toMatchObject({ - track_total_hits: true, - query: { - bool: { - filter: [ - { - range: { - '@timestamp': { - format: 'epoch_millis', - }, - }, - }, - { - term: { - foo: { - value: 'bar', - }, - }, - }, - { - range: { - 'http.status': { - lt: 400, - }, - }, - }, - ], + test('It handles the ALERT state correctly', () => { + const alertInstanceUpdaterMock = jest.fn(); + const alertParams = { + ...baseAlertParams, + criteria: [positiveCriteria[0]], + groupBy: ['host.name', 'event.dataset'], + }; + // Two groups should fire, one shouldn't + const results = [ + { + key: { + 'host.name': 'i-am-a-host-name-1', + 'event.dataset': 'i-am-a-dataset-1', + }, + doc_count: 100, + filtered_results: { + doc_count: 10, + }, }, - }, - size: 0, + { + key: { + 'host.name': 'i-am-a-host-name-2', + 'event.dataset': 'i-am-a-dataset-2', + }, + doc_count: 100, + filtered_results: { + doc_count: 2, + }, + }, + { + key: { + 'host.name': 'i-am-a-host-name-3', + 'event.dataset': 'i-am-a-dataset-3', + }, + doc_count: 100, + filtered_results: { + doc_count: 20, + }, + }, + ] as GroupedSearchQueryResponse['aggregations']['groups']['buckets']; + processGroupByResults( + results, + alertParams, + alertsMock.createAlertInstanceFactory, + alertInstanceUpdaterMock + ); + expect(alertInstanceUpdaterMock.mock.calls.length).toBe(results.length); + // First call, second argument + expect(alertInstanceUpdaterMock.mock.calls[0][1]).toBe(AlertStates.ALERT); + // First call, third argument + expect(alertInstanceUpdaterMock.mock.calls[0][2]).toEqual([ + { + actionGroup: 'logs.threshold.fired', + context: { + conditions: ' numericField more than 10', + group: 'i-am-a-host-name-1, i-am-a-dataset-1', + matchingDocuments: 10, + }, + }, + ]); + + // Second call, second argument + expect(alertInstanceUpdaterMock.mock.calls[1][1]).toBe(AlertStates.OK); + // Second call, third argument + expect(alertInstanceUpdaterMock.mock.calls[1][2]).toBe(undefined); + + // Third call, second argument + expect(alertInstanceUpdaterMock.mock.calls[2][1]).toBe(AlertStates.ALERT); + // Third call, third argument + expect(alertInstanceUpdaterMock.mock.calls[2][2]).toEqual([ + { + actionGroup: 'logs.threshold.fired', + context: { + conditions: ' numericField more than 10', + group: 'i-am-a-host-name-3, i-am-a-dataset-3', + matchingDocuments: 20, + }, + }, + ]); }); }); }); diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts index db76e955f00734..224b898141c364 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts @@ -5,7 +5,12 @@ */ import { i18n } from '@kbn/i18n'; -import { AlertExecutorOptions, AlertServices } from '../../../../../alerts/server'; +import { + AlertExecutorOptions, + AlertServices, + AlertInstance, + AlertInstanceContext, +} from '../../../../../alerts/server'; import { AlertStates, Comparator, @@ -19,7 +24,6 @@ import { } from '../../../../common/alerting/logs/types'; import { InfraBackendLibs } from '../../infra_types'; import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; -import { InfraSource } from '../../../../common/http_api/source_api'; import { decodeOrThrow } from '../../../../common/runtime_types'; import { UNGROUPED_FACTORY_KEY } from '../common/utils'; @@ -42,6 +46,7 @@ export const createLogThresholdExecutor = (libs: InfraBackendLibs) => const sourceConfiguration = await sources.getSourceConfiguration(savedObjectsClient, 'default'); const indexPattern = sourceConfiguration.configuration.logAlias; + const timestampField = sourceConfiguration.configuration.fields.timestamp; const alertInstance = alertInstanceFactory(UNGROUPED_FACTORY_KEY); try { @@ -49,8 +54,8 @@ export const createLogThresholdExecutor = (libs: InfraBackendLibs) => const query = groupBy && groupBy.length > 0 - ? getGroupedESQuery(validatedParams, sourceConfiguration.configuration, indexPattern) - : getUngroupedESQuery(validatedParams, sourceConfiguration.configuration, indexPattern); + ? getGroupedESQuery(validatedParams, timestampField, indexPattern) + : getUngroupedESQuery(validatedParams, timestampField, indexPattern); if (!query) { throw new Error('ES query could not be built from the provided alert params'); @@ -60,13 +65,15 @@ export const createLogThresholdExecutor = (libs: InfraBackendLibs) => processGroupByResults( await getGroupedResults(query, callCluster), validatedParams, - alertInstanceFactory + alertInstanceFactory, + updateAlertInstance ); } else { processUngroupedResults( await getUngroupedResults(query, callCluster), validatedParams, - alertInstanceFactory + alertInstanceFactory, + updateAlertInstance ); } } catch (e) { @@ -78,10 +85,11 @@ export const createLogThresholdExecutor = (libs: InfraBackendLibs) => } }; -const processUngroupedResults = ( +export const processUngroupedResults = ( results: UngroupedSearchQueryResponse, params: LogDocumentCountAlertParams, - alertInstanceFactory: AlertExecutorOptions['services']['alertInstanceFactory'] + alertInstanceFactory: AlertExecutorOptions['services']['alertInstanceFactory'], + alertInstaceUpdater: AlertInstanceUpdater ) => { const { count, criteria } = params; @@ -89,19 +97,18 @@ const processUngroupedResults = ( 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, - }); + alertInstaceUpdater(alertInstance, AlertStates.ALERT, [ + { + actionGroup: FIRED_ACTIONS.id, + context: { + matchingDocuments: documentCount, + conditions: createConditionsMessage(criteria), + group: null, + }, + }, + ]); } else { - alertInstance.replaceState({ - alertState: AlertStates.OK, - }); + alertInstaceUpdater(alertInstance, AlertStates.OK); } }; @@ -110,10 +117,11 @@ interface ReducedGroupByResults { documentCount: number; } -const processGroupByResults = ( +export const processGroupByResults = ( results: GroupedSearchQueryResponse['aggregations']['groups']['buckets'], params: LogDocumentCountAlertParams, - alertInstanceFactory: AlertExecutorOptions['services']['alertInstanceFactory'] + alertInstanceFactory: AlertExecutorOptions['services']['alertInstanceFactory'], + alertInstaceUpdater: AlertInstanceUpdater ) => { const { count, criteria } = params; @@ -128,23 +136,41 @@ const processGroupByResults = ( 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, - }); + alertInstaceUpdater(alertInstance, AlertStates.ALERT, [ + { + actionGroup: FIRED_ACTIONS.id, + context: { + matchingDocuments: documentCount, + conditions: createConditionsMessage(criteria), + group: group.name, + }, + }, + ]); } else { - alertInstance.replaceState({ - alertState: AlertStates.OK, - }); + alertInstaceUpdater(alertInstance, AlertStates.OK); } }); }; +type AlertInstanceUpdater = ( + alertInstance: AlertInstance, + state: AlertStates, + actions?: Array<{ actionGroup: string; context: AlertInstanceContext }> +) => void; + +export const updateAlertInstance: AlertInstanceUpdater = (alertInstance, state, actions) => { + if (actions && actions.length > 0) { + actions.forEach((actionSet) => { + const { actionGroup, context } = actionSet; + alertInstance.scheduleActions(actionGroup, context); + }); + } + + alertInstance.replaceState({ + alertState: state, + }); +}; + export const buildFiltersFromCriteria = ( params: Omit, timestampField: string @@ -198,7 +224,7 @@ export const buildFiltersFromCriteria = ( export const getGroupedESQuery = ( params: Omit, - sourceConfiguration: InfraSource['configuration'], + timestampField: string, index: string ): object | undefined => { const { groupBy } = params; @@ -207,8 +233,6 @@ export const getGroupedESQuery = ( return; } - const timestampField = sourceConfiguration.fields.timestamp; - const { rangeFilter, groupedRangeFilter, mustFilters, mustNotFilters } = buildFiltersFromCriteria( params, timestampField @@ -258,12 +282,12 @@ export const getGroupedESQuery = ( export const getUngroupedESQuery = ( params: Omit, - sourceConfiguration: InfraSource['configuration'], + timestampField: string, index: string ): object => { const { rangeFilter, mustFilters, mustNotFilters } = buildFiltersFromCriteria( params, - sourceConfiguration.fields.timestamp + timestampField ); const body = { @@ -357,7 +381,7 @@ const buildCriterionQuery = (criterion: Criterion): Filter | undefined => { } }; -const getPositiveComparators = () => { +export const getPositiveComparators = () => { return [ Comparator.GT, Comparator.GT_OR_EQ, @@ -369,11 +393,11 @@ const getPositiveComparators = () => { ]; }; -const getNegativeComparators = () => { +export const getNegativeComparators = () => { return [Comparator.NOT_EQ, Comparator.NOT_MATCH, Comparator.NOT_MATCH_PHRASE]; }; -const queryMappings: { +export const queryMappings: { [key: string]: string; } = { [Comparator.GT]: 'range',