From 456080214bdfb14ee13d1afe4112beae7da9fa52 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Thu, 1 Feb 2024 13:33:05 +0100 Subject: [PATCH] [ML] AIOps: Adds link to log rate analysis from anomaly table (#175289) ## Summary Part of #153753. Adds an action to the actions menu of the ML anomaly table in both Anomaly Explorer and Single Metric Viewer to Log Rate Analysis. The action is available for the anomaly detection functions `count`, `high_count`, `low_count`, `high_non_zero_count` and `low_non_zero_count`. - For multi-metric and population jobs, adds the current entity as a Kuery search filter. - Fixes an issue with restoring the correct time range from url state. - Fixes an issue with restoring the correct query from url state. - The action auto-selects an overall time range and baseline and deviation time range based on the anomaly timestamp and multiples of bucket spans. Functional tests have been added that test the actions in both Anomaly Explorer and Single Metric Viewer for single and multi metric `count` job, as well as testing a job when the action should not be available. [aiops-ad-link-0001.webm](https://github.com/elastic/kibana/assets/230104/c1d1336b-f62d-4861-b1a4-1a8ed9dbef4c) ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/4929 - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- .../create_categorize_query.ts | 4 +- .../application/url_state/common.test.ts | 22 ++ .../public/application/url_state/common.ts | 5 + .../log_categorization/use_discover_links.ts | 5 +- .../log_rate_analysis_content.tsx | 4 +- .../log_rate_analysis_page.tsx | 25 +- x-pack/plugins/aiops/public/hooks/use_data.ts | 9 +- .../plugins/aiops/public/hooks/use_search.ts | 63 ++-- .../components/anomalies_table/links_menu.tsx | 158 ++++++++-- x-pack/test/functional/apps/aiops/index.ts | 1 + .../aiops/log_rate_analysis_anomaly_table.ts | 287 ++++++++++++++++++ .../services/aiops/log_rate_analysis_page.ts | 8 +- .../functional/services/ml/anomalies_table.ts | 18 ++ 13 files changed, 554 insertions(+), 55 deletions(-) create mode 100644 x-pack/plugins/aiops/public/application/url_state/common.test.ts create mode 100644 x-pack/test/functional/apps/aiops/log_rate_analysis_anomaly_table.ts diff --git a/x-pack/plugins/aiops/common/api/log_categorization/create_categorize_query.ts b/x-pack/plugins/aiops/common/api/log_categorization/create_categorize_query.ts index 1c5f5cbbbc0cdc..c3289d1527f2b4 100644 --- a/x-pack/plugins/aiops/common/api/log_categorization/create_categorize_query.ts +++ b/x-pack/plugins/aiops/common/api/log_categorization/create_categorize_query.ts @@ -10,11 +10,11 @@ import { cloneDeep } from 'lodash'; import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; export function createCategorizeQuery( - queryIn: QueryDslQueryContainer, + queryIn: QueryDslQueryContainer | undefined, timeField: string, timeRange: { from: number; to: number } | undefined ) { - const query = cloneDeep(queryIn); + const query = cloneDeep(queryIn ?? { match_all: {} }); if (query.bool === undefined) { query.bool = {}; diff --git a/x-pack/plugins/aiops/public/application/url_state/common.test.ts b/x-pack/plugins/aiops/public/application/url_state/common.test.ts new file mode 100644 index 00000000000000..5a972eec7715b0 --- /dev/null +++ b/x-pack/plugins/aiops/public/application/url_state/common.test.ts @@ -0,0 +1,22 @@ +/* + * 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 { isDefaultSearchQuery } from './common'; + +describe('isDefaultSearchQuery', () => { + it('returns true for default search query', () => { + expect(isDefaultSearchQuery({ match_all: {} })).toBe(true); + }); + + it('returns false for non default search query', () => { + expect( + isDefaultSearchQuery({ + bool: { must_not: [{ term: { 'the-term': 'the-value' } }] }, + }) + ).toBe(false); + }); +}); diff --git a/x-pack/plugins/aiops/public/application/url_state/common.ts b/x-pack/plugins/aiops/public/application/url_state/common.ts index 8ca9ec848150ce..eb037dabb9648d 100644 --- a/x-pack/plugins/aiops/public/application/url_state/common.ts +++ b/x-pack/plugins/aiops/public/application/url_state/common.ts @@ -8,11 +8,16 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { Filter, Query } from '@kbn/es-query'; +import { isPopulatedObject } from '@kbn/ml-is-populated-object'; import { SEARCH_QUERY_LANGUAGE, type SearchQueryLanguage } from '@kbn/ml-query-utils'; const defaultSearchQuery = { match_all: {}, +} as const; + +export const isDefaultSearchQuery = (arg: unknown): arg is typeof defaultSearchQuery => { + return isPopulatedObject(arg, ['match_all']); }; export interface AiOpsPageUrlState { diff --git a/x-pack/plugins/aiops/public/components/log_categorization/use_discover_links.ts b/x-pack/plugins/aiops/public/components/log_categorization/use_discover_links.ts index 8d83a884156891..9d80758797324f 100644 --- a/x-pack/plugins/aiops/public/components/log_categorization/use_discover_links.ts +++ b/x-pack/plugins/aiops/public/components/log_categorization/use_discover_links.ts @@ -56,10 +56,7 @@ export function useDiscoverLinks() { }, }); - let path = basePath.get(); - path += '/app/discover#/'; - path += '?_g=' + _g; - path += '&_a=' + encodeURIComponent(_a); + const path = `${basePath.get()}/app/discover#/?_g=${_g}&_a=${encodeURIComponent(_a)}`; window.open(path, '_blank'); }; diff --git a/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_content/log_rate_analysis_content.tsx b/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_content/log_rate_analysis_content.tsx index 51244a23006345..daac98f67f7502 100644 --- a/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_content/log_rate_analysis_content.tsx +++ b/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_content/log_rate_analysis_content.tsx @@ -77,7 +77,7 @@ export interface LogRateAnalysisContentProps { /** Optional callback that exposes data of the completed analysis */ onAnalysisCompleted?: (d: LogRateAnalysisResultsData) => void; /** Optional callback that exposes current window parameters */ - onWindowParametersChange?: (wp?: WindowParameters) => void; + onWindowParametersChange?: (wp?: WindowParameters, replace?: boolean) => void; /** Identifier to indicate the plugin utilizing the component */ embeddingOrigin: string; } @@ -126,7 +126,7 @@ export const LogRateAnalysisContent: FC = ({ windowParametersTouched.current = true; if (onWindowParametersChange) { - onWindowParametersChange(windowParameters); + onWindowParametersChange(windowParameters, true); } }, [onWindowParametersChange, windowParameters]); diff --git a/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_page.tsx b/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_page.tsx index 4931503b7366e8..22b6a68006a5e6 100644 --- a/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_page.tsx +++ b/x-pack/plugins/aiops/public/components/log_rate_analysis/log_rate_analysis_page.tsx @@ -113,10 +113,14 @@ export const LogRateAnalysisPage: FC = ({ stickyHistogram }) => { useEffect(() => { if (globalState?.time !== undefined) { - timefilter.setTime({ - from: globalState.time.from, - to: globalState.time.to, - }); + if ( + !isEqual({ from: globalState.time.from, to: globalState.time.to }, timefilter.getTime()) + ) { + timefilter.setTime({ + from: globalState.time.from, + to: globalState.time.to, + }); + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(globalState?.time), timefilter]); @@ -136,11 +140,14 @@ export const LogRateAnalysisPage: FC = ({ stickyHistogram }) => { }); }, [dataService, searchQueryLanguage, searchString]); - const onWindowParametersHandler = (wp?: WindowParameters) => { - if (!isEqual(wp, stateFromUrl.wp)) { - setUrlState({ - wp: windowParametersToAppState(wp), - }); + const onWindowParametersHandler = (wp?: WindowParameters, replace = false) => { + if (!isEqual(windowParametersToAppState(wp), stateFromUrl.wp)) { + setUrlState( + { + wp: windowParametersToAppState(wp), + }, + replace + ); } }; diff --git a/x-pack/plugins/aiops/public/hooks/use_data.ts b/x-pack/plugins/aiops/public/hooks/use_data.ts index 934a470588960b..4f0e2526d7a1db 100644 --- a/x-pack/plugins/aiops/public/hooks/use_data.ts +++ b/x-pack/plugins/aiops/public/hooks/use_data.ts @@ -54,9 +54,14 @@ export const useData = ( timeRangeSelector: selectedDataView?.timeFieldName !== undefined, autoRefreshSelector: true, }); + const timeRangeMemoized = useMemo( + () => timefilter.getActiveBounds(), + // eslint-disable-next-line react-hooks/exhaustive-deps + [JSON.stringify(timefilter.getActiveBounds())] + ); const fieldStatsRequest: DocumentStatsSearchStrategyParams | undefined = useMemo(() => { - const timefilterActiveBounds = timeRange ?? timefilter.getActiveBounds(); + const timefilterActiveBounds = timeRange ?? timeRangeMemoized; if (timefilterActiveBounds !== undefined) { _timeBuckets.setInterval('auto'); _timeBuckets.setBounds(timefilterActiveBounds); @@ -72,7 +77,7 @@ export const useData = ( }; } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [lastRefresh, searchQuery, timeRange]); + }, [lastRefresh, searchQuery, timeRange, timeRangeMemoized]); const overallStatsRequest = useMemo(() => { return fieldStatsRequest diff --git a/x-pack/plugins/aiops/public/hooks/use_search.ts b/x-pack/plugins/aiops/public/hooks/use_search.ts index 8c62db36289fdc..060e87dab59c5a 100644 --- a/x-pack/plugins/aiops/public/hooks/use_search.ts +++ b/x-pack/plugins/aiops/public/hooks/use_search.ts @@ -9,9 +9,15 @@ import { useMemo } from 'react'; import type { DataView } from '@kbn/data-views-plugin/public'; import type { SavedSearch } from '@kbn/saved-search-plugin/public'; +import { isQuery } from '@kbn/data-plugin/public'; import { getEsQueryFromSavedSearch } from '../application/utils/search_utils'; -import type { AiOpsIndexBasedAppState } from '../application/url_state/common'; +import { + isDefaultSearchQuery, + type AiOpsIndexBasedAppState, +} from '../application/url_state/common'; +import { createMergedEsQuery } from '../application/utils/search_utils'; + import { useAiopsAppContext } from './use_aiops_app_context'; export const useSearch = ( @@ -37,23 +43,44 @@ export const useSearch = ( [dataView, uiSettings, savedSearch, filterManager] ); - if (searchData === undefined || (aiopsListState && aiopsListState.searchString !== '')) { - if (aiopsListState?.filters && readOnly === false) { - const globalFilters = filterManager?.getGlobalFilters(); + return useMemo(() => { + if (searchData === undefined || (aiopsListState && aiopsListState.searchString !== '')) { + if (aiopsListState?.filters && readOnly === false) { + const globalFilters = filterManager?.getGlobalFilters(); + + if (filterManager) filterManager.setFilters(aiopsListState.filters); + if (globalFilters) filterManager?.addFilters(globalFilters); + } + + // In cases where the url state contains only a KQL query and not yet + // the transformed ES query we regenerate it. This may happen if we restore + // url state on page load coming from another page like ML's Single Metric Viewer. + let searchQuery = aiopsListState?.searchQuery; + const query = { + language: aiopsListState?.searchQueryLanguage, + query: aiopsListState?.searchString, + }; + if ( + (aiopsListState.searchString !== '' || + (Array.isArray(aiopsListState.filters) && aiopsListState.filters.length > 0)) && + (isDefaultSearchQuery(searchQuery) || searchQuery === undefined) && + isQuery(query) + ) { + searchQuery = createMergedEsQuery(query, aiopsListState.filters, dataView, uiSettings); + } - if (filterManager) filterManager.setFilters(aiopsListState.filters); - if (globalFilters) filterManager?.addFilters(globalFilters); + return { + ...(isDefaultSearchQuery(searchQuery) ? {} : { searchQuery }), + searchString: aiopsListState?.searchString, + searchQueryLanguage: aiopsListState?.searchQueryLanguage, + }; + } else { + return { + searchQuery: searchData.searchQuery, + searchString: searchData.searchString, + searchQueryLanguage: searchData.queryLanguage, + }; } - return { - searchQuery: aiopsListState?.searchQuery, - searchString: aiopsListState?.searchString, - searchQueryLanguage: aiopsListState?.searchQueryLanguage, - }; - } else { - return { - searchQuery: searchData.searchQuery, - searchString: searchData.searchString, - searchQueryLanguage: searchData.queryLanguage, - }; - } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify([searchData, aiopsListState])]); }; diff --git a/x-pack/plugins/ml/public/application/components/anomalies_table/links_menu.tsx b/x-pack/plugins/ml/public/application/components/anomalies_table/links_menu.tsx index b81f632c9a62c7..c67092978d38a9 100644 --- a/x-pack/plugins/ml/public/application/components/anomalies_table/links_menu.tsx +++ b/x-pack/plugins/ml/public/application/components/anomalies_table/links_menu.tsx @@ -9,7 +9,7 @@ import { cloneDeep } from 'lodash'; import moment from 'moment'; import rison from '@kbn/rison'; import React, { FC, useEffect, useMemo, useState } from 'react'; -import { APP_ID as MAPS_APP_ID } from '@kbn/maps-plugin/common'; + import { EuiButtonIcon, EuiContextMenuItem, @@ -18,6 +18,9 @@ import { EuiProgress, EuiToolTip, } from '@elastic/eui'; + +import type { SerializableRecord } from '@kbn/utility-types'; +import { APP_ID as MAPS_APP_ID } from '@kbn/maps-plugin/common'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { ES_FIELD_TYPES } from '@kbn/field-types'; @@ -36,15 +39,19 @@ import type { DataView, DataViewField } from '@kbn/data-views-plugin/common'; import { CATEGORIZE_FIELD_TRIGGER } from '@kbn/ml-ui-actions'; import { isDefined } from '@kbn/ml-is-defined'; import { escapeQuotes } from '@kbn/es-query'; +import { isQuery } from '@kbn/data-plugin/public'; + import { PLUGIN_ID } from '../../../../common/constants/app'; -import { mlJobService } from '../../services/job_service'; import { findMessageField, getDataViewIdFromName } from '../../util/index_utils'; import { getInitialAnomaliesLayers, getInitialSourceIndexFieldLayers } from '../../../maps/util'; import { parseInterval } from '../../../../common/util/parse_interval'; +import { ML_APP_LOCATOR, ML_PAGES } from '../../../../common/constants/locator'; +import { getFiltersForDSLQuery } from '../../../../common/util/job_utils'; + +import { mlJobService } from '../../services/job_service'; import { ml } from '../../services/ml_api_service'; import { escapeKueryForFieldValuePair, replaceStringTokens } from '../../util/string_utils'; import { getUrlForRecord, openCustomUrlWindow } from '../../util/custom_url_utils'; -import { ML_APP_LOCATOR, ML_PAGES } from '../../../../common/constants/locator'; import { escapeDoubleQuotes, getDateFormatTz, @@ -54,8 +61,12 @@ import { usePermissionCheck } from '../../capabilities/check_capabilities'; import type { TimeRangeBounds } from '../../util/time_buckets'; import { useMlKibana } from '../../contexts/kibana'; import { getFieldTypeFromMapping } from '../../services/mapping_service'; + import { getQueryStringForInfluencers } from './get_query_string_for_influencers'; -import { getFiltersForDSLQuery } from '../../../../common/util/job_utils'; + +const LOG_RATE_ANALYSIS_MARGIN_FACTOR = 20; +const LOG_RATE_ANALYSIS_BASELINE_FACTOR = 15; + interface LinksMenuProps { anomaly: MlAnomaliesTableRecord; bounds: TimeRangeBounds; @@ -71,6 +82,7 @@ interface LinksMenuProps { export const LinksMenuUI = (props: LinksMenuProps) => { const [openInDiscoverUrl, setOpenInDiscoverUrl] = useState(); const [discoverUrlError, setDiscoverUrlError] = useState(); + const [openInLogRateAnalysisUrl, setOpenInLogRateAnalysisUrl] = useState(); const [messageField, setMessageField] = useState<{ dataView: DataView; @@ -239,20 +251,39 @@ export const LinksMenuUI = (props: LinksMenuProps) => { } })(); - const generateDiscoverUrl = async () => { + // withWindowParameters is used to generate the url state + // for Log Rate Analysis to create a baseline and deviation + // selection based on the anomaly record timestamp and bucket span. + const generateRedirectUrlPageState = async ( + withWindowParameters = false, + timeAttribute = 'timeRange' + ): Promise => { const interval = props.interval; const dataViewId = await getDataViewId(); const record = props.anomaly.source; - const earliestMoment = moment(record.timestamp).startOf(interval); - if (interval === 'hour') { + // Use the exact timestamp for Log Rate Analysis, + // in all other cases snap it to the provided interval. + const earliestMoment = withWindowParameters + ? moment(record.timestamp) + : moment(record.timestamp).startOf(interval); + + // For Log Rate Analysis, look back further to + // provide enough room for the baseline time range. + // In all other cases look back 1 hour. + if (withWindowParameters) { + earliestMoment.subtract(record.bucket_span * LOG_RATE_ANALYSIS_MARGIN_FACTOR, 's'); + } else if (interval === 'hour') { // Start from the previous hour. earliestMoment.subtract(1, 'h'); } const latestMoment = moment(record.timestamp).add(record.bucket_span, 's'); - if (props.isAggregatedData === true) { + + if (withWindowParameters) { + latestMoment.add(record.bucket_span * LOG_RATE_ANALYSIS_MARGIN_FACTOR, 's'); + } else if (props.isAggregatedData === true) { if (interval === 'hour') { // Show to the end of the next hour. latestMoment.add(1, 'h'); @@ -263,9 +294,16 @@ export const LinksMenuUI = (props: LinksMenuProps) => { const from = timeFormatter(earliestMoment.unix() * 1000); // e.g. 2016-02-08T16:00:00.000Z const to = timeFormatter(latestMoment.unix() * 1000); // e.g. 2016-02-08T18:59:59.000Z + // The window parameters for Log Rate Analysis. + // The deviation time range will span the current anomaly's bucket. + const dMin = record.timestamp; + const dMax = record.timestamp + record.bucket_span * 1000; + const bMax = dMin - record.bucket_span * 1000; + const bMin = bMax - record.bucket_span * 1000 * LOG_RATE_ANALYSIS_BASELINE_FACTOR; + let kqlQuery = ''; - if (record.influencers) { + if (record.influencers && !withWindowParameters) { kqlQuery = record.influencers .filter((influencer) => isDefined(influencer)) .map((influencer) => { @@ -283,9 +321,21 @@ export const LinksMenuUI = (props: LinksMenuProps) => { .join(' AND '); } - const url = await discoverLocator.getRedirectUrl({ + // For multi-metric or population jobs, we add the selected entity for links to + // Log Rate Analysis, so they can be restored as part of the search filter. + if (withWindowParameters && props.anomaly.entityName && props.anomaly.entityValue) { + if (kqlQuery !== '') { + kqlQuery += ' AND '; + } + + kqlQuery = `"${escapeQuotes(props.anomaly.entityName)}":"${escapeQuotes( + props.anomaly.entityValue + '' + )}"`; + } + + return { indexPatternId: dataViewId, - timeRange: { + [timeAttribute]: { from, to, mode: 'absolute', @@ -298,15 +348,76 @@ export const LinksMenuUI = (props: LinksMenuProps) => { dataViewId === null ? [] : getFiltersForDSLQuery(job.datafeed_config.query, dataViewId, job.job_id), - }); + ...(withWindowParameters + ? { + wp: { bMin, bMax, dMin, dMax }, + } + : {}), + }; + }; + + const generateDiscoverUrl = async () => { + const pageState = await generateRedirectUrlPageState(); + const url = await discoverLocator.getRedirectUrl(pageState); if (!unmounted) { setOpenInDiscoverUrl(url); } }; + const generateLogRateAnalysisUrl = async () => { + if ( + props.anomaly.source.function_description !== 'count' || + // Disable link for datafeeds that use aggregations + // and define a non-standard summary count field name + (job.analysis_config.summary_count_field_name !== undefined && + job.analysis_config.summary_count_field_name !== 'doc_count') + ) { + if (!unmounted) { + setOpenInLogRateAnalysisUrl(undefined); + } + return; + } + + const mlLocator = share.url.locators.get(ML_APP_LOCATOR); + + if (!mlLocator) { + // eslint-disable-next-line no-console + console.error('Unable to detect locator for ML or bounds'); + return; + } + const pageState = await generateRedirectUrlPageState(true, 'time'); + + const { indexPatternId, wp, query, filters, ...globalState } = pageState; + + const url = await mlLocator.getRedirectUrl({ + page: ML_PAGES.AIOPS_LOG_RATE_ANALYSIS, + pageState: { + index: indexPatternId, + globalState, + appState: { + logRateAnalysis: { + wp, + ...(isQuery(query) + ? { + filters, + searchString: query.query, + searchQueryLanguage: query.language, + } + : {}), + }, + }, + }, + }); + + if (!unmounted) { + setOpenInLogRateAnalysisUrl(url); + } + }; + if (!isCategorizationAnomalyRecord) { generateDiscoverUrl(); + generateLogRateAnalysisUrl(); } else { getDataViewId(); } @@ -606,10 +717,7 @@ export const LinksMenuUI = (props: LinksMenuProps) => { // Need to encode the _a parameter as it will contain characters such as '+' if using the regex. const { basePath } = kibana.services.http; - let path = basePath.get(); - path += '/app/discover#/'; - path += '?_g=' + _g; - path += '&_a=' + encodeURIComponent(_a); + const path = `${basePath.get()}/app/discover#/?_g=${_g}&_a=${encodeURIComponent(_a)}`; window.open(path, '_blank'); }) .catch((resp) => { @@ -813,10 +921,26 @@ export const LinksMenuUI = (props: LinksMenuProps) => { ); } + if (openInLogRateAnalysisUrl) { + items.push( + + + + ); + } + if (messageField !== null) { items.push( { closePopover(); diff --git a/x-pack/test/functional/apps/aiops/index.ts b/x-pack/test/functional/apps/aiops/index.ts index 0326a2f80b082e..8706d3d242c6bc 100644 --- a/x-pack/test/functional/apps/aiops/index.ts +++ b/x-pack/test/functional/apps/aiops/index.ts @@ -30,6 +30,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { }); loadTestFile(require.resolve('./log_rate_analysis')); + loadTestFile(require.resolve('./log_rate_analysis_anomaly_table')); loadTestFile(require.resolve('./change_point_detection')); loadTestFile(require.resolve('./log_pattern_analysis')); loadTestFile(require.resolve('./log_pattern_analysis_in_discover')); diff --git a/x-pack/test/functional/apps/aiops/log_rate_analysis_anomaly_table.ts b/x-pack/test/functional/apps/aiops/log_rate_analysis_anomaly_table.ts new file mode 100644 index 00000000000000..c92ecd8b044c3a --- /dev/null +++ b/x-pack/test/functional/apps/aiops/log_rate_analysis_anomaly_table.ts @@ -0,0 +1,287 @@ +/* + * 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 { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; + +import type { LogRateAnalysisType } from '@kbn/aiops-utils'; +import type { Datafeed, Job } from '@kbn/ml-plugin/server/shared'; + +import { isDefaultSearchQuery } from '@kbn/aiops-plugin/public/application/url_state/common'; + +import type { FtrProviderContext } from '../../ftr_provider_context'; + +import type { LogRateAnalysisDataGenerator } from '../../services/aiops/log_rate_analysis_data_generator'; + +function getJobWithDataFeed( + detectorFunction: string, + detectorField?: string, + partitionFieldName?: string, + query: QueryDslQueryContainer = { match_all: {} } +) { + const postFix = `${detectorFunction}${detectorField ? `_${detectorField}` : ''}${ + partitionFieldName ? `_${partitionFieldName}` : '' + }${!isDefaultSearchQuery(query) ? '_with_query' : ''}`; + const jobId = `fq_lra_${postFix}`; + + // @ts-expect-error not full interface + const jobConfig: Job = { + job_id: jobId, + description: `${detectorFunction}(${ + detectorField ? detectorField : '' + }) on farequote dataset with 15m bucket span`, + groups: ['farequote', 'automated', partitionFieldName ? 'multi-metric' : 'single-metric'], + analysis_config: { + bucket_span: '15m', + influencers: [], + detectors: [ + { + function: detectorFunction, + ...(detectorField ? { field_name: detectorField } : {}), + ...(partitionFieldName ? { partition_field_name: partitionFieldName } : {}), + }, + ], + }, + data_description: { time_field: '@timestamp' }, + analysis_limits: { model_memory_limit: '10mb' }, + model_plot_config: { enabled: true }, + }; + + // @ts-expect-error not full interface + const datafeedConfig: Datafeed = { + datafeed_id: `datafeed-fq_lra_${postFix}`, + indices: ['ft_farequote'], + job_id: jobId, + query, + }; + + return { jobConfig, datafeedConfig }; +} + +interface TestData { + jobConfig: Job; + datafeedConfig: Datafeed; + analysisType: LogRateAnalysisType; + dataGenerator: LogRateAnalysisDataGenerator; + entitySelectionField?: string; + entitySelectionValue?: string; + expected: { + anomalyTableLogRateAnalysisButtonAvailable: boolean; + totalDocCount?: number; + analysisResults?: Array<{ + fieldName: string; + fieldValue: string; + impact: string; + logRate: string; + pValue: string; + }>; + }; +} + +const testData: TestData[] = [ + // Single metric job, should find AAL with log rate analysis + { + ...getJobWithDataFeed('count'), + analysisType: 'spike', + dataGenerator: 'farequote_with_spike', + expected: { + anomalyTableLogRateAnalysisButtonAvailable: true, + totalDocCount: 7869, + analysisResults: [ + { + fieldName: 'airline', + fieldValue: 'AAL', + impact: 'High', + logRate: 'Chart type:bar chart', + pValue: '8.96e-49', + }, + ], + }, + }, + // Multi metric job, should filter by AAL, no significant results + { + ...getJobWithDataFeed('high_count', undefined, 'airline'), + analysisType: 'spike', + dataGenerator: 'farequote_with_spike', + entitySelectionField: 'airline', + entitySelectionValue: 'AAL', + expected: { + anomalyTableLogRateAnalysisButtonAvailable: true, + totalDocCount: 910, + }, + }, + // Single metric job with datafeed query filter, no significant results + { + ...getJobWithDataFeed('count', undefined, undefined, { + bool: { + must: [ + { + term: { + airline: { + value: 'AAL', + }, + }, + }, + ], + }, + }), + analysisType: 'spike', + dataGenerator: 'farequote_with_spike', + expected: { + anomalyTableLogRateAnalysisButtonAvailable: true, + totalDocCount: 910, + }, + }, + // Single metric job with non-count detector, link should not be available + { + ...getJobWithDataFeed('mean', 'responsetime'), + analysisType: 'spike', + dataGenerator: 'farequote_with_spike', + expected: { + anomalyTableLogRateAnalysisButtonAvailable: false, + }, + }, +]; + +export default function ({ getService }: FtrProviderContext) { + const aiops = getService('aiops'); + const browser = getService('browser'); + const comboBox = getService('comboBox'); + const esArchiver = getService('esArchiver'); + const testSubjects = getService('testSubjects'); + const ml = getService('ml'); + + describe('anomaly table with link to log rate analysis', async function () { + this.tags(['ml']); + + before(async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); + await ml.testResources.createDataViewIfNeeded('ft_farequote', '@timestamp'); + await ml.testResources.setKibanaTimeZoneToUTC(); + }); + + for (const page of ['anomaly explorer', 'single metric viewer']) { + for (const td of testData) { + const { + jobConfig, + datafeedConfig, + analysisType, + dataGenerator, + entitySelectionField, + entitySelectionValue, + expected, + } = td; + describe(`via ${page} for job ${jobConfig.job_id}`, async function () { + before(async () => { + await ml.api.createAndRunAnomalyDetectionLookbackJob(jobConfig, datafeedConfig); + + await ml.securityUI.loginAsMlPowerUser(); + }); + + after(async () => { + await ml.api.cleanMlIndices(); + await ml.testResources.deleteDataViewByTitle('ft_farequote'); + }); + + it('should navigate to ML job management', async () => { + await ml.testExecution.logTestStep('navigate to job list'); + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToJobManagement(); + }); + + it(`should load the ${page} for job '${jobConfig.job_id}`, async () => { + await ml.testExecution.logTestStep('open job in single metric viewer'); + await ml.jobTable.filterWithSearchString(jobConfig.job_id, 1); + + if (page === 'single metric viewer') { + await ml.jobTable.clickOpenJobInSingleMetricViewerButton(jobConfig.job_id); + } else { + await ml.jobTable.clickOpenJobInAnomalyExplorerButton(jobConfig.job_id); + } + + await ml.commonUI.waitForMlLoadingIndicatorToDisappear(); + + if (page === 'single metric viewer' && entitySelectionField && entitySelectionValue) { + await testSubjects.existOrFail( + `mlSingleMetricViewerEntitySelection ${entitySelectionField}` + ); + await comboBox.set( + `mlSingleMetricViewerEntitySelection ${entitySelectionField} > comboBoxInput`, + entitySelectionValue + ); + } + }); + + it(`should show the anomaly table`, async () => { + await ml.testExecution.logTestStep('displays the anomalies table'); + await ml.anomaliesTable.assertTableExists(); + + await ml.testExecution.logTestStep('anomalies table is not empty'); + await ml.anomaliesTable.assertTableNotEmpty(); + }); + + if (expected.anomalyTableLogRateAnalysisButtonAvailable) { + it('should click the log rate analysis action', async () => { + await ml.anomaliesTable.assertAnomalyActionsMenuButtonExists(0); + await ml.anomaliesTable.scrollRowIntoView(0); + await ml.anomaliesTable.assertAnomalyActionsMenuButtonEnabled(0, true); + await ml.anomaliesTable.assertAnomalyActionLogRateAnalysisButtonExists(0); + await ml.anomaliesTable.ensureAnomalyActionLogRateAnalysisButtonClicked(0); + + if (expected.totalDocCount !== undefined) { + await aiops.logPatternAnalysisPage.assertTotalDocumentCount(expected.totalDocCount); + } + }); + + const shouldHaveResults = expected.analysisResults !== undefined; + + it('should complete the analysis', async () => { + await aiops.logRateAnalysisPage.assertAnalysisComplete( + analysisType, + dataGenerator, + !shouldHaveResults + ); + }); + + if (shouldHaveResults) { + it('should show analysis results', async () => { + await aiops.logRateAnalysisResultsTable.assertLogRateAnalysisResultsTableExists(); + const actualAnalysisTable = + await aiops.logRateAnalysisResultsTable.parseAnalysisTable(); + + expect(actualAnalysisTable).to.be.eql( + expected.analysisResults, + `Expected analysis table to be ${JSON.stringify( + expected.analysisResults + )}, got ${JSON.stringify(actualAnalysisTable)}` + ); + }); + } + + it('should navigate back to the anomaly table', async () => { + await browser.goBack(); + + await ml.testExecution.logTestStep('displays the anomalies table'); + await ml.anomaliesTable.assertTableExists(); + + await ml.testExecution.logTestStep('anomalies table is not empty'); + await ml.anomaliesTable.assertTableNotEmpty(); + }); + } else { + it('should not show the log rate analysis action', async () => { + await ml.anomaliesTable.assertAnomalyActionsMenuButtonExists(0); + await ml.anomaliesTable.scrollRowIntoView(0); + await ml.anomaliesTable.assertAnomalyActionsMenuButtonEnabled(0, true); + await ml.anomaliesTable.assertAnomalyActionLogRateAnalysisButtonNotExists(0); + }); + } + }); + } + } + }); +} diff --git a/x-pack/test/functional/services/aiops/log_rate_analysis_page.ts b/x-pack/test/functional/services/aiops/log_rate_analysis_page.ts index 6b1b45f4aa9ebe..e46b751ece54dd 100644 --- a/x-pack/test/functional/services/aiops/log_rate_analysis_page.ts +++ b/x-pack/test/functional/services/aiops/log_rate_analysis_page.ts @@ -244,7 +244,8 @@ export function LogRateAnalysisPageProvider({ getService, getPageObject }: FtrPr async assertAnalysisComplete( analysisType: LogRateAnalysisType, - dataGenerator: LogRateAnalysisDataGenerator + dataGenerator: LogRateAnalysisDataGenerator, + noResults = false ) { const dataGeneratorParts = dataGenerator.split('_'); const zeroDocsFallback = dataGeneratorParts.includes('zerodocsfallback'); @@ -253,6 +254,11 @@ export function LogRateAnalysisPageProvider({ getService, getPageObject }: FtrPr const currentProgressTitle = await testSubjects.getVisibleText('aiopsAnalysisComplete'); expect(currentProgressTitle).to.be('Analysis complete'); + if (noResults) { + await testSubjects.existOrFail('aiopsNoResultsFoundEmptyPrompt'); + return; + } + await testSubjects.existOrFail('aiopsAnalysisTypeCalloutTitle'); const currentAnalysisTypeCalloutTitle = await testSubjects.getVisibleText( 'aiopsAnalysisTypeCalloutTitle' diff --git a/x-pack/test/functional/services/ml/anomalies_table.ts b/x-pack/test/functional/services/ml/anomalies_table.ts index 8e6c652b33d97a..52eaf5715f6739 100644 --- a/x-pack/test/functional/services/ml/anomalies_table.ts +++ b/x-pack/test/functional/services/ml/anomalies_table.ts @@ -131,6 +131,24 @@ export function MachineLearningAnomaliesTableProvider({ getService }: FtrProvide ); }, + async assertAnomalyActionLogRateAnalysisButtonExists(rowIndex: number) { + await this.ensureAnomalyActionsMenuOpen(rowIndex); + await testSubjects.existOrFail('mlAnomaliesListRowAction_runLogRateAnalysisButton'); + }, + + async assertAnomalyActionLogRateAnalysisButtonNotExists(rowIndex: number) { + await this.ensureAnomalyActionsMenuOpen(rowIndex); + await testSubjects.missingOrFail('mlAnomaliesListRowAction_runLogRateAnalysisButton'); + }, + + async ensureAnomalyActionLogRateAnalysisButtonClicked(rowIndex: number) { + await retry.tryForTime(10 * 1000, async () => { + await this.ensureAnomalyActionsMenuOpen(rowIndex); + await testSubjects.click('mlAnomaliesListRowAction_runLogRateAnalysisButton'); + await testSubjects.existOrFail('aiopsLogRateAnalysisPage'); + }); + }, + /** * Asserts selected number of rows per page on the pagination control. * @param rowsNumber