Revisit custom threshold types in public folder (#170306)

Part of #159340
Closes #169364

## Summary

This PR:
1. Removes preFill logic
In this PR, I removed the logic about prefilling custom threshold rule
params as it was originally for other rule types (not custom equation)
and to be used in the Metric threshold rule and the code related to this
logic was super confusing, and I wasn't even sure if it works as
expected since we haven't used this logic anywhere. I created a
[ticket](https://github.com/elastic/kibana/issues/170295) to bring back
this feature properly later, specifically for the custom equation, and
integrate it in one of the apps, such as Infra. We also need to be able
to preFill data view information (both adHoc and persisted data view)
2. Renames types and file names 
      - From `metricThreshold` to `customThreshold`
      - From `metricExplorer` to `expression`
3. Removes unused types
4. Remove logic related to aggregations other than the custom equation
at the top level

Also, the fields that end with `pct` now have the `%` after the related
value: (The reason message was fixed in another PR)

<img
src="83694d3b-2ee2-4e95-afe9-5a959c76c3c7"
width=400 />


## 🧪 How to test
- Nothing has changed related to functionality, so please make sure the
custom threshold rule is working as before for
    - Creating a new rule with multiple conditions
    - Adding groups
    - Editing a rule and checking the charts are shown as before
    - Test both adHoc and persisted data view

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Faisal Kanout <faisal.kanout@elastic.co>
This commit is contained in:
Maryam Saeidi 2023-11-14 10:02:40 +01:00 committed by GitHub
parent 33ba4913e5
commit 6f2ad265d5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
92 changed files with 1117 additions and 2483 deletions

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { CUSTOM_AGGREGATOR } from '@kbn/observability-plugin/common/custom_threshold_rule/constants';
import {
Aggregators,
Comparator,
@ -38,7 +39,7 @@ export const createCustomThresholdRule = async (
params: {
criteria: ruleParams.params?.criteria || [
{
aggType: Aggregators.CUSTOM,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.GT,
threshold: [1],
timeSize: 1,

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { CUSTOM_AGGREGATOR } from '@kbn/observability-plugin/common/custom_threshold_rule/constants';
import {
Aggregators,
Comparator,
@ -22,7 +23,7 @@ export const scenario1 = {
params: {
criteria: [
{
aggType: Aggregators.CUSTOM,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.LT,
threshold: [100],
timeSize: 1,

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { CUSTOM_AGGREGATOR } from '@kbn/observability-plugin/common/custom_threshold_rule/constants';
import {
Aggregators,
Comparator,
@ -22,7 +23,7 @@ export const scenario2 = {
params: {
criteria: [
{
aggType: Aggregators.CUSTOM,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.LT,
threshold: [40],
timeSize: 1,

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { CUSTOM_AGGREGATOR } from '@kbn/observability-plugin/common/custom_threshold_rule/constants';
import {
Aggregators,
Comparator,
@ -22,7 +23,7 @@ export const scenario3 = {
params: {
criteria: [
{
aggType: Aggregators.CUSTOM,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.LT,
threshold: [5],
timeSize: 1,

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { CUSTOM_AGGREGATOR } from '@kbn/observability-plugin/common/custom_threshold_rule/constants';
import {
Aggregators,
Comparator,
@ -22,7 +23,7 @@ export const scenario4 = {
params: {
criteria: [
{
aggType: Aggregators.CUSTOM,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.GT,
threshold: [80],
timeSize: 1,

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { CUSTOM_AGGREGATOR } from '@kbn/observability-plugin/common/custom_threshold_rule/constants';
import {
Aggregators,
Comparator,
@ -22,7 +23,7 @@ export const scenario5 = {
params: {
criteria: [
{
aggType: Aggregators.CUSTOM,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.GT,
threshold: [80],
timeSize: 5,

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { CUSTOM_AGGREGATOR } from '@kbn/observability-plugin/common/custom_threshold_rule/constants';
import {
Aggregators,
Comparator,
@ -22,7 +23,7 @@ export const scenario6 = {
params: {
criteria: [
{
aggType: Aggregators.CUSTOM,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.LT,
threshold: [1],
timeSize: 1,

View file

@ -5,16 +5,13 @@
* 2.0.
*/
export const SNAPSHOT_CUSTOM_AGGREGATIONS = ['avg', 'max', 'min', 'rate'] as const;
export const METRIC_EXPLORER_AGGREGATIONS = [
'avg',
'max',
'min',
'cardinality',
'rate',
'count',
'sum',
'p95',
'p99',
'custom',
] as const;
export const CUSTOM_AGGREGATOR = 'custom';

View file

@ -14,7 +14,7 @@ import { InfraWaffleMapDataFormat } from './types';
export const FORMATTERS = {
number: formatNumber,
// Because the implimentation for formatting large numbers is the same as formatting
// Because the implementation for formatting large numbers is the same as formatting
// bytes we are re-using the same code, we just format the number using the abbreviated number format.
abbreviatedNumber: createBytesFormatter(InfraWaffleMapDataFormat.abbreviatedNumber),
// bytes in bytes formatted string out

View file

@ -1,154 +0,0 @@
/*
* 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 * as rt from 'io-ts';
import { xor } from 'lodash';
import { METRIC_EXPLORER_AGGREGATIONS } from './constants';
export const OMITTED_AGGREGATIONS_FOR_CUSTOM_METRICS = ['custom', 'rate', 'p95', 'p99'];
type MetricExplorerAggregations = typeof METRIC_EXPLORER_AGGREGATIONS[number];
const metricsExplorerAggregationKeys = METRIC_EXPLORER_AGGREGATIONS.reduce<
Record<MetricExplorerAggregations, null>
>((acc, agg) => ({ ...acc, [agg]: null }), {} as Record<MetricExplorerAggregations, null>);
export const metricsExplorerAggregationRT = rt.keyof(metricsExplorerAggregationKeys);
export type MetricExplorerCustomMetricAggregations = Exclude<
MetricsExplorerAggregation,
'custom' | 'rate' | 'p95' | 'p99'
>;
const metricsExplorerCustomMetricAggregationKeys = xor(
METRIC_EXPLORER_AGGREGATIONS,
OMITTED_AGGREGATIONS_FOR_CUSTOM_METRICS
).reduce<Record<MetricExplorerCustomMetricAggregations, null>>(
(acc, agg) => ({ ...acc, [agg]: null }),
{} as Record<MetricExplorerCustomMetricAggregations, null>
);
export const metricsExplorerCustomMetricAggregationRT = rt.keyof(
metricsExplorerCustomMetricAggregationKeys
);
export const metricsExplorerMetricRequiredFieldsRT = rt.type({
aggregation: metricsExplorerAggregationRT,
});
export const metricsExplorerCustomMetricRT = rt.intersection([
rt.type({
name: rt.string,
aggregation: metricsExplorerCustomMetricAggregationRT,
}),
rt.partial({
field: rt.string,
filter: rt.string,
}),
]);
export type MetricsExplorerCustomMetric = rt.TypeOf<typeof metricsExplorerCustomMetricRT>;
export const metricsExplorerMetricOptionalFieldsRT = rt.partial({
field: rt.union([rt.string, rt.undefined]),
custom_metrics: rt.array(metricsExplorerCustomMetricRT),
equation: rt.string,
});
export const metricsExplorerMetricRT = rt.intersection([
metricsExplorerMetricRequiredFieldsRT,
metricsExplorerMetricOptionalFieldsRT,
]);
export const timeRangeRT = rt.type({
from: rt.number,
to: rt.number,
interval: rt.string,
});
export const metricsExplorerRequestBodyRequiredFieldsRT = rt.type({
timerange: timeRangeRT,
indexPattern: rt.string,
metrics: rt.array(metricsExplorerMetricRT),
});
const groupByRT = rt.union([rt.string, rt.null, rt.undefined]);
export const afterKeyObjectRT = rt.record(rt.string, rt.union([rt.string, rt.null]));
export const metricsExplorerRequestBodyOptionalFieldsRT = rt.partial({
groupBy: rt.union([groupByRT, rt.array(groupByRT)]),
afterKey: rt.union([rt.string, rt.null, rt.undefined, afterKeyObjectRT]),
limit: rt.union([rt.number, rt.null, rt.undefined]),
filterQuery: rt.union([rt.string, rt.null, rt.undefined]),
forceInterval: rt.boolean,
dropLastBucket: rt.boolean,
});
export const metricsExplorerRequestBodyRT = rt.intersection([
metricsExplorerRequestBodyRequiredFieldsRT,
metricsExplorerRequestBodyOptionalFieldsRT,
]);
export const metricsExplorerPageInfoRT = rt.type({
total: rt.number,
afterKey: rt.union([rt.string, rt.null, afterKeyObjectRT]),
});
export const metricsExplorerColumnTypeRT = rt.keyof({
date: null,
number: null,
string: null,
});
export const metricsExplorerColumnRT = rt.type({
name: rt.string,
type: metricsExplorerColumnTypeRT,
});
export const metricsExplorerRowRT = rt.intersection([
rt.type({
timestamp: rt.number,
}),
rt.record(
rt.string,
rt.union([rt.string, rt.number, rt.null, rt.undefined, rt.array(rt.object)])
),
]);
export const metricsExplorerSeriesRT = rt.intersection([
rt.type({
id: rt.string,
columns: rt.array(metricsExplorerColumnRT),
rows: rt.array(metricsExplorerRowRT),
}),
rt.partial({
keys: rt.array(rt.string),
}),
]);
export const metricsExplorerResponseRT = rt.type({
series: rt.array(metricsExplorerSeriesRT),
pageInfo: metricsExplorerPageInfoRT,
});
export type AfterKey = rt.TypeOf<typeof afterKeyObjectRT>;
export type MetricsExplorerAggregation = rt.TypeOf<typeof metricsExplorerAggregationRT>;
export type MetricsExplorerColumnType = rt.TypeOf<typeof metricsExplorerColumnTypeRT>;
export type MetricsExplorerMetric = rt.TypeOf<typeof metricsExplorerMetricRT>;
export type MetricsExplorerPageInfo = rt.TypeOf<typeof metricsExplorerPageInfoRT>;
export type MetricsExplorerColumn = rt.TypeOf<typeof metricsExplorerColumnRT>;
export type MetricsExplorerRow = rt.TypeOf<typeof metricsExplorerRowRT>;
export type MetricsExplorerSeries = rt.TypeOf<typeof metricsExplorerSeriesRT>;
export type MetricsExplorerRequestBody = rt.TypeOf<typeof metricsExplorerRequestBodyRT>;
export type MetricsExplorerResponse = rt.TypeOf<typeof metricsExplorerResponseRT>;

View file

@ -6,22 +6,9 @@
*/
import * as rt from 'io-ts';
import { ML_ANOMALY_THRESHOLD } from '@kbn/ml-anomaly-utils/anomaly_threshold';
import { values } from 'lodash';
import { SerializedSearchSourceFields } from '@kbn/data-plugin/common';
import { Color } from './color_palette';
import { metricsExplorerMetricRT } from './metrics_explorer';
import { TimeUnitChar } from '../utils/formatters/duration';
import { SNAPSHOT_CUSTOM_AGGREGATIONS } from './constants';
type DeepPartialArray<T> = Array<DeepPartial<T>>;
type DeepPartialObject<T> = { [P in keyof T]+?: DeepPartial<T[P]> };
export type DeepPartial<T> = T extends any[]
? DeepPartialArray<T[number]>
: T extends object
? DeepPartialObject<T>
: T;
import { CUSTOM_AGGREGATOR } from './constants';
export const ThresholdFormatterTypeRT = rt.keyof({
abbreviatedNumber: null,
@ -33,97 +20,6 @@ export const ThresholdFormatterTypeRT = rt.keyof({
});
export type ThresholdFormatterType = rt.TypeOf<typeof ThresholdFormatterTypeRT>;
const pointRT = rt.type({
timestamp: rt.number,
value: rt.number,
});
export type Point = rt.TypeOf<typeof pointRT>;
const serieRT = rt.type({
id: rt.string,
points: rt.array(pointRT),
});
const seriesRT = rt.array(serieRT);
export type Series = rt.TypeOf<typeof seriesRT>;
export const getLogAlertsChartPreviewDataSuccessResponsePayloadRT = rt.type({
data: rt.type({
series: seriesRT,
}),
});
export type GetLogAlertsChartPreviewDataSuccessResponsePayload = rt.TypeOf<
typeof getLogAlertsChartPreviewDataSuccessResponsePayloadRT
>;
/**
* Properties specific to the Metrics Source Configuration.
*/
export const SourceConfigurationTimestampColumnRuntimeType = rt.type({
timestampColumn: rt.type({
id: rt.string,
}),
});
export const SourceConfigurationMessageColumnRuntimeType = rt.type({
messageColumn: rt.type({
id: rt.string,
}),
});
export const SourceConfigurationFieldColumnRuntimeType = rt.type({
fieldColumn: rt.type({
id: rt.string,
field: rt.string,
}),
});
export const SourceConfigurationColumnRuntimeType = rt.union([
SourceConfigurationTimestampColumnRuntimeType,
SourceConfigurationMessageColumnRuntimeType,
SourceConfigurationFieldColumnRuntimeType,
]);
// Kibana data views
export const logDataViewReferenceRT = rt.type({
type: rt.literal('data_view'),
dataViewId: rt.string,
});
// Index name
export const logIndexNameReferenceRT = rt.type({
type: rt.literal('index_name'),
indexName: rt.string,
});
export const logIndexReferenceRT = rt.union([logDataViewReferenceRT, logIndexNameReferenceRT]);
/**
* Source status
*/
const SourceStatusFieldRuntimeType = rt.type({
name: rt.string,
type: rt.string,
searchable: rt.boolean,
aggregatable: rt.boolean,
displayable: rt.boolean,
});
export const SourceStatusRuntimeType = rt.type({
logIndicesExist: rt.boolean,
metricIndicesExist: rt.boolean,
remoteClustersExist: rt.boolean,
indexFields: rt.array(SourceStatusFieldRuntimeType),
});
export const metricsSourceStatusRT = rt.strict({
metricIndicesExist: SourceStatusRuntimeType.props.metricIndicesExist,
remoteClustersExist: SourceStatusRuntimeType.props.metricIndicesExist,
indexFields: SourceStatusRuntimeType.props.indexFields,
});
export type MetricsSourceStatus = rt.TypeOf<typeof metricsSourceStatusRT>;
export enum Comparator {
GT = '>',
LT = '<',
@ -139,23 +35,10 @@ export enum Aggregators {
SUM = 'sum',
MIN = 'min',
MAX = 'max',
RATE = 'rate',
CARDINALITY = 'cardinality',
P95 = 'p95',
P99 = 'p99',
CUSTOM = 'custom',
}
const metricsExplorerOptionsMetricRT = rt.intersection([
metricsExplorerMetricRT,
rt.partial({
rate: rt.boolean,
color: rt.keyof(Object.fromEntries(values(Color).map((c) => [c, null])) as Record<Color, null>),
label: rt.string,
}),
]);
export type MetricsExplorerOptionsMetric = rt.TypeOf<typeof metricsExplorerOptionsMetricRT>;
export const aggType = fromEnum('Aggregators', Aggregators);
export type AggType = rt.TypeOf<typeof aggType>;
export enum MetricsExplorerChartType {
line = 'line',
@ -163,11 +46,6 @@ export enum MetricsExplorerChartType {
bar = 'bar',
}
export enum InfraRuleType {
MetricThreshold = 'metrics.alert.threshold',
InventoryThreshold = 'metrics.alert.inventory.threshold',
}
export enum AlertStates {
OK,
ALERT,
@ -176,27 +54,6 @@ export enum AlertStates {
ERROR,
}
const metricAnomalyNodeTypeRT = rt.union([rt.literal('hosts'), rt.literal('k8s')]);
const metricAnomalyMetricRT = rt.union([
rt.literal('memory_usage'),
rt.literal('network_in'),
rt.literal('network_out'),
]);
const metricAnomalyInfluencerFilterRT = rt.type({
fieldName: rt.string,
fieldValue: rt.string,
});
export interface MetricAnomalyParams {
nodeType: rt.TypeOf<typeof metricAnomalyNodeTypeRT>;
metric: rt.TypeOf<typeof metricAnomalyMetricRT>;
alertInterval?: string;
sourceId?: string;
spaceId?: string;
threshold: Exclude<ML_ANOMALY_THRESHOLD, ML_ANOMALY_THRESHOLD.LOW>;
influencerFilter: rt.TypeOf<typeof metricAnomalyInfluencerFilterRT> | undefined;
}
// Types for the executor
export interface ThresholdParams {
@ -209,7 +66,7 @@ export interface ThresholdParams {
groupBy?: string[];
}
interface BaseMetricExpressionParams {
export interface BaseMetricExpressionParams {
timeSize: number;
timeUnit: TimeUnitChar;
sourceId?: string;
@ -219,38 +76,21 @@ interface BaseMetricExpressionParams {
warningThreshold?: number[];
}
export interface NonCountMetricExpressionParams extends BaseMetricExpressionParams {
aggType: Exclude<Aggregators, [Aggregators.COUNT, Aggregators.CUSTOM]>;
metric: string;
}
export interface CountMetricExpressionParams extends BaseMetricExpressionParams {
aggType: Aggregators.COUNT;
}
export type CustomMetricAggTypes = Exclude<
Aggregators,
Aggregators.CUSTOM | Aggregators.RATE | Aggregators.P95 | Aggregators.P99
>;
export interface CustomThresholdExpressionMetric {
name: string;
aggType: CustomMetricAggTypes;
aggType: AggType;
field?: string;
filter?: string;
}
export interface CustomMetricExpressionParams extends BaseMetricExpressionParams {
aggType: Aggregators.CUSTOM;
aggType: typeof CUSTOM_AGGREGATOR;
metrics: CustomThresholdExpressionMetric[];
equation?: string;
label?: string;
}
export type MetricExpressionParams =
| NonCountMetricExpressionParams
| CountMetricExpressionParams
| CustomMetricExpressionParams;
export type MetricExpressionParams = CustomMetricExpressionParams;
export const QUERY_INVALID: unique symbol = Symbol('QUERY_INVALID');
@ -269,22 +109,22 @@ export enum InfraFormatterType {
percent = 'percent',
}
export type SnapshotCustomAggregation = typeof SNAPSHOT_CUSTOM_AGGREGATIONS[number];
const snapshotCustomAggregationKeys = SNAPSHOT_CUSTOM_AGGREGATIONS.reduce<
Record<SnapshotCustomAggregation, null>
>((acc, agg) => ({ ...acc, [agg]: null }), {} as Record<SnapshotCustomAggregation, null>);
/*
* Utils
*
* This utility function can be used to turn a TypeScript enum into a io-ts codec.
*/
export function fromEnum<EnumType extends string>(
enumName: string,
theEnum: Record<string, EnumType>
): rt.Type<EnumType, EnumType, unknown> {
const isEnumValue = (input: unknown): input is EnumType =>
Object.values<unknown>(theEnum).includes(input);
export const SnapshotCustomAggregationRT = rt.keyof(snapshotCustomAggregationKeys);
export const SnapshotCustomMetricInputRT = rt.intersection([
rt.type({
type: rt.literal('custom'),
field: rt.string,
aggregation: SnapshotCustomAggregationRT,
id: rt.string,
}),
rt.partial({
label: rt.string,
}),
]);
export type SnapshotCustomMetricInput = rt.TypeOf<typeof SnapshotCustomMetricInputRT>;
return new rt.Type<EnumType>(
enumName,
isEnumValue,
(input, context) => (isEnumValue(input) ? rt.success(input) : rt.failure(input, context)),
rt.identity
);
}

View file

@ -22,8 +22,14 @@ Array [
"title": "unknown-index",
},
"expression": Object {
"aggType": "count",
"aggType": "custom",
"comparator": ">",
"metrics": Array [
Object {
"aggType": "count",
"name": "A",
},
],
"threshold": Array [
2000,
],

View file

@ -13,9 +13,9 @@ import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { render } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
buildMetricThresholdAlert,
buildMetricThresholdRule,
} from '../mocks/metric_threshold_rule';
buildCustomThresholdAlert,
buildCustomThresholdRule,
} from '../mocks/custom_threshold_rule';
import AlertDetailsAppSection from './alert_details_app_section';
import { ExpressionChart } from './expression_chart';
@ -59,8 +59,8 @@ describe('AlertDetailsAppSection', () => {
<IntlProvider locale="en">
<QueryClientProvider client={queryClient}>
<AlertDetailsAppSection
alert={buildMetricThresholdAlert()}
rule={buildMetricThresholdRule()}
alert={buildCustomThresholdAlert()}
rule={buildCustomThresholdRule()}
ruleLink={ruleLink}
setAlertSummaryFields={mockedSetAlertSummaryFields}
/>

View file

@ -28,28 +28,27 @@ import {
AlertActiveTimeRangeAnnotation,
} from '@kbn/observability-alert-details';
import { DataView } from '@kbn/data-views-plugin/common';
import { MetricsExplorerChartType } from '../../../../common/custom_threshold_rule/types';
import { useKibana } from '../../../utils/kibana_react';
import { metricValueFormatter } from '../../../../common/custom_threshold_rule/metric_value_formatter';
import { AlertSummaryField, TopAlert } from '../../..';
import { generateUniqueKey } from '../lib/generate_unique_key';
import { ExpressionChart } from './expression_chart';
import { TIME_LABELS } from './criterion_preview_chart/criterion_preview_chart';
import { Threshold } from './custom_threshold';
import { MetricsExplorerChartType } from '../hooks/use_metrics_explorer_options';
import { AlertParams, MetricThresholdRuleTypeParams } from '../types';
import { AlertParams, CustomThresholdRuleTypeParams } from '../types';
// TODO Use a generic props for app sections https://github.com/elastic/kibana/issues/152690
export type MetricThresholdRule = Rule<MetricThresholdRuleTypeParams>;
export type MetricThresholdAlert = TopAlert;
export type CustomThresholdRule = Rule<CustomThresholdRuleTypeParams>;
export type CustomThresholdAlert = TopAlert;
const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD HH:mm';
const ALERT_START_ANNOTATION_ID = 'alert_start_annotation';
const ALERT_TIME_RANGE_ANNOTATION_ID = 'alert_time_range_annotation';
interface AppSectionProps {
alert: MetricThresholdAlert;
rule: MetricThresholdRule;
alert: CustomThresholdAlert;
rule: CustomThresholdRule;
ruleLink: string;
setAlertSummaryFields: React.Dispatch<React.SetStateAction<AlertSummaryField[] | undefined>>;
}
@ -132,13 +131,10 @@ export default function AlertDetailsAppSection({
const overview = !!ruleParams.criteria ? (
<EuiFlexGroup direction="column" data-test-subj="thresholdAlertOverviewSection">
{ruleParams.criteria.map((criterion, index) => (
<EuiFlexItem key={generateUniqueKey(criterion)}>
<EuiFlexItem key={`criterion-${index}`}>
<EuiPanel hasBorder hasShadow={false}>
<EuiTitle size="xs">
<h4>
{criterion.aggType.toUpperCase()}{' '}
{'metric' in criterion ? criterion.metric : undefined}
</h4>
<h4>{criterion.aggType.toUpperCase()} </h4>
</EuiTitle>
<EuiText size="s" color="subdued">
<FormattedMessage
@ -155,11 +151,14 @@ export default function AlertDetailsAppSection({
<EuiFlexItem style={{ minHeight: 150, minWidth: 160 }} grow={1}>
<Threshold
chartProps={chartProps}
id={`threshold-${generateUniqueKey(criterion)}`}
threshold={criterion.threshold[0]}
id={`threshold-${index}`}
threshold={criterion.threshold}
value={alert.fields[ALERT_EVALUATION_VALUES]![index]}
valueFormatter={(d) =>
metricValueFormatter(d, 'metric' in criterion ? criterion.metric : undefined)
metricValueFormatter(
d,
criterion.metrics[0] ? criterion.metrics[0].name : undefined
)
}
title={i18n.translate(
'xpack.observability.customThreshold.rule.alertDetailsAppSection.thresholdTitle',

View file

@ -8,17 +8,11 @@
import React, { useCallback, useContext, useMemo } from 'react';
import { OBSERVABILITY_THRESHOLD_RULE_TYPE_ID } from '@kbn/rule-data-utils';
import { MetricsExplorerSeries } from '../../../../common/custom_threshold_rule/metrics_explorer';
import { TriggerActionsContext } from './triggers_actions_context';
import { useAlertPrefillContext } from '../helpers/use_alert_prefill';
import { MetricsExplorerOptions } from '../hooks/use_metrics_explorer_options';
import { observabilityRuleCreationValidConsumers } from '../../../../common/constants';
interface Props {
visible?: boolean;
options?: Partial<MetricsExplorerOptions>;
series?: MetricsExplorerSeries;
setVisible: React.Dispatch<React.SetStateAction<boolean>>;
}
@ -34,14 +28,9 @@ export function AlertFlyout(props: Props) {
onClose: onCloseFlyout,
canChangeTrigger: false,
ruleTypeId: OBSERVABILITY_THRESHOLD_RULE_TYPE_ID,
metadata: {
currentOptions: props.options,
series: props.series,
},
validConsumers: observabilityRuleCreationValidConsumers,
useRuleProducer: true,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[triggersActionsUI, onCloseFlyout]
);
@ -49,8 +38,5 @@ export function AlertFlyout(props: Props) {
}
export function PrefilledThresholdAlertFlyout({ onClose }: { onClose(): void }) {
const { metricThresholdPrefill } = useAlertPrefillContext();
const { groupBy, filterQuery, metrics } = metricThresholdPrefill;
return <AlertFlyout options={{ groupBy, filterQuery, metrics }} visible setVisible={onClose} />;
return <AlertFlyout visible setVisible={onClose} />;
}

View file

@ -12,10 +12,17 @@ import { i18n } from '@kbn/i18n';
import { EuiLoadingChart, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { sum, min as getMin, max as getMax } from 'lodash';
import { GetLogAlertsChartPreviewDataSuccessResponsePayload } from '../../../../../common/custom_threshold_rule/types';
import { formatNumber } from '../../../../../common/custom_threshold_rule/formatters/number';
type Series = GetLogAlertsChartPreviewDataSuccessResponsePayload['data']['series'];
interface Point {
timestamp: number;
value: number;
}
type Series = Array<{
id: string;
points: Point[];
}>;
export const NUM_BUCKETS = 20;

View file

@ -12,14 +12,15 @@ import { decorateWithGlobalStorybookThemeProviders } from '../../../../test_util
import {
Aggregators,
Comparator,
MetricExpressionParams,
CustomMetricExpressionParams,
} from '../../../../../common/custom_threshold_rule/types';
import { CUSTOM_AGGREGATOR } from '../../../../../common/custom_threshold_rule/constants';
import { TimeUnitChar } from '../../../../../common';
import { CustomEquationEditor, CustomEquationEditorProps } from './custom_equation_editor';
import { aggregationType } from '../expression_row';
import { MetricExpression } from '../../types';
import { validateMetricThreshold } from '../validation';
import { validateCustomThreshold } from '../validation';
export default {
title: 'app/Alerts/CustomEquationEditor',
@ -67,8 +68,8 @@ const CustomEquationEditorTemplate: Story<CustomEquationEditorProps> = (args) =>
);
useEffect(() => {
const validationObject = validateMetricThreshold({
criteria: [expression as MetricExpressionParams],
const validationObject = validateCustomThreshold({
criteria: [expression as CustomMetricExpressionParams],
searchConfiguration: {},
});
setErrors(validationObject.errors[0]);
@ -89,9 +90,15 @@ export const CustomEquationEditorDefault = CustomEquationEditorTemplate.bind({})
export const CustomEquationEditorWithEquationErrors = CustomEquationEditorTemplate.bind({});
export const CustomEquationEditorWithFieldError = CustomEquationEditorTemplate.bind({});
const BASE_ARGS = {
const BASE_ARGS: Partial<CustomEquationEditorProps> = {
expression: {
aggType: Aggregators.CUSTOM,
aggType: CUSTOM_AGGREGATOR,
metrics: [
{
name: 'A',
aggType: Aggregators.COUNT,
},
],
timeSize: 1,
timeUnit: 'm' as TimeUnitChar,
threshold: [1],
@ -113,12 +120,16 @@ CustomEquationEditorDefault.args = {
CustomEquationEditorWithEquationErrors.args = {
...BASE_ARGS,
expression: {
...BASE_ARGS.expression,
aggType: CUSTOM_AGGREGATOR,
equation: 'Math.round(A / B)',
metrics: [
{ name: 'A', aggType: Aggregators.AVERAGE, field: 'system.cpu.user.pct' },
{ name: 'B', aggType: Aggregators.MAX, field: 'system.cpu.cores' },
],
timeSize: 1,
timeUnit: 'm' as TimeUnitChar,
threshold: [1],
comparator: Comparator.GT,
},
errors: {
equation:

View file

@ -4,6 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiFieldText,
EuiFormRow,
@ -16,15 +17,13 @@ import {
EuiPopover,
} from '@elastic/eui';
import React, { useState, useCallback, useMemo } from 'react';
import { omit, range, first, xor, debounce } from 'lodash';
import { range, first, xor, debounce } from 'lodash';
import { IErrorObject } from '@kbn/triggers-actions-ui-plugin/public';
import { FormattedMessage } from '@kbn/i18n-react';
import { DataViewBase } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { OMITTED_AGGREGATIONS_FOR_CUSTOM_METRICS } from '../../../../../common/custom_threshold_rule/metrics_explorer';
import {
Aggregators,
CustomMetricAggTypes,
CustomThresholdExpressionMetric,
} from '../../../../../common/custom_threshold_rule/types';
@ -45,7 +44,7 @@ export interface CustomEquationEditorProps {
const NEW_METRIC = {
name: 'A',
aggType: Aggregators.COUNT as CustomMetricAggTypes,
aggType: Aggregators.COUNT as Aggregators,
};
const MAX_VARIABLES = 26;
const CHAR_CODE_FOR_A = 65;
@ -111,14 +110,12 @@ export function CustomEquationEditor({
const disableAdd = customMetrics?.length === MAX_VARIABLES;
const disableDelete = customMetrics?.length === 1;
const filteredAggregationTypes = omit(aggregationTypes, OMITTED_AGGREGATIONS_FOR_CUSTOM_METRICS);
const metricRows = customMetrics?.map((row) => (
<MetricRowWithAgg
key={row.name}
name={row.name}
aggType={row.aggType}
aggregationTypes={filteredAggregationTypes}
aggregationTypes={aggregationTypes}
field={row.field}
filter={row.filter}
fields={fields}
@ -161,6 +158,7 @@ export function CustomEquationEditor({
<EuiPopover
button={
<EuiFormRow
data-test-subj="equationAndThreshold"
fullWidth
label={i18n.translate(
'xpack.observability.customThreshold.rule.alertFlyout.customEquationEditor.equationAndThreshold',

View file

@ -6,32 +6,29 @@
*/
import {
EuiFormRow,
EuiFlexItem,
EuiFlexGroup,
EuiSelect,
EuiComboBox,
EuiComboBoxOptionOption,
EuiPopover,
EuiExpression,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiPopover,
EuiSelect,
} from '@elastic/eui';
import React, { useMemo, useCallback, useState } from 'react';
import { get } from 'lodash';
import { i18n } from '@kbn/i18n';
import { ValidNormalizedTypes } from '@kbn/triggers-actions-ui-plugin/public';
import { DataViewBase } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
Aggregators,
CustomMetricAggTypes,
} from '../../../../../common/custom_threshold_rule/types';
import { MetricRowControls } from './metric_row_controls';
import { NormalizedFields, MetricRowBaseProps } from './types';
import { ClosablePopoverTitle } from '../closable_popover_title';
import { ValidNormalizedTypes } from '@kbn/triggers-actions-ui-plugin/public';
import { get } from 'lodash';
import React, { useCallback, useMemo, useState } from 'react';
import { Aggregators } from '../../../../../common/custom_threshold_rule/types';
import { RuleFlyoutKueryBar } from '../../../rule_kql_filter/kuery_bar';
import { ClosablePopoverTitle } from '../closable_popover_title';
import { MetricRowControls } from './metric_row_controls';
import { MetricRowBaseProps, NormalizedFields } from './types';
interface MetricRowWithAggProps extends MetricRowBaseProps {
aggType?: CustomMetricAggTypes;
aggType?: Aggregators;
field?: string;
dataView: DataViewBase;
filter?: string;
@ -93,8 +90,8 @@ export function MetricRowWithAgg({
(customAggType: string) => {
onChange({
name,
field,
aggType: customAggType as CustomMetricAggTypes,
field: customAggType === Aggregators.COUNT ? undefined : field,
aggType: customAggType as Aggregators,
});
},
[name, field, onChange]

View file

@ -34,7 +34,7 @@ const defaultProps: Props = {
chartProps: { theme: EUI_CHARTS_THEME_LIGHT.theme, baseTheme: LIGHT_THEME },
comparator: Comparator.GT,
id: 'componentId',
threshold: 90,
threshold: [90],
title: 'Threshold breached',
value: 93,
valueFormatter: (d) => `${d}%`,

View file

@ -19,7 +19,7 @@ describe('Threshold', () => {
chartProps: { theme: EUI_CHARTS_THEME_LIGHT.theme, baseTheme: LIGHT_THEME },
comparator: Comparator.GT,
id: 'componentId',
threshold: 90,
threshold: [90],
title: 'Threshold breached',
value: 93,
valueFormatter: (d) => `${d}%`,
@ -41,4 +41,12 @@ describe('Threshold', () => {
const component = renderComponent();
expect(component.queryByTestId('thresholdRule-90-93')).toBeTruthy();
});
it('shows component for between', () => {
const component = renderComponent({
comparator: Comparator.BETWEEN,
threshold: [90, 95],
});
expect(component.queryByTestId('thresholdRule-90-95-93')).toBeTruthy();
});
});

View file

@ -21,7 +21,7 @@ export interface Props {
chartProps: ChartProps;
comparator: Comparator | string;
id: string;
threshold: number;
threshold: number[];
title: string;
value: number;
valueFormatter: (d: number) => string;
@ -48,7 +48,7 @@ export function Threshold({
minWidth: '100%',
}}
hasShadow={false}
data-test-subj={`thresholdRule-${threshold}-${value}`}
data-test-subj={`thresholdRule-${threshold.join('-')}-${value}`}
>
<Chart>
<Settings theme={theme} baseTheme={baseTheme} locale={i18n.getLocale()} />
@ -63,7 +63,10 @@ export function Threshold({
{i18n.translate(
'xpack.observability.customThreshold.rule.thresholdExtraTitle',
{
values: { comparator, threshold: valueFormatter(threshold) },
values: {
comparator,
threshold: threshold.map((t) => valueFormatter(t)).join(' - '),
},
defaultMessage: `Alert when {comparator} {threshold}`,
}
)}

View file

@ -5,16 +5,17 @@
* 2.0.
*/
import React, { ReactElement } from 'react';
import { act } from 'react-dom/test-utils';
import { LineAnnotation, RectAnnotation } from '@elastic/charts';
import { DataViewBase } from '@kbn/es-query';
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
// We are using this inside a `jest.mock` call. Jest requires dynamic dependencies to be prefixed with `mock`
import { coreMock as mockCoreMock } from '@kbn/core/public/mocks';
import { DataViewBase } from '@kbn/es-query';
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import React, { ReactElement } from 'react';
import { act } from 'react-dom/test-utils';
import { CUSTOM_AGGREGATOR } from '../../../../common/custom_threshold_rule/constants';
import { Aggregators, Comparator } from '../../../../common/custom_threshold_rule/types';
import { MetricExpression } from '../types';
import { ExpressionChart } from './expression_chart';
import { Aggregators, Comparator } from '../../../../common/custom_threshold_rule/types';
const mockStartServices = mockCoreMock.createStart();
@ -37,8 +38,8 @@ const mockResponse = {
series: [{ id: 'Everything', rows: [], columns: [] }],
};
jest.mock('../hooks/use_metrics_explorer_chart_data', () => ({
useMetricsExplorerChartData: () => ({ loading: false, data: { pages: [mockResponse] } }),
jest.mock('../hooks/use_expression_chart_data', () => ({
useExpressionChartData: () => ({ loading: false, data: { pages: [mockResponse] } }),
}));
describe('ExpressionChart', () => {
@ -76,7 +77,13 @@ describe('ExpressionChart', () => {
it('should display no data message', async () => {
const expression: MetricExpression = {
aggType: Aggregators.AVERAGE,
aggType: CUSTOM_AGGREGATOR,
metrics: [
{
name: 'A',
aggType: Aggregators.COUNT,
},
],
timeSize: 1,
timeUnit: 'm',
sourceId: 'default',

View file

@ -25,18 +25,11 @@ import { first, last } from 'lodash';
import moment from 'moment';
import { i18n } from '@kbn/i18n';
import { useKibana } from '../../../utils/kibana_react';
import {
MetricsExplorerAggregation,
MetricsExplorerRow,
} from '../../../../common/custom_threshold_rule/metrics_explorer';
import { Color } from '../../../../common/custom_threshold_rule/color_palette';
import {
MetricsExplorerChartType,
MetricsExplorerOptionsMetric,
} from '../../../../common/custom_threshold_rule/types';
import { MetricsExplorerChartType } from '../../../../common/custom_threshold_rule/types';
import { MetricExpression, TimeRange } from '../types';
import { createFormatterForMetric } from '../helpers/create_formatter_for_metric';
import { useMetricsExplorerChartData } from '../hooks/use_metrics_explorer_chart_data';
import { useExpressionChartData } from '../hooks/use_expression_chart_data';
import {
ChartContainer,
LoadingState,
@ -47,8 +40,8 @@ import {
import { ThresholdAnnotations } from './criterion_preview_chart/threshold_annotations';
import { CUSTOM_EQUATION } from '../i18n_strings';
import { calculateDomain } from '../helpers/calculate_domain';
import { getMetricId } from '../helpers/get_metric_id';
import { MetricExplorerSeriesChart } from './series_chart';
import { MetricsExplorerRow } from '../types';
interface Props {
expression: MetricExpression;
@ -74,7 +67,7 @@ export function ExpressionChart({
timeFieldName,
}: Props) {
const { charts, uiSettings } = useKibana().services;
const { isLoading, data } = useMetricsExplorerChartData(
const { isLoading, data } = useExpressionChartData(
expression,
derivedIndexPattern,
filterQuery,
@ -106,15 +99,7 @@ export function ExpressionChart({
const firstTimestamp = first(firstSeries.rows)!.timestamp;
const lastTimestamp = last(firstSeries.rows)!.timestamp;
const metric: MetricsExplorerOptionsMetric = {
field: expression.metric,
aggregation: expression.aggType as MetricsExplorerAggregation,
color: Color.color0,
};
if (metric.aggregation === 'custom') {
metric.label = expression.label || CUSTOM_EQUATION;
}
const name = expression.label || CUSTOM_EQUATION;
const dateFormatter =
firstTimestamp == null || lastTimestamp == null
@ -130,13 +115,13 @@ export function ExpressionChart({
rows: firstSeries.rows.map((row) => {
const newRow: MetricsExplorerRow = { ...row };
thresholds.forEach((thresholdValue, index) => {
newRow[getMetricId(metric, `threshold_${index}`)] = thresholdValue;
newRow[`metric_threshold_${index}`] = thresholdValue;
});
return newRow;
}),
};
const dataDomain = calculateDomain(series, [metric], false);
const dataDomain = calculateDomain(series);
const domain = {
max: Math.max(dataDomain.max, last(thresholds) || dataDomain.max) * 1.1,
min: Math.min(dataDomain.min, first(thresholds) || dataDomain.min) * 0.9, // add 10% floor,
@ -155,7 +140,8 @@ export function ExpressionChart({
<Chart ref={chartRef}>
<MetricExplorerSeriesChart
type={chartType}
metric={metric}
name={name}
color={Color.color0}
id="0"
series={series}
stack={false}
@ -192,7 +178,7 @@ export function ExpressionChart({
<Axis
id={'values'}
position={Position.Left}
tickFormat={createFormatterForMetric(metric)}
tickFormat={createFormatterForMetric(expression.metrics)}
domain={domain}
/>
<Tooltip

View file

@ -5,11 +5,12 @@
* 2.0.
*/
import { Comparator } from '../../../../common/custom_threshold_rule/types';
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { CUSTOM_AGGREGATOR } from '../../../../common/custom_threshold_rule/constants';
import { Aggregators, Comparator } from '../../../../common/custom_threshold_rule/types';
import { MetricExpression } from '../types';
import { ExpressionRow } from './expression_row';
@ -22,16 +23,10 @@ describe('ExpressionRow', () => {
{
name: 'system.cpu.user.pct',
type: 'test',
searchable: true,
aggregatable: true,
displayable: true,
},
{
name: 'system.load.1',
type: 'test',
searchable: true,
aggregatable: true,
displayable: true,
},
]}
remove={() => {}}
@ -61,33 +56,45 @@ describe('ExpressionRow', () => {
}
it('should display thresholds as a percentage for pct metrics', async () => {
const expression = {
metric: 'system.cpu.user.pct',
const expression: MetricExpression = {
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.GT,
metrics: [
{
name: 'A',
aggType: Aggregators.COUNT,
field: 'system.cpu.user.pct',
},
],
threshold: [0.5],
timeSize: 1,
timeUnit: 'm',
aggType: 'custom',
};
const { wrapper, update } = await setup(expression as MetricExpression);
const { wrapper, update } = await setup(expression);
await update();
const [valueMatch] =
wrapper
.html()
.match(
'<span class="euiExpression__value css-uocz3u-euiExpression__value-columns">50</span>'
'<span class="euiExpression__value css-uocz3u-euiExpression__value-columns">50%</span>'
) ?? [];
expect(valueMatch).toBeTruthy();
});
it('should display thresholds as a decimal for all other metrics', async () => {
const expression = {
metric: 'system.load.1',
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.GT,
metrics: [
{
name: 'A',
aggType: Aggregators.COUNT,
field: 'system.load.1',
},
],
threshold: [0.5],
timeSize: 1,
timeUnit: 'm',
aggType: 'custom',
};
const { wrapper } = await setup(expression as MetricExpression);
const [valueMatch] =

View file

@ -4,6 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiButtonIcon,
EuiFieldText,
@ -11,7 +12,6 @@ import {
EuiFlexItem,
EuiFormRow,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useMemo, useState } from 'react';
@ -22,10 +22,10 @@ import {
IErrorObject,
ThresholdExpression,
} from '@kbn/triggers-actions-ui-plugin/public';
import { DataViewBase } from '@kbn/es-query';
import { DataViewBase, DataViewFieldBase } from '@kbn/es-query';
import { debounce } from 'lodash';
import { Comparator } from '../../../../common/custom_threshold_rule/types';
import { AGGREGATION_TYPES, DerivedIndexPattern, MetricExpression } from '../types';
import { Aggregators, Comparator } from '../../../../common/custom_threshold_rule/types';
import { MetricExpression } from '../types';
import { CustomEquationEditor } from './custom_equation';
import { CUSTOM_EQUATION, LABEL_HELP_MESSAGE, LABEL_LABEL } from '../i18n_strings';
import { decimalToPct, pctToDecimal } from '../helpers/corrected_percent_convert';
@ -42,7 +42,7 @@ const customComparators = {
};
interface ExpressionRowProps {
fields: DerivedIndexPattern['fields'];
fields: DataViewFieldBase[];
expressionId: number;
expression: MetricExpression;
errors: IErrorObject;
@ -74,9 +74,12 @@ export const ExpressionRow: React.FC<ExpressionRowProps> = (props) => {
canDelete,
} = props;
const { metric, comparator = Comparator.GT, threshold = [] } = expression;
const { metrics, comparator = Comparator.GT, threshold = [] } = expression;
const isMetricPct = useMemo(() => Boolean(metric && metric.endsWith('.pct')), [metric]);
const isMetricPct = useMemo(
() => Boolean(metrics.length === 1 && metrics[0].field?.endsWith('.pct')),
[metrics]
);
const [label, setLabel] = useState<string | undefined>(expression?.label || undefined);
const updateComparator = useCallback(
@ -217,17 +220,8 @@ const ThresholdElement: React.FC<{
onChangeSelectedThreshold={updateThreshold}
errors={errors}
display="fullWidth"
unit={isMetricPct ? '%' : ''}
/>
{isMetricPct && (
<div
style={{
alignSelf: 'center',
}}
>
<EuiText size={'s'}>%</EuiText>
</div>
)}
</>
);
};
@ -242,7 +236,7 @@ export const aggregationType: { [key: string]: AggregationType } = {
),
fieldRequired: true,
validNormalizedTypes: ['number', 'histogram'],
value: AGGREGATION_TYPES.AVERAGE,
value: Aggregators.AVERAGE,
},
max: {
text: i18n.translate(
@ -253,7 +247,7 @@ export const aggregationType: { [key: string]: AggregationType } = {
),
fieldRequired: true,
validNormalizedTypes: ['number', 'date', 'histogram'],
value: AGGREGATION_TYPES.MAX,
value: Aggregators.MAX,
},
min: {
text: i18n.translate(
@ -264,7 +258,7 @@ export const aggregationType: { [key: string]: AggregationType } = {
),
fieldRequired: true,
validNormalizedTypes: ['number', 'date', 'histogram'],
value: AGGREGATION_TYPES.MIN,
value: Aggregators.MIN,
},
cardinality: {
text: i18n.translate(
@ -274,20 +268,9 @@ export const aggregationType: { [key: string]: AggregationType } = {
}
),
fieldRequired: false,
value: AGGREGATION_TYPES.CARDINALITY,
value: Aggregators.CARDINALITY,
validNormalizedTypes: ['number', 'string', 'ip', 'date'],
},
rate: {
text: i18n.translate(
'xpack.observability.customThreshold.rule.alertFlyout.aggregationText.rate',
{
defaultMessage: 'Rate',
}
),
fieldRequired: false,
value: AGGREGATION_TYPES.RATE,
validNormalizedTypes: ['number'],
},
count: {
text: i18n.translate(
'xpack.observability.customThreshold.rule.alertFlyout.aggregationText.count',
@ -296,7 +279,7 @@ export const aggregationType: { [key: string]: AggregationType } = {
}
),
fieldRequired: false,
value: AGGREGATION_TYPES.COUNT,
value: Aggregators.COUNT,
validNormalizedTypes: ['number'],
},
sum: {
@ -307,35 +290,7 @@ export const aggregationType: { [key: string]: AggregationType } = {
}
),
fieldRequired: false,
value: AGGREGATION_TYPES.SUM,
validNormalizedTypes: ['number', 'histogram'],
},
p95: {
text: i18n.translate(
'xpack.observability.customThreshold.rule.alertFlyout.aggregationText.p95',
{
defaultMessage: '95th Percentile',
}
),
fieldRequired: false,
value: AGGREGATION_TYPES.P95,
validNormalizedTypes: ['number', 'histogram'],
},
p99: {
text: i18n.translate(
'xpack.observability.customThreshold.rule.alertFlyout.aggregationText.p99',
{
defaultMessage: '99th Percentile',
}
),
fieldRequired: false,
value: AGGREGATION_TYPES.P99,
validNormalizedTypes: ['number', 'histogram'],
},
custom: {
text: CUSTOM_EQUATION,
fieldRequired: false,
value: AGGREGATION_TYPES.CUSTOM,
value: Aggregators.SUM,
validNormalizedTypes: ['number', 'histogram'],
},
};

View file

@ -6,26 +6,26 @@
*/
import { EuiComboBox } from '@elastic/eui';
import { DataViewFieldBase } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import React, { useCallback } from 'react';
import { MetricsExplorerOptions } from '../hooks/use_metrics_explorer_options';
import { DerivedIndexPattern } from '../types';
export type MetricsExplorerFields = Array<DataViewFieldBase & { aggregatable: boolean }>;
export type GroupBy = string | null | string[];
export interface GroupByOptions {
groupBy: GroupBy;
}
interface Props {
options: MetricsExplorerOptions;
onChange: (groupBy: string | null | string[]) => void;
fields: DerivedIndexPattern['fields'];
options: GroupByOptions;
onChange: (groupBy: GroupBy) => void;
fields: MetricsExplorerFields;
errorOptions?: string[];
}
export function MetricsExplorerGroupBy({
options,
onChange,
fields,
errorOptions,
...rest
}: Props) {
export function GroupBy({ options, onChange, fields, errorOptions, ...rest }: Props) {
const handleChange = useCallback(
(selectedOptions: Array<{ label: string }>) => {
const groupBy = selectedOptions.map((option) => option.label);

View file

@ -14,21 +14,17 @@ import {
AreaSeriesStyle,
BarSeriesStyle,
} from '@elastic/charts';
import { MetricsExplorerSeries } from '../../../../common/custom_threshold_rule/metrics_explorer';
import { MetricsExplorerSeries } from '../types';
import { Color, colorTransformer } from '../../../../common/custom_threshold_rule/color_palette';
import {
MetricsExplorerChartType,
MetricsExplorerOptionsMetric,
} from '../../../../common/custom_threshold_rule/types';
import { MetricsExplorerChartType } from '../../../../common/custom_threshold_rule/types';
import { getMetricId } from '../helpers/get_metric_id';
import { useKibanaTimeZoneSetting } from '../hooks/use_kibana_time_zone_setting';
import { createMetricLabel } from '../helpers/create_metric_label';
type NumberOrString = string | number;
interface Props {
metric: MetricsExplorerOptionsMetric;
name: string;
color: Color;
id: NumberOrString | NumberOrString[];
series: MetricsExplorerSeries;
type: MetricsExplorerChartType;
@ -43,17 +39,15 @@ export function MetricExplorerSeriesChart(props: Props) {
return <MetricsExplorerAreaChart {...props} />;
}
export function MetricsExplorerAreaChart({ metric, id, series, type, stack, opacity }: Props) {
export function MetricsExplorerAreaChart({ name, color, id, series, type, stack, opacity }: Props) {
const timezone = useKibanaTimeZoneSetting();
const color = (metric.color && colorTransformer(metric.color)) || colorTransformer(Color.color0);
const seriesColor = (color && colorTransformer(color)) || colorTransformer(Color.color0);
const yAccessors = Array.isArray(id)
? id.map((i) => getMetricId(metric, i)).slice(id.length - 1, id.length)
: [getMetricId(metric, id)];
? id.map((i) => `metric_${i}`).slice(id.length - 1, id.length)
: [`metric_${id}`];
const y0Accessors =
Array.isArray(id) && id.length > 1
? id.map((i) => getMetricId(metric, i)).slice(0, 1)
: undefined;
Array.isArray(id) && id.length > 1 ? id.map((i) => `metric_${i}`).slice(0, 1) : undefined;
const chartId = `series-${series.id}-${yAccessors.join('-')}`;
const seriesAreaStyle: RecursivePartial<AreaSeriesStyle> = {
@ -71,7 +65,7 @@ export function MetricsExplorerAreaChart({ metric, id, series, type, stack, opac
<AreaSeries
id={chartId}
key={chartId}
name={createMetricLabel(metric)}
name={name}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor="timestamp"
@ -80,24 +74,24 @@ export function MetricsExplorerAreaChart({ metric, id, series, type, stack, opac
data={series.rows}
stackAccessors={stack ? ['timestamp'] : void 0}
areaSeriesStyle={seriesAreaStyle}
color={color}
color={seriesColor}
timeZone={timezone}
/>
);
}
export function MetricsExplorerBarChart({ metric, id, series, stack }: Props) {
export function MetricsExplorerBarChart({ name, color, id, series, stack }: Props) {
const timezone = useKibanaTimeZoneSetting();
const color = (metric.color && colorTransformer(metric.color)) || colorTransformer(Color.color0);
const seriesColor = (color && colorTransformer(color)) || colorTransformer(Color.color0);
const yAccessors = Array.isArray(id)
? id.map((i) => getMetricId(metric, i)).slice(id.length - 1, id.length)
: [getMetricId(metric, id)];
? id.map((i) => `metric_${i}`).slice(id.length - 1, id.length)
: [`metric_${id}`];
const chartId = `series-${series.id}-${yAccessors.join('-')}`;
const seriesBarStyle: RecursivePartial<BarSeriesStyle> = {
rectBorder: {
stroke: color,
stroke: seriesColor,
strokeWidth: 1,
visible: true,
},
@ -109,7 +103,7 @@ export function MetricsExplorerBarChart({ metric, id, series, stack }: Props) {
<BarSeries
id={chartId}
key={chartId}
name={createMetricLabel(metric)}
name={name}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor="timestamp"
@ -117,7 +111,7 @@ export function MetricsExplorerBarChart({ metric, id, series, stack }: Props) {
data={series.rows}
stackAccessors={stack ? ['timestamp'] : void 0}
barSeriesStyle={seriesBarStyle}
color={color}
color={seriesColor}
timeZone={timezone}
/>
);

View file

@ -11,25 +11,17 @@ import { i18n } from '@kbn/i18n';
import { ValidationResult } from '@kbn/triggers-actions-ui-plugin/public';
import { isEmpty } from 'lodash';
import {
Aggregators,
Comparator,
CustomMetricExpressionParams,
MetricExpressionParams,
} from '../../../../common/custom_threshold_rule/types';
export const EQUATION_REGEX = /[^A-Z|+|\-|\s|\d+|\.|\(|\)|\/|\*|>|<|=|\?|\:|&|\!|\|]+/g;
const isCustomMetricExpressionParams = (
subject: MetricExpressionParams
): subject is CustomMetricExpressionParams => {
return subject.aggType === Aggregators.CUSTOM;
};
export function validateMetricThreshold({
export function validateCustomThreshold({
criteria,
searchConfiguration,
}: {
criteria: MetricExpressionParams[];
criteria: CustomMetricExpressionParams[];
searchConfiguration: SerializedSearchSourceFields;
}): ValidationResult {
const validationResult = { errors: {} };
@ -46,7 +38,6 @@ export function validateMetricThreshold({
threshold0: string[];
threshold1: string[];
};
metric: string[];
metricsError?: string;
metrics: Record<string, { aggType?: string; field?: string; filter?: string }>;
equation?: string;
@ -187,66 +178,53 @@ export function validateMetricThreshold({
);
}
if (c.aggType !== 'count' && c.aggType !== 'custom' && !c.metric) {
errors[id].metric.push(
i18n.translate(
'xpack.observability.customThreshold.rule.alertFlyout.error.metricRequired',
{
defaultMessage: 'Metric is required.',
}
)
if (!c.metrics || (c.metrics && c.metrics.length < 1)) {
errors[id].metricsError = i18n.translate(
'xpack.observability.customThreshold.rule.alertFlyout.error.metricsError',
{
defaultMessage: 'You must define at least 1 custom metric',
}
);
} else {
c.metrics.forEach((metric) => {
const customMetricErrors: { aggType?: string; field?: string; filter?: string } = {};
if (!metric.aggType) {
customMetricErrors.aggType = i18n.translate(
'xpack.observability.customThreshold.rule.alertFlyout.error.metrics.aggTypeRequired',
{
defaultMessage: 'Aggregation is required',
}
);
}
if (metric.aggType !== 'count' && !metric.field) {
customMetricErrors.field = i18n.translate(
'xpack.observability.customThreshold.rule.alertFlyout.error.metrics.fieldRequired',
{
defaultMessage: 'Field is required',
}
);
}
if (metric.aggType === 'count' && metric.filter) {
try {
fromKueryExpression(metric.filter);
} catch (e) {
customMetricErrors.filter = e.message;
}
}
if (!isEmpty(customMetricErrors)) {
errors[id].metrics[metric.name] = customMetricErrors;
}
});
}
if (isCustomMetricExpressionParams(c)) {
if (!c.metrics || (c.metrics && c.metrics.length < 1)) {
errors[id].metricsError = i18n.translate(
'xpack.observability.customThreshold.rule.alertFlyout.error.metricsError',
{
defaultMessage: 'You must define at least 1 custom metric',
}
);
} else {
c.metrics.forEach((metric) => {
const customMetricErrors: { aggType?: string; field?: string; filter?: string } = {};
if (!metric.aggType) {
customMetricErrors.aggType = i18n.translate(
'xpack.observability.customThreshold.rule.alertFlyout.error.metrics.aggTypeRequired',
{
defaultMessage: 'Aggregation is required',
}
);
}
if (metric.aggType !== 'count' && !metric.field) {
customMetricErrors.field = i18n.translate(
'xpack.observability.customThreshold.rule.alertFlyout.error.metrics.fieldRequired',
{
defaultMessage: 'Field is required',
}
);
}
if (metric.aggType === 'count' && metric.filter) {
try {
fromKueryExpression(metric.filter);
} catch (e) {
customMetricErrors.filter = e.message;
}
}
if (!isEmpty(customMetricErrors)) {
errors[id].metrics[metric.name] = customMetricErrors;
}
});
}
if (c.equation && c.equation.match(EQUATION_REGEX)) {
errors[id].equation = i18n.translate(
'xpack.observability.customThreshold.rule.alertFlyout.error.equation.invalidCharacters',
{
defaultMessage:
'The equation field only supports the following characters: A-Z, +, -, /, *, (, ), ?, !, &, :, |, >, <, =',
}
);
}
if (c.equation && c.equation.match(EQUATION_REGEX)) {
errors[id].equation = i18n.translate(
'xpack.observability.customThreshold.rule.alertFlyout.error.equation.invalidCharacters',
{
defaultMessage:
'The equation field only supports the following characters: A-Z, +, -, /, *, (, ), ?, !, &, :, |, >, <, =',
}
);
}
});

View file

@ -13,7 +13,6 @@ import { queryClient } from '@kbn/osquery-plugin/public/query_client';
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import { Aggregators, Comparator } from '../../../common/custom_threshold_rule/types';
import { MetricsExplorerMetric } from '../../../common/custom_threshold_rule/metrics_explorer';
import { useKibana } from '../../utils/kibana_react';
import { kibanaStartMock } from '../../utils/kibana_react.mock';
import Expressions from './custom_threshold_rule_expression';
@ -36,11 +35,7 @@ describe('Expression', () => {
mockKibana();
});
async function setup(currentOptions: {
metrics?: MetricsExplorerMetric[];
filterQuery?: string;
groupBy?: string;
}) {
async function setup() {
const ruleParams = {
criteria: [],
groupBy: undefined,
@ -64,7 +59,6 @@ describe('Expression', () => {
setRuleParams={(key, value) => Reflect.set(ruleParams, key, value)}
setRuleProperty={() => {}}
metadata={{
currentOptions,
adHocDataViewList: [],
}}
dataViews={dataViewMock}
@ -84,41 +78,8 @@ describe('Expression', () => {
return { wrapper, update, ruleParams };
}
it('should prefill the alert using the context metadata', async () => {
const currentOptions = {
groupBy: 'host.hostname',
filterQuery: 'foo',
metrics: [
{ aggregation: 'avg', field: 'system.load.1' },
{ aggregation: 'cardinality', field: 'system.cpu.user.pct' },
] as MetricsExplorerMetric[],
};
const { ruleParams } = await setup(currentOptions);
expect(ruleParams.groupBy).toBe('host.hostname');
expect(ruleParams.searchConfiguration.query.query).toBe('foo');
expect(ruleParams.criteria).toEqual([
{
metric: 'system.load.1',
comparator: Comparator.GT,
threshold: [],
timeSize: 1,
timeUnit: 'm',
aggType: 'avg',
},
{
metric: 'system.cpu.user.pct',
comparator: Comparator.GT,
threshold: [],
timeSize: 1,
timeUnit: 'm',
aggType: 'cardinality',
},
]);
});
it('should use default metrics', async () => {
const currentOptions = {};
const { ruleParams } = await setup(currentOptions);
const { ruleParams } = await setup();
expect(ruleParams.criteria).toEqual([
{
metrics: [
@ -137,13 +98,6 @@ describe('Expression', () => {
});
it('should show an error message when searchSource throws an error', async () => {
const currentOptions = {
groupBy: 'host.hostname',
metrics: [
{ aggregation: 'avg', field: 'system.load.1' },
{ aggregation: 'cardinality', field: 'system.cpu.user.pct' },
] as MetricsExplorerMetric[],
};
const errorMessage = 'Error in searchSource create';
const kibanaMock = kibanaStartMock.startContract();
useKibanaMock.mockReturnValue({
@ -169,20 +123,13 @@ describe('Expression', () => {
},
},
});
const { wrapper } = await setup(currentOptions);
const { wrapper } = await setup();
expect(wrapper.find(`[data-test-subj="thresholdRuleExpressionError"]`).first().text()).toBe(
errorMessage
);
});
it('should show no timestamp error when selected data view does not have a timeField', async () => {
const currentOptions = {
groupBy: 'host.hostname',
metrics: [
{ aggregation: 'avg', field: 'system.load.1' },
{ aggregation: 'cardinality', field: 'system.cpu.user.pct' },
] as MetricsExplorerMetric[],
};
const mockedIndex = {
id: 'c34a7c79-a88b-4b4a-ad19-72f6d24104e4',
title: 'metrics-fake_hosts',
@ -234,7 +181,7 @@ describe('Expression', () => {
},
},
});
const { wrapper } = await setup(currentOptions);
const { wrapper } = await setup();
expect(
wrapper.find(`[data-test-subj="thresholdRuleDataViewErrorNoTimestamp"]`).first().text()
).toBe(

View file

@ -36,13 +36,13 @@ import {
} from '@kbn/triggers-actions-ui-plugin/public';
import { useKibana } from '../../utils/kibana_react';
import { CUSTOM_AGGREGATOR } from '../../../common/custom_threshold_rule/constants';
import { Aggregators, Comparator } from '../../../common/custom_threshold_rule/types';
import { TimeUnitChar } from '../../../common/utils/formatters/duration';
import { AlertContextMeta, AlertParams, MetricExpression } from './types';
import { ExpressionChart } from './components/expression_chart';
import { ExpressionRow } from './components/expression_row';
import { MetricsExplorerGroupBy } from './components/group_by';
import { MetricsExplorerOptions } from './hooks/use_metrics_explorer_options';
import { MetricsExplorerFields, GroupBy } from './components/group_by';
const FILTER_TYPING_DEBOUNCE_MS = 500;
@ -51,8 +51,8 @@ type Props = Omit<
'defaultActionGroupId' | 'actionGroups' | 'charts' | 'data' | 'unifiedSearch'
>;
export const defaultExpression = {
aggType: Aggregators.CUSTOM,
export const defaultExpression: MetricExpression = {
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.GT,
metrics: [
{
@ -63,7 +63,7 @@ export const defaultExpression = {
threshold: [1000],
timeSize: 1,
timeUnit: 'm',
} as MetricExpression;
};
// eslint-disable-next-line import/no-default-export
export default function Expressions(props: Props) {
@ -152,15 +152,7 @@ export default function Expressions(props: Props) {
setTimeSize(ruleParams.criteria[0].timeSize);
setTimeUnit(ruleParams.criteria[0].timeUnit);
} else {
preFillAlertCriteria();
}
if (!ruleParams.filterQuery) {
preFillAlertFilter();
}
if (!ruleParams.groupBy) {
preFillAlertGroupBy();
setRuleParams('criteria', [defaultExpression]);
}
if (typeof ruleParams.alertOnNoData === 'undefined') {
@ -171,17 +163,6 @@ export default function Expressions(props: Props) {
}
}, [metadata]); // eslint-disable-line react-hooks/exhaustive-deps
const options = useMemo<MetricsExplorerOptions>(() => {
if (metadata?.currentOptions?.metrics) {
return metadata.currentOptions as MetricsExplorerOptions;
} else {
return {
metrics: [],
aggregation: 'count',
};
}
}, [metadata]);
const onSelectDataView = useCallback(
(newDataView: DataView) => {
const ruleCriteria = (ruleParams.criteria ? ruleParams.criteria.slice() : []).map(
@ -282,67 +263,11 @@ export default function Expressions(props: Props) {
[ruleParams.criteria, setRuleParams]
);
const preFillAlertCriteria = useCallback(() => {
const md = metadata;
if (md?.currentOptions?.metrics?.length) {
setRuleParams(
'criteria',
md.currentOptions.metrics.map((metric) => ({
metric: metric.field,
comparator: Comparator.GT,
threshold: [],
timeSize,
timeUnit,
aggType: metric.aggregation,
})) as AlertParams['criteria']
);
} else {
setRuleParams('criteria', [defaultExpression]);
}
}, [metadata, setRuleParams, timeSize, timeUnit]);
const preFillAlertFilter = useCallback(() => {
const md = metadata;
if (md && md.currentOptions?.filterQuery) {
setRuleParams('searchConfiguration', {
...ruleParams.searchConfiguration,
query: {
query: md.currentOptions.filterQuery,
language: 'kuery',
},
});
} else if (md && md.currentOptions?.groupBy && md.series) {
const { groupBy } = md.currentOptions;
const query = Array.isArray(groupBy)
? groupBy.map((field, index) => `${field}: "${md.series?.keys?.[index]}"`).join(' and ')
: `${groupBy}: "${md.series.id}"`;
setRuleParams('searchConfiguration', {
...ruleParams.searchConfiguration,
query: {
query,
language: 'kuery',
},
});
}
}, [metadata, setRuleParams, ruleParams.searchConfiguration]);
const preFillAlertGroupBy = useCallback(() => {
const md = metadata;
if (md && md.currentOptions?.groupBy && !md.series) {
setRuleParams('groupBy', md.currentOptions.groupBy);
}
}, [metadata, setRuleParams]);
const hasGroupBy = useMemo(
() => ruleParams.groupBy && ruleParams.groupBy.length > 0,
[ruleParams.groupBy]
);
const disableNoData = useMemo(
() => ruleParams.criteria?.every((c) => c.aggType === Aggregators.COUNT),
[ruleParams.criteria]
);
// Test to see if any of the group fields in groupBy are already filtered down to a single
// group by the filterQuery. If this is the case, then a groupBy is unnecessary, as it would only
// ever produce one group instance
@ -477,7 +402,7 @@ export default function Expressions(props: Props) {
)}
<ExpressionRow
canDelete={(ruleParams.criteria && ruleParams.criteria.length > 1) || false}
fields={derivedIndexPattern.fields as any}
fields={derivedIndexPattern.fields}
remove={removeExpression}
addExpression={addExpression}
key={idx} // idx's don't usually make good key's but here the index has semantic meaning
@ -543,12 +468,11 @@ export default function Expressions(props: Props) {
fullWidth
display="rowCompressed"
>
<MetricsExplorerGroupBy
<GroupBy
onChange={onGroupByChange}
fields={derivedIndexPattern.fields as any}
fields={derivedIndexPattern.fields as MetricsExplorerFields}
options={{
...options,
groupBy: ruleParams.groupBy || undefined,
groupBy: ruleParams.groupBy || null,
}}
errorOptions={redundantFilterGroupBy}
/>
@ -591,22 +515,19 @@ export default function Expressions(props: Props) {
}
)}{' '}
<EuiToolTip
content={
(disableNoData ? `${docCountNoDataDisabledHelpText} ` : '') +
i18n.translate(
'xpack.observability.customThreshold.rule.alertFlyout.groupDisappearHelpText',
{
defaultMessage:
'Enable this to trigger the action if a previously detected group begins to report no results. This is not recommended for dynamically scaling infrastructures that may rapidly start and stop nodes automatically.',
}
)
}
content={i18n.translate(
'xpack.observability.customThreshold.rule.alertFlyout.groupDisappearHelpText',
{
defaultMessage:
'Enable this to trigger the action if a previously detected group begins to report no results. This is not recommended for dynamically scaling infrastructures that may rapidly start and stop nodes automatically.',
}
)}
>
<EuiIcon type="questionInCircle" color="subdued" />
</EuiToolTip>
</>
}
disabled={disableNoData || !hasGroupBy}
disabled={!hasGroupBy}
checked={Boolean(hasGroupBy && ruleParams.alertOnGroupDisappear)}
onChange={(e) => setRuleParams('alertOnGroupDisappear', e.target.checked)}
/>
@ -614,10 +535,3 @@ export default function Expressions(props: Props) {
</>
);
}
const docCountNoDataDisabledHelpText = i18n.translate(
'xpack.observability.customThreshold.rule.alertFlyout.docCountNoDataDisabledHelpText',
{
defaultMessage: '[This setting is not applicable to the Document Count aggregator.]',
}
);

View file

@ -5,10 +5,8 @@
* 2.0.
*/
import { min, max, sum, isNumber } from 'lodash';
import { MetricsExplorerSeries } from '../../../../common/custom_threshold_rule/metrics_explorer';
import { MetricsExplorerOptionsMetric } from '../../../../common/custom_threshold_rule/types';
import { getMetricId } from './get_metric_id';
import { min, max, isNumber } from 'lodash';
import { MetricsExplorerSeries } from '../types';
const getMin = (values: Array<number | null>) => {
const minValue = min(values);
@ -20,24 +18,7 @@ const getMax = (values: Array<number | null>) => {
return isNumber(maxValue) && Number.isFinite(maxValue) ? maxValue : undefined;
};
export const calculateDomain = (
series: MetricsExplorerSeries,
metrics: MetricsExplorerOptionsMetric[],
stacked = false
): { min: number; max: number } => {
const values = series.rows
.reduce((acc, row) => {
const rowValues = metrics
.map((m, index) => {
return (row[getMetricId(m, index)] as number) || null;
})
.filter((v) => isNumber(v));
const minValue = getMin(rowValues);
// For stacked domains we want to add 10% head room so the charts have
// enough room to draw the 2 pixel line as well.
const maxValue = stacked ? sum(rowValues) * 1.1 : getMax(rowValues);
return acc.concat([minValue || null, maxValue || null]);
}, [] as Array<number | null>)
.filter((v) => isNumber(v));
export const calculateDomain = (series: MetricsExplorerSeries): { min: number; max: number } => {
const values = series.rows.map((row) => row.metric_0 as number | null).filter((v) => isNumber(v));
return { min: getMin(values) || 0, max: getMax(values) || 0 };
};

View file

@ -6,21 +6,15 @@
*/
import numeral from '@elastic/numeral';
import { InfraFormatterType } from '../../../../common/custom_threshold_rule/types';
import { CustomThresholdExpressionMetric } from '../../../../common/custom_threshold_rule/types';
import { createFormatter } from '../../../../common/custom_threshold_rule/formatters';
import { MetricsExplorerMetric } from '../../../../common/custom_threshold_rule/metrics_explorer';
import { metricToFormat } from './metric_to_format';
export const createFormatterForMetric = (metric?: MetricsExplorerMetric) => {
if (metric?.aggregation === 'custom') {
return (input: number) => numeral(input).format('0.[0000]');
}
if (metric && metric.field) {
const format = metricToFormat(metric);
if (format === InfraFormatterType.bits && metric.aggregation === 'rate') {
return createFormatter(InfraFormatterType.bits, '{{value}}/s');
}
export const createFormatterForMetric = (metrics: CustomThresholdExpressionMetric[]) => {
if (metrics.length === 1) {
const format = metricToFormat(metrics[0]);
return createFormatter(format);
}
return createFormatter(InfraFormatterType.number);
return (input: number) => numeral(input).format('0.[0000]');
};

View file

@ -5,38 +5,36 @@
* 2.0.
*/
import { MetricsExplorerMetric } from '../../../../common/custom_threshold_rule/metrics_explorer';
import {
Aggregators,
CustomThresholdExpressionMetric,
} from '../../../../common/custom_threshold_rule/types';
import { createFormatterForMetric } from './create_formatter_for_metric';
describe('createFormatterForMetric()', () => {
it('should just work for count', () => {
const metric: MetricsExplorerMetric = { aggregation: 'count' };
const metric: CustomThresholdExpressionMetric[] = [{ name: 'A', aggType: Aggregators.COUNT }];
const format = createFormatterForMetric(metric);
expect(format(1291929)).toBe('1,291,929');
});
it('should just work for numerics', () => {
const metric: MetricsExplorerMetric = { aggregation: 'avg', field: 'system.load.1' };
const metric: CustomThresholdExpressionMetric[] = [
{ name: 'A', aggType: Aggregators.AVERAGE, field: 'system.load.1' },
];
const format = createFormatterForMetric(metric);
expect(format(1000.2)).toBe('1,000.2');
});
it('should just work for percents', () => {
const metric: MetricsExplorerMetric = { aggregation: 'avg', field: 'system.cpu.total.pct' };
const metric: CustomThresholdExpressionMetric[] = [
{ name: 'A', aggType: Aggregators.AVERAGE, field: 'system.cpu.total.pct' },
];
const format = createFormatterForMetric(metric);
expect(format(0.349)).toBe('34.9%');
});
it('should just work for rates', () => {
const metric: MetricsExplorerMetric = {
aggregation: 'rate',
field: 'host.network.egress.bytes',
};
const format = createFormatterForMetric(metric);
expect(format(103929292)).toBe('831.4 Mbit/s');
});
it('should just work for bytes', () => {
const metric: MetricsExplorerMetric = {
aggregation: 'avg',
field: 'host.network.egress.bytes',
};
const metric: CustomThresholdExpressionMetric[] = [
{ name: 'A', aggType: Aggregators.AVERAGE, field: 'host.network.egress.bytes' },
];
const format = createFormatterForMetric(metric);
expect(format(103929292)).toBe('103.9 MB');
});

View file

@ -1,20 +0,0 @@
/*
* 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 { MetricsExplorerMetric } from '../../../../common/custom_threshold_rule/metrics_explorer';
import { createMetricLabel } from './create_metric_label';
describe('createMetricLabel()', () => {
it('should work with metrics with fields', () => {
const metric: MetricsExplorerMetric = { aggregation: 'avg', field: 'system.load.1' };
expect(createMetricLabel(metric)).toBe('avg(system.load.1)');
});
it('should work with document count', () => {
const metric: MetricsExplorerMetric = { aggregation: 'count' };
expect(createMetricLabel(metric)).toBe('count()');
});
});

View file

@ -1,15 +0,0 @@
/*
* 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 { MetricsExplorerOptionsMetric } from '../hooks/use_metrics_explorer_options';
export const createMetricLabel = (metric: MetricsExplorerOptionsMetric) => {
if (metric.label) {
return metric.label;
}
return `${metric.aggregation}(${metric.field || ''})`;
};

View file

@ -1,12 +0,0 @@
/*
* 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 { MetricsExplorerOptionsMetric } from '../../../../common/custom_threshold_rule/types';
export const getMetricId = (metric: MetricsExplorerOptionsMetric, index: string | number) => {
return `metric_${index}`;
};

View file

@ -6,18 +6,17 @@
*/
import { last } from 'lodash';
import { InfraFormatterType } from '../../../../common/custom_threshold_rule/types';
import { MetricsExplorerMetric } from '../../../../common/custom_threshold_rule/metrics_explorer';
import {
CustomThresholdExpressionMetric,
InfraFormatterType,
} from '../../../../common/custom_threshold_rule/types';
export const metricToFormat = (metric?: MetricsExplorerMetric) => {
export const metricToFormat = (metric?: CustomThresholdExpressionMetric) => {
if (metric && metric.field) {
const suffix = last(metric.field.split(/\./));
if (suffix === 'pct') {
return InfraFormatterType.percent;
}
if (suffix === 'bytes' && metric.aggregation === 'rate') {
return InfraFormatterType.bits;
}
if (suffix === 'bytes') {
return InfraFormatterType.bytes;
}

View file

@ -1,16 +0,0 @@
/*
* 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 createContainer from 'constate';
import { useMetricThresholdAlertPrefill } from '../hooks/use_metric_threshold_alert_prefill';
const useAlertPrefill = () => {
const metricThresholdPrefill = useMetricThresholdAlertPrefill();
return { metricThresholdPrefill };
};
export const [AlertPrefillProvider, useAlertPrefillContext] = createContainer(useAlertPrefill);

View file

@ -6,19 +6,13 @@
*/
import {
ExpressionOptions,
ExpressionTimestampsRT,
MetricsExplorerResponse,
MetricsExplorerSeries,
} from '../../common/custom_threshold_rule/metrics_explorer';
import {
MetricsExplorerChartOptions,
MetricsExplorerChartType,
MetricsExplorerOptions,
MetricsExplorerTimeOptions,
MetricsExplorerTimestampsRT,
MetricsExplorerYAxisMode,
} from '../components/custom_threshold/hooks/use_metrics_explorer_options';
} from '../../types';
export const options: MetricsExplorerOptions = {
export const options: ExpressionOptions = {
limit: 3,
groupBy: 'host.name',
aggregation: 'avg',
@ -41,22 +35,9 @@ export const source = {
},
anomalyThreshold: 20,
};
export const chartOptions: MetricsExplorerChartOptions = {
type: MetricsExplorerChartType.line,
yAxisMode: MetricsExplorerYAxisMode.fromZero,
stack: false,
};
export const derivedIndexPattern = { title: 'metricbeat-*', fields: [] };
export const timeRange: MetricsExplorerTimeOptions = {
from: 'now-1h',
to: 'now',
interval: '>=10s',
};
export const mockedTimestamps: MetricsExplorerTimestampsRT = {
export const mockedTimestamps: ExpressionTimestampsRT = {
fromTimestamp: 1678376367166,
toTimestamp: 1678379973620,
interval: '>=10s',

View file

@ -8,20 +8,24 @@
import DateMath from '@kbn/datemath';
import { DataViewBase } from '@kbn/es-query';
import { useMemo } from 'react';
import { MetricExplorerCustomMetricAggregations } from '../../../../common/custom_threshold_rule/metrics_explorer';
import { CustomThresholdExpressionMetric } from '../../../../common/custom_threshold_rule/types';
import { MetricExpression, TimeRange } from '../types';
import { useMetricsExplorerData } from './use_metrics_explorer_data';
import {
MetricsExplorerOptions,
MetricsExplorerTimestampsRT,
} from './use_metrics_explorer_options';
Aggregators,
AggType,
CustomThresholdExpressionMetric,
} from '../../../../common/custom_threshold_rule/types';
import {
ExpressionOptions,
ExpressionTimestampsRT,
MetricExpression,
MetricsExplorerMetricRT,
TimeRange,
} from '../types';
import { useExpressionData } from './use_expression_data';
const DEFAULT_TIME_RANGE = {};
const DEFAULT_TIMESTAMP = '@timestamp';
export const useMetricsExplorerChartData = (
export const useExpressionChartData = (
expression: MetricExpression,
derivedIndexPattern: DataViewBase,
filterQuery?: string,
@ -31,7 +35,7 @@ export const useMetricsExplorerChartData = (
) => {
const { timeSize, timeUnit } = expression || { timeSize: 1, timeUnit: 'm' };
const options: MetricsExplorerOptions = useMemo(
const options: ExpressionOptions = useMemo(
() => ({
limit: 1,
forceInterval: true,
@ -39,29 +43,28 @@ export const useMetricsExplorerChartData = (
groupBy,
filterQuery,
metrics: [
expression.aggType === 'custom'
? {
aggregation: 'custom',
custom_metrics:
expression?.metrics?.map(mapMetricThresholdMetricToMetricsExplorerMetric) ?? [],
equation: expression.equation,
}
: { field: expression.metric, aggregation: expression.aggType },
{
aggregation: 'custom',
// Infra API expects this field to be custom_metrics
// since the same field is used in the metric threshold rule
custom_metrics:
expression?.metrics?.map(mapCustomThresholdMetricToMetricsExplorerMetric) ?? [],
equation: expression.equation,
},
],
aggregation: expression.aggType || 'avg',
aggregation: expression.aggType || 'custom',
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
expression.aggType,
expression.equation,
expression.metric,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(expression.metrics),
filterQuery,
groupBy,
]
);
const timestamps: MetricsExplorerTimestampsRT = useMemo(() => {
const timestamps: ExpressionTimestampsRT = useMemo(() => {
const from = timeRange.from ?? `now-${(timeSize || 1) * 20}${timeUnit}`;
const to = timeRange.to ?? 'now';
const fromTimestamp = DateMath.parse(from)!.valueOf();
@ -74,23 +77,23 @@ export const useMetricsExplorerChartData = (
};
}, [timeRange.from, timeRange.to, timeSize, timeUnit, timeFieldName]);
return useMetricsExplorerData(options, derivedIndexPattern, timestamps);
return useExpressionData(options, derivedIndexPattern, timestamps);
};
const mapMetricThresholdMetricToMetricsExplorerMetric = (
const mapCustomThresholdMetricToMetricsExplorerMetric = (
metric: CustomThresholdExpressionMetric
) => {
): MetricsExplorerMetricRT => {
if (metric.aggType === 'count') {
return {
name: metric.name,
aggregation: 'count' as MetricExplorerCustomMetricAggregations,
aggregation: Aggregators.COUNT,
filter: metric.filter,
};
}
return {
name: metric.name,
aggregation: metric.aggType as MetricExplorerCustomMetricAggregations,
aggregation: metric.aggType as AggType,
field: metric.field,
};
};

View file

@ -9,22 +9,19 @@ import { DataViewBase } from '@kbn/es-query';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import {
MetricsExplorerResponse,
metricsExplorerResponseRT,
} from '../../../../common/custom_threshold_rule/metrics_explorer';
import {
MetricsExplorerOptions,
MetricsExplorerTimestampsRT,
} from './use_metrics_explorer_options';
import { convertKueryToElasticSearchQuery } from '../helpers/kuery';
import { decodeOrThrow } from '../helpers/runtime_types';
import {
ExpressionOptions,
ExpressionTimestampsRT,
MetricsExplorerResponse,
metricsExplorerResponseRT,
} from '../types';
export function useMetricsExplorerData(
options: MetricsExplorerOptions,
export function useExpressionData(
options: ExpressionOptions,
derivedIndexPattern: DataViewBase,
{ fromTimestamp, toTimestamp, interval, timeFieldName }: MetricsExplorerTimestampsRT,
{ fromTimestamp, toTimestamp, interval, timeFieldName }: ExpressionTimestampsRT,
enabled = true
) {
const { http } = useKibana().services;
@ -51,7 +48,7 @@ export function useMetricsExplorerData(
body: JSON.stringify({
forceInterval: options.forceInterval,
dropLastBucket: options.dropLastBucket != null ? options.dropLastBucket : true,
metrics: options.aggregation === 'count' ? [{ aggregation: 'count' }] : options.metrics,
metrics: options.metrics,
groupBy: options.groupBy,
afterKey,
limit: options.limit,

View file

@ -7,19 +7,27 @@
import { isEqual } from 'lodash';
import { useState } from 'react';
import { MetricsExplorerMetric } from '../../../../common/custom_threshold_rule/metrics_explorer';
import {
Aggregators,
CustomThresholdExpressionMetric,
} from '../../../../common/custom_threshold_rule/types';
export interface MetricThresholdPrefillOptions {
groupBy: string | string[] | undefined;
export interface CustomThresholdPrefillOptions {
groupBy?: string[];
filterQuery: string | undefined;
metrics: MetricsExplorerMetric[];
metrics: CustomThresholdExpressionMetric[];
}
export const useMetricThresholdAlertPrefill = () => {
const [prefillOptionsState, setPrefillOptionsState] = useState<MetricThresholdPrefillOptions>({
export const useCustomThresholdAlertPrefill = () => {
const [prefillOptionsState, setPrefillOptionsState] = useState<CustomThresholdPrefillOptions>({
groupBy: undefined,
filterQuery: undefined,
metrics: [],
metrics: [
{
name: 'A',
aggType: Aggregators.COUNT,
},
],
});
const { groupBy, filterQuery, metrics } = prefillOptionsState;
@ -28,7 +36,7 @@ export const useMetricThresholdAlertPrefill = () => {
groupBy,
filterQuery,
metrics,
setPrefillOptions(newState: MetricThresholdPrefillOptions) {
setPrefillOptions(newState: CustomThresholdPrefillOptions) {
if (!isEqual(newState, prefillOptionsState)) setPrefillOptionsState(newState);
},
};

View file

@ -6,24 +6,19 @@
*/
import React from 'react';
import { DataViewBase } from '@kbn/es-query';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useMetricsExplorerData } from './use_metrics_explorer_data';
import { renderHook } from '@testing-library/react-hooks';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import {
MetricsExplorerOptions,
MetricsExplorerTimestampsRT,
} from './use_metrics_explorer_options';
import { DataViewBase } from '@kbn/es-query';
import { ExpressionOptions, ExpressionTimestampsRT } from '../types';
import { useExpressionData } from './use_expression_data';
import {
createSeries,
derivedIndexPattern,
mockedTimestamps,
options,
resp,
} from '../../../utils/metrics_explorer';
} from './mocks/metrics_explorer';
const mockedFetch = jest.fn();
@ -51,10 +46,10 @@ const renderUseMetricsExplorerDataHook = () => {
};
return renderHook(
(props: {
options: MetricsExplorerOptions;
options: ExpressionOptions;
derivedIndexPattern: DataViewBase;
timestamps: MetricsExplorerTimestampsRT;
}) => useMetricsExplorerData(props.options, props.derivedIndexPattern, props.timestamps),
timestamps: ExpressionTimestampsRT;
}) => useExpressionData(props.options, props.derivedIndexPattern, props.timestamps),
{
initialProps: {
options,
@ -72,7 +67,7 @@ jest.mock('../helpers/kuery', () => {
};
});
describe('useMetricsExplorerData Hook', () => {
describe('useExpressionData Hook', () => {
afterEach(() => {
queryClient.clear();
});

View file

@ -1,127 +0,0 @@
/*
* 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 { renderHook, act } from '@testing-library/react-hooks';
import {
useMetricsExplorerOptions,
MetricsExplorerOptions,
MetricsExplorerTimeOptions,
DEFAULT_OPTIONS,
DEFAULT_TIMERANGE,
} from './use_metrics_explorer_options';
let PREFILL: Record<string, any> = {};
jest.mock('../helpers/use_alert_prefill', () => ({
useAlertPrefillContext: () => ({
metricThresholdPrefill: {
setPrefillOptions(opts: Record<string, any>) {
PREFILL = opts;
},
},
}),
}));
jest.mock('./use_kibana_timefilter_time', () => ({
useKibanaTimefilterTime: (defaults: { from: string; to: string }) => [() => defaults],
useSyncKibanaTimeFilterTime: () => [() => {}],
}));
const renderUseMetricsExplorerOptionsHook = () => renderHook(() => useMetricsExplorerOptions());
interface LocalStore {
[key: string]: string;
}
interface LocalStorage {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
}
const STORE: LocalStore = {};
const localStorageMock: LocalStorage = {
getItem: (key: string) => {
return STORE[key] || null;
},
setItem: (key: string, value: any) => {
STORE[key] = value.toString();
},
};
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
});
describe('useMetricExplorerOptions', () => {
beforeEach(() => {
delete STORE.MetricsExplorerOptions;
delete STORE.MetricsExplorerTimeRange;
PREFILL = {};
});
it('should just work', () => {
const { result } = renderUseMetricsExplorerOptionsHook();
expect(result.current.options).toEqual(DEFAULT_OPTIONS);
expect(result.current.timeRange).toEqual(DEFAULT_TIMERANGE);
expect(result.current.isAutoReloading).toEqual(false);
expect(STORE.MetricsExplorerOptions).toEqual(JSON.stringify(DEFAULT_OPTIONS));
});
it('should change the store when options update', () => {
const { result, rerender } = renderUseMetricsExplorerOptionsHook();
const newOptions: MetricsExplorerOptions = {
...DEFAULT_OPTIONS,
metrics: [{ aggregation: 'count' }],
};
act(() => {
result.current.setOptions(newOptions);
});
rerender();
expect(result.current.options).toEqual(newOptions);
expect(STORE.MetricsExplorerOptions).toEqual(JSON.stringify(newOptions));
});
it('should change the store when timerange update', () => {
const { result, rerender } = renderUseMetricsExplorerOptionsHook();
const newTimeRange: MetricsExplorerTimeOptions = {
...DEFAULT_TIMERANGE,
from: 'now-15m',
};
act(() => {
result.current.setTimeRange(newTimeRange);
});
rerender();
expect(result.current.timeRange).toEqual(newTimeRange);
});
it('should load from store when available', () => {
const newOptions: MetricsExplorerOptions = {
...DEFAULT_OPTIONS,
metrics: [{ aggregation: 'avg', field: 'system.load.1' }],
};
STORE.MetricsExplorerOptions = JSON.stringify(newOptions);
const { result } = renderUseMetricsExplorerOptionsHook();
expect(result.current.options).toEqual(newOptions);
});
it('should sync the options to the threshold alert preview context', () => {
const { result, rerender } = renderUseMetricsExplorerOptionsHook();
const newOptions: MetricsExplorerOptions = {
...DEFAULT_OPTIONS,
metrics: [{ aggregation: 'count' }],
filterQuery: 'foo',
groupBy: 'host.hostname',
};
act(() => {
result.current.setOptions(newOptions);
});
rerender();
expect(PREFILL.metrics).toEqual(newOptions.metrics);
expect(PREFILL.groupBy).toEqual(newOptions.groupBy);
expect(PREFILL.filterQuery).toEqual(newOptions.filterQuery);
});
});

View file

@ -1,236 +0,0 @@
/*
* 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 DateMath from '@kbn/datemath';
import * as t from 'io-ts';
import { values } from 'lodash';
import createContainer from 'constate';
import type { TimeRange } from '@kbn/es-query';
import { useState, useEffect, useMemo, Dispatch, SetStateAction } from 'react';
import { metricsExplorerMetricRT } from '../../../../common/custom_threshold_rule/metrics_explorer';
import { Color } from '../../../../common/custom_threshold_rule/color_palette';
import { useAlertPrefillContext } from '../helpers/use_alert_prefill';
import { useKibanaTimefilterTime, useSyncKibanaTimeFilterTime } from './use_kibana_timefilter_time';
const metricsExplorerOptionsMetricRT = t.intersection([
metricsExplorerMetricRT,
t.partial({
rate: t.boolean,
color: t.keyof(Object.fromEntries(values(Color).map((c) => [c, null])) as Record<Color, null>),
label: t.string,
}),
]);
export type MetricsExplorerOptionsMetric = t.TypeOf<typeof metricsExplorerOptionsMetricRT>;
export enum MetricsExplorerChartType {
line = 'line',
area = 'area',
bar = 'bar',
}
export enum MetricsExplorerYAxisMode {
fromZero = 'fromZero',
auto = 'auto',
}
export const metricsExplorerChartOptionsRT = t.type({
yAxisMode: t.keyof(
Object.fromEntries(values(MetricsExplorerYAxisMode).map((v) => [v, null])) as Record<
MetricsExplorerYAxisMode,
null
>
),
type: t.keyof(
Object.fromEntries(values(MetricsExplorerChartType).map((v) => [v, null])) as Record<
MetricsExplorerChartType,
null
>
),
stack: t.boolean,
});
export type MetricsExplorerChartOptions = t.TypeOf<typeof metricsExplorerChartOptionsRT>;
const metricExplorerOptionsRequiredRT = t.type({
aggregation: t.string,
metrics: t.array(metricsExplorerOptionsMetricRT),
});
const metricExplorerOptionsOptionalRT = t.partial({
limit: t.number,
groupBy: t.union([t.string, t.array(t.string)]),
filterQuery: t.string,
source: t.string,
forceInterval: t.boolean,
dropLastBucket: t.boolean,
});
export const metricExplorerOptionsRT = t.intersection([
metricExplorerOptionsRequiredRT,
metricExplorerOptionsOptionalRT,
]);
export type MetricsExplorerOptions = t.TypeOf<typeof metricExplorerOptionsRT>;
export const metricsExplorerTimestampsRT = t.type({
fromTimestamp: t.number,
toTimestamp: t.number,
interval: t.string,
timeFieldName: t.string,
});
export type MetricsExplorerTimestampsRT = t.TypeOf<typeof metricsExplorerTimestampsRT>;
export const metricsExplorerTimeOptionsRT = t.type({
from: t.string,
to: t.string,
interval: t.string,
});
export type MetricsExplorerTimeOptions = t.TypeOf<typeof metricsExplorerTimeOptionsRT>;
export const DEFAULT_TIMERANGE: MetricsExplorerTimeOptions = {
from: 'now-1h',
to: 'now',
interval: '>=10s',
};
export const DEFAULT_CHART_OPTIONS: MetricsExplorerChartOptions = {
type: MetricsExplorerChartType.line,
yAxisMode: MetricsExplorerYAxisMode.fromZero,
stack: false,
};
export const DEFAULT_METRICS: MetricsExplorerOptionsMetric[] = [
{
aggregation: 'avg',
field: 'system.cpu.total.norm.pct',
color: Color.color0,
},
{
aggregation: 'avg',
field: 'kubernetes.pod.cpu.usage.node.pct',
color: Color.color1,
},
{
aggregation: 'avg',
field: 'docker.cpu.total.pct',
color: Color.color2,
},
];
export const DEFAULT_OPTIONS: MetricsExplorerOptions = {
aggregation: 'avg',
metrics: DEFAULT_METRICS,
source: 'default',
};
export const DEFAULT_METRICS_EXPLORER_VIEW_STATE = {
options: DEFAULT_OPTIONS,
chartOptions: DEFAULT_CHART_OPTIONS,
currentTimerange: DEFAULT_TIMERANGE,
};
function parseJsonOrDefault<Obj>(value: string | null, defaultValue: Obj): Obj {
if (!value) {
return defaultValue;
}
try {
return JSON.parse(value) as Obj;
} catch (e) {
return defaultValue;
}
}
function useStateWithLocalStorage<State>(
key: string,
defaultState: State
): [State, Dispatch<SetStateAction<State>>] {
const storageState = localStorage.getItem(key);
const [state, setState] = useState<State>(parseJsonOrDefault<State>(storageState, defaultState));
useEffect(() => {
localStorage.setItem(key, JSON.stringify(state));
}, [key, state]);
return [state, setState];
}
const getDefaultTimeRange = ({ from, to }: TimeRange) => {
const fromTimestamp = DateMath.parse(from)!.valueOf();
const toTimestamp = DateMath.parse(to, { roundUp: true })!.valueOf();
return {
fromTimestamp,
toTimestamp,
interval: DEFAULT_TIMERANGE.interval,
};
};
export const useMetricsExplorerOptions = () => {
const TIME_DEFAULTS = { from: 'now-1h', to: 'now' };
const [getTime] = useKibanaTimefilterTime(TIME_DEFAULTS);
const { from, to } = getTime();
const [options, setOptions] = useStateWithLocalStorage<MetricsExplorerOptions>(
'MetricsExplorerOptions',
DEFAULT_OPTIONS
);
const [timeRange, setTimeRange] = useState<MetricsExplorerTimeOptions>({
from,
to,
interval: DEFAULT_TIMERANGE.interval,
});
const [timestamps, setTimestamps] = useState<MetricsExplorerTimestampsRT>({
...getDefaultTimeRange({ from, to }),
timeFieldName: '@timestamp',
});
useSyncKibanaTimeFilterTime(TIME_DEFAULTS, {
from: timeRange.from,
to: timeRange.to,
});
const [chartOptions, setChartOptions] = useStateWithLocalStorage<MetricsExplorerChartOptions>(
'MetricsExplorerChartOptions',
DEFAULT_CHART_OPTIONS
);
const [isAutoReloading, setAutoReloading] = useState<boolean>(false);
const { metricThresholdPrefill } = useAlertPrefillContext();
// For Jest compatibility; including metricThresholdPrefill as a dep in useEffect causes an
// infinite loop in test environment
const prefillContext = useMemo(() => metricThresholdPrefill, [metricThresholdPrefill]);
useEffect(() => {
if (prefillContext) {
const { setPrefillOptions } = prefillContext;
const { metrics, groupBy, filterQuery } = options;
setPrefillOptions({ metrics, groupBy, filterQuery });
}
}, [options, prefillContext]);
return {
defaultViewState: {
options: DEFAULT_OPTIONS,
chartOptions: DEFAULT_CHART_OPTIONS,
currentTimerange: timeRange,
},
options,
chartOptions,
setChartOptions,
timeRange,
isAutoReloading,
setOptions,
setTimeRange,
startAutoReload: () => setAutoReloading(true),
stopAutoReload: () => setAutoReloading(false),
timestamps,
setTimestamps,
};
};
export const [MetricsExplorerOptionsContainer, useMetricsExplorerOptionsContainerContext] =
createContainer(useMetricsExplorerOptions);

View file

@ -1,51 +0,0 @@
/*
* 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, Comparator } from '../../../../common/custom_threshold_rule/types';
import { MetricExpression } from '../types';
import { generateUniqueKey } from './generate_unique_key';
describe('generateUniqueKey', () => {
const mockedCriteria: Array<[MetricExpression, string]> = [
[
{
aggType: Aggregators.COUNT,
comparator: Comparator.LT,
threshold: [2000, 5000],
timeSize: 15,
timeUnit: 'm',
},
'count<2000,5000',
],
[
{
aggType: Aggregators.CUSTOM,
comparator: Comparator.GT_OR_EQ,
threshold: [30],
timeSize: 15,
timeUnit: 'm',
},
'custom>=30',
],
[
{
aggType: Aggregators.AVERAGE,
comparator: Comparator.LT_OR_EQ,
threshold: [500],
timeSize: 15,
timeUnit: 'm',
metric: 'metric',
},
'avg(metric)<=500',
],
];
it.each(mockedCriteria)('unique key of %p is %s', (input, output) => {
const uniqueKey = generateUniqueKey(input);
expect(uniqueKey).toBe(output);
});
});

View file

@ -1,14 +0,0 @@
/*
* 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 { MetricExpression } from '../types';
export const generateUniqueKey = (criterion: MetricExpression) => {
const metric = criterion.metric ? `(${criterion.metric})` : '';
return criterion.aggType + metric + criterion.comparator + criterion.threshold.join(',');
};

View file

@ -1,32 +0,0 @@
/*
* 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 { first } from 'lodash';
import { MetricsExplorerResponse } from '../../../../common/custom_threshold_rule/metrics_explorer';
import { MetricThresholdAlertParams, ExpressionChartSeries } from '../types';
export const transformMetricsExplorerData = (
params: MetricThresholdAlertParams,
data: MetricsExplorerResponse | null
) => {
const { criteria } = params;
const firstSeries = first(data?.series);
if (criteria && firstSeries) {
const series = firstSeries.rows.reduce((acc, row) => {
const { timestamp } = row;
criteria.forEach((item, index) => {
if (!acc[index]) {
acc[index] = [];
}
const value = (row[`metric_${index}`] as number) || 0;
acc[index].push({ timestamp, value });
});
return acc;
}, [] as ExpressionChartSeries);
return { id: firstSeries.id, series };
}
};

View file

@ -6,13 +6,14 @@
*/
import { v4 as uuidv4 } from 'uuid';
import { CUSTOM_AGGREGATOR } from '../../../../common/custom_threshold_rule/constants';
import { Aggregators, Comparator } from '../../../../common/custom_threshold_rule/types';
import { MetricThresholdAlert, MetricThresholdRule } from '../components/alert_details_app_section';
import { CustomThresholdAlert, CustomThresholdRule } from '../components/alert_details_app_section';
export const buildMetricThresholdRule = (
rule: Partial<MetricThresholdRule> = {}
): MetricThresholdRule => {
export const buildCustomThresholdRule = (
rule: Partial<CustomThresholdRule> = {}
): CustomThresholdRule => {
return {
alertTypeId: 'metrics.alert.threshold',
createdBy: 'admin',
@ -59,29 +60,47 @@ export const buildMetricThresholdRule = (
params: {
criteria: [
{
aggType: Aggregators.COUNT,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.GT,
metrics: [
{
name: 'A',
aggType: Aggregators.COUNT,
},
],
threshold: [2000],
timeSize: 15,
timeUnit: 'm',
},
{
aggType: Aggregators.MAX,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.GT,
metrics: [
{
name: 'B',
aggType: Aggregators.MAX,
field: 'system.cpu.user.pct',
},
],
threshold: [4],
timeSize: 15,
timeUnit: 'm',
metric: 'system.cpu.user.pct',
warningComparator: Comparator.GT,
warningThreshold: [2.2],
},
{
aggType: Aggregators.MIN,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.GT,
metrics: [
{
name: 'C',
aggType: Aggregators.MIN,
field: 'system.memory.used.pct',
},
],
threshold: [0.8],
timeSize: 15,
timeUnit: 'm',
metric: 'system.memory.used.pct',
},
],
searchConfiguration: {
@ -126,9 +145,9 @@ export const buildMetricThresholdRule = (
};
};
export const buildMetricThresholdAlert = (
alert: Partial<MetricThresholdAlert> = {}
): MetricThresholdAlert => {
export const buildCustomThresholdAlert = (
alert: Partial<CustomThresholdAlert> = {}
): CustomThresholdAlert => {
return {
link: '/app/metrics/explorer',
reason: 'system.cpu.user.pct reported no data in the last 1m for ',
@ -136,20 +155,32 @@ export const buildMetricThresholdAlert = (
'kibana.alert.rule.parameters': {
criteria: [
{
aggType: Aggregators.AVERAGE,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.GT,
metrics: [
{
name: 'A',
aggType: Aggregators.AVERAGE,
field: 'system.cpu.user.pct',
},
],
threshold: [2000],
timeSize: 15,
timeUnit: 'm',
metric: 'system.cpu.user.pct',
},
{
aggType: Aggregators.MAX,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.GT,
metrics: [
{
name: 'B',
aggType: Aggregators.MAX,
metric: 'system.cpu.user.pct',
},
],
threshold: [4],
timeSize: 15,
timeUnit: 'm',
metric: 'system.cpu.user.pct',
warningComparator: Comparator.GT,
warningThreshold: [2.2],
},

View file

@ -24,64 +24,22 @@ import {
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
import { TimeUnitChar } from '../../../common/utils/formatters';
import { MetricsExplorerSeries } from '../../../common/custom_threshold_rule/metrics_explorer';
import {
Comparator,
CustomMetricExpressionParams,
MetricExpressionParams,
MetricsSourceStatus,
NonCountMetricExpressionParams,
SnapshotCustomMetricInput,
BaseMetricExpressionParams,
aggType,
} from '../../../common/custom_threshold_rule/types';
import { ObservabilityPublicStart } from '../../plugin';
import { MetricsExplorerOptions } from './hooks/use_metrics_explorer_options';
export interface AlertContextMeta {
adHocDataViewList: DataView[];
currentOptions?: Partial<MetricsExplorerOptions>;
series?: MetricsExplorerSeries;
}
export type MetricExpression = Omit<
MetricExpressionParams,
'metric' | 'timeSize' | 'timeUnit' | 'metrics' | 'equation'
> & {
metric?: NonCountMetricExpressionParams['metric'];
metrics?: CustomMetricExpressionParams['metrics'];
label?: CustomMetricExpressionParams['label'];
equation?: CustomMetricExpressionParams['equation'];
timeSize?: MetricExpressionParams['timeSize'];
timeUnit?: MetricExpressionParams['timeUnit'];
export type MetricExpression = Omit<CustomMetricExpressionParams, 'timeSize' | 'timeUnit'> & {
timeSize?: BaseMetricExpressionParams['timeSize'];
timeUnit?: BaseMetricExpressionParams['timeUnit'];
};
export enum AGGREGATION_TYPES {
COUNT = 'count',
AVERAGE = 'avg',
SUM = 'sum',
MIN = 'min',
MAX = 'max',
RATE = 'rate',
CARDINALITY = 'cardinality',
P95 = 'p95',
P99 = 'p99',
CUSTOM = 'custom',
}
export interface MetricThresholdAlertParams {
criteria?: MetricExpression[];
groupBy?: string | string[];
filterQuery?: string;
sourceId?: string;
}
export interface ExpressionChartRow {
timestamp: number;
value: number;
}
export type ExpressionChartSeries = ExpressionChartRow[][];
export interface TimeRange {
from?: string;
to?: string;
@ -106,8 +64,6 @@ export interface InfraClientStartDeps {
discover: DiscoverStart;
embeddable?: EmbeddableStart;
lens: LensPublicStart;
// TODO:: check if needed => https://github.com/elastic/kibana/issues/159340
// ml: MlPluginStart;
observability: ObservabilityPublicStart;
observabilityShared: ObservabilitySharedPluginStart;
osquery?: unknown; // OsqueryPluginStart;
@ -125,56 +81,121 @@ export interface InfraClientStartDeps {
export type RendererResult = React.ReactElement<any> | null;
export type RendererFunction<RenderArgs, Result = RendererResult> = (args: RenderArgs) => Result;
export interface DerivedIndexPattern {
fields: MetricsSourceStatus['indexFields'];
title: string;
}
export const SnapshotMetricTypeKeys = {
count: null,
cpu: null,
diskLatency: null,
load: null,
memory: null,
memoryTotal: null,
tx: null,
rx: null,
logRate: null,
diskIOReadBytes: null,
diskIOWriteBytes: null,
s3TotalRequests: null,
s3NumberOfObjects: null,
s3BucketSize: null,
s3DownloadBytes: null,
s3UploadBytes: null,
rdsConnections: null,
rdsQueriesExecuted: null,
rdsActiveTransactions: null,
rdsLatency: null,
sqsMessagesVisible: null,
sqsMessagesDelayed: null,
sqsMessagesSent: null,
sqsMessagesEmpty: null,
sqsOldestMessage: null,
custom: null,
};
export const SnapshotMetricTypeRT = rt.keyof(SnapshotMetricTypeKeys);
export type SnapshotMetricType = rt.TypeOf<typeof SnapshotMetricTypeRT>;
export interface InventoryMetricConditions {
metric: SnapshotMetricType;
timeSize: number;
timeUnit: TimeUnitChar;
sourceId?: string;
threshold: number[];
comparator: Comparator;
customMetric?: SnapshotCustomMetricInput;
warningThreshold?: number[];
warningComparator?: Comparator;
}
export interface MetricThresholdRuleTypeParams extends RuleTypeParams {
criteria: MetricExpressionParams[];
export interface CustomThresholdRuleTypeParams extends RuleTypeParams {
criteria: CustomMetricExpressionParams[];
searchConfiguration: SerializedSearchSourceFields;
groupBy?: string | string[];
}
export const expressionTimestampsRT = rt.type({
fromTimestamp: rt.number,
toTimestamp: rt.number,
interval: rt.string,
timeFieldName: rt.string,
});
export type ExpressionTimestampsRT = rt.TypeOf<typeof expressionTimestampsRT>;
/*
* Expression options
*/
export const metricsExplorerMetricRT = rt.intersection([
rt.type({
name: rt.string,
aggregation: aggType,
}),
rt.partial({
field: rt.string,
filter: rt.string,
}),
]);
const customThresholdExpressionMetricRT = rt.intersection([
rt.type({
aggregation: rt.string,
}),
rt.partial({
field: rt.union([rt.string, rt.undefined]),
custom_metrics: rt.array(metricsExplorerMetricRT),
equation: rt.string,
}),
]);
export const expressionOptionsRT = rt.intersection([
rt.type({
aggregation: rt.string,
metrics: rt.array(customThresholdExpressionMetricRT),
}),
rt.partial({
limit: rt.number,
groupBy: rt.union([rt.string, rt.array(rt.string)]),
filterQuery: rt.string,
source: rt.string,
forceInterval: rt.boolean,
dropLastBucket: rt.boolean,
}),
]);
export type MetricsExplorerMetricRT = rt.TypeOf<typeof metricsExplorerMetricRT>;
export type ExpressionOptions = rt.TypeOf<typeof expressionOptionsRT>;
/*
* End of expression options
*/
/*
* Metrics explorer types
*/
export const timeRangeRT = rt.type({
from: rt.number,
to: rt.number,
interval: rt.string,
});
export const afterKeyObjectRT = rt.record(rt.string, rt.union([rt.string, rt.null]));
export const metricsExplorerPageInfoRT = rt.type({
total: rt.number,
afterKey: rt.union([rt.string, rt.null, afterKeyObjectRT]),
});
export const metricsExplorerColumnTypeRT = rt.keyof({
date: null,
number: null,
string: null,
});
export const metricsExplorerColumnRT = rt.type({
name: rt.string,
type: metricsExplorerColumnTypeRT,
});
export const metricsExplorerRowRT = rt.intersection([
rt.type({
timestamp: rt.number,
}),
rt.record(
rt.string,
rt.union([rt.string, rt.number, rt.null, rt.undefined, rt.array(rt.object)])
),
]);
export const metricsExplorerSeriesRT = rt.intersection([
rt.type({
id: rt.string,
columns: rt.array(metricsExplorerColumnRT),
rows: rt.array(metricsExplorerRowRT),
}),
rt.partial({
keys: rt.array(rt.string),
}),
]);
export const metricsExplorerResponseRT = rt.type({
series: rt.array(metricsExplorerSeriesRT),
pageInfo: metricsExplorerPageInfoRT,
});
export type MetricsExplorerRow = rt.TypeOf<typeof metricsExplorerRowRT>;
export type MetricsExplorerSeries = rt.TypeOf<typeof metricsExplorerSeriesRT>;
export type MetricsExplorerResponse = rt.TypeOf<typeof metricsExplorerResponseRT>;
/*
* End of metrics explorer types
*/

View file

@ -14,7 +14,7 @@ import { ConfigSchema } from '../plugin';
import { ObservabilityRuleTypeRegistry } from './create_observability_rule_type_registry';
import { SLO_BURN_RATE_RULE_TYPE_ID } from '../../common/constants';
import { validateBurnRateRule } from '../components/burn_rate_rule_editor/validation';
import { validateMetricThreshold } from '../components/custom_threshold/components/validation';
import { validateCustomThreshold } from '../components/custom_threshold/components/validation';
import { formatReason } from '../components/custom_threshold/rule_data_formatters';
const sloBurnRateDefaultActionMessage = i18n.translate(
@ -117,7 +117,7 @@ export const registerObservabilityRuleTypes = (
ruleParamsExpression: lazy(
() => import('../components/custom_threshold/custom_threshold_rule_expression')
),
validate: validateMetricThreshold,
validate: validateCustomThreshold,
defaultActionMessage: thresholdDefaultActionMessage,
defaultRecoveryMessage: thresholdDefaultRecoveryMessage,
requiresAppContext: false,

View file

@ -43,7 +43,7 @@ import {
getFormattedGroupBy,
} from './utils';
import { formatAlertResult } from './lib/format_alert_result';
import { formatAlertResult, getLabel } from './lib/format_alert_result';
import { EvaluatedRuleParams, evaluateRule } from './lib/evaluate_rule';
import { MissingGroupsRecord } from './lib/check_missing_group';
import { convertStringsToMissingGroupsRecord } from './lib/convert_strings_to_missing_groups_record';
@ -86,7 +86,6 @@ export const createCustomThresholdExecutor = ({
executionId,
});
// TODO: check if we need to use "savedObjectsClient"=> https://github.com/elastic/kibana/issues/159340
const {
alertWithLifecycle,
getAlertUuid,
@ -215,7 +214,9 @@ export const createCustomThresholdExecutor = ({
if (nextState === AlertStates.NO_DATA) {
reason = alertResults
.filter((result) => result[group]?.isNoData)
.map((result) => buildNoDataAlertReason({ ...result[group], group }))
.map((result) =>
buildNoDataAlertReason({ ...result[group], label: getLabel(result[group]), group })
)
.join('\n');
}
}
@ -271,9 +272,7 @@ export const createCustomThresholdExecutor = ({
timestamp,
value: alertResults.map((result, index) => {
const evaluation = result[group];
if (!evaluation && criteria[index].aggType === 'count') {
return 0;
} else if (!evaluation) {
if (!evaluation) {
return null;
}
return formatAlertResult(evaluation).currentValue;

View file

@ -8,7 +8,7 @@
import { ElasticsearchClient } from '@kbn/core/server';
import type { Logger } from '@kbn/logging';
import { isString, get, identity } from 'lodash';
import { MetricExpressionParams } from '../../../../../common/custom_threshold_rule/types';
import { CustomMetricExpressionParams } from '../../../../../common/custom_threshold_rule/types';
import type { BucketKey } from './get_data';
import { calculateCurrentTimeframe, createBaseFilters } from './metric_query';
@ -19,7 +19,7 @@ export interface MissingGroupsRecord {
export const checkMissingGroups = async (
esClient: ElasticsearchClient,
metricParams: MetricExpressionParams,
metricParams: CustomMetricExpressionParams,
indexPattern: string,
timeFieldName: string,
groupBy: string | undefined | string[],

View file

@ -5,33 +5,19 @@
* 2.0.
*/
import {
Aggregators,
MetricExpressionParams,
} from '../../../../../common/custom_threshold_rule/types';
import { CustomMetricExpressionParams } from '../../../../../common/custom_threshold_rule/types';
import { createConditionScript } from './create_condition_script';
import { createLastPeriod } from './wrap_in_period';
export const createBucketSelector = (
condition: MetricExpressionParams,
condition: CustomMetricExpressionParams,
alertOnGroupDisappear: boolean = false,
timeFieldName: string,
groupBy?: string | string[],
lastPeriodEnd?: number
) => {
const hasGroupBy = !!groupBy;
const isPercentile = [Aggregators.P95, Aggregators.P99].includes(condition.aggType);
const isCount = condition.aggType === Aggregators.COUNT;
const isRate = condition.aggType === Aggregators.RATE;
const bucketPath = isCount
? "currentPeriod['all']>_count"
: isRate
? `aggregatedValue`
: isPercentile
? `currentPeriod[\'all\']>aggregatedValue[${
condition.aggType === Aggregators.P95 ? '95' : '99'
}]`
: "currentPeriod['all']>aggregatedValue";
const bucketPath = "currentPeriod['all']>aggregatedValue";
const shouldTrigger = {
bucket_script: {

View file

@ -8,25 +8,16 @@
import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query';
import { isEmpty } from 'lodash';
import { CustomThresholdExpressionMetric } from '../../../../../common/custom_threshold_rule/types';
import { MetricsExplorerCustomMetric } from './metrics_explorer';
const isMetricExpressionCustomMetric = (
subject: MetricsExplorerCustomMetric | CustomThresholdExpressionMetric
): subject is CustomThresholdExpressionMetric => {
return 'aggType' in subject;
};
export const createCustomMetricsAggregations = (
id: string,
customMetrics: Array<MetricsExplorerCustomMetric | CustomThresholdExpressionMetric>,
customMetrics: CustomThresholdExpressionMetric[],
equation?: string
) => {
const bucketsPath: { [id: string]: string } = {};
const metricAggregations = customMetrics.reduce((acc, metric) => {
const key = `${id}_${metric.name}`;
const aggregation = isMetricExpressionCustomMetric(metric)
? metric.aggType
: metric.aggregation;
const aggregation = metric.aggType;
if (aggregation === 'count') {
bucketsPath[metric.name] = `${key}>_count`;

View file

@ -1,24 +0,0 @@
/*
* 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 } from '../../../../../common/custom_threshold_rule/types';
export const createPercentileAggregation = (
type: Aggregators.P95 | Aggregators.P99,
field: string
) => {
const value = type === Aggregators.P95 ? 95 : 99;
return {
aggregatedValue: {
percentiles: {
field,
percents: [value],
keyed: true,
},
},
};
};

View file

@ -1,66 +0,0 @@
/*
* 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: `currentPeriod['all']>${id}_first_bucket.maxValue`,
second: `currentPeriod['all']>${id}_second_bucket.maxValue`,
},
script: `params.second > 0.0 && params.first > 0.0 && params.second > params.first ? (params.second - params.first) / ${intervalInSeconds}: null`,
},
},
};
};
export const createRateAggsBuckets = (
timeframe: { start: number; end: number; timeFieldName: string },
id: string,
field: string
) => {
const { firstBucketRange, secondBucketRange } = calculateRateTimeranges({
to: timeframe.end,
from: timeframe.start,
});
return {
[`${id}_first_bucket`]: {
filter: {
range: {
[timeframe.timeFieldName]: {
gte: moment(firstBucketRange.from).toISOString(),
lt: moment(firstBucketRange.to).toISOString(),
},
},
},
aggs: { maxValue: { max: { field } } },
},
[`${id}_second_bucket`]: {
filter: {
range: {
[timeframe.timeFieldName]: {
gte: moment(secondBucketRange.from).toISOString(),
lt: moment(secondBucketRange.to).toISOString(),
},
},
},
aggs: { maxValue: { max: { field } } },
},
};
};

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import { Aggregators } from '../../../../../common/custom_threshold_rule/types';
import moment from 'moment';
import { createTimerange } from './create_timerange';
@ -18,65 +17,37 @@ describe('createTimerange(interval, aggType, timeframe)', () => {
};
describe('Basic Metric Aggs', () => {
it('should return a second range for last 1 second', () => {
const subject = createTimerange(1000, Aggregators.COUNT, timeframe);
const subject = createTimerange(1000, timeframe);
expect(subject.end - subject.start).toEqual(1000);
});
it('should return a minute range for last 1 minute', () => {
const subject = createTimerange(60000, Aggregators.COUNT, timeframe);
const subject = createTimerange(60000, timeframe);
expect(subject.end - subject.start).toEqual(60000);
});
it('should return 5 minute range for last 5 minutes', () => {
const subject = createTimerange(300000, Aggregators.COUNT, timeframe);
const subject = createTimerange(300000, timeframe);
expect(subject.end - subject.start).toEqual(300000);
});
it('should return a hour range for last 1 hour', () => {
const subject = createTimerange(3600000, Aggregators.COUNT, timeframe);
const subject = createTimerange(3600000, timeframe);
expect(subject.end - subject.start).toEqual(3600000);
});
it('should return a day range for last 1 day', () => {
const subject = createTimerange(86400000, Aggregators.COUNT, timeframe);
const subject = createTimerange(86400000, timeframe);
expect(subject.end - subject.start).toEqual(86400000);
});
});
describe('Rate Aggs', () => {
it('should return a 20 second range for last 1 second', () => {
const subject = createTimerange(1000, Aggregators.RATE, timeframe);
expect(subject.end - subject.start).toEqual(1000 * 2);
});
it('should return a 5 minute range for last 1 minute', () => {
const subject = createTimerange(60000, Aggregators.RATE, timeframe);
expect(subject.end - subject.start).toEqual(60000 * 2);
});
it('should return 25 minute range for last 5 minutes', () => {
const subject = createTimerange(300000, Aggregators.RATE, timeframe);
expect(subject.end - subject.start).toEqual(300000 * 2);
});
it('should return 5 hour range for last hour', () => {
const subject = createTimerange(3600000, Aggregators.RATE, timeframe);
expect(subject.end - subject.start).toEqual(3600000 * 2);
});
it('should return a 5 day range for last day', () => {
const subject = createTimerange(86400000, Aggregators.RATE, timeframe);
expect(subject.end - subject.start).toEqual(86400000 * 2);
});
});
describe('With lastPeriodEnd', () => {
it('should return a minute and 1 second range for last 1 second when the lastPeriodEnd is less than the timeframe start', () => {
const subject = createTimerange(
1000,
Aggregators.COUNT,
timeframe,
end.clone().subtract(1, 'minutes').valueOf()
);
expect(subject.end - subject.start).toEqual(61000);
});
it('should return a second range for last 1 second when the lastPeriodEnd is not less than the timeframe start', () => {
const subject = createTimerange(
1000,
Aggregators.COUNT,
timeframe,
end.clone().add(2, 'seconds').valueOf()
);
const subject = createTimerange(1000, timeframe, end.clone().add(2, 'seconds').valueOf());
expect(subject.end - subject.start).toEqual(1000);
});
});

View file

@ -6,20 +6,16 @@
*/
import moment from 'moment';
import { Aggregators } from '../../../../../common/custom_threshold_rule/types';
export const createTimerange = (
interval: number,
aggType: Aggregators,
timeframe: { end: string; start: string },
lastPeriodEnd?: number
) => {
const end = moment(timeframe.end).valueOf();
let start = moment(timeframe.start).valueOf();
// Rate aggregations need 5 buckets worth of data
const minimumBuckets = aggType === Aggregators.RATE ? 2 : 1;
start = start - interval * minimumBuckets;
start = start - interval;
// Use lastPeriodEnd - interval when it's less than start
if (lastPeriodEnd && lastPeriodEnd - interval < start) {

View file

@ -8,35 +8,20 @@
import moment from 'moment';
import { ElasticsearchClient } from '@kbn/core/server';
import type { Logger } from '@kbn/logging';
import {
Aggregators,
CustomMetricExpressionParams,
MetricExpressionParams,
} from '../../../../../common/custom_threshold_rule/types';
import { CustomMetricExpressionParams } from '../../../../../common/custom_threshold_rule/types';
import { AdditionalContext, getIntervalInSeconds } from '../utils';
import {
AVERAGE_I18N,
CARDINALITY_I18N,
CUSTOM_EQUATION_I18N,
DOCUMENT_COUNT_I18N,
MAX_I18N,
MIN_I18N,
SUM_I18N,
} from '../translations';
import { SearchConfigurationType } from '../types';
import { createTimerange } from './create_timerange';
import { getData } from './get_data';
import { checkMissingGroups, MissingGroupsRecord } from './check_missing_group';
import { isCustom } from './metric_expression_params';
export interface EvaluatedRuleParams {
criteria: MetricExpressionParams[];
criteria: CustomMetricExpressionParams[];
groupBy: string | undefined | string[];
searchConfiguration: SearchConfigurationType;
}
export type Evaluation = Omit<MetricExpressionParams, 'metric'> & {
metric: string;
export type Evaluation = CustomMetricExpressionParams & {
currentValue: number | null;
timestamp: string;
shouldFire: boolean;
@ -45,26 +30,6 @@ export type Evaluation = Omit<MetricExpressionParams, 'metric'> & {
context?: AdditionalContext;
};
const getMetric = (criterion: CustomMetricExpressionParams) => {
if (!criterion.label && criterion.metrics.length === 1) {
switch (criterion.metrics[0].aggType) {
case Aggregators.COUNT:
return DOCUMENT_COUNT_I18N;
case Aggregators.AVERAGE:
return AVERAGE_I18N(criterion.metrics[0].field!);
case Aggregators.MAX:
return MAX_I18N(criterion.metrics[0].field!);
case Aggregators.MIN:
return MIN_I18N(criterion.metrics[0].field!);
case Aggregators.CARDINALITY:
return CARDINALITY_I18N(criterion.metrics[0].field!);
case Aggregators.SUM:
return SUM_I18N(criterion.metrics[0].field!);
}
}
return criterion.label || CUSTOM_EQUATION_I18N;
};
export const evaluateRule = async <Params extends EvaluatedRuleParams = EvaluatedRuleParams>(
esClient: ElasticsearchClient,
params: Params,
@ -84,12 +49,7 @@ export const evaluateRule = async <Params extends EvaluatedRuleParams = Evaluate
const interval = `${criterion.timeSize}${criterion.timeUnit}`;
const intervalAsSeconds = getIntervalInSeconds(interval);
const intervalAsMS = intervalAsSeconds * 1000;
const calculatedTimerange = createTimerange(
intervalAsMS,
criterion.aggType,
timeframe,
lastPeriodEnd
);
const calculatedTimerange = createTimerange(intervalAsMS, timeframe, lastPeriodEnd);
const currentValues = await getData(
esClient,
@ -133,12 +93,6 @@ export const evaluateRule = async <Params extends EvaluatedRuleParams = Evaluate
if (result.trigger || result.value === null) {
evaluations[key] = {
...criterion,
metric:
criterion.aggType === 'count'
? DOCUMENT_COUNT_I18N
: isCustom(criterion)
? getMetric(criterion)
: criterion.metric,
currentValue: result.value,
timestamp: moment(calculatedTimerange.end).toISOString(),
shouldFire: result.trigger,

View file

@ -6,7 +6,17 @@
*/
import { i18n } from '@kbn/i18n';
import { Aggregators } from '../../../../../common/custom_threshold_rule/types';
import { createFormatter } from '../../../../../common/custom_threshold_rule/formatters';
import {
AVERAGE_I18N,
CARDINALITY_I18N,
CUSTOM_EQUATION_I18N,
DOCUMENT_COUNT_I18N,
MAX_I18N,
MIN_I18N,
SUM_I18N,
} from '../translations';
import { Evaluation } from './evaluate_rule';
export type FormattedEvaluation = Omit<Evaluation, 'currentValue' | 'threshold'> & {
@ -14,31 +24,45 @@ export type FormattedEvaluation = Omit<Evaluation, 'currentValue' | 'threshold'>
threshold: string[];
};
export const getLabel = (criterion: Evaluation) => {
if (!criterion.label && criterion.metrics.length === 1) {
switch (criterion.metrics[0].aggType) {
case Aggregators.COUNT:
return DOCUMENT_COUNT_I18N;
case Aggregators.AVERAGE:
return AVERAGE_I18N(criterion.metrics[0].field!);
case Aggregators.MAX:
return MAX_I18N(criterion.metrics[0].field!);
case Aggregators.MIN:
return MIN_I18N(criterion.metrics[0].field!);
case Aggregators.CARDINALITY:
return CARDINALITY_I18N(criterion.metrics[0].field!);
case Aggregators.SUM:
return SUM_I18N(criterion.metrics[0].field!);
}
}
return criterion.label || CUSTOM_EQUATION_I18N;
};
export const formatAlertResult = (evaluationResult: Evaluation): FormattedEvaluation => {
const { metric, currentValue, threshold, comparator } = evaluationResult;
const { metrics, currentValue, threshold, comparator } = evaluationResult;
const noDataValue = i18n.translate(
'xpack.observability.customThreshold.rule.alerting.threshold.noDataFormattedValue',
{ defaultMessage: '[NO DATA]' }
);
if (metric.endsWith('.pct')) {
const formatter = createFormatter('percent');
return {
...evaluationResult,
currentValue:
currentValue !== null && currentValue !== undefined ? formatter(currentValue) : noDataValue,
threshold: Array.isArray(threshold)
? threshold.map((v: number) => formatter(v))
: [formatter(threshold)],
comparator,
};
let formatter = createFormatter('highPrecision');
const label = getLabel(evaluationResult);
if (metrics.length === 1 && metrics[0].field && metrics[0].field.endsWith('.pct')) {
formatter = createFormatter('percent');
}
const formatter = createFormatter('highPrecision');
return {
...evaluationResult,
currentValue:
currentValue !== null && currentValue !== undefined ? formatter(currentValue) : noDataValue,
label: label || CUSTOM_EQUATION_I18N,
threshold: Array.isArray(threshold)
? threshold.map((v: number) => formatter(v))
: [formatter(threshold)],

View file

@ -9,11 +9,7 @@ import { SearchResponse, AggregationsAggregate } from '@elastic/elasticsearch/li
import { ElasticsearchClient } from '@kbn/core/server';
import type { Logger } from '@kbn/logging';
import { EcsFieldsResponse } from '@kbn/rule-registry-plugin/common/search_strategy';
import {
Aggregators,
Comparator,
MetricExpressionParams,
} from '../../../../../common/custom_threshold_rule/types';
import { CustomMetricExpressionParams } from '../../../../../common/custom_threshold_rule/types';
import { UNGROUPED_FACTORY_KEY } from '../constants';
import { CONTAINER_ID, AdditionalContext, doFieldsExist, KUBERNETES_POD_UID } from '../utils';
@ -81,11 +77,6 @@ interface ResponseAggregations extends Partial<Aggs> {
};
}
const getValue = (aggregatedValue: AggregatedValue, params: MetricExpressionParams) =>
[Aggregators.P95, Aggregators.P99].includes(params.aggType) && aggregatedValue.values != null
? aggregatedValue.values[params.aggType === Aggregators.P95 ? '95.0' : '99.0']
: aggregatedValue.value;
const NO_DATA_RESPONSE = {
[UNGROUPED_FACTORY_KEY]: {
value: null,
@ -105,7 +96,7 @@ const createContainerList = (containerContext: ContainerContext) => {
export const getData = async (
esClient: ElasticsearchClient,
params: MetricExpressionParams,
params: CustomMetricExpressionParams,
index: string,
timeFieldName: string,
groupBy: string | undefined | string[],
@ -132,16 +123,10 @@ export const getData = async (
const nextAfterKey = groupings.after_key;
for (const bucket of groupings.buckets) {
const key = Object.values(bucket.key).join(',');
const {
shouldTrigger,
missingGroup,
currentPeriod,
aggregatedValue: aggregatedValueForRate,
additionalContext,
containerContext,
} = bucket;
const { shouldTrigger, missingGroup, currentPeriod, additionalContext, containerContext } =
bucket;
const { aggregatedValue, doc_count: docCount } = currentPeriod.buckets.all;
const { aggregatedValue } = currentPeriod.buckets.all;
const containerList = containerContext ? createContainerList(containerContext) : undefined;
@ -156,14 +141,7 @@ export const getData = async (
bucketKey: bucket.key,
};
} else {
const value =
params.aggType === Aggregators.COUNT
? docCount
: params.aggType === Aggregators.RATE && aggregatedValueForRate != null
? aggregatedValueForRate.value
: aggregatedValue != null
? getValue(aggregatedValue, params)
: null;
const value = aggregatedValue ? aggregatedValue.value : null;
previous[key] = {
trigger: (shouldTrigger && shouldTrigger.value > 0) || false,
@ -194,36 +172,10 @@ export const getData = async (
return previous;
}
if (aggs.all?.buckets.all) {
const {
currentPeriod,
aggregatedValue: aggregatedValueForRate,
shouldTrigger,
} = aggs.all.buckets.all;
const { currentPeriod, shouldTrigger } = aggs.all.buckets.all;
const { aggregatedValue, doc_count: docCount } = currentPeriod.buckets.all;
const value =
params.aggType === Aggregators.COUNT
? docCount
: params.aggType === Aggregators.RATE && aggregatedValueForRate != null
? aggregatedValueForRate.value
: aggregatedValue != null
? getValue(aggregatedValue, params)
: null;
// There is an edge case where there is no results and the shouldTrigger
// bucket scripts will be missing. This is only an issue for document count because
// the value will end up being ZERO, for other metrics it will be null. In this case
// we need to do the evaluation in Node.js
if (aggs.all && params.aggType === Aggregators.COUNT && value === 0) {
const trigger = comparatorMap[params.comparator](value, params.threshold);
return {
[UNGROUPED_FACTORY_KEY]: {
value,
trigger,
bucketKey: { groupBy0: UNGROUPED_FACTORY_KEY },
},
};
}
const { aggregatedValue } = currentPeriod.buckets.all;
const value = aggregatedValue ? aggregatedValue.value : null;
return {
[UNGROUPED_FACTORY_KEY]: {
value,
@ -268,15 +220,3 @@ export const getData = async (
}
return NO_DATA_RESPONSE;
};
const comparatorMap = {
[Comparator.BETWEEN]: (value: number, [a, b]: number[]) =>
value >= Math.min(a, b) && value <= Math.max(a, b),
// `threshold` is always an array of numbers in case the BETWEEN comparator is
// used; all other compartors will just destructure the first value in the array
[Comparator.GT]: (a: number, [b]: number[]) => a > b,
[Comparator.LT]: (a: number, [b]: number[]) => a < b,
[Comparator.OUTSIDE_RANGE]: (value: number, [a, b]: number[]) => value < a || value > b,
[Comparator.GT_OR_EQ]: (a: number, [b]: number[]) => a >= b,
[Comparator.LT_OR_EQ]: (a: number, [b]: number[]) => a <= b,
};

View file

@ -1,26 +0,0 @@
/*
* 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 {
CustomMetricExpressionParams,
MetricExpressionParams,
NonCountMetricExpressionParams,
} from '../../../../../common/custom_threshold_rule/types';
export const isNotCountOrCustom = (
metricExpressionParams: MetricExpressionParams
): metricExpressionParams is NonCountMetricExpressionParams => {
const { aggType } = metricExpressionParams;
return aggType !== 'count' && aggType !== 'custom';
};
export const isCustom = (
metricExpressionParams: MetricExpressionParams
): metricExpressionParams is CustomMetricExpressionParams => {
const { aggType } = metricExpressionParams;
return aggType === 'custom';
};

View file

@ -5,18 +5,25 @@
* 2.0.
*/
import moment from 'moment';
import { CUSTOM_AGGREGATOR } from '../../../../../common/custom_threshold_rule/constants';
import {
Comparator,
Aggregators,
MetricExpressionParams,
CustomMetricExpressionParams,
} from '../../../../../common/custom_threshold_rule/types';
import moment from 'moment';
import { getElasticsearchMetricQuery } from './metric_query';
describe("The Metric Threshold Alert's getElasticsearchMetricQuery", () => {
const expressionParams: MetricExpressionParams = {
metric: 'system.is.a.good.puppy.dog',
aggType: Aggregators.AVERAGE,
const expressionParams: CustomMetricExpressionParams = {
metrics: [
{
name: 'A',
aggType: Aggregators.AVERAGE,
field: 'system.is.a.good.puppy.dog',
},
],
aggType: CUSTOM_AGGREGATOR,
timeUnit: 'm',
timeSize: 1,
threshold: [1],
@ -47,8 +54,28 @@ describe("The Metric Threshold Alert's getElasticsearchMetricQuery", () => {
});
test('includes a metric field filter', () => {
expect(searchBody.query.bool.filter).toMatchObject(
expect.arrayContaining([{ exists: { field: 'system.is.a.good.puppy.dog' } }])
expect(searchBody.aggs.groupings.aggs.currentPeriod).toMatchObject(
expect.objectContaining({
aggs: {
// eslint-disable-next-line @typescript-eslint/naming-convention
aggregatedValue_A: {
avg: {
field: 'system.is.a.good.puppy.dog',
},
},
aggregatedValue: {
bucket_script: {
buckets_path: {
A: 'aggregatedValue_A',
},
script: {
source: 'params.A',
lang: 'painless',
},
},
},
},
})
);
});
});
@ -79,7 +106,6 @@ describe("The Metric Threshold Alert's getElasticsearchMetricQuery", () => {
expect(searchBody.query.bool.filter).toMatchObject(
expect.arrayContaining([
{ range: { mockedTimeFieldName: expect.any(Object) } },
{ exists: { field: 'system.is.a.good.puppy.dog' } },
{
bool: {
filter: [
@ -108,6 +134,36 @@ describe("The Metric Threshold Alert's getElasticsearchMetricQuery", () => {
},
])
);
expect(searchBody.aggs.groupings.aggs).toMatchObject(
expect.objectContaining({
currentPeriod: {
filters: {
filters: {
all: { range: { mockedTimeFieldName: expect.any(Object) } },
},
},
aggs: {
// eslint-disable-next-line @typescript-eslint/naming-convention
aggregatedValue_A: {
avg: {
field: 'system.is.a.good.puppy.dog',
},
},
aggregatedValue: {
bucket_script: {
buckets_path: {
A: 'aggregatedValue_A',
},
script: {
source: 'params.A',
lang: 'painless',
},
},
},
},
},
})
);
});
});
});

View file

@ -6,11 +6,7 @@
*/
import moment from 'moment';
import {
Aggregators,
MetricExpressionParams,
} from '../../../../../common/custom_threshold_rule/types';
import { isCustom, isNotCountOrCustom } from './metric_expression_params';
import { CustomMetricExpressionParams } from '../../../../../common/custom_threshold_rule/types';
import { createCustomMetricsAggregations } from './create_custom_metrics_aggregations';
import {
CONTAINER_ID,
@ -20,26 +16,19 @@ import {
validGroupByForContext,
} from '../utils';
import { createBucketSelector } from './create_bucket_selector';
import { createPercentileAggregation } from './create_percentile_aggregation';
import { createRateAggsBuckets, createRateAggsBucketScript } from './create_rate_aggregation';
import { wrapInCurrentPeriod } from './wrap_in_period';
import { getParsedFilterQuery } from '../../../../utils/get_parsed_filtered_query';
export const calculateCurrentTimeframe = (
metricParams: MetricExpressionParams,
metricParams: CustomMetricExpressionParams,
timeframe: { start: number; end: number }
) => ({
...timeframe,
start: moment(timeframe.end)
.subtract(
metricParams.aggType === Aggregators.RATE ? metricParams.timeSize * 2 : metricParams.timeSize,
metricParams.timeUnit
)
.valueOf(),
start: moment(timeframe.end).subtract(metricParams.timeSize, metricParams.timeUnit).valueOf(),
});
export const createBaseFilters = (
metricParams: MetricExpressionParams,
metricParams: CustomMetricExpressionParams,
timeframe: { start: number; end: number },
timeFieldName: string,
filterQuery?: string
@ -55,24 +44,13 @@ export const createBaseFilters = (
},
];
const metricFieldFilters =
isNotCountOrCustom(metricParams) && metricParams.metric
? [
{
exists: {
field: metricParams.metric,
},
},
]
: [];
const parsedFilterQuery = getParsedFilterQuery(filterQuery);
return [...rangeFilters, ...metricFieldFilters, ...parsedFilterQuery];
return [...rangeFilters, ...parsedFilterQuery];
};
export const getElasticsearchMetricQuery = (
metricParams: MetricExpressionParams,
metricParams: CustomMetricExpressionParams,
timeframe: { start: number; end: number },
timeFieldName: string,
compositeSize: number,
@ -83,13 +61,6 @@ export const getElasticsearchMetricQuery = (
afterKey?: Record<string, string>,
fieldsExisted?: Record<string, boolean> | null
) => {
const { aggType } = metricParams;
if (isNotCountOrCustom(metricParams) && !metricParams.metric) {
throw new Error(
'Can only aggregate without a metric if using the document count or custom aggregator'
);
}
// We need to make a timeframe that represents the current timeframe as opposed
// to the total timeframe (which includes the last period).
const currentTimeframe = {
@ -97,26 +68,11 @@ export const getElasticsearchMetricQuery = (
timeFieldName,
};
const metricAggregations =
aggType === Aggregators.COUNT
? {}
: aggType === Aggregators.RATE
? createRateAggsBuckets(currentTimeframe, 'aggregatedValue', metricParams.metric)
: aggType === Aggregators.P95 || aggType === Aggregators.P99
? createPercentileAggregation(aggType, metricParams.metric)
: isCustom(metricParams)
? createCustomMetricsAggregations(
'aggregatedValue',
metricParams.metrics,
metricParams.equation
)
: {
aggregatedValue: {
[aggType]: {
field: metricParams.metric,
},
},
};
const metricAggregations = createCustomMetricsAggregations(
'aggregatedValue',
metricParams.metrics,
metricParams.equation
);
const bucketSelectorAggregations = createBucketSelector(
metricParams,
@ -126,11 +82,6 @@ export const getElasticsearchMetricQuery = (
lastPeriodEnd
);
const rateAggBucketScript =
metricParams.aggType === Aggregators.RATE
? createRateAggsBucketScript(currentTimeframe, 'aggregatedValue')
: {};
const currentPeriod = wrapInCurrentPeriod(currentTimeframe, metricAggregations);
const containerIncludesList = ['container.*'];
@ -207,7 +158,6 @@ export const getElasticsearchMetricQuery = (
},
aggs: {
...currentPeriod,
...rateAggBucketScript,
...bucketSelectorAggregations,
...additionalContextAgg,
...containerContextAgg,
@ -225,7 +175,6 @@ export const getElasticsearchMetricQuery = (
},
aggs: {
...currentPeriod,
...rateAggBucketScript,
...bucketSelectorAggregations,
},
},

View file

@ -1,131 +0,0 @@
/*
* 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 * as rt from 'io-ts';
import { METRIC_EXPLORER_AGGREGATIONS } from '../../../../../common/custom_threshold_rule/constants';
import { metricsExplorerCustomMetricAggregationRT } from '../../../../../common/custom_threshold_rule/metrics_explorer';
type MetricExplorerAggregations = typeof METRIC_EXPLORER_AGGREGATIONS[number];
const metricsExplorerAggregationKeys = METRIC_EXPLORER_AGGREGATIONS.reduce<
Record<MetricExplorerAggregations, null>
>((acc, agg) => ({ ...acc, [agg]: null }), {} as Record<MetricExplorerAggregations, null>);
export const metricsExplorerAggregationRT = rt.keyof(metricsExplorerAggregationKeys);
export const metricsExplorerMetricRequiredFieldsRT = rt.type({
aggregation: metricsExplorerAggregationRT,
});
export const metricsExplorerCustomMetricRT = rt.intersection([
rt.type({
name: rt.string,
aggregation: metricsExplorerCustomMetricAggregationRT,
}),
rt.partial({
field: rt.string,
filter: rt.string,
}),
]);
export type MetricsExplorerCustomMetric = rt.TypeOf<typeof metricsExplorerCustomMetricRT>;
export const metricsExplorerMetricOptionalFieldsRT = rt.partial({
field: rt.union([rt.string, rt.undefined]),
custom_metrics: rt.array(metricsExplorerCustomMetricRT),
equation: rt.string,
});
export const metricsExplorerMetricRT = rt.intersection([
metricsExplorerMetricRequiredFieldsRT,
metricsExplorerMetricOptionalFieldsRT,
]);
export const timeRangeRT = rt.type({
from: rt.number,
to: rt.number,
interval: rt.string,
});
export const metricsExplorerRequestBodyRequiredFieldsRT = rt.type({
timerange: timeRangeRT,
indexPattern: rt.string,
metrics: rt.array(metricsExplorerMetricRT),
});
const groupByRT = rt.union([rt.string, rt.null, rt.undefined]);
export const afterKeyObjectRT = rt.record(rt.string, rt.union([rt.string, rt.null]));
export const metricsExplorerRequestBodyOptionalFieldsRT = rt.partial({
groupBy: rt.union([groupByRT, rt.array(groupByRT)]),
afterKey: rt.union([rt.string, rt.null, rt.undefined, afterKeyObjectRT]),
limit: rt.union([rt.number, rt.null, rt.undefined]),
filterQuery: rt.union([rt.string, rt.null, rt.undefined]),
forceInterval: rt.boolean,
dropLastBucket: rt.boolean,
});
export const metricsExplorerRequestBodyRT = rt.intersection([
metricsExplorerRequestBodyRequiredFieldsRT,
metricsExplorerRequestBodyOptionalFieldsRT,
]);
export const metricsExplorerPageInfoRT = rt.type({
total: rt.number,
afterKey: rt.union([rt.string, rt.null, afterKeyObjectRT]),
});
export const metricsExplorerColumnTypeRT = rt.keyof({
date: null,
number: null,
string: null,
});
export const metricsExplorerColumnRT = rt.type({
name: rt.string,
type: metricsExplorerColumnTypeRT,
});
export const metricsExplorerRowRT = rt.intersection([
rt.type({
timestamp: rt.number,
}),
rt.record(
rt.string,
rt.union([rt.string, rt.number, rt.null, rt.undefined, rt.array(rt.object)])
),
]);
export const metricsExplorerSeriesRT = rt.intersection([
rt.type({
id: rt.string,
columns: rt.array(metricsExplorerColumnRT),
rows: rt.array(metricsExplorerRowRT),
}),
rt.partial({
keys: rt.array(rt.string),
}),
]);
export const metricsExplorerResponseRT = rt.type({
series: rt.array(metricsExplorerSeriesRT),
pageInfo: metricsExplorerPageInfoRT,
});
export type AfterKey = rt.TypeOf<typeof afterKeyObjectRT>;
export type MetricsExplorerColumnType = rt.TypeOf<typeof metricsExplorerColumnTypeRT>;
export type MetricsExplorerPageInfo = rt.TypeOf<typeof metricsExplorerPageInfoRT>;
export type MetricsExplorerColumn = rt.TypeOf<typeof metricsExplorerColumnRT>;
export type MetricsExplorerRow = rt.TypeOf<typeof metricsExplorerRowRT>;
export type MetricsExplorerRequestBody = rt.TypeOf<typeof metricsExplorerRequestBodyRT>;
export type MetricsExplorerResponse = rt.TypeOf<typeof metricsExplorerResponseRT>;

View file

@ -6,11 +6,11 @@
*/
import moment from 'moment';
import { MetricExpressionParams } from '../../../../../common/custom_threshold_rule/types';
import { CustomMetricExpressionParams } from '../../../../../common/custom_threshold_rule/types';
export const createLastPeriod = (
lastPeriodEnd: number,
{ timeUnit, timeSize }: MetricExpressionParams,
{ timeUnit, timeSize }: CustomMetricExpressionParams,
timeFieldName: string
) => {
const start = moment(lastPeriodEnd).subtract(timeSize, timeUnit).toISOString();

View file

@ -10,7 +10,13 @@ import { Comparator } from '../../../../common/custom_threshold_rule/types';
import { formatDurationFromTimeUnitChar } from '../../../../common';
import { Evaluation } from './lib/evaluate_rule';
import { formatAlertResult, FormattedEvaluation } from './lib/format_alert_result';
import { BELOW_TEXT, ABOVE_TEXT, BETWEEN_TEXT, NOT_BETWEEN_TEXT } from './translations';
import {
BELOW_TEXT,
ABOVE_TEXT,
BETWEEN_TEXT,
NOT_BETWEEN_TEXT,
CUSTOM_EQUATION_I18N,
} from './translations';
import { UNGROUPED_FACTORY_KEY } from './constants';
const toNumber = (value: number | string) =>
@ -103,17 +109,15 @@ export const buildFiredAlertReason: (
};
const buildAggregationReason: (evaluation: FormattedEvaluation) => string = ({
metric,
label,
comparator,
threshold,
currentValue,
timeSize,
timeUnit,
}) =>
i18n.translate('xpack.observability.customThreshold.rule.threshold.firedAlertReason', {
defaultMessage: '{metric} is {currentValue}, {comparator} the threshold of {threshold}',
defaultMessage: '{label} is {currentValue}, {comparator} the threshold of {threshold}',
values: {
metric,
label,
comparator: alertComparatorToI18n(comparator),
threshold: thresholdToI18n(threshold),
currentValue,
@ -123,16 +127,16 @@ const buildAggregationReason: (evaluation: FormattedEvaluation) => string = ({
// Once recovered reason messages are re-enabled, checkout this issue https://github.com/elastic/kibana/issues/121272 regarding latest reason format
export const buildRecoveredAlertReason: (alertResult: {
group: string;
metric: string;
label?: string;
comparator: Comparator;
threshold: Array<number | string>;
currentValue: number | string;
}) => string = ({ group, metric, comparator, threshold, currentValue }) =>
}) => string = ({ group, label = CUSTOM_EQUATION_I18N, comparator, threshold, currentValue }) =>
i18n.translate('xpack.observability.customThreshold.rule.threshold.recoveredAlertReason', {
defaultMessage:
'{metric} is now {comparator} a threshold of {threshold} (current value is {currentValue}) for {group}',
'{label} is now {comparator} a threshold of {threshold} (current value is {currentValue}) for {group}',
values: {
metric,
label,
comparator: recoveredComparatorToI18n(
comparator,
threshold.map(toNumber),
@ -144,16 +148,16 @@ export const buildRecoveredAlertReason: (alertResult: {
},
});
export const buildNoDataAlertReason: (alertResult: {
group: string;
metric: string;
timeSize: number;
timeUnit: string;
}) => string = ({ group, metric, timeSize, timeUnit }) =>
export const buildNoDataAlertReason: (alertResult: Evaluation & { group: string }) => string = ({
group,
label = CUSTOM_EQUATION_I18N,
timeSize,
timeUnit,
}) =>
i18n.translate('xpack.observability.customThreshold.rule.threshold.noDataAlertReason', {
defaultMessage: '{metric} reported no data in the last {interval}{group}',
defaultMessage: '{label} reported no data in the last {interval}{group}',
values: {
metric,
label,
interval: `${timeSize}${timeUnit}`,
group: formatGroup(group),
},

View file

@ -8,6 +8,7 @@
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import { extractReferences, injectReferences } from '@kbn/data-plugin/common';
import { dataViewSpecSchema } from '@kbn/data-views-plugin/server/rest_api_routes/schema';
import { i18n } from '@kbn/i18n';
import { IRuleTypeAlerts, GetViewInAppRelativeUrlFnOpts } from '@kbn/alerting-plugin/server';
import { IBasePath, Logger } from '@kbn/core/server';
@ -52,7 +53,7 @@ export const MetricsRulesTypeAlertDefinition: IRuleTypeAlerts = {
};
export const searchConfigurationSchema = schema.object({
index: schema.string(),
index: schema.oneOf([schema.string(), dataViewSpecSchema]),
query: schema.object({
language: schema.string({
validate: validateKQLStringFilter,

View file

@ -18,8 +18,6 @@ import { FIRED_ACTIONS_ID, NO_DATA_ACTIONS_ID, FIRED_ACTION, NO_DATA_ACTION } fr
import { MissingGroupsRecord } from './lib/check_missing_group';
import { AdditionalContext } from './utils';
import { searchConfigurationSchema } from './register_custom_threshold_rule_type';
import { Aggregators, Comparator } from '../../../../common/custom_threshold_rule/types';
import { TimeUnitChar } from '../../../../common';
export enum AlertStates {
OK,
@ -75,27 +73,6 @@ type CustomThresholdAlert = Alert<
CustomThresholdSpecificActionGroups
>;
interface BaseMetricExpressionParams {
timeSize: number;
timeUnit: TimeUnitChar;
threshold: number[];
comparator: Comparator;
}
export interface NonCountMetricExpressionParams extends BaseMetricExpressionParams {
aggType: Exclude<Aggregators, [Aggregators.COUNT, Aggregators.CUSTOM]>;
metric: string;
}
export interface CountMetricExpressionParams extends BaseMetricExpressionParams {
aggType: Aggregators.COUNT;
}
export type CustomMetricAggTypes = Exclude<
Aggregators,
Aggregators.CUSTOM | Aggregators.RATE | Aggregators.P95 | Aggregators.P99
>;
export interface AlertExecutionDetails {
alertId: string;
executionId: string;

View file

@ -70,7 +70,6 @@
"@kbn/exploratory-view-plugin",
"@kbn/rison",
"@kbn/io-ts-utils",
"@kbn/ml-anomaly-utils",
"@kbn/observability-alert-details",
"@kbn/ui-actions-plugin",
"@kbn/field-types",

View file

@ -29102,8 +29102,6 @@
"xpack.observability.customThreshold.rule.alerts.dataTimeRangeLabel": "Dernière {lookback} {timeLabel}",
"xpack.observability.customThreshold.rule.alerts.dataTimeRangeLabelWithGrouping": "Dernières {lookback} {timeLabel} de données pour {id}",
"xpack.observability.customThreshold.rule.threshold.errorAlertReason": "Elasticsearch a échoué lors de l'interrogation des données pour {metric}",
"xpack.observability.customThreshold.rule.threshold.noDataAlertReason": "{metric} n'a signalé aucune donnée dans les dernières {interval} {group}",
"xpack.observability.customThreshold.rule.threshold.recoveredAlertReason": "{metric} est maintenant {comparator} un seuil de {threshold} (la valeur actuelle est {currentValue}) pour {group}",
"xpack.observability.customThreshold.rule.threshold.thresholdRange": "{a} et {b}",
"xpack.observability.customThreshold.rule.thresholdExtraTitle": "Alerte lorsque {comparator} {threshold}.",
"xpack.observability.enableAgentExplorerDescription": "{betaLabel} Active la vue d'explorateur d'agent.",
@ -29251,9 +29249,6 @@
"xpack.observability.customThreshold.rule.alertFlyout.aggregationText.count": "Compte du document",
"xpack.observability.customThreshold.rule.alertFlyout.aggregationText.max": "Max",
"xpack.observability.customThreshold.rule.alertFlyout.aggregationText.min": "Min",
"xpack.observability.customThreshold.rule.alertFlyout.aggregationText.p95": "95e centile",
"xpack.observability.customThreshold.rule.alertFlyout.aggregationText.p99": "99e centile",
"xpack.observability.customThreshold.rule.alertFlyout.aggregationText.rate": "Taux",
"xpack.observability.customThreshold.rule.alertFlyout.aggregationText.sum": "Somme",
"xpack.observability.customThreshold.rule.alertFlyout.alertDescription": "Alerter quand un type de données Observability atteint ou dépasse une valeur donnée.",
"xpack.observability.customThreshold.rule.alertFlyout.alertOnGroupDisappear": "Me prévenir si un groupe cesse de signaler les données",
@ -29274,12 +29269,10 @@
"xpack.observability.customThreshold.rule.alertFlyout.customEquationTooltip": "Ceci est compatible avec des calculs de base (A + B / C) et la logique booléenne (A < B ? A : B).",
"xpack.observability.customThreshold.rule.alertFlyout.dataViewError.noTimestamp": "La vue de données sélectionnée ne dispose pas de champ d'horodatage. Veuillez sélectionner une autre vue de données.",
"xpack.observability.customThreshold.rule.alertFlyout.defineTextQueryPrompt": "Définir le filtre de recherche (facultatif)",
"xpack.observability.customThreshold.rule.alertFlyout.docCountNoDataDisabledHelpText": "[Ce paramètre nest pas applicable à lagrégateur du nombre de documents.]",
"xpack.observability.customThreshold.rule.alertFlyout.error.aggregationRequired": "L'agrégation est requise.",
"xpack.observability.customThreshold.rule.alertFlyout.error.equation.invalidCharacters": "Le champ d'équation prend en charge uniquement les caractères suivants : A-Z, +, -, /, *, (, ), ?, !, &, :, |, >, <, =",
"xpack.observability.customThreshold.rule.alertFlyout.error.invalidFilterQuery": "La requête de filtre n'est pas valide.",
"xpack.observability.customThreshold.rule.alertFlyout.error.invalidSearchConfiguration": "La vue de données est requise.",
"xpack.observability.customThreshold.rule.alertFlyout.error.metricRequired": "L'indicateur est requis.",
"xpack.observability.customThreshold.rule.alertFlyout.error.metrics.aggTypeRequired": "L'agrégation est requise",
"xpack.observability.customThreshold.rule.alertFlyout.error.metrics.fieldRequired": "Le champ est obligatoire",
"xpack.observability.customThreshold.rule.alertFlyout.error.metricsError": "Vous devez définir au moins 1 indicateur personnalisé",

View file

@ -29101,8 +29101,6 @@
"xpack.observability.customThreshold.rule.alerts.dataTimeRangeLabel": "最後の{lookback} {timeLabel}",
"xpack.observability.customThreshold.rule.alerts.dataTimeRangeLabelWithGrouping": "{id}のデータの最後の{lookback} {timeLabel}",
"xpack.observability.customThreshold.rule.threshold.errorAlertReason": "{metric}のデータのクエリを試行しているときに、Elasticsearchが失敗しました",
"xpack.observability.customThreshold.rule.threshold.noDataAlertReason": "{metric}は最後の{interval}{group}でデータがないことを報告しました",
"xpack.observability.customThreshold.rule.threshold.recoveredAlertReason": "{metric} が {comparator} に、{group} が {threshold} のしきい値(現在の値は {currentValue})になりました",
"xpack.observability.customThreshold.rule.threshold.thresholdRange": "{a}および{b}",
"xpack.observability.customThreshold.rule.thresholdExtraTitle": "{comparator} {threshold}のときにアラートを通知",
"xpack.observability.enableAgentExplorerDescription": "{betaLabel}エージェントエクスプローラー表示を有効にします。",
@ -29250,9 +29248,6 @@
"xpack.observability.customThreshold.rule.alertFlyout.aggregationText.count": "ドキュメントカウント",
"xpack.observability.customThreshold.rule.alertFlyout.aggregationText.max": "最高",
"xpack.observability.customThreshold.rule.alertFlyout.aggregationText.min": "最低",
"xpack.observability.customThreshold.rule.alertFlyout.aggregationText.p95": "95パーセンタイル",
"xpack.observability.customThreshold.rule.alertFlyout.aggregationText.p99": "99パーセンタイル",
"xpack.observability.customThreshold.rule.alertFlyout.aggregationText.rate": "レート",
"xpack.observability.customThreshold.rule.alertFlyout.aggregationText.sum": "合計",
"xpack.observability.customThreshold.rule.alertFlyout.alertDescription": "オブザーバビリティデータタイプが特定の値以上になったときにアラートを送信します。",
"xpack.observability.customThreshold.rule.alertFlyout.alertOnGroupDisappear": "グループがデータのレポートを停止する場合にアラートで通知する",
@ -29273,12 +29268,10 @@
"xpack.observability.customThreshold.rule.alertFlyout.customEquationTooltip": "これは基本的な数学ロジックA + B / CとブールロジックA < B ?A :Bをサポートします。",
"xpack.observability.customThreshold.rule.alertFlyout.dataViewError.noTimestamp": "選択したデータビューにタイムスタンプフィールドがありません。他のデータビューを選択してください。",
"xpack.observability.customThreshold.rule.alertFlyout.defineTextQueryPrompt": "クエリフィルターを定義(任意)",
"xpack.observability.customThreshold.rule.alertFlyout.docCountNoDataDisabledHelpText": "[この設定は、ドキュメントカウントアグリゲーターには適用されません。]",
"xpack.observability.customThreshold.rule.alertFlyout.error.aggregationRequired": "集約が必要です。",
"xpack.observability.customThreshold.rule.alertFlyout.error.equation.invalidCharacters": "等式フィールドでは次の文字のみを使用できますA-Z、+、-、/、*、(、)、?、!、&、:、|、>、<、=",
"xpack.observability.customThreshold.rule.alertFlyout.error.invalidFilterQuery": "フィルタークエリは無効です。",
"xpack.observability.customThreshold.rule.alertFlyout.error.invalidSearchConfiguration": "データビューが必要です。",
"xpack.observability.customThreshold.rule.alertFlyout.error.metricRequired": "メトリックが必要です。",
"xpack.observability.customThreshold.rule.alertFlyout.error.metrics.aggTypeRequired": "集約が必要です",
"xpack.observability.customThreshold.rule.alertFlyout.error.metrics.fieldRequired": "フィールドが必要です",
"xpack.observability.customThreshold.rule.alertFlyout.error.metricsError": "1つ以上のカスタムメトリックを定義する必要があります",

View file

@ -29099,8 +29099,6 @@
"xpack.observability.customThreshold.rule.alerts.dataTimeRangeLabel": "过去 {lookback} {timeLabel}",
"xpack.observability.customThreshold.rule.alerts.dataTimeRangeLabelWithGrouping": "{id} 过去 {lookback} {timeLabel}的数据",
"xpack.observability.customThreshold.rule.threshold.errorAlertReason": "Elasticsearch 尝试查询 {metric} 的数据时出现故障",
"xpack.observability.customThreshold.rule.threshold.noDataAlertReason": "对于 {group}{metric} 在过去 {interval}中未报告数据",
"xpack.observability.customThreshold.rule.threshold.recoveredAlertReason": "对于 {group}{metric} 现在{comparator}阈值 {threshold}(当前值为 {currentValue}",
"xpack.observability.customThreshold.rule.threshold.thresholdRange": "{a} 和 {b}",
"xpack.observability.customThreshold.rule.thresholdExtraTitle": "{comparator} {threshold} 时告警",
"xpack.observability.enableAgentExplorerDescription": "{betaLabel} 启用代理浏览器视图。",
@ -29248,9 +29246,6 @@
"xpack.observability.customThreshold.rule.alertFlyout.aggregationText.count": "文档计数",
"xpack.observability.customThreshold.rule.alertFlyout.aggregationText.max": "最大值",
"xpack.observability.customThreshold.rule.alertFlyout.aggregationText.min": "最小值",
"xpack.observability.customThreshold.rule.alertFlyout.aggregationText.p95": "第 95 个百分位",
"xpack.observability.customThreshold.rule.alertFlyout.aggregationText.p99": "第 99 个百分位",
"xpack.observability.customThreshold.rule.alertFlyout.aggregationText.rate": "比率",
"xpack.observability.customThreshold.rule.alertFlyout.aggregationText.sum": "求和",
"xpack.observability.customThreshold.rule.alertFlyout.alertDescription": "任何 Observability 数据类型到达或超出给定值时告警。",
"xpack.observability.customThreshold.rule.alertFlyout.alertOnGroupDisappear": "组停止报告数据时提醒我",
@ -29271,12 +29266,10 @@
"xpack.observability.customThreshold.rule.alertFlyout.customEquationTooltip": "这支持基本数学 (A + B / C) 和布尔逻辑 (A < B ?A :B)。",
"xpack.observability.customThreshold.rule.alertFlyout.dataViewError.noTimestamp": "选定数据视图没有时间戳字段,请选择其他数据视图。",
"xpack.observability.customThreshold.rule.alertFlyout.defineTextQueryPrompt": "定义查询筛选(可选)",
"xpack.observability.customThreshold.rule.alertFlyout.docCountNoDataDisabledHelpText": "[此设置不适用于文档计数聚合器。]",
"xpack.observability.customThreshold.rule.alertFlyout.error.aggregationRequired": "“聚合”必填。",
"xpack.observability.customThreshold.rule.alertFlyout.error.equation.invalidCharacters": "方程字段仅支持以下字符A-Z、+、-、/、*、(、)、?、!、&、:、|、>、<、=",
"xpack.observability.customThreshold.rule.alertFlyout.error.invalidFilterQuery": "筛选查询无效。",
"xpack.observability.customThreshold.rule.alertFlyout.error.invalidSearchConfiguration": "需要数据视图。",
"xpack.observability.customThreshold.rule.alertFlyout.error.metricRequired": "“指标”必填。",
"xpack.observability.customThreshold.rule.alertFlyout.error.metrics.aggTypeRequired": "“聚合”必填",
"xpack.observability.customThreshold.rule.alertFlyout.error.metrics.fieldRequired": "“字段”必填",
"xpack.observability.customThreshold.rule.alertFlyout.error.metricsError": "必须至少定义 1 个定制指标",

View file

@ -115,6 +115,23 @@ describe('threshold expression', () => {
expect(onChangeSelectedThresholdComparator).toHaveBeenCalled();
});
it('renders threshold unit correctly', async () => {
const wrapper = mountWithIntl(
<ThresholdExpression
thresholdComparator={'>'}
threshold={[10]}
errors={{ threshold0: [], threshold1: [] }}
onChangeSelectedThreshold={jest.fn()}
onChangeSelectedThresholdComparator={jest.fn()}
unit="%"
/>
);
expect(wrapper.find('[data-test-subj="thresholdPopover"]').last().text()).toMatchInlineSnapshot(
`"Is above 10%"`
);
});
it('renders the correct number of threshold inputs', async () => {
const wrapper = mountWithIntl(
<ThresholdExpression

View file

@ -46,6 +46,7 @@ export interface ThresholdExpressionProps {
| 'rightUp'
| 'rightDown';
display?: 'fullWidth' | 'inline';
unit?: string;
}
export const ThresholdExpression = ({
@ -57,6 +58,7 @@ export const ThresholdExpression = ({
display = 'inline',
threshold = [],
popupPosition,
unit = '',
}: ThresholdExpressionProps) => {
const comparators = customComparators ?? builtInComparators;
const [alertThresholdPopoverOpen, setAlertThresholdPopoverOpen] = useState(false);
@ -88,7 +90,9 @@ export const ThresholdExpression = ({
<EuiExpression
data-test-subj="thresholdPopover"
description={comparators[comparator].text}
value={(threshold || []).slice(0, numRequiredThresholds).join(` ${andThresholdText} `)}
value={
(threshold || []).slice(0, numRequiredThresholds).join(` ${andThresholdText} `) + unit
}
isActive={Boolean(
alertThresholdPopoverOpen ||
(errors.threshold0 && errors.threshold0.length) ||

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { CUSTOM_AGGREGATOR } from '@kbn/observability-plugin/common/custom_threshold_rule/constants';
import moment from 'moment';
import { cleanup, generate } from '@kbn/infra-forge';
import {
@ -91,7 +92,7 @@ export default function ({ getService }: FtrProviderContext) {
params: {
criteria: [
{
aggType: Aggregators.CUSTOM,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.GT,
threshold: [0.5],
timeSize: 5,

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { CUSTOM_AGGREGATOR } from '@kbn/observability-plugin/common/custom_threshold_rule/constants';
import moment from 'moment';
import {
Aggregators,
@ -84,7 +85,7 @@ export default function ({ getService }: FtrProviderContext) {
params: {
criteria: [
{
aggType: Aggregators.CUSTOM,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.GT,
threshold: [0.5],
timeSize: 5,

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { CUSTOM_AGGREGATOR } from '@kbn/observability-plugin/common/custom_threshold_rule/constants';
import moment from 'moment';
import { ApmSynthtraceEsClient } from '@kbn/apm-synthtrace';
import { format } from 'url';
@ -97,7 +98,7 @@ export default function ({ getService }: FtrProviderContext) {
params: {
criteria: [
{
aggType: Aggregators.CUSTOM,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.GT,
threshold: [7500000],
timeSize: 5,

View file

@ -11,6 +11,7 @@
* 2.0.
*/
import { CUSTOM_AGGREGATOR } from '@kbn/observability-plugin/common/custom_threshold_rule/constants';
import moment from 'moment';
import { cleanup, generate } from '@kbn/infra-forge';
import {
@ -96,7 +97,7 @@ export default function ({ getService }: FtrProviderContext) {
params: {
criteria: [
{
aggType: Aggregators.CUSTOM,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.GT,
threshold: [0.9],
timeSize: 1,
@ -194,7 +195,7 @@ export default function ({ getService }: FtrProviderContext) {
.eql({
criteria: [
{
aggType: Aggregators.CUSTOM,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.GT,
threshold: [0.9],
timeSize: 1,

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { CUSTOM_AGGREGATOR } from '@kbn/observability-plugin/common/custom_threshold_rule/constants';
import moment from 'moment';
import { cleanup, generate } from '@kbn/infra-forge';
import {
@ -91,7 +92,7 @@ export default function ({ getService }: FtrProviderContext) {
params: {
criteria: [
{
aggType: Aggregators.CUSTOM,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.GT,
threshold: [2],
timeSize: 1,

View file

@ -7,6 +7,7 @@
import moment from 'moment';
import { cleanup, generate } from '@kbn/infra-forge';
import { CUSTOM_AGGREGATOR } from '@kbn/observability-plugin/common/custom_threshold_rule/constants';
import {
Aggregators,
Comparator,
@ -90,7 +91,7 @@ export default function ({ getService }: FtrProviderContext) {
params: {
criteria: [
{
aggType: Aggregators.CUSTOM,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.GT_OR_EQ,
threshold: [0.2],
timeSize: 1,

View file

@ -6,6 +6,7 @@
*/
import expect from '@kbn/expect';
import { CUSTOM_AGGREGATOR } from '@kbn/observability-plugin/common/custom_threshold_rule/constants';
import {
Aggregators,
Comparator,
@ -73,7 +74,7 @@ export default function ({ getService }: FtrProviderContext) {
params: {
criteria: [
{
aggType: Aggregators.CUSTOM,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.GT,
threshold: [7500000],
timeSize: 5,

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import { MetricThresholdParams } from '@kbn/infra-plugin/common/alerting/metrics';
import { ThresholdParams } from '@kbn/observability-plugin/common/custom_threshold_rule/types';
import type { SuperTest, Test } from 'supertest';
@ -32,7 +31,7 @@ export async function createIndexConnector({
return body.id as string;
}
export async function createRule({
export async function createRule<Params = ThresholdParams>({
supertest,
name,
ruleTypeId,
@ -45,7 +44,7 @@ export async function createRule({
supertest: SuperTest<Test>;
ruleTypeId: string;
name: string;
params: MetricThresholdParams | ThresholdParams;
params: Params;
actions?: any[];
tags?: any[];
schedule?: { interval: string };

View file

@ -8,7 +8,12 @@
import moment from 'moment';
import expect from '@kbn/expect';
import { cleanup, generate } from '@kbn/infra-forge';
import { Aggregators, Comparator, InfraRuleType } from '@kbn/infra-plugin/common/alerting/metrics';
import {
Aggregators,
Comparator,
InfraRuleType,
MetricThresholdParams,
} from '@kbn/infra-plugin/common/alerting/metrics';
import {
waitForDocumentInIndex,
waitForAlertInIndex,
@ -48,7 +53,7 @@ export default function ({ getService }: FtrProviderContext) {
name: 'Index Connector: Metric threshold API test',
indexName: ALERT_ACTION_INDEX,
});
const createdRule = await createRule({
const createdRule = await createRule<MetricThresholdParams>({
supertest,
ruleTypeId: InfraRuleType.MetricThreshold,
consumer: 'infrastructure',

View file

@ -6,6 +6,7 @@
*/
import { cleanup, generate } from '@kbn/infra-forge';
import { CUSTOM_AGGREGATOR } from '@kbn/observability-plugin/common/custom_threshold_rule/constants';
import {
Aggregators,
Comparator,
@ -88,7 +89,7 @@ export default function ({ getService }: FtrProviderContext) {
params: {
criteria: [
{
aggType: Aggregators.CUSTOM,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.GT,
threshold: [0.5],
timeSize: 5,

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { CUSTOM_AGGREGATOR } from '@kbn/observability-plugin/common/custom_threshold_rule/constants';
import {
Aggregators,
Comparator,
@ -74,7 +75,7 @@ export default function ({ getService }: FtrProviderContext) {
params: {
criteria: [
{
aggType: Aggregators.CUSTOM,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.GT,
threshold: [0.5],
timeSize: 5,

View file

@ -12,6 +12,7 @@
*/
import { cleanup, generate } from '@kbn/infra-forge';
import { CUSTOM_AGGREGATOR } from '@kbn/observability-plugin/common/custom_threshold_rule/constants';
import {
Aggregators,
Comparator,
@ -90,7 +91,7 @@ export default function ({ getService }: FtrProviderContext) {
params: {
criteria: [
{
aggType: Aggregators.CUSTOM,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.GT,
threshold: [0.9],
timeSize: 1,
@ -187,7 +188,7 @@ export default function ({ getService }: FtrProviderContext) {
.eql({
criteria: [
{
aggType: Aggregators.CUSTOM,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.GT,
threshold: [0.9],
timeSize: 1,

View file

@ -6,6 +6,7 @@
*/
import { cleanup, generate } from '@kbn/infra-forge';
import { CUSTOM_AGGREGATOR } from '@kbn/observability-plugin/common/custom_threshold_rule/constants';
import {
Aggregators,
Comparator,
@ -84,7 +85,7 @@ export default function ({ getService }: FtrProviderContext) {
params: {
criteria: [
{
aggType: Aggregators.CUSTOM,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.GT,
threshold: [2],
timeSize: 1,

View file

@ -11,6 +11,7 @@
* 2.0.
*/
import { CUSTOM_AGGREGATOR } from '@kbn/observability-plugin/common/custom_threshold_rule/constants';
import { kbnTestConfig } from '@kbn/test';
import moment from 'moment';
import { cleanup, generate } from '@kbn/infra-forge';
@ -94,7 +95,7 @@ export default function ({ getService }: FtrProviderContext) {
params: {
criteria: [
{
aggType: Aggregators.CUSTOM,
aggType: CUSTOM_AGGREGATOR,
comparator: Comparator.GT_OR_EQ,
threshold: [0.2],
timeSize: 1,