diff --git a/x-pack/solutions/observability/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/convert_metric_value.test.ts b/x-pack/solutions/observability/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/convert_metric_value.test.ts new file mode 100644 index 0000000000000..1a2f4ae755285 --- /dev/null +++ b/x-pack/solutions/observability/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/convert_metric_value.test.ts @@ -0,0 +1,63 @@ +/* + * 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 { convertMetricValue } from './convert_metric_value'; + +describe('convertMetricValue', () => { + describe('cpu metrics', () => { + it('should convert cpu percentage from 0-100 to 0-1 scale', () => { + expect(convertMetricValue('cpu', 50)).toBe(0.5); + expect(convertMetricValue('cpu', 100)).toBe(1); + expect(convertMetricValue('cpu', 0)).toBe(0); + }); + + it('should convert cpuV2 percentage from 0-100 to 0-1 scale', () => { + expect(convertMetricValue('cpuV2', 50)).toBe(0.5); + expect(convertMetricValue('cpuV2', 100)).toBe(1); + expect(convertMetricValue('cpuV2', 0)).toBe(0); + }); + }); + + describe('memory metrics', () => { + it('should convert memory percentage from 0-100 to 0-1 scale', () => { + expect(convertMetricValue('memory', 75)).toBe(0.75); + expect(convertMetricValue('memory', 100)).toBe(1); + expect(convertMetricValue('memory', 0)).toBe(0); + }); + }); + + describe('network metrics (bits to bytes)', () => { + it('should convert tx threshold from bits to bytes', () => { + expect(convertMetricValue('tx', 8)).toBe(1); + }); + + it('should convert rx threshold from bits to bytes', () => { + expect(convertMetricValue('rx', 8)).toBe(1); + expect(convertMetricValue('rx', 800)).toBe(100); + }); + + it('should convert txV2 threshold from bits to bytes', () => { + expect(convertMetricValue('txV2', 8)).toBe(1); + expect(convertMetricValue('txV2', 800)).toBe(100); + expect(convertMetricValue('txV2', 450000)).toBe(56250); + }); + + it('should convert rxV2 threshold from bits to bytes', () => { + expect(convertMetricValue('rxV2', 8)).toBe(1); + expect(convertMetricValue('rxV2', 800)).toBe(100); + expect(convertMetricValue('rxV2', 450000)).toBe(56250); + }); + }); + + describe('metrics without conversion', () => { + it('should return the value unchanged for metrics without converters', () => { + expect(convertMetricValue('diskIOReadBytes', 1000)).toBe(1000); + expect(convertMetricValue('diskIOWriteBytes', 2000)).toBe(2000); + expect(convertMetricValue('logRate', 500)).toBe(500); + }); + }); +}); diff --git a/x-pack/solutions/observability/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/convert_metric_value.ts b/x-pack/solutions/observability/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/convert_metric_value.ts index cf93ac372dfcb..b84fb8bb389f3 100644 --- a/x-pack/solutions/observability/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/convert_metric_value.ts +++ b/x-pack/solutions/observability/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/convert_metric_value.ts @@ -21,4 +21,6 @@ const converters: Record number> = { memory: (n) => Number(n) / 100, tx: (n) => Number(n) / 8, rx: (n) => Number(n) / 8, + txV2: (n) => Number(n) / 8, + rxV2: (n) => Number(n) / 8, }; diff --git a/x-pack/solutions/observability/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/create_metric_aggregations.ts b/x-pack/solutions/observability/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/create_metric_aggregations.ts index bf31a09a5a1c9..04305caff3f11 100644 --- a/x-pack/solutions/observability/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/create_metric_aggregations.ts +++ b/x-pack/solutions/observability/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/create_metric_aggregations.ts @@ -16,7 +16,7 @@ import type { InfraTimerangeInput, SnapshotCustomMetricInput, } from '../../../../../common/http_api'; -import { isMetricRate, isCustomMetricRate, isInterfaceRateAgg } from './is_rate'; +import { isMetricRate, isCustomMetricRate, getInterfaceRateFields } from './is_rate'; import { createRateAggs } from './create_rate_aggs'; import { createLogRateAggs } from './create_log_rate_aggs'; import { createRateAggsWithInterface } from './create_rate_agg_with_interface'; @@ -46,17 +46,12 @@ export const createMetricAggregations = async ( const aggregations = await inventoryModel.metrics.getAggregations({ schema }); const metricAgg = aggregations.get(metric); - if (isInterfaceRateAgg(metricAgg)) { - const field = get( - metricAgg, - `${metric}_interfaces.aggregations.${metric}_interface_max.max.field` - ) as unknown as string; - const interfaceField = get( - metricAgg, - `${metric}_interfaces.terms.field` - ) as unknown as string; - return createRateAggsWithInterface(timerange, metric, field, interfaceField); + const interfaceRateConfig = getInterfaceRateFields(metricAgg, metric); + if (interfaceRateConfig) { + const { field, interfaceField, filter } = interfaceRateConfig; + return createRateAggsWithInterface(timerange, metric, field, interfaceField, filter); } + if (isMetricRate(metricAgg)) { const field = get(metricAgg, `${metric}_max.max.field`) as unknown as string; return createRateAggs(timerange, metric, field); diff --git a/x-pack/solutions/observability/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/create_rate_agg_with_interface.ts b/x-pack/solutions/observability/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/create_rate_agg_with_interface.ts index a52fa2a02de83..42ffd99ee80f4 100644 --- a/x-pack/solutions/observability/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/create_rate_agg_with_interface.ts +++ b/x-pack/solutions/observability/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/create_rate_agg_with_interface.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { estypes } from '@elastic/elasticsearch'; import type { InfraTimerangeInput } from '../../../../../common/http_api'; import { calculateRateTimeranges } from './calculate_rate_timeranges'; @@ -12,8 +13,9 @@ export const createRateAggsWithInterface = ( timerange: InfraTimerangeInput, id: string, field: string, - interfaceField: string -) => { + interfaceField: string, + filter?: estypes.QueryDslQueryContainer +): Record => { const { firstBucketRange, secondBucketRange, intervalInSeconds } = calculateRateTimeranges(timerange); @@ -31,29 +33,35 @@ export const createRateAggsWithInterface = ( }, }; - return { - [`${id}_first_bucket`]: { - filter: { - range: { - '@timestamp': { - gte: firstBucketRange.from, - lt: firstBucketRange.to, - format: 'epoch_millis', - }, + const createBucketFilter = (range: { from: number; to: number }) => { + const rangeFilter: estypes.QueryDslQueryContainer = { + range: { + '@timestamp': { + gte: range.from, + lt: range.to, + format: 'epoch_millis', }, }, + }; + + if (!filter) { + return rangeFilter; + } + + return { + bool: { + must: [filter, rangeFilter], + }, + }; + }; + + return { + [`${id}_first_bucket`]: { + filter: createBucketFilter(firstBucketRange), aggs: interfaceAggs, }, [`${id}_second_bucket`]: { - filter: { - range: { - '@timestamp': { - gte: secondBucketRange.from, - lt: secondBucketRange.to, - format: 'epoch_millis', - }, - }, - }, + filter: createBucketFilter(secondBucketRange), aggs: interfaceAggs, }, [id]: { diff --git a/x-pack/solutions/observability/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/is_rate.test.ts b/x-pack/solutions/observability/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/is_rate.test.ts index e1bf5afdb37dd..0f2470465e306 100644 --- a/x-pack/solutions/observability/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/is_rate.test.ts +++ b/x-pack/solutions/observability/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/is_rate.test.ts @@ -6,7 +6,14 @@ */ import type { MetricsUIAggregation } from '@kbn/metrics-data-access-plugin/common'; -import { isCustomMetricRate, isInterfaceRateAgg, isMetricRate, isRate } from './is_rate'; +import { + isCustomMetricRate, + isInterfaceRateAgg, + isFilteredInterfaceRateAgg, + isMetricRate, + isRate, + getInterfaceRateFields, +} from './is_rate'; import type { SnapshotCustomMetricInput } from '../../../../../common/http_api'; const customMaxMetricMock: SnapshotCustomMetricInput = { @@ -33,6 +40,79 @@ const metricMock: MetricsUIAggregation = { }, }; +// Mock for networkTrafficWithInterfaces pattern (direct interface rate) +const directInterfaceRateMock: MetricsUIAggregation = { + rx_interfaces: { + terms: { field: 'system.network.name' }, + aggregations: { + rx_interface_max: { max: { field: 'system.network.in.bytes' } }, + }, + }, + rx_sum_of_interfaces: { + sum_bucket: { + buckets_path: 'rx_interfaces>rx_interface_max', + }, + }, + rx_deriv: { + derivative: { + buckets_path: 'rx_sum_of_interfaces', + gap_policy: 'skip', + unit: '1s', + }, + }, + rx: { + bucket_script: { + buckets_path: { value: 'rx_deriv[normalized_value]' }, + script: { + source: 'params.value > 0.0 ? params.value : 0.0', + lang: 'painless', + }, + gap_policy: 'skip', + }, + }, +}; + +// Mock for networkTrafficWithInterfacesWithFilter pattern (filter-wrapped interface rate) +const filteredInterfaceRateMock: MetricsUIAggregation = { + rxv2_dimension: { + filter: { + term: { + direction: 'receive', + }, + }, + aggs: { + rxv2_interfaces: { + terms: { field: 'device' }, + aggregations: { + rxv2_interface_max: { max: { field: 'system.network.io' } }, + }, + }, + rxv2_sum_of_interfaces: { + sum_bucket: { + buckets_path: 'rxV2_interfaces>rxv2_interface_max', + }, + }, + }, + }, + rxv2_deriv: { + derivative: { + buckets_path: 'rxV2_dimension>rxv2_sum_of_interfaces', + gap_policy: 'skip', + unit: '1s', + }, + }, + rxV2: { + bucket_script: { + buckets_path: { value: 'rxV2_deriv[normalized_value]' }, + script: { + source: 'params.value > 0.0 ? params.value : 0.0', + lang: 'painless', + }, + gap_policy: 'skip', + }, + }, +}; + describe('isRate', () => { describe('isMetricRate', () => { it('should return false when metric is undefined', () => { @@ -50,24 +130,87 @@ describe('isRate', () => { it("should return true when aggregation is equal to 'rate'", () => { expect(isCustomMetricRate(customRateMetricMock)).toEqual(true); }); + }); - describe('isInterfaceRateAgg', () => { - it('should return false if metric is undefined', () => { - expect(isInterfaceRateAgg(undefined)).toEqual(false); - }); - it('should return true when correct metric is passed', () => { - expect(isInterfaceRateAgg(metricMock)).toEqual(true); - }); + describe('isInterfaceRateAgg', () => { + it('should return false if metric is undefined', () => { + expect(isInterfaceRateAgg(undefined)).toEqual(false); + }); + it('should return true for direct interface rate pattern', () => { + expect(isInterfaceRateAgg(directInterfaceRateMock)).toEqual(true); + }); + it('should return true for filter-wrapped interface rate pattern', () => { + expect(isInterfaceRateAgg(filteredInterfaceRateMock)).toEqual(true); + }); + it('should return false for non-interface rate metrics', () => { + expect(isInterfaceRateAgg({ simple_max: { max: { field: 'test' } } })).toEqual(false); + }); + }); + + describe('isFilteredInterfaceRateAgg', () => { + it('should return false if metric is undefined', () => { + expect(isFilteredInterfaceRateAgg(undefined)).toEqual(false); + }); + it('should return false for direct interface rate pattern', () => { + expect(isFilteredInterfaceRateAgg(directInterfaceRateMock)).toEqual(false); + }); + it('should return true for filter-wrapped interface rate pattern', () => { + expect(isFilteredInterfaceRateAgg(filteredInterfaceRateMock)).toEqual(true); + }); + it('should return false for non-interface rate metrics', () => { + expect(isFilteredInterfaceRateAgg({ simple_max: { max: { field: 'test' } } })).toEqual(false); + }); + }); + + describe('getInterfaceRateFields', () => { + it('should return null if metric is undefined', () => { + expect(getInterfaceRateFields(undefined, 'rx')).toEqual(null); }); - describe('isRate', () => { - it('should return false when incorrect metrics are provided', () => { - expect(isRate({} as MetricsUIAggregation, {} as SnapshotCustomMetricInput)).toEqual(false); + it('should return null for non-interface rate metrics', () => { + expect(getInterfaceRateFields({ simple_max: { max: { field: 'test' } } }, 'test')).toEqual( + null + ); + }); + + it('should extract fields from direct interface rate pattern', () => { + const result = getInterfaceRateFields(directInterfaceRateMock, 'rx'); + expect(result).toEqual({ + field: 'system.network.in.bytes', + interfaceField: 'system.network.name', }); + expect(result?.filter).toBeUndefined(); + }); - it('should return true when proper metric are provided', () => { - expect(isRate(metricMock, customRateMetricMock)).toEqual(true); + it('should extract fields and filter from filter-wrapped interface rate pattern', () => { + const result = getInterfaceRateFields(filteredInterfaceRateMock, 'rxv2'); + expect(result).toEqual({ + field: 'system.network.io', + interfaceField: 'device', + filter: { + term: { + direction: 'receive', + }, + }, }); }); }); + + describe('isRate', () => { + it('should return false when incorrect metrics are provided', () => { + expect(isRate({} as MetricsUIAggregation, {} as SnapshotCustomMetricInput)).toEqual(false); + }); + + it('should return true when proper metric are provided', () => { + expect(isRate(metricMock, customRateMetricMock)).toEqual(true); + }); + + it('should return true for direct interface rate pattern', () => { + expect(isRate(directInterfaceRateMock)).toEqual(true); + }); + + it('should return true for filter-wrapped interface rate pattern', () => { + expect(isRate(filteredInterfaceRateMock)).toEqual(true); + }); + }); }); diff --git a/x-pack/solutions/observability/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/is_rate.ts b/x-pack/solutions/observability/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/is_rate.ts index 9b73c264496bb..e4573496fd47c 100644 --- a/x-pack/solutions/observability/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/is_rate.ts +++ b/x-pack/solutions/observability/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/is_rate.ts @@ -5,16 +5,24 @@ * 2.0. */ -import { has } from 'lodash'; +import { get, has } from 'lodash'; +import type { estypes } from '@elastic/elasticsearch'; import type { MetricsUIAggregation } from '@kbn/metrics-data-access-plugin/common'; import { isBasicMetricAgg, isDerivativeAgg, isSumBucketAgg, isTermsWithAggregation, + isFilterWithAggregations, } from '@kbn/metrics-data-access-plugin/common'; import type { SnapshotCustomMetricInput } from '../../../../../common/http_api'; +export interface InterfaceRateConfig { + field: string; + interfaceField: string; + filter?: estypes.QueryDslQueryContainer; +} + export const isMetricRate = (metric: MetricsUIAggregation | undefined): boolean => { if (!metric) { return false; @@ -30,14 +38,81 @@ export const isCustomMetricRate = (customMetric: SnapshotCustomMetricInput) => { return customMetric.aggregation === 'rate'; }; +const hasInterfaceRatePattern = (aggs: Record): boolean => { + const aggValues = Object.values(aggs); + return ( + aggValues.some((agg) => isTermsWithAggregation(agg)) && + aggValues.some((agg) => isSumBucketAgg(agg)) + ); +}; + export const isInterfaceRateAgg = (metric: MetricsUIAggregation | undefined) => { if (!metric) { return false; } + + // Check for direct interface rate pattern (terms + sum_bucket at top level) + if (hasInterfaceRatePattern(metric)) { + return true; + } + + // Check for filter-wrapped interface rate pattern + return isFilteredInterfaceRateAgg(metric); +}; + +export const isFilteredInterfaceRateAgg = (metric: MetricsUIAggregation | undefined) => { + if (!metric) { + return false; + } const values = Object.values(metric); - return ( - values.some((agg) => isTermsWithAggregation(agg)) && values.some((agg) => isSumBucketAgg(agg)) - ); + + // Check for filter-wrapped interface rate pattern (filter with nested terms + sum_bucket) + return values.some((agg) => { + if (isFilterWithAggregations(agg)) { + const nestedAggs = agg.aggs as Record; + return hasInterfaceRatePattern(nestedAggs); + } + return false; + }); +}; + +export const getInterfaceRateFields = ( + metric: MetricsUIAggregation | undefined, + metricId: string +): InterfaceRateConfig | null => { + if (!metric) { + return null; + } + + if (isFilteredInterfaceRateAgg(metric)) { + const basePath = `${metricId}_dimension.aggs.${metricId}_interfaces`; + const field = get(metric, `${basePath}.aggregations.${metricId}_interface_max.max.field`) as + | string + | undefined; + const interfaceField = get(metric, `${basePath}.terms.field`) as string | undefined; + if (!field || !interfaceField) { + return null; + } + return { + field, + interfaceField, + filter: get(metric, `${metricId}_dimension.filter`) as estypes.QueryDslQueryContainer, + }; + } + + if (hasInterfaceRatePattern(metric)) { + const basePath = `${metricId}_interfaces`; + const field = get(metric, `${basePath}.aggregations.${metricId}_interface_max.max.field`) as + | string + | undefined; + const interfaceField = get(metric, `${basePath}.terms.field`) as string | undefined; + if (!field || !interfaceField) { + return null; + } + return { field, interfaceField }; + } + + return null; }; export const isRate = ( diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/common/index.ts b/x-pack/solutions/observability/plugins/metrics_data_access/common/index.ts index 3c449c47531e4..17af0bb11f7aa 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/common/index.ts +++ b/x-pack/solutions/observability/plugins/metrics_data_access/common/index.ts @@ -15,6 +15,7 @@ export { isDerivativeAgg, isSumBucketAgg, isTermsWithAggregation, + isFilterWithAggregations, } from './inventory_models'; export { diff --git a/x-pack/solutions/observability/plugins/metrics_data_access/common/inventory_models/index.ts b/x-pack/solutions/observability/plugins/metrics_data_access/common/inventory_models/index.ts index 28d99c0d0176f..583e76fd83cc7 100644 --- a/x-pack/solutions/observability/plugins/metrics_data_access/common/inventory_models/index.ts +++ b/x-pack/solutions/observability/plugins/metrics_data_access/common/inventory_models/index.ts @@ -101,3 +101,10 @@ export const isTermsWithAggregation = ( const aggContainer = agg as estypes.AggregationsAggregationContainer; return !!(aggContainer.aggregations && aggContainer.terms); }; + +export const isFilterWithAggregations = ( + agg: unknown +): agg is Pick => { + const aggContainer = agg as estypes.AggregationsAggregationContainer; + return !!(aggContainer.filter && aggContainer.aggs); +};