diff --git a/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/custom_threshold_executor.test.ts b/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/custom_threshold_executor.test.ts index b8dff520ff119..ce700c8c57a83 100644 --- a/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/custom_threshold_executor.test.ts +++ b/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/custom_threshold_executor.test.ts @@ -154,6 +154,7 @@ const mockedIndex = { const mockedDataView = { getIndexPattern: () => 'mockedIndexPattern', getName: () => 'mockedDataViewName', + getRuntimeMappings: () => undefined, ...mockedIndex, }; const mockedSearchSource = { @@ -970,7 +971,7 @@ describe('The custom threshold alert type', () => { stateResult2 ); expect(stateResult3.missingGroups).toEqual([{ key: 'b', bucketKey: { groupBy0: 'b' } }]); - expect(mockedEvaluateRule.mock.calls[2][10]).toEqual([ + expect(mockedEvaluateRule.mock.calls[2][11]).toEqual([ { bucketKey: { groupBy0: 'b' }, key: 'b' }, ]); }); @@ -2959,7 +2960,7 @@ describe('The custom threshold alert type', () => { stateResult2 ); expect(stateResult3.missingGroups).toEqual([{ key: 'b', bucketKey: { groupBy0: 'b' } }]); - expect(mockedEvaluateRule.mock.calls[2][10]).toEqual([ + expect(mockedEvaluateRule.mock.calls[2][11]).toEqual([ { bucketKey: { groupBy0: 'b' }, key: 'b' }, ]); }); diff --git a/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/custom_threshold_executor.ts b/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/custom_threshold_executor.ts index 591e8062d5ca7..c9a9b7625f191 100644 --- a/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/custom_threshold_executor.ts +++ b/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/custom_threshold_executor.ts @@ -126,6 +126,7 @@ export const createCustomThresholdExecutor = ({ const initialSearchSource = await searchSourceClient.create(params.searchConfiguration); const dataView = initialSearchSource.getField('index')!; const { id: dataViewId, timeFieldName } = dataView; + const runtimeMappings = dataView.getRuntimeMappings(); const dataViewIndexPattern = dataView.getIndexPattern(); const dataViewName = dataView.getName(); if (!dataViewIndexPattern) { @@ -147,6 +148,7 @@ export const createCustomThresholdExecutor = ({ logger, { end: dateEnd, start: dateStart }, esQueryConfig, + runtimeMappings, state.lastRunTimestamp, previousMissingGroups ); diff --git a/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/lib/check_missing_group.ts b/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/lib/check_missing_group.ts index 8c5b75f00003a..2d8952a98fa78 100644 --- a/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/lib/check_missing_group.ts +++ b/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/lib/check_missing_group.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { ElasticsearchClient } from '@kbn/core/server'; import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import type { EsQueryConfig } from '@kbn/es-query'; @@ -16,6 +17,7 @@ import { } from '../../../../../common/custom_threshold_rule/types'; import type { BucketKey } from './get_data'; import { calculateCurrentTimeFrame, createBoolQuery } from './metric_query'; +import { isPopulatedObject } from './is_populated_object'; export interface MissingGroupsRecord { key: string; @@ -32,7 +34,8 @@ export const checkMissingGroups = async ( logger: Logger, timeframe: { start: number; end: number }, esQueryConfig: EsQueryConfig, - missingGroups: MissingGroupsRecord[] = [] + missingGroups: MissingGroupsRecord[] = [], + runtimeMappings?: estypes.MappingRuntimeFields ): Promise => { if (missingGroups.length === 0) { return missingGroups; @@ -65,6 +68,7 @@ export const checkMissingGroups = async ( terminate_after: 1, track_total_hits: true, query, + ...(isPopulatedObject(runtimeMappings) ? { runtime_mappings: runtimeMappings } : {}), }, ]; }); diff --git a/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/lib/evaluate_rule.ts b/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/lib/evaluate_rule.ts index 2e2d1e5af48b2..e0d861f53ae38 100644 --- a/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/lib/evaluate_rule.ts +++ b/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/lib/evaluate_rule.ts @@ -6,6 +6,7 @@ */ import moment from 'moment'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { ElasticsearchClient } from '@kbn/core/server'; import { EsQueryConfig } from '@kbn/es-query'; import type { Logger } from '@kbn/logging'; @@ -45,6 +46,7 @@ export const evaluateRule = async >> => { @@ -77,6 +79,7 @@ export const evaluateRule = async @@ -170,6 +172,7 @@ export const getData = async ( alertOnGroupDisappear, timeframe, logger, + runtimeMappings, lastPeriodEnd, previous, nextAfterKey @@ -209,6 +212,7 @@ export const getData = async ( alertOnGroupDisappear, searchConfiguration, esQueryConfig, + runtimeMappings, lastPeriodEnd, groupBy, afterKey, diff --git a/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/lib/is_populated_object.test.ts b/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/lib/is_populated_object.test.ts new file mode 100644 index 0000000000000..fdbe8d9fa5f21 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/lib/is_populated_object.test.ts @@ -0,0 +1,52 @@ +/* + * 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 { isPopulatedObject } from './is_populated_object'; + +describe('isPopulatedObject', () => { + it('does not allow numbers', () => { + expect(isPopulatedObject(0)).toBe(false); + }); + it('does not allow strings', () => { + expect(isPopulatedObject('')).toBe(false); + }); + it('does not allow null', () => { + expect(isPopulatedObject(null)).toBe(false); + }); + it('does not allow an empty object', () => { + expect(isPopulatedObject({})).toBe(false); + }); + it('allows an object with an attribute', () => { + expect(isPopulatedObject({ attribute: 'value' })).toBe(true); + }); + it('does not allow an object with a non-existing required attribute', () => { + expect(isPopulatedObject({ attribute: 'value' }, ['otherAttribute'])).toBe(false); + }); + it('allows an object with an existing required attribute', () => { + expect(isPopulatedObject({ attribute: 'value' }, ['attribute'])).toBe(true); + }); + it('allows an object with two existing required attributes', () => { + expect( + isPopulatedObject({ attribute1: 'value1', attribute2: 'value2' }, [ + 'attribute1', + 'attribute2', + ]) + ).toBe(true); + }); + it('does not allow an object with two required attributes where one does not exist', () => { + expect( + isPopulatedObject({ attribute1: 'value1', attribute2: 'value2' }, [ + 'attribute1', + 'otherAttribute', + ]) + ).toBe(false); + }); + it('does not allow an object with a required attribute in the prototype ', () => { + const testObject = { attribute: 'value', __proto__: { otherAttribute: 'value' } }; + expect(isPopulatedObject(testObject, ['otherAttribute'])).toBe(false); + }); +}); diff --git a/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/lib/is_populated_object.ts b/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/lib/is_populated_object.ts new file mode 100644 index 0000000000000..7fee714dbd6a0 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/lib/is_populated_object.ts @@ -0,0 +1,35 @@ +/* + * 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. + */ + +/** + * A type guard to check record like object structures. + * + * Examples: + * - `isPopulatedObject({...})` + * Limits type to Record + * + * - `isPopulatedObject({...}, ['attribute'])` + * Limits type to Record<'attribute', unknown> + * + * - `isPopulatedObject({...})` + * Limits type to a record with keys of the given interface. + * Note that you might want to add keys from the interface to the + * array of requiredAttributes to satisfy runtime requirements. + * Otherwise you'd just satisfy TS requirements but might still + * run into runtime issues. + */ +export const isPopulatedObject = ( + arg: unknown, + requiredAttributes: U[] = [] +): arg is Record => { + return ( + typeof arg === 'object' && + arg !== null && + Object.keys(arg).length > 0 && + (requiredAttributes.length === 0 || requiredAttributes.every((d) => Object.hasOwn(arg, d))) + ); +}; diff --git a/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/lib/metric_query.test.ts b/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/lib/metric_query.test.ts index 53955b7130c54..a6981b4c02f38 100644 --- a/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/lib/metric_query.test.ts +++ b/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/lib/metric_query.test.ts @@ -64,6 +64,7 @@ describe("The Metric Threshold Alert's getElasticsearchMetricQuery", () => { true, searchConfiguration, esQueryConfig, + undefined, void 0, groupBy ); @@ -121,6 +122,7 @@ describe("The Metric Threshold Alert's getElasticsearchMetricQuery", () => { true, currentSearchConfiguration, esQueryConfig, + undefined, void 0, groupBy ); @@ -233,6 +235,7 @@ describe("The Metric Threshold Alert's getElasticsearchMetricQuery", () => { true, currentSearchConfiguration, esQueryConfig, + undefined, void 0, groupBy ); diff --git a/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/lib/metric_query.ts b/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/lib/metric_query.ts index 4e7fc236bfdf5..8cf6867aa4a01 100644 --- a/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/lib/metric_query.ts +++ b/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/lib/metric_query.ts @@ -6,6 +6,7 @@ */ import moment from 'moment'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import type { EsQueryConfig, Filter } from '@kbn/es-query'; import { @@ -24,6 +25,7 @@ import { } from '../utils'; import { createBucketSelector } from './create_bucket_selector'; import { wrapInCurrentPeriod } from './wrap_in_period'; +import { isPopulatedObject } from './is_populated_object'; export const calculateCurrentTimeFrame = ( metricParams: CustomMetricExpressionParams, @@ -76,6 +78,7 @@ export const getElasticsearchMetricQuery = ( alertOnGroupDisappear: boolean, searchConfiguration: SearchConfigurationType, esQueryConfig: EsQueryConfig, + runtimeMappings?: estypes.MappingRuntimeFields, lastPeriodEnd?: number, groupBy?: string | string[], afterKey?: Record, @@ -211,6 +214,7 @@ export const getElasticsearchMetricQuery = ( return { track_total_hits: true, query, + ...(isPopulatedObject(runtimeMappings) ? { runtime_mappings: runtimeMappings } : {}), size: 0, aggs, };