Skip to content

Commit bf2a181

Browse files
Add Suppression Anomaly Rules in Advanced Settings (opensearch-project#859) (opensearch-project#860)
This PR introduces suppression anomaly rules under the Advanced Settings section, enabling users to suppress anomalies based on the difference between expected and actual values, either as an absolute value or a relative percentage. Testing: * Added unit tests to verify the suppression rules functionality. * Conducted manual end-to-end (e2e) tests to validate the implementation. Signed-off-by: Kaituo Li <kaituo@amazon.com> (cherry picked from commit 9874c48) Co-authored-by: Kaituo Li <kaituo@amazon.com>
1 parent 0e7eb49 commit bf2a181

File tree

25 files changed

+3843
-864
lines changed

25 files changed

+3843
-864
lines changed

.github/workflows/remote-integ-tests-workflow.yml

+1-4
Original file line numberDiff line numberDiff line change
@@ -149,10 +149,7 @@ jobs:
149149

150150
- name: Run spec files from output
151151
run: |
152-
for i in $FILELIST; do
153-
yarn cypress:run-without-security --browser electron --spec "${i}"
154-
sleep 60
155-
done
152+
env CYPRESS_NO_COMMAND_LOG=1 yarn cypress:run-without-security --browser chromium --spec 'cypress/integration/plugins/anomaly-detection-dashboards-plugin/*'
156153
working-directory: opensearch-dashboards-functional-test
157154

158155
- name: Capture failure screenshots

public/models/interfaces.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ import { DETECTOR_STATE } from '../../server/utils/constants';
1515
import { Duration } from 'moment';
1616
import moment from 'moment';
1717
import { MDSQueryParams } from '../../server/models/types';
18-
import { ImputationOption } from './types';
18+
import {
19+
ImputationOption,
20+
Rule
21+
} from './types';
1922

2023
export type FieldInfo = {
2124
label: string;
@@ -211,6 +214,7 @@ export type Detector = {
211214
taskProgress?: number;
212215
taskError?: string;
213216
imputationOption?: ImputationOption;
217+
rules?: Rule[];
214218
};
215219

216220
export type DetectorListItem = {

public/models/types.ts

+84
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,87 @@ export enum ImputationMethod {
3333
PREVIOUS = 'PREVIOUS',
3434
}
3535

36+
// Constants for field names
37+
export const RULES_FIELD = "rules";
38+
export const ACTION_FIELD = "action";
39+
export const CONDITIONS_FIELD = "conditions";
40+
export const FEATURE_NAME_FIELD = "feature_name";
41+
export const THRESHOLD_TYPE_FIELD = "threshold_type";
42+
export const OPERATOR_FIELD = "operator";
43+
export const VALUE_FIELD = "value";
44+
45+
// Enums
46+
export enum Action {
47+
IGNORE_ANOMALY = "IGNORE_ANOMALY", // ignore anomaly if found
48+
}
49+
50+
export enum ThresholdType {
51+
/**
52+
* Specifies a threshold for ignoring anomalies where the actual value
53+
* exceeds the expected value by a certain margin.
54+
*
55+
* Assume a represents the actual value and b signifies the expected value.
56+
* IGNORE_SIMILAR_FROM_ABOVE implies the anomaly should be disregarded if a-b
57+
* is less than or equal to ignoreSimilarFromAbove.
58+
*/
59+
ACTUAL_OVER_EXPECTED_MARGIN = "ACTUAL_OVER_EXPECTED_MARGIN",
60+
61+
/**
62+
* Specifies a threshold for ignoring anomalies where the actual value
63+
* is below the expected value by a certain margin.
64+
*
65+
* Assume a represents the actual value and b signifies the expected value.
66+
* Likewise, IGNORE_SIMILAR_FROM_BELOW
67+
* implies the anomaly should be disregarded if b-a is less than or equal to
68+
* ignoreSimilarFromBelow.
69+
*/
70+
EXPECTED_OVER_ACTUAL_MARGIN = "EXPECTED_OVER_ACTUAL_MARGIN",
71+
72+
/**
73+
* Specifies a threshold for ignoring anomalies based on the ratio of
74+
* the difference to the actual value when the actual value exceeds
75+
* the expected value.
76+
*
77+
* Assume a represents the actual value and b signifies the expected value.
78+
* The variable IGNORE_NEAR_EXPECTED_FROM_ABOVE_BY_RATIO presumably implies the
79+
* anomaly should be disregarded if the ratio of the deviation from the actual
80+
* to the expected (a-b)/|a| is less than or equal to IGNORE_NEAR_EXPECTED_FROM_ABOVE_BY_RATIO.
81+
*/
82+
ACTUAL_OVER_EXPECTED_RATIO = "ACTUAL_OVER_EXPECTED_RATIO",
83+
84+
/**
85+
* Specifies a threshold for ignoring anomalies based on the ratio of
86+
* the difference to the actual value when the actual value is below
87+
* the expected value.
88+
*
89+
* Assume a represents the actual value and b signifies the expected value.
90+
* Likewise, IGNORE_NEAR_EXPECTED_FROM_BELOW_BY_RATIO appears to indicate that the anomaly
91+
* should be ignored if the ratio of the deviation from the expected to the actual
92+
* (b-a)/|a| is less than or equal to ignoreNearExpectedFromBelowByRatio.
93+
*/
94+
EXPECTED_OVER_ACTUAL_RATIO = "EXPECTED_OVER_ACTUAL_RATIO",
95+
}
96+
97+
// Method to get the description of ThresholdType
98+
export function getThresholdTypeDescription(thresholdType: ThresholdType): string {
99+
return thresholdType; // In TypeScript, the enum itself holds the description.
100+
}
101+
102+
// Enums for Operators
103+
export enum Operator {
104+
LTE = "LTE",
105+
}
106+
107+
// Interfaces for Rule and Condition
108+
export interface Rule {
109+
action: Action;
110+
conditions: Condition[];
111+
}
112+
113+
export interface Condition {
114+
featureName: string;
115+
thresholdType: ThresholdType;
116+
operator: Operator;
117+
value: number;
118+
}
119+

public/pages/ConfigureModel/components/AdvancedSettings/AdvancedSettings.tsx

+219-22
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,17 @@ import {
2020
EuiSelect,
2121
EuiButtonIcon,
2222
EuiFieldText,
23+
EuiToolTip,
2324
} from '@elastic/eui';
24-
import { Field, FieldProps, FieldArray, } from 'formik';
25+
import { Field, FieldProps, FieldArray } from 'formik';
2526
import React, { useEffect, useState } from 'react';
2627
import ContentPanel from '../../../../components/ContentPanel/ContentPanel';
2728
import { BASE_DOCS_LINK } from '../../../../utils/constants';
2829
import {
2930
isInvalid,
3031
getError,
3132
validatePositiveInteger,
33+
validatePositiveDecimal,
3234
} from '../../../../utils/utils';
3335
import { FormattedFormRow } from '../../../../components/FormattedFormRow/FormattedFormRow';
3436
import { SparseDataOptionValue } from '../../utils/constants';
@@ -47,6 +49,46 @@ export function AdvancedSettings(props: AdvancedSettingsProps) {
4749
{ value: SparseDataOptionValue.CUSTOM_VALUE, text: 'Custom value' },
4850
];
4951

52+
const aboveBelowOptions = [
53+
{ value: 'above', text: 'above' },
54+
{ value: 'below', text: 'below' },
55+
];
56+
57+
function extractArrayError(fieldName: string, form: any): string {
58+
const error = form.errors[fieldName];
59+
console.log('Error for field:', fieldName, error); // Log the error for debugging
60+
61+
// Check if the error is an array with objects inside
62+
if (Array.isArray(error) && error.length > 0) {
63+
// Iterate through the array to find the first non-empty error message
64+
for (const err of error) {
65+
if (typeof err === 'object' && err !== null) {
66+
const entry = Object.entries(err).find(
67+
([_, fieldError]) => fieldError
68+
); // Find the first entry with a non-empty error message
69+
if (entry) {
70+
const [fieldKey, fieldError] = entry;
71+
72+
// Replace fieldKey with a more user-friendly name if it matches specific fields
73+
const friendlyFieldName =
74+
fieldKey === 'absoluteThreshold'
75+
? 'absolute threshold'
76+
: fieldKey === 'relativeThreshold'
77+
? 'relative threshold'
78+
: fieldKey; // Use the original fieldKey if no match
79+
80+
return typeof fieldError === 'string'
81+
? `${friendlyFieldName} ${fieldError.toLowerCase()}` // Format the error message with the friendly field name
82+
: String(fieldError || '');
83+
}
84+
}
85+
}
86+
}
87+
88+
// Default case to handle other types of errors
89+
return typeof error === 'string' ? error : String(error || '');
90+
}
91+
5092
return (
5193
<ContentPanel
5294
title={
@@ -137,28 +179,29 @@ export function AdvancedSettings(props: AdvancedSettingsProps) {
137179
<EuiSelect {...field} options={sparseDataOptions}/>
138180
</FormattedFormRow>
139181

140-
{/* Conditionally render the "Custom value" title and the input fields when 'Custom value' is selected */}
141-
{field.value === SparseDataOptionValue.CUSTOM_VALUE && (
142-
<>
143-
<EuiSpacer size="m" />
144-
<EuiText size="xs">
145-
<h5>Custom value</h5>
146-
</EuiText>
147-
<EuiSpacer size="s" />
148-
<FieldArray name="imputationOption.custom_value">
149-
{(arrayHelpers) => (
150-
<>
151-
{form.values.imputationOption.custom_value?.map((_, index) => (
152-
<EuiFlexGroup
153-
key={index}
154-
gutterSize="s"
155-
alignItems="center"
156-
>
157-
<EuiFlexItem grow={false}>
158-
<Field
159-
name={`imputationOption.custom_value.${index}.featureName`}
160-
id={`imputationOption.custom_value.${index}.featureName`}
182+
{/* Conditionally render the "Custom value" title and the input fields when 'Custom value' is selected */}
183+
{field.value === SparseDataOptionValue.CUSTOM_VALUE && (
184+
<>
185+
<EuiSpacer size="m" />
186+
<EuiText size="xs">
187+
<h5>Custom value</h5>
188+
</EuiText>
189+
<EuiSpacer size="s" />
190+
<FieldArray name="imputationOption.custom_value">
191+
{(arrayHelpers) => (
192+
<>
193+
{form.values.imputationOption.custom_value?.map(
194+
(_, index) => (
195+
<EuiFlexGroup
196+
key={index}
197+
gutterSize="s"
198+
alignItems="center"
161199
>
200+
<EuiFlexItem grow={false}>
201+
<Field
202+
name={`imputationOption.custom_value.${index}.featureName`}
203+
id={`imputationOption.custom_value.${index}.featureName`}
204+
>
162205
{({ field }: FieldProps) => (
163206
<EuiFieldText
164207
placeholder="Feature name"
@@ -211,6 +254,160 @@ export function AdvancedSettings(props: AdvancedSettingsProps) {
211254
);
212255
}}
213256
</Field>
257+
258+
<EuiSpacer size="m" />
259+
<FieldArray name="suppressionRules">
260+
{(arrayHelpers) => (
261+
<>
262+
<Field name="suppressionRules">
263+
{({ field, form }: FieldProps) => (
264+
<>
265+
<EuiFlexGroup>
266+
{/* Controls the width of the whole row as FormattedFormRow does not allow that. Otherwise, our row is too packed. */}
267+
<EuiFlexItem
268+
grow={false}
269+
style={{ maxWidth: '1200px' }}
270+
>
271+
<FormattedFormRow
272+
title="Suppression Rules"
273+
hint={[
274+
`Set rules to ignore anomalies by comparing actual values against expected values.
275+
Anomalies can be ignored if the difference is within a specified absolute value or a relative percentage of the expected value.`,
276+
]}
277+
hintLink={`${BASE_DOCS_LINK}/ad`}
278+
isInvalid={isInvalid(field.name, form)}
279+
error={extractArrayError(field.name, form)}
280+
fullWidth
281+
>
282+
<>
283+
{form.values.suppressionRules?.map(
284+
(rule, index) => (
285+
<EuiFlexGroup
286+
key={index}
287+
gutterSize="s"
288+
alignItems="center"
289+
>
290+
<EuiFlexItem grow={false}>
291+
<EuiText size="s">
292+
Ignore anomalies for the feature
293+
</EuiText>
294+
</EuiFlexItem>
295+
<EuiFlexItem grow={2}>
296+
<Field
297+
name={`suppressionRules.${index}.featureName`}
298+
>
299+
{({ field }: FieldProps) => (
300+
<EuiFieldText
301+
placeholder="Feature name"
302+
{...field}
303+
fullWidth
304+
/>
305+
)}
306+
</Field>
307+
</EuiFlexItem>
308+
<EuiFlexItem grow={false}>
309+
<EuiText size="s">
310+
when the actual value is no more than
311+
</EuiText>
312+
</EuiFlexItem>
313+
<EuiFlexItem grow={1}>
314+
<EuiToolTip content="Absolute threshold value">
315+
<Field
316+
name={`suppressionRules.${index}.absoluteThreshold`}
317+
validate={validatePositiveDecimal}
318+
>
319+
{({ field }: FieldProps) => (
320+
<EuiFieldNumber
321+
placeholder="Absolute"
322+
{...field}
323+
value={field.value || ''}
324+
/>
325+
)}
326+
</Field>
327+
</EuiToolTip>
328+
</EuiFlexItem>
329+
<EuiFlexItem grow={false}>
330+
<EuiText size="s">or</EuiText>
331+
</EuiFlexItem>
332+
<EuiFlexItem grow={1}>
333+
<EuiToolTip content="Relative threshold value as a percentage">
334+
<Field
335+
name={`suppressionRules.${index}.relativeThreshold`}
336+
validate={validatePositiveDecimal}
337+
>
338+
{({ field }: FieldProps) => (
339+
<div
340+
style={{
341+
display: 'flex',
342+
alignItems: 'center',
343+
}}
344+
>
345+
<EuiFieldNumber
346+
placeholder="Relative"
347+
{...field}
348+
value={field.value || ''}
349+
/>
350+
<EuiText size="s">%</EuiText>
351+
</div>
352+
)}
353+
</Field>
354+
</EuiToolTip>
355+
</EuiFlexItem>
356+
<EuiFlexItem grow={1}>
357+
<EuiToolTip content="Select above or below expected value">
358+
<Field
359+
name={`suppressionRules.${index}.aboveBelow`}
360+
>
361+
{({ field }: FieldProps) => (
362+
<EuiSelect
363+
options={aboveBelowOptions}
364+
{...field}
365+
/>
366+
)}
367+
</Field>
368+
</EuiToolTip>
369+
</EuiFlexItem>
370+
<EuiFlexItem grow={false}>
371+
<EuiText size="s">
372+
the expected value.
373+
</EuiText>
374+
</EuiFlexItem>
375+
<EuiFlexItem grow={false}>
376+
<EuiButtonIcon
377+
iconType="trash"
378+
color="danger"
379+
aria-label="Delete rule"
380+
onClick={() =>
381+
arrayHelpers.remove(index)
382+
}
383+
/>
384+
</EuiFlexItem>
385+
</EuiFlexGroup>
386+
)
387+
)}
388+
</>
389+
</FormattedFormRow>
390+
</EuiFlexItem>
391+
</EuiFlexGroup>
392+
</>
393+
)}
394+
</Field>
395+
<EuiSpacer size="s" />
396+
<EuiButtonIcon
397+
iconType="plusInCircle"
398+
onClick={() =>
399+
arrayHelpers.push({
400+
fieldName: '',
401+
absoluteThreshold: null, // Set to null to allow empty inputs
402+
relativeThreshold: null, // Set to null to allow empty inputs
403+
aboveBelow: 'above',
404+
})
405+
}
406+
aria-label="Add rule"
407+
/>
408+
</>
409+
)}
410+
</FieldArray>
214411
</>
215412
) : null}
216413
</ContentPanel>

0 commit comments

Comments
 (0)