diff --git a/x-pack/plugins/observability/common/constants.ts b/x-pack/plugins/observability/common/constants.ts index b10eafd3e608f..97d3d1d9eb938 100644 --- a/x-pack/plugins/observability/common/constants.ts +++ b/x-pack/plugins/observability/common/constants.ts @@ -62,3 +62,5 @@ export const observabilityRuleCreationValidConsumers: RuleCreationValidConsumer[ AlertConsumers.LOGS, AlertConsumers.OBSERVABILITY, ]; + +export const EventsAsUnit = 'events'; diff --git a/x-pack/plugins/observability/common/custom_threshold_rule/metric_value_formatter.ts b/x-pack/plugins/observability/common/custom_threshold_rule/metric_value_formatter.ts index 114f30fd85307..6d013fb038497 100644 --- a/x-pack/plugins/observability/common/custom_threshold_rule/metric_value_formatter.ts +++ b/x-pack/plugins/observability/common/custom_threshold_rule/metric_value_formatter.ts @@ -16,9 +16,9 @@ export const metricValueFormatter = (value: number | null, metric: string = '') } ); - const formatter = metric.endsWith('.pct') - ? createFormatter('percent') - : createFormatter('highPrecision'); + let formatter = createFormatter('highPrecision'); + if (metric.endsWith('.pct')) formatter = createFormatter('percent'); + if (metric.endsWith('.bytes')) formatter = createFormatter('bytes'); return value == null ? noDataValue : formatter(value); }; diff --git a/x-pack/plugins/observability/common/custom_threshold_rule/types.ts b/x-pack/plugins/observability/common/custom_threshold_rule/types.ts index 67849df1b59d7..d9943d539d21f 100644 --- a/x-pack/plugins/observability/common/custom_threshold_rule/types.ts +++ b/x-pack/plugins/observability/common/custom_threshold_rule/types.ts @@ -35,6 +35,9 @@ export enum Aggregators { MIN = 'min', MAX = 'max', CARDINALITY = 'cardinality', + RATE = 'rate', + P95 = 'p95', + P99 = 'p99', } export const aggType = fromEnum('Aggregators', Aggregators); export type AggType = rt.TypeOf; diff --git a/x-pack/plugins/observability/public/components/custom_threshold/components/expression_row.tsx b/x-pack/plugins/observability/public/components/custom_threshold/components/expression_row.tsx index 6b0643791596a..9612b37e2d10f 100644 --- a/x-pack/plugins/observability/public/components/custom_threshold/components/expression_row.tsx +++ b/x-pack/plugins/observability/public/components/custom_threshold/components/expression_row.tsx @@ -307,4 +307,31 @@ export const aggregationType: { [key: string]: AggregationType } = { value: Aggregators.SUM, validNormalizedTypes: ['number', 'histogram'], }, + p95: { + text: i18n.translate( + 'xpack.observability.customThreshold.rule.alertFlyout.aggregationText.p95', + { defaultMessage: '95th Percentile' } + ), + fieldRequired: false, + value: Aggregators.P95, + validNormalizedTypes: ['number', 'histogram'], + }, + p99: { + text: i18n.translate( + 'xpack.observability.customThreshold.rule.alertFlyout.aggregationText.p99', + { defaultMessage: '99th Percentile' } + ), + fieldRequired: false, + value: Aggregators.P99, + validNormalizedTypes: ['number', 'histogram'], + }, + rate: { + text: i18n.translate( + 'xpack.observability..customThreshold.rule.alertFlyout.aggregationText.rate', + { defaultMessage: 'Rate' } + ), + fieldRequired: false, + value: Aggregators.RATE, + validNormalizedTypes: ['number'], + }, }; diff --git a/x-pack/plugins/observability/public/components/custom_threshold/components/rule_condition_chart/helpers.test.ts b/x-pack/plugins/observability/public/components/custom_threshold/components/rule_condition_chart/helpers.test.ts new file mode 100644 index 0000000000000..4211907b5d4a0 --- /dev/null +++ b/x-pack/plugins/observability/public/components/custom_threshold/components/rule_condition_chart/helpers.test.ts @@ -0,0 +1,138 @@ +/* + * 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 { + Aggregators, + CustomThresholdExpressionMetric, +} from '../../../../../common/custom_threshold_rule/types'; +import { getBufferThreshold, getLensOperationFromRuleMetric, lensFieldFormatter } from './helpers'; +const useCases = [ + [ + { + aggType: Aggregators.SUM, + field: 'system.cpu.user.pct', + filter: '', + name: '', + }, + 'sum(system.cpu.user.pct)', + ], + [ + { + aggType: Aggregators.MAX, + field: 'system.cpu.user.pct', + filter: '', + name: '', + }, + 'max(system.cpu.user.pct)', + ], + [ + { + aggType: Aggregators.MIN, + field: 'system.cpu.user.pct', + filter: '', + name: '', + }, + 'min(system.cpu.user.pct)', + ], + [ + { + aggType: Aggregators.AVERAGE, + field: 'system.cpu.user.pct', + filter: '', + name: '', + }, + 'average(system.cpu.user.pct)', + ], + [ + { + aggType: Aggregators.COUNT, + field: 'system.cpu.user.pct', + filter: '', + name: '', + }, + 'count(___records___)', + ], + [ + { + aggType: Aggregators.CARDINALITY, + field: 'system.cpu.user.pct', + filter: '', + name: '', + }, + 'unique_count(system.cpu.user.pct)', + ], + [ + { + aggType: Aggregators.P95, + field: 'system.cpu.user.pct', + filter: '', + name: '', + }, + 'percentile(system.cpu.user.pct, percentile=95)', + ], + [ + { + aggType: Aggregators.P99, + field: 'system.cpu.user.pct', + filter: '', + name: '', + }, + 'percentile(system.cpu.user.pct, percentile=99)', + ], + [ + { + aggType: Aggregators.RATE, + field: 'system.network.in.bytes', + filter: '', + name: '', + }, + `counter_rate(max(system.network.in.bytes), kql='')`, + ], + [ + { + aggType: Aggregators.RATE, + field: 'system.network.in.bytes', + filter: 'host.name : "foo"', + name: '', + }, + `counter_rate(max(system.network.in.bytes), kql='host.name : foo')`, + ], +]; + +test.each(useCases)('returns the correct operation from %p. => %p', (metric, expectedValue) => { + return expect(getLensOperationFromRuleMetric(metric as CustomThresholdExpressionMetric)).toEqual( + expectedValue + ); +}); + +describe('getBufferThreshold', () => { + const testData = [ + { threshold: undefined, buffer: '0.00' }, + { threshold: 0.1, buffer: '0.12' }, + { threshold: 0.01, buffer: '0.02' }, + { threshold: 0.001, buffer: '0.01' }, + { threshold: 0.00098, buffer: '0.01' }, + { threshold: 130, buffer: '143.00' }, + ]; + + it.each(testData)('getBufferThreshold($threshold) = $buffer', ({ threshold, buffer }) => { + expect(getBufferThreshold(threshold)).toBe(buffer); + }); +}); + +describe('lensFieldFormatter', () => { + const testData = [ + { metrics: [{ field: 'system.bytes' }], format: 'bits' }, + { metrics: [{ field: 'system.pct' }], format: 'percent' }, + { metrics: [{ field: 'system.host.cores' }], format: 'number' }, + { metrics: [{ field: undefined }], format: 'number' }, + ]; + it.each(testData)('getBufferThreshold($threshold) = $buffer', ({ metrics, format }) => { + expect(lensFieldFormatter(metrics as unknown as CustomThresholdExpressionMetric[])).toBe( + format + ); + }); +}); diff --git a/x-pack/plugins/observability/public/components/custom_threshold/components/rule_condition_chart/helpers.ts b/x-pack/plugins/observability/public/components/custom_threshold/components/rule_condition_chart/helpers.ts new file mode 100644 index 0000000000000..1875af6ceb93e --- /dev/null +++ b/x-pack/plugins/observability/public/components/custom_threshold/components/rule_condition_chart/helpers.ts @@ -0,0 +1,69 @@ +/* + * 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 { + Aggregators, + CustomThresholdExpressionMetric, +} from '../../../../../common/custom_threshold_rule/types'; + +export const getLensOperationFromRuleMetric = (metric: CustomThresholdExpressionMetric): string => { + const { aggType, field, filter } = metric; + let operation: string = aggType; + const operationArgs: string[] = []; + const aggFilter = JSON.stringify(filter || '').replace(/"|\\/g, ''); + + if (aggType === Aggregators.RATE) { + return `counter_rate(max(${field}), kql='${aggFilter}')`; + } + + if (aggType === Aggregators.AVERAGE) operation = 'average'; + if (aggType === Aggregators.CARDINALITY) operation = 'unique_count'; + if (aggType === Aggregators.P95 || aggType === Aggregators.P99) operation = 'percentile'; + if (aggType === Aggregators.COUNT) operation = 'count'; + + let sourceField = field; + + if (aggType === Aggregators.COUNT) { + sourceField = '___records___'; + } + + operationArgs.push(sourceField || ''); + + if (aggType === Aggregators.P95) { + operationArgs.push('percentile=95'); + } + + if (aggType === Aggregators.P99) { + operationArgs.push('percentile=99'); + } + + if (aggFilter) operationArgs.push(`kql='${aggFilter}'`); + + return operation + '(' + operationArgs.join(', ') + ')'; +}; + +export const getBufferThreshold = (threshold?: number): string => + (Math.ceil((threshold || 0) * 1.1 * 100) / 100).toFixed(2).toString(); + +export const LensFieldFormat = { + NUMBER: 'number', + PERCENT: 'percent', + BITS: 'bits', +} as const; + +export const lensFieldFormatter = ( + metrics: CustomThresholdExpressionMetric[] +): typeof LensFieldFormat[keyof typeof LensFieldFormat] => { + if (metrics.length < 1 || !metrics[0].field) return LensFieldFormat.NUMBER; + const firstMetricField = metrics[0].field; + if (firstMetricField.endsWith('.pct')) return LensFieldFormat.PERCENT; + if (firstMetricField.endsWith('.bytes')) return LensFieldFormat.BITS; + return LensFieldFormat.NUMBER; +}; + +export const isRate = (metrics: CustomThresholdExpressionMetric[]): boolean => + Boolean(metrics.length > 0 && metrics[0].aggType === Aggregators.RATE); diff --git a/x-pack/plugins/observability/public/components/custom_threshold/components/rule_condition_chart/rule_condition_chart.test.tsx b/x-pack/plugins/observability/public/components/custom_threshold/components/rule_condition_chart/rule_condition_chart.test.tsx index 320958a20c0e8..7aab6dd0d636b 100644 --- a/x-pack/plugins/observability/public/components/custom_threshold/components/rule_condition_chart/rule_condition_chart.test.tsx +++ b/x-pack/plugins/observability/public/components/custom_threshold/components/rule_condition_chart/rule_condition_chart.test.tsx @@ -13,7 +13,7 @@ import { Comparator, Aggregators } from '../../../../../common/custom_threshold_ import { useKibana } from '../../../../utils/kibana_react'; import { kibanaStartMock } from '../../../../utils/kibana_react.mock'; import { MetricExpression } from '../../types'; -import { getBufferThreshold, RuleConditionChart } from './rule_condition_chart'; +import { RuleConditionChart } from './rule_condition_chart'; jest.mock('../../../../utils/kibana_react'); @@ -71,18 +71,3 @@ describe('Rule condition chart', () => { expect(wrapper.find('[data-test-subj="thresholdRuleNoChartData"]').exists()).toBeTruthy(); }); }); - -describe('getBufferThreshold', () => { - const testData = [ - { threshold: undefined, buffer: '0.00' }, - { threshold: 0.1, buffer: '0.12' }, - { threshold: 0.01, buffer: '0.02' }, - { threshold: 0.001, buffer: '0.01' }, - { threshold: 0.00098, buffer: '0.01' }, - { threshold: 130, buffer: '143.00' }, - ]; - - it.each(testData)('getBufferThreshold($threshold) = $buffer', ({ threshold, buffer }) => { - expect(getBufferThreshold(threshold)).toBe(buffer); - }); -}); diff --git a/x-pack/plugins/observability/public/components/custom_threshold/components/rule_condition_chart/rule_condition_chart.tsx b/x-pack/plugins/observability/public/components/custom_threshold/components/rule_condition_chart/rule_condition_chart.tsx index bc2701eb79489..e1eefcfc2f706 100644 --- a/x-pack/plugins/observability/public/components/custom_threshold/components/rule_condition_chart/rule_condition_chart.tsx +++ b/x-pack/plugins/observability/public/components/custom_threshold/components/rule_condition_chart/rule_condition_chart.tsx @@ -6,7 +6,7 @@ */ import React, { useState, useEffect } from 'react'; import { EuiEmptyPrompt, useEuiTheme } from '@elastic/eui'; -import { FillStyle, OperationType, SeriesType } from '@kbn/lens-plugin/public'; +import { FillStyle, SeriesType } from '@kbn/lens-plugin/public'; import { DataView } from '@kbn/data-views-plugin/common'; import { FormattedMessage } from '@kbn/i18n-react'; import useAsync from 'react-use/lib/useAsync'; @@ -19,19 +19,22 @@ import { XYReferenceLinesLayer, XYByValueAnnotationsLayer, } from '@kbn/lens-embeddable-utils'; - import { IErrorObject } from '@kbn/triggers-actions-ui-plugin/public'; import { i18n } from '@kbn/i18n'; import { TimeRange } from '@kbn/es-query'; import { EventAnnotationConfig } from '@kbn/event-annotation-common'; -import { - Aggregators, - Comparator, - AggType, -} from '../../../../../common/custom_threshold_rule/types'; +import { EventsAsUnit } from '../../../../../common/constants'; +import { Comparator } from '../../../../../common/custom_threshold_rule/types'; import { useKibana } from '../../../../utils/kibana_react'; import { MetricExpression } from '../../types'; import { AggMap, PainlessTinyMathParser } from './painless_tinymath_parser'; +import { + lensFieldFormatter, + getBufferThreshold, + getLensOperationFromRuleMetric, + isRate, + LensFieldFormat, +} from './helpers'; interface RuleConditionChartProps { metricExpression: MetricExpression; @@ -44,15 +47,6 @@ interface RuleConditionChartProps { seriesType?: SeriesType; } -const getOperationTypeFromRuleAggType = (aggType: AggType): OperationType => { - if (aggType === Aggregators.AVERAGE) return 'average'; - if (aggType === Aggregators.CARDINALITY) return 'unique_count'; - return aggType; -}; - -export const getBufferThreshold = (threshold?: number): string => - (Math.ceil((threshold || 0) * 1.1 * 100) / 100).toFixed(2).toString(); - export function RuleConditionChart({ metricExpression, dataView, @@ -107,13 +101,6 @@ export function RuleConditionChart({ useEffect(() => { if (!threshold) return; const refLayers = []; - const isPercent = Boolean(metrics.length === 1 && metrics[0].field?.endsWith('.pct')); - const format = { - id: isPercent ? 'percent' : 'number', - params: { - decimals: isPercent ? 0 : 2, - }, - }; if ( comparator === Comparator.OUTSIDE_RANGE || @@ -125,7 +112,6 @@ export function RuleConditionChart({ value: (threshold[0] || 0).toString(), color: euiTheme.colors.danger, fill: comparator === Comparator.OUTSIDE_RANGE ? 'below' : 'none', - format, }, ], }); @@ -135,7 +121,6 @@ export function RuleConditionChart({ value: (threshold[1] || 0).toString(), color: euiTheme.colors.danger, fill: comparator === Comparator.OUTSIDE_RANGE ? 'above' : 'none', - format, }, ], }); @@ -152,7 +137,6 @@ export function RuleConditionChart({ value: (threshold[0] || 0).toString(), color: euiTheme.colors.danger, fill, - format, }, ], }); @@ -163,7 +147,6 @@ export function RuleConditionChart({ value: getBufferThreshold(threshold[0]), color: 'transparent', fill, - format, }, ], }); @@ -191,17 +174,7 @@ export function RuleConditionChart({ return; } const aggMapFromMetrics = metrics.reduce((acc, metric) => { - const operation = getOperationTypeFromRuleAggType(metric.aggType); - let sourceField = metric.field; - - if (metric.aggType === Aggregators.COUNT) { - sourceField = '___records___'; - } - let operationField = `${operation}(${sourceField})`; - if (metric?.filter) { - const aggFilter = JSON.stringify(metric.filter).replace(/"|\\/g, ''); - operationField = `${operation}(${sourceField},kql='${aggFilter}')`; - } + const operationField = getLensOperationFromRuleMetric(metric); return { ...acc, [metric.name]: operationField, @@ -232,16 +205,17 @@ export function RuleConditionChart({ if (!formulaAsync.value || !dataView || !formula) { return; } - const isPercent = Boolean(metrics.length === 1 && metrics[0].field?.endsWith('.pct')); + const formatId = lensFieldFormatter(metrics); const baseLayer = { type: 'formula', value: formula, label: 'Custom Threshold', groupBy, format: { - id: isPercent ? 'percent' : 'number', + id: formatId, params: { - decimals: isPercent ? 0 : 2, + decimals: formatId === LensFieldFormat.PERCENT ? 0 : 2, + suffix: isRate(metrics) && formatId === LensFieldFormat.NUMBER ? EventsAsUnit : undefined, }, }, }; @@ -273,6 +247,8 @@ export function RuleConditionChart({ value: layer.value, label: layer.label, format: layer.format, + // We always scale the chart with seconds with RATE Agg. + timeScale: isRate(metrics) ? 's' : undefined, })), options: xYDataLayerOptions, }); diff --git a/x-pack/plugins/observability/server/lib/rules/custom_threshold/custom_threshold_executor.test.ts b/x-pack/plugins/observability/server/lib/rules/custom_threshold/custom_threshold_executor.test.ts index dfc6daa82d40d..9e3eab1e8a054 100644 --- a/x-pack/plugins/observability/server/lib/rules/custom_threshold/custom_threshold_executor.test.ts +++ b/x-pack/plugins/observability/server/lib/rules/custom_threshold/custom_threshold_executor.test.ts @@ -1056,7 +1056,7 @@ describe('The custom threshold alert type', () => { const { action } = mostRecentAction(instanceID); const reasons = action.reason; expect(reasons).toBe( - 'Average test.metric.1 is 1, above the threshold of 1; Average test.metric.2 is 3, above the threshold of 3. (duration: 1 min, data view: mockedDataViewName)' + 'Average test.metric.1 is 1, above or equal the threshold of 1; Average test.metric.2 is 3, above or equal the threshold of 3. (duration: 1 min, data view: mockedDataViewName)' ); }); }); diff --git a/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/check_missing_group.ts b/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/check_missing_group.ts index 6b8041b448484..c45120cc62fa3 100644 --- a/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/check_missing_group.ts +++ b/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/check_missing_group.ts @@ -10,7 +10,7 @@ import type { Logger } from '@kbn/logging'; import { isString, get, identity } from 'lodash'; import { CustomMetricExpressionParams } from '../../../../../common/custom_threshold_rule/types'; import type { BucketKey } from './get_data'; -import { calculateCurrentTimeframe, createBaseFilters } from './metric_query'; +import { calculateCurrentTimeFrame, createBaseFilters } from './metric_query'; export interface MissingGroupsRecord { key: string; @@ -31,8 +31,8 @@ export const checkMissingGroups = async ( if (missingGroups.length === 0) { return missingGroups; } - const currentTimeframe = calculateCurrentTimeframe(metricParams, timeframe); - const baseFilters = createBaseFilters(metricParams, currentTimeframe, timeFieldName, filterQuery); + const currentTimeFrame = calculateCurrentTimeFrame(metricParams, timeframe); + const baseFilters = createBaseFilters(currentTimeFrame, timeFieldName, filterQuery); const groupByFields = isString(groupBy) ? [groupBy] : groupBy ? groupBy : []; const searches = missingGroups.flatMap((group) => { diff --git a/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/create_custom_metrics_aggregations.ts b/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/create_custom_metrics_aggregations.ts index 255475c8d8a5c..3ab20c5a632c7 100644 --- a/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/create_custom_metrics_aggregations.ts +++ b/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/create_custom_metrics_aggregations.ts @@ -7,11 +7,17 @@ import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; import { isEmpty } from 'lodash'; -import { CustomThresholdExpressionMetric } from '../../../../../common/custom_threshold_rule/types'; +import { + Aggregators, + CustomThresholdExpressionMetric, +} from '../../../../../common/custom_threshold_rule/types'; +import { createRateAggsBuckets, createRateAggsBucketScript } from './create_rate_aggregation'; export const createCustomMetricsAggregations = ( id: string, customMetrics: CustomThresholdExpressionMetric[], + currentTimeFrame: { start: number; end: number }, + timeFieldName: string, equation?: string ) => { const bucketsPath: { [id: string]: string } = {}; @@ -30,6 +36,28 @@ export const createCustomMetricsAggregations = ( }, }; } + if (aggregation === Aggregators.P95 || aggregation === Aggregators.P99) { + bucketsPath[metric.name] = key; + return { + ...acc, + [key]: { + percentiles: { + field: metric.field, + percents: [aggregation === Aggregators.P95 ? 95 : 99], + keyed: true, + }, + }, + }; + } + + if (aggregation === Aggregators.RATE) { + bucketsPath[metric.name] = key; + return { + ...acc, + ...createRateAggsBuckets(currentTimeFrame, key, timeFieldName, metric.field || ''), + ...createRateAggsBucketScript(currentTimeFrame, key), + }; + } if (aggregation && metric.field) { bucketsPath[metric.name] = key; diff --git a/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/create_rate_aggregation.ts b/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/create_rate_aggregation.ts new file mode 100644 index 0000000000000..b68101d8e9b35 --- /dev/null +++ b/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/create_rate_aggregation.ts @@ -0,0 +1,66 @@ +/* + * 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 moment from 'moment'; +import { calculateRateTimeranges } from '../utils'; +export const createRateAggsBucketScript = ( + timeframe: { start: number; end: number }, + id: string +) => { + const { intervalInSeconds } = calculateRateTimeranges({ + to: timeframe.end, + from: timeframe.start, + }); + return { + [id]: { + bucket_script: { + buckets_path: { + first: `${id}_first_bucket.maxValue`, + second: `${id}_second_bucket.maxValue`, + }, + script: `params.second > 0.0 && params.first > 0.0 && params.second > params.first ? (params.second - params.first) / ${intervalInSeconds}: 0`, + }, + }, + }; +}; + +export const createRateAggsBuckets = ( + timeframe: { start: number; end: number }, + id: string, + timeFieldName: string, + field: string +) => { + const { firstBucketRange, secondBucketRange } = calculateRateTimeranges({ + to: timeframe.end, + from: timeframe.start, + }); + + return { + [`${id}_first_bucket`]: { + filter: { + range: { + [timeFieldName]: { + gte: moment(firstBucketRange.from).toISOString(), + lt: moment(firstBucketRange.to).toISOString(), + }, + }, + }, + aggs: { maxValue: { max: { field } } }, + }, + [`${id}_second_bucket`]: { + filter: { + range: { + [timeFieldName]: { + gte: moment(secondBucketRange.from).toISOString(), + lt: moment(secondBucketRange.to).toISOString(), + }, + }, + }, + aggs: { maxValue: { max: { field } } }, + }, + }; +}; diff --git a/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/create_timerange.ts b/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/create_timerange.ts index a6c39adcd3204..0d82b5df637b8 100644 --- a/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/create_timerange.ts +++ b/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/create_timerange.ts @@ -10,11 +10,15 @@ import moment from 'moment'; export const createTimerange = ( interval: number, timeframe: { end: string; start: string }, - lastPeriodEnd?: number + lastPeriodEnd?: number, + isRateAgg?: boolean ) => { const end = moment(timeframe.end).valueOf(); let start = moment(timeframe.start).valueOf(); + const minimumBuckets = isRateAgg ? 2 : 1; + + interval = interval * minimumBuckets; start = start - interval; // Use lastPeriodEnd - interval when it's less than start diff --git a/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/evaluate_rule.ts b/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/evaluate_rule.ts index f5b3f0adf1bdd..59f5801613dd0 100644 --- a/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/evaluate_rule.ts +++ b/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/evaluate_rule.ts @@ -8,8 +8,11 @@ import moment from 'moment'; import { ElasticsearchClient } from '@kbn/core/server'; import type { Logger } from '@kbn/logging'; -import { CustomMetricExpressionParams } from '../../../../../common/custom_threshold_rule/types'; import { getIntervalInSeconds } from '../../../../../common/utils/get_interval_in_seconds'; +import { + Aggregators, + CustomMetricExpressionParams, +} from '../../../../../common/custom_threshold_rule/types'; import { AdditionalContext } from '../utils'; import { SearchConfigurationType } from '../types'; import { createTimerange } from './create_timerange'; @@ -50,7 +53,15 @@ export const evaluateRule = async metric.aggType === Aggregators.RATE + ); + const calculatedTimerange = createTimerange( + intervalAsMS, + timeframe, + lastPeriodEnd, + isRateAggregation + ); const currentValues = await getData( esClient, diff --git a/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/format_alert_result.ts b/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/format_alert_result.ts index c0220d89c9d98..02acac53023d0 100644 --- a/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/format_alert_result.ts +++ b/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/format_alert_result.ts @@ -6,8 +6,9 @@ */ import { i18n } from '@kbn/i18n'; +import { EventsAsUnit } from '../../../../../common/constants'; +import { metricValueFormatter } from '../../../../../common/custom_threshold_rule/metric_value_formatter'; import { Aggregators } from '../../../../../common/custom_threshold_rule/types'; -import { createFormatter } from '../../../../../common/custom_threshold_rule/formatters'; import { AVERAGE_I18N, CARDINALITY_I18N, @@ -15,6 +16,9 @@ import { DOCUMENT_COUNT_I18N, MAX_I18N, MIN_I18N, + PERCENTILE_95_I18N, + PERCENTILE_99_I18N, + RATE_I18N, SUM_I18N, } from '../translations'; import { Evaluation } from './evaluate_rule'; @@ -31,6 +35,12 @@ export const getLabel = (criterion: Evaluation) => { return DOCUMENT_COUNT_I18N; case Aggregators.AVERAGE: return AVERAGE_I18N(criterion.metrics[0].field!); + case Aggregators.P95: + return PERCENTILE_95_I18N(criterion.metrics[0].field!); + case Aggregators.P99: + return PERCENTILE_99_I18N(criterion.metrics[0].field!); + case Aggregators.RATE: + return RATE_I18N(criterion.metrics[0].field!); case Aggregators.MAX: return MAX_I18N(criterion.metrics[0].field!); case Aggregators.MIN: @@ -51,21 +61,27 @@ export const formatAlertResult = (evaluationResult: Evaluation): FormattedEvalua { defaultMessage: '[NO DATA]' } ); - let formatter = createFormatter('highPrecision'); const label = getLabel(evaluationResult); - if (metrics.length === 1 && metrics[0].field && metrics[0].field.endsWith('.pct')) { - formatter = createFormatter('percent'); - } + const perSecIfRate = metrics[0].aggType === Aggregators.RATE ? '/s' : ''; + const eventsAsUnit = + metrics[0].aggType === Aggregators.RATE && + !metrics[0].field?.endsWith('.pct') && + !metrics[0].field?.endsWith('.bytes') + ? ` ${EventsAsUnit}` + : ''; + const rateUnitPerSec = eventsAsUnit + perSecIfRate; return { ...evaluationResult, currentValue: - currentValue !== null && currentValue !== undefined ? formatter(currentValue) : noDataValue, + currentValue !== null && currentValue !== undefined + ? metricValueFormatter(currentValue, metrics[0].field) + rateUnitPerSec + : noDataValue, label: label || CUSTOM_EQUATION_I18N, threshold: Array.isArray(threshold) - ? threshold.map((v: number) => formatter(v)) - : [formatter(threshold)], + ? threshold.map((v: number) => metricValueFormatter(v, metrics[0].field) + rateUnitPerSec) + : [metricValueFormatter(currentValue, metrics[0].field) + rateUnitPerSec], comparator, }; }; diff --git a/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/metric_query.ts b/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/metric_query.ts index 6471926522929..3cc1eee92fec9 100644 --- a/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/metric_query.ts +++ b/x-pack/plugins/observability/server/lib/rules/custom_threshold/lib/metric_query.ts @@ -6,7 +6,10 @@ */ import moment from 'moment'; -import { CustomMetricExpressionParams } from '../../../../../common/custom_threshold_rule/types'; +import { + Aggregators, + CustomMetricExpressionParams, +} from '../../../../../common/custom_threshold_rule/types'; import { createCustomMetricsAggregations } from './create_custom_metrics_aggregations'; import { CONTAINER_ID, @@ -19,16 +22,23 @@ import { createBucketSelector } from './create_bucket_selector'; import { wrapInCurrentPeriod } from './wrap_in_period'; import { getParsedFilterQuery } from '../../../../utils/get_parsed_filtered_query'; -export const calculateCurrentTimeframe = ( +export const calculateCurrentTimeFrame = ( metricParams: CustomMetricExpressionParams, timeframe: { start: number; end: number } -) => ({ - ...timeframe, - start: moment(timeframe.end).subtract(metricParams.timeSize, metricParams.timeUnit).valueOf(), -}); +) => { + const isRateAgg = metricParams.metrics.some((metric) => metric.aggType === Aggregators.RATE); + return { + ...timeframe, + start: moment(timeframe.end) + .subtract( + isRateAgg ? metricParams.timeSize * 2 : metricParams.timeSize, + metricParams.timeUnit + ) + .valueOf(), + }; +}; export const createBaseFilters = ( - metricParams: CustomMetricExpressionParams, timeframe: { start: number; end: number }, timeFieldName: string, filterQuery?: string @@ -63,14 +73,16 @@ export const getElasticsearchMetricQuery = ( ) => { // We need to make a timeframe that represents the current timeframe as opposed // to the total timeframe (which includes the last period). - const currentTimeframe = { - ...calculateCurrentTimeframe(metricParams, timeframe), + const currentTimeFrame = { + ...calculateCurrentTimeFrame(metricParams, timeframe), timeFieldName, }; const metricAggregations = createCustomMetricsAggregations( 'aggregatedValue', metricParams.metrics, + currentTimeFrame, + timeFieldName, metricParams.equation ); @@ -82,7 +94,7 @@ export const getElasticsearchMetricQuery = ( lastPeriodEnd ); - const currentPeriod = wrapInCurrentPeriod(currentTimeframe, metricAggregations); + const currentPeriod = wrapInCurrentPeriod(currentTimeFrame, metricAggregations); const containerIncludesList = ['container.*']; const containerExcludesList = [ @@ -184,7 +196,7 @@ export const getElasticsearchMetricQuery = ( aggs.groupings.composite.after = afterKey; } - const baseFilters = createBaseFilters(metricParams, timeframe, timeFieldName, filterQuery); + const baseFilters = createBaseFilters(timeframe, timeFieldName, filterQuery); return { track_total_hits: true, diff --git a/x-pack/plugins/observability/server/lib/rules/custom_threshold/messages.ts b/x-pack/plugins/observability/server/lib/rules/custom_threshold/messages.ts index 973ce8f57093a..ca160d06b6573 100644 --- a/x-pack/plugins/observability/server/lib/rules/custom_threshold/messages.ts +++ b/x-pack/plugins/observability/server/lib/rules/custom_threshold/messages.ts @@ -16,6 +16,8 @@ import { BETWEEN_TEXT, NOT_BETWEEN_TEXT, CUSTOM_EQUATION_I18N, + ABOVE_OR_EQ_TEXT, + BELOW_OR_EQ_TEXT, } from './translations'; import { UNGROUPED_FACTORY_KEY } from './constants'; @@ -33,11 +35,13 @@ const recoveredComparatorToI18n = ( case Comparator.OUTSIDE_RANGE: return BETWEEN_TEXT; case Comparator.GT: + return ABOVE_TEXT; case Comparator.GT_OR_EQ: - return BELOW_TEXT; + return ABOVE_OR_EQ_TEXT; case Comparator.LT: + return BELOW_TEXT; case Comparator.LT_OR_EQ: - return ABOVE_TEXT; + return BELOW_OR_EQ_TEXT; } }; @@ -48,11 +52,13 @@ const alertComparatorToI18n = (comparator: Comparator) => { case Comparator.OUTSIDE_RANGE: return NOT_BETWEEN_TEXT; case Comparator.GT: - case Comparator.GT_OR_EQ: return ABOVE_TEXT; + case Comparator.GT_OR_EQ: + return ABOVE_OR_EQ_TEXT; case Comparator.LT: - case Comparator.LT_OR_EQ: return BELOW_TEXT; + case Comparator.LT_OR_EQ: + return BELOW_OR_EQ_TEXT; } }; diff --git a/x-pack/plugins/observability/server/lib/rules/custom_threshold/register_custom_threshold_rule_type.ts b/x-pack/plugins/observability/server/lib/rules/custom_threshold/register_custom_threshold_rule_type.ts index f0e0ede0945c8..5e9c2e0cea019 100644 --- a/x-pack/plugins/observability/server/lib/rules/custom_threshold/register_custom_threshold_rule_type.ts +++ b/x-pack/plugins/observability/server/lib/rules/custom_threshold/register_custom_threshold_rule_type.ts @@ -18,7 +18,7 @@ import { createLifecycleExecutor, IRuleDataClient } from '@kbn/rule-registry-plu import { LicenseType } from '@kbn/licensing-plugin/server'; import { EsQueryRuleParamsExtractedParams } from '@kbn/stack-alerts-plugin/server/rule_types/es_query/rule_type_params'; import { observabilityFeatureId, observabilityPaths } from '../../../../common'; -import { Comparator } from '../../../../common/custom_threshold_rule/types'; +import { Aggregators, Comparator } from '../../../../common/custom_threshold_rule/types'; import { THRESHOLD_RULE_REGISTRATION_CONTEXT } from '../../../common/constants'; import { @@ -76,6 +76,8 @@ export function thresholdRuleType( timeUnit: schema.string(), timeSize: schema.number(), }; + const allowedAggregators = Object.values(Aggregators); + allowedAggregators.splice(Object.values(Aggregators).indexOf(Aggregators.COUNT), 1); const customCriterion = schema.object({ ...baseCriterion, @@ -85,7 +87,7 @@ export function thresholdRuleType( schema.oneOf([ schema.object({ name: schema.string(), - aggType: oneOfLiterals(['avg', 'sum', 'max', 'min', 'cardinality']), + aggType: oneOfLiterals(allowedAggregators), field: schema.string(), filter: schema.never(), }), diff --git a/x-pack/plugins/observability/server/lib/rules/custom_threshold/translations.ts b/x-pack/plugins/observability/server/lib/rules/custom_threshold/translations.ts index 301e793150dc6..020229c27a4ad 100644 --- a/x-pack/plugins/observability/server/lib/rules/custom_threshold/translations.ts +++ b/x-pack/plugins/observability/server/lib/rules/custom_threshold/translations.ts @@ -22,6 +22,30 @@ export const AVERAGE_I18N = (metric: string) => }, }); +export const PERCENTILE_99_I18N = (metric: string) => + i18n.translate('xpack.observability.customThreshold.rule.aggregators.p99', { + defaultMessage: '99th percentile of {metric}', + values: { + metric, + }, + }); + +export const PERCENTILE_95_I18N = (metric: string) => + i18n.translate('xpack.observability.customThreshold.rule.aggregators.p95', { + defaultMessage: '95th percentile of {metric}', + values: { + metric, + }, + }); + +export const RATE_I18N = (metric: string) => + i18n.translate('xpack.observability.customThreshold.rule.aggregators.rate', { + defaultMessage: 'Rate of {metric}', + values: { + metric, + }, + }); + export const MAX_I18N = (metric: string) => i18n.translate('xpack.observability.customThreshold.rule.aggregators.max', { defaultMessage: 'Max {metric}', @@ -70,6 +94,13 @@ export const BELOW_TEXT = i18n.translate( } ); +export const BELOW_OR_EQ_TEXT = i18n.translate( + 'xpack.observability.customThreshold.rule.threshold.belowOrEqual', + { + defaultMessage: 'below or equal', + } +); + export const ABOVE_TEXT = i18n.translate( 'xpack.observability.customThreshold.rule.threshold.above', { @@ -77,6 +108,13 @@ export const ABOVE_TEXT = i18n.translate( } ); +export const ABOVE_OR_EQ_TEXT = i18n.translate( + 'xpack.observability.customThreshold.rule.threshold.aboveOrEqual', + { + defaultMessage: 'above or equal', + } +); + export const BETWEEN_TEXT = i18n.translate( 'xpack.observability.customThreshold.rule.threshold.between', { diff --git a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/group_by_fired.ts b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/group_by_fired.ts index 13ff525daa022..ca73e43114441 100644 --- a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/group_by_fired.ts +++ b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/group_by_fired.ts @@ -262,7 +262,7 @@ export default function ({ getService }: FtrProviderContext) { `https://localhost:5601/app/observability/alerts?_a=(kuery:%27kibana.alert.uuid:%20%22${alertId}%22%27%2CrangeFrom:%27${rangeFrom}%27%2CrangeTo:now%2Cstatus:all)` ); expect(resp.hits.hits[0]._source?.reason).eql( - `Average system.cpu.total.norm.pct is 80%, above the threshold of 20%. (duration: 1 min, data view: ${DATA_VIEW}, group: host-0,container-0)` + `Average system.cpu.total.norm.pct is 80%, above or equal the threshold of 20%. (duration: 1 min, data view: ${DATA_VIEW}, group: host-0,container-0)` ); expect(resp.hits.hits[0]._source?.value).eql('80%'); expect(resp.hits.hits[0]._source?.host).eql( diff --git a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/p99_pct_fired.ts b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/p99_pct_fired.ts new file mode 100644 index 0000000000000..7079babd8b6fb --- /dev/null +++ b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/p99_pct_fired.ts @@ -0,0 +1,254 @@ +/* + * 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 moment from 'moment'; +import { omit } from 'lodash'; +import { cleanup, generate, Dataset, PartialConfig } from '@kbn/data-forge'; +import { + Aggregators, + Comparator, +} from '@kbn/observability-plugin/common/custom_threshold_rule/types'; +import { FIRED_ACTIONS_ID } from '@kbn/observability-plugin/server/lib/rules/custom_threshold/constants'; +import expect from '@kbn/expect'; +import { OBSERVABILITY_THRESHOLD_RULE_TYPE_ID } from '@kbn/rule-data-utils'; +import { parseSearchParams } from '@kbn/share-plugin/common/url_service'; +import { createIndexConnector, createRule } from '../helpers/alerting_api_helper'; +import { + waitForAlertInIndex, + waitForDocumentInIndex, + waitForRuleStatus, +} from '../helpers/alerting_wait_for_helpers'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { ActionDocument, LogsExplorerLocatorParsedParams } from './typings'; +import { ISO_DATE_REGEX } from './constants'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esClient = getService('es'); + const supertest = getService('supertest'); + const esDeleteAllIndices = getService('esDeleteAllIndices'); + const logger = getService('log'); + + describe('Custom Threshold rule - P99 - PCT - FIRED', () => { + const CUSTOM_THRESHOLD_RULE_ALERT_INDEX = '.alerts-observability.threshold.alerts-default'; + const ALERT_ACTION_INDEX = 'alert-action-threshold'; + const DATE_VIEW_TITLE = 'kbn-data-forge-fake_hosts.fake_hosts-*'; + const DATE_VIEW_NAME = 'ad-hoc-data-view-name'; + const DATA_VIEW_ID = 'data-view-id'; + const MOCKED_AD_HOC_DATA_VIEW = { + id: DATA_VIEW_ID, + title: DATE_VIEW_TITLE, + timeFieldName: '@timestamp', + sourceFilters: [], + fieldFormats: {}, + runtimeFieldMap: {}, + allowNoIndex: false, + name: DATE_VIEW_NAME, + allowHidden: false, + }; + let dataForgeConfig: PartialConfig; + let dataForgeIndices: string[]; + let actionId: string; + let ruleId: string; + let alertId: string; + let startedAt: string; + + before(async () => { + dataForgeConfig = { + schedule: [ + { + template: 'good', + start: 'now-15m', + end: 'now', + metrics: [{ name: 'system.cpu.user.pct', method: 'linear', start: 2.5, end: 2.5 }], + }, + ], + indexing: { dataset: 'fake_hosts' as Dataset, eventsPerCycle: 1, interval: 10000 }, + }; + dataForgeIndices = await generate({ client: esClient, config: dataForgeConfig, logger }); + logger.info(JSON.stringify(dataForgeIndices.join(','))); + await waitForDocumentInIndex({ + esClient, + indexName: DATE_VIEW_TITLE, + docCountTarget: 270, + }); + }); + + after(async () => { + await supertest.delete(`/api/alerting/rule/${ruleId}`).set('kbn-xsrf', 'foo'); + await supertest.delete(`/api/actions/connector/${actionId}`).set('kbn-xsrf', 'foo'); + await esClient.deleteByQuery({ + index: CUSTOM_THRESHOLD_RULE_ALERT_INDEX, + query: { term: { 'kibana.alert.rule.uuid': ruleId } }, + }); + await esClient.deleteByQuery({ + index: '.kibana-event-log-*', + query: { term: { 'kibana.alert.rule.consumer': 'logs' } }, + }); + await esDeleteAllIndices([ALERT_ACTION_INDEX, ...dataForgeIndices]); + await cleanup({ client: esClient, config: dataForgeConfig, logger }); + }); + + describe('Rule creation', () => { + it('creates rule successfully', async () => { + actionId = await createIndexConnector({ + supertest, + name: 'Index Connector: Threshold API test', + indexName: ALERT_ACTION_INDEX, + }); + + const createdRule = await createRule({ + supertest, + tags: ['observability'], + consumer: 'logs', + name: 'Threshold rule', + ruleTypeId: OBSERVABILITY_THRESHOLD_RULE_TYPE_ID, + params: { + criteria: [ + { + comparator: Comparator.GT, + threshold: [0.5], + timeSize: 5, + timeUnit: 'm', + metrics: [{ name: 'A', field: 'system.cpu.user.pct', aggType: Aggregators.P99 }], + }, + ], + alertOnNoData: true, + alertOnGroupDisappear: true, + searchConfiguration: { + query: { + query: '', + language: 'kuery', + }, + index: MOCKED_AD_HOC_DATA_VIEW, + }, + }, + actions: [ + { + group: FIRED_ACTIONS_ID, + id: actionId, + params: { + documents: [ + { + ruleType: '{{rule.type}}', + alertDetailsUrl: '{{context.alertDetailsUrl}}', + reason: '{{context.reason}}', + value: '{{context.value}}', + host: '{{context.host}}', + viewInAppUrl: '{{context.viewInAppUrl}}', + }, + ], + }, + frequency: { + notify_when: 'onActionGroupChange', + throttle: null, + summary: false, + }, + }, + ], + }); + ruleId = createdRule.id; + expect(ruleId).not.to.be(undefined); + }); + + it('should be active', async () => { + const executionStatus = await waitForRuleStatus({ + id: ruleId, + expectedStatus: 'active', + supertest, + }); + expect(executionStatus.status).to.be('active'); + }); + + it('should set correct information in the alert document', async () => { + const resp = await waitForAlertInIndex({ + esClient, + indexName: CUSTOM_THRESHOLD_RULE_ALERT_INDEX, + ruleId, + }); + alertId = (resp.hits.hits[0]._source as any)['kibana.alert.uuid']; + startedAt = (resp.hits.hits[0]._source as any)['kibana.alert.start']; + + expect(resp.hits.hits[0]._source).property( + 'kibana.alert.rule.category', + 'Custom threshold (Beta)' + ); + expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'logs'); + expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule'); + expect(resp.hits.hits[0]._source).property('kibana.alert.rule.producer', 'observability'); + expect(resp.hits.hits[0]._source).property('kibana.alert.rule.revision', 0); + expect(resp.hits.hits[0]._source).property( + 'kibana.alert.rule.rule_type_id', + 'observability.rules.custom_threshold' + ); + expect(resp.hits.hits[0]._source).property('kibana.alert.rule.uuid', ruleId); + expect(resp.hits.hits[0]._source).property('kibana.space_ids').contain('default'); + expect(resp.hits.hits[0]._source) + .property('kibana.alert.rule.tags') + .contain('observability'); + expect(resp.hits.hits[0]._source).property( + 'kibana.alert.action_group', + 'custom_threshold.fired' + ); + expect(resp.hits.hits[0]._source).property('tags').contain('observability'); + expect(resp.hits.hits[0]._source).property('kibana.alert.instance.id', '*'); + expect(resp.hits.hits[0]._source).property('kibana.alert.workflow_status', 'open'); + expect(resp.hits.hits[0]._source).property('event.kind', 'signal'); + expect(resp.hits.hits[0]._source).property('event.action', 'open'); + + expect(resp.hits.hits[0]._source) + .property('kibana.alert.rule.parameters') + .eql({ + criteria: [ + { + comparator: '>', + threshold: [0.5], + timeSize: 5, + timeUnit: 'm', + metrics: [{ name: 'A', field: 'system.cpu.user.pct', aggType: 'p99' }], + }, + ], + alertOnNoData: true, + alertOnGroupDisappear: true, + searchConfiguration: { + index: MOCKED_AD_HOC_DATA_VIEW, + query: { query: '', language: 'kuery' }, + }, + }); + }); + + it('should set correct action variables', async () => { + const rangeFrom = moment(startedAt).subtract('5', 'minute').toISOString(); + const resp = await waitForDocumentInIndex({ + esClient, + indexName: ALERT_ACTION_INDEX, + }); + + expect(resp.hits.hits[0]._source?.ruleType).eql('observability.rules.custom_threshold'); + expect(resp.hits.hits[0]._source?.alertDetailsUrl).eql( + `https://localhost:5601/app/observability/alerts?_a=(kuery:%27kibana.alert.uuid:%20%22${alertId}%22%27%2CrangeFrom:%27${rangeFrom}%27%2CrangeTo:now%2Cstatus:all)` + ); + expect(resp.hits.hits[0]._source?.reason).eql( + `99th percentile of system.cpu.user.pct is 250%, above the threshold of 50%. (duration: 5 mins, data view: ${DATE_VIEW_NAME})` + ); + expect(resp.hits.hits[0]._source?.value).eql('250%'); + + const parsedViewInAppUrl = parseSearchParams( + new URL(resp.hits.hits[0]._source?.viewInAppUrl || '').search + ); + + expect(resp.hits.hits[0]._source?.viewInAppUrl).contain('LOGS_EXPLORER_LOCATOR'); + expect(omit(parsedViewInAppUrl.params, 'timeRange.from')).eql({ + dataset: DATE_VIEW_TITLE, + timeRange: { to: 'now' }, + query: { query: '', language: 'kuery' }, + }); + expect(parsedViewInAppUrl.params.timeRange.from).match(ISO_DATE_REGEX); + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/rate_bytes_fired.ts b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/rate_bytes_fired.ts new file mode 100644 index 0000000000000..226083cb29730 --- /dev/null +++ b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/rate_bytes_fired.ts @@ -0,0 +1,268 @@ +/* + * 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 moment from 'moment'; +import { cleanup, generate, Dataset, PartialConfig } from '@kbn/data-forge'; +import { + Aggregators, + Comparator, +} from '@kbn/observability-plugin/common/custom_threshold_rule/types'; +import { FIRED_ACTIONS_ID } from '@kbn/observability-plugin/server/lib/rules/custom_threshold/constants'; +import expect from '@kbn/expect'; +import { OBSERVABILITY_THRESHOLD_RULE_TYPE_ID } from '@kbn/rule-data-utils'; +import { createIndexConnector, createRule } from '../helpers/alerting_api_helper'; +import { createDataView, deleteDataView } from '../helpers/data_view'; +import { + waitForAlertInIndex, + waitForDocumentInIndex, + waitForRuleStatus, +} from '../helpers/alerting_wait_for_helpers'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { ActionDocument } from './typings'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esClient = getService('es'); + const supertest = getService('supertest'); + const esDeleteAllIndices = getService('esDeleteAllIndices'); + const logger = getService('log'); + + describe('Custom Threshold rule RATE - GROUP_BY - BYTES - FIRED', () => { + const CUSTOM_THRESHOLD_RULE_ALERT_INDEX = '.alerts-observability.threshold.alerts-default'; + const ALERT_ACTION_INDEX = 'alert-action-threshold'; + const DATE_VIEW = 'kbn-data-forge-fake_hosts.fake_hosts-*'; + const DATA_VIEW_ID = 'data-view-id'; + let dataForgeConfig: PartialConfig; + let dataForgeIndices: string[]; + let actionId: string; + let ruleId: string; + let alertId: string; + let startedAt: string; + + before(async () => { + dataForgeConfig = { + schedule: [ + { + template: 'good', + start: 'now-15m', + end: 'now', + metrics: [{ name: 'system.network.in.bytes', method: 'exp', start: 10, end: 100 }], + }, + ], + indexing: { dataset: 'fake_hosts' as Dataset, eventsPerCycle: 1, interval: 10000 }, + }; + dataForgeIndices = await generate({ client: esClient, config: dataForgeConfig, logger }); + await waitForDocumentInIndex({ + esClient, + indexName: dataForgeIndices.join(','), + docCountTarget: 270, + }); + await createDataView({ + supertest, + name: DATE_VIEW, + id: DATA_VIEW_ID, + title: DATE_VIEW, + }); + }); + + after(async () => { + await supertest.delete(`/api/alerting/rule/${ruleId}`).set('kbn-xsrf', 'foo'); + await supertest.delete(`/api/actions/connector/${actionId}`).set('kbn-xsrf', 'foo'); + await esClient.deleteByQuery({ + index: CUSTOM_THRESHOLD_RULE_ALERT_INDEX, + query: { term: { 'kibana.alert.rule.uuid': ruleId } }, + }); + await esClient.deleteByQuery({ + index: '.kibana-event-log-*', + query: { term: { 'kibana.alert.rule.consumer': 'logs' } }, + }); + await deleteDataView({ + supertest, + id: DATA_VIEW_ID, + }); + await esDeleteAllIndices([ALERT_ACTION_INDEX, ...dataForgeIndices]); + await cleanup({ client: esClient, config: dataForgeConfig, logger }); + }); + + describe('Rule creation', () => { + it('creates rule successfully', async () => { + actionId = await createIndexConnector({ + supertest, + name: 'Index Connector: Threshold API test', + indexName: ALERT_ACTION_INDEX, + }); + + const createdRule = await createRule({ + supertest, + tags: ['observability'], + consumer: 'logs', + name: 'Threshold rule', + ruleTypeId: OBSERVABILITY_THRESHOLD_RULE_TYPE_ID, + params: { + criteria: [ + { + comparator: Comparator.GT_OR_EQ, + threshold: [0.2], + timeSize: 1, + timeUnit: 'm', + metrics: [ + { name: 'A', field: 'system.network.in.bytes', aggType: Aggregators.RATE }, + ], + }, + ], + alertOnNoData: true, + alertOnGroupDisappear: true, + searchConfiguration: { + query: { + query: '', + language: 'kuery', + }, + index: DATA_VIEW_ID, + }, + groupBy: ['host.name', 'container.id'], + }, + actions: [ + { + group: FIRED_ACTIONS_ID, + id: actionId, + params: { + documents: [ + { + ruleType: '{{rule.type}}', + alertDetailsUrl: '{{context.alertDetailsUrl}}', + reason: '{{context.reason}}', + value: '{{context.value}}', + host: '{{context.host}}', + group: '{{context.group}}', + }, + ], + }, + frequency: { + notify_when: 'onActionGroupChange', + throttle: null, + summary: false, + }, + }, + ], + }); + ruleId = createdRule.id; + expect(ruleId).not.to.be(undefined); + }); + + it('should be active', async () => { + const executionStatus = await waitForRuleStatus({ + id: ruleId, + expectedStatus: 'active', + supertest, + }); + expect(executionStatus.status).to.be('active'); + }); + + it('should set correct information in the alert document', async () => { + const resp = await waitForAlertInIndex({ + esClient, + indexName: CUSTOM_THRESHOLD_RULE_ALERT_INDEX, + ruleId, + }); + alertId = (resp.hits.hits[0]._source as any)['kibana.alert.uuid']; + startedAt = (resp.hits.hits[0]._source as any)['kibana.alert.start']; + + expect(resp.hits.hits[0]._source).property( + 'kibana.alert.rule.category', + 'Custom threshold (Beta)' + ); + expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'logs'); + expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule'); + expect(resp.hits.hits[0]._source).property('kibana.alert.rule.producer', 'observability'); + expect(resp.hits.hits[0]._source).property('kibana.alert.rule.revision', 0); + expect(resp.hits.hits[0]._source).property( + 'kibana.alert.rule.rule_type_id', + 'observability.rules.custom_threshold' + ); + expect(resp.hits.hits[0]._source).property('kibana.alert.rule.uuid', ruleId); + expect(resp.hits.hits[0]._source).property('kibana.space_ids').contain('default'); + expect(resp.hits.hits[0]._source) + .property('kibana.alert.rule.tags') + .contain('observability'); + expect(resp.hits.hits[0]._source).property( + 'kibana.alert.action_group', + 'custom_threshold.fired' + ); + expect(resp.hits.hits[0]._source).property('tags').contain('observability'); + expect(resp.hits.hits[0]._source).property( + 'kibana.alert.instance.id', + 'host-0,container-0' + ); + expect(resp.hits.hits[0]._source).property('kibana.alert.workflow_status', 'open'); + expect(resp.hits.hits[0]._source).property('event.kind', 'signal'); + expect(resp.hits.hits[0]._source).property('event.action', 'open'); + + expect(resp.hits.hits[0]._source).property('host.name', 'host-0'); + expect(resp.hits.hits[0]._source) + .property('host.mac') + .eql(['00-00-5E-00-53-23', '00-00-5E-00-53-24']); + expect(resp.hits.hits[0]._source).property('container.id', 'container-0'); + expect(resp.hits.hits[0]._source).property('container.name', 'container-name'); + expect(resp.hits.hits[0]._source).not.property('container.cpu'); + + expect(resp.hits.hits[0]._source) + .property('kibana.alert.group') + .eql([ + { + field: 'host.name', + value: 'host-0', + }, + { + field: 'container.id', + value: 'container-0', + }, + ]); + + expect(resp.hits.hits[0]._source) + .property('kibana.alert.rule.parameters') + .eql({ + criteria: [ + { + comparator: '>=', + threshold: [0.2], + timeSize: 1, + timeUnit: 'm', + metrics: [{ name: 'A', field: 'system.network.in.bytes', aggType: 'rate' }], + }, + ], + alertOnNoData: true, + alertOnGroupDisappear: true, + searchConfiguration: { index: 'data-view-id', query: { query: '', language: 'kuery' } }, + groupBy: ['host.name', 'container.id'], + }); + }); + + it('should set correct action variables', async () => { + const rangeFrom = moment(startedAt).subtract('5', 'minute').toISOString(); + const resp = await waitForDocumentInIndex({ + esClient, + indexName: ALERT_ACTION_INDEX, + }); + + expect(resp.hits.hits[0]._source?.ruleType).eql('observability.rules.custom_threshold'); + expect(resp.hits.hits[0]._source?.alertDetailsUrl).eql( + `https://localhost:5601/app/observability/alerts?_a=(kuery:%27kibana.alert.uuid:%20%22${alertId}%22%27%2CrangeFrom:%27${rangeFrom}%27%2CrangeTo:now%2Cstatus:all)` + ); + expect(resp.hits.hits[0]._source?.reason).eql( + `Rate of system.network.in.bytes is 0.2 B/s, above or equal the threshold of 0.2 B/s. (duration: 1 min, data view: kbn-data-forge-fake_hosts.fake_hosts-*, group: host-0,container-0)` + ); + expect(resp.hits.hits[0]._source?.value).eql('0.2 B/s'); + expect(resp.hits.hits[0]._source?.host).eql( + '{"name":"host-0","mac":["00-00-5E-00-53-23","00-00-5E-00-53-24"]}' + ); + expect(resp.hits.hits[0]._source?.group).eql( + '{"field":"host.name","value":"host-0"},{"field":"container.id","value":"container-0"}' + ); + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/observability/index.ts b/x-pack/test/alerting_api_integration/observability/index.ts index 884c17d2abfd1..812123dd96b13 100644 --- a/x-pack/test/alerting_api_integration/observability/index.ts +++ b/x-pack/test/alerting_api_integration/observability/index.ts @@ -11,6 +11,8 @@ export default function ({ loadTestFile }: any) { describe('Rules Endpoints', () => { loadTestFile(require.resolve('./metric_threshold_rule')); loadTestFile(require.resolve('./custom_threshold_rule/avg_pct_fired')); + loadTestFile(require.resolve('./custom_threshold_rule/p99_pct_fired')); + loadTestFile(require.resolve('./custom_threshold_rule/rate_bytes_fired')); loadTestFile(require.resolve('./custom_threshold_rule/avg_pct_no_data')); loadTestFile(require.resolve('./custom_threshold_rule/avg_us_fired')); loadTestFile(require.resolve('./custom_threshold_rule/custom_eq_avg_bytes_fired')); diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/group_by_fired.ts b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/group_by_fired.ts index a80be6721f6af..ccd2aa6edaeaa 100644 --- a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/group_by_fired.ts +++ b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/group_by_fired.ts @@ -255,7 +255,7 @@ export default function ({ getService }: FtrProviderContext) { `${protocol}://${hostname}:${port}/app/observability/alerts?_a=(kuery:%27kibana.alert.uuid:%20%22${alertId}%22%27%2CrangeFrom:%27${rangeFrom}%27%2CrangeTo:now%2Cstatus:all)` ); expect(resp.hits.hits[0]._source?.reason).eql( - `Average system.cpu.total.norm.pct is 80%, above the threshold of 20%. (duration: 1 min, data view: ${DATA_VIEW}, group: host-0)` + `Average system.cpu.total.norm.pct is 80%, above or equal the threshold of 20%. (duration: 1 min, data view: ${DATA_VIEW}, group: host-0)` ); expect(resp.hits.hits[0]._source?.value).eql('80%'); expect(resp.hits.hits[0]._source?.host).eql( diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/p99_bytes_fired.ts b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/p99_bytes_fired.ts new file mode 100644 index 0000000000000..70f3192c117e9 --- /dev/null +++ b/x-pack/test_serverless/api_integration/test_suites/observability/custom_threshold_rule/p99_bytes_fired.ts @@ -0,0 +1,199 @@ +/* + * 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 { cleanup, generate } from '@kbn/infra-forge'; +import { + Aggregators, + Comparator, +} from '@kbn/observability-plugin/common/custom_threshold_rule/types'; +import { FIRED_ACTIONS_ID } from '@kbn/observability-plugin/server/lib/rules/custom_threshold/constants'; +import expect from '@kbn/expect'; +import { OBSERVABILITY_THRESHOLD_RULE_TYPE_ID } from '@kbn/rule-data-utils'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const esClient = getService('es'); + const supertest = getService('supertest'); + const esDeleteAllIndices = getService('esDeleteAllIndices'); + const alertingApi = getService('alertingApi'); + const dataViewApi = getService('dataViewApi'); + const logger = getService('log'); + + describe('Custom Threshold rule - P99 - BYTES - FIRED', () => { + const CUSTOM_THRESHOLD_RULE_ALERT_INDEX = '.alerts-observability.threshold.alerts-default'; + // DATE_VIEW should match the index template: + // x-pack/packages/kbn-infra-forge/src/data_sources/composable/template.json + const DATE_VIEW = 'kbn-data-forge-fake_hosts'; + const ALERT_ACTION_INDEX = 'alert-action-threshold'; + const DATA_VIEW_ID = 'data-view-id'; + let infraDataIndex: string; + let actionId: string; + let ruleId: string; + + before(async () => { + infraDataIndex = await generate({ + esClient, + lookback: 'now-15m', + logger, + }); + await dataViewApi.create({ + name: DATE_VIEW, + id: DATA_VIEW_ID, + title: DATE_VIEW, + }); + }); + + after(async () => { + await supertest + .delete(`/api/alerting/rule/${ruleId}`) + .set('kbn-xsrf', 'foo') + .set('x-elastic-internal-origin', 'foo'); + await supertest + .delete(`/api/actions/connector/${actionId}`) + .set('kbn-xsrf', 'foo') + .set('x-elastic-internal-origin', 'foo'); + await esClient.deleteByQuery({ + index: CUSTOM_THRESHOLD_RULE_ALERT_INDEX, + query: { term: { 'kibana.alert.rule.uuid': ruleId } }, + conflicts: 'proceed', + }); + await esClient.deleteByQuery({ + index: '.kibana-event-log-*', + query: { term: { 'rule.id': ruleId } }, + conflicts: 'proceed', + }); + await dataViewApi.delete({ + id: DATA_VIEW_ID, + }); + await esDeleteAllIndices([ALERT_ACTION_INDEX, infraDataIndex]); + await cleanup({ esClient, logger }); + }); + + describe('Rule creation', () => { + it('creates rule successfully', async () => { + actionId = await alertingApi.createIndexConnector({ + name: 'Index Connector: Threshold API test', + indexName: ALERT_ACTION_INDEX, + }); + + const createdRule = await alertingApi.createRule({ + tags: ['observability'], + consumer: 'observability', + name: 'Threshold rule', + ruleTypeId: OBSERVABILITY_THRESHOLD_RULE_TYPE_ID, + params: { + criteria: [ + { + comparator: Comparator.GT, + threshold: [1], + timeSize: 5, + timeUnit: 'm', + metrics: [ + { name: 'A', field: 'system.network.in.bytes', aggType: Aggregators.P99 }, + ], + }, + ], + alertOnNoData: true, + alertOnGroupDisappear: true, + searchConfiguration: { + query: { + query: '', + language: 'kuery', + }, + index: DATA_VIEW_ID, + }, + }, + actions: [ + { + group: FIRED_ACTIONS_ID, + id: actionId, + params: { + documents: [ + { + ruleType: '{{rule.type}}', + }, + ], + }, + frequency: { + notify_when: 'onActionGroupChange', + throttle: null, + summary: false, + }, + }, + ], + }); + ruleId = createdRule.id; + expect(ruleId).not.to.be(undefined); + }); + + it('should be active', async () => { + const executionStatus = await alertingApi.waitForRuleStatus({ + ruleId, + expectedStatus: 'active', + }); + expect(executionStatus).to.be('active'); + }); + + it('should find the created rule with correct information about the consumer', async () => { + const match = await alertingApi.findRule(ruleId); + expect(match).not.to.be(undefined); + expect(match.consumer).to.be('observability'); + }); + + it('should set correct information in the alert document', async () => { + const resp = await alertingApi.waitForAlertInIndex({ + indexName: CUSTOM_THRESHOLD_RULE_ALERT_INDEX, + ruleId, + }); + + expect(resp.hits.hits[0]._source).property( + 'kibana.alert.rule.category', + 'Custom threshold (Beta)' + ); + expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'observability'); + expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule'); + expect(resp.hits.hits[0]._source).property('kibana.alert.rule.producer', 'observability'); + expect(resp.hits.hits[0]._source).property('kibana.alert.rule.revision', 0); + expect(resp.hits.hits[0]._source).property( + 'kibana.alert.rule.rule_type_id', + 'observability.rules.custom_threshold' + ); + expect(resp.hits.hits[0]._source).property('kibana.alert.rule.uuid', ruleId); + expect(resp.hits.hits[0]._source).property('kibana.space_ids').contain('default'); + expect(resp.hits.hits[0]._source) + .property('kibana.alert.rule.tags') + .contain('observability'); + expect(resp.hits.hits[0]._source).property( + 'kibana.alert.action_group', + 'custom_threshold.fired' + ); + expect(resp.hits.hits[0]._source).property('tags').contain('observability'); + expect(resp.hits.hits[0]._source).property('kibana.alert.instance.id', '*'); + expect(resp.hits.hits[0]._source).property('kibana.alert.workflow_status', 'open'); + expect(resp.hits.hits[0]._source).property('event.kind', 'signal'); + expect(resp.hits.hits[0]._source).property('event.action', 'open'); + + expect(resp.hits.hits[0]._source) + .property('kibana.alert.rule.parameters') + .eql({ + criteria: [ + { + comparator: '>', + threshold: [1], + timeSize: 5, + timeUnit: 'm', + metrics: [{ name: 'A', field: 'system.network.in.bytes', aggType: 'p99' }], + }, + ], + alertOnNoData: true, + alertOnGroupDisappear: true, + searchConfiguration: { index: 'data-view-id', query: { query: '', language: 'kuery' } }, + }); + }); + }); + }); +}