Compare commits

...

1 Commits

Author SHA1 Message Date
srikanthccv
90512748fb chore: add new alert experience support for anomaly alerts 2026-07-11 23:08:23 +05:30
19 changed files with 444 additions and 133 deletions

View File

@@ -7,11 +7,13 @@ import { AlertTypes } from 'types/api/alerts/alertTypes';
import { ALERT_TYPE_URL_MAP } from './constants';
// The setup-guide button only exists in the classic form, which is reachable
// via the unadvertised showClassicCreateAlertsPage param.
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string; search: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALERTS_NEW}`,
search: 'ruleType=anomaly_rule',
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
}),
}));
@@ -20,7 +22,7 @@ jest.mock('react-router-dom-v5-compat', () => ({
useNavigationType: jest.fn(() => 'PUSH'),
useLocation: jest.fn(() => ({
pathname: '/alerts/new',
search: 'ruleType=anomaly_rule',
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
hash: '',
state: null,
})),

View File

@@ -152,7 +152,7 @@ describe('CreateAlertRule', () => {
expect(screen.getByText(AlertTypes.METRICS_BASED_ALERT)).toBeInTheDocument();
});
it('should render classic flow when ruleType is anomaly_rule even if showClassicCreateAlertsPage is not true', () => {
it('should render new flow when ruleType is anomaly_rule', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.showClassicCreateAlertsPage) {
return 'false';
@@ -163,8 +163,8 @@ describe('CreateAlertRule', () => {
return null;
});
render(<CreateAlertRule />);
expect(screen.getByText(FORM_ALERT_RULES_TEXT)).toBeInTheDocument();
expect(screen.queryByText(CREATE_ALERT_V2_TEXT)).not.toBeInTheDocument();
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
});
it('should use alertType from URL when provided', () => {

View File

@@ -100,10 +100,9 @@ function CreateRules(): JSX.Element {
return <SelectAlertType onSelect={handleSelectType} />;
}
if (
showClassicCreateAlertsPageFlag ||
alertType === AlertTypes.ANOMALY_BASED_ALERT
) {
// The classic experience is no longer offered in the UI; the query param
// is kept as an unadvertised escape hatch until the flow is removed.
if (showClassicCreateAlertsPageFlag) {
return (
<FormAlertRules
alertType={alertType}

View File

@@ -2,7 +2,9 @@ import { useQuery } from 'react-query';
import { Button, Tooltip } from 'antd';
import getAllChannels from 'api/channels/getAll';
import classNames from 'classnames';
import { ChartLine } from '@signozhq/icons';
import { FeatureKeys } from 'constants/features';
import { Activity, ChartLine } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { SuccessResponseV2 } from 'types/api';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Channels } from 'types/api/channels/getAll';
@@ -19,6 +21,7 @@ import './styles.scss';
function AlertCondition(): JSX.Element {
const { alertType, setAlertType } = useCreateAlertState();
const { featureFlags } = useAppContext();
const {
data,
@@ -30,9 +33,15 @@ function AlertCondition(): JSX.Element {
});
const channels = data?.data || [];
const isAnomalyDetectionEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.ANOMALY_DETECTION)
?.active || false;
// Anomaly alerts always show both tabs so existing rules stay editable;
// metric alerts only offer the anomaly tab when the feature is enabled.
const showMultipleTabs =
alertType === AlertTypes.ANOMALY_BASED_ALERT ||
alertType === AlertTypes.METRICS_BASED_ALERT;
(isAnomalyDetectionEnabled && alertType === AlertTypes.METRICS_BASED_ALERT);
const tabs = [
{
@@ -40,16 +49,15 @@ function AlertCondition(): JSX.Element {
icon: <ChartLine size={14} data-testid="threshold-view" />,
value: AlertTypes.METRICS_BASED_ALERT,
},
// Hide anomaly tab for now
// ...(showMultipleTabs
// ? [
// {
// label: 'Anomaly',
// icon: <Activity size={14} data-testid="anomaly-view" />,
// value: AlertTypes.ANOMALY_BASED_ALERT,
// },
// ]
// : []),
...(showMultipleTabs
? [
{
label: 'Anomaly',
icon: <Activity size={14} data-testid="anomaly-view" />,
value: AlertTypes.ANOMALY_BASED_ALERT,
},
]
: []),
];
const handleAlertTypeChange = (value: AlertTypes): void => {

View File

@@ -188,7 +188,7 @@ function AnomalyThreshold({
}}
options={ANOMALY_SEASONALITY_OPTIONS}
/>
{notificationSettings.routingPolicies ? (
{!notificationSettings.routingPolicies ? (
<>
<Typography.Text
data-testid="seasonality-text"

View File

@@ -1,7 +1,11 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { FeatureKeys } from 'constants/features';
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
import * as appHooks from 'providers/App/App';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { FeatureFlagProps } from 'types/api/features/getFeaturesFlags';
import { CreateAlertProvider } from '../../context';
import AlertCondition from '../AlertCondition';
@@ -96,6 +100,23 @@ const createTestQueryClient = (): QueryClient =>
},
});
const ANOMALY_DETECTION_FLAG: FeatureFlagProps = {
name: FeatureKeys.ANOMALY_DETECTION,
active: true,
usage: 0,
usage_limit: -1,
route: '',
};
const useAppContextSpy = jest.spyOn(appHooks, 'useAppContext');
const mockAppContext = (isAnomalyDetectionEnabled: boolean): void => {
useAppContextSpy.mockReturnValue({
...getAppContextMockState(),
featureFlags: isAnomalyDetectionEnabled ? [ANOMALY_DETECTION_FLAG] : [],
});
};
const renderAlertCondition = (
alertType?: string,
): ReturnType<typeof render> => {
@@ -113,6 +134,10 @@ const renderAlertCondition = (
};
describe('AlertCondition', () => {
beforeEach(() => {
mockAppContext(true);
});
it('renders the stepper with correct step number and label', () => {
renderAlertCondition();
expect(screen.getByTestId(STEPPER_TEST_ID)).toHaveTextContent(
@@ -125,10 +150,9 @@ describe('AlertCondition', () => {
// Verify default alertType is METRICS_BASED_ALERT (shows AlertThreshold component)
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
// TODO: uncomment this when anomaly tab is implemented
// expect(
// screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
// ).not.toBeInTheDocument();
expect(
screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
).not.toBeInTheDocument();
// Verify threshold tab is active by default
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
@@ -136,8 +160,7 @@ describe('AlertCondition', () => {
// Verify both tabs are visible (METRICS_BASED_ALERT supports multiple tabs)
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
// TODO: uncomment this when anomaly tab is implemented
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
});
it('renders threshold tab by default', () => {
@@ -152,13 +175,27 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
// TODO: Unskip this when anomaly tab is implemented
it.skip('renders anomaly tab when alert type supports multiple tabs', () => {
it('renders anomaly tab when alert type supports multiple tabs', () => {
renderAlertCondition();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('does not offer the anomaly tab when anomaly detection is disabled', () => {
mockAppContext(false);
renderAlertCondition();
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.queryByText(ANOMALY_TAB_TEXT)).not.toBeInTheDocument();
});
it('shows both tabs for anomaly alerts even when anomaly detection is disabled', () => {
mockAppContext(false);
renderAlertCondition('ANOMALY_BASED_ALERT');
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_THRESHOLD_TEST_ID)).toBeInTheDocument();
});
it('shows AlertThreshold component when alert type is not anomaly based', () => {
renderAlertCondition();
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
@@ -167,8 +204,7 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
// TODO: Unskip this when anomaly tab is implemented
it.skip('shows AnomalyThreshold component when alert type is anomaly based', () => {
it('shows AnomalyThreshold component when alert type is anomaly based', () => {
renderAlertCondition();
// Click on anomaly tab to switch to anomaly-based alert
@@ -179,8 +215,7 @@ describe('AlertCondition', () => {
expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument();
});
// TODO: Unskip this when anomaly tab is implemented
it.skip('switches between threshold and anomaly tabs', () => {
it('switches between threshold and anomaly tabs', () => {
renderAlertCondition();
// Initially shows threshold component
@@ -205,8 +240,7 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
// TODO: Unskip this when anomaly tab is implemented
it.skip('applies active tab styling correctly', () => {
it('applies active tab styling correctly', () => {
renderAlertCondition();
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
@@ -227,11 +261,10 @@ describe('AlertCondition', () => {
it('shows multiple tabs for METRICS_BASED_ALERT', () => {
renderAlertCondition('METRIC_BASED_ALERT');
// TODO: uncomment this when anomaly tab is implemented
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('shows multiple tabs for ANOMALY_BASED_ALERT', () => {
@@ -239,9 +272,8 @@ describe('AlertCondition', () => {
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
// TODO: uncomment this when anomaly tab is implemented
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('shows only threshold tab for LOGS_BASED_ALERT', () => {

View File

@@ -1,14 +1,7 @@
import { useCallback, useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import logEvent from 'api/common/logEvent';
import classNames from 'classnames';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { RotateCcw } from '@signozhq/icons';
import { useAlertRuleOptional } from 'providers/Alert';
import { Labels } from 'types/api/alerts/def';
@@ -22,8 +15,6 @@ function CreateAlertHeader(): JSX.Element {
const alertRuleContext = useAlertRuleOptional();
const { currentQuery } = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
const groupByLabels = useMemo(() => {
const labels = new Array<string>();
@@ -46,14 +37,6 @@ function CreateAlertHeader(): JSX.Element {
[groupByLabels],
);
const handleSwitchToClassicExperience = useCallback(() => {
logEvent('Alert: Switch to classic experience button clicked', {});
urlQuery.set(QueryParams.showClassicCreateAlertsPage, 'true');
const url = `${ROUTES.ALERTS_NEW}?${urlQuery.toString()}`;
safeNavigate(url, { replace: true });
}, [safeNavigate, urlQuery]);
return (
<div
className={classNames('alert-header', { 'edit-alert-header': isEditMode })}
@@ -61,15 +44,6 @@ function CreateAlertHeader(): JSX.Element {
{!isEditMode && (
<div className="alert-header__tab-bar">
<div className="alert-header__tab">New Alert Rule</div>
<Button
prefix={<RotateCcw size={12} />}
onClick={handleSwitchToClassicExperience}
variant="solid"
color="secondary"
size="sm"
>
Switch to Classic Experience
</Button>
</div>
)}
<div className="alert-header__content">

View File

@@ -1,20 +1,12 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { defaultPostableAlertRuleV2 } from 'container/CreateAlertV2/constants';
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
import * as useSafeNavigateHook from 'hooks/useSafeNavigate';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import * as rulesHook from '../../../../api/generated/services/rules';
import { CreateAlertProvider } from '../../context';
import CreateAlertHeader from '../CreateAlertHeader';
const mockSafeNavigate = jest.fn();
jest.spyOn(useSafeNavigateHook, 'useSafeNavigate').mockReturnValue({
safeNavigate: mockSafeNavigate,
});
jest.spyOn(rulesHook, 'useCreateRule').mockReturnValue({
mutate: jest.fn(),
isLoading: false,
@@ -106,34 +98,8 @@ describe('CreateAlertHeader', () => {
).toHaveValue('TEST_ALERT');
});
it('should navigate to classic experience when button is clicked', () => {
it('should not render "switch to classic experience" button', () => {
renderCreateAlertHeader();
const switchToClassicExperienceButton = screen.getByText(
'Switch to Classic Experience',
);
expect(switchToClassicExperienceButton).toBeInTheDocument();
fireEvent.click(switchToClassicExperienceButton);
const params = new URLSearchParams();
params.set(QueryParams.showClassicCreateAlertsPage, 'true');
expect(mockSafeNavigate).toHaveBeenCalledWith(
`${ROUTES.ALERTS_NEW}?${params.toString()}`,
{ replace: true },
);
});
it('should not render "switch to classic experience" button when isEditMode is true', () => {
render(
<CreateAlertProvider
isEditMode
initialAlertType={AlertTypes.METRICS_BASED_ALERT}
initialAlertState={getCreateAlertLocalStateFromAlertDef(
defaultPostableAlertRuleV2,
)}
>
<CreateAlertHeader />
</CreateAlertProvider>,
);
expect(
screen.queryByText('Switch to Classic Experience'),
).not.toBeInTheDocument();

View File

@@ -14,10 +14,7 @@ import APIError from 'types/api/error';
import { isModifierKeyPressed } from 'utils/app';
import { useCreateAlertState } from '../context';
import {
buildCreateThresholdAlertRulePayload,
validateCreateAlertState,
} from './utils';
import { buildCreateAlertRulePayload, validateCreateAlertState } from './utils';
import './styles.scss';
import {
@@ -85,7 +82,7 @@ function Footer(): JSX.Element {
);
const handleTestNotification = useCallback((): void => {
const payload = buildCreateThresholdAlertRulePayload({
const payload = buildCreateAlertRulePayload({
alertType,
basicAlertState,
thresholdState,
@@ -122,7 +119,7 @@ function Footer(): JSX.Element {
const queryClient = useQueryClient();
const handleSaveAlert = useCallback((): void => {
const payload = buildCreateThresholdAlertRulePayload({
const payload = buildCreateAlertRulePayload({
alertType,
basicAlertState,
thresholdState,

View File

@@ -18,6 +18,8 @@ import { EQueryType } from 'types/common/dashboard';
import { BuildCreateAlertRulePayloadArgs } from '../types';
import {
buildCreateAlertRulePayload,
buildCreateAnomalyAlertRulePayload,
buildCreateThresholdAlertRulePayload,
getAlertOnAbsentProps,
getEnforceMinimumDatapointsProps,
@@ -550,4 +552,115 @@ describe('Footer utils', () => {
},
);
});
describe('buildCreateAnomalyAlertRulePayload', () => {
const mockCreateAlertContextState = createMockAlertContextState();
const ANOMALY_PAYLOAD_ARGS: BuildCreateAlertRulePayloadArgs = {
basicAlertState: mockCreateAlertContextState.alertState,
thresholdState: {
...mockCreateAlertContextState.thresholdState,
thresholds: [
{
...mockCreateAlertContextState.thresholdState.thresholds[0],
thresholdValue: 3,
},
],
},
advancedOptions: mockCreateAlertContextState.advancedOptions,
evaluationWindow: mockCreateAlertContextState.evaluationWindow,
notificationSettings: mockCreateAlertContextState.notificationSettings,
query: initialQueriesMap.metrics,
alertType: AlertTypes.ANOMALY_BASED_ALERT,
};
it('builds a v2alpha1 anomaly rule payload', () => {
const props = buildCreateAnomalyAlertRulePayload(ANOMALY_PAYLOAD_ARGS);
expect(props.ruleType).toBe('anomaly_rule');
expect(props.schemaVersion).toBe('v2alpha1');
expect(props.version).toBe('v5');
// The stored alertType is metric based; anomaly is a rule type
expect(props.alertType).toBe('METRIC_BASED_ALERT');
expect(props.condition.algorithm).toBe(
ANOMALY_PAYLOAD_ARGS.thresholdState.algorithm,
);
expect(props.condition.seasonality).toBe(
ANOMALY_PAYLOAD_ARGS.thresholdState.seasonality,
);
expect(props.condition.selectedQueryName).toBe(
ANOMALY_PAYLOAD_ARGS.thresholdState.selectedQuery,
);
// Evaluation comes from the anomaly condition's own window
expect(props.evaluation).toStrictEqual({
kind: 'rolling',
spec: {
evalWindow: ANOMALY_PAYLOAD_ARGS.thresholdState.evaluationWindow,
frequency: '1m',
},
});
});
it('keeps the target positive for the above operator', () => {
const props = buildCreateAnomalyAlertRulePayload({
...ANOMALY_PAYLOAD_ARGS,
thresholdState: {
...ANOMALY_PAYLOAD_ARGS.thresholdState,
operator: 'above',
},
});
expect(props.condition.thresholds?.spec[0].target).toBe(3);
expect(props.condition.thresholds?.spec[0].op).toBe('above');
});
it.each([['below'], ['2']])(
'negates the target for the below operator (%s)',
(op) => {
const props = buildCreateAnomalyAlertRulePayload({
...ANOMALY_PAYLOAD_ARGS,
thresholdState: {
...ANOMALY_PAYLOAD_ARGS.thresholdState,
operator: op,
},
});
expect(props.condition.thresholds?.spec[0].target).toBe(-3);
expect(props.condition.thresholds?.spec[0].op).toBe(op);
},
);
it('keeps the target positive for the outside_bounds operator', () => {
const props = buildCreateAnomalyAlertRulePayload({
...ANOMALY_PAYLOAD_ARGS,
thresholdState: {
...ANOMALY_PAYLOAD_ARGS.thresholdState,
operator: 'outside_bounds',
},
});
expect(props.condition.thresholds?.spec[0].target).toBe(3);
});
});
describe('buildCreateAlertRulePayload', () => {
const mockCreateAlertContextState = createMockAlertContextState();
const args: BuildCreateAlertRulePayloadArgs = {
basicAlertState: mockCreateAlertContextState.alertState,
thresholdState: mockCreateAlertContextState.thresholdState,
advancedOptions: mockCreateAlertContextState.advancedOptions,
evaluationWindow: mockCreateAlertContextState.evaluationWindow,
notificationSettings: mockCreateAlertContextState.notificationSettings,
query: initialQueriesMap.metrics,
alertType: mockCreateAlertContextState.alertType,
};
it('builds an anomaly payload for anomaly based alerts', () => {
const props = buildCreateAlertRulePayload({
...args,
alertType: AlertTypes.ANOMALY_BASED_ALERT,
});
expect(props.ruleType).toBe('anomaly_rule');
});
it('builds a threshold payload for other alert types', () => {
const props = buildCreateAlertRulePayload(args);
expect(props.ruleType).toBe('threshold_rule');
});
});
});

View File

@@ -2,6 +2,7 @@ import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import {
BasicThreshold,
PostableAlertRuleV2,
@@ -11,9 +12,11 @@ import { compositeQueryToQueryEnvelope } from 'utils/compositeQueryToQueryEnvelo
import {
AdvancedOptionsState,
AlertThresholdOperator,
EvaluationWindowState,
NotificationSettingsState,
} from '../context/types';
import { normalizeOperator } from '../utils';
import { BuildCreateAlertRulePayloadArgs } from './types';
// Get formatted time/unit pairs for create alert api payload
@@ -288,16 +291,15 @@ export function buildCreateThresholdAlertRulePayload(
}
// Build Create Anomaly Alert Rule Payload
// TODO: Update this function before enabling anomaly alert rule creation
export function buildCreateAnomalyAlertRulePayload(
args: BuildCreateAlertRulePayloadArgs,
): PostableAlertRuleV2 {
const {
alertType,
basicAlertState,
thresholdState,
query,
notificationSettings,
evaluationWindow,
advancedOptions,
} = args;
@@ -313,19 +315,55 @@ export function buildCreateAnomalyAlertRulePayload(
unit: basicAlertState.yAxisUnit,
});
// v2alpha1 thresholds are literal: "3 deviations below the predicted data"
// means the anomaly z-score must drop under -3, so the target is negated
// for the below operator (the deviations input is always positive).
const isBelowOperator =
normalizeOperator(thresholdState.operator) ===
AlertThresholdOperator.IS_BELOW;
const thresholds: BasicThreshold[] = thresholdState.thresholds.map(
(threshold) => {
const deviations = Math.abs(parseFloat(threshold.thresholdValue.toString()));
return {
name: threshold.label,
target: isBelowOperator ? -deviations : deviations,
matchType: thresholdState.matchType,
op: thresholdState.operator,
channels: threshold.channels,
targetUnit: threshold.unit,
};
},
);
const alertOnAbsentProps = getAlertOnAbsentProps(advancedOptions);
const enforceMinimumDatapointsProps =
getEnforceMinimumDatapointsProps(advancedOptions);
const evaluationProps = getEvaluationProps(evaluationWindow, advancedOptions);
const notificationSettingsProps =
getNotificationSettingsProps(notificationSettings);
// The anomaly condition carries its own evaluation window
// ("during the last X"), so the evaluation is always a rolling window.
const frequency = getFormattedTimeValue(
advancedOptions.evaluationCadence.default.value,
advancedOptions.evaluationCadence.default.timeUnit,
);
return {
alert: basicAlertState.name,
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
alertType,
alertType:
alertType === AlertTypes.ANOMALY_BASED_ALERT
? AlertTypes.METRICS_BASED_ALERT
: alertType,
condition: {
thresholds: {
kind: 'basic',
spec: thresholds,
},
compositeQuery,
selectedQueryName: thresholdState.selectedQuery,
algorithm: thresholdState.algorithm,
seasonality: thresholdState.seasonality,
...alertOnAbsentProps,
...enforceMinimumDatapointsProps,
},
@@ -335,9 +373,25 @@ export function buildCreateAnomalyAlertRulePayload(
summary: notificationSettings.description,
},
notificationSettings: notificationSettingsProps,
evaluation: evaluationProps,
version: '',
schemaVersion: '',
evaluation: {
kind: 'rolling',
spec: {
evalWindow: thresholdState.evaluationWindow,
frequency,
},
},
version: 'v5',
schemaVersion: 'v2alpha1',
source: window?.location.toString(),
};
}
// Build the create/test alert rule payload for the selected alert type
export function buildCreateAlertRulePayload(
args: BuildCreateAlertRulePayloadArgs,
): PostableAlertRuleV2 {
if (args.alertType === AlertTypes.ANOMALY_BASED_ALERT) {
return buildCreateAnomalyAlertRulePayload(args);
}
return buildCreateThresholdAlertRulePayload(args);
}

View File

@@ -316,6 +316,60 @@ describe('CreateAlertV2 utils', () => {
});
});
describe('getThresholdStateFromAlertDef for anomaly rules', () => {
const anomalyAlertDef: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
ruleType: 'anomaly_rule',
condition: {
...defaultPostableAlertRuleV2.condition,
algorithm: 'standard',
seasonality: 'daily',
selectedQueryName: 'A',
thresholds: {
kind: 'basic',
spec: [
{
name: 'critical',
target: -3,
targetUnit: '',
channels: ['email'],
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
op: AlertThresholdOperator.IS_BELOW,
},
],
},
},
evaluation: {
kind: 'rolling',
spec: {
evalWindow: '1h0m0s',
frequency: '1m',
},
},
};
it('shows the absolute deviation value for negative anomaly targets', () => {
const props = getThresholdStateFromAlertDef(anomalyAlertDef);
expect(props.thresholds[0].thresholdValue).toBe(3);
expect(props.operator).toBe(AlertThresholdOperator.IS_BELOW);
});
it('hydrates the anomaly evaluation window, algorithm and seasonality', () => {
const props = getThresholdStateFromAlertDef(anomalyAlertDef);
expect(props.evaluationWindow).toBe('1h0m0s');
expect(props.algorithm).toBe('standard');
expect(props.seasonality).toBe('daily');
});
it('does not touch the target for non-anomaly rules', () => {
const props = getThresholdStateFromAlertDef({
...anomalyAlertDef,
ruleType: 'threshold_rule',
});
expect(props.thresholds[0].thresholdValue).toBe(-3);
});
});
describe('normalizeOperator', () => {
it.each([
['1', AlertThresholdOperator.IS_ABOVE],

View File

@@ -239,6 +239,39 @@ describe('CreateAlertV2 Context Utils', () => {
});
});
it('should set evaluation window', () => {
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
type: 'SET_EVALUATION_WINDOW',
payload: TimeDuration.ONE_HOUR,
});
expect(result).toStrictEqual({
...INITIAL_ALERT_THRESHOLD_STATE,
evaluationWindow: TimeDuration.ONE_HOUR,
});
});
it('should set algorithm', () => {
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
type: 'SET_ALGORITHM',
payload: Algorithm.STANDARD,
});
expect(result).toStrictEqual({
...INITIAL_ALERT_THRESHOLD_STATE,
algorithm: Algorithm.STANDARD,
});
});
it('should set seasonality', () => {
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
type: 'SET_SEASONALITY',
payload: Seasonality.WEEKLY,
});
expect(result).toStrictEqual({
...INITIAL_ALERT_THRESHOLD_STATE,
seasonality: Seasonality.WEEKLY,
});
});
it('should set thresholds', () => {
const newThresholds = [
{

View File

@@ -124,6 +124,12 @@ export const alertThresholdReducer = (
return { ...state, operator: action.payload };
case 'SET_MATCH_TYPE':
return { ...state, matchType: action.payload };
case 'SET_EVALUATION_WINDOW':
return { ...state, evaluationWindow: action.payload };
case 'SET_ALGORITHM':
return { ...state, algorithm: action.payload };
case 'SET_SEASONALITY':
return { ...state, seasonality: action.payload };
case 'SET_THRESHOLDS':
return { ...state, thresholds: action.payload };
case 'RESET':

View File

@@ -4,6 +4,7 @@ import { Spin } from 'antd';
import { TIMEZONE_DATA } from 'components/CustomTimePicker/timezoneUtils';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { getRandomColor } from 'container/ExplorerOptions/utils';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import { v4 } from 'uuid';
@@ -303,13 +304,19 @@ export function normalizeMatchType(
export function getThresholdStateFromAlertDef(
alertDef: PostableAlertRuleV2,
): AlertThresholdState {
// Anomaly targets are stored as literal z-scores (negative for the below
// operator); the deviations select always shows the positive value.
const isAnomalyRule =
alertDef.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT;
return {
...INITIAL_ALERT_THRESHOLD_STATE,
thresholds:
alertDef.condition.thresholds?.spec.map((threshold) => ({
id: v4(),
label: threshold.name,
thresholdValue: threshold.target,
thresholdValue: isAnomalyRule
? Math.abs(threshold.target)
: threshold.target,
recoveryThresholdValue: null,
unit: threshold.targetUnit,
color: getColorForThreshold(threshold.name),
@@ -321,6 +328,18 @@ export function getThresholdStateFromAlertDef(
matchType:
alertDef.condition.thresholds?.spec[0].matchType ||
AlertThresholdMatchType.AT_LEAST_ONCE,
...(isAnomalyRule
? {
evaluationWindow:
alertDef.evaluation?.spec?.evalWindow ||
INITIAL_ALERT_THRESHOLD_STATE.evaluationWindow,
algorithm:
alertDef.condition.algorithm || INITIAL_ALERT_THRESHOLD_STATE.algorithm,
seasonality:
alertDef.condition.seasonality ||
INITIAL_ALERT_THRESHOLD_STATE.seasonality,
}
: {}),
};
}

View File

@@ -11,6 +11,7 @@ import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { CreateAlertProvider } from 'container/CreateAlertV2/context';
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { useAlertRule } from 'providers/Alert';
@@ -85,11 +86,18 @@ function AlertDetails(): JSX.Element {
return <Spinner />;
}
// Anomaly rules are stored with a metric alertType; the editor's alert
// type is derived from the ruleType so the anomaly condition is shown.
const initialAlertType =
alertRuleDetails?.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT
? AlertTypes.ANOMALY_BASED_ALERT
: (alertRuleDetails?.alertType as AlertTypes);
return (
<CreateAlertProvider
ruleId={ruleId || ''}
isEditMode
initialAlertType={alertRuleDetails?.alertType as AlertTypes}
initialAlertType={initialAlertType}
initialAlertState={initialAlertState}
>
<div

View File

@@ -28,6 +28,8 @@ export interface PostableAlertRuleV2 {
absentFor?: number;
requireMinPoints?: boolean;
requiredNumPoints?: number;
algorithm?: string;
seasonality?: string;
};
evaluation?: {
kind?: 'rolling' | 'cumulative';

View File

@@ -221,16 +221,15 @@ func postableRuleExamples() []handler.OpenAPIExample {
},
{
Name: "metric_anomaly",
Summary: "Metric anomaly rule (v1 only)",
Description: "Anomaly rules are not yet supported under schemaVersion v2alpha1, so this example uses the v1 shape. Wraps a builder query in the `anomaly` function with daily seasonality SigNoz compares each point against the forecast for that time of day. Fires when the anomaly score stays below the threshold for the entire window; `requireMinPoints` guards against noisy intervals.",
Summary: "Metric anomaly rule",
Description: "Wraps a builder query in the `anomaly` function with daily seasonality SigNoz compares each point against the forecast for that time of day. Thresholds compare against the anomaly z-score and are literal: a drop of more than 2 deviations below the forecast is expressed as `op: below` with `target: -2`. Fires when the anomaly score stays below the threshold for the entire window; `requireMinPoints` guards against noisy intervals.",
Value: map[string]any{
"alert": "Anomalous drop in ingested spans",
"alertType": "METRIC_BASED_ALERT",
"description": "Detect an abrupt drop in span ingestion using a z-score anomaly function",
"ruleType": "anomaly_rule",
"version": "v5",
"evalWindow": "24h",
"frequency": "3h",
"alert": "Anomalous drop in ingested spans",
"alertType": "METRIC_BASED_ALERT",
"description": "Detect an abrupt drop in span ingestion using a z-score anomaly function",
"ruleType": "anomaly_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"condition": map[string]any{
"compositeQuery": map[string]any{
"queryType": "builder",
@@ -256,17 +255,29 @@ func postableRuleExamples() []handler.OpenAPIExample {
},
},
},
"op": "below",
"matchType": "all_the_times",
"target": 2,
"selectedQueryName": "A",
"algorithm": "standard",
"seasonality": "daily",
"selectedQueryName": "A",
"requireMinPoints": true,
"requiredNumPoints": 3,
"thresholds": map[string]any{
"kind": "basic",
"spec": []any{
map[string]any{
"name": "warning",
"op": "below",
"matchType": "all_the_times",
"target": -2,
"channels": []any{"slack-ingestion"},
},
},
},
},
"labels": map[string]any{"severity": "warning"},
"preferredChannels": []any{"slack-ingestion"},
"evaluation": rolling("24h", "3h"),
"notificationSettings": map[string]any{
"renotify": renotify("4h", "firing"),
},
"labels": map[string]any{"severity": "warning"},
"annotations": map[string]any{
"description": "Ingestion rate for tenant {{$tenant_id}} is anomalously low (z-score {{$value}}).",
"summary": "Span ingestion anomaly",

View File

@@ -712,6 +712,39 @@ func TestValidate_V2Alpha1(t *testing.T) {
json: validV2Alpha1Builder(),
},
// anomaly rules are supported under v2alpha1; thresholds are literal,
// so a drop of 2 deviations below the forecast carries target -2
{
name: "valid v2alpha1 anomaly rule",
json: `{
"alert": "Test", "version": "v5", "schemaVersion": "v2alpha1", "ruleType": "anomaly_rule",
"condition": {
"compositeQuery": {"queryType": "builder", "queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "metrics", "aggregations": [{"metricName": "cpu", "spaceAggregation": "p50"}], "stepInterval": "5m"}}]},
"algorithm": "standard",
"seasonality": "daily",
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": -2.0, "matchType": "2", "op": "2", "channels": ["slack"]}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "1h", "frequency": "1m"}},
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
}`,
},
{
name: "v2alpha1 anomaly rule with invalid seasonality",
json: `{
"alert": "Test", "version": "v5", "schemaVersion": "v2alpha1", "ruleType": "anomaly_rule",
"condition": {
"compositeQuery": {"queryType": "builder", "queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "metrics", "aggregations": [{"metricName": "cpu", "spaceAggregation": "p50"}], "stepInterval": "5m"}}]},
"algorithm": "standard",
"seasonality": "yearly",
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": -2.0, "matchType": "2", "op": "2", "channels": ["slack"]}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "1h", "frequency": "1m"}},
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
}`,
wantErr: true,
errSubstr: "seasonality",
},
// missing required fields
{
name: "missing thresholds",