Compare commits

...

15 Commits

Author SHA1 Message Date
Ashwin Bhatkal
44505f102b feat(alerts): create anomaly alerts in the new alert experience 2026-10-16 17:16:29 +05:30
Ashwin Bhatkal
82944d93d5 chore(alerts): mark alert type picker page view event as void 2026-10-16 17:16:29 +05:30
Ashwin Bhatkal
c0259dca6f feat(alerts): keep the query when switching between threshold and anomaly 2026-10-16 17:16:29 +05:30
Ashwin Bhatkal
bf94fc507f feat(alerts): sync the anomaly query function with the selected deviations 2026-10-16 17:16:29 +05:30
Ashwin Bhatkal
7ac59c9681 feat(alerts): start new anomaly alerts at 3 deviations 2026-10-16 17:16:29 +05:30
Ashwin Bhatkal
037b40af2c feat(alerts): show the anomaly tab in v2 alert conditions behind the anomaly flag 2026-10-16 17:16:29 +05:30
Ashwin Bhatkal
b0008c4550 chore(alerts): mark fire-and-forget preview query calls as void 2026-10-16 17:16:29 +05:30
Ashwin Bhatkal
91148e678d feat(alerts): limit anomaly alerts to builder metric queries in v2 2026-10-16 17:16:29 +05:30
Ashwin Bhatkal
e30211ffe9 fix(alerts): pick anomaly notification channels by name when routing policies are off 2026-10-16 17:16:29 +05:30
Ashwin Bhatkal
75b1ced7bd feat(alerts): open anomaly rules on the anomaly tab in alert details 2026-10-16 17:16:29 +05:30
Ashwin Bhatkal
7611b408c8 chore(alerts): fix lint warnings in alert details and v2 utils tests 2026-10-16 17:16:29 +05:30
Ashwin Bhatkal
b98c33e44f feat(alerts): restore anomaly rule settings when editing in v2 2026-10-16 17:16:29 +05:30
Ashwin Bhatkal
cb93643b90 feat(alerts): build v2alpha1 payload for anomaly rules 2026-10-16 17:16:29 +05:30
Ashwin Bhatkal
25ff5e7967 feat(alerts): keep anomaly window, algorithm and seasonality in v2 alert state 2026-10-16 17:16:29 +05:30
Ashwin Bhatkal
2298ec6243 chore(alerts): fix lint warnings in create alert footer 2026-10-16 17:16:29 +05:30
23 changed files with 524 additions and 137 deletions

View File

@@ -7,11 +7,12 @@ import { AlertTypes } from 'types/api/alerts/alertTypes';
import { ALERT_TYPE_URL_MAP } from './constants';
// The setup guide button only exists in the classic form.
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 +21,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 with the anomaly alert type when ruleType is anomaly_rule', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.showClassicCreateAlertsPage) {
return 'false';
@@ -163,8 +163,9 @@ 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.getByText(AlertTypes.ANOMALY_BASED_ALERT)).toBeInTheDocument();
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
});
it('should use alertType from URL when provided', () => {

View File

@@ -41,7 +41,7 @@ function CreateRules(): JSX.Element {
useEffect(() => {
if (isTypeSelectionMode) {
logEvent('Alert: New alert data source selection page visited', {});
void logEvent('Alert: New alert data source selection page visited', {});
}
}, [isTypeSelectionMode]);
@@ -100,10 +100,7 @@ function CreateRules(): JSX.Element {
return <SelectAlertType onSelect={handleSelectType} />;
}
if (
showClassicCreateAlertsPageFlag ||
alertType === AlertTypes.ANOMALY_BASED_ALERT
) {
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';
@@ -14,11 +16,14 @@ import Stepper from '../Stepper';
import AlertThreshold from './AlertThreshold';
import AnomalyThreshold from './AnomalyThreshold';
import { ANOMALY_TAB_TOOLTIP, THRESHOLD_TAB_TOOLTIP } from './constants';
import { useAnomalyFunctionSync } from './useAnomalyFunctionSync';
import './styles.scss';
function AlertCondition(): JSX.Element {
const { alertType, setAlertType } = useCreateAlertState();
const { featureFlags } = useAppContext();
useAnomalyFunctionSync();
const {
data,
@@ -30,9 +35,14 @@ function AlertCondition(): JSX.Element {
});
const channels = data?.data || [];
const isAnomalyDetectionEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.ANOMALY_DETECTION)
?.active || false;
// Anomaly rules stay editable even when the flag is off.
const showMultipleTabs =
alertType === AlertTypes.ANOMALY_BASED_ALERT ||
alertType === AlertTypes.METRICS_BASED_ALERT;
(isAnomalyDetectionEnabled && alertType === AlertTypes.METRICS_BASED_ALERT);
const tabs = [
{
@@ -40,16 +50,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

@@ -53,7 +53,7 @@ function AnomalyThreshold({
const updateThreshold = (
id: string,
field: string,
value: string | string[],
value: number | string[],
): void => {
setThresholdState({
type: 'SET_THRESHOLDS',
@@ -112,7 +112,7 @@ function AnomalyThreshold({
updateThreshold(
thresholdState.thresholds[0].id,
'thresholdValue',
value.toString(),
value,
);
}}
options={deviationOptions}
@@ -188,7 +188,7 @@ function AnomalyThreshold({
}}
options={ANOMALY_SEASONALITY_OPTIONS}
/>
{notificationSettings.routingPolicies ? (
{!notificationSettings.routingPolicies ? (
<>
<Typography.Text
data-testid="seasonality-text"
@@ -198,12 +198,13 @@ function AnomalyThreshold({
</Typography.Text>
<Select
value={thresholdState.thresholds[0].channels}
data-testid="anomaly-notification-channel-select"
onChange={(value): void =>
updateThreshold(thresholdState.thresholds[0].id, 'channels', value)
}
style={{ width: 350 }}
options={channels.map((channel) => ({
value: channel.id,
value: channel.name,
label: channel.name,
}))}
mode="multiple"

View File

@@ -1,6 +1,8 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import * as appHooks from 'providers/App/App';
import { createAppContextMock } from 'tests/fixtures/appContextMock';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { CreateAlertProvider } from '../../context';
@@ -65,25 +67,23 @@ jest.mock('../AnomalyThreshold', () => ({
},
}));
const mockHandleSetQueryData = jest.fn();
const mockRedirectWithQueryBuilderData = jest.fn();
const mockCurrentQuery = {
dataSource: 'METRICS',
queryName: 'A',
builder: {
queryData: [{ dataSource: 'METRICS' }],
queryFormulas: [],
},
};
// Mock useQueryBuilder hook
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): {
currentQuery: {
builder: { queryData: unknown[]; queryFormulas: unknown[] };
dataSource: string;
queryName: string;
};
redirectWithQueryBuilderData: () => void;
} => ({
currentQuery: {
dataSource: 'METRICS',
queryName: 'A',
builder: {
queryData: [{ dataSource: 'METRICS' }],
queryFormulas: [],
},
},
redirectWithQueryBuilderData: jest.fn(),
useQueryBuilder: (): unknown => ({
currentQuery: mockCurrentQuery,
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
handleSetQueryData: mockHandleSetQueryData,
}),
}));
@@ -112,7 +112,14 @@ const renderAlertCondition = (
);
};
const useAppContextSpy = jest.spyOn(appHooks, 'useAppContext');
describe('AlertCondition', () => {
beforeEach(() => {
jest.clearAllMocks();
useAppContextSpy.mockReturnValue(createAppContextMock('ADMIN'));
});
it('renders the stepper with correct step number and label', () => {
renderAlertCondition();
expect(screen.getByTestId(STEPPER_TEST_ID)).toHaveTextContent(
@@ -125,10 +132,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 +142,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,8 +157,7 @@ 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();
@@ -167,8 +171,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 +182,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 +207,36 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
// TODO: Unskip this when anomaly tab is implemented
it.skip('applies active tab styling correctly', () => {
it('adds the anomaly function with the default z-score when switching to anomaly', () => {
renderAlertCondition();
expect(mockHandleSetQueryData).not.toHaveBeenCalled();
fireEvent.click(screen.getByText(ANOMALY_TAB_TEXT));
expect(mockHandleSetQueryData).toHaveBeenCalledWith(
0,
expect.objectContaining({
functions: [
{ name: 'anomaly', args: [], namedArgs: { z_score_threshold: 3 } },
],
}),
);
});
it('keeps the current query when switching between threshold and anomaly', () => {
renderAlertCondition();
fireEvent.click(screen.getByText(ANOMALY_TAB_TEXT));
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
mockCurrentQuery,
{ alertType: AlertTypes.ANOMALY_BASED_ALERT, ruleType: 'anomaly_rule' },
undefined,
true,
);
});
it('applies active tab styling correctly', () => {
renderAlertCondition();
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
@@ -227,11 +257,20 @@ 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('hides the anomaly tab for METRICS_BASED_ALERT when anomaly detection is off', () => {
useAppContextSpy.mockReturnValue(
createAppContextMock('ADMIN', { featureFlags: [] }),
);
renderAlertCondition('METRIC_BASED_ALERT');
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.queryByText(ANOMALY_TAB_TEXT)).not.toBeInTheDocument();
});
it('shows multiple tabs for ANOMALY_BASED_ALERT', () => {
@@ -239,9 +278,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,7 +1,8 @@
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
import * as appHooks from 'providers/App/App';
import { Channels } from 'types/api/channels/getAll';
import * as context from '../../context';
import AnomalyThreshold from '../AnomalyThreshold';
@@ -22,12 +23,11 @@ jest.mock('uplot', () => {
const mockSetAlertState = jest.fn();
const mockSetThresholdState = jest.fn();
jest.spyOn(context, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({
setThresholdState: mockSetThresholdState,
setAlertState: mockSetAlertState,
}),
);
const mockContextState = createMockAlertContextState({
setThresholdState: mockSetThresholdState,
setAlertState: mockSetAlertState,
});
const useCreateAlertStateSpy = jest.spyOn(context, 'useCreateAlertState');
// Mock useQueryBuilder hook
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
@@ -52,10 +52,12 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
}),
}));
const channels = [{ id: '1', name: 'slack-alerts' }] as Channels[];
const renderAnomalyThreshold = (): ReturnType<typeof render> =>
render(
<AnomalyThreshold
channels={[]}
channels={channels}
isLoadingChannels={false}
isErrorChannels={false}
refreshChannels={jest.fn()}
@@ -65,8 +67,13 @@ const renderAnomalyThreshold = (): ReturnType<typeof render> =>
describe('AnomalyThreshold', () => {
beforeEach(() => {
jest.clearAllMocks();
useCreateAlertStateSpy.mockReturnValue(mockContextState);
});
const openSelect = (testId: string): void => {
fireEvent.mouseDown(within(screen.getByTestId(testId)).getByRole('combobox'));
};
it('renders the first condition sentence', () => {
renderAnomalyThreshold();
expect(screen.getByTestId('notification-text')).toBeInTheDocument();
@@ -92,4 +99,38 @@ describe('AnomalyThreshold', () => {
expect(screen.getByTestId('seasonality-select')).toBeInTheDocument();
expect(screen.getByTestId('seasonality-text')).toBeInTheDocument();
});
it('stores the picked deviation as a number', () => {
renderAnomalyThreshold();
openSelect('threshold-value-select');
fireEvent.click(screen.getByTitle('4'));
expect(mockSetThresholdState).toHaveBeenCalledWith({
type: 'SET_THRESHOLDS',
payload: [expect.objectContaining({ thresholdValue: 4 })],
});
});
it('picks channels by name when routing policies are off', () => {
renderAnomalyThreshold();
openSelect('anomaly-notification-channel-select');
fireEvent.click(screen.getByTitle('slack-alerts'));
expect(mockSetThresholdState).toHaveBeenCalledWith({
type: 'SET_THRESHOLDS',
payload: [expect.objectContaining({ channels: ['slack-alerts'] })],
});
});
it('hides the channel picker when routing policies are on', () => {
useCreateAlertStateSpy.mockReturnValue({
...mockContextState,
notificationSettings: {
...mockContextState.notificationSettings,
routingPolicies: true,
},
});
renderAnomalyThreshold();
expect(
screen.queryByTestId('anomaly-notification-channel-select'),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,31 @@
import { QueryFunction } from 'types/api/v5/queryRange';
import { syncAnomalyFunction } from '../utils';
const timeShift: QueryFunction = { name: 'timeShift', args: [{ value: 3600 }] };
const anomaly = (zScoreThreshold: number): QueryFunction => ({
name: 'anomaly',
args: [],
namedArgs: { z_score_threshold: zScoreThreshold },
});
describe('syncAnomalyFunction', () => {
it('adds the anomaly function after the existing functions', () => {
expect(syncAnomalyFunction([timeShift], 3)).toStrictEqual([
timeShift,
anomaly(3),
]);
});
it('replaces a stale z-score and is idempotent', () => {
const synced = syncAnomalyFunction([anomaly(3), timeShift], 5);
expect(synced).toStrictEqual([timeShift, anomaly(5)]);
expect(syncAnomalyFunction(synced, 5)).toStrictEqual(synced);
});
it('removes the anomaly function when no z-score is given', () => {
expect(syncAnomalyFunction([anomaly(3), timeShift], undefined)).toStrictEqual(
[timeShift],
);
});
});

View File

@@ -0,0 +1,28 @@
import { useEffect } from 'react';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { isEqual } from 'lodash-es';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { useCreateAlertState } from '../context';
import { syncAnomalyFunction } from './utils';
// The anomaly function's z-score only draws the preview bands; alerting uses the threshold target.
export function useAnomalyFunctionSync(): void {
const { alertType, thresholdState } = useCreateAlertState();
const { currentQuery, handleSetQueryData } = useQueryBuilder();
const zScoreThreshold =
alertType === AlertTypes.ANOMALY_BASED_ALERT
? Number(thresholdState.thresholds[0]?.thresholdValue)
: undefined;
useEffect(() => {
currentQuery.builder.queryData.forEach((queryData, index) => {
const currentFunctions = queryData.functions || [];
const functions = syncAnomalyFunction(currentFunctions, zScoreThreshold);
if (!isEqual(functions, currentFunctions)) {
handleSetQueryData(index, { ...queryData, functions });
}
});
}, [currentQuery.builder.queryData, zScoreThreshold, handleSetQueryData]);
}

View File

@@ -15,6 +15,7 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { ArrowRight } from '@signozhq/icons';
import { IUser } from 'providers/App/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryFunction } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { USER_ROLES } from 'types/roles';
import { openInNewTab } from 'utils/navigation';
@@ -43,6 +44,25 @@ export function getQueryNames(currentQuery: Query): BaseOptionType[] {
return queryConfig[currentQuery.queryType]?.() || [];
}
// Drops the anomaly function, or re-adds it last with the given z-score.
export function syncAnomalyFunction(
functions: QueryFunction[],
zScoreThreshold: number | undefined,
): QueryFunction[] {
const otherFunctions = functions.filter(({ name }) => name !== 'anomaly');
if (zScoreThreshold === undefined) {
return otherFunctions;
}
return [
...otherFunctions,
{
name: 'anomaly',
args: [],
namedArgs: { z_score_threshold: zScoreThreshold },
},
];
}
export function getCategoryByOptionId(id: string): string | undefined {
const categories = getYAxisCategories(YAxisSource.ALERTS);
return categories.find((category) =>

View File

@@ -13,10 +13,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 {
@@ -84,7 +81,7 @@ function Footer(): JSX.Element {
);
const handleTestNotification = useCallback((): void => {
const payload = buildCreateThresholdAlertRulePayload({
const payload = buildCreateAlertRulePayload({
alertType,
basicAlertState,
thresholdState,
@@ -117,11 +114,12 @@ function Footer(): JSX.Element {
notificationSettings,
currentQuery,
testAlertRule,
handleApiError,
]);
const queryClient = useQueryClient();
const handleSaveAlert = useCallback((): void => {
const payload = buildCreateThresholdAlertRulePayload({
const payload = buildCreateAlertRulePayload({
alertType,
basicAlertState,
thresholdState,
@@ -173,6 +171,7 @@ function Footer(): JSX.Element {
createAlertRule,
safeNavigate,
handleApiError,
queryClient,
]);
const isSavingAlertRule = isCreatingAlertRule || isUpdatingAlertRule;

View File

@@ -9,8 +9,11 @@ import {
} from 'container/CreateAlertV2/context/constants';
import {
AdvancedOptionsState,
AlertThresholdOperator,
EvaluationWindowState,
NotificationSettingsState,
Seasonality,
TimeDuration,
} from 'container/CreateAlertV2/context/types';
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import { AlertTypes } from 'types/api/alerts/alertTypes';
@@ -18,6 +21,7 @@ import { EQueryType } from 'types/common/dashboard';
import { BuildCreateAlertRulePayloadArgs } from '../types';
import {
buildCreateAlertRulePayload,
buildCreateThresholdAlertRulePayload,
getAlertOnAbsentProps,
getEnforceMinimumDatapointsProps,
@@ -573,4 +577,65 @@ describe('Footer utils', () => {
},
);
});
describe('buildCreateAlertRulePayload', () => {
const mockCreateAlertContextState = createMockAlertContextState();
const thresholdState = mockCreateAlertContextState.thresholdState;
const anomalyArgs: BuildCreateAlertRulePayloadArgs = {
basicAlertState: mockCreateAlertContextState.alertState,
thresholdState: {
...thresholdState,
evaluationWindow: TimeDuration.ONE_HOUR,
seasonality: Seasonality.DAILY,
thresholds: [
{ ...thresholdState.thresholds[0], thresholdValue: 3, unit: 'ms' },
],
},
advancedOptions: mockCreateAlertContextState.advancedOptions,
evaluationWindow: mockCreateAlertContextState.evaluationWindow,
notificationSettings: mockCreateAlertContextState.notificationSettings,
query: initialQueriesMap.metrics,
alertType: AlertTypes.ANOMALY_BASED_ALERT,
};
it('builds a v2alpha1 anomaly rule with a rolling window', () => {
const props = buildCreateAlertRulePayload(anomalyArgs);
expect(props.ruleType).toBe('anomaly_rule');
expect(props.alertType).toBe(AlertTypes.METRICS_BASED_ALERT);
expect(props.schemaVersion).toBe('v2alpha1');
expect(props.condition.algorithm).toBe('standard');
expect(props.condition.seasonality).toBe('daily');
expect(props.condition.thresholds?.spec[0]).toMatchObject({
target: 3,
targetUnit: '',
});
expect(props.evaluation).toStrictEqual({
kind: 'rolling',
spec: { evalWindow: '1h0m0s', frequency: '1m' },
});
});
it.each([
[AlertThresholdOperator.IS_BELOW, -3],
['2', -3],
[AlertThresholdOperator.IS_ABOVE, 3],
[AlertThresholdOperator.ABOVE_BELOW, 3],
])('signs the z-score target for operator %s', (operator, target) => {
const props = buildCreateAlertRulePayload({
...anomalyArgs,
thresholdState: { ...anomalyArgs.thresholdState, operator },
});
expect(props.condition.thresholds?.spec[0].target).toBe(target);
expect(props.condition.thresholds?.spec[0].op).toBe(operator);
});
it('builds a threshold rule for other alert types', () => {
const props = buildCreateAlertRulePayload({
...anomalyArgs,
alertType: AlertTypes.METRICS_BASED_ALERT,
});
expect(props.ruleType).toBe('threshold_rule');
expect(props.condition.seasonality).toBeUndefined();
});
});
});

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,
@@ -9,8 +10,10 @@ import {
import { EQueryType } from 'types/common/dashboard';
import { compositeQueryToQueryEnvelope } from 'utils/compositeQueryToQueryEnvelope';
import { normalizeOperator } from '../context/conditionNormalizers';
import {
AdvancedOptionsState,
AlertThresholdOperator,
EvaluationWindowState,
NotificationSettingsState,
} from '../context/types';
@@ -289,56 +292,51 @@ 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,
query,
notificationSettings,
evaluationWindow,
advancedOptions,
} = args;
const { thresholdState, evaluationWindow, advancedOptions } = args;
const payload = buildCreateThresholdAlertRulePayload(args);
const compositeQuery = compositeQueryToQueryEnvelope({
builderQueries: {
...mapQueryDataToApi(query.builder.queryData, 'queryName').data,
...mapQueryDataToApi(query.builder.queryFormulas, 'queryName').data,
},
promQueries: mapQueryDataToApi(query.promql, 'name').data,
chQueries: mapQueryDataToApi(query.clickhouse_sql, 'name').data,
queryType: query.queryType,
panelType: PANEL_TYPES.TIME_SERIES,
unit: basicAlertState.yAxisUnit,
});
const alertOnAbsentProps = getAlertOnAbsentProps(advancedOptions);
const enforceMinimumDatapointsProps =
getEnforceMinimumDatapointsProps(advancedOptions);
const evaluationProps = getEvaluationProps(evaluationWindow, advancedOptions);
const notificationSettingsProps =
getNotificationSettingsProps(notificationSettings);
// Targets are literal z-scores: "below N deviations" fires under -N.
// A target unit would rescale the z-score, so it is always cleared.
const sign =
normalizeOperator(thresholdState.operator) === AlertThresholdOperator.IS_BELOW
? -1
: 1;
const thresholds: BasicThreshold[] = (
payload.condition.thresholds?.spec || []
).map((threshold) => ({
...threshold,
target: sign * Math.abs(threshold.target),
targetUnit: '',
}));
return {
alert: basicAlertState.name,
...payload,
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
alertType,
alertType: AlertTypes.METRICS_BASED_ALERT,
condition: {
compositeQuery,
...alertOnAbsentProps,
...enforceMinimumDatapointsProps,
...payload.condition,
thresholds: { kind: 'basic', spec: thresholds },
algorithm: thresholdState.algorithm,
seasonality: thresholdState.seasonality,
},
labels: basicAlertState.labels,
annotations: {
description: notificationSettings.description,
summary: notificationSettings.description,
},
notificationSettings: notificationSettingsProps,
evaluation: evaluationProps,
version: '',
schemaVersion: '',
source: window?.location.toString(),
evaluation: getEvaluationProps(
{
...evaluationWindow,
windowType: 'rolling',
timeframe: thresholdState.evaluationWindow,
},
advancedOptions,
),
};
}
export function buildCreateAlertRulePayload(
args: BuildCreateAlertRulePayloadArgs,
): PostableAlertRuleV2 {
return args.alertType === AlertTypes.ANOMALY_BASED_ALERT
? buildCreateAnomalyAlertRulePayload(args)
: buildCreateThresholdAlertRulePayload(args);
}

View File

@@ -81,13 +81,15 @@ function QuerySection(): JSX.Element {
}, [isLoadingQueries]);
const handleCancelQuery = useCallback(() => {
queryClient.cancelQueries([REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW]);
void queryClient.cancelQueries([REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW]);
setIsCancelled(true);
}, [queryClient]);
const runQueryHandler = useCallback(() => {
setIsCancelled(false);
queryClient.invalidateQueries([REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW]);
void queryClient.invalidateQueries([
REACT_QUERY_KEY.ALERT_RULES_CHART_PREVIEW,
]);
// Reset the source param when the query is changed
// Then manually run the query
if (source === YAxisSource.DASHBOARDS && didQueryChange) {
@@ -129,6 +131,11 @@ function QuerySection(): JSX.Element {
},
];
const isAnomalyDetection = alertType === AlertTypes.ANOMALY_BASED_ALERT;
const activeTab = isAnomalyDetection
? AlertTypes.METRICS_BASED_ALERT
: alertType;
return (
<div className="query-section">
<Stepper stepNumber={1} label="Define the query" />
@@ -144,7 +151,7 @@ function QuerySection(): JSX.Element {
<Button
key={tab.value}
className={classNames('list-view-tab', 'explorer-view-option', {
'active-tab': alertType === tab.value,
'active-tab': activeTab === tab.value,
})}
onClick={(): void => {
setAlertType(tab.value as AlertTypes);
@@ -165,6 +172,7 @@ function QuerySection(): JSX.Element {
handleCancelQuery={handleCancelQuery}
alertDef={alertDef}
panelType={PANEL_TYPES.TIME_SERIES}
isAnomalyDetection={isAnomalyDetection}
key={currentQuery.queryType}
ruleId=""
hideTitle

View File

@@ -61,10 +61,12 @@ jest.mock(
alertType,
panelType,
setQueryCategory,
isAnomalyDetection,
}: any): JSX.Element {
return (
<div data-testid="query-section-component">
<div data-testid="query-category">{queryCategory}</div>
<div data-testid="is-anomaly-detection">{String(isAnomalyDetection)}</div>
<div data-testid="alert-type">{alertType}</div>
<div data-testid="panel-type">{panelType}</div>
<button
@@ -127,11 +129,11 @@ const queryClient = new QueryClient({
},
},
});
const renderQuerySection = (): ReturnType<typeof render> =>
const renderQuerySection = (search = ''): ReturnType<typeof render> =>
render(
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<MemoryRouter initialEntries={[`/alerts/new${search}`]}>
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
<QuerySection />
</CreateAlertProvider>
@@ -201,6 +203,15 @@ describe('QuerySection', () => {
expect(metricsTab).toHaveClass(ACTIVE_TAB_CLASS);
});
it('keeps Metrics active and restricts query types for anomaly alerts', () => {
renderQuerySection(`?${QueryParams.ruleType}=anomaly_rule`);
expect(screen.getByText(METRICS_TEXT).closest('button')).toHaveClass(
ACTIVE_TAB_CLASS,
);
expect(screen.getByTestId('is-anomaly-detection')).toHaveTextContent('true');
});
it('handles alert type change when clicking on different tabs', async () => {
const user = userEvent.setup();
renderQuerySection();

View File

@@ -1,5 +1,6 @@
import { Color } from '@signozhq/design-tokens';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import { defaultPostableAlertRuleV2 } from '../constants';
@@ -11,6 +12,7 @@ import {
import {
getAdvancedOptionsStateFromAlertDef,
getColorForThreshold,
getAlertTypeFromAlertDef,
getCreateAlertLocalStateFromAlertDef,
getEvaluationWindowStateFromAlertDef,
getNotificationSettingsStateFromAlertDef,
@@ -271,7 +273,7 @@ describe('CreateAlertV2 utils', () => {
});
});
describe('getThresholdStateFromAlertDef', () => {
it('getThresholdStateFromAlertDef maps thresholds, query, operator and match type', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
annotations: {
@@ -316,6 +318,76 @@ describe('CreateAlertV2 utils', () => {
});
});
describe('getThresholdStateFromAlertDef for anomaly rules', () => {
const anomalyDef: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
ruleType: 'anomaly_rule',
condition: {
...defaultPostableAlertRuleV2.condition,
algorithm: 'standard',
seasonality: 'weekly',
thresholds: {
kind: 'basic',
spec: [
{
name: 'critical',
target: -3,
targetUnit: '',
channels: ['email'],
matchType: AlertThresholdMatchType.ALL_THE_TIME,
op: AlertThresholdOperator.IS_BELOW,
},
],
},
},
evaluation: {
kind: 'rolling',
spec: { evalWindow: '1h0m0s', frequency: '1m' },
},
};
it('shows deviations unsigned and restores the anomaly settings', () => {
expect(getThresholdStateFromAlertDef(anomalyDef)).toMatchObject({
operator: AlertThresholdOperator.IS_BELOW,
thresholds: [{ thresholdValue: 3 }],
algorithm: 'standard',
seasonality: 'weekly',
evaluationWindow: '1h0m0s',
});
});
it('keeps signed targets and the default window for threshold rules', () => {
const state = getThresholdStateFromAlertDef({
...anomalyDef,
ruleType: 'threshold_rule',
});
expect(state.thresholds[0].thresholdValue).toBe(-3);
expect(state.evaluationWindow).toBe('5m0s');
});
});
describe('getAlertTypeFromAlertDef', () => {
it('maps anomaly rules to the anomaly alert type', () => {
expect(
getAlertTypeFromAlertDef({
...defaultPostableAlertRuleV2,
alertType: AlertTypes.METRICS_BASED_ALERT,
ruleType: 'anomaly_rule',
}),
).toBe(AlertTypes.ANOMALY_BASED_ALERT);
});
it('keeps the stored alert type for other rules', () => {
expect(
getAlertTypeFromAlertDef({
...defaultPostableAlertRuleV2,
alertType: AlertTypes.LOGS_BASED_ALERT,
ruleType: 'threshold_rule',
}),
).toBe(AlertTypes.LOGS_BASED_ALERT);
});
});
describe('getThresholdStateFromAlertDef null channels', () => {
it('falls back to an empty array so downstream consumers never see null', () => {
const def: PostableAlertRuleV2 = {

View File

@@ -97,6 +97,13 @@ describe('CreateAlertProvider — URL-declared prefill (issue #5291)', () => {
expect(screen.getByTestId('window-type')).toHaveTextContent('rolling');
});
it('starts new anomaly alerts at 3 deviations', () => {
renderWithSearch(`?ruleType=anomaly_rule`);
expect(screen.getByTestId('threshold-count')).toHaveTextContent('1');
expect(screen.getByTestId('threshold-value')).toHaveTextContent('3');
});
it('applies the meter evaluation window when the preset is declared', () => {
// Ingestion-style prefill: explicit in_total + meter preset.
renderWithSearch(

View File

@@ -35,6 +35,11 @@ export const INITIAL_CRITICAL_THRESHOLD: Threshold = {
color: Color.BG_SAKURA_500,
};
export const INITIAL_ANOMALY_THRESHOLD: Threshold = {
...INITIAL_CRITICAL_THRESHOLD,
thresholdValue: 3,
};
export const INITIAL_WARNING_THRESHOLD: Threshold = {
id: v4(),
label: 'warning',

View File

@@ -22,7 +22,10 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { INITIAL_CREATE_ALERT_STATE } from './constants';
import {
INITIAL_ANOMALY_THRESHOLD,
INITIAL_CREATE_ALERT_STATE,
} from './constants';
import {
EvaluationWindowPreset,
resolveUrlAlertPrefill,
@@ -143,12 +146,20 @@ export function CreateAlertProvider(
const handleAlertTypeChange = useCallback(
(value: AlertTypes): void => {
const queryToRedirect = buildInitialAlertDef(value);
const currentQueryToRedirect = mapQueryDataFromApi(
queryToRedirect.condition.compositeQuery,
);
// Threshold and anomaly alerts share the metric query, so switching keeps it.
const metricAlertTypes = [
AlertTypes.METRICS_BASED_ALERT,
AlertTypes.ANOMALY_BASED_ALERT,
];
const keepsQuery =
value !== alertType &&
metricAlertTypes.includes(value) &&
metricAlertTypes.includes(alertType);
const queryToRedirect = keepsQuery
? currentQuery
: mapQueryDataFromApi(buildInitialAlertDef(value).condition.compositeQuery);
redirectWithQueryBuilderData(
currentQueryToRedirect,
queryToRedirect,
{
[QueryParams.alertType]: value,
[QueryParams.ruleType]:
@@ -161,7 +172,7 @@ export function CreateAlertProvider(
);
setAlertType(value);
},
[redirectWithQueryBuilderData],
[alertType, currentQuery, redirectWithQueryBuilderData],
);
const ruleNameAppliedRef = useRef(false);
@@ -183,6 +194,16 @@ export function CreateAlertProvider(
},
});
if (alertType === AlertTypes.ANOMALY_BASED_ALERT) {
setCreateAlertState({
slice: CreateAlertSlice.THRESHOLD,
action: {
type: 'SET_THRESHOLDS',
payload: [INITIAL_ANOMALY_THRESHOLD],
},
});
}
if (urlPrefill.thresholds) {
setCreateAlertState({
slice: CreateAlertSlice.THRESHOLD,

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,8 @@ 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 { AlertTypes } from 'types/api/alerts/alertTypes';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import { v4 } from 'uuid';
@@ -248,13 +250,18 @@ export {
export function getThresholdStateFromAlertDef(
alertDef: PostableAlertRuleV2,
): AlertThresholdState {
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,
// anomaly targets are signed z-scores; the operator carries the direction
thresholdValue: isAnomalyRule
? Math.abs(threshold.target)
: threshold.target,
recoveryThresholdValue: null,
unit: threshold.targetUnit,
color: getColorForThreshold(threshold.name),
@@ -268,9 +275,26 @@ export function getThresholdStateFromAlertDef(
matchType:
alertDef.condition.thresholds?.spec[0].matchType ||
AlertThresholdMatchType.AT_LEAST_ONCE,
algorithm:
alertDef.condition.algorithm || INITIAL_ALERT_THRESHOLD_STATE.algorithm,
seasonality:
alertDef.condition.seasonality || INITIAL_ALERT_THRESHOLD_STATE.seasonality,
evaluationWindow:
(isAnomalyRule && alertDef.evaluation?.spec?.evalWindow) ||
INITIAL_ALERT_THRESHOLD_STATE.evaluationWindow,
};
}
// Anomaly rules are stored as metric alerts; only the rule type tells them apart.
export function getAlertTypeFromAlertDef(
alertDef: PostableAlertRuleV2 | undefined,
): AlertTypes {
if (alertDef?.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT) {
return AlertTypes.ANOMALY_BASED_ALERT;
}
return alertDef?.alertType as AlertTypes;
}
export function getCreateAlertLocalStateFromAlertDef(
alertDef: PostableAlertRuleV2 | undefined,
): CreateAlertState {

View File

@@ -10,11 +10,13 @@ import Spinner from 'components/Spinner';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { CreateAlertProvider } from 'container/CreateAlertV2/context';
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
import {
getAlertTypeFromAlertDef,
getCreateAlertLocalStateFromAlertDef,
} from 'container/CreateAlertV2/utils';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { useAlertRule } from 'providers/Alert';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
import { fromRuleDTOToPostableRuleV2 } from 'types/api/alerts/convert';
@@ -74,7 +76,7 @@ function AlertDetails(): JSX.Element {
const handleTabChange = (route: string): void => {
if (route === ROUTES.ALERT_HISTORY) {
logEvent('Alert History tab: Visited', { ruleId });
void logEvent('Alert History tab: Visited', { ruleId });
}
};
@@ -89,7 +91,7 @@ function AlertDetails(): JSX.Element {
<CreateAlertProvider
ruleId={ruleId || ''}
isEditMode
initialAlertType={alertRuleDetails?.alertType as AlertTypes}
initialAlertType={getAlertTypeFromAlertDef(alertRuleDetails)}
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';