From 4f645518cc49fe255e149d4cb8b1541edb46222f Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Thu, 24 Nov 2022 10:41:11 +0100 Subject: [PATCH] [ML] Explain Log Rate Spikes: Additional API integration tests (#146113) Additional API integration tests. - The test data was moved to its own file `test_data.ts` and types for its structure defined in `types.ts` to be in line with the structure used for functional tests. - The file that runs the test was extended so it can run an array of test data definitions. - The datasets used in the funcional tests (`ecommerce` with some additional documents added to create a significant spike and the computationally generated spike data set to create distinct groups) were moved to a service `ExplainLogRateSpikesDataGenerator` so they can be generated and used across functional and API integration tests. - The computationally generated spike data set `artificial_logs_with_spike` is now also used for API integration tests. - Additional assertions have been added to check the grouping result. `ecommerce` does not return any groups whereas `artificial_logs_with_spike` does. - The functional tests code is now consolidated and one test file is able to run multiple test data definitions too. (cherry picked from commit 5481f07f79e6923a982ae5ce42af051630b10646) --- .../apis/aiops/explain_log_rate_spikes.ts | 426 +++++++++--------- .../api_integration/apis/aiops/test_data.ts | 157 +++++++ .../test/api_integration/apis/aiops/types.ts | 30 ++ x-pack/test/api_integration/services/aiops.ts | 18 + x-pack/test/api_integration/services/index.ts | 2 + ...kes_logs.ts => explain_log_rate_spikes.ts} | 98 ++-- .../explain_log_rate_spikes_farequote.ts | 198 -------- x-pack/test/functional/apps/aiops/index.ts | 7 +- .../test/functional/apps/aiops/test_data.ts | 103 +---- x-pack/test/functional/apps/aiops/types.ts | 17 +- .../explain_log_rate_spikes_data_generator.ts | 197 ++++++++ .../test/functional/services/aiops/index.ts | 3 + 12 files changed, 667 insertions(+), 589 deletions(-) create mode 100644 x-pack/test/api_integration/apis/aiops/test_data.ts create mode 100644 x-pack/test/api_integration/apis/aiops/types.ts create mode 100644 x-pack/test/api_integration/services/aiops.ts rename x-pack/test/functional/apps/aiops/{explain_log_rate_spikes_logs.ts => explain_log_rate_spikes.ts} (71%) delete mode 100644 x-pack/test/functional/apps/aiops/explain_log_rate_spikes_farequote.ts create mode 100644 x-pack/test/functional/services/aiops/explain_log_rate_spikes_data_generator.ts diff --git a/x-pack/test/api_integration/apis/aiops/explain_log_rate_spikes.ts b/x-pack/test/api_integration/apis/aiops/explain_log_rate_spikes.ts index 79c5be4a93a34..4e4b217f939fb 100644 --- a/x-pack/test/api_integration/apis/aiops/explain_log_rate_spikes.ts +++ b/x-pack/test/api_integration/apis/aiops/explain_log_rate_spikes.ts @@ -15,268 +15,258 @@ import type { ApiExplainLogRateSpikes } from '@kbn/aiops-plugin/common/api'; import type { FtrProviderContext } from '../../ftr_provider_context'; import { parseStream } from './parse_stream'; +import { explainLogRateSpikesTestData } from './test_data'; export default ({ getService }: FtrProviderContext) => { + const aiops = getService('aiops'); const supertest = getService('supertest'); const config = getService('config'); const kibanaServerUrl = formatUrl(config.get('servers.kibana')); - - const requestBody: ApiExplainLogRateSpikes['body'] = { - baselineMax: 1561719083292, - baselineMin: 1560954147006, - deviationMax: 1562254538692, - deviationMin: 1561986810992, - end: 2147483647000, - index: 'ft_ecommerce', - searchQuery: '{"bool":{"filter":[],"must":[{"match_all":{}}],"must_not":[]}}', - start: 0, - timeFieldName: 'order_date', - }; - - const expected = { - chunksLength: 34, - actionsLength: 33, - noIndexChunksLength: 4, - noIndexActionsLength: 3, - changePointFilter: 'add_change_points', - histogramFilter: 'add_change_points_histogram', - errorFilter: 'add_error', - changePoints: [ - { - fieldName: 'day_of_week', - fieldValue: 'Wednesday', - doc_count: 145, - bg_count: 142, - score: 36.31595998561873, - pValue: 1.6911377077437753e-16, - normalizedScore: 0.8055203624020835, - }, - { - fieldName: 'day_of_week', - fieldValue: 'Thursday', - doc_count: 157, - bg_count: 224, - score: 20.366950718358762, - pValue: 1.428057484826135e-9, - normalizedScore: 0.7661649691018979, - }, - ], - histogramLength: 20, - }; + const esArchiver = getService('esArchiver'); describe('POST /internal/aiops/explain_log_rate_spikes', () => { - const esArchiver = getService('esArchiver'); + explainLogRateSpikesTestData.forEach((testData) => { + describe(`with ${testData.testName}`, () => { + before(async () => { + if (testData.esArchive) { + await esArchiver.loadIfNeeded(testData.esArchive); + } else if (testData.dataGenerator) { + await aiops.explainLogRateSpikesDataGenerator.generateData(testData.dataGenerator); + } + }); - before(async () => { - await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/ecommerce'); - }); + after(async () => { + if (testData.esArchive) { + await esArchiver.unload(testData.esArchive); + } else if (testData.dataGenerator) { + await aiops.explainLogRateSpikesDataGenerator.removeGeneratedData( + testData.dataGenerator + ); + } + }); - after(async () => { - await esArchiver.unload('x-pack/test/functional/es_archives/ml/ecommerce'); - }); + async function assertAnalysisResult(data: any[]) { + expect(data.length).to.eql( + testData.expected.actionsLength, + `Expected 'actionsLength' to be ${testData.expected.actionsLength}, got ${data.length}.` + ); + data.forEach((d) => { + expect(typeof d.type).to.be('string'); + }); - async function requestWithoutStreaming(body: ApiExplainLogRateSpikes['body']) { - const resp = await supertest - .post(`/internal/aiops/explain_log_rate_spikes`) - .set('kbn-xsrf', 'kibana') - .send(body) - .expect(200); + const addChangePointsActions = data.filter( + (d) => d.type === testData.expected.changePointFilter + ); + expect(addChangePointsActions.length).to.greaterThan(0); + + const changePoints = addChangePointsActions + .flatMap((d) => d.payload) + .sort(function (a, b) { + if (a.fieldName === b.fieldName) { + return b.fieldValue - a.fieldValue; + } + return a.fieldName > b.fieldName ? 1 : -1; + }); + + expect(changePoints.length).to.eql( + testData.expected.changePoints.length, + `Expected 'changePoints.length' to be ${testData.expected.changePoints.length}, got ${changePoints.length}.` + ); + changePoints.forEach((cp, index) => { + const ecp = testData.expected.changePoints[index]; + expect(cp.fieldName).to.eql(ecp.fieldName); + expect(cp.fieldValue).to.eql(ecp.fieldValue); + expect(cp.doc_count).to.eql(ecp.doc_count); + expect(cp.bg_count).to.eql(ecp.bg_count); + }); - // compression is on by default so if the request body is undefined - // the response header should include "gzip" and otherwise be "undefined" - if (body.compressResponse === undefined) { - expect(resp.header['content-encoding']).to.be('gzip'); - } else if (body.compressResponse === false) { - expect(resp.header['content-encoding']).to.be(undefined); - } + const histogramActions = data.filter((d) => d.type === testData.expected.histogramFilter); + const histograms = histogramActions.flatMap((d) => d.payload); + // for each change point we should get a histogram + expect(histogramActions.length).to.be(changePoints.length); + // each histogram should have a length of 20 items. + histograms.forEach((h, index) => { + expect(h.histogram.length).to.be(20); + }); - expect(Buffer.isBuffer(resp.body)).to.be(true); + const groupActions = data.filter((d) => d.type === testData.expected.groupFilter); + const groups = groupActions.flatMap((d) => d.payload); + + expect(groups).to.eql( + testData.expected.groups, + 'Grouping result does not match expected values.' + ); + + const groupHistogramActions = data.filter( + (d) => d.type === testData.expected.groupHistogramFilter + ); + const groupHistograms = groupHistogramActions.flatMap((d) => d.payload); + // for each change point group we should get a histogram + expect(groupHistograms.length).to.be(groups.length); + // each histogram should have a length of 20 items. + groupHistograms.forEach((h, index) => { + expect(h.histogram.length).to.be(20); + }); + } - const chunks: string[] = resp.body.toString().split('\n'); + async function requestWithoutStreaming(body: ApiExplainLogRateSpikes['body']) { + const resp = await supertest + .post(`/internal/aiops/explain_log_rate_spikes`) + .set('kbn-xsrf', 'kibana') + .send(body) + .expect(200); + + // compression is on by default so if the request body is undefined + // the response header should include "gzip" and otherwise be "undefined" + if (body.compressResponse === undefined) { + expect(resp.header['content-encoding']).to.be('gzip'); + } else if (body.compressResponse === false) { + expect(resp.header['content-encoding']).to.be(undefined); + } - expect(chunks.length).to.be(expected.chunksLength); + expect(Buffer.isBuffer(resp.body)).to.be(true); - const lastChunk = chunks.pop(); - expect(lastChunk).to.be(''); + const chunks: string[] = resp.body.toString().split('\n'); - let data: any[] = []; + expect(chunks.length).to.eql( + testData.expected.chunksLength, + `Expected 'chunksLength' to be ${testData.expected.chunksLength}, got ${chunks.length}.` + ); - expect(() => { - data = chunks.map((c) => JSON.parse(c)); - }).not.to.throwError(); + const lastChunk = chunks.pop(); + expect(lastChunk).to.be(''); - expect(data.length).to.be(expected.actionsLength); - data.forEach((d) => { - expect(typeof d.type).to.be('string'); - }); + let data: any[] = []; - const addChangePointsActions = data.filter((d) => d.type === expected.changePointFilter); - expect(addChangePointsActions.length).to.greaterThan(0); + expect(() => { + data = chunks.map((c) => JSON.parse(c)); + }).not.to.throwError(); - const changePoints = addChangePointsActions - .flatMap((d) => d.payload) - .sort(function (a, b) { - if (a.fieldName === b.fieldName) { - return b.fieldValue - a.fieldValue; - } - return a.fieldName > b.fieldName ? 1 : -1; - }); + await assertAnalysisResult(data); + } - expect(changePoints.length).to.equal(expected.changePoints.length); - changePoints.forEach((cp, index) => { - const ecp = expected.changePoints[index]; - expect(cp.fieldName).to.equal(ecp.fieldName); - expect(cp.fieldValue).to.equal(ecp.fieldValue); - expect(cp.doc_count).to.equal(ecp.doc_count); - expect(cp.bg_count).to.equal(ecp.bg_count); - }); + it('should return full data without streaming with compression with flushFix', async () => { + await requestWithoutStreaming(testData.requestBody); + }); - const histogramActions = data.filter((d) => d.type === expected.histogramFilter); - const histograms = histogramActions.flatMap((d) => d.payload); - // for each change point we should get a histogram - expect(histogramActions.length).to.be(changePoints.length); - // each histogram should have a length of 20 items. - histograms.forEach((h, index) => { - expect(h.histogram.length).to.be(20); - }); - } + it('should return full data without streaming with compression without flushFix', async () => { + await requestWithoutStreaming({ ...testData.requestBody, flushFix: false }); + }); - it('should return full data without streaming with compression with flushFix', async () => { - await requestWithoutStreaming(requestBody); - }); + it('should return full data without streaming without compression with flushFix', async () => { + await requestWithoutStreaming({ ...testData.requestBody, compressResponse: false }); + }); - it('should return full data without streaming with compression without flushFix', async () => { - await requestWithoutStreaming({ ...requestBody, flushFix: false }); - }); + it('should return full data without streaming without compression without flushFix', async () => { + await requestWithoutStreaming({ + ...testData.requestBody, + compressResponse: false, + flushFix: false, + }); + }); - it('should return full data without streaming without compression with flushFix', async () => { - await requestWithoutStreaming({ ...requestBody, compressResponse: false }); - }); + async function requestWithStreaming(body: ApiExplainLogRateSpikes['body']) { + const resp = await fetch(`${kibanaServerUrl}/internal/aiops/explain_log_rate_spikes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'kbn-xsrf': 'stream', + }, + body: JSON.stringify(body), + }); - it('should return full data without streaming without compression without flushFix', async () => { - await requestWithoutStreaming({ ...requestBody, compressResponse: false, flushFix: false }); - }); + // compression is on by default so if the request body is undefined + // the response header should include "gzip" and otherwise be "null" + if (body.compressResponse === undefined) { + expect(resp.headers.get('content-encoding')).to.be('gzip'); + } else if (body.compressResponse === false) { + expect(resp.headers.get('content-encoding')).to.be(null); + } - async function requestWithStreaming(body: ApiExplainLogRateSpikes['body']) { - const resp = await fetch(`${kibanaServerUrl}/internal/aiops/explain_log_rate_spikes`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'kbn-xsrf': 'stream', - }, - body: JSON.stringify(body), - }); + expect(resp.ok).to.be(true); + expect(resp.status).to.be(200); - // compression is on by default so if the request body is undefined - // the response header should include "gzip" and otherwise be "null" - if (body.compressResponse === undefined) { - expect(resp.headers.get('content-encoding')).to.be('gzip'); - } else if (body.compressResponse === false) { - expect(resp.headers.get('content-encoding')).to.be(null); - } + const stream = resp.body; - expect(resp.ok).to.be(true); - expect(resp.status).to.be(200); + expect(stream).not.to.be(null); - const stream = resp.body; + if (stream !== null) { + const data: any[] = []; + let chunkCounter = 0; + const parseStreamCallback = (c: number) => (chunkCounter = c); - expect(stream).not.to.be(null); + for await (const action of parseStream(stream, parseStreamCallback)) { + expect(action.type).not.to.be('error'); + data.push(action); + } - if (stream !== null) { - const data: any[] = []; - let chunkCounter = 0; - const parseStreamCallback = (c: number) => (chunkCounter = c); + // If streaming works correctly we should receive more than one chunk. + expect(chunkCounter).to.be.greaterThan(1); - for await (const action of parseStream(stream, parseStreamCallback)) { - expect(action.type).not.to.be('error'); - data.push(action); + await assertAnalysisResult(data); + } } - // If streaming works correctly we should receive more than one chunk. - expect(chunkCounter).to.be.greaterThan(1); - - expect(data.length).to.be(expected.actionsLength); - - const addChangePointsActions = data.filter((d) => d.type === expected.changePointFilter); - expect(addChangePointsActions.length).to.greaterThan(0); - - const changePoints = addChangePointsActions - .flatMap((d) => d.payload) - .sort(function (a, b) { - if (a.fieldName === b.fieldName) { - return b.fieldValue - a.fieldValue; - } - return a.fieldName > b.fieldName ? 1 : -1; - }); - - expect(changePoints.length).to.equal(expected.changePoints.length); - changePoints.forEach((cp, index) => { - const ecp = expected.changePoints[index]; - expect(cp.fieldName).to.equal(ecp.fieldName); - expect(cp.fieldValue).to.equal(ecp.fieldValue); - expect(cp.doc_count).to.equal(ecp.doc_count); - expect(cp.bg_count).to.equal(ecp.bg_count); + it('should return data in chunks with streaming with compression with flushFix', async () => { + await requestWithStreaming(testData.requestBody); }); - const histogramActions = data.filter((d) => d.type === expected.histogramFilter); - const histograms = histogramActions.flatMap((d) => d.payload); - // for each change point we should get a histogram - expect(histogramActions.length).to.be(changePoints.length); - // each histogram should have a length of 20 items. - histograms.forEach((h, index) => { - expect(h.histogram.length).to.be(20); + it('should return data in chunks with streaming with compression without flushFix', async () => { + await requestWithStreaming({ ...testData.requestBody, flushFix: false }); }); - } - } - - it('should return data in chunks with streaming with compression with flushFix', async () => { - await requestWithStreaming(requestBody); - }); - - it('should return data in chunks with streaming with compression without flushFix', async () => { - await requestWithStreaming({ ...requestBody, flushFix: false }); - }); - - it('should return data in chunks with streaming without compression with flushFix', async () => { - await requestWithStreaming({ ...requestBody, compressResponse: false }); - }); - - it('should return data in chunks with streaming without compression without flushFix', async () => { - await requestWithStreaming({ ...requestBody, compressResponse: false, flushFix: false }); - }); - - it('should return an error for non existing index without streaming', async () => { - const resp = await supertest - .post(`/internal/aiops/explain_log_rate_spikes`) - .set('kbn-xsrf', 'kibana') - .send({ - ...requestBody, - index: 'does_not_exist', - }) - .expect(200); - - const chunks: string[] = resp.body.toString().split('\n'); - expect(chunks.length).to.be(expected.noIndexChunksLength); + it('should return data in chunks with streaming without compression with flushFix', async () => { + await requestWithStreaming({ ...testData.requestBody, compressResponse: false }); + }); - const lastChunk = chunks.pop(); - expect(lastChunk).to.be(''); + it('should return data in chunks with streaming without compression without flushFix', async () => { + await requestWithStreaming({ + ...testData.requestBody, + compressResponse: false, + flushFix: false, + }); + }); - let data: any[] = []; + it('should return an error for non existing index without streaming', async () => { + const resp = await supertest + .post(`/internal/aiops/explain_log_rate_spikes`) + .set('kbn-xsrf', 'kibana') + .send({ + ...testData.requestBody, + index: 'does_not_exist', + }) + .expect(200); + + const chunks: string[] = resp.body.toString().split('\n'); + + expect(chunks.length).to.eql( + testData.expected.noIndexChunksLength, + `Expected 'noIndexChunksLength' to be ${testData.expected.noIndexChunksLength}, got ${chunks.length}.` + ); + + const lastChunk = chunks.pop(); + expect(lastChunk).to.be(''); + + let data: any[] = []; + + expect(() => { + data = chunks.map((c) => JSON.parse(c)); + }).not.to.throwError(); + + expect(data.length).to.eql( + testData.expected.noIndexActionsLength, + `Expected 'noIndexActionsLength' to be ${testData.expected.noIndexActionsLength}, got ${data.length}.` + ); + data.forEach((d) => { + expect(typeof d.type).to.be('string'); + }); - expect(() => { - data = chunks.map((c) => JSON.parse(c)); - }).not.to.throwError(); + const errorActions = data.filter((d) => d.type === testData.expected.errorFilter); + expect(errorActions.length).to.be(1); - expect(data.length).to.be(expected.noIndexActionsLength); - data.forEach((d) => { - expect(typeof d.type).to.be('string'); + expect(errorActions[0].payload).to.be('Failed to fetch index information.'); + }); }); - - const errorActions = data.filter((d) => d.type === expected.errorFilter); - expect(errorActions.length).to.be(1); - - expect(errorActions[0].payload).to.be('Failed to fetch index information.'); }); }); }; diff --git a/x-pack/test/api_integration/apis/aiops/test_data.ts b/x-pack/test/api_integration/apis/aiops/test_data.ts new file mode 100644 index 0000000000000..55784f04fe568 --- /dev/null +++ b/x-pack/test/api_integration/apis/aiops/test_data.ts @@ -0,0 +1,157 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TestData } from './types'; + +export const explainLogRateSpikesTestData: TestData[] = [ + { + testName: 'ecommerce', + esArchive: 'x-pack/test/functional/es_archives/ml/ecommerce', + requestBody: { + baselineMax: 1561719083292, + baselineMin: 1560954147006, + deviationMax: 1562254538692, + deviationMin: 1561986810992, + end: 2147483647000, + index: 'ft_ecommerce', + searchQuery: '{"bool":{"filter":[],"must":[{"match_all":{}}],"must_not":[]}}', + start: 0, + timeFieldName: 'order_date', + grouping: true, + }, + expected: { + chunksLength: 35, + actionsLength: 34, + noIndexChunksLength: 4, + noIndexActionsLength: 3, + changePointFilter: 'add_change_points', + groupFilter: 'add_change_point_group', + groupHistogramFilter: 'add_change_point_group_histogram', + histogramFilter: 'add_change_points_histogram', + errorFilter: 'add_error', + changePoints: [ + { + fieldName: 'day_of_week', + fieldValue: 'Wednesday', + doc_count: 145, + bg_count: 142, + score: 36.31595998561873, + pValue: 1.6911377077437753e-16, + normalizedScore: 0.8055203624020835, + total_doc_count: 0, + total_bg_count: 0, + }, + { + fieldName: 'day_of_week', + fieldValue: 'Thursday', + doc_count: 157, + bg_count: 224, + score: 20.366950718358762, + pValue: 1.428057484826135e-9, + normalizedScore: 0.7661649691018979, + total_doc_count: 0, + total_bg_count: 0, + }, + ], + groups: [], + histogramLength: 20, + }, + }, + { + testName: 'artificial_logs_with_spike', + dataGenerator: 'artificial_logs_with_spike', + requestBody: { + start: 1668760018793, + end: 1668931954793, + searchQuery: '{"match_all":{}}', + timeFieldName: '@timestamp', + index: 'artificial_logs_with_spike', + baselineMin: 1668769200000, + baselineMax: 1668837600000, + deviationMin: 1668855600000, + deviationMax: 1668924000000, + grouping: true, + }, + expected: { + chunksLength: 25, + actionsLength: 24, + noIndexChunksLength: 4, + noIndexActionsLength: 3, + changePointFilter: 'add_change_points', + groupFilter: 'add_change_point_group', + groupHistogramFilter: 'add_change_point_group_histogram', + histogramFilter: 'add_change_points_histogram', + errorFilter: 'add_error', + changePoints: [ + { + fieldName: 'response_code', + fieldValue: '500', + doc_count: 1821, + bg_count: 553, + total_doc_count: 4671, + total_bg_count: 1975, + score: 26.546201745993947, + pValue: 2.9589053032077285e-12, + normalizedScore: 0.7814127409489161, + }, + { + fieldName: 'url', + fieldValue: 'home.php', + doc_count: 1742, + bg_count: 632, + total_doc_count: 4671, + total_bg_count: 1975, + score: 4.53094842981472, + pValue: 0.010770456205312423, + normalizedScore: 0.10333028878375965, + }, + { + fieldName: 'url', + fieldValue: 'login.php', + doc_count: 1742, + bg_count: 632, + total_doc_count: 4671, + total_bg_count: 1975, + score: 4.53094842981472, + pValue: 0.010770456205312423, + normalizedScore: 0.10333028878375965, + }, + { + fieldName: 'user', + fieldValue: 'Peter', + doc_count: 1981, + bg_count: 553, + total_doc_count: 4671, + total_bg_count: 1975, + score: 47.34435085428873, + pValue: 2.7454255728359757e-21, + normalizedScore: 0.8327337555873047, + }, + ], + groups: [ + { + id: '2038579476', + group: [ + { fieldName: 'response_code', fieldValue: '500', duplicate: false }, + { fieldName: 'url', fieldValue: 'home.php', duplicate: false }, + { fieldName: 'url', fieldValue: 'home.php', duplicate: false }, + { fieldName: 'url', fieldValue: 'login.php', duplicate: false }, + ], + docCount: 792, + pValue: 0.010770456205312423, + }, + { + id: '817080373', + group: [{ fieldName: 'user', fieldValue: 'Peter', duplicate: false }], + docCount: 1981, + pValue: 2.7454255728359757e-21, + }, + ], + histogramLength: 20, + }, + }, +]; diff --git a/x-pack/test/api_integration/apis/aiops/types.ts b/x-pack/test/api_integration/apis/aiops/types.ts new file mode 100644 index 0000000000000..e19994f5e0e14 --- /dev/null +++ b/x-pack/test/api_integration/apis/aiops/types.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ApiExplainLogRateSpikes } from '@kbn/aiops-plugin/common/api'; +import type { ChangePoint, ChangePointGroup } from '@kbn/ml-agg-utils'; + +export interface TestData { + testName: string; + esArchive?: string; + dataGenerator?: string; + requestBody: ApiExplainLogRateSpikes['body']; + expected: { + chunksLength: number; + actionsLength: number; + noIndexChunksLength: number; + noIndexActionsLength: number; + changePointFilter: 'add_change_points'; + groupFilter: 'add_change_point_group'; + groupHistogramFilter: 'add_change_point_group_histogram'; + histogramFilter: 'add_change_points_histogram'; + errorFilter: 'add_error'; + changePoints: ChangePoint[]; + groups: ChangePointGroup[]; + histogramLength: number; + }; +} diff --git a/x-pack/test/api_integration/services/aiops.ts b/x-pack/test/api_integration/services/aiops.ts new file mode 100644 index 0000000000000..02c597e1dc709 --- /dev/null +++ b/x-pack/test/api_integration/services/aiops.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../functional/ftr_provider_context'; + +import { ExplainLogRateSpikesDataGeneratorProvider } from '../../functional/services/aiops/explain_log_rate_spikes_data_generator'; + +export function AiopsProvider(context: FtrProviderContext) { + const explainLogRateSpikesDataGenerator = ExplainLogRateSpikesDataGeneratorProvider(context); + + return { + explainLogRateSpikesDataGenerator, + }; +} diff --git a/x-pack/test/api_integration/services/index.ts b/x-pack/test/api_integration/services/index.ts index 5db28d64cf953..d83825349f1e9 100644 --- a/x-pack/test/api_integration/services/index.ts +++ b/x-pack/test/api_integration/services/index.ts @@ -15,6 +15,7 @@ import { SupertestWithoutAuthProvider } from './supertest_without_auth'; import { UsageAPIProvider } from './usage_api'; +import { AiopsProvider } from './aiops'; import { InfraOpsSourceConfigurationProvider } from './infraops_source_configuration'; import { MachineLearningProvider } from './ml'; import { IngestManagerProvider } from '../../common/services/ingest_manager'; @@ -26,6 +27,7 @@ export const services = { esSupertest: kibanaApiIntegrationServices.esSupertest, supertest: kibanaApiIntegrationServices.supertest, + aiops: AiopsProvider, esSupertestWithoutAuth: EsSupertestWithoutAuthProvider, infraOpsSourceConfiguration: InfraOpsSourceConfigurationProvider, supertestWithoutAuth: SupertestWithoutAuthProvider, diff --git a/x-pack/test/functional/apps/aiops/explain_log_rate_spikes_logs.ts b/x-pack/test/functional/apps/aiops/explain_log_rate_spikes.ts similarity index 71% rename from x-pack/test/functional/apps/aiops/explain_log_rate_spikes_logs.ts rename to x-pack/test/functional/apps/aiops/explain_log_rate_spikes.ts index 2e7f7d881450c..7b36eda71a016 100644 --- a/x-pack/test/functional/apps/aiops/explain_log_rate_spikes_logs.ts +++ b/x-pack/test/functional/apps/aiops/explain_log_rate_spikes.ts @@ -8,20 +8,18 @@ import expect from '@kbn/expect'; import type { FtrProviderContext } from '../../ftr_provider_context'; -import type { TestDataGenerated } from './types'; -import { artificialLogDataViewTestData } from './test_data'; +import type { TestData } from './types'; +import { explainLogRateSpikesTestData } from './test_data'; export default function ({ getPageObject, getService }: FtrProviderContext) { - const es = getService('es'); const headerPage = getPageObject('header'); const elasticChart = getService('elasticChart'); const aiops = getService('aiops'); - const log = getService('log'); // aiops / Explain Log Rate Spikes lives in the ML UI so we need some related services. const ml = getService('ml'); - function runTests(testData: TestDataGenerated) { + function runTests(testData: TestData) { it(`${testData.suiteTitle} loads the source data in explain log rate spikes`, async () => { await elasticChart.setNewChartUiDebugFlag(true); @@ -93,7 +91,7 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { await aiops.explainLogRateSpikesPage.adjustBrushHandler( 'aiopsBrushDeviation', 'handle--w', - targetPx - intervalPx * testData.brushIntervalFactor + targetPx - intervalPx * (testData.brushIntervalFactor - 1) ); if (testData.brushBaselineTargetTimestamp) { @@ -114,9 +112,10 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { await aiops.explainLogRateSpikesPage.adjustBrushHandler( 'aiopsBrushBaseline', 'handle--w', - targetBaselinePx - intervalPx * testData.brushIntervalFactor + targetBaselinePx - intervalPx * (testData.brushIntervalFactor - 1) ); } + // Get the new brush selection width for later comparison. const brushSelectionWidthAfter = await aiops.explainLogRateSpikesPage.getBrushSelectionWidth( 'aiopsBrushDeviation' @@ -127,7 +126,9 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { // Finally, the adjusted brush should trigger // a warning on the "Rerun analysis" button. expect(brushSelectionWidthBefore).not.to.be(brushSelectionWidthAfter); - expect(brushSelectionWidthAfter).not.to.be.greaterThan(intervalPx * 21); + expect(brushSelectionWidthAfter).not.to.be.greaterThan( + intervalPx * 2 * testData.brushIntervalFactor + ); await aiops.explainLogRateSpikesPage.assertRerunAnalysisButtonExists(true); @@ -146,6 +147,7 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { const analysisGroupsTable = await aiops.explainLogRateSpikesAnalysisGroupsTable.parseAnalysisTable(); + expect(analysisGroupsTable).to.be.eql(testData.expected.analysisGroupsTable); await ml.testExecution.logTestStep('expand table row'); @@ -157,69 +159,37 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { }); } - describe('explain log rate spikes - artificial log data', function () { - this.tags(['aiops']); + describe('explain log rate spikes', async function () { + for (const testData of explainLogRateSpikesTestData) { + describe(`with '${testData.sourceIndexOrSavedSearch}'`, function () { + before(async () => { + await aiops.explainLogRateSpikesDataGenerator.generateData(testData.dataGenerator); - before(async () => { - try { - await es.indices.delete({ index: artificialLogDataViewTestData.sourceIndexOrSavedSearch }); - } catch (e) { - log.error( - `Error deleting index '${artificialLogDataViewTestData.sourceIndexOrSavedSearch}' in before() callback` - ); - } - // Create index with mapping - await es.indices.create({ - index: artificialLogDataViewTestData.sourceIndexOrSavedSearch, - mappings: { - properties: { - user: { type: 'keyword' }, - response_code: { type: 'keyword' }, - url: { type: 'keyword' }, - version: { type: 'keyword' }, - '@timestamp': { type: 'date' }, - }, - }, - }); + await ml.testResources.createIndexPatternIfNeeded( + testData.sourceIndexOrSavedSearch, + '@timestamp' + ); - await es.bulk({ - refresh: 'wait_for', - body: artificialLogDataViewTestData.bulkBody, - }); + await ml.testResources.setKibanaTimeZoneToUTC(); - await ml.testResources.createIndexPatternIfNeeded( - artificialLogDataViewTestData.sourceIndexOrSavedSearch, - '@timestamp' - ); + await ml.securityUI.loginAsMlPowerUser(); + }); - await ml.testResources.setKibanaTimeZoneToUTC(); + after(async () => { + await elasticChart.setNewChartUiDebugFlag(false); + await ml.testResources.deleteIndexPatternByTitle(testData.sourceIndexOrSavedSearch); - await ml.securityUI.loginAsMlPowerUser(); - }); + await aiops.explainLogRateSpikesDataGenerator.removeGeneratedData(testData.dataGenerator); + }); - after(async () => { - await elasticChart.setNewChartUiDebugFlag(false); - await ml.testResources.deleteIndexPatternByTitle( - artificialLogDataViewTestData.sourceIndexOrSavedSearch - ); - try { - await es.indices.delete({ index: artificialLogDataViewTestData.sourceIndexOrSavedSearch }); - } catch (e) { - log.error( - `Error deleting index '${artificialLogDataViewTestData.sourceIndexOrSavedSearch}' in after() callback` - ); - } - }); + it(`${testData.suiteTitle} loads the explain log rate spikes page`, async () => { + // Start navigation from the base of the ML app. + await ml.navigation.navigateToMl(); + await elasticChart.setNewChartUiDebugFlag(true); + }); - describe('with artificial logs', function () { - // Run tests on full farequote index. - it(`${artificialLogDataViewTestData.suiteTitle} loads the explain log rate spikes page`, async () => { - // Start navigation from the base of the ML app. - await ml.navigation.navigateToMl(); - await elasticChart.setNewChartUiDebugFlag(true); + runTests(testData); }); - - runTests(artificialLogDataViewTestData); - }); + } }); } diff --git a/x-pack/test/functional/apps/aiops/explain_log_rate_spikes_farequote.ts b/x-pack/test/functional/apps/aiops/explain_log_rate_spikes_farequote.ts deleted file mode 100644 index cb163fe2e47a8..0000000000000 --- a/x-pack/test/functional/apps/aiops/explain_log_rate_spikes_farequote.ts +++ /dev/null @@ -1,198 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import expect from '@kbn/expect'; - -import type { FtrProviderContext } from '../../ftr_provider_context'; -import type { TestDataEsArchive } from './types'; -import { farequoteDataViewTestData } from './test_data'; - -const ES_INDEX = 'ft_farequote'; - -export default function ({ getPageObject, getService }: FtrProviderContext) { - const es = getService('es'); - const headerPage = getPageObject('header'); - const elasticChart = getService('elasticChart'); - const esArchiver = getService('esArchiver'); - const aiops = getService('aiops'); - - // aiops / Explain Log Rate Spikes lives in the ML UI so we need some related services. - const ml = getService('ml'); - - function runTests(testData: TestDataEsArchive) { - it(`${testData.suiteTitle} loads the source data in explain log rate spikes`, async () => { - await elasticChart.setNewChartUiDebugFlag(true); - - await ml.testExecution.logTestStep( - `${testData.suiteTitle} loads the saved search selection page` - ); - await aiops.explainLogRateSpikesPage.navigateToIndexPatternSelection(); - - await ml.testExecution.logTestStep( - `${testData.suiteTitle} loads the explain log rate spikes page` - ); - await ml.jobSourceSelection.selectSourceForExplainLogRateSpikes( - testData.sourceIndexOrSavedSearch - ); - }); - - it(`${testData.suiteTitle} displays index details`, async () => { - await ml.testExecution.logTestStep(`${testData.suiteTitle} displays the time range step`); - await aiops.explainLogRateSpikesPage.assertTimeRangeSelectorSectionExists(); - - await ml.testExecution.logTestStep(`${testData.suiteTitle} loads data for full time range`); - await aiops.explainLogRateSpikesPage.clickUseFullDataButton( - testData.expected.totalDocCountFormatted - ); - await headerPage.waitUntilLoadingHasFinished(); - - await ml.testExecution.logTestStep( - `${testData.suiteTitle} displays elements in the doc count panel correctly` - ); - await aiops.explainLogRateSpikesPage.assertTotalDocCountHeaderExists(); - await aiops.explainLogRateSpikesPage.assertTotalDocCountChartExists(); - - await ml.testExecution.logTestStep( - `${testData.suiteTitle} displays elements in the page correctly` - ); - await aiops.explainLogRateSpikesPage.assertSearchPanelExists(); - - await ml.testExecution.logTestStep('displays empty prompt'); - await aiops.explainLogRateSpikesPage.assertNoWindowParametersEmptyPromptExists(); - - await ml.testExecution.logTestStep('clicks the document count chart to start analysis'); - await aiops.explainLogRateSpikesPage.clickDocumentCountChart(testData.chartClickCoordinates); - await aiops.explainLogRateSpikesPage.assertAnalysisSectionExists(); - - await ml.testExecution.logTestStep('displays the no results found prompt'); - await aiops.explainLogRateSpikesPage.assertNoResultsFoundEmptyPromptExists(); - - await ml.testExecution.logTestStep('adjusts the brushes to get analysis results'); - await aiops.explainLogRateSpikesPage.assertRerunAnalysisButtonExists(false); - - // Get the current width of the deviation brush for later comparison. - const brushSelectionWidthBefore = await aiops.explainLogRateSpikesPage.getBrushSelectionWidth( - 'aiopsBrushDeviation' - ); - - // Get the px values for the timestamp we want to move the brush to. - const { targetPx, intervalPx } = await aiops.explainLogRateSpikesPage.getPxForTimestamp( - testData.brushDeviationTargetTimestamp - ); - - // Adjust the right brush handle - await aiops.explainLogRateSpikesPage.adjustBrushHandler( - 'aiopsBrushDeviation', - 'handle--e', - targetPx + intervalPx - ); - - // Adjust the left brush handle - await aiops.explainLogRateSpikesPage.adjustBrushHandler( - 'aiopsBrushDeviation', - 'handle--w', - targetPx - ); - - // Get the new brush selection width for later comparison. - const brushSelectionWidthAfter = await aiops.explainLogRateSpikesPage.getBrushSelectionWidth( - 'aiopsBrushDeviation' - ); - - // Assert the adjusted brush: The selection width should have changed and - // we test if the selection is smaller than two bucket intervals. - // Finally, the adjusted brush should trigger - // a warning on the "Rerun analysis" button. - expect(brushSelectionWidthBefore).not.to.be(brushSelectionWidthAfter); - expect(brushSelectionWidthAfter).not.to.be.greaterThan(intervalPx * 2); - - await aiops.explainLogRateSpikesPage.assertRerunAnalysisButtonExists(true); - - await ml.testExecution.logTestStep('rerun the analysis with adjusted settings'); - - await aiops.explainLogRateSpikesPage.clickRerunAnalysisButton(true); - await aiops.explainLogRateSpikesPage.assertProgressTitle('Progress: 100% — Done.'); - - // The group switch should be disabled by default - await aiops.explainLogRateSpikesPage.assertSpikeAnalysisGroupSwitchExists(false); - - // Enabled grouping - await aiops.explainLogRateSpikesPage.clickSpikeAnalysisGroupSwitch(false); - - await aiops.explainLogRateSpikesAnalysisGroupsTable.assertSpikeAnalysisTableExists(); - - const analysisGroupsTable = - await aiops.explainLogRateSpikesAnalysisGroupsTable.parseAnalysisTable(); - - expect(analysisGroupsTable).to.be.eql(testData.expected.analysisGroupsTable); - - await ml.testExecution.logTestStep('expand table row'); - await aiops.explainLogRateSpikesAnalysisGroupsTable.assertExpandRowButtonExists(); - await aiops.explainLogRateSpikesAnalysisGroupsTable.expandRow(); - - const analysisTable = await aiops.explainLogRateSpikesAnalysisTable.parseAnalysisTable(); - expect(analysisTable).to.be.eql(testData.expected.analysisTable); - }); - } - - describe('explain log rate spikes', function () { - this.tags(['aiops']); - before(async () => { - await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); - - await ml.testResources.createIndexPatternIfNeeded(ES_INDEX, '@timestamp'); - - await es.updateByQuery({ - index: ES_INDEX, - body: { - script: { - // @ts-expect-error - inline: 'ctx._source.custom_field = "default"', - lang: 'painless', - }, - }, - }); - - await es.bulk({ - refresh: 'wait_for', - body: [...Array(100)].flatMap((i) => { - return [ - { index: { _index: ES_INDEX } }, - { - '@timestamp': '2016-02-09T16:19:59.000Z', - '@version': i, - airline: 'UAL', - custom_field: 'deviation', - responsetime: 10, - type: 'farequote', - }, - ]; - }), - }); - - await ml.testResources.setKibanaTimeZoneToUTC(); - - await ml.securityUI.loginAsMlPowerUser(); - }); - - after(async () => { - await elasticChart.setNewChartUiDebugFlag(false); - await ml.testResources.deleteIndexPatternByTitle(ES_INDEX); - }); - - describe('with farequote', function () { - // Run tests on full farequote index. - it(`${farequoteDataViewTestData.suiteTitle} loads the explain log rate spikes page`, async () => { - // Start navigation from the base of the ML app. - await ml.navigation.navigateToMl(); - await elasticChart.setNewChartUiDebugFlag(true); - }); - - runTests(farequoteDataViewTestData); - }); - }); -} diff --git a/x-pack/test/functional/apps/aiops/index.ts b/x-pack/test/functional/apps/aiops/index.ts index 9552ed3d8c61a..6bcda13122004 100644 --- a/x-pack/test/functional/apps/aiops/index.ts +++ b/x-pack/test/functional/apps/aiops/index.ts @@ -8,8 +8,6 @@ import type { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, loadTestFile }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); - // aiops / Explain Log Rate Spikes lives in the ML UI so we need some related services. const ml = getService('ml'); @@ -28,12 +26,9 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await ml.securityCommon.cleanMlUsers(); await ml.securityCommon.cleanMlRoles(); - await esArchiver.unload('x-pack/test/functional/es_archives/ml/farequote'); - await ml.testResources.resetKibanaTimeZone(); }); - loadTestFile(require.resolve('./explain_log_rate_spikes_farequote')); - loadTestFile(require.resolve('./explain_log_rate_spikes_logs')); + loadTestFile(require.resolve('./explain_log_rate_spikes')); }); } diff --git a/x-pack/test/functional/apps/aiops/test_data.ts b/x-pack/test/functional/apps/aiops/test_data.ts index f8ce5c219c7c6..4a07dcc8a7433 100644 --- a/x-pack/test/functional/apps/aiops/test_data.ts +++ b/x-pack/test/functional/apps/aiops/test_data.ts @@ -5,12 +5,11 @@ * 2.0. */ -import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { TestData } from './types'; -import type { GeneratedDoc, TestDataEsArchive, TestDataGenerated } from './types'; - -export const farequoteDataViewTestData: TestDataEsArchive = { - suiteTitle: 'farequote index pattern', +export const farequoteDataViewTestData: TestData = { + suiteTitle: 'farequote with spike', + dataGenerator: 'farequote_with_spike', isSavedSearch: false, sourceIndexOrSavedSearch: 'ft_farequote', brushDeviationTargetTimestamp: 1455033600000, @@ -39,110 +38,38 @@ export const farequoteDataViewTestData: TestDataEsArchive = { const REFERENCE_TS = 1669018354793; const DAY_MS = 86400000; -const ES_INDEX = 'aiops_frequent_items_test'; const DEVIATION_TS = REFERENCE_TS - DAY_MS * 2; const BASELINE_TS = DEVIATION_TS - DAY_MS * 1; -export const artificialLogDataViewTestData: TestDataGenerated = { - suiteTitle: 'artificial index pattern', +export const artificialLogDataViewTestData: TestData = { + suiteTitle: 'artificial logs with spike', + dataGenerator: 'artificial_logs_with_spike', isSavedSearch: false, - sourceIndexOrSavedSearch: 'aiops_frequent_items_test', + sourceIndexOrSavedSearch: 'artificial_logs_with_spike', brushBaselineTargetTimestamp: BASELINE_TS + DAY_MS / 2, brushDeviationTargetTimestamp: DEVIATION_TS + DAY_MS / 2, brushIntervalFactor: 10, chartClickCoordinates: [-200, 30], - bulkBody: getArtificialLogsBulkBody(), expected: { totalDocCountFormatted: '8,400', analysisGroupsTable: [ - { group: 'user: Peter', docCount: '2081' }, - { group: 'response_code: 500url: login.php', docCount: '834' }, + { group: 'user: Peter', docCount: '1981' }, + { group: 'response_code: 500url: login.php', docCount: '792' }, ], analysisTable: [ { fieldName: 'user', fieldValue: 'Peter', logRate: 'Chart type:bar chart', - pValue: '2.78e-22', + pValue: '2.75e-21', impact: 'High', }, ], }, }; -function getArtificialLogsBulkBody() { - const bulkBody: estypes.BulkRequest['body'] = []; - const action = { index: { _index: ES_INDEX } }; - let tsOffset = 0; - - // Creates docs evenly spread across baseline and deviation time frame - [BASELINE_TS, DEVIATION_TS].forEach((ts) => { - ['Peter', 'Paul', 'Mary'].forEach((user) => { - ['200', '404', '500'].forEach((responseCode) => { - ['login.php', 'user.php', 'home.php'].forEach((url) => { - // Don't add docs that match the exact pattern of the filter we want to base the test queries on - if ( - !( - user === 'Peter' && - responseCode === '500' && - (url === 'home.php' || url === 'login.php') - ) - ) { - tsOffset = 0; - [...Array(100)].forEach(() => { - tsOffset += DAY_MS / 100; - const doc: GeneratedDoc = { - user, - response_code: responseCode, - url, - version: 'v1.0.0', - '@timestamp': ts + tsOffset, - }; - - bulkBody.push(action); - bulkBody.push(doc); - }); - } - }); - }); - }); - }); - - // Now let's add items to the dataset to make some specific significant terms being returned as results - ['200', '404'].forEach((responseCode) => { - ['login.php', 'user.php', 'home.php'].forEach((url) => { - tsOffset = 0; - [...Array(300)].forEach(() => { - tsOffset += DAY_MS / 300; - bulkBody.push(action); - bulkBody.push({ - user: 'Peter', - response_code: responseCode, - url, - version: 'v1.0.0', - '@timestamp': DEVIATION_TS + tsOffset, - }); - }); - }); - }); - - ['Paul', 'Mary'].forEach((user) => { - ['login.php', 'home.php'].forEach((url) => { - tsOffset = 0; - [...Array(400)].forEach(() => { - tsOffset += DAY_MS / 400; - bulkBody.push(action); - bulkBody.push({ - user, - response_code: '500', - url, - version: 'v1.0.0', - '@timestamp': DEVIATION_TS + tsOffset, - }); - }); - }); - }); - - return bulkBody; -} +export const explainLogRateSpikesTestData: TestData[] = [ + farequoteDataViewTestData, + artificialLogDataViewTestData, +]; diff --git a/x-pack/test/functional/apps/aiops/types.ts b/x-pack/test/functional/apps/aiops/types.ts index acbf96a9db45c..a7f53f55976ea 100644 --- a/x-pack/test/functional/apps/aiops/types.ts +++ b/x-pack/test/functional/apps/aiops/types.ts @@ -5,10 +5,9 @@ * 2.0. */ -import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; - -export interface TestDataEsArchive { +export interface TestData { suiteTitle: string; + dataGenerator: string; isSavedSearch?: boolean; sourceIndexOrSavedSearch: string; rowsPerPage?: 10 | 25 | 50; @@ -28,15 +27,3 @@ export interface TestDataEsArchive { }>; }; } - -export interface GeneratedDoc { - user: string; - response_code: string; - url: string; - version: string; - '@timestamp': number; -} - -export interface TestDataGenerated extends TestDataEsArchive { - bulkBody: estypes.BulkRequest['body']; -} diff --git a/x-pack/test/functional/services/aiops/explain_log_rate_spikes_data_generator.ts b/x-pack/test/functional/services/aiops/explain_log_rate_spikes_data_generator.ts new file mode 100644 index 0000000000000..2039ab7b7ae3c --- /dev/null +++ b/x-pack/test/functional/services/aiops/explain_log_rate_spikes_data_generator.ts @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export interface GeneratedDoc { + user: string; + response_code: string; + url: string; + version: string; + '@timestamp': number; +} + +const REFERENCE_TS = 1669018354793; +const DAY_MS = 86400000; + +const DEVIATION_TS = REFERENCE_TS - DAY_MS * 2; +const BASELINE_TS = DEVIATION_TS - DAY_MS * 1; + +function getArtificialLogsWithSpike(index: string) { + const bulkBody: estypes.BulkRequest['body'] = []; + const action = { index: { _index: index } }; + let tsOffset = 0; + + // Creates docs evenly spread across baseline and deviation time frame + [BASELINE_TS, DEVIATION_TS].forEach((ts) => { + ['Peter', 'Paul', 'Mary'].forEach((user) => { + ['200', '404', '500'].forEach((responseCode) => { + ['login.php', 'user.php', 'home.php'].forEach((url) => { + // Don't add docs that match the exact pattern of the filter we want to base the test queries on + if ( + !( + user === 'Peter' && + responseCode === '500' && + (url === 'home.php' || url === 'login.php') + ) + ) { + tsOffset = 0; + [...Array(100)].forEach(() => { + tsOffset += DAY_MS / 100; + const doc: GeneratedDoc = { + user, + response_code: responseCode, + url, + version: 'v1.0.0', + '@timestamp': ts + tsOffset, + }; + + bulkBody.push(action); + bulkBody.push(doc); + }); + } + }); + }); + }); + }); + + // Now let's add items to the dataset to make some specific significant terms being returned as results + ['200', '404'].forEach((responseCode) => { + ['login.php', 'user.php', 'home.php'].forEach((url) => { + tsOffset = 0; + [...Array(300)].forEach(() => { + tsOffset += DAY_MS / 300; + bulkBody.push(action); + bulkBody.push({ + user: 'Peter', + response_code: responseCode, + url, + version: 'v1.0.0', + '@timestamp': DEVIATION_TS + tsOffset, + }); + }); + }); + }); + + ['Paul', 'Mary'].forEach((user) => { + ['login.php', 'home.php'].forEach((url) => { + tsOffset = 0; + [...Array(400)].forEach(() => { + tsOffset += DAY_MS / 400; + bulkBody.push(action); + bulkBody.push({ + user, + response_code: '500', + url, + version: 'v1.0.0', + '@timestamp': DEVIATION_TS + tsOffset, + }); + }); + }); + }); + + return bulkBody; +} + +export function ExplainLogRateSpikesDataGeneratorProvider({ getService }: FtrProviderContext) { + const es = getService('es'); + const esArchiver = getService('esArchiver'); + const log = getService('log'); + + return new (class DataGenerator { + public async generateData(dataGenerator: string) { + switch (dataGenerator) { + case 'farequote_with_spike': + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); + + await es.updateByQuery({ + index: 'ft_farequote', + body: { + script: { + // @ts-expect-error + inline: 'ctx._source.custom_field = "default"', + lang: 'painless', + }, + }, + }); + + await es.bulk({ + refresh: 'wait_for', + body: [...Array(100)].flatMap((i) => { + return [ + { index: { _index: 'ft_farequote' } }, + { + '@timestamp': '2016-02-09T16:19:59.000Z', + '@version': i, + airline: 'UAL', + custom_field: 'deviation', + responsetime: 10, + type: 'farequote', + }, + ]; + }), + }); + break; + + case 'artificial_logs_with_spike': + try { + await es.indices.delete({ + index: 'artificial_logs_with_spike', + }); + } catch (e) { + log.info(`Could not delete index 'artificial_logs_with_spike' in before() callback`); + } + + // Create index with mapping + await es.indices.create({ + index: 'artificial_logs_with_spike', + mappings: { + properties: { + user: { type: 'keyword' }, + response_code: { type: 'keyword' }, + url: { type: 'keyword' }, + version: { type: 'keyword' }, + '@timestamp': { type: 'date' }, + }, + }, + }); + + await es.bulk({ + refresh: 'wait_for', + body: getArtificialLogsWithSpike('artificial_logs_with_spike'), + }); + break; + + default: + log.error(`Unsupported data generator '${dataGenerator}`); + } + } + + public async removeGeneratedData(dataGenerator: string) { + switch (dataGenerator) { + case 'farequote_with_spike': + await esArchiver.unload('x-pack/test/functional/es_archives/ml/farequote'); + break; + + case 'artificial_logs_with_spike': + try { + await es.indices.delete({ + index: 'artificial_logs_with_spike', + }); + } catch (e) { + log.error(`Error deleting index 'artificial_logs_with_spike' in after() callback`); + } + break; + + default: + log.error(`Unsupported data generator '${dataGenerator}`); + } + } + })(); +} diff --git a/x-pack/test/functional/services/aiops/index.ts b/x-pack/test/functional/services/aiops/index.ts index 4b99fffd39fa4..4816d37bcff04 100644 --- a/x-pack/test/functional/services/aiops/index.ts +++ b/x-pack/test/functional/services/aiops/index.ts @@ -10,16 +10,19 @@ import type { FtrProviderContext } from '../../ftr_provider_context'; import { ExplainLogRateSpikesPageProvider } from './explain_log_rate_spikes_page'; import { ExplainLogRateSpikesAnalysisTableProvider } from './explain_log_rate_spikes_analysis_table'; import { ExplainLogRateSpikesAnalysisGroupsTableProvider } from './explain_log_rate_spikes_analysis_groups_table'; +import { ExplainLogRateSpikesDataGeneratorProvider } from './explain_log_rate_spikes_data_generator'; export function AiopsProvider(context: FtrProviderContext) { const explainLogRateSpikesPage = ExplainLogRateSpikesPageProvider(context); const explainLogRateSpikesAnalysisTable = ExplainLogRateSpikesAnalysisTableProvider(context); const explainLogRateSpikesAnalysisGroupsTable = ExplainLogRateSpikesAnalysisGroupsTableProvider(context); + const explainLogRateSpikesDataGenerator = ExplainLogRateSpikesDataGeneratorProvider(context); return { explainLogRateSpikesPage, explainLogRateSpikesAnalysisTable, explainLogRateSpikesAnalysisGroupsTable, + explainLogRateSpikesDataGenerator, }; }