Skip to content

Commit

Permalink
[ML] API integration tests for partition_fields_values endpoint (#1…
Browse files Browse the repository at this point in the history
…33519)

* api integration tests, improve type definitions

* refactor

* more tests

* tests for disabled model plot

* describe section

* runRequest function

* change maxRecordScore assertion
  • Loading branch information
darnautov authored Jun 7, 2022
1 parent ce499dc commit 7206958
Show file tree
Hide file tree
Showing 3 changed files with 269 additions and 14 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import Boom from '@hapi/boom';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { PARTITION_FIELDS } from '../../../common/constants/anomalies';
import { PartitionFieldsType } from '../../../common/types/anomalies';
import { CriteriaField } from './results_service';
Expand All @@ -18,6 +19,11 @@ type SearchTerm =
}
| undefined;

export interface PartitionFieldData {
name: string;
values: Array<{ value: string; maxRecordScore?: number }>;
}

/**
* Gets an object for aggregation query to retrieve field name and values.
* @param fieldType - Field type
Expand Down Expand Up @@ -110,23 +116,38 @@ function getFieldAgg(
* @param fieldType - Field type
* @param aggs - Aggregation response
*/
function getFieldObject(fieldType: PartitionFieldsType, aggs: any) {
const fieldNameKey = `${fieldType}_name`;
const fieldValueKey = `${fieldType}_value`;
function getFieldObject(
fieldType: PartitionFieldsType,
aggs: Record<estypes.AggregateName, estypes.AggregationsAggregate>
): Record<PartitionFieldsType, PartitionFieldData> | {} {
const fieldNameKey = `${fieldType}_name` as const;
const fieldValueKey = `${fieldType}_value` as const;

const fieldNameAgg = aggs[fieldNameKey] as estypes.AggregationsMultiTermsAggregate;
const fieldValueAgg = aggs[fieldValueKey] as unknown as {
values: estypes.AggregationsMultiBucketAggregateBase<{
key: string;
maxRecordScore?: { value: number };
}>;
};

return aggs[fieldNameKey].buckets.length > 0
return Array.isArray(fieldNameAgg.buckets) && fieldNameAgg.buckets.length > 0
? {
[fieldType]: {
name: aggs[fieldNameKey].buckets[0].key,
values: aggs[fieldValueKey].values.buckets.map(({ key, maxRecordScore }: any) => ({
value: key,
...(maxRecordScore ? { maxRecordScore: maxRecordScore.value } : {}),
})),
name: fieldNameAgg.buckets[0].key,
values: Array.isArray(fieldValueAgg.values.buckets)
? fieldValueAgg.values.buckets.map(({ key, maxRecordScore }) => ({
value: key,
...(maxRecordScore ? { maxRecordScore: maxRecordScore.value } : {}),
}))
: [],
},
}
: {};
}

export type PartitionFieldValueResponse = Record<PartitionFieldsType, PartitionFieldData>;

export const getPartitionFieldsValuesFactory = (mlClient: MlClient) =>
/**
* Gets the record of partition fields with possible values that fit the provided queries.
Expand All @@ -144,15 +165,15 @@ export const getPartitionFieldsValuesFactory = (mlClient: MlClient) =>
earliestMs: number,
latestMs: number,
fieldsConfig: FieldsConfig = {}
) {
): Promise<PartitionFieldValueResponse | {}> {
const jobsResponse = await mlClient.getJobs({ job_id: jobId });
if (jobsResponse.count === 0 || jobsResponse.jobs === undefined) {
throw Boom.notFound(`Job with the id "${jobId}" not found`);
}

const job = jobsResponse.jobs[0];

const isModelPlotEnabled = job?.model_plot_config?.enabled;
const isModelPlotEnabled = !!job?.model_plot_config?.enabled;
const isAnomalousOnly = (Object.entries(fieldsConfig) as Array<[string, FieldConfig]>).some(
([k, v]) => {
return !!v?.anomalousOnly;
Expand All @@ -165,14 +186,14 @@ export const getPartitionFieldsValuesFactory = (mlClient: MlClient) =>
}
);

const isModelPlotSearch = !!isModelPlotEnabled && !isAnomalousOnly;
const isModelPlotSearch = isModelPlotEnabled && !isAnomalousOnly;

// Remove the time filter in case model plot is not enabled
// and time range is not applied, so
// it includes the records that occurred as anomalies historically
const searchAllTime = !isModelPlotEnabled && !applyTimeRange;

const requestBody = {
const requestBody: estypes.SearchRequest['body'] = {
query: {
bool: {
filter: [
Expand Down Expand Up @@ -230,7 +251,7 @@ export const getPartitionFieldsValuesFactory = (mlClient: MlClient) =>
return PARTITION_FIELDS.reduce((acc, key) => {
return {
...acc,
...getFieldObject(key, body.aggregations),
...getFieldObject(key, body.aggregations!),
};
}, {});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
/*
* 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 { Datafeed, Job } from '@kbn/ml-plugin/common/types/anomaly_detection_jobs';
import type { PartitionFieldValueResponse } from '@kbn/ml-plugin/server/models/results_service/get_partition_fields_values';
import { USER } from '../../../../functional/services/ml/security_common';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common_api';

export default ({ getService }: FtrProviderContext) => {
const esArchiver = getService('esArchiver');
const supertest = getService('supertestWithoutAuth');
const ml = getService('ml');

function getJobConfig(jobId: string, enableModelPlot = true) {
return {
job_id: jobId,
description:
'mean/min/max(responsetime) partition=airline on farequote dataset with 1h bucket span',
groups: ['farequote', 'automated', 'multi-metric'],
analysis_config: {
bucket_span: '1h',
influencers: ['airline'],
detectors: [
{ function: 'mean', field_name: 'responsetime', partition_field_name: 'airline' },
{ function: 'min', field_name: 'responsetime', partition_field_name: 'airline' },
{ function: 'max', field_name: 'responsetime', partition_field_name: 'airline' },
],
},
data_description: { time_field: '@timestamp' },
analysis_limits: { model_memory_limit: '20mb' },
model_plot_config: { enabled: enableModelPlot },
} as Job;
}

function getDatafeedConfig(jobId: string) {
return {
datafeed_id: `datafeed-${jobId}`,
indices: ['ft_farequote'],
job_id: jobId,
query: { bool: { must: [{ match_all: {} }] } },
} as Datafeed;
}

async function createMockJobs() {
await ml.api.createAndRunAnomalyDetectionLookbackJob(
getJobConfig('fq_multi_1_ae'),
getDatafeedConfig('fq_multi_1_ae')
);

await ml.api.createAndRunAnomalyDetectionLookbackJob(
getJobConfig('fq_multi_2_ae', false),
getDatafeedConfig('fq_multi_2_ae')
);
}

async function runRequest(requestBody: object): Promise<PartitionFieldValueResponse> {
const { body, status } = await supertest
.post(`/api/ml/results/partition_fields_values`)
.auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER))
.set(COMMON_REQUEST_HEADERS)
.send(requestBody);
ml.api.assertResponseStatusCode(200, status, body);
return body;
}

describe('GetAnomaliesTableData', function () {
before(async () => {
await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote');
await ml.testResources.setKibanaTimeZoneToUTC();
await createMockJobs();
});

after(async () => {
await ml.api.cleanMlIndices();
});

describe('when model plot is enabled', () => {
it('should fetch anomalous only field values within the time range with an empty search term sorting by anomaly score', async () => {
const requestBody = {
jobId: 'fq_multi_1_ae',
criteriaFields: [{ fieldName: 'detector_index', fieldValue: 0 }],
earliestMs: 1454889600000, // February 8, 2016 12:00:00 AM GMT
latestMs: 1454976000000, // February 9, 2016 12:00:00 AM GMT,
searchTerm: {},
fieldsConfig: {
partition_field: {
applyTimeRange: true,
anomalousOnly: true,
sort: { by: 'anomaly_score', order: 'desc' },
},
},
};

const body = await runRequest(requestBody);

expect(body.partition_field.name).to.eql('airline');
expect(body.partition_field.values.length).to.eql(6);
expect(body.partition_field.values[0].value).to.eql('ACA');
expect(body.partition_field.values[0].maxRecordScore).to.be.above(0);
expect(body.partition_field.values[1].value).to.eql('JBU');
expect(body.partition_field.values[1].maxRecordScore).to.be.above(0);
expect(body.partition_field.values[2].value).to.eql('SWR');
expect(body.partition_field.values[2].maxRecordScore).to.be.above(0);
expect(body.partition_field.values[3].value).to.eql('BAW');
expect(body.partition_field.values[3].maxRecordScore).to.be.above(0);
expect(body.partition_field.values[4].value).to.eql('TRS');
expect(body.partition_field.values[4].maxRecordScore).to.be.above(0);
expect(body.partition_field.values[5].value).to.eql('EGF');
expect(body.partition_field.values[5].maxRecordScore).to.be.above(0);
});

it('should fetch all values withing the time range sorting by name', async () => {
const requestBody = {
jobId: 'fq_multi_1_ae',
criteriaFields: [{ fieldName: 'detector_index', fieldValue: 0 }],
earliestMs: 1454889600000, // February 8, 2016 12:00:00 AM GMT
latestMs: 1454976000000, // February 9, 2016 12:00:00 AM GMT,
searchTerm: {},
fieldsConfig: {
partition_field: {
applyTimeRange: true,
anomalousOnly: false,
sort: { by: 'name', order: 'asc' },
},
},
};

const body = await runRequest(requestBody);

expect(body).to.eql({
partition_field: {
name: 'airline',
values: [
{ value: 'AAL' },
{ value: 'ACA' },
{ value: 'AMX' },
{ value: 'ASA' },
{ value: 'AWE' },
{ value: 'BAW' },
{ value: 'DAL' },
{ value: 'EGF' },
{ value: 'FFT' },
{ value: 'JAL' },
{ value: 'JBU' },
{ value: 'JZA' },
{ value: 'KLM' },
{ value: 'NKS' },
{ value: 'SWA' },
{ value: 'SWR' },
{ value: 'TRS' },
{ value: 'UAL' },
{ value: 'VRD' },
],
},
});
});

it('should fetch anomalous only field value applying the search term', async () => {
const requestBody = {
jobId: 'fq_multi_1_ae',
criteriaFields: [{ fieldName: 'detector_index', fieldValue: 0 }],
earliestMs: 1454889600000, // February 8, 2016 12:00:00 AM GMT
latestMs: 1454976000000, // February 9, 2016 12:00:00 AM GMT,
searchTerm: {
partition_field: 'JB',
},
fieldsConfig: {
partition_field: {
applyTimeRange: true,
anomalousOnly: true,
sort: { by: 'anomaly_score', order: 'asc' },
},
},
};

const body = await runRequest(requestBody);

expect(body.partition_field.name).to.eql('airline');
expect(body.partition_field.values.length).to.eql(1);
expect(body.partition_field.values[0].value).to.eql('JBU');
expect(body.partition_field.values[0].maxRecordScore).to.be.above(0);
});
});

describe('when model plot is disabled', () => {
it('should fetch results within the time range', async () => {
const requestBody = {
jobId: 'fq_multi_2_ae',
criteriaFields: [{ fieldName: 'detector_index', fieldValue: 0 }],
earliestMs: 1454889600000, // February 8, 2016 12:00:00 AM GMT
latestMs: 1454976000000, // February 9, 2016 12:00:00 AM GMT,
searchTerm: {},
fieldsConfig: {
partition_field: {
applyTimeRange: true,
anomalousOnly: false,
sort: { by: 'name', order: 'asc' },
},
},
};

const body = await runRequest(requestBody);
expect(body.partition_field.values.length).to.eql(6);
});

it('should fetch results outside the time range', async () => {
const requestBody = {
jobId: 'fq_multi_2_ae',
criteriaFields: [{ fieldName: 'detector_index', fieldValue: 0 }],
earliestMs: 1454889600000, // February 8, 2016 12:00:00 AM GMT
latestMs: 1454976000000, // February 9, 2016 12:00:00 AM GMT,
searchTerm: {},
fieldsConfig: {
partition_field: {
applyTimeRange: false,
anomalousOnly: false,
sort: { by: 'name', order: 'asc' },
},
},
};

const body = await runRequest(requestBody);
expect(body.partition_field.values.length).to.eql(19);
});
});
});
};
1 change: 1 addition & 0 deletions x-pack/test/api_integration/apis/ml/results/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./get_stopped_partitions'));
loadTestFile(require.resolve('./get_category_definition'));
loadTestFile(require.resolve('./get_category_examples'));
loadTestFile(require.resolve('./get_partition_fields_values'));
});
}

0 comments on commit 7206958

Please sign in to comment.