diff --git a/x-pack/plugins/ml/public/maps/anomaly_source_field.ts b/x-pack/plugins/ml/public/maps/anomaly_source_field.ts index bc033639708938..ac60cb3b54fb5a 100644 --- a/x-pack/plugins/ml/public/maps/anomaly_source_field.ts +++ b/x-pack/plugins/ml/public/maps/anomaly_source_field.ts @@ -98,6 +98,12 @@ export const ANOMALY_SOURCE_FIELDS: Record> = { }), type: 'string', }, + influencers: { + label: i18n.translate('xpack.ml.maps.anomalyLayerInfluencersLabel', { + defaultMessage: 'Influencers', + }), + type: 'string', + }, }; export class AnomalySourceTooltipProperty implements ITooltipProperty { diff --git a/x-pack/plugins/ml/public/maps/maps_util.test.js b/x-pack/plugins/ml/public/maps/maps_util.test.js new file mode 100644 index 00000000000000..dd6fde9e8b28c1 --- /dev/null +++ b/x-pack/plugins/ml/public/maps/maps_util.test.js @@ -0,0 +1,90 @@ +/* + * 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 { getInfluencersHtmlString, getResultsForJobId } from './util'; +import { + mlResultsServiceMock, + typicalExpected, + actualExpected, + typicalToActualExpected, +} from './results.test.mock'; + +describe('Maps util', () => { + describe('getInfluencersHtmlString', () => { + const splitField = 'split_field_influencer'; + const valueFour = 'value_four'; + const influencerFour = 'influencer_four'; + const influencers = [ + { + influencer_field_name: 'influencer_one', + influencer_field_values: ['value_one', 'value_two', 'value_three', valueFour], + }, + { + influencer_field_name: 'influencer_two', + influencer_field_values: ['value_one', 'value_two', 'value_three', valueFour], + }, + { + influencer_field_name: splitField, + influencer_field_values: ['value_one', 'value_two'], + }, + { + influencer_field_name: 'influencer_three', + influencer_field_values: ['value_one', 'value_two', 'value_three', valueFour], + }, + { + influencer_field_name: influencerFour, + influencer_field_values: ['value_one', 'value_two', 'value_three', valueFour], + }, + ]; + + test('should create the html string when given an array of influencers', () => { + const expected = + ''; + const actual = getInfluencersHtmlString(influencers, splitField); + expect(actual).toBe(expected); + // Should not include split field + expect(actual.includes(splitField)).toBe(false); + // should limit to the first three influencer values + expect(actual.includes(valueFour)).toBe(false); + // should limit to the first three influencer names + expect(actual.includes(influencerFour)).toBe(false); + }); + }); + + describe('getResultsForJobId', () => { + const jobId = 'jobId'; + const searchFilters = { + timeFilters: { from: 'now-2y', to: 'now' }, + query: { language: 'kuery', query: '' }, + }; + + test('should get map features from job anomalies results for typical layer', async () => { + const actual = await getResultsForJobId( + mlResultsServiceMock, + jobId, + 'typical', + searchFilters + ); + expect(actual).toEqual(typicalExpected); + }); + + test('should get map features from job anomalies results for actual layer', async () => { + const actual = await getResultsForJobId(mlResultsServiceMock, jobId, 'actual', searchFilters); + expect(actual).toEqual(actualExpected); + }); + + test('should get map features from job anomalies results for "typical to actual" layer', async () => { + const actual = await getResultsForJobId( + mlResultsServiceMock, + jobId, + 'typical to actual', + searchFilters + ); + expect(actual).toEqual(typicalToActualExpected); + }); + }); +}); diff --git a/x-pack/plugins/ml/public/maps/results.test.mock.ts b/x-pack/plugins/ml/public/maps/results.test.mock.ts new file mode 100644 index 00000000000000..f718e818ba73c9 --- /dev/null +++ b/x-pack/plugins/ml/public/maps/results.test.mock.ts @@ -0,0 +1,160 @@ +/* + * 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. + */ + +const results = { + took: 9, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 19, + relation: 'eq', + }, + max_score: 4.4813457, + hits: [ + { + _index: '.ml-anomalies-shared', + _id: 'test-tooltip-one_record_1645974000000_900_0_0_0', + _score: 4.4813457, + _source: { + job_id: 'test-tooltip-one', + result_type: 'record', + probability: 0.00042878057629659614, + multi_bucket_impact: -5, + record_score: 77.74620142126848, + initial_record_score: 77.74620142126848, + bucket_span: 900, + detector_index: 0, + is_interim: false, + timestamp: 1645974000000, + function: 'lat_long', + function_description: 'lat_long', + typical: [39.9864616394043, -97.862548828125], + actual: [29.261693651787937, -121.93940273718908], + field_name: 'geo.coordinates', + influencers: [ + { + influencer_field_name: 'geo.dest', + influencer_field_values: ['CN', 'DO', 'RU', 'US'], + }, + { + influencer_field_name: 'clientip', + influencer_field_values: [ + '108.131.25.207', + '192.41.143.247', + '194.12.201.131', + '41.91.106.242', + ], + }, + { + influencer_field_name: 'agent.keyword', + influencer_field_values: [ + 'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)', + 'Mozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24', + 'Mozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1', + ], + }, + ], + geo_results: { + typical_point: '39.986461639404,-97.862548828125', + actual_point: '29.261693651788,-121.939402737189', + }, + 'geo.dest': ['CN', 'DO', 'RU', 'US'], + 'agent.keyword': [ + 'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)', + 'Mozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24', + 'Mozilla/5.0 (X11; Linux x86_64; rv:6.0a1) Gecko/20110421 Firefox/6.0a1', + ], + clientip: ['108.131.25.207', '192.41.143.247', '194.12.201.131', '41.91.106.242'], + }, + }, + ], + }, +}; + +export const typicalExpected = { + features: [ + { + geometry: { coordinates: [-97.862548828125, 39.986461639404], type: 'Point' }, + properties: { + actual: [-121.939402737189, 29.261693651788], + actualDisplay: [-121.94, 29.26], + fieldName: 'geo.coordinates', + functionDescription: 'lat_long', + influencers: + '', + record_score: 77, + timestamp: 'February 27th 2022, 10:00:00', + typical: [-97.862548828125, 39.986461639404], + typicalDisplay: [-97.86, 39.99], + }, + type: 'Feature', + }, + ], + type: 'FeatureCollection', +}; + +export const actualExpected = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-121.939402737189, 29.261693651788], + }, + properties: { + actual: [-121.939402737189, 29.261693651788], + actualDisplay: [-121.94, 29.26], + typical: [-97.862548828125, 39.986461639404], + typicalDisplay: [-97.86, 39.99], + fieldName: 'geo.coordinates', + functionDescription: 'lat_long', + timestamp: 'February 27th 2022, 10:00:00', + record_score: 77, + influencers: + '', + }, + }, + ], +}; +export const typicalToActualExpected = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [-97.862548828125, 39.986461639404], + [-121.939402737189, 29.261693651788], + ], + }, + properties: { + actual: [-121.939402737189, 29.261693651788], + actualDisplay: [-121.94, 29.26], + typical: [-97.862548828125, 39.986461639404], + typicalDisplay: [-97.86, 39.99], + fieldName: 'geo.coordinates', + functionDescription: 'lat_long', + timestamp: 'February 27th 2022, 10:00:00', + record_score: 77, + influencers: + '', + }, + }, + ], +}; + +export const mlResultsServiceMock = { + anomalySearch: () => results, +}; diff --git a/x-pack/plugins/ml/public/maps/util.ts b/x-pack/plugins/ml/public/maps/util.ts index df731f4bb309aa..11e6b6f5a39200 100644 --- a/x-pack/plugins/ml/public/maps/util.ts +++ b/x-pack/plugins/ml/public/maps/util.ts @@ -23,6 +23,34 @@ export const ML_ANOMALY_LAYERS = { } as const; export type MlAnomalyLayersType = typeof ML_ANOMALY_LAYERS[keyof typeof ML_ANOMALY_LAYERS]; +const INFLUENCER_LIMIT = 3; +const INFLUENCER_MAX_VALUES = 3; + +export function getInfluencersHtmlString( + influencers: Array<{ influencer_field_name: string; influencer_field_values: string[] }>, + splitFields: string[] +) { + let htmlString = ''; + + return htmlString; +} // Must reverse coordinates here. Map expects [lon, lat] - anomalies are stored as [lat, lon] for lat_lon jobs function getCoordinates(actualCoordinateStr: string, round: boolean = false): number[] { @@ -136,6 +164,15 @@ export async function getResultsForJobId( coordinates: [typical, actual], }; } + + const splitFields = { + ...(_source.partition_field_name + ? { [_source.partition_field_name]: _source.partition_field_value } + : {}), + ...(_source.by_field_name ? { [_source.by_field_name]: _source.by_field_value } : {}), + ...(_source.over_field_name ? { [_source.over_field_name]: _source.over_field_value } : {}), + }; + return { type: 'Feature', geometry, @@ -148,12 +185,14 @@ export async function getResultsForJobId( functionDescription: _source.function_description, timestamp: formatHumanReadableDateTimeSeconds(_source.timestamp), record_score: Math.floor(_source.record_score), - ...(_source.partition_field_name - ? { [_source.partition_field_name]: _source.partition_field_value } - : {}), - ...(_source.by_field_name ? { [_source.by_field_name]: _source.by_field_value } : {}), - ...(_source.over_field_name - ? { [_source.over_field_name]: _source.over_field_value } + ...(Object.keys(splitFields).length > 0 ? splitFields : {}), + ...(_source.influencers?.length + ? { + influencers: getInfluencersHtmlString( + _source.influencers, + Object.keys(splitFields) + ), + } : {}), }, };