Skip to content

Commit

Permalink
Add Suppression Anomaly Rules in Advanced Settings (opensearch-projec…
Browse files Browse the repository at this point in the history
…t#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 <[email protected]>
(cherry picked from commit 9874c48)

Co-authored-by: Kaituo Li <[email protected]>
  • Loading branch information
opensearch-trigger-bot[bot] and kaituo committed Sep 10, 2024
1 parent 0e7eb49 commit bf2a181
Show file tree
Hide file tree
Showing 25 changed files with 3,843 additions and 864 deletions.
5 changes: 1 addition & 4 deletions .github/workflows/remote-integ-tests-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,7 @@ jobs:

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

- name: Capture failure screenshots
Expand Down
6 changes: 5 additions & 1 deletion public/models/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import { DETECTOR_STATE } from '../../server/utils/constants';
import { Duration } from 'moment';
import moment from 'moment';
import { MDSQueryParams } from '../../server/models/types';
import { ImputationOption } from './types';
import {
ImputationOption,
Rule
} from './types';

export type FieldInfo = {
label: string;
Expand Down Expand Up @@ -211,6 +214,7 @@ export type Detector = {
taskProgress?: number;
taskError?: string;
imputationOption?: ImputationOption;
rules?: Rule[];
};

export type DetectorListItem = {
Expand Down
84 changes: 84 additions & 0 deletions public/models/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,87 @@ export enum ImputationMethod {
PREVIOUS = 'PREVIOUS',
}

// Constants for field names
export const RULES_FIELD = "rules";
export const ACTION_FIELD = "action";
export const CONDITIONS_FIELD = "conditions";
export const FEATURE_NAME_FIELD = "feature_name";
export const THRESHOLD_TYPE_FIELD = "threshold_type";
export const OPERATOR_FIELD = "operator";
export const VALUE_FIELD = "value";

// Enums
export enum Action {
IGNORE_ANOMALY = "IGNORE_ANOMALY", // ignore anomaly if found
}

export enum ThresholdType {
/**
* Specifies a threshold for ignoring anomalies where the actual value
* exceeds the expected value by a certain margin.
*
* Assume a represents the actual value and b signifies the expected value.
* IGNORE_SIMILAR_FROM_ABOVE implies the anomaly should be disregarded if a-b
* is less than or equal to ignoreSimilarFromAbove.
*/
ACTUAL_OVER_EXPECTED_MARGIN = "ACTUAL_OVER_EXPECTED_MARGIN",

/**
* Specifies a threshold for ignoring anomalies where the actual value
* is below the expected value by a certain margin.
*
* Assume a represents the actual value and b signifies the expected value.
* Likewise, IGNORE_SIMILAR_FROM_BELOW
* implies the anomaly should be disregarded if b-a is less than or equal to
* ignoreSimilarFromBelow.
*/
EXPECTED_OVER_ACTUAL_MARGIN = "EXPECTED_OVER_ACTUAL_MARGIN",

/**
* Specifies a threshold for ignoring anomalies based on the ratio of
* the difference to the actual value when the actual value exceeds
* the expected value.
*
* Assume a represents the actual value and b signifies the expected value.
* The variable IGNORE_NEAR_EXPECTED_FROM_ABOVE_BY_RATIO presumably implies the
* anomaly should be disregarded if the ratio of the deviation from the actual
* to the expected (a-b)/|a| is less than or equal to IGNORE_NEAR_EXPECTED_FROM_ABOVE_BY_RATIO.
*/
ACTUAL_OVER_EXPECTED_RATIO = "ACTUAL_OVER_EXPECTED_RATIO",

/**
* Specifies a threshold for ignoring anomalies based on the ratio of
* the difference to the actual value when the actual value is below
* the expected value.
*
* Assume a represents the actual value and b signifies the expected value.
* Likewise, IGNORE_NEAR_EXPECTED_FROM_BELOW_BY_RATIO appears to indicate that the anomaly
* should be ignored if the ratio of the deviation from the expected to the actual
* (b-a)/|a| is less than or equal to ignoreNearExpectedFromBelowByRatio.
*/
EXPECTED_OVER_ACTUAL_RATIO = "EXPECTED_OVER_ACTUAL_RATIO",
}

// Method to get the description of ThresholdType
export function getThresholdTypeDescription(thresholdType: ThresholdType): string {
return thresholdType; // In TypeScript, the enum itself holds the description.
}

// Enums for Operators
export enum Operator {
LTE = "LTE",
}

// Interfaces for Rule and Condition
export interface Rule {
action: Action;
conditions: Condition[];
}

export interface Condition {
featureName: string;
thresholdType: ThresholdType;
operator: Operator;
value: number;
}

Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,17 @@ import {
EuiSelect,
EuiButtonIcon,
EuiFieldText,
EuiToolTip,
} from '@elastic/eui';
import { Field, FieldProps, FieldArray, } from 'formik';
import { Field, FieldProps, FieldArray } from 'formik';
import React, { useEffect, useState } from 'react';
import ContentPanel from '../../../../components/ContentPanel/ContentPanel';
import { BASE_DOCS_LINK } from '../../../../utils/constants';
import {
isInvalid,
getError,
validatePositiveInteger,
validatePositiveDecimal,
} from '../../../../utils/utils';
import { FormattedFormRow } from '../../../../components/FormattedFormRow/FormattedFormRow';
import { SparseDataOptionValue } from '../../utils/constants';
Expand All @@ -47,6 +49,46 @@ export function AdvancedSettings(props: AdvancedSettingsProps) {
{ value: SparseDataOptionValue.CUSTOM_VALUE, text: 'Custom value' },
];

const aboveBelowOptions = [
{ value: 'above', text: 'above' },
{ value: 'below', text: 'below' },
];

function extractArrayError(fieldName: string, form: any): string {
const error = form.errors[fieldName];
console.log('Error for field:', fieldName, error); // Log the error for debugging

// Check if the error is an array with objects inside
if (Array.isArray(error) && error.length > 0) {
// Iterate through the array to find the first non-empty error message
for (const err of error) {
if (typeof err === 'object' && err !== null) {
const entry = Object.entries(err).find(
([_, fieldError]) => fieldError
); // Find the first entry with a non-empty error message
if (entry) {
const [fieldKey, fieldError] = entry;

// Replace fieldKey with a more user-friendly name if it matches specific fields
const friendlyFieldName =
fieldKey === 'absoluteThreshold'
? 'absolute threshold'
: fieldKey === 'relativeThreshold'
? 'relative threshold'
: fieldKey; // Use the original fieldKey if no match

return typeof fieldError === 'string'
? `${friendlyFieldName} ${fieldError.toLowerCase()}` // Format the error message with the friendly field name
: String(fieldError || '');
}
}
}
}

// Default case to handle other types of errors
return typeof error === 'string' ? error : String(error || '');
}

return (
<ContentPanel
title={
Expand Down Expand Up @@ -137,28 +179,29 @@ export function AdvancedSettings(props: AdvancedSettingsProps) {
<EuiSelect {...field} options={sparseDataOptions}/>
</FormattedFormRow>

{/* Conditionally render the "Custom value" title and the input fields when 'Custom value' is selected */}
{field.value === SparseDataOptionValue.CUSTOM_VALUE && (
<>
<EuiSpacer size="m" />
<EuiText size="xs">
<h5>Custom value</h5>
</EuiText>
<EuiSpacer size="s" />
<FieldArray name="imputationOption.custom_value">
{(arrayHelpers) => (
<>
{form.values.imputationOption.custom_value?.map((_, index) => (
<EuiFlexGroup
key={index}
gutterSize="s"
alignItems="center"
>
<EuiFlexItem grow={false}>
<Field
name={`imputationOption.custom_value.${index}.featureName`}
id={`imputationOption.custom_value.${index}.featureName`}
{/* Conditionally render the "Custom value" title and the input fields when 'Custom value' is selected */}
{field.value === SparseDataOptionValue.CUSTOM_VALUE && (
<>
<EuiSpacer size="m" />
<EuiText size="xs">
<h5>Custom value</h5>
</EuiText>
<EuiSpacer size="s" />
<FieldArray name="imputationOption.custom_value">
{(arrayHelpers) => (
<>
{form.values.imputationOption.custom_value?.map(
(_, index) => (
<EuiFlexGroup
key={index}
gutterSize="s"
alignItems="center"
>
<EuiFlexItem grow={false}>
<Field
name={`imputationOption.custom_value.${index}.featureName`}
id={`imputationOption.custom_value.${index}.featureName`}
>
{({ field }: FieldProps) => (
<EuiFieldText
placeholder="Feature name"
Expand Down Expand Up @@ -211,6 +254,160 @@ export function AdvancedSettings(props: AdvancedSettingsProps) {
);
}}
</Field>

<EuiSpacer size="m" />
<FieldArray name="suppressionRules">
{(arrayHelpers) => (
<>
<Field name="suppressionRules">
{({ field, form }: FieldProps) => (
<>
<EuiFlexGroup>
{/* Controls the width of the whole row as FormattedFormRow does not allow that. Otherwise, our row is too packed. */}
<EuiFlexItem
grow={false}
style={{ maxWidth: '1200px' }}
>
<FormattedFormRow
title="Suppression Rules"
hint={[
`Set rules to ignore anomalies by comparing actual values against expected values.
Anomalies can be ignored if the difference is within a specified absolute value or a relative percentage of the expected value.`,
]}
hintLink={`${BASE_DOCS_LINK}/ad`}
isInvalid={isInvalid(field.name, form)}
error={extractArrayError(field.name, form)}
fullWidth
>
<>
{form.values.suppressionRules?.map(
(rule, index) => (
<EuiFlexGroup
key={index}
gutterSize="s"
alignItems="center"
>
<EuiFlexItem grow={false}>
<EuiText size="s">
Ignore anomalies for the feature
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={2}>
<Field
name={`suppressionRules.${index}.featureName`}
>
{({ field }: FieldProps) => (
<EuiFieldText
placeholder="Feature name"
{...field}
fullWidth
/>
)}
</Field>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s">
when the actual value is no more than
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={1}>
<EuiToolTip content="Absolute threshold value">
<Field
name={`suppressionRules.${index}.absoluteThreshold`}
validate={validatePositiveDecimal}
>
{({ field }: FieldProps) => (
<EuiFieldNumber
placeholder="Absolute"
{...field}
value={field.value || ''}
/>
)}
</Field>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s">or</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={1}>
<EuiToolTip content="Relative threshold value as a percentage">
<Field
name={`suppressionRules.${index}.relativeThreshold`}
validate={validatePositiveDecimal}
>
{({ field }: FieldProps) => (
<div
style={{
display: 'flex',
alignItems: 'center',
}}
>
<EuiFieldNumber
placeholder="Relative"
{...field}
value={field.value || ''}
/>
<EuiText size="s">%</EuiText>
</div>
)}
</Field>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={1}>
<EuiToolTip content="Select above or below expected value">
<Field
name={`suppressionRules.${index}.aboveBelow`}
>
{({ field }: FieldProps) => (
<EuiSelect
options={aboveBelowOptions}
{...field}
/>
)}
</Field>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s">
the expected value.
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="trash"
color="danger"
aria-label="Delete rule"
onClick={() =>
arrayHelpers.remove(index)
}
/>
</EuiFlexItem>
</EuiFlexGroup>
)
)}
</>
</FormattedFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</>
)}
</Field>
<EuiSpacer size="s" />
<EuiButtonIcon
iconType="plusInCircle"
onClick={() =>
arrayHelpers.push({
fieldName: '',
absoluteThreshold: null, // Set to null to allow empty inputs
relativeThreshold: null, // Set to null to allow empty inputs
aboveBelow: 'above',
})
}
aria-label="Add rule"
/>
</>
)}
</FieldArray>
</>
) : null}
</ContentPanel>
Expand Down
Loading

0 comments on commit bf2a181

Please sign in to comment.