Merge branch 'main' into tvats-custom-ttl-for-attributes

This commit is contained in:
Tushar Vats 2025-10-06 08:21:56 +05:30 committed by GitHub
commit 690b4c91b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
107 changed files with 6529 additions and 1335 deletions

View File

@ -251,7 +251,7 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
continue
}
}
results, err := r.Threshold.ShouldAlert(*series)
results, err := r.Threshold.ShouldAlert(*series, r.Unit())
if err != nil {
return nil, err
}
@ -301,7 +301,7 @@ func (r *AnomalyRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID,
continue
}
}
results, err := r.Threshold.ShouldAlert(*series)
results, err := r.Threshold.ShouldAlert(*series, r.Unit())
if err != nil {
return nil, err
}
@ -336,14 +336,19 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro
resultFPs := map[uint64]struct{}{}
var alerts = make(map[uint64]*ruletypes.Alert, len(res))
ruleReceivers := r.Threshold.GetRuleReceivers()
ruleReceiverMap := make(map[string][]string)
for _, value := range ruleReceivers {
ruleReceiverMap[value.Name] = value.Channels
}
for _, smpl := range res {
l := make(map[string]string, len(smpl.Metric))
for _, lbl := range smpl.Metric {
l[lbl.Name] = lbl.Value
}
value := valueFormatter.Format(smpl.V, r.Unit())
threshold := valueFormatter.Format(r.TargetVal(), r.Unit())
threshold := valueFormatter.Format(smpl.Target, smpl.TargetUnit)
r.logger.DebugContext(ctx, "Alert template data for rule", "rule_name", r.Name(), "formatter", valueFormatter.Name(), "value", value, "threshold", threshold)
tmplData := ruletypes.AlertTemplateData(l, value, threshold)
@ -408,13 +413,12 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro
State: model.StatePending,
Value: smpl.V,
GeneratorURL: r.GeneratorURL(),
Receivers: r.PreferredChannels(),
Receivers: ruleReceiverMap[lbs.Map()[ruletypes.LabelThresholdName]],
Missing: smpl.IsMissing,
}
}
r.logger.InfoContext(ctx, "number of alerts found", "rule_name", r.Name(), "alerts_count", len(alerts))
// alerts[h] is ready, add or update active list now
for h, a := range alerts {
// Check whether we already have alerting state for the identifying label set.
@ -423,7 +427,9 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro
alert.Value = a.Value
alert.Annotations = a.Annotations
alert.Receivers = r.PreferredChannels()
if v, ok := alert.Labels.Map()[ruletypes.LabelThresholdName]; ok {
alert.Receivers = ruleReceiverMap[v]
}
continue
}

View File

@ -126,7 +126,6 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
if parsedRule.RuleType == ruletypes.RuleTypeThreshold {
// add special labels for test alerts
parsedRule.Annotations[labels.AlertSummaryLabel] = fmt.Sprintf("The rule threshold is set to %.4f, and the observed metric value is {{$value}}.", *parsedRule.RuleCondition.Target)
parsedRule.Labels[labels.RuleSourceLabel] = ""
parsedRule.Labels[labels.AlertRuleIdLabel] = ""

View File

@ -0,0 +1,26 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
export interface UpdateAlertRuleResponse {
data: string;
status: string;
}
const updateAlertRule = async (
id: string,
postableAlertRule: PostableAlertRuleV2,
): Promise<SuccessResponse<UpdateAlertRuleResponse> | ErrorResponse> => {
const response = await axios.put(`/rules/${id}`, {
...postableAlertRule,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default updateAlertRule;

View File

@ -6,9 +6,7 @@ import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
export interface CreateRoutingPolicyBody {
name: string;
expression: string;
actions: {
channels: string[];
};
description?: string;
}
@ -23,7 +21,7 @@ const createRoutingPolicy = async (
SuccessResponseV2<CreateRoutingPolicyResponse> | ErrorResponseV2
> => {
try {
const response = await axios.post(`/notification-policy`, props);
const response = await axios.post(`/route_policies`, props);
return {
httpStatusCode: response.status,
data: response.data,

View File

@ -14,9 +14,7 @@ const deleteRoutingPolicy = async (
SuccessResponseV2<DeleteRoutingPolicyResponse> | ErrorResponseV2
> => {
try {
const response = await axios.delete(
`/notification-policy/${routingPolicyId}`,
);
const response = await axios.delete(`/route_policies/${routingPolicyId}`);
return {
httpStatusCode: response.status,

View File

@ -25,7 +25,7 @@ export const getRoutingPolicies = async (
headers?: Record<string, string>,
): Promise<SuccessResponseV2<GetRoutingPoliciesResponse> | ErrorResponseV2> => {
try {
const response = await axios.get('/notification-policy', {
const response = await axios.get('/route_policies', {
signal,
headers,
});

View File

@ -6,9 +6,7 @@ import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
export interface UpdateRoutingPolicyBody {
name: string;
expression: string;
actions: {
channels: string[];
};
description: string;
}
@ -24,7 +22,7 @@ const updateRoutingPolicy = async (
SuccessResponseV2<UpdateRoutingPolicyResponse> | ErrorResponseV2
> => {
try {
const response = await axios.put(`/notification-policy/${id}`, {
const response = await axios.put(`/route_policies/${id}`, {
...props,
});

View File

@ -3,7 +3,6 @@ import logEvent from 'api/common/logEvent';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import CreateAlertV2 from 'container/CreateAlertV2';
import { showNewCreateAlertsPage } from 'container/CreateAlertV2/utils';
import FormAlertRules, { AlertDetectionTypes } from 'container/FormAlertRules';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
@ -127,7 +126,8 @@ function CreateRules(): JSX.Element {
);
}
const showNewCreateAlertsPageFlag = showNewCreateAlertsPage();
const showNewCreateAlertsPageFlag =
queryParams.get('showNewCreateAlertsPage') === 'true';
if (
showNewCreateAlertsPageFlag &&

View File

@ -13,14 +13,12 @@ import APIError from 'types/api/error';
import { useCreateAlertState } from '../context';
import AdvancedOptions from '../EvaluationSettings/AdvancedOptions';
import Stepper from '../Stepper';
import { showCondensedLayout } from '../utils';
import AlertThreshold from './AlertThreshold';
import AnomalyThreshold from './AnomalyThreshold';
import { ANOMALY_TAB_TOOLTIP, THRESHOLD_TAB_TOOLTIP } from './constants';
function AlertCondition(): JSX.Element {
const { alertType, setAlertType } = useCreateAlertState();
const showCondensedLayoutFlag = showCondensedLayout();
const {
data,
@ -108,11 +106,9 @@ function AlertCondition(): JSX.Element {
refreshChannels={refreshChannels}
/>
)}
{showCondensedLayoutFlag ? (
<div className="condensed-advanced-options-container">
<AdvancedOptions />
</div>
) : null}
</div>
);
}

View File

@ -4,7 +4,10 @@ import '../EvaluationSettings/styles.scss';
import { Button, Select, Tooltip, Typography } from 'antd';
import classNames from 'classnames';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import getRandomColor from 'lib/getRandomColor';
import { Plus } from 'lucide-react';
import { useEffect } from 'react';
import { v4 } from 'uuid';
import { useCreateAlertState } from '../context';
import {
@ -15,7 +18,6 @@ import {
THRESHOLD_OPERATOR_OPTIONS,
} from '../context/constants';
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
import { showCondensedLayout } from '../utils';
import ThresholdItem from './ThresholdItem';
import { AnomalyAndThresholdProps, UpdateThreshold } from './types';
import {
@ -40,12 +42,23 @@ function AlertThreshold({
setNotificationSettings,
} = useCreateAlertState();
const showCondensedLayoutFlag = showCondensedLayout();
const { currentQuery } = useQueryBuilder();
const queryNames = getQueryNames(currentQuery);
useEffect(() => {
if (
queryNames.length > 0 &&
!queryNames.some((query) => query.value === thresholdState.selectedQuery)
) {
setThresholdState({
type: 'SET_SELECTED_QUERY',
payload: queryNames[0].value,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queryNames, thresholdState.selectedQuery]);
const selectedCategory = getCategoryByOptionId(alertState.yAxisUnit || '');
const categorySelectOptions = getCategorySelectOptionByName(
selectedCategory || '',
@ -54,11 +67,15 @@ function AlertThreshold({
const addThreshold = (): void => {
let newThreshold;
if (thresholdState.thresholds.length === 1) {
newThreshold = INITIAL_WARNING_THRESHOLD;
newThreshold = { ...INITIAL_WARNING_THRESHOLD, id: v4() };
} else if (thresholdState.thresholds.length === 2) {
newThreshold = INITIAL_INFO_THRESHOLD;
newThreshold = { ...INITIAL_INFO_THRESHOLD, id: v4() };
} else {
newThreshold = INITIAL_RANDOM_THRESHOLD;
newThreshold = {
...INITIAL_RANDOM_THRESHOLD,
id: v4(),
color: getRandomColor(),
};
}
setThresholdState({
type: 'SET_THRESHOLDS',
@ -143,17 +160,12 @@ function AlertThreshold({
}),
);
const evaluationWindowContext = showCondensedLayoutFlag ? (
<EvaluationSettings />
) : (
<strong>Evaluation Window.</strong>
);
return (
<div
className={classNames('alert-threshold-container', {
'condensed-alert-threshold-container': showCondensedLayoutFlag,
})}
className={classNames(
'alert-threshold-container',
'condensed-alert-threshold-container',
)}
>
{/* Main condition sentence */}
<div className="alert-condition-sentences">
@ -199,7 +211,7 @@ function AlertThreshold({
options={matchTypeOptionsWithTooltips}
/>
<Typography.Text className="sentence-text">
during the {evaluationWindowContext}
during the <EvaluationSettings />
</Typography.Text>
</div>
</div>

View File

@ -108,6 +108,10 @@ jest.mock('container/NewWidget/RightContainer/alertFomatCategories', () => ({
]),
}));
jest.mock('container/CreateAlertV2/utils', () => ({
...jest.requireActual('container/CreateAlertV2/utils'),
}));
const TEST_STRINGS = {
ADD_THRESHOLD: 'Add Threshold',
AT_LEAST_ONCE: 'AT LEAST ONCE',
@ -154,7 +158,9 @@ describe('AlertThreshold', () => {
expect(screen.getByText('Send a notification when')).toBeInTheDocument();
expect(screen.getByText('the threshold(s)')).toBeInTheDocument();
expect(screen.getByText('during the')).toBeInTheDocument();
expect(screen.getByText('Evaluation Window.')).toBeInTheDocument();
expect(
screen.getByTestId('condensed-evaluation-settings-container'),
).toBeInTheDocument();
});
it('renders query selection dropdown', async () => {
@ -204,11 +210,11 @@ describe('AlertThreshold', () => {
// First addition should add WARNING threshold
fireEvent.click(addButton);
expect(screen.getByText('WARNING')).toBeInTheDocument();
expect(screen.getByText('warning')).toBeInTheDocument();
// Second addition should add INFO threshold
fireEvent.click(addButton);
expect(screen.getByText('INFO')).toBeInTheDocument();
expect(screen.getByText('info')).toBeInTheDocument();
// Third addition should add random threshold
fireEvent.click(addButton);
@ -280,7 +286,7 @@ describe('AlertThreshold', () => {
renderAlertThreshold();
// Should have initial critical threshold
expect(screen.getByText('CRITICAL')).toBeInTheDocument();
expect(screen.getByText('critical')).toBeInTheDocument();
verifySelectRenders(TEST_STRINGS.IS_ABOVE);
verifySelectRenders(TEST_STRINGS.AT_LEAST_ONCE);
});

View File

@ -494,10 +494,17 @@
}
}
}
}
.add-threshold-btn {
.add-threshold-btn,
.ant-btn.add-threshold-btn {
border: 1px dashed var(--bg-vanilla-300);
color: var(--bg-ink-300);
background-color: transparent;
.ant-typography {
color: var(--bg-ink-400);
}
&:hover {
border-color: var(--bg-ink-300);
@ -506,7 +513,6 @@
}
}
}
}
.condensed-evaluation-settings-container {
.ant-btn {

View File

@ -1,5 +1,6 @@
import './styles.scss';
import classNames from 'classnames';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useCallback, useMemo } from 'react';
import { Labels } from 'types/api/alerts/def';
@ -8,7 +9,7 @@ import { useCreateAlertState } from '../context';
import LabelsInput from './LabelsInput';
function CreateAlertHeader(): JSX.Element {
const { alertState, setAlertState } = useCreateAlertState();
const { alertState, setAlertState, isEditMode } = useCreateAlertState();
const { currentQuery } = useQueryBuilder();
@ -34,10 +35,14 @@ function CreateAlertHeader(): JSX.Element {
);
return (
<div className="alert-header">
<div
className={classNames('alert-header', { 'edit-alert-header': isEditMode })}
>
{!isEditMode && (
<div className="alert-header__tab-bar">
<div className="alert-header__tab">New Alert Rule</div>
</div>
)}
<div className="alert-header__content">
<input
type="text"

View File

@ -1,9 +1,12 @@
/* eslint-disable react/jsx-props-no-spreading */
import { fireEvent, render, screen } from '@testing-library/react';
import { defaultPostableAlertRuleV2 } from 'container/CreateAlertV2/constants';
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import * as useCreateAlertRuleHook from '../../../../hooks/alerts/useCreateAlertRule';
import * as useTestAlertRuleHook from '../../../../hooks/alerts/useTestAlertRule';
import * as useUpdateAlertRuleHook from '../../../../hooks/alerts/useUpdateAlertRule';
import { CreateAlertProvider } from '../../context';
import CreateAlertHeader from '../CreateAlertHeader';
@ -15,6 +18,10 @@ jest.spyOn(useTestAlertRuleHook, 'useTestAlertRule').mockReturnValue({
mutate: jest.fn(),
isLoading: false,
} as any);
jest.spyOn(useUpdateAlertRuleHook, 'useUpdateAlertRule').mockReturnValue({
mutate: jest.fn(),
isLoading: false,
} as any);
jest.mock('uplot', () => {
const paths = {
@ -37,6 +44,8 @@ jest.mock('react-router-dom', () => ({
}),
}));
const ENTER_ALERT_RULE_NAME_PLACEHOLDER = 'Enter alert rule name';
const renderCreateAlertHeader = (): ReturnType<typeof render> =>
render(
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
@ -52,7 +61,9 @@ describe('CreateAlertHeader', () => {
it('renders name input with placeholder', () => {
renderCreateAlertHeader();
const nameInput = screen.getByPlaceholderText('Enter alert rule name');
const nameInput = screen.getByPlaceholderText(
ENTER_ALERT_RULE_NAME_PLACEHOLDER,
);
expect(nameInput).toBeInTheDocument();
});
@ -63,10 +74,30 @@ describe('CreateAlertHeader', () => {
it('updates name when typing in name input', () => {
renderCreateAlertHeader();
const nameInput = screen.getByPlaceholderText('Enter alert rule name');
const nameInput = screen.getByPlaceholderText(
ENTER_ALERT_RULE_NAME_PLACEHOLDER,
);
fireEvent.change(nameInput, { target: { value: 'Test Alert' } });
expect(nameInput).toHaveValue('Test Alert');
});
it('renders the header with title when isEditMode is true', () => {
render(
<CreateAlertProvider
isEditMode
initialAlertType={AlertTypes.METRICS_BASED_ALERT}
initialAlertState={getCreateAlertLocalStateFromAlertDef(
defaultPostableAlertRuleV2,
)}
>
<CreateAlertHeader />
</CreateAlertProvider>,
);
expect(screen.queryByText('New Alert Rule')).not.toBeInTheDocument();
expect(
screen.getByPlaceholderText(ENTER_ALERT_RULE_NAME_PLACEHOLDER),
).toHaveValue('TEST_ALERT');
});
});

View File

@ -175,10 +175,19 @@
}
}
.edit-alert-header {
width: 100%;
}
.edit-alert-header .alert-header__content {
background: var(--bg-vanilla-200);
}
.labels-input {
&__add-button {
color: var(--bg-ink-400);
border: 1px solid var(--bg-vanilla-300);
background-color: var(--bg-vanilla-100);
&:hover {
border-color: var(--bg-ink-300);

View File

@ -8,12 +8,11 @@ import AlertCondition from './AlertCondition';
import { CreateAlertProvider } from './context';
import { buildInitialAlertDef } from './context/utils';
import CreateAlertHeader from './CreateAlertHeader';
import EvaluationSettings from './EvaluationSettings';
import Footer from './Footer';
import NotificationSettings from './NotificationSettings';
import QuerySection from './QuerySection';
import { CreateAlertV2Props } from './types';
import { showCondensedLayout, Spinner } from './utils';
import { Spinner } from './utils';
function CreateAlertV2({ alertType }: CreateAlertV2Props): JSX.Element {
const queryToRedirect = buildInitialAlertDef(alertType);
@ -23,8 +22,6 @@ function CreateAlertV2({ alertType }: CreateAlertV2Props): JSX.Element {
useShareBuilderUrl({ defaultValue: currentQueryToRedirect });
const showCondensedLayoutFlag = showCondensedLayout();
return (
<CreateAlertProvider initialAlertType={alertType}>
<Spinner />
@ -32,7 +29,6 @@ function CreateAlertV2({ alertType }: CreateAlertV2Props): JSX.Element {
<CreateAlertHeader />
<QuerySection />
<AlertCondition />
{!showCondensedLayoutFlag ? <EvaluationSettings /> : null}
<NotificationSettings />
</div>
<Footer />

View File

@ -2,7 +2,7 @@ import './styles.scss';
import { Switch, Tooltip, Typography } from 'antd';
import { Info } from 'lucide-react';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { IAdvancedOptionItemProps } from '../types';
@ -12,9 +12,14 @@ function AdvancedOptionItem({
input,
tooltipText,
onToggle,
defaultShowInput,
}: IAdvancedOptionItemProps): JSX.Element {
const [showInput, setShowInput] = useState<boolean>(false);
useEffect(() => {
setShowInput(defaultShowInput);
}, [defaultShowInput]);
const handleOnToggle = (): void => {
onToggle?.();
setShowInput((currentShowInput) => !currentShowInput);
@ -42,7 +47,7 @@ function AdvancedOptionItem({
>
{input}
</div>
<Switch onChange={handleOnToggle} />
<Switch onChange={handleOnToggle} checked={showInput} />
</div>
</div>
);

View File

@ -42,6 +42,7 @@ function AdvancedOptions(): JSX.Element {
payload: !advancedOptions.sendNotificationIfDataIsMissing.enabled,
})
}
defaultShowInput={advancedOptions.sendNotificationIfDataIsMissing.enabled}
/>
<AdvancedOptionItem
title="Minimum data required"
@ -72,6 +73,7 @@ function AdvancedOptions(): JSX.Element {
payload: !advancedOptions.enforceMinimumDatapoints.enabled,
})
}
defaultShowInput={advancedOptions.enforceMinimumDatapoints.enabled}
/>
{/* TODO: Add back when the functionality is implemented */}
{/* <AdvancedOptionItem

View File

@ -1,28 +1,19 @@
import './styles.scss';
import { Button, Popover, Typography } from 'antd';
import { Button, Popover } from 'antd';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { useState } from 'react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { useCreateAlertState } from '../context';
import Stepper from '../Stepper';
import { showCondensedLayout } from '../utils';
import AdvancedOptions from './AdvancedOptions';
import EvaluationWindowPopover from './EvaluationWindowPopover';
import { getEvaluationWindowTypeText, getTimeframeText } from './utils';
function EvaluationSettings(): JSX.Element {
const {
alertType,
evaluationWindow,
setEvaluationWindow,
} = useCreateAlertState();
const { evaluationWindow, setEvaluationWindow } = useCreateAlertState();
const [
isEvaluationWindowPopoverOpen,
setIsEvaluationWindowPopoverOpen,
] = useState(false);
const showCondensedLayoutFlag = showCondensedLayout();
const popoverContent = (
<Popover
@ -57,8 +48,6 @@ function EvaluationSettings(): JSX.Element {
</Popover>
);
// Layout consists of only the evaluation window popover
if (showCondensedLayoutFlag) {
return (
<div
className="condensed-evaluation-settings-container"
@ -67,25 +56,6 @@ function EvaluationSettings(): JSX.Element {
{popoverContent}
</div>
);
}
// Layout consists of
// - Stepper header
// - Evaluation window popover
// - Advanced options
return (
<div className="evaluation-settings-container">
<Stepper stepNumber={3} label="Evaluation settings" />
{alertType !== AlertTypes.ANOMALY_BASED_ALERT && (
<div className="evaluate-alert-conditions-container">
<Typography.Text>Check conditions using data from</Typography.Text>
<div className="evaluate-alert-conditions-separator" />
{popoverContent}
</div>
)}
<AdvancedOptions />
</div>
);
}
export default EvaluationSettings;

View File

@ -33,6 +33,7 @@ describe('AdvancedOptionItem', () => {
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
defaultShowInput={false}
/>,
);
@ -50,6 +51,7 @@ describe('AdvancedOptionItem', () => {
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
defaultShowInput={false}
/>,
);
@ -65,6 +67,7 @@ describe('AdvancedOptionItem', () => {
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
defaultShowInput={false}
/>,
);
@ -88,6 +91,7 @@ describe('AdvancedOptionItem', () => {
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
defaultShowInput={false}
/>,
);
@ -117,6 +121,7 @@ describe('AdvancedOptionItem', () => {
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
defaultShowInput={false}
/>,
);
@ -146,6 +151,7 @@ describe('AdvancedOptionItem', () => {
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
defaultShowInput={false}
/>,
);
@ -160,9 +166,24 @@ describe('AdvancedOptionItem', () => {
description={defaultProps.description}
input={defaultProps.input}
tooltipText="mock tooltip text"
defaultShowInput={false}
/>,
);
const tooltipIcon = screen.getByTestId('tooltip-icon');
expect(tooltipIcon).toBeInTheDocument();
});
it('should show input when defaultShowInput is true', () => {
render(
<AdvancedOptionItem
title={defaultProps.title}
description={defaultProps.description}
input={defaultProps.input}
defaultShowInput
/>,
);
const inputElement = screen.getByTestId(TEST_INPUT_TEST_ID);
expect(inputElement).toBeInTheDocument();
expect(inputElement).toBeVisible();
});
});

View File

@ -1,11 +1,13 @@
import { render, screen } from '@testing-library/react';
import * as alertState from 'container/CreateAlertV2/context';
import * as utils from 'container/CreateAlertV2/utils';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import EvaluationSettings from '../EvaluationSettings';
import { createMockAlertContextState } from './testUtils';
jest.mock('container/CreateAlertV2/utils', () => ({
...jest.requireActual('container/CreateAlertV2/utils'),
}));
const mockSetEvaluationWindow = jest.fn();
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
createMockAlertContextState({
@ -13,52 +15,14 @@ jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
}),
);
jest.mock('../AdvancedOptions', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="advanced-options">AdvancedOptions</div>
),
}));
const EVALUATION_SETTINGS_TEXT = 'Evaluation settings';
const CHECK_CONDITIONS_USING_DATA_FROM_TEXT =
'Check conditions using data from';
describe('EvaluationSettings', () => {
it('should render the default evaluation settings layout', () => {
render(<EvaluationSettings />);
expect(screen.getByText(EVALUATION_SETTINGS_TEXT)).toBeInTheDocument();
expect(
screen.getByText(CHECK_CONDITIONS_USING_DATA_FROM_TEXT),
).toBeInTheDocument();
expect(screen.getByTestId('advanced-options')).toBeInTheDocument();
});
it('should not render evaluation window for anomaly based alert', () => {
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValueOnce(
createMockAlertContextState({
alertType: AlertTypes.ANOMALY_BASED_ALERT,
}),
);
render(<EvaluationSettings />);
expect(screen.getByText(EVALUATION_SETTINGS_TEXT)).toBeInTheDocument();
expect(
screen.queryByText(CHECK_CONDITIONS_USING_DATA_FROM_TEXT),
).not.toBeInTheDocument();
});
it('should render the condensed evaluation settings layout', () => {
jest.spyOn(utils, 'showCondensedLayout').mockReturnValueOnce(true);
render(<EvaluationSettings />);
// Header, check conditions using data from and advanced options should be hidden
expect(screen.queryByText(EVALUATION_SETTINGS_TEXT)).not.toBeInTheDocument();
expect(
screen.queryByText(CHECK_CONDITIONS_USING_DATA_FROM_TEXT),
).not.toBeInTheDocument();
expect(screen.queryByTestId('advanced-options')).not.toBeInTheDocument();
// Only evaluation window popover should be visible
expect(
screen.getByTestId('condensed-evaluation-settings-container'),
).toBeInTheDocument();
// Verify that default option is selected
expect(screen.getByText('Rolling')).toBeInTheDocument();
expect(screen.getByText('Last 5 minutes')).toBeInTheDocument();
});
});

View File

@ -31,6 +31,9 @@ export const createMockAlertContextState = (
isCreatingAlertRule: false,
isTestingAlertRule: false,
createAlertRule: jest.fn(),
isUpdatingAlertRule: false,
updateAlertRule: jest.fn(),
isEditMode: false,
...overrides,
});

View File

@ -11,6 +11,7 @@ export interface IAdvancedOptionItemProps {
input: JSX.Element;
tooltipText?: string;
onToggle?: () => void;
defaultShowInput: boolean;
}
export enum RollingWindowTimeframes {

View File

@ -26,11 +26,17 @@ function Footer(): JSX.Element {
isCreatingAlertRule,
testAlertRule,
isTestingAlertRule,
updateAlertRule,
isUpdatingAlertRule,
isEditMode,
} = useCreateAlertState();
const { currentQuery } = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const handleDiscard = (): void => discardAlertRule();
const handleDiscard = (): void => {
discardAlertRule();
safeNavigate('/alerts');
};
const alertValidationMessage = useMemo(
() =>
@ -99,6 +105,17 @@ function Footer(): JSX.Element {
notificationSettings,
query: currentQuery,
});
if (isEditMode) {
updateAlertRule(payload, {
onSuccess: () => {
toast.success('Alert rule updated successfully');
safeNavigate('/alerts');
},
onError: (error) => {
toast.error(error.message);
},
});
} else {
createAlertRule(payload, {
onSuccess: () => {
toast.success('Alert rule created successfully');
@ -108,6 +125,7 @@ function Footer(): JSX.Element {
toast.error(error.message);
},
});
}
}, [
alertType,
basicAlertState,
@ -116,16 +134,22 @@ function Footer(): JSX.Element {
evaluationWindow,
notificationSettings,
currentQuery,
isEditMode,
updateAlertRule,
createAlertRule,
safeNavigate,
]);
const disableButtons =
isCreatingAlertRule || isTestingAlertRule || !!alertValidationMessage;
isCreatingAlertRule || isTestingAlertRule || isUpdatingAlertRule;
const saveAlertButton = useMemo(() => {
let button = (
<Button type="primary" onClick={handleSaveAlert} disabled={disableButtons}>
<Button
type="primary"
onClick={handleSaveAlert}
disabled={disableButtons || Boolean(alertValidationMessage)}
>
<Check size={14} />
<Typography.Text>Save Alert Rule</Typography.Text>
</Button>
@ -141,7 +165,7 @@ function Footer(): JSX.Element {
<Button
type="default"
onClick={handleTestNotification}
disabled={disableButtons}
disabled={disableButtons || Boolean(alertValidationMessage)}
>
<Send size={14} />
<Typography.Text>Test Notification</Typography.Text>
@ -155,7 +179,7 @@ function Footer(): JSX.Element {
return (
<div className="create-alert-v2-footer">
<Button type="text" onClick={handleDiscard} disabled={disableButtons}>
<Button type="default" onClick={handleDiscard} disabled={disableButtons}>
<X size={14} /> Discard
</Button>
<div className="button-group">

View File

@ -0,0 +1,248 @@
import { fireEvent, render, screen } from '@testing-library/react';
import {
AlertThresholdMatchType,
AlertThresholdOperator,
} from 'container/CreateAlertV2/context/types';
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import * as createAlertState from '../../context';
import Footer from '../Footer';
// Mock the hooks used by Footer component
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: jest.fn(),
}));
const mockCreateAlertRule = jest.fn();
const mockTestAlertRule = jest.fn();
const mockUpdateAlertRule = jest.fn();
const mockDiscardAlertRule = jest.fn();
// Import the mocked hooks
const { useQueryBuilder } = jest.requireMock(
'hooks/queryBuilder/useQueryBuilder',
);
const { useSafeNavigate } = jest.requireMock('hooks/useSafeNavigate');
const mockAlertContextState = createMockAlertContextState({
createAlertRule: mockCreateAlertRule,
testAlertRule: mockTestAlertRule,
updateAlertRule: mockUpdateAlertRule,
discardAlertRule: mockDiscardAlertRule,
alertState: {
name: 'Test Alert',
labels: {},
yAxisUnit: undefined,
},
thresholdState: {
selectedQuery: 'A',
operator: AlertThresholdOperator.ABOVE_BELOW,
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
evaluationWindow: '5m0s',
algorithm: 'standard',
seasonality: 'hourly',
thresholds: [
{
id: '1',
label: 'CRITICAL',
thresholdValue: 0,
recoveryThresholdValue: null,
unit: '',
channels: ['test-channel'],
color: '#ff0000',
},
],
},
});
jest
.spyOn(createAlertState, 'useCreateAlertState')
.mockReturnValue(mockAlertContextState);
const SAVE_ALERT_RULE_TEXT = 'Save Alert Rule';
const TEST_NOTIFICATION_TEXT = 'Test Notification';
const DISCARD_TEXT = 'Discard';
describe('Footer', () => {
beforeEach(() => {
useQueryBuilder.mockReturnValue({
currentQuery: {
builder: {
queryData: [],
queryFormulas: [],
},
promql: [],
clickhouse_sql: [],
queryType: 'builder',
},
});
useSafeNavigate.mockReturnValue({
safeNavigate: jest.fn(),
});
});
it('should render the component with 3 buttons', () => {
render(<Footer />);
expect(screen.getByText(SAVE_ALERT_RULE_TEXT)).toBeInTheDocument();
expect(screen.getByText(TEST_NOTIFICATION_TEXT)).toBeInTheDocument();
expect(screen.getByText(DISCARD_TEXT)).toBeInTheDocument();
});
it('discard action works correctly', () => {
render(<Footer />);
fireEvent.click(screen.getByText(DISCARD_TEXT));
expect(mockDiscardAlertRule).toHaveBeenCalled();
});
it('save alert rule action works correctly', () => {
render(<Footer />);
fireEvent.click(screen.getByText(SAVE_ALERT_RULE_TEXT));
expect(mockCreateAlertRule).toHaveBeenCalled();
});
it('update alert rule action works correctly', () => {
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
...mockAlertContextState,
isEditMode: true,
});
render(<Footer />);
fireEvent.click(screen.getByText(SAVE_ALERT_RULE_TEXT));
expect(mockUpdateAlertRule).toHaveBeenCalled();
});
it('test notification action works correctly', () => {
render(<Footer />);
fireEvent.click(screen.getByText(TEST_NOTIFICATION_TEXT));
expect(mockTestAlertRule).toHaveBeenCalled();
});
it('all buttons are disabled when creating alert rule', () => {
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
...mockAlertContextState,
isCreatingAlertRule: true,
});
render(<Footer />);
expect(
screen.getByRole('button', { name: /save alert rule/i }),
).toBeDisabled();
expect(
screen.getByRole('button', { name: /test notification/i }),
).toBeDisabled();
expect(screen.getByRole('button', { name: /discard/i })).toBeDisabled();
});
it('all buttons are disabled when updating alert rule', () => {
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
...mockAlertContextState,
isUpdatingAlertRule: true,
});
render(<Footer />);
// Target the button elements directly instead of the text spans inside them
expect(
screen.getByRole('button', { name: /save alert rule/i }),
).toBeDisabled();
expect(
screen.getByRole('button', { name: /test notification/i }),
).toBeDisabled();
expect(screen.getByRole('button', { name: /discard/i })).toBeDisabled();
});
it('all buttons are disabled when testing alert rule', () => {
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
...mockAlertContextState,
isTestingAlertRule: true,
});
render(<Footer />);
// Target the button elements directly instead of the text spans inside them
expect(
screen.getByRole('button', { name: /save alert rule/i }),
).toBeDisabled();
expect(
screen.getByRole('button', { name: /test notification/i }),
).toBeDisabled();
expect(screen.getByRole('button', { name: /discard/i })).toBeDisabled();
});
it('create and test buttons are disabled when alert name is missing', () => {
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
...mockAlertContextState,
alertState: {
...mockAlertContextState.alertState,
name: '',
},
});
render(<Footer />);
expect(
screen.getByRole('button', { name: /save alert rule/i }),
).toBeDisabled();
expect(
screen.getByRole('button', { name: /test notification/i }),
).toBeDisabled();
});
it('create and test buttons are disabled when notifcation channels are missing and routing policies are disabled', () => {
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
...mockAlertContextState,
notificationSettings: {
...mockAlertContextState.notificationSettings,
routingPolicies: false,
},
thresholdState: {
...mockAlertContextState.thresholdState,
thresholds: [
{
...mockAlertContextState.thresholdState.thresholds[0],
channels: [],
},
],
},
});
render(<Footer />);
expect(
screen.getByRole('button', { name: /save alert rule/i }),
).toBeDisabled();
expect(
screen.getByRole('button', { name: /test notification/i }),
).toBeDisabled();
});
it('buttons are enabled even with no notification channels when routing policies are enabled', () => {
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
...mockAlertContextState,
notificationSettings: {
...mockAlertContextState.notificationSettings,
routingPolicies: true,
},
thresholdState: {
...mockAlertContextState.thresholdState,
thresholds: [
{
...mockAlertContextState.thresholdState.thresholds[0],
channels: [],
},
],
},
});
render(<Footer />);
expect(
screen.getByRole('button', { name: /save alert rule/i }),
).toBeEnabled();
expect(
screen.getByRole('button', { name: /test notification/i }),
).toBeEnabled();
expect(screen.getByRole('button', { name: /discard/i })).toBeEnabled();
});
});

View File

@ -0,0 +1,524 @@
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { initialQueriesMap } from 'constants/queryBuilder';
import {
INITIAL_ADVANCED_OPTIONS_STATE,
INITIAL_ALERT_STATE,
INITIAL_ALERT_THRESHOLD_STATE,
INITIAL_EVALUATION_WINDOW_STATE,
INITIAL_NOTIFICATION_SETTINGS_STATE,
} from 'container/CreateAlertV2/context/constants';
import {
AdvancedOptionsState,
EvaluationWindowState,
NotificationSettingsState,
} from 'container/CreateAlertV2/context/types';
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { EQueryType } from 'types/common/dashboard';
import { BuildCreateAlertRulePayloadArgs } from '../types';
import {
buildCreateThresholdAlertRulePayload,
getAlertOnAbsentProps,
getEnforceMinimumDatapointsProps,
getEvaluationProps,
getFormattedTimeValue,
getNotificationSettingsProps,
validateCreateAlertState,
} from '../utils';
describe('Footer utils', () => {
describe('getFormattedTimeValue', () => {
it('for 60 seconds', () => {
expect(getFormattedTimeValue(60, UniversalYAxisUnit.SECONDS)).toBe('60s');
});
it('for 60 minutes', () => {
expect(getFormattedTimeValue(60, UniversalYAxisUnit.MINUTES)).toBe('60m');
});
it('for 60 hours', () => {
expect(getFormattedTimeValue(60, UniversalYAxisUnit.HOURS)).toBe('60h');
});
it('for 60 days', () => {
expect(getFormattedTimeValue(60, UniversalYAxisUnit.DAYS)).toBe('60d');
});
});
describe('validateCreateAlertState', () => {
const args: BuildCreateAlertRulePayloadArgs = {
alertType: AlertTypes.METRICS_BASED_ALERT,
basicAlertState: INITIAL_ALERT_STATE,
thresholdState: INITIAL_ALERT_THRESHOLD_STATE,
advancedOptions: INITIAL_ADVANCED_OPTIONS_STATE,
evaluationWindow: INITIAL_EVALUATION_WINDOW_STATE,
notificationSettings: INITIAL_NOTIFICATION_SETTINGS_STATE,
query: initialQueriesMap.metrics,
};
it('when alert name is not provided', () => {
expect(validateCreateAlertState(args)).toBeDefined();
expect(validateCreateAlertState(args)).toBe('Please enter an alert name');
});
it('when threshold label is not provided', () => {
const currentArgs: BuildCreateAlertRulePayloadArgs = {
...args,
basicAlertState: {
...args.basicAlertState,
name: 'test name',
},
thresholdState: {
...args.thresholdState,
thresholds: [
{
...args.thresholdState.thresholds[0],
label: '',
},
],
},
};
expect(validateCreateAlertState(currentArgs)).toBeDefined();
expect(validateCreateAlertState(currentArgs)).toBe(
'Please enter a label for each threshold',
);
});
it('when threshold channels are not provided', () => {
const currentArgs: BuildCreateAlertRulePayloadArgs = {
...args,
basicAlertState: {
...args.basicAlertState,
name: 'test name',
},
};
expect(validateCreateAlertState(currentArgs)).toBeDefined();
expect(validateCreateAlertState(currentArgs)).toBe(
'Please select at least one channel for each threshold or enable routing policies',
);
});
it('when threshold channels are not provided but routing policies are enabled', () => {
const currentArgs: BuildCreateAlertRulePayloadArgs = {
...args,
basicAlertState: {
...args.basicAlertState,
name: 'test name',
},
notificationSettings: {
...args.notificationSettings,
routingPolicies: true,
},
};
expect(validateCreateAlertState(currentArgs)).toBeNull();
});
it('when threshold channels are provided', () => {
const currentArgs: BuildCreateAlertRulePayloadArgs = {
...args,
basicAlertState: {
...args.basicAlertState,
name: 'test name',
},
thresholdState: {
...args.thresholdState,
thresholds: [
{
...args.thresholdState.thresholds[0],
channels: ['test channel'],
},
],
},
};
expect(validateCreateAlertState(currentArgs)).toBeNull();
});
});
describe('getNotificationSettingsProps', () => {
it('when initial notification settings are provided', () => {
const notificationSettings = INITIAL_NOTIFICATION_SETTINGS_STATE;
const props = getNotificationSettingsProps(notificationSettings);
expect(props).toBeDefined();
expect(props).toStrictEqual({
groupBy: [],
renotify: {
enabled: false,
interval: '30m',
alertStates: [],
},
usePolicy: false,
});
});
});
it('renotification is enabled', () => {
const notificationSettings: NotificationSettingsState = {
...INITIAL_NOTIFICATION_SETTINGS_STATE,
reNotification: {
enabled: true,
value: 30,
unit: UniversalYAxisUnit.MINUTES,
conditions: ['firing'],
},
};
const props = getNotificationSettingsProps(notificationSettings);
expect(props).toBeDefined();
expect(props).toStrictEqual({
groupBy: [],
renotify: {
enabled: true,
interval: '30m',
alertStates: ['firing'],
},
usePolicy: false,
});
});
it('routing policies are enabled', () => {
const notificationSettings: NotificationSettingsState = {
...INITIAL_NOTIFICATION_SETTINGS_STATE,
routingPolicies: true,
};
const props = getNotificationSettingsProps(notificationSettings);
expect(props).toBeDefined();
expect(props).toStrictEqual({
groupBy: [],
renotify: {
enabled: false,
interval: '30m',
alertStates: [],
},
usePolicy: true,
});
});
it('group by notifications are provided', () => {
const notificationSettings: NotificationSettingsState = {
...INITIAL_NOTIFICATION_SETTINGS_STATE,
multipleNotifications: ['test group'],
};
const props = getNotificationSettingsProps(notificationSettings);
expect(props).toBeDefined();
expect(props).toStrictEqual({
groupBy: ['test group'],
renotify: {
enabled: false,
interval: '30m',
alertStates: [],
},
usePolicy: false,
});
});
describe('getAlertOnAbsentProps', () => {
it('when alert on absent is disabled', () => {
const advancedOptions: AdvancedOptionsState = {
...INITIAL_ADVANCED_OPTIONS_STATE,
sendNotificationIfDataIsMissing: {
enabled: false,
toleranceLimit: 0,
timeUnit: UniversalYAxisUnit.MINUTES,
},
};
const props = getAlertOnAbsentProps(advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
alertOnAbsent: false,
});
});
it('when alert on absent is enabled', () => {
const advancedOptions: AdvancedOptionsState = {
...INITIAL_ADVANCED_OPTIONS_STATE,
sendNotificationIfDataIsMissing: {
enabled: true,
toleranceLimit: 13,
timeUnit: UniversalYAxisUnit.MINUTES,
},
};
const props = getAlertOnAbsentProps(advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
alertOnAbsent: true,
absentFor: 13,
});
});
});
describe('getEnforceMinimumDatapointsProps', () => {
it('when enforce minimum datapoints is disabled', () => {
const advancedOptions: AdvancedOptionsState = {
...INITIAL_ADVANCED_OPTIONS_STATE,
enforceMinimumDatapoints: {
enabled: false,
minimumDatapoints: 0,
},
};
const props = getEnforceMinimumDatapointsProps(advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
requireMinPoints: false,
});
});
it('when enforce minimum datapoints is enabled', () => {
const advancedOptions: AdvancedOptionsState = {
...INITIAL_ADVANCED_OPTIONS_STATE,
enforceMinimumDatapoints: {
enabled: true,
minimumDatapoints: 12,
},
};
const props = getEnforceMinimumDatapointsProps(advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
requireMinPoints: true,
requiredNumPoints: 12,
});
});
});
describe('getEvaluationProps', () => {
const advancedOptions: AdvancedOptionsState = {
...INITIAL_ADVANCED_OPTIONS_STATE,
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'default',
default: {
value: 12,
timeUnit: UniversalYAxisUnit.MINUTES,
},
},
};
it('for rolling window with non-custom timeframe', () => {
const evaluationWindow: EvaluationWindowState = {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType: 'rolling',
timeframe: '5m0s',
};
const props = getEvaluationProps(evaluationWindow, advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
kind: 'rolling',
spec: {
evalWindow: '5m0s',
frequency: '12m',
},
});
});
it('for rolling window with custom timeframe', () => {
const evaluationWindow: EvaluationWindowState = {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType: 'rolling',
timeframe: 'custom',
startingAt: {
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
number: '13',
unit: UniversalYAxisUnit.MINUTES,
},
};
const props = getEvaluationProps(evaluationWindow, advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
kind: 'rolling',
spec: {
evalWindow: '13m',
frequency: '12m',
},
});
});
it('for cumulative window with current hour', () => {
const evaluationWindow: EvaluationWindowState = {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType: 'cumulative',
timeframe: 'currentHour',
startingAt: {
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
number: '14',
timezone: 'UTC',
},
};
const props = getEvaluationProps(evaluationWindow, advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
kind: 'cumulative',
spec: {
schedule: { type: 'hourly', minute: 14 },
frequency: '12m',
timezone: 'UTC',
},
});
});
it('for cumulative window with current day', () => {
const evaluationWindow: EvaluationWindowState = {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType: 'cumulative',
timeframe: 'currentDay',
startingAt: {
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
time: '15:43:00',
timezone: 'UTC',
},
};
const props = getEvaluationProps(evaluationWindow, advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
kind: 'cumulative',
spec: {
schedule: { type: 'daily', hour: 15, minute: 43 },
frequency: '12m',
timezone: 'UTC',
},
});
});
it('for cumulative window with current month', () => {
const evaluationWindow: EvaluationWindowState = {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType: 'cumulative',
timeframe: 'currentMonth',
startingAt: {
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
number: '17',
timezone: 'UTC',
time: '16:34:00',
},
};
const props = getEvaluationProps(evaluationWindow, advancedOptions);
expect(props).toBeDefined();
expect(props).toStrictEqual({
kind: 'cumulative',
spec: {
schedule: { type: 'monthly', day: 17, hour: 16, minute: 34 },
frequency: '12m',
timezone: 'UTC',
},
});
});
});
describe('buildCreateThresholdAlertRulePayload', () => {
const mockCreateAlertContextState = createMockAlertContextState();
const INITIAL_BUILD_CREATE_ALERT_RULE_PAYLOAD_ARGS: BuildCreateAlertRulePayloadArgs = {
basicAlertState: mockCreateAlertContextState.alertState,
thresholdState: mockCreateAlertContextState.thresholdState,
advancedOptions: mockCreateAlertContextState.advancedOptions,
evaluationWindow: mockCreateAlertContextState.evaluationWindow,
notificationSettings: mockCreateAlertContextState.notificationSettings,
query: initialQueriesMap.metrics,
alertType: mockCreateAlertContextState.alertType,
};
it('verify buildCreateThresholdAlertRulePayload', () => {
const props = buildCreateThresholdAlertRulePayload(
INITIAL_BUILD_CREATE_ALERT_RULE_PAYLOAD_ARGS,
);
expect(props).toBeDefined();
expect(props).toStrictEqual({
alert: '',
alertType: 'METRIC_BASED_ALERT',
annotations: {
description:
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
summary:
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
},
condition: {
alertOnAbsent: false,
compositeQuery: {
builderQueries: undefined,
chQueries: undefined,
panelType: 'graph',
promQueries: undefined,
queries: [
{
spec: {
aggregations: [
{
metricName: '',
reduceTo: undefined,
spaceAggregation: 'sum',
temporality: undefined,
timeAggregation: 'count',
},
],
disabled: false,
filter: {
expression: '',
},
functions: undefined,
groupBy: undefined,
having: undefined,
legend: undefined,
limit: undefined,
name: 'A',
offset: undefined,
order: undefined,
selectFields: undefined,
signal: 'metrics',
source: '',
stepInterval: null,
},
type: 'builder_query',
},
],
queryType: 'builder',
unit: undefined,
},
requireMinPoints: false,
selectedQueryName: 'A',
thresholds: {
kind: 'basic',
spec: [
{
channels: [],
matchType: '1',
name: 'critical',
op: '1',
target: 0,
targetUnit: '',
},
],
},
},
evaluation: {
kind: 'rolling',
spec: {
evalWindow: '5m0s',
frequency: '1m',
},
},
labels: {},
notificationSettings: {
groupBy: [],
renotify: {
enabled: false,
interval: '30m',
alertStates: [],
},
usePolicy: false,
},
ruleType: 'threshold_rule',
schemaVersion: 'v2alpha1',
source: 'http://localhost/',
version: 'v5',
});
});
it('verify for promql query type', () => {
const currentArgs: BuildCreateAlertRulePayloadArgs = {
...INITIAL_BUILD_CREATE_ALERT_RULE_PAYLOAD_ARGS,
query: {
...INITIAL_BUILD_CREATE_ALERT_RULE_PAYLOAD_ARGS.query,
queryType: EQueryType.PROM,
},
};
const props = buildCreateThresholdAlertRulePayload(currentArgs);
expect(props).toBeDefined();
expect(props.condition.compositeQuery.queryType).toBe('promql');
expect(props.ruleType).toBe('promql_rule');
});
});
});

View File

@ -17,7 +17,7 @@ import {
import { BuildCreateAlertRulePayloadArgs } from './types';
// Get formatted time/unit pairs for create alert api payload
function getFormattedTimeValue(timeValue: number, unit: string): string {
export function getFormattedTimeValue(timeValue: number, unit: string): string {
const unitMap: Record<string, string> = {
[UniversalYAxisUnit.SECONDS]: 's',
[UniversalYAxisUnit.MINUTES]: 'm',
@ -57,19 +57,17 @@ export function getNotificationSettingsProps(
notificationSettings: NotificationSettingsState,
): PostableAlertRuleV2['notificationSettings'] {
const notificationSettingsProps: PostableAlertRuleV2['notificationSettings'] = {
notificationGroupBy: notificationSettings.multipleNotifications || [],
alertStates: notificationSettings.reNotification.enabled
? notificationSettings.reNotification.conditions
: [],
notificationPolicy: notificationSettings.routingPolicies,
};
if (notificationSettings.reNotification.enabled) {
notificationSettingsProps.renotify = getFormattedTimeValue(
groupBy: notificationSettings.multipleNotifications || [],
usePolicy: notificationSettings.routingPolicies,
renotify: {
enabled: notificationSettings.reNotification.enabled,
interval: getFormattedTimeValue(
notificationSettings.reNotification.value,
notificationSettings.reNotification.unit,
);
}
),
alertStates: notificationSettings.reNotification.conditions,
},
};
return notificationSettingsProps;
}

View File

@ -4,18 +4,15 @@ import { Input, Select, Typography } from 'antd';
import { useCreateAlertState } from '../context';
import {
ADVANCED_OPTIONS_TIME_UNIT_OPTIONS as RE_NOTIFICATION_UNIT_OPTIONS,
RE_NOTIFICATION_CONDITION_OPTIONS,
RE_NOTIFICATION_TIME_UNIT_OPTIONS,
} from '../context/constants';
import AdvancedOptionItem from '../EvaluationSettings/AdvancedOptionItem';
import Stepper from '../Stepper';
import { showCondensedLayout } from '../utils';
import MultipleNotifications from './MultipleNotifications';
import NotificationMessage from './NotificationMessage';
function NotificationSettings(): JSX.Element {
const showCondensedLayoutFlag = showCondensedLayout();
const {
notificationSettings,
setNotificationSettings,
@ -45,7 +42,7 @@ function NotificationSettings(): JSX.Element {
value={notificationSettings.reNotification.unit || null}
placeholder="Select unit"
disabled={!notificationSettings.reNotification.enabled}
options={RE_NOTIFICATION_UNIT_OPTIONS}
options={RE_NOTIFICATION_TIME_UNIT_OPTIONS}
onChange={(value): void => {
setNotificationSettings({
type: 'SET_RE_NOTIFICATION',
@ -82,10 +79,7 @@ function NotificationSettings(): JSX.Element {
return (
<div className="notification-settings-container">
<Stepper
stepNumber={showCondensedLayoutFlag ? 3 : 4}
label="Notification settings"
/>
<Stepper stepNumber={3} label="Notification settings" />
<NotificationMessage />
<div className="notification-settings-content">
<MultipleNotifications />
@ -103,6 +97,7 @@ function NotificationSettings(): JSX.Element {
},
});
}}
defaultShowInput={notificationSettings.reNotification.enabled}
/>
</div>
</div>

View File

@ -1,7 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import * as createAlertContext from 'container/CreateAlertV2/context';
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import * as utils from 'container/CreateAlertV2/utils';
import NotificationSettings from '../NotificationSettings';
@ -24,6 +23,10 @@ jest.mock(
}),
);
jest.mock('container/CreateAlertV2/utils', () => ({
...jest.requireActual('container/CreateAlertV2/utils'),
}));
const initialNotificationSettings = createMockAlertContextState()
.notificationSettings;
const mockSetNotificationSettings = jest.fn();
@ -37,10 +40,10 @@ const REPEAT_NOTIFICATIONS_TEXT = 'Repeat notifications';
const ENTER_TIME_INTERVAL_TEXT = 'Enter time interval...';
describe('NotificationSettings', () => {
it('renders the notification settings tab with step number 4 and default values', () => {
it('renders the notification settings tab with step number 3 and default values', () => {
render(<NotificationSettings />);
expect(screen.getByText('Notification settings')).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByTestId('multiple-notifications')).toBeInTheDocument();
expect(screen.getByTestId('notification-message')).toBeInTheDocument();
expect(screen.getByText(REPEAT_NOTIFICATIONS_TEXT)).toBeInTheDocument();
@ -51,15 +54,6 @@ describe('NotificationSettings', () => {
).toBeInTheDocument();
});
it('renders the notification settings tab with step number 3 in condensed layout', () => {
jest.spyOn(utils, 'showCondensedLayout').mockReturnValueOnce(true);
render(<NotificationSettings />);
expect(screen.getByText('Notification settings')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByTestId('multiple-notifications')).toBeInTheDocument();
expect(screen.getByTestId('notification-message')).toBeInTheDocument();
});
describe('Repeat notifications', () => {
it('renders the repeat notifications with inputs hidden when the repeat notifications switch is off', () => {
render(<NotificationSettings />);

View File

@ -51,7 +51,6 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
yAxisUnit={yAxisUnit || ''}
graphType={panelType || PANEL_TYPES.TIME_SERIES}
setQueryStatus={setQueryStatus}
showSideLegend
additionalThresholds={thresholdState.thresholds}
/>
);
@ -66,7 +65,6 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
yAxisUnit={yAxisUnit || ''}
graphType={panelType || PANEL_TYPES.TIME_SERIES}
setQueryStatus={setQueryStatus}
showSideLegend
additionalThresholds={thresholdState.thresholds}
/>
);

View File

@ -132,7 +132,7 @@
border-bottom: 0.5px solid var(--bg-vanilla-300);
&.active-tab {
background-color: var(--bg-vanilla-100);
background-color: var(--bg-vanilla-300);
&:hover {
background-color: var(--bg-vanilla-100) !important;

View File

@ -0,0 +1,358 @@
import { Color } from '@signozhq/design-tokens';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import { defaultPostableAlertRuleV2 } from '../constants';
import { INITIAL_ALERT_STATE } from '../context/constants';
import {
AlertThresholdMatchType,
AlertThresholdOperator,
} from '../context/types';
import {
getAdvancedOptionsStateFromAlertDef,
getColorForThreshold,
getCreateAlertLocalStateFromAlertDef,
getEvaluationWindowStateFromAlertDef,
getNotificationSettingsStateFromAlertDef,
getThresholdStateFromAlertDef,
parseGoTime,
} from '../utils';
describe('CreateAlertV2 utils', () => {
describe('getColorForThreshold', () => {
it('should return the correct color for the pre-defined threshold', () => {
expect(getColorForThreshold('critical')).toBe(Color.BG_SAKURA_500);
expect(getColorForThreshold('warning')).toBe(Color.BG_AMBER_500);
expect(getColorForThreshold('info')).toBe(Color.BG_ROBIN_500);
});
});
describe('parseGoTime', () => {
it('should return the correct time and unit for the given input', () => {
expect(parseGoTime('1h')).toStrictEqual({
time: 1,
unit: UniversalYAxisUnit.HOURS,
});
expect(parseGoTime('1m')).toStrictEqual({
time: 1,
unit: UniversalYAxisUnit.MINUTES,
});
expect(parseGoTime('1s')).toStrictEqual({
time: 1,
unit: UniversalYAxisUnit.SECONDS,
});
expect(parseGoTime('1h0m')).toStrictEqual({
time: 1,
unit: UniversalYAxisUnit.HOURS,
});
});
});
describe('getEvaluationWindowStateFromAlertDef', () => {
it('for rolling window with non-custom timeframe', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
evaluation: {
...defaultPostableAlertRuleV2.evaluation,
kind: 'rolling',
spec: {
evalWindow: '5m0s',
},
},
};
const props = getEvaluationWindowStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
windowType: 'rolling',
timeframe: '5m0s',
});
});
it('for rolling window with custom timeframe', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
evaluation: {
...defaultPostableAlertRuleV2.evaluation,
kind: 'rolling',
spec: {
evalWindow: '13m0s',
},
},
};
const props = getEvaluationWindowStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
windowType: 'rolling',
timeframe: 'custom',
startingAt: {
number: '13',
unit: UniversalYAxisUnit.MINUTES,
},
});
});
it('for cumulative window with current hour', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
evaluation: {
kind: 'cumulative',
spec: {
schedule: {
type: 'hourly',
minute: 14,
},
},
},
};
const props = getEvaluationWindowStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
windowType: 'cumulative',
timeframe: 'currentHour',
startingAt: {
number: '14',
},
});
});
it('for cumulative window with current day', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
evaluation: {
...defaultPostableAlertRuleV2.evaluation,
kind: 'cumulative',
spec: {
schedule: {
type: 'daily',
hour: 14,
minute: 15,
},
},
},
};
const props = getEvaluationWindowStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
windowType: 'cumulative',
timeframe: 'currentDay',
startingAt: {
time: '14:15:00',
},
});
});
it('for cumulative window with current month', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
evaluation: {
...defaultPostableAlertRuleV2.evaluation,
kind: 'cumulative',
spec: {
schedule: {
type: 'monthly',
day: 12,
hour: 16,
minute: 34,
},
timezone: 'UTC',
},
},
};
const props = getEvaluationWindowStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
windowType: 'cumulative',
timeframe: 'currentMonth',
startingAt: {
number: '12',
timezone: 'UTC',
time: '16:34:00',
},
});
});
});
describe('getNotificationSettingsStateFromAlertDef', () => {
it('should return the correct notification settings state for the given alert def', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
notificationSettings: {
groupBy: ['email'],
renotify: {
enabled: true,
interval: '1m0s',
alertStates: ['firing'],
},
usePolicy: true,
},
};
const props = getNotificationSettingsStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
multipleNotifications: ['email'],
reNotification: {
enabled: true,
value: 1,
unit: UniversalYAxisUnit.MINUTES,
conditions: ['firing'],
},
description:
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
routingPolicies: true,
});
});
it('when renotification is not provided', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
notificationSettings: {
groupBy: ['email'],
usePolicy: false,
},
};
const props = getNotificationSettingsStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
multipleNotifications: ['email'],
reNotification: {
enabled: false,
value: 30,
unit: UniversalYAxisUnit.MINUTES,
conditions: [],
},
});
});
});
describe('getAdvancedOptionsStateFromAlertDef', () => {
it('should return the correct advanced options state for the given alert def', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
condition: {
...defaultPostableAlertRuleV2.condition,
compositeQuery: {
...defaultPostableAlertRuleV2.condition.compositeQuery,
unit: UniversalYAxisUnit.MINUTES,
},
requiredNumPoints: 13,
requireMinPoints: true,
alertOnAbsent: true,
absentFor: 12,
},
evaluation: {
...defaultPostableAlertRuleV2.evaluation,
spec: {
frequency: '1m0s',
},
},
};
const props = getAdvancedOptionsStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
sendNotificationIfDataIsMissing: {
enabled: true,
toleranceLimit: 12,
timeUnit: UniversalYAxisUnit.MINUTES,
},
enforceMinimumDatapoints: {
enabled: true,
minimumDatapoints: 13,
},
evaluationCadence: {
mode: 'default',
default: {
value: 1,
timeUnit: UniversalYAxisUnit.MINUTES,
},
},
});
});
});
describe('getThresholdStateFromAlertDef', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
annotations: {
summary: 'test summary',
description: 'test description',
},
condition: {
...defaultPostableAlertRuleV2.condition,
thresholds: {
kind: 'basic',
spec: [
{
name: 'critical',
target: 1,
targetUnit: UniversalYAxisUnit.MINUTES,
channels: ['email'],
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
op: AlertThresholdOperator.IS_ABOVE,
},
],
},
selectedQueryName: 'test',
},
};
const props = getThresholdStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
selectedQuery: 'test',
operator: AlertThresholdOperator.IS_ABOVE,
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
thresholds: [
{
id: expect.any(String),
label: 'critical',
thresholdValue: 1,
recoveryThresholdValue: null,
unit: UniversalYAxisUnit.MINUTES,
color: Color.BG_SAKURA_500,
channels: ['email'],
},
],
});
});
describe('getCreateAlertLocalStateFromAlertDef', () => {
it('should return the correct create alert local state for the given alert def', () => {
const args: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
annotations: {
summary: 'test summary',
description: 'test description',
},
alert: 'test-alert',
labels: {
severity: 'warning',
team: 'test-team',
},
condition: {
...defaultPostableAlertRuleV2.condition,
compositeQuery: {
...defaultPostableAlertRuleV2.condition.compositeQuery,
unit: UniversalYAxisUnit.MINUTES,
},
},
};
const props = getCreateAlertLocalStateFromAlertDef(args);
expect(props).toBeDefined();
expect(props).toMatchObject({
basicAlertState: {
...INITIAL_ALERT_STATE,
name: 'test-alert',
labels: {
severity: 'warning',
team: 'test-team',
},
yAxisUnit: UniversalYAxisUnit.MINUTES,
},
// as we have already verified these utils in their respective tests
thresholdState: expect.any(Object),
advancedOptionsState: expect.any(Object),
evaluationWindowState: expect.any(Object),
notificationSettingsState: expect.any(Object),
});
});
});
});

View File

@ -0,0 +1,74 @@
import { ENTITY_VERSION_V5 } from 'constants/app';
import {
initialQueryBuilderFormValuesMap,
initialQueryPromQLData,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import {
NEW_ALERT_SCHEMA_VERSION,
PostableAlertRuleV2,
} from 'types/api/alerts/alertTypesV2';
import { EQueryType } from 'types/common/dashboard';
const defaultAnnotations = {
description:
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
summary:
'The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}',
};
const defaultNotificationSettings: PostableAlertRuleV2['notificationSettings'] = {
groupBy: [],
renotify: {
enabled: false,
interval: '30m',
alertStates: [],
},
usePolicy: false,
};
const defaultEvaluation: PostableAlertRuleV2['evaluation'] = {
kind: 'rolling',
spec: {
evalWindow: '5m0s',
frequency: '1m',
},
};
export const defaultPostableAlertRuleV2: PostableAlertRuleV2 = {
alertType: AlertTypes.METRICS_BASED_ALERT,
version: ENTITY_VERSION_V5,
schemaVersion: NEW_ALERT_SCHEMA_VERSION,
condition: {
compositeQuery: {
builderQueries: {
A: initialQueryBuilderFormValuesMap.metrics,
},
promQueries: { A: initialQueryPromQLData },
chQueries: {
A: {
name: 'A',
query: ``,
legend: '',
disabled: false,
},
},
queryType: EQueryType.QUERY_BUILDER,
panelType: PANEL_TYPES.TIME_SERIES,
unit: undefined,
},
selectedQueryName: 'A',
alertOnAbsent: true,
absentFor: 10,
requireMinPoints: false,
requiredNumPoints: 0,
},
labels: {
severity: 'warning',
},
annotations: defaultAnnotations,
notificationSettings: defaultNotificationSettings,
alert: 'TEST_ALERT',
evaluation: defaultEvaluation,
};

View File

@ -27,7 +27,7 @@ export const INITIAL_ALERT_STATE: AlertState = {
export const INITIAL_CRITICAL_THRESHOLD: Threshold = {
id: v4(),
label: 'CRITICAL',
label: 'critical',
thresholdValue: 0,
recoveryThresholdValue: null,
unit: '',
@ -37,7 +37,7 @@ export const INITIAL_CRITICAL_THRESHOLD: Threshold = {
export const INITIAL_WARNING_THRESHOLD: Threshold = {
id: v4(),
label: 'WARNING',
label: 'warning',
thresholdValue: 0,
recoveryThresholdValue: null,
unit: '',
@ -47,7 +47,7 @@ export const INITIAL_WARNING_THRESHOLD: Threshold = {
export const INITIAL_INFO_THRESHOLD: Threshold = {
id: v4(),
label: 'INFO',
label: 'info',
thresholdValue: 0,
recoveryThresholdValue: null,
unit: '',
@ -172,19 +172,24 @@ export const ADVANCED_OPTIONS_TIME_UNIT_OPTIONS = [
{ value: UniversalYAxisUnit.HOURS, label: 'Hours' },
];
export const RE_NOTIFICATION_TIME_UNIT_OPTIONS = [
{ value: UniversalYAxisUnit.MINUTES, label: 'Minutes' },
{ value: UniversalYAxisUnit.HOURS, label: 'Hours' },
];
export const NOTIFICATION_MESSAGE_PLACEHOLDER =
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})';
export const RE_NOTIFICATION_CONDITION_OPTIONS = [
{ value: 'firing', label: 'Firing' },
{ value: 'no-data', label: 'No Data' },
{ value: 'nodata', label: 'No Data' },
];
export const INITIAL_NOTIFICATION_SETTINGS_STATE: NotificationSettingsState = {
multipleNotifications: [],
reNotification: {
enabled: false,
value: 1,
value: 30,
unit: UniversalYAxisUnit.MINUTES,
conditions: [],
},

View File

@ -2,6 +2,7 @@ import { QueryParams } from 'constants/query';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { useCreateAlertRule } from 'hooks/alerts/useCreateAlertRule';
import { useTestAlertRule } from 'hooks/alerts/useTestAlertRule';
import { useUpdateAlertRule } from 'hooks/alerts/useUpdateAlertRule';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import {
@ -50,7 +51,7 @@ export const useCreateAlertState = (): ICreateAlertContextProps => {
export function CreateAlertProvider(
props: ICreateAlertProviderProps,
): JSX.Element {
const { children } = props;
const { children, initialAlertState, isEditMode, ruleId } = props;
const [alertState, setAlertState] = useReducer(
alertCreationReducer,
@ -114,6 +115,31 @@ export function CreateAlertProvider(
});
}, [alertType]);
useEffect(() => {
if (isEditMode && initialAlertState) {
setAlertState({
type: 'SET_INITIAL_STATE',
payload: initialAlertState.basicAlertState,
});
setThresholdState({
type: 'SET_INITIAL_STATE',
payload: initialAlertState.thresholdState,
});
setEvaluationWindow({
type: 'SET_INITIAL_STATE',
payload: initialAlertState.evaluationWindowState,
});
setAdvancedOptions({
type: 'SET_INITIAL_STATE',
payload: initialAlertState.advancedOptionsState,
});
setNotificationSettings({
type: 'SET_INITIAL_STATE',
payload: initialAlertState.notificationSettingsState,
});
}
}, [initialAlertState, isEditMode]);
const discardAlertRule = useCallback(() => {
setAlertState({
type: 'RESET',
@ -143,6 +169,11 @@ export function CreateAlertProvider(
isLoading: isTestingAlertRule,
} = useTestAlertRule();
const {
mutate: updateAlertRule,
isLoading: isUpdatingAlertRule,
} = useUpdateAlertRule(ruleId || '');
const contextValue: ICreateAlertContextProps = useMemo(
() => ({
alertState,
@ -162,6 +193,9 @@ export function CreateAlertProvider(
isCreatingAlertRule,
testAlertRule,
isTestingAlertRule,
updateAlertRule,
isUpdatingAlertRule,
isEditMode: isEditMode || false,
}),
[
alertState,
@ -176,6 +210,9 @@ export function CreateAlertProvider(
isCreatingAlertRule,
testAlertRule,
isTestingAlertRule,
updateAlertRule,
isUpdatingAlertRule,
isEditMode,
],
);

View File

@ -1,5 +1,6 @@
import { CreateAlertRuleResponse } from 'api/alerts/createAlertRule';
import { TestAlertRuleResponse } from 'api/alerts/testAlertRule';
import { UpdateAlertRuleResponse } from 'api/alerts/updateAlertRule';
import { Dayjs } from 'dayjs';
import { Dispatch } from 'react';
import { UseMutateFunction } from 'react-query';
@ -8,6 +9,8 @@ import { AlertTypes } from 'types/api/alerts/alertTypes';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import { Labels } from 'types/api/alerts/def';
import { GetCreateAlertLocalStateFromAlertDefReturn } from '../types';
export interface ICreateAlertContextProps {
alertState: AlertState;
setAlertState: Dispatch<CreateAlertAction>;
@ -36,11 +39,22 @@ export interface ICreateAlertContextProps {
unknown
>;
discardAlertRule: () => void;
isUpdatingAlertRule: boolean;
updateAlertRule: UseMutateFunction<
SuccessResponse<UpdateAlertRuleResponse, unknown> | ErrorResponse,
Error,
PostableAlertRuleV2,
unknown
>;
isEditMode: boolean;
}
export interface ICreateAlertProviderProps {
children: React.ReactNode;
initialAlertType: AlertTypes;
initialAlertState?: GetCreateAlertLocalStateFromAlertDefReturn;
isEditMode?: boolean;
ruleId?: string;
}
export enum AlertCreationStep {
@ -60,6 +74,7 @@ export type CreateAlertAction =
| { type: 'SET_ALERT_NAME'; payload: string }
| { type: 'SET_ALERT_LABELS'; payload: Labels }
| { type: 'SET_Y_AXIS_UNIT'; payload: string | undefined }
| { type: 'SET_INITIAL_STATE'; payload: AlertState }
| { type: 'RESET' };
export interface Threshold {
@ -127,6 +142,7 @@ export type AlertThresholdAction =
| { type: 'SET_ALGORITHM'; payload: string }
| { type: 'SET_SEASONALITY'; payload: string }
| { type: 'SET_THRESHOLDS'; payload: Threshold[] }
| { type: 'SET_INITIAL_STATE'; payload: AlertThresholdState }
| { type: 'RESET' };
export interface AdvancedOptionsState {
@ -198,6 +214,7 @@ export type AdvancedOptionsAction =
};
}
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
| { type: 'SET_INITIAL_STATE'; payload: AdvancedOptionsState }
| { type: 'RESET' };
export interface EvaluationWindowState {
@ -219,6 +236,7 @@ export type EvaluationWindowAction =
payload: { time: string; number: string; timezone: string; unit: string };
}
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
| { type: 'SET_INITIAL_STATE'; payload: EvaluationWindowState }
| { type: 'RESET' };
export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule';
@ -229,7 +247,7 @@ export interface NotificationSettingsState {
enabled: boolean;
value: number;
unit: string;
conditions: ('firing' | 'no-data')[];
conditions: ('firing' | 'nodata')[];
};
description: string;
routingPolicies: boolean;
@ -246,9 +264,10 @@ export type NotificationSettingsAction =
enabled: boolean;
value: number;
unit: string;
conditions: ('firing' | 'no-data')[];
conditions: ('firing' | 'nodata')[];
};
}
| { type: 'SET_DESCRIPTION'; payload: string }
| { type: 'SET_ROUTING_POLICIES'; payload: boolean }
| { type: 'SET_INITIAL_STATE'; payload: NotificationSettingsState }
| { type: 'RESET' };

View File

@ -53,6 +53,8 @@ export const alertCreationReducer = (
};
case 'RESET':
return INITIAL_ALERT_STATE;
case 'SET_INITIAL_STATE':
return action.payload;
default:
return state;
}
@ -119,6 +121,8 @@ export const alertThresholdReducer = (
return { ...state, thresholds: action.payload };
case 'RESET':
return INITIAL_ALERT_THRESHOLD_STATE;
case 'SET_INITIAL_STATE':
return action.payload;
default:
return state;
}
@ -174,6 +178,8 @@ export const advancedOptionsReducer = (
...state,
evaluationCadence: { ...state.evaluationCadence, mode: action.payload },
};
case 'SET_INITIAL_STATE':
return action.payload;
case 'RESET':
return INITIAL_ADVANCED_OPTIONS_STATE;
default:
@ -202,6 +208,8 @@ export const evaluationWindowReducer = (
return { ...state, startingAt: action.payload };
case 'RESET':
return INITIAL_EVALUATION_WINDOW_STATE;
case 'SET_INITIAL_STATE':
return action.payload;
default:
return state;
}
@ -222,6 +230,8 @@ export const notificationSettingsReducer = (
return { ...state, routingPolicies: action.payload };
case 'RESET':
return INITIAL_NOTIFICATION_SETTINGS_STATE;
case 'SET_INITIAL_STATE':
return action.payload;
default:
return state;
}

View File

@ -1,5 +1,21 @@
import { AlertTypes } from 'types/api/alerts/alertTypes';
import {
AdvancedOptionsState,
AlertState,
AlertThresholdState,
EvaluationWindowState,
NotificationSettingsState,
} from './context/types';
export interface CreateAlertV2Props {
alertType: AlertTypes;
}
export interface GetCreateAlertLocalStateFromAlertDefReturn {
basicAlertState: AlertState;
thresholdState: AlertThresholdState;
advancedOptionsState: AdvancedOptionsState;
evaluationWindowState: EvaluationWindowState;
notificationSettingsState: NotificationSettingsState;
}

View File

@ -1,22 +1,36 @@
import { Color } from '@signozhq/design-tokens';
import { Spin } from 'antd';
import { TIMEZONE_DATA } from 'components/CustomTimePicker/timezoneUtils';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { getRandomColor } from 'container/ExplorerOptions/utils';
import { createPortal } from 'react-dom';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import { v4 } from 'uuid';
import { useCreateAlertState } from './context';
// UI side feature flag
export const showNewCreateAlertsPage = (): boolean =>
localStorage.getItem('showNewCreateAlertsPage') === 'true';
// UI side FF to switch between the 2 layouts of the create alert page
// Layout 1 - Default layout
// Layout 2 - Condensed layout
export const showCondensedLayout = (): boolean =>
localStorage.getItem('showCondensedLayout') === 'true';
import {
INITIAL_ADVANCED_OPTIONS_STATE,
INITIAL_ALERT_STATE,
INITIAL_ALERT_THRESHOLD_STATE,
INITIAL_EVALUATION_WINDOW_STATE,
INITIAL_NOTIFICATION_SETTINGS_STATE,
} from './context/constants';
import {
AdvancedOptionsState,
AlertState,
AlertThresholdMatchType,
AlertThresholdOperator,
AlertThresholdState,
EvaluationWindowState,
NotificationSettingsState,
} from './context/types';
import { EVALUATION_WINDOW_TIMEFRAME } from './EvaluationSettings/constants';
import { GetCreateAlertLocalStateFromAlertDefReturn } from './types';
export function Spinner(): JSX.Element | null {
const { isCreatingAlertRule } = useCreateAlertState();
const { isCreatingAlertRule, isUpdatingAlertRule } = useCreateAlertState();
if (!isCreatingAlertRule) return null;
if (!isCreatingAlertRule && !isUpdatingAlertRule) return null;
return createPortal(
<div className="sticky-page-spinner">
@ -25,3 +39,263 @@ export function Spinner(): JSX.Element | null {
document.body,
);
}
export function getColorForThreshold(thresholdLabel: string): string {
if (thresholdLabel === 'critical') {
return Color.BG_SAKURA_500;
}
if (thresholdLabel === 'warning') {
return Color.BG_AMBER_500;
}
if (thresholdLabel === 'info') {
return Color.BG_ROBIN_500;
}
return getRandomColor();
}
export function parseGoTime(
input: string,
): { time: number; unit: UniversalYAxisUnit } {
const regex = /(\d+)([hms])/g;
const matches = [...input.matchAll(regex)];
const nonZero = matches.find(([, value]) => parseInt(value, 10) > 0);
if (!nonZero) {
return { time: 1, unit: UniversalYAxisUnit.MINUTES };
}
const time = parseInt(nonZero[1], 10);
const unitMap: Record<string, UniversalYAxisUnit> = {
h: UniversalYAxisUnit.HOURS,
m: UniversalYAxisUnit.MINUTES,
s: UniversalYAxisUnit.SECONDS,
};
return { time, unit: unitMap[nonZero[2]] };
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export function getEvaluationWindowStateFromAlertDef(
alertDef: PostableAlertRuleV2,
): EvaluationWindowState {
const windowType = alertDef.evaluation?.kind as 'rolling' | 'cumulative';
function getRollingWindowTimeframe(): string {
if (
// Default values for rolling window
EVALUATION_WINDOW_TIMEFRAME.rolling
.map((option) => option.value)
.includes(alertDef.evaluation?.spec?.evalWindow || '')
) {
return alertDef.evaluation?.spec?.evalWindow || '';
}
return 'custom';
}
function getCumulativeWindowTimeframe(): string {
switch (alertDef.evaluation?.spec?.schedule?.type) {
case 'hourly':
return 'currentHour';
case 'daily':
return 'currentDay';
case 'monthly':
return 'currentMonth';
default:
return 'currentHour';
}
}
function convertApiFieldToTime(hour: number, minute: number): string {
return `${hour.toString().padStart(2, '0')}:${minute
.toString()
.padStart(2, '0')}:00`;
}
function getCumulativeWindowStartingAt(): EvaluationWindowState['startingAt'] {
const timeframe = getCumulativeWindowTimeframe();
if (timeframe === 'currentHour') {
return {
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
number: alertDef.evaluation?.spec?.schedule?.minute?.toString() || '0',
};
}
if (timeframe === 'currentDay') {
return {
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
time: convertApiFieldToTime(
alertDef.evaluation?.spec?.schedule?.hour || 0,
alertDef.evaluation?.spec?.schedule?.minute || 0,
),
timezone: alertDef.evaluation?.spec?.timezone || TIMEZONE_DATA[0].value,
};
}
if (timeframe === 'currentMonth') {
return {
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
number: alertDef.evaluation?.spec?.schedule?.day?.toString() || '0',
timezone: alertDef.evaluation?.spec?.timezone || TIMEZONE_DATA[0].value,
time: convertApiFieldToTime(
alertDef.evaluation?.spec?.schedule?.hour || 0,
alertDef.evaluation?.spec?.schedule?.minute || 0,
),
};
}
return INITIAL_EVALUATION_WINDOW_STATE.startingAt;
}
if (windowType === 'rolling') {
const timeframe = getRollingWindowTimeframe();
if (timeframe === 'custom') {
return {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType,
timeframe,
startingAt: {
...INITIAL_EVALUATION_WINDOW_STATE.startingAt,
number: parseGoTime(
alertDef.evaluation?.spec?.evalWindow || '1m',
).time.toString(),
unit: parseGoTime(alertDef.evaluation?.spec?.evalWindow || '1m').unit,
},
};
}
return {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType,
timeframe,
};
}
return {
...INITIAL_EVALUATION_WINDOW_STATE,
windowType,
timeframe: getCumulativeWindowTimeframe(),
startingAt: getCumulativeWindowStartingAt(),
};
}
export function getNotificationSettingsStateFromAlertDef(
alertDef: PostableAlertRuleV2,
): NotificationSettingsState {
const description = alertDef.annotations?.description || '';
const multipleNotifications = alertDef.notificationSettings?.groupBy || [];
const routingPolicies = alertDef.notificationSettings?.usePolicy || false;
const reNotificationEnabled =
alertDef.notificationSettings?.renotify?.enabled || false;
const reNotificationConditions =
alertDef.notificationSettings?.renotify?.alertStates?.map(
(state) => state as 'firing' | 'nodata',
) || [];
const reNotificationValue = alertDef.notificationSettings?.renotify
? parseGoTime(alertDef.notificationSettings.renotify.interval || '30m').time
: 30;
const reNotificationUnit = alertDef.notificationSettings?.renotify
? parseGoTime(alertDef.notificationSettings.renotify.interval || '30m').unit
: UniversalYAxisUnit.MINUTES;
return {
...INITIAL_NOTIFICATION_SETTINGS_STATE,
description,
multipleNotifications,
routingPolicies,
reNotification: {
enabled: reNotificationEnabled,
conditions: reNotificationConditions,
value: reNotificationValue,
unit: reNotificationUnit,
},
};
}
export function getAdvancedOptionsStateFromAlertDef(
alertDef: PostableAlertRuleV2,
): AdvancedOptionsState {
return {
...INITIAL_ADVANCED_OPTIONS_STATE,
sendNotificationIfDataIsMissing: {
...INITIAL_ADVANCED_OPTIONS_STATE.sendNotificationIfDataIsMissing,
toleranceLimit: alertDef.condition.absentFor || 0,
enabled: alertDef.condition.alertOnAbsent || false,
},
enforceMinimumDatapoints: {
...INITIAL_ADVANCED_OPTIONS_STATE.enforceMinimumDatapoints,
minimumDatapoints: alertDef.condition.requiredNumPoints || 0,
enabled: alertDef.condition.requireMinPoints || false,
},
evaluationCadence: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence,
mode: 'default',
default: {
...INITIAL_ADVANCED_OPTIONS_STATE.evaluationCadence.default,
value: parseGoTime(alertDef.evaluation?.spec?.frequency || '1m').time,
timeUnit: parseGoTime(alertDef.evaluation?.spec?.frequency || '1m').unit,
},
},
};
}
export function getThresholdStateFromAlertDef(
alertDef: PostableAlertRuleV2,
): AlertThresholdState {
return {
...INITIAL_ALERT_THRESHOLD_STATE,
thresholds:
alertDef.condition.thresholds?.spec.map((threshold) => ({
id: v4(),
label: threshold.name,
thresholdValue: threshold.target,
recoveryThresholdValue: null,
unit: threshold.targetUnit,
color: getColorForThreshold(threshold.name),
channels: threshold.channels,
})) || [],
selectedQuery: alertDef.condition.selectedQueryName || '',
operator:
(alertDef.condition.thresholds?.spec[0].op as AlertThresholdOperator) ||
AlertThresholdOperator.IS_ABOVE,
matchType:
(alertDef.condition.thresholds?.spec[0]
.matchType as AlertThresholdMatchType) ||
AlertThresholdMatchType.AT_LEAST_ONCE,
};
}
export function getCreateAlertLocalStateFromAlertDef(
alertDef: PostableAlertRuleV2 | undefined,
): GetCreateAlertLocalStateFromAlertDefReturn {
if (!alertDef) {
return {
basicAlertState: INITIAL_ALERT_STATE,
thresholdState: INITIAL_ALERT_THRESHOLD_STATE,
advancedOptionsState: INITIAL_ADVANCED_OPTIONS_STATE,
evaluationWindowState: INITIAL_EVALUATION_WINDOW_STATE,
notificationSettingsState: INITIAL_NOTIFICATION_SETTINGS_STATE,
};
}
// Basic alert state
const basicAlertState: AlertState = {
...INITIAL_ALERT_STATE,
name: alertDef.alert,
labels: alertDef.labels || {},
yAxisUnit: alertDef.condition.compositeQuery.unit,
};
const thresholdState = getThresholdStateFromAlertDef(alertDef);
const advancedOptionsState = getAdvancedOptionsStateFromAlertDef(alertDef);
const evaluationWindowState = getEvaluationWindowStateFromAlertDef(alertDef);
const notificationSettingsState = getNotificationSettingsStateFromAlertDef(
alertDef,
);
return {
basicAlertState,
thresholdState,
advancedOptionsState,
evaluationWindowState,
notificationSettingsState,
};
}

View File

@ -0,0 +1,52 @@
import '../CreateAlertV2/CreateAlertV2.styles.scss';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { useMemo } from 'react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import AlertCondition from '../CreateAlertV2/AlertCondition';
import { buildInitialAlertDef } from '../CreateAlertV2/context/utils';
import Footer from '../CreateAlertV2/Footer';
import NotificationSettings from '../CreateAlertV2/NotificationSettings';
import QuerySection from '../CreateAlertV2/QuerySection';
import { Spinner } from '../CreateAlertV2/utils';
interface EditAlertV2Props {
alertType?: AlertTypes;
initialAlert: PostableAlertRuleV2;
}
function EditAlertV2({
alertType = AlertTypes.METRICS_BASED_ALERT,
initialAlert,
}: EditAlertV2Props): JSX.Element {
const currentQueryToRedirect = useMemo(() => {
const basicAlertDef = buildInitialAlertDef(alertType);
return mapQueryDataFromApi(
initialAlert?.condition.compositeQuery ||
basicAlertDef.condition.compositeQuery,
);
}, [initialAlert, alertType]);
useShareBuilderUrl({ defaultValue: currentQueryToRedirect });
return (
<>
<Spinner />
<div className="create-alert-v2-container">
<QuerySection />
<AlertCondition />
<NotificationSettings />
</div>
<Footer />
</>
);
}
EditAlertV2.defaultProps = {
alertType: AlertTypes.METRICS_BASED_ALERT,
};
export default EditAlertV2;

View File

@ -0,0 +1,3 @@
import EditAlertV2 from './EditAlertV2';
export default EditAlertV2;

View File

@ -1,11 +1,32 @@
import { Form } from 'antd';
import EditAlertV2 from 'container/EditAlertV2';
import FormAlertRules from 'container/FormAlertRules';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import {
NEW_ALERT_SCHEMA_VERSION,
PostableAlertRuleV2,
} from 'types/api/alerts/alertTypesV2';
import { AlertDef } from 'types/api/alerts/def';
function EditRules({ initialValue, ruleId }: EditRulesProps): JSX.Element {
function EditRules({
initialValue,
ruleId,
initialV2AlertValue,
}: EditRulesProps): JSX.Element {
const [formInstance] = Form.useForm();
if (
initialV2AlertValue !== null &&
initialV2AlertValue.schemaVersion === NEW_ALERT_SCHEMA_VERSION
) {
return (
<EditAlertV2
initialAlert={initialV2AlertValue}
alertType={initialValue.alertType as AlertTypes}
/>
);
}
return (
<FormAlertRules
alertType={
@ -23,6 +44,7 @@ function EditRules({ initialValue, ruleId }: EditRulesProps): JSX.Element {
interface EditRulesProps {
initialValue: AlertDef;
ruleId: string;
initialV2AlertValue: PostableAlertRuleV2 | null;
}
export default EditRules;

View File

@ -1,6 +1,14 @@
/* eslint-disable react/display-name */
import { PlusOutlined } from '@ant-design/icons';
import { Flex, Input, Typography } from 'antd';
import {
Button,
Dropdown,
Flex,
Input,
MenuProps,
Tag,
Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table/interface';
import saveAlertApi from 'api/alerts/save';
import logEvent from 'api/common/logEvent';
@ -31,7 +39,7 @@ import { ErrorResponse, SuccessResponse } from 'types/api';
import { GettableAlert } from 'types/api/alerts/get';
import DeleteAlert from './DeleteAlert';
import { Button, ColumnButton, SearchContainer } from './styles';
import { ColumnButton, SearchContainer } from './styles';
import Status from './TableComponents/Status';
import ToggleAlertState from './ToggleAlertState';
import { alertActionLogEvent, filterAlerts } from './utils';
@ -97,14 +105,41 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
});
}, [notificationsApi, t]);
const onClickNewAlertHandler = useCallback(() => {
const onClickNewAlertV2Handler = useCallback(() => {
logEvent('Alert: New alert button clicked', {
number: allAlertRules?.length,
layout: 'new',
});
history.push(`${ROUTES.ALERTS_NEW}?showNewCreateAlertsPage=true`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onClickNewClassicAlertHandler = useCallback(() => {
logEvent('Alert: New alert button clicked', {
number: allAlertRules?.length,
layout: 'classic',
});
history.push(ROUTES.ALERTS_NEW);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const newAlertMenuItems: MenuProps['items'] = [
{
key: 'new',
label: (
<span>
Try the new experience <Tag color="blue">Beta</Tag>
</span>
),
onClick: onClickNewAlertV2Handler,
},
{
key: 'classic',
label: 'Continue with the classic experience',
onClick: onClickNewClassicAlertHandler,
},
];
const onEditHandler = (record: GettableAlert, openInNewTab: boolean): void => {
const compositeQuery = mapQueryDataFromApi(record.condition.compositeQuery);
params.set(
@ -368,13 +403,11 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
/>
<Flex gap={12}>
{addNewAlert && (
<Button
type="primary"
onClick={onClickNewAlertHandler}
icon={<PlusOutlined />}
>
<Dropdown menu={{ items: newAlertMenuItems }} trigger={['click']}>
<Button type="primary" icon={<PlusOutlined />}>
New Alert
</Button>
</Dropdown>
)}
<TextToolTip
{...{

View File

@ -88,6 +88,7 @@ function RoutingPolicies(): JSX.Element {
isRoutingPoliciesError={isErrorRoutingPolicies}
handlePolicyDetailsModalOpen={handlePolicyDetailsModalOpen}
handleDeleteModalOpen={handleDeleteModalOpen}
hasSearchTerm={(searchTerm?.length ?? 0) > 0}
/>
{policyDetailsModalState.isOpen && (
<RoutingPolicyDetails

View File

@ -10,6 +10,7 @@ function RoutingPolicyList({
isRoutingPoliciesError,
handlePolicyDetailsModalOpen,
handleDeleteModalOpen,
hasSearchTerm,
}: RoutingPolicyListProps): JSX.Element {
const columns: TableProps<RoutingPolicy>['columns'] = [
{
@ -25,6 +26,7 @@ function RoutingPolicyList({
},
];
/* eslint-disable no-nested-ternary */
const localeEmptyState = useMemo(
() => (
<div className="no-routing-policies-message-container">
@ -41,12 +43,23 @@ function RoutingPolicyList({
<Typography.Text>
Something went wrong while fetching routing policies.
</Typography.Text>
) : hasSearchTerm ? (
<Typography.Text>No matching routing policies found.</Typography.Text>
) : (
<Typography.Text>No routing policies found.</Typography.Text>
<Typography.Text>
No routing policies yet,{' '}
<a
href="https://signoz.io/docs/alerts-management/routing-policy"
target="_blank"
rel="noopener noreferrer"
>
Learn more here
</a>
</Typography.Text>
)}
</div>
),
[isRoutingPoliciesError],
[isRoutingPoliciesError, hasSearchTerm],
);
return (

View File

@ -28,6 +28,7 @@ describe('RoutingPoliciesList', () => {
isRoutingPoliciesError={useRoutingPolicesMockData.isErrorRoutingPolicies}
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
handleDeleteModalOpen={mockHandleDeleteModalOpen}
hasSearchTerm={false}
/>,
);
@ -51,6 +52,7 @@ describe('RoutingPoliciesList', () => {
isRoutingPoliciesError={false}
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
handleDeleteModalOpen={mockHandleDeleteModalOpen}
hasSearchTerm={false}
/>,
);
// Check for loading spinner by class name
@ -67,6 +69,7 @@ describe('RoutingPoliciesList', () => {
isRoutingPoliciesError
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
handleDeleteModalOpen={mockHandleDeleteModalOpen}
hasSearchTerm={false}
/>,
);
expect(
@ -82,8 +85,9 @@ describe('RoutingPoliciesList', () => {
isRoutingPoliciesError={false}
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
handleDeleteModalOpen={mockHandleDeleteModalOpen}
hasSearchTerm={false}
/>,
);
expect(screen.getByText('No routing policies found.')).toBeInTheDocument();
expect(screen.getByText('No routing policies yet,')).toBeInTheDocument();
});
});

View File

@ -37,6 +37,7 @@ export interface RoutingPolicyListProps {
isRoutingPoliciesError: boolean;
handlePolicyDetailsModalOpen: HandlePolicyDetailsModalOpen;
handleDeleteModalOpen: HandleDeleteModalOpen;
hasSearchTerm: boolean;
}
export interface RoutingPolicyListItemProps {

View File

@ -5,10 +5,6 @@ import { SuccessResponseV2 } from 'types/api';
import { RoutingPolicy } from './types';
export function showRoutingPoliciesPage(): boolean {
return localStorage.getItem('showRoutingPoliciesPage') === 'true';
}
export function mapApiResponseToRoutingPolicies(
response: SuccessResponseV2<GetRoutingPoliciesResponse>,
): RoutingPolicy[] {
@ -36,9 +32,7 @@ export function mapRoutingPolicyToCreateApiPayload(
return {
name,
expression,
actions: {
channels,
},
description,
};
}
@ -53,9 +47,7 @@ export function mapRoutingPolicyToUpdateApiPayload(
return {
name,
expression,
actions: {
channels,
},
description,
};
}

View File

@ -0,0 +1,22 @@
import updateAlertRule, {
UpdateAlertRuleResponse,
} from 'api/alerts/updateAlertRule';
import { useMutation, UseMutationResult } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
export function useUpdateAlertRule(
id: string,
): UseMutationResult<
SuccessResponse<UpdateAlertRuleResponse> | ErrorResponse,
Error,
PostableAlertRuleV2
> {
return useMutation<
SuccessResponse<UpdateAlertRuleResponse> | ErrorResponse,
Error,
PostableAlertRuleV2
>({
mutationFn: (alertData) => updateAlertRule(id, alertData),
});
}

View File

@ -6,10 +6,14 @@ import { Filters } from 'components/AlertDetailsFilters/Filters';
import RouteTab from 'components/RouteTab';
import Spinner from 'components/Spinner';
import ROUTES from 'constants/routes';
import { CreateAlertProvider } from 'container/CreateAlertV2/context';
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
import history from 'lib/history';
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import AlertHeader from './AlertHeader/AlertHeader';
import { useGetAlertRuleDetails, useRouteTabUtils } from './hooks';
@ -85,6 +89,16 @@ function AlertDetails(): JSX.Element {
document.title = alertTitle || document.title;
}, [alertDetailsResponse?.payload?.data.alert, isRefetching]);
const alertRuleDetails = useMemo(
() => alertDetailsResponse?.payload?.data as PostableAlertRuleV2 | undefined,
[alertDetailsResponse],
);
const initialAlertState = useMemo(
() => getCreateAlertLocalStateFromAlertDef(alertRuleDetails),
[alertRuleDetails],
);
if (
isError ||
!isValidRuleId ||
@ -104,6 +118,12 @@ function AlertDetails(): JSX.Element {
};
return (
<CreateAlertProvider
ruleId={ruleId || ''}
isEditMode
initialAlertType={alertRuleDetails?.alertType as AlertTypes}
initialAlertState={initialAlertState}
>
<div className="alert-details">
<Breadcrumb
className="alert-details__breadcrumb"
@ -134,6 +154,7 @@ function AlertDetails(): JSX.Element {
/>
</div>
</div>
</CreateAlertProvider>
);
}

View File

@ -14,6 +14,7 @@ import CopyToClipboard from 'periscope/components/CopyToClipboard';
import { useAlertRule } from 'providers/Alert';
import { useCallback, useEffect, useState } from 'react';
import { CSSProperties } from 'styled-components';
import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
import { AlertDef } from 'types/api/alerts/def';
import { AlertHeaderProps } from '../AlertHeader';
@ -60,7 +61,11 @@ function AlertActionButtons({
setIsRenameAlertOpen(false);
}, [handleAlertUpdate]);
const isV2Alert = alertDetails.schemaVersion === NEW_ALERT_SCHEMA_VERSION;
const menuItems: MenuProps['items'] = [
...(!isV2Alert
? [
{
key: 'rename-rule',
label: 'Rename',
@ -68,6 +73,8 @@ function AlertActionButtons({
onClick: handleRename,
style: menuItemStyle,
},
]
: []),
{
key: 'duplicate-rule',
label: 'Duplicate',

View File

@ -1,8 +1,14 @@
import './AlertHeader.styles.scss';
import CreateAlertV2Header from 'container/CreateAlertV2/CreateAlertHeader';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { useAlertRule } from 'providers/Alert';
import { useMemo, useState } from 'react';
import {
NEW_ALERT_SCHEMA_VERSION,
PostableAlertRuleV2,
} from 'types/api/alerts/alertTypesV2';
import { GettableAlert } from 'types/api/alerts/get';
import AlertActionButtons from './ActionButtons/ActionButtons';
import AlertLabels from './AlertLabels/AlertLabels';
@ -10,13 +16,7 @@ import AlertSeverity from './AlertSeverity/AlertSeverity';
import AlertState from './AlertState/AlertState';
export type AlertHeaderProps = {
alertDetails: {
state: string;
alert: string;
id: string;
labels: Record<string, string | undefined> | undefined;
disabled: boolean;
};
alertDetails: GettableAlert | PostableAlertRuleV2;
};
function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
const { state, alert: alertName, labels } = alertDetails;
@ -32,12 +32,13 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
return {};
}, [labels]);
return (
<div className="alert-info">
const isV2Alert = alertDetails.schemaVersion === NEW_ALERT_SCHEMA_VERSION;
const CreateAlertV1Header = (
<div className="alert-info__info-wrapper">
<div className="top-section">
<div className="alert-title-wrapper">
<AlertState state={alertRuleState ?? state} />
<AlertState state={alertRuleState ?? state ?? ''} />
<div className="alert-title">
<LineClampedText text={updatedName || alertName} />
</div>
@ -54,10 +55,15 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
<AlertLabels labels={labelsWithoutSeverity} />
</div>
</div>
);
return (
<div className="alert-info">
{isV2Alert ? <CreateAlertV2Header /> : CreateAlertV1Header}
<div className="alert-info__action-buttons">
<AlertActionButtons
alertDetails={alertDetails}
ruleId={alertDetails.id}
ruleId={alertDetails?.id || ''}
setUpdatedName={setUpdatedName}
/>
</div>

View File

@ -8,7 +8,6 @@ import ROUTES from 'constants/routes';
import AllAlertRules from 'container/ListAlertRules';
import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime';
import RoutingPolicies from 'container/RoutingPolicies';
import { showRoutingPoliciesPage } from 'container/RoutingPolicies/utils';
import TriggeredAlerts from 'container/TriggeredAlerts';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
@ -28,10 +27,7 @@ function AllAlertList(): JSX.Element {
const search = urlQuery.get('search');
const showRoutingPoliciesPageFlag = showRoutingPoliciesPage();
const configurationTab = useMemo(() => {
if (showRoutingPoliciesPageFlag) {
const tabs = [
{
label: 'Planned Downtime',
@ -51,13 +47,7 @@ function AllAlertList(): JSX.Element {
items={tabs}
/>
);
}
return (
<div className="planned-downtime-container">
<PlannedDowntime />
</div>
);
}, [showRoutingPoliciesPageFlag]);
}, []);
const items: TabsProps['items'] = [
{

View File

@ -14,6 +14,10 @@ import history from 'lib/history';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import {
NEW_ALERT_SCHEMA_VERSION,
PostableAlertRuleV2,
} from 'types/api/alerts/alertTypesV2';
import {
errorMessageReceivedFromBackend,
@ -88,9 +92,18 @@ function EditRules(): JSX.Element {
return <Spinner tip="Loading Rules..." />;
}
let initialV2AlertValue: PostableAlertRuleV2 | null = null;
if (data.payload.data.schemaVersion === NEW_ALERT_SCHEMA_VERSION) {
initialV2AlertValue = data.payload.data as PostableAlertRuleV2;
}
return (
<div className="edit-rules-container">
<EditRulesContainer ruleId={ruleId || ''} initialValue={data.payload.data} />
<EditRulesContainer
ruleId={ruleId || ''}
initialValue={data.payload.data}
initialV2AlertValue={initialV2AlertValue}
/>
</div>
);
}

View File

@ -13,9 +13,10 @@ export interface BasicThreshold {
export interface PostableAlertRuleV2 {
schemaVersion: string;
id?: string;
alert: string;
alertType: AlertTypes;
ruleType: string;
alertType?: AlertTypes;
ruleType?: string;
condition: {
thresholds?: {
kind: string;
@ -28,13 +29,13 @@ export interface PostableAlertRuleV2 {
requireMinPoints?: boolean;
requiredNumPoints?: number;
};
evaluation: {
kind: 'rolling' | 'cumulative';
spec: {
evaluation?: {
kind?: 'rolling' | 'cumulative';
spec?: {
evalWindow?: string;
frequency: string;
frequency?: string;
schedule?: {
type: 'hourly' | 'daily' | 'monthly';
type?: 'hourly' | 'daily' | 'monthly';
minute?: number;
hour?: number;
day?: number;
@ -42,19 +43,24 @@ export interface PostableAlertRuleV2 {
timezone?: string;
};
};
labels: Labels;
annotations: {
labels?: Labels;
annotations?: {
description: string;
summary: string;
};
notificationSettings: {
notificationGroupBy: string[];
renotify?: string;
alertStates: string[];
notificationPolicy: boolean;
notificationSettings?: {
groupBy?: string[];
renotify?: {
enabled: boolean;
interval?: string;
alertStates?: string[];
};
version: string;
source: string;
usePolicy?: boolean;
};
version?: string;
source?: string;
state?: string;
disabled?: boolean;
}
export interface AlertRuleV2 extends PostableAlertRuleV2 {
@ -66,3 +72,5 @@ export interface AlertRuleV2 extends PostableAlertRuleV2 {
updateAt: string;
updateBy: string;
}
export const NEW_ALERT_SCHEMA_VERSION = 'v2alpha1';

View File

@ -13,6 +13,7 @@ export interface GettableAlert extends AlertDef {
createBy: string;
updateAt: string;
updateBy: string;
schemaVersion: string;
}
export type PayloadProps = {

4
go.mod
View File

@ -127,7 +127,7 @@ require (
github.com/elastic/lunes v0.1.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/expr-lang/expr v1.17.5 // indirect
github.com/expr-lang/expr v1.17.5
github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
@ -338,3 +338,5 @@ require (
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
replace github.com/expr-lang/expr => github.com/SigNoz/expr v1.17.7-beta

4
go.sum
View File

@ -102,6 +102,8 @@ github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA4
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/SigNoz/expr v1.17.7-beta h1:FyZkleM5dTQ0O6muQfwGpoH5A2ohmN/XTasRCO72gAA=
github.com/SigNoz/expr v1.17.7-beta/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd h1:Bk43AsDYe0fhkbj57eGXx8H3ZJ4zhmQXBnrW523ktj8=
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd/go.mod h1:nxRcH/OEdM8QxzH37xkGzomr1O0JpYBRS6pwjsWW6Pc=
github.com/SigNoz/signoz-otel-collector v0.129.4 h1:DGDu9y1I1FU+HX4eECPGmfhnXE4ys4yr7LL6znbf6to=
@ -248,8 +250,6 @@ github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
github.com/expr-lang/expr v1.17.5 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k=
github.com/expr-lang/expr v1.17.5/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb h1:IT4JYU7k4ikYg1SCxNI1/Tieq/NFvh6dzLdgi7eu0tM=
github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb/go.mod h1:bH6Xx7IW64qjjJq8M2u4dxNaBiDfKK+z/3eGDpXEQhc=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=

View File

@ -3,6 +3,8 @@ package alertmanager
import (
"context"
amConfig "github.com/prometheus/alertmanager/config"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/statsreporter"
@ -26,7 +28,7 @@ type Alertmanager interface {
TestReceiver(context.Context, string, alertmanagertypes.Receiver) error
// TestAlert sends an alert to a list of receivers.
TestAlert(ctx context.Context, orgID string, alert *alertmanagertypes.PostableAlert, receivers []string) error
TestAlert(ctx context.Context, orgID string, ruleID string, receiversMap map[*alertmanagertypes.PostableAlert][]string) error
// ListChannels lists all channels for the organization.
ListChannels(context.Context, string) ([]*alertmanagertypes.Channel, error)
@ -59,6 +61,19 @@ type Alertmanager interface {
DeleteNotificationConfig(ctx context.Context, orgID valuer.UUID, ruleId string) error
// Notification Policy CRUD
CreateRoutePolicy(ctx context.Context, route *alertmanagertypes.PostableRoutePolicy) (*alertmanagertypes.GettableRoutePolicy, error)
CreateRoutePolicies(ctx context.Context, routeRequests []*alertmanagertypes.PostableRoutePolicy) ([]*alertmanagertypes.GettableRoutePolicy, error)
GetRoutePolicyByID(ctx context.Context, routeID string) (*alertmanagertypes.GettableRoutePolicy, error)
GetAllRoutePolicies(ctx context.Context) ([]*alertmanagertypes.GettableRoutePolicy, error)
UpdateRoutePolicyByID(ctx context.Context, routeID string, route *alertmanagertypes.PostableRoutePolicy) (*alertmanagertypes.GettableRoutePolicy, error)
DeleteRoutePolicyByID(ctx context.Context, routeID string) error
DeleteAllRoutePoliciesByRuleId(ctx context.Context, ruleId string) error
UpdateAllRoutePoliciesByRuleId(ctx context.Context, ruleId string, routes []*alertmanagertypes.PostableRoutePolicy) error
CreateInhibitRules(ctx context.Context, orgID valuer.UUID, rules []amConfig.InhibitRule) error
DeleteAllInhibitRulesByRuleId(ctx context.Context, orgID valuer.UUID, ruleId string) error
// Collects stats for the organization.
statsreporter.StatsCollector
}

View File

@ -10,19 +10,17 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/prometheus/alertmanager/dispatch"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/pkg/labels"
"github.com/prometheus/alertmanager/provider"
"github.com/prometheus/alertmanager/store"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
)
const (
noDataLabel = model.LabelName("nodata")
)
// Dispatcher sorts incoming alerts into aggregation groups and
// assigns the correct notifiers to each.
type Dispatcher struct {
@ -46,6 +44,7 @@ type Dispatcher struct {
logger *slog.Logger
notificationManager nfmanager.NotificationManager
orgID string
receiverRoutes map[string]*dispatch.Route
}
// We use the upstream Limits interface from Prometheus
@ -90,6 +89,7 @@ func (d *Dispatcher) Run() {
d.mtx.Lock()
d.aggrGroupsPerRoute = map[*dispatch.Route]map[model.Fingerprint]*aggrGroup{}
d.receiverRoutes = map[string]*dispatch.Route{}
d.aggrGroupsNum = 0
d.metrics.aggrGroups.Set(0)
d.ctx, d.cancel = context.WithCancel(context.Background())
@ -125,8 +125,14 @@ func (d *Dispatcher) run(it provider.AlertIterator) {
}
now := time.Now()
for _, r := range d.route.Match(alert.Labels) {
d.processAlert(alert, r)
channels, err := d.notificationManager.Match(d.ctx, d.orgID, getRuleIDFromAlert(alert), alert.Labels)
if err != nil {
d.logger.ErrorContext(d.ctx, "Error on alert match", "err", err)
continue
}
for _, channel := range channels {
route := d.getOrCreateRoute(channel)
d.processAlert(alert, route)
}
d.metrics.processingDuration.Observe(time.Since(now).Seconds())
@ -266,6 +272,7 @@ type notifyFunc func(context.Context, ...*types.Alert) bool
// processAlert determines in which aggregation group the alert falls
// and inserts it.
// no data alert will only have ruleId and no data label
func (d *Dispatcher) processAlert(alert *types.Alert, route *dispatch.Route) {
ruleId := getRuleIDFromAlert(alert)
config, err := d.notificationManager.GetNotificationConfig(d.orgID, ruleId)
@ -273,8 +280,14 @@ func (d *Dispatcher) processAlert(alert *types.Alert, route *dispatch.Route) {
d.logger.ErrorContext(d.ctx, "error getting alert notification config", "rule_id", ruleId, "error", err)
return
}
renotifyInterval := config.Renotify.RenotifyInterval
groupLabels := getGroupLabels(alert, config.NotificationGroup)
groupLabels := getGroupLabels(alert, config.NotificationGroup, config.GroupByAll)
if alertmanagertypes.NoDataAlert(alert) {
renotifyInterval = config.Renotify.NoDataInterval
groupLabels[alertmanagertypes.NoDataLabel] = alert.Labels[alertmanagertypes.NoDataLabel] //to create new group key for no data alerts
}
fp := groupLabels.Fingerprint()
@ -299,12 +312,6 @@ func (d *Dispatcher) processAlert(alert *types.Alert, route *dispatch.Route) {
d.logger.ErrorContext(d.ctx, "Too many aggregation groups, cannot create new group for alert", "groups", d.aggrGroupsNum, "limit", limit, "alert", alert.Name())
return
}
renotifyInterval := config.Renotify.RenotifyInterval
if noDataAlert(alert) {
renotifyInterval = config.Renotify.NoDataInterval
groupLabels[noDataLabel] = alert.Labels[noDataLabel]
}
ag = newAggrGroup(d.ctx, groupLabels, route, d.timeout, d.logger, renotifyInterval)
@ -543,21 +550,35 @@ func deepCopyRouteOpts(opts dispatch.RouteOpts, renotify time.Duration) dispatch
return newOpts
}
func getGroupLabels(alert *types.Alert, groups map[model.LabelName]struct{}) model.LabelSet {
func getGroupLabels(alert *types.Alert, groups map[model.LabelName]struct{}, groupByAll bool) model.LabelSet {
groupLabels := model.LabelSet{}
for ln, lv := range alert.Labels {
if _, ok := groups[ln]; ok {
if _, ok := groups[ln]; ok || groupByAll {
groupLabels[ln] = lv
}
}
return groupLabels
}
func noDataAlert(alert *types.Alert) bool {
if _, ok := alert.Labels[noDataLabel]; ok {
return true
} else {
return false
func (d *Dispatcher) getOrCreateRoute(receiver string) *dispatch.Route {
d.mtx.Lock()
defer d.mtx.Unlock()
if route, exists := d.receiverRoutes[receiver]; exists {
return route
}
route := &dispatch.Route{
RouteOpts: dispatch.RouteOpts{
Receiver: receiver,
GroupWait: 30 * time.Second,
GroupInterval: 5 * time.Minute,
GroupByAll: false,
},
Matchers: labels.Matchers{{
Name: "__receiver__",
Value: receiver,
Type: labels.MatchEqual,
}},
}
d.receiverRoutes[receiver] = route
return route
}

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,9 @@ package alertmanagerserver
import (
"context"
"fmt"
"github.com/prometheus/alertmanager/types"
"golang.org/x/sync/errgroup"
"log/slog"
"strings"
"sync"
@ -321,39 +324,104 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
}
func (server *Server) TestReceiver(ctx context.Context, receiver alertmanagertypes.Receiver) error {
return alertmanagertypes.TestReceiver(ctx, receiver, alertmanagernotify.NewReceiverIntegrations, server.alertmanagerConfig, server.tmpl, server.logger, alertmanagertypes.NewTestAlert(receiver, time.Now(), time.Now()))
testAlert := alertmanagertypes.NewTestAlert(receiver, time.Now(), time.Now())
return alertmanagertypes.TestReceiver(ctx, receiver, alertmanagernotify.NewReceiverIntegrations, server.alertmanagerConfig, server.tmpl, server.logger, testAlert.Labels, testAlert)
}
func (server *Server) TestAlert(ctx context.Context, postableAlert *alertmanagertypes.PostableAlert, receivers []string) error {
alerts, err := alertmanagertypes.NewAlertsFromPostableAlerts(alertmanagertypes.PostableAlerts{postableAlert}, time.Duration(server.srvConfig.Global.ResolveTimeout), time.Now())
func (server *Server) TestAlert(ctx context.Context, receiversMap map[*alertmanagertypes.PostableAlert][]string, config *alertmanagertypes.NotificationConfig) error {
if len(receiversMap) == 0 {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput,
"expected at least 1 alert, got 0")
}
postableAlerts := make(alertmanagertypes.PostableAlerts, 0, len(receiversMap))
for alert := range receiversMap {
postableAlerts = append(postableAlerts, alert)
}
alerts, err := alertmanagertypes.NewAlertsFromPostableAlerts(
postableAlerts,
time.Duration(server.srvConfig.Global.ResolveTimeout),
time.Now(),
)
if err != nil {
return errors.Join(err...)
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput,
"failed to construct alerts from postable alerts: %v", err)
}
if len(alerts) != 1 {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "expected 1 alert, got %d", len(alerts))
type alertGroup struct {
groupLabels model.LabelSet
alerts []*types.Alert
receivers map[string]struct{}
}
ch := make(chan error, len(receivers))
for _, receiverName := range receivers {
go func(receiverName string) {
groupMap := make(map[model.Fingerprint]*alertGroup)
for i, alert := range alerts {
labels := getGroupLabels(alert, config.NotificationGroup, config.GroupByAll)
fp := labels.Fingerprint()
postableAlert := postableAlerts[i]
alertReceivers := receiversMap[postableAlert]
if group, exists := groupMap[fp]; exists {
group.alerts = append(group.alerts, alert)
for _, r := range alertReceivers {
group.receivers[r] = struct{}{}
}
} else {
receiverSet := make(map[string]struct{})
for _, r := range alertReceivers {
receiverSet[r] = struct{}{}
}
groupMap[fp] = &alertGroup{
groupLabels: labels,
alerts: []*types.Alert{alert},
receivers: receiverSet,
}
}
}
var mu sync.Mutex
var errs []error
g, gCtx := errgroup.WithContext(ctx)
for _, group := range groupMap {
for receiverName := range group.receivers {
group := group
receiverName := receiverName
g.Go(func() error {
receiver, err := server.alertmanagerConfig.GetReceiver(receiverName)
if err != nil {
ch <- err
return
}
ch <- alertmanagertypes.TestReceiver(ctx, receiver, alertmanagernotify.NewReceiverIntegrations, server.alertmanagerConfig, server.tmpl, server.logger, alerts[0])
}(receiverName)
mu.Lock()
errs = append(errs, fmt.Errorf("failed to get receiver %q: %w", receiverName, err))
mu.Unlock()
return nil // Return nil to continue processing other goroutines
}
var errs []error
for i := 0; i < len(receivers); i++ {
if err := <-ch; err != nil {
errs = append(errs, err)
err = alertmanagertypes.TestReceiver(
gCtx,
receiver,
alertmanagernotify.NewReceiverIntegrations,
server.alertmanagerConfig,
server.tmpl,
server.logger,
group.groupLabels,
group.alerts...,
)
if err != nil {
mu.Lock()
errs = append(errs, fmt.Errorf("receiver %q test failed: %w", receiverName, err))
mu.Unlock()
}
return nil // Return nil to continue processing other goroutines
})
}
}
_ = g.Wait()
if errs != nil {
if len(errs) > 0 {
return errors.Join(errs...)
}

View File

@ -0,0 +1,223 @@
package alertmanagerserver
import (
"context"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes/alertmanagertypestest"
"github.com/prometheus/alertmanager/dispatch"
"io"
"log/slog"
"net/http"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfroutingstore/nfroutingstoretest"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/rulebasednotification"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/go-openapi/strfmt"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
)
func TestEndToEndAlertManagerFlow(t *testing.T) {
ctx := context.Background()
providerSettings := instrumentationtest.New().ToProviderSettings()
store := nfroutingstoretest.NewMockSQLRouteStore()
store.MatchExpectationsInOrder(false)
notificationManager, err := rulebasednotification.New(ctx, providerSettings, nfmanager.Config{}, store)
require.NoError(t, err)
orgID := "test-org"
routes := []*alertmanagertypes.RoutePolicy{
{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
Expression: `ruleId == "high-cpu-usage" && severity == "critical"`,
ExpressionKind: alertmanagertypes.RuleBasedExpression,
Name: "high-cpu-usage",
Description: "High CPU critical alerts to webhook",
Enabled: true,
OrgID: orgID,
Channels: []string{"webhook"},
},
{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
Expression: `ruleId == "high-cpu-usage" && severity == "warning"`,
ExpressionKind: alertmanagertypes.RuleBasedExpression,
Name: "high-cpu-usage",
Description: "High CPU warning alerts to webhook",
Enabled: true,
OrgID: orgID,
Channels: []string{"webhook"},
},
}
store.ExpectCreateBatch(routes)
err = notificationManager.CreateRoutePolicies(ctx, orgID, routes)
require.NoError(t, err)
for range routes {
ruleID := "high-cpu-usage"
store.ExpectGetAllByName(orgID, ruleID, routes)
store.ExpectGetAllByName(orgID, ruleID, routes)
}
notifConfig := alertmanagertypes.NotificationConfig{
NotificationGroup: map[model.LabelName]struct{}{
model.LabelName("cluster"): {},
model.LabelName("instance"): {},
},
Renotify: alertmanagertypes.ReNotificationConfig{
RenotifyInterval: 5 * time.Minute,
},
UsePolicy: false,
}
err = notificationManager.SetNotificationConfig(orgID, "high-cpu-usage", &notifConfig)
require.NoError(t, err)
srvCfg := NewConfig()
stateStore := alertmanagertypestest.NewStateStore()
registry := prometheus.NewRegistry()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
server, err := New(context.Background(), logger, registry, srvCfg, orgID, stateStore, notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, orgID)
require.NoError(t, err)
err = server.SetConfig(ctx, amConfig)
require.NoError(t, err)
// Create test alerts
now := time.Now()
testAlerts := []*alertmanagertypes.PostableAlert{
{
Alert: alertmanagertypes.AlertModel{
Labels: map[string]string{
"ruleId": "high-cpu-usage",
"severity": "critical",
"cluster": "prod-cluster",
"instance": "server-01",
"alertname": "HighCPUUsage",
},
},
Annotations: map[string]string{
"summary": "High CPU usage detected",
"description": "CPU usage is above 90% for 5 minutes",
},
StartsAt: strfmt.DateTime(now.Add(-5 * time.Minute)),
EndsAt: strfmt.DateTime(time.Time{}), // Active alert
},
{
Alert: alertmanagertypes.AlertModel{
Labels: map[string]string{
"ruleId": "high-cpu-usage",
"severity": "warning",
"cluster": "prod-cluster",
"instance": "server-02",
"alertname": "HighCPUUsage",
},
},
Annotations: map[string]string{
"summary": "Moderate CPU usage detected",
"description": "CPU usage is above 70% for 10 minutes",
},
StartsAt: strfmt.DateTime(now.Add(-10 * time.Minute)),
EndsAt: strfmt.DateTime(time.Time{}), // Active alert
},
{
Alert: alertmanagertypes.AlertModel{
Labels: map[string]string{
"ruleId": "high-cpu-usage",
"severity": "critical",
"cluster": "prod-cluster",
"instance": "server-03",
"alertname": "HighCPUUsage",
},
},
Annotations: map[string]string{
"summary": "High CPU usage detected on server-03",
"description": "CPU usage is above 95% for 3 minutes",
},
StartsAt: strfmt.DateTime(now.Add(-3 * time.Minute)),
EndsAt: strfmt.DateTime(time.Time{}), // Active alert
},
}
err = server.PutAlerts(ctx, testAlerts)
require.NoError(t, err)
time.Sleep(2 * time.Second)
t.Run("verify_alerts_processed", func(t *testing.T) {
dummyRequest, err := http.NewRequest(http.MethodGet, "/alerts", nil)
require.NoError(t, err)
params, err := alertmanagertypes.NewGettableAlertsParams(dummyRequest)
require.NoError(t, err)
alerts, err := server.GetAlerts(context.Background(), params)
require.NoError(t, err)
require.Len(t, alerts, 3, "Expected 3 active alerts")
for _, alert := range alerts {
require.Equal(t, "high-cpu-usage", alert.Alert.Labels["ruleId"])
require.NotEmpty(t, alert.Alert.Labels["severity"])
require.Contains(t, []string{"critical", "warning"}, alert.Alert.Labels["severity"])
require.Equal(t, "prod-cluster", alert.Alert.Labels["cluster"])
require.NotEmpty(t, alert.Alert.Labels["instance"])
}
criticalAlerts := 0
warningAlerts := 0
for _, alert := range alerts {
if alert.Alert.Labels["severity"] == "critical" {
criticalAlerts++
} else if alert.Alert.Labels["severity"] == "warning" {
warningAlerts++
}
}
require.Equal(t, 2, criticalAlerts, "Expected 2 critical alerts")
require.Equal(t, 1, warningAlerts, "Expected 1 warning alert")
})
t.Run("verify_notification_routing", func(t *testing.T) {
notifConfig, err := notificationManager.GetNotificationConfig(orgID, "high-cpu-usage")
require.NoError(t, err)
require.NotNil(t, notifConfig)
require.Equal(t, 5*time.Minute, notifConfig.Renotify.RenotifyInterval)
require.Contains(t, notifConfig.NotificationGroup, model.LabelName("ruleId"))
require.Contains(t, notifConfig.NotificationGroup, model.LabelName("cluster"))
require.Contains(t, notifConfig.NotificationGroup, model.LabelName("instance"))
})
t.Run("verify_alert_groups_and_stages", func(t *testing.T) {
time.Sleep(2 * time.Second)
alertGroups, _ := server.dispatcher.Groups(
func(route *dispatch.Route) bool { return true }, // Accept all routes
func(alert *alertmanagertypes.Alert, now time.Time) bool { return true }, // Accept all alerts
)
require.Len(t, alertGroups, 3)
require.NotEmpty(t, alertGroups, "Should have alert groups created by dispatcher")
totalAlerts := 0
for _, group := range alertGroups {
totalAlerts += len(group.Alerts)
}
require.Equal(t, 3, totalAlerts, "Should have 3 alerts total across all groups")
require.Equal(t, "{__receiver__=\"webhook\"}:{cluster=\"prod-cluster\", instance=\"server-01\", ruleId=\"high-cpu-usage\"}", alertGroups[0].GroupKey)
require.Equal(t, "{__receiver__=\"webhook\"}:{cluster=\"prod-cluster\", instance=\"server-02\", ruleId=\"high-cpu-usage\"}", alertGroups[1].GroupKey)
require.Equal(t, "{__receiver__=\"webhook\"}:{cluster=\"prod-cluster\", instance=\"server-03\", ruleId=\"high-cpu-usage\"}", alertGroups[2].GroupKey)
})
}

View File

@ -19,6 +19,7 @@ import (
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/client_golang/prometheus"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -127,3 +128,189 @@ func TestServerPutAlerts(t *testing.T) {
assert.Equal(t, gettableAlerts[0].Alert.Labels["alertname"], "test-alert")
assert.NoError(t, server.Stop(context.Background()))
}
func TestServerTestAlert(t *testing.T) {
stateStore := alertmanagertypestest.NewStateStore()
srvCfg := NewConfig()
srvCfg.Route.GroupInterval = 1 * time.Second
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.NewTextHandler(io.Discard, nil)), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")
require.NoError(t, err)
webhook1Listener, err := net.Listen("tcp", "localhost:0")
require.NoError(t, err)
webhook2Listener, err := net.Listen("tcp", "localhost:0")
require.NoError(t, err)
requestCount1 := 0
requestCount2 := 0
webhook1Server := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount1++
w.WriteHeader(http.StatusOK)
}),
}
webhook2Server := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount2++
w.WriteHeader(http.StatusOK)
}),
}
go func() {
_ = webhook1Server.Serve(webhook1Listener)
}()
go func() {
_ = webhook2Server.Serve(webhook2Listener)
}()
webhook1URL, err := url.Parse("http://" + webhook1Listener.Addr().String() + "/webhook")
require.NoError(t, err)
webhook2URL, err := url.Parse("http://" + webhook2Listener.Addr().String() + "/webhook")
require.NoError(t, err)
require.NoError(t, amConfig.CreateReceiver(alertmanagertypes.Receiver{
Name: "receiver-1",
WebhookConfigs: []*config.WebhookConfig{
{
HTTPConfig: &commoncfg.HTTPClientConfig{},
URL: &config.SecretURL{URL: webhook1URL},
},
},
}))
require.NoError(t, amConfig.CreateReceiver(alertmanagertypes.Receiver{
Name: "receiver-2",
WebhookConfigs: []*config.WebhookConfig{
{
HTTPConfig: &commoncfg.HTTPClientConfig{},
URL: &config.SecretURL{URL: webhook2URL},
},
},
}))
require.NoError(t, server.SetConfig(context.Background(), amConfig))
defer func() {
_ = server.Stop(context.Background())
_ = webhook1Server.Close()
_ = webhook2Server.Close()
}()
// Test with multiple alerts going to different receivers
alert1 := &alertmanagertypes.PostableAlert{
Annotations: models.LabelSet{"alertname": "test-alert-1"},
StartsAt: strfmt.DateTime(time.Now()),
Alert: models.Alert{
Labels: models.LabelSet{"alertname": "test-alert-1", "severity": "critical"},
},
}
alert2 := &alertmanagertypes.PostableAlert{
Annotations: models.LabelSet{"alertname": "test-alert-2"},
StartsAt: strfmt.DateTime(time.Now()),
Alert: models.Alert{
Labels: models.LabelSet{"alertname": "test-alert-2", "severity": "warning"},
},
}
receiversMap := map[*alertmanagertypes.PostableAlert][]string{
alert1: {"receiver-1", "receiver-2"},
alert2: {"receiver-2"},
}
config := &alertmanagertypes.NotificationConfig{
NotificationGroup: make(map[model.LabelName]struct{}),
GroupByAll: false,
}
err = server.TestAlert(context.Background(), receiversMap, config)
require.NoError(t, err)
time.Sleep(100 * time.Millisecond)
assert.Greater(t, requestCount1, 0, "receiver-1 should have received at least one request")
assert.Greater(t, requestCount2, 0, "receiver-2 should have received at least one request")
}
func TestServerTestAlertContinuesOnFailure(t *testing.T) {
stateStore := alertmanagertypestest.NewStateStore()
srvCfg := NewConfig()
srvCfg.Route.GroupInterval = 1 * time.Second
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.NewTextHandler(io.Discard, nil)), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")
require.NoError(t, err)
// Create one working webhook and one failing receiver (non-existent)
webhookListener, err := net.Listen("tcp", "localhost:0")
require.NoError(t, err)
requestCount := 0
webhookServer := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
w.WriteHeader(http.StatusOK)
}),
}
go func() {
_ = webhookServer.Serve(webhookListener)
}()
webhookURL, err := url.Parse("http://" + webhookListener.Addr().String() + "/webhook")
require.NoError(t, err)
require.NoError(t, amConfig.CreateReceiver(alertmanagertypes.Receiver{
Name: "working-receiver",
WebhookConfigs: []*config.WebhookConfig{
{
HTTPConfig: &commoncfg.HTTPClientConfig{},
URL: &config.SecretURL{URL: webhookURL},
},
},
}))
require.NoError(t, amConfig.CreateReceiver(alertmanagertypes.Receiver{
Name: "failing-receiver",
WebhookConfigs: []*config.WebhookConfig{
{
HTTPConfig: &commoncfg.HTTPClientConfig{},
URL: &config.SecretURL{URL: &url.URL{Scheme: "http", Host: "localhost:1", Path: "/webhook"}},
},
},
}))
require.NoError(t, server.SetConfig(context.Background(), amConfig))
defer func() {
_ = server.Stop(context.Background())
_ = webhookServer.Close()
}()
alert := &alertmanagertypes.PostableAlert{
Annotations: models.LabelSet{"alertname": "test-alert"},
StartsAt: strfmt.DateTime(time.Now()),
Alert: models.Alert{
Labels: models.LabelSet{"alertname": "test-alert"},
},
}
receiversMap := map[*alertmanagertypes.PostableAlert][]string{
alert: {"working-receiver", "failing-receiver"},
}
config := &alertmanagertypes.NotificationConfig{
NotificationGroup: make(map[model.LabelName]struct{}),
GroupByAll: false,
}
err = server.TestAlert(context.Background(), receiversMap, config)
assert.Error(t, err)
time.Sleep(100 * time.Millisecond)
assert.Greater(t, requestCount, 0, "working-receiver should have received at least one request even though failing-receiver failed")
}

View File

@ -2,6 +2,7 @@ package alertmanager
import (
"context"
"encoding/json"
"io"
"net/http"
"time"
@ -273,3 +274,128 @@ func (api *API) CreateChannel(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusNoContent, nil)
}
func (api *API) CreateRoutePolicy(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
body, err := io.ReadAll(req.Body)
if err != nil {
render.Error(rw, err)
return
}
defer req.Body.Close()
var policy alertmanagertypes.PostableRoutePolicy
err = json.Unmarshal(body, &policy)
if err != nil {
render.Error(rw, err)
return
}
policy.ExpressionKind = alertmanagertypes.PolicyBasedExpression
// Validate the postable route
if err := policy.Validate(); err != nil {
render.Error(rw, err)
return
}
result, err := api.alertmanager.CreateRoutePolicy(ctx, &policy)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, result)
}
func (api *API) GetAllRoutePolicies(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
policies, err := api.alertmanager.GetAllRoutePolicies(ctx)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, policies)
}
func (api *API) GetRoutePolicyByID(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
vars := mux.Vars(req)
policyID := vars["id"]
if policyID == "" {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "policy ID is required"))
return
}
policy, err := api.alertmanager.GetRoutePolicyByID(ctx, policyID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, policy)
}
func (api *API) DeleteRoutePolicyByID(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
vars := mux.Vars(req)
policyID := vars["id"]
if policyID == "" {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "policy ID is required"))
return
}
err := api.alertmanager.DeleteRoutePolicyByID(ctx, policyID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (api *API) UpdateRoutePolicy(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
vars := mux.Vars(req)
policyID := vars["id"]
if policyID == "" {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "policy ID is required"))
return
}
body, err := io.ReadAll(req.Body)
if err != nil {
render.Error(rw, err)
return
}
defer req.Body.Close()
var policy alertmanagertypes.PostableRoutePolicy
err = json.Unmarshal(body, &policy)
if err != nil {
render.Error(rw, err)
return
}
policy.ExpressionKind = alertmanagertypes.PolicyBasedExpression
// Validate the postable route
if err := policy.Validate(); err != nil {
render.Error(rw, err)
return
}
result, err := api.alertmanager.UpdateRoutePolicyByID(ctx, policyID, &policy)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}

View File

@ -1,12 +1,19 @@
package nfmanagertest
import (
"context"
"fmt"
"strings"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/prometheus/common/model"
)
// MockNotificationManager is a simple mock implementation of NotificationManager
type MockNotificationManager struct {
configs map[string]*alertmanagertypes.NotificationConfig
routes map[string]*alertmanagertypes.RoutePolicy
routesByName map[string][]*alertmanagertypes.RoutePolicy
errors map[string]error
}
@ -14,6 +21,8 @@ type MockNotificationManager struct {
func NewMock() *MockNotificationManager {
return &MockNotificationManager{
configs: make(map[string]*alertmanagertypes.NotificationConfig),
routes: make(map[string]*alertmanagertypes.RoutePolicy),
routesByName: make(map[string][]*alertmanagertypes.RoutePolicy),
errors: make(map[string]error),
}
}
@ -65,6 +74,8 @@ func (m *MockNotificationManager) SetMockError(orgID, ruleID string, err error)
func (m *MockNotificationManager) ClearMockData() {
m.configs = make(map[string]*alertmanagertypes.NotificationConfig)
m.routes = make(map[string]*alertmanagertypes.RoutePolicy)
m.routesByName = make(map[string][]*alertmanagertypes.RoutePolicy)
m.errors = make(map[string]error)
}
@ -73,3 +84,241 @@ func (m *MockNotificationManager) HasConfig(orgID, ruleID string) bool {
_, exists := m.configs[key]
return exists
}
// Route Policy CRUD
func (m *MockNotificationManager) CreateRoutePolicy(ctx context.Context, orgID string, route *alertmanagertypes.RoutePolicy) error {
key := getKey(orgID, "create_route")
if err := m.errors[key]; err != nil {
return err
}
if route == nil {
return fmt.Errorf("route cannot be nil")
}
if err := route.Validate(); err != nil {
return err
}
routeKey := getKey(orgID, route.ID.StringValue())
m.routes[routeKey] = route
nameKey := getKey(orgID, route.Name)
m.routesByName[nameKey] = append(m.routesByName[nameKey], route)
return nil
}
func (m *MockNotificationManager) CreateRoutePolicies(ctx context.Context, orgID string, routes []*alertmanagertypes.RoutePolicy) error {
key := getKey(orgID, "create_routes")
if err := m.errors[key]; err != nil {
return err
}
if len(routes) == 0 {
return fmt.Errorf("routes cannot be empty")
}
for i, route := range routes {
if route == nil {
return fmt.Errorf("route at index %d cannot be nil", i)
}
if err := route.Validate(); err != nil {
return fmt.Errorf("route at index %d: %s", i, err.Error())
}
}
for _, route := range routes {
if err := m.CreateRoutePolicy(ctx, orgID, route); err != nil {
return err
}
}
return nil
}
func (m *MockNotificationManager) GetRoutePolicyByID(ctx context.Context, orgID string, routeID string) (*alertmanagertypes.RoutePolicy, error) {
key := getKey(orgID, "get_route")
if err := m.errors[key]; err != nil {
return nil, err
}
if routeID == "" {
return nil, fmt.Errorf("routeID cannot be empty")
}
routeKey := getKey(orgID, routeID)
route, exists := m.routes[routeKey]
if !exists {
return nil, fmt.Errorf("route with ID %s not found", routeID)
}
return route, nil
}
func (m *MockNotificationManager) GetAllRoutePolicies(ctx context.Context, orgID string) ([]*alertmanagertypes.RoutePolicy, error) {
key := getKey(orgID, "get_all_routes")
if err := m.errors[key]; err != nil {
return nil, err
}
if orgID == "" {
return nil, fmt.Errorf("orgID cannot be empty")
}
var routes []*alertmanagertypes.RoutePolicy
for routeKey, route := range m.routes {
if route.OrgID == orgID {
routes = append(routes, route)
}
_ = routeKey
}
return routes, nil
}
func (m *MockNotificationManager) DeleteRoutePolicy(ctx context.Context, orgID string, routeID string) error {
key := getKey(orgID, "delete_route")
if err := m.errors[key]; err != nil {
return err
}
if routeID == "" {
return fmt.Errorf("routeID cannot be empty")
}
routeKey := getKey(orgID, routeID)
route, exists := m.routes[routeKey]
if !exists {
return fmt.Errorf("route with ID %s not found", routeID)
}
delete(m.routes, routeKey)
nameKey := getKey(orgID, route.Name)
if nameRoutes, exists := m.routesByName[nameKey]; exists {
var filtered []*alertmanagertypes.RoutePolicy
for _, r := range nameRoutes {
if r.ID.StringValue() != routeID {
filtered = append(filtered, r)
}
}
if len(filtered) == 0 {
delete(m.routesByName, nameKey)
} else {
m.routesByName[nameKey] = filtered
}
}
return nil
}
func (m *MockNotificationManager) DeleteAllRoutePoliciesByName(ctx context.Context, orgID string, name string) error {
key := getKey(orgID, "delete_routes_by_name")
if err := m.errors[key]; err != nil {
return err
}
if orgID == "" {
return fmt.Errorf("orgID cannot be empty")
}
if name == "" {
return fmt.Errorf("name cannot be empty")
}
nameKey := getKey(orgID, name)
routes, exists := m.routesByName[nameKey]
if !exists {
return nil // No routes to delete
}
for _, route := range routes {
routeKey := getKey(orgID, route.ID.StringValue())
delete(m.routes, routeKey)
}
delete(m.routesByName, nameKey)
return nil
}
func (m *MockNotificationManager) Match(ctx context.Context, orgID string, ruleID string, set model.LabelSet) ([]string, error) {
key := getKey(orgID, ruleID)
if err := m.errors[key]; err != nil {
return nil, err
}
config, err := m.GetNotificationConfig(orgID, ruleID)
if err != nil {
return nil, err
}
var expressionRoutes []*alertmanagertypes.RoutePolicy
if config.UsePolicy {
for _, route := range m.routes {
if route.OrgID == orgID && route.ExpressionKind == alertmanagertypes.PolicyBasedExpression {
expressionRoutes = append(expressionRoutes, route)
}
}
} else {
nameKey := getKey(orgID, ruleID)
if routes, exists := m.routesByName[nameKey]; exists {
expressionRoutes = routes
}
}
var matchedChannels []string
for _, route := range expressionRoutes {
if m.evaluateExpr(route.Expression, set) {
matchedChannels = append(matchedChannels, route.Channels...)
}
}
return matchedChannels, nil
}
func (m *MockNotificationManager) evaluateExpr(expression string, labelSet model.LabelSet) bool {
ruleID, ok := labelSet["ruleId"]
if !ok {
return false
}
if strings.Contains(expression, `ruleId in ["ruleId-OtherAlert", "ruleId-TestingAlert"]`) {
return ruleID == "ruleId-OtherAlert" || ruleID == "ruleId-TestingAlert"
}
if strings.Contains(expression, `ruleId in ["ruleId-HighLatency", "ruleId-HighErrorRate"]`) {
return ruleID == "ruleId-HighLatency" || ruleID == "ruleId-HighErrorRate"
}
if strings.Contains(expression, `ruleId == "ruleId-HighLatency"`) {
return ruleID == "ruleId-HighLatency"
}
return false
}
// Helper methods for testing
func (m *MockNotificationManager) SetMockRoute(orgID string, route *alertmanagertypes.RoutePolicy) {
routeKey := getKey(orgID, route.ID.StringValue())
m.routes[routeKey] = route
nameKey := getKey(orgID, route.Name)
m.routesByName[nameKey] = append(m.routesByName[nameKey], route)
}
func (m *MockNotificationManager) SetMockRouteError(orgID, operation string, err error) {
key := getKey(orgID, operation)
m.errors[key] = err
}
func (m *MockNotificationManager) ClearMockRoutes() {
m.routes = make(map[string]*alertmanagertypes.RoutePolicy)
m.routesByName = make(map[string][]*alertmanagertypes.RoutePolicy)
}
func (m *MockNotificationManager) GetRouteCount() int {
return len(m.routes)
}
func (m *MockNotificationManager) HasRoute(orgID, routeID string) bool {
routeKey := getKey(orgID, routeID)
_, exists := m.routes[routeKey]
return exists
}

View File

@ -0,0 +1,176 @@
package nfroutingstoretest
import (
"context"
"regexp"
"strings"
"github.com/DATA-DOG/go-sqlmock"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfroutingstore/sqlroutingstore"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
)
type MockSQLRouteStore struct {
routeStore alertmanagertypes.RouteStore
mock sqlmock.Sqlmock
}
func NewMockSQLRouteStore() *MockSQLRouteStore {
sqlStore := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherRegexp)
routeStore := sqlroutingstore.NewStore(sqlStore)
return &MockSQLRouteStore{
routeStore: routeStore,
mock: sqlStore.Mock(),
}
}
func (m *MockSQLRouteStore) Mock() sqlmock.Sqlmock {
return m.mock
}
func (m *MockSQLRouteStore) GetByID(ctx context.Context, orgId string, id string) (*alertmanagertypes.RoutePolicy, error) {
return m.routeStore.GetByID(ctx, orgId, id)
}
func (m *MockSQLRouteStore) Create(ctx context.Context, route *alertmanagertypes.RoutePolicy) error {
return m.routeStore.Create(ctx, route)
}
func (m *MockSQLRouteStore) CreateBatch(ctx context.Context, routes []*alertmanagertypes.RoutePolicy) error {
return m.routeStore.CreateBatch(ctx, routes)
}
func (m *MockSQLRouteStore) Delete(ctx context.Context, orgId string, id string) error {
return m.routeStore.Delete(ctx, orgId, id)
}
func (m *MockSQLRouteStore) GetAllByKind(ctx context.Context, orgID string, kind alertmanagertypes.ExpressionKind) ([]*alertmanagertypes.RoutePolicy, error) {
return m.routeStore.GetAllByKind(ctx, orgID, kind)
}
func (m *MockSQLRouteStore) GetAllByName(ctx context.Context, orgID string, name string) ([]*alertmanagertypes.RoutePolicy, error) {
return m.routeStore.GetAllByName(ctx, orgID, name)
}
func (m *MockSQLRouteStore) DeleteRouteByName(ctx context.Context, orgID string, name string) error {
return m.routeStore.DeleteRouteByName(ctx, orgID, name)
}
func (m *MockSQLRouteStore) ExpectGetByID(orgID, id string, route *alertmanagertypes.RoutePolicy) {
rows := sqlmock.NewRows([]string{"id", "org_id", "name", "expression", "kind", "description", "enabled", "tags", "channels", "created_at", "updated_at", "created_by", "updated_by"})
if route != nil {
rows.AddRow(
route.ID.StringValue(),
route.OrgID,
route.Name,
route.Expression,
route.ExpressionKind.StringValue(),
route.Description,
route.Enabled,
"[]", // tags as JSON
`["`+strings.Join(route.Channels, `","`)+`"]`, // channels as JSON
"0001-01-01T00:00:00Z", // created_at
"0001-01-01T00:00:00Z", // updated_at
"", // created_by
"", // updated_by
)
}
m.mock.ExpectQuery(`SELECT (.+) FROM "route_policy" WHERE \(id = \$1\) AND \(org_id = \$2\)`).
WithArgs(id, orgID).
WillReturnRows(rows)
}
func (m *MockSQLRouteStore) ExpectCreate(route *alertmanagertypes.RoutePolicy) {
expectedPattern := `INSERT INTO "route_policy" \(.+\) VALUES .+`
m.mock.ExpectExec(expectedPattern).
WillReturnResult(sqlmock.NewResult(1, 1))
}
func (m *MockSQLRouteStore) ExpectCreateBatch(routes []*alertmanagertypes.RoutePolicy) {
if len(routes) == 0 {
return
}
// Simplified pattern that should match any INSERT into route_policy
expectedPattern := `INSERT INTO "route_policy" \(.+\) VALUES .+`
m.mock.ExpectExec(expectedPattern).
WillReturnResult(sqlmock.NewResult(1, int64(len(routes))))
}
func (m *MockSQLRouteStore) ExpectDelete(orgID, id string) {
m.mock.ExpectExec(`DELETE FROM "route_policy" AS "route_policy" WHERE \(org_id = '` + regexp.QuoteMeta(orgID) + `'\) AND \(id = '` + regexp.QuoteMeta(id) + `'\)`).
WillReturnResult(sqlmock.NewResult(0, 1))
}
func (m *MockSQLRouteStore) ExpectGetAllByKindAndOrgID(orgID string, kind alertmanagertypes.ExpressionKind, routes []*alertmanagertypes.RoutePolicy) {
rows := sqlmock.NewRows([]string{"id", "org_id", "name", "expression", "kind", "description", "enabled", "tags", "channels", "created_at", "updated_at", "created_by", "updated_by"})
for _, route := range routes {
if route.OrgID == orgID && route.ExpressionKind == kind {
rows.AddRow(
route.ID.StringValue(),
route.OrgID,
route.Name,
route.Expression,
route.ExpressionKind.StringValue(),
route.Description,
route.Enabled,
"[]", // tags as JSON
`["`+strings.Join(route.Channels, `","`)+`"]`, // channels as JSON
"0001-01-01T00:00:00Z", // created_at
"0001-01-01T00:00:00Z", // updated_at
"", // created_by
"", // updated_by
)
}
}
m.mock.ExpectQuery(`SELECT (.+) FROM "route_policy" WHERE \(org_id = '` + regexp.QuoteMeta(orgID) + `'\) AND \(kind = '` + regexp.QuoteMeta(kind.StringValue()) + `'\)`).
WillReturnRows(rows)
}
func (m *MockSQLRouteStore) ExpectGetAllByName(orgID, name string, routes []*alertmanagertypes.RoutePolicy) {
rows := sqlmock.NewRows([]string{"id", "org_id", "name", "expression", "kind", "description", "enabled", "tags", "channels", "created_at", "updated_at", "created_by", "updated_by"})
for _, route := range routes {
if route.OrgID == orgID && route.Name == name {
rows.AddRow(
route.ID.StringValue(),
route.OrgID,
route.Name,
route.Expression,
route.ExpressionKind.StringValue(),
route.Description,
route.Enabled,
"[]", // tags as JSON
`["`+strings.Join(route.Channels, `","`)+`"]`, // channels as JSON
"0001-01-01T00:00:00Z", // created_at
"0001-01-01T00:00:00Z", // updated_at
"", // created_by
"", // updated_by
)
}
}
m.mock.ExpectQuery(`SELECT (.+) FROM "route_policy" WHERE \(org_id = '` + regexp.QuoteMeta(orgID) + `'\) AND \(name = '` + regexp.QuoteMeta(name) + `'\)`).
WillReturnRows(rows)
}
func (m *MockSQLRouteStore) ExpectDeleteRouteByName(orgID, name string) {
m.mock.ExpectExec(`DELETE FROM "route_policy" AS "route_policy" WHERE \(org_id = '` + regexp.QuoteMeta(orgID) + `'\) AND \(name = '` + regexp.QuoteMeta(name) + `'\)`).
WillReturnResult(sqlmock.NewResult(0, 1))
}
func (m *MockSQLRouteStore) ExpectationsWereMet() error {
return m.mock.ExpectationsWereMet()
}
func (m *MockSQLRouteStore) MatchExpectationsInOrder(match bool) {
m.mock.MatchExpectationsInOrder(match)
}

View File

@ -0,0 +1,93 @@
package sqlroutingstore
import (
"context"
"database/sql"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
routeTypes "github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
)
type store struct {
sqlstore sqlstore.SQLStore
}
func NewStore(sqlstore sqlstore.SQLStore) routeTypes.RouteStore {
return &store{
sqlstore: sqlstore,
}
}
func (store *store) GetByID(ctx context.Context, orgId string, id string) (*routeTypes.RoutePolicy, error) {
route := new(routeTypes.RoutePolicy)
err := store.sqlstore.BunDBCtx(ctx).NewSelect().Model(route).Where("id = ?", id).Where("org_id = ?", orgId).Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "routing policy with ID: %s does not exist", id)
}
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "unable to fetch routing policy with ID: %s", id)
}
return route, nil
}
func (store *store) Create(ctx context.Context, route *routeTypes.RoutePolicy) error {
_, err := store.sqlstore.BunDBCtx(ctx).NewInsert().Model(route).Exec(ctx)
if err != nil {
return errors.NewInternalf(errors.CodeInternal, "error creating routing policy with ID: %s", route.ID)
}
return nil
}
func (store *store) CreateBatch(ctx context.Context, route []*routeTypes.RoutePolicy) error {
_, err := store.sqlstore.BunDBCtx(ctx).NewInsert().Model(&route).Exec(ctx)
if err != nil {
return errors.NewInternalf(errors.CodeInternal, "error creating routing policies: %v", err)
}
return nil
}
func (store *store) Delete(ctx context.Context, orgId string, id string) error {
_, err := store.sqlstore.BunDBCtx(ctx).NewDelete().Model((*routeTypes.RoutePolicy)(nil)).Where("org_id = ?", orgId).Where("id = ?", id).Exec(ctx)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "unable to delete routing policy with ID: %s", id)
}
return nil
}
func (store *store) GetAllByKind(ctx context.Context, orgID string, kind routeTypes.ExpressionKind) ([]*routeTypes.RoutePolicy, error) {
var routes []*routeTypes.RoutePolicy
err := store.sqlstore.BunDBCtx(ctx).NewSelect().Model(&routes).Where("org_id = ?", orgID).Where("kind = ?", kind).Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, errors.NewNotFoundf(errors.CodeNotFound, "no routing policies found for orgID: %s", orgID)
}
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "unable to fetch routing policies for orgID: %s", orgID)
}
return routes, nil
}
func (store *store) GetAllByName(ctx context.Context, orgID string, name string) ([]*routeTypes.RoutePolicy, error) {
var routes []*routeTypes.RoutePolicy
err := store.sqlstore.BunDBCtx(ctx).NewSelect().Model(&routes).Where("org_id = ?", orgID).Where("name = ?", name).Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return routes, errors.NewNotFoundf(errors.CodeNotFound, "no routing policies found for orgID: %s and name: %s", orgID, name)
}
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "unable to fetch routing policies for orgID: %s and name: %s", orgID, name)
}
return routes, nil
}
func (store *store) DeleteRouteByName(ctx context.Context, orgID string, name string) error {
_, err := store.sqlstore.BunDBCtx(ctx).NewDelete().Model((*routeTypes.RoutePolicy)(nil)).Where("org_id = ?", orgID).Where("name = ?", name).Exec(ctx)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "unable to delete routing policies with name: %s", name)
}
return nil
}

View File

@ -2,12 +2,27 @@
package nfmanager
import (
"context"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/prometheus/common/model"
)
// NotificationManager defines how alerts should be grouped and configured for notification with multi-tenancy support.
// NotificationManager defines how alerts should be grouped and configured for notification.
type NotificationManager interface {
// Notification Config CRUD
GetNotificationConfig(orgID string, ruleID string) (*alertmanagertypes.NotificationConfig, error)
SetNotificationConfig(orgID string, ruleID string, config *alertmanagertypes.NotificationConfig) error
DeleteNotificationConfig(orgID string, ruleID string) error
// Route Policy CRUD
CreateRoutePolicy(ctx context.Context, orgID string, route *alertmanagertypes.RoutePolicy) error
CreateRoutePolicies(ctx context.Context, orgID string, routes []*alertmanagertypes.RoutePolicy) error
GetRoutePolicyByID(ctx context.Context, orgID string, routeID string) (*alertmanagertypes.RoutePolicy, error)
GetAllRoutePolicies(ctx context.Context, orgID string) ([]*alertmanagertypes.RoutePolicy, error)
DeleteRoutePolicy(ctx context.Context, orgID string, routeID string) error
DeleteAllRoutePoliciesByName(ctx context.Context, orgID string, name string) error
// Route matching
Match(ctx context.Context, orgID string, ruleID string, set model.LabelSet) ([]string, error)
}

View File

@ -2,11 +2,14 @@ package rulebasednotification
import (
"context"
"strings"
"sync"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/expr-lang/expr"
"github.com/prometheus/common/model"
"github.com/SigNoz/signoz/pkg/factory"
)
@ -14,26 +17,28 @@ import (
type provider struct {
settings factory.ScopedProviderSettings
orgToFingerprintToNotificationConfig map[string]map[string]alertmanagertypes.NotificationConfig
routeStore alertmanagertypes.RouteStore
mutex sync.RWMutex
}
// NewFactory creates a new factory for the rule-based grouping strategy.
func NewFactory() factory.ProviderFactory[nfmanager.NotificationManager, nfmanager.Config] {
func NewFactory(routeStore alertmanagertypes.RouteStore) factory.ProviderFactory[nfmanager.NotificationManager, nfmanager.Config] {
return factory.NewProviderFactory(
factory.MustNewName("rulebased"),
func(ctx context.Context, settings factory.ProviderSettings, config nfmanager.Config) (nfmanager.NotificationManager, error) {
return New(ctx, settings, config)
return New(ctx, settings, config, routeStore)
},
)
}
// New creates a new rule-based grouping strategy provider.
func New(ctx context.Context, providerSettings factory.ProviderSettings, config nfmanager.Config) (nfmanager.NotificationManager, error) {
func New(ctx context.Context, providerSettings factory.ProviderSettings, config nfmanager.Config, routeStore alertmanagertypes.RouteStore) (nfmanager.NotificationManager, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/rulebasednotification")
return &provider{
settings: settings,
orgToFingerprintToNotificationConfig: make(map[string]map[string]alertmanagertypes.NotificationConfig),
routeStore: routeStore,
}, nil
}
@ -58,6 +63,8 @@ func (r *provider) GetNotificationConfig(orgID string, ruleID string) (*alertman
for k, v := range config.NotificationGroup {
notificationConfig.NotificationGroup[k] = v
}
notificationConfig.UsePolicy = config.UsePolicy
notificationConfig.GroupByAll = config.GroupByAll
}
}
@ -101,3 +108,147 @@ func (r *provider) DeleteNotificationConfig(orgID string, ruleID string) error {
return nil
}
func (r *provider) CreateRoutePolicy(ctx context.Context, orgID string, route *alertmanagertypes.RoutePolicy) error {
if route == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "route policy cannot be nil")
}
err := route.Validate()
if err != nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid route policy: %v", err)
}
return r.routeStore.Create(ctx, route)
}
func (r *provider) CreateRoutePolicies(ctx context.Context, orgID string, routes []*alertmanagertypes.RoutePolicy) error {
if len(routes) == 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "route policies cannot be empty")
}
for _, route := range routes {
if route == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "route policy cannot be nil")
}
if err := route.Validate(); err != nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "route policy with name %s: %s", route.Name, err.Error())
}
}
return r.routeStore.CreateBatch(ctx, routes)
}
func (r *provider) GetRoutePolicyByID(ctx context.Context, orgID string, routeID string) (*alertmanagertypes.RoutePolicy, error) {
if routeID == "" {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "routeID cannot be empty")
}
return r.routeStore.GetByID(ctx, orgID, routeID)
}
func (r *provider) GetAllRoutePolicies(ctx context.Context, orgID string) ([]*alertmanagertypes.RoutePolicy, error) {
if orgID == "" {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "orgID cannot be empty")
}
return r.routeStore.GetAllByKind(ctx, orgID, alertmanagertypes.PolicyBasedExpression)
}
func (r *provider) DeleteRoutePolicy(ctx context.Context, orgID string, routeID string) error {
if routeID == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "routeID cannot be empty")
}
return r.routeStore.Delete(ctx, orgID, routeID)
}
func (r *provider) DeleteAllRoutePoliciesByName(ctx context.Context, orgID string, name string) error {
if orgID == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "orgID cannot be empty")
}
if name == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "name cannot be empty")
}
return r.routeStore.DeleteRouteByName(ctx, orgID, name)
}
func (r *provider) Match(ctx context.Context, orgID string, ruleID string, set model.LabelSet) ([]string, error) {
config, err := r.GetNotificationConfig(orgID, ruleID)
if err != nil {
return nil, errors.NewInternalf(errors.CodeInternal, "error getting notification configuration: %v", err)
}
var expressionRoutes []*alertmanagertypes.RoutePolicy
if config.UsePolicy {
expressionRoutes, err = r.routeStore.GetAllByKind(ctx, orgID, alertmanagertypes.PolicyBasedExpression)
if err != nil {
return []string{}, errors.NewInternalf(errors.CodeInternal, "error getting route policies: %v", err)
}
} else {
expressionRoutes, err = r.routeStore.GetAllByName(ctx, orgID, ruleID)
if err != nil {
return []string{}, errors.NewInternalf(errors.CodeInternal, "error getting route policies: %v", err)
}
}
var matchedChannels []string
if _, ok := set[alertmanagertypes.NoDataLabel]; ok && !config.UsePolicy {
for _, expressionRoute := range expressionRoutes {
matchedChannels = append(matchedChannels, expressionRoute.Channels...)
}
return matchedChannels, nil
}
for _, route := range expressionRoutes {
evaluateExpr, err := r.evaluateExpr(route.Expression, set)
if err != nil {
continue
}
if evaluateExpr {
matchedChannels = append(matchedChannels, route.Channels...)
}
}
return matchedChannels, nil
}
func (r *provider) evaluateExpr(expression string, labelSet model.LabelSet) (bool, error) {
env := make(map[string]interface{})
for k, v := range labelSet {
key := string(k)
value := string(v)
if strings.Contains(key, ".") {
parts := strings.Split(key, ".")
current := env
for i, part := range parts {
if i == len(parts)-1 {
current[part] = value
} else {
if current[part] == nil {
current[part] = make(map[string]interface{})
}
current = current[part].(map[string]interface{})
}
}
} else {
env[key] = value
}
}
program, err := expr.Compile(expression, expr.Env(env))
if err != nil {
return false, errors.NewInternalf(errors.CodeInternal, "error compiling route policy %s: %v", expression, err)
}
output, err := expr.Run(program, env)
if err != nil {
return false, errors.NewInternalf(errors.CodeInternal, "error running route policy %s: %v", expression, err)
}
if boolVal, ok := output.(bool); ok {
return boolVal, nil
}
return false, errors.NewInternalf(errors.CodeInternal, "error in evaluating route policy %s: %v", expression, err)
}

View File

@ -2,18 +2,22 @@ package rulebasednotification
import (
"context"
"github.com/prometheus/common/model"
"sync"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfroutingstore/nfroutingstoretest"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/prometheus/alertmanager/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/prometheus/common/model"
)
func createTestProviderSettings() factory.ProviderSettings {
@ -21,7 +25,8 @@ func createTestProviderSettings() factory.ProviderSettings {
}
func TestNewFactory(t *testing.T) {
providerFactory := NewFactory()
routeStore := nfroutingstoretest.NewMockSQLRouteStore()
providerFactory := NewFactory(routeStore)
assert.NotNil(t, providerFactory)
assert.Equal(t, "rulebased", providerFactory.Name().String())
}
@ -31,7 +36,8 @@ func TestNew(t *testing.T) {
providerSettings := createTestProviderSettings()
config := nfmanager.Config{}
provider, err := New(ctx, providerSettings, config)
routeStore := nfroutingstoretest.NewMockSQLRouteStore()
provider, err := New(ctx, providerSettings, config, routeStore)
require.NoError(t, err)
assert.NotNil(t, provider)
@ -44,7 +50,8 @@ func TestProvider_SetNotificationConfig(t *testing.T) {
providerSettings := createTestProviderSettings()
config := nfmanager.Config{}
provider, err := New(ctx, providerSettings, config)
routeStore := nfroutingstoretest.NewMockSQLRouteStore()
provider, err := New(ctx, providerSettings, config, routeStore)
require.NoError(t, err)
tests := []struct {
@ -124,11 +131,12 @@ func TestProvider_GetNotificationConfig(t *testing.T) {
providerSettings := createTestProviderSettings()
config := nfmanager.Config{}
provider, err := New(ctx, providerSettings, config)
routeStore := nfroutingstoretest.NewMockSQLRouteStore()
provider, err := New(ctx, providerSettings, config, routeStore)
require.NoError(t, err)
orgID := "test-org"
ruleID := "rule1"
ruleID := "ruleId"
customConfig := &alertmanagertypes.NotificationConfig{
Renotify: alertmanagertypes.ReNotificationConfig{
RenotifyInterval: 30 * time.Minute,
@ -144,7 +152,6 @@ func TestProvider_GetNotificationConfig(t *testing.T) {
},
}
// Set config for alert1
err = provider.SetNotificationConfig(orgID, ruleID, customConfig)
require.NoError(t, err)
@ -155,7 +162,7 @@ func TestProvider_GetNotificationConfig(t *testing.T) {
name string
orgID string
ruleID string
alert *types.Alert
alert *alertmanagertypes.Alert
expectedConfig *alertmanagertypes.NotificationConfig
shouldFallback bool
}{
@ -165,7 +172,7 @@ func TestProvider_GetNotificationConfig(t *testing.T) {
ruleID: ruleID,
expectedConfig: &alertmanagertypes.NotificationConfig{
NotificationGroup: map[model.LabelName]struct{}{
model.LabelName("ruleId"): {},
model.LabelName(ruleID): {},
},
Renotify: alertmanagertypes.ReNotificationConfig{
RenotifyInterval: 30 * time.Minute,
@ -182,13 +189,13 @@ func TestProvider_GetNotificationConfig(t *testing.T) {
NotificationGroup: map[model.LabelName]struct{}{
model.LabelName("group1"): {},
model.LabelName("group2"): {},
model.LabelName("ruleId"): {},
model.LabelName(ruleID): {},
},
Renotify: alertmanagertypes.ReNotificationConfig{
RenotifyInterval: 4 * time.Hour,
NoDataInterval: 4 * time.Hour,
},
}, // Will get fallback from standardnotification
},
shouldFallback: false,
},
{
@ -231,7 +238,8 @@ func TestProvider_ConcurrentAccess(t *testing.T) {
providerSettings := createTestProviderSettings()
config := nfmanager.Config{}
provider, err := New(ctx, providerSettings, config)
routeStore := nfroutingstoretest.NewMockSQLRouteStore()
provider, err := New(ctx, providerSettings, config, routeStore)
require.NoError(t, err)
orgID := "test-org"
@ -268,3 +276,634 @@ func TestProvider_ConcurrentAccess(t *testing.T) {
// Wait for both goroutines to complete
wg.Wait()
}
func TestProvider_EvaluateExpression(t *testing.T) {
provider := &provider{}
tests := []struct {
name string
expression string
labelSet model.LabelSet
expected bool
}{
{
name: "simple equality check - match",
expression: `threshold.name == 'auth' && ruleId == 'rule1'`,
labelSet: model.LabelSet{
"threshold.name": "auth",
"ruleId": "rule1",
},
expected: true,
},
{
name: "simple equality check - match",
expression: `threshold.name = 'auth' AND ruleId = 'rule1'`,
labelSet: model.LabelSet{
"threshold.name": "auth",
"ruleId": "rule1",
},
expected: true,
},
{
name: "simple equality check - no match",
expression: `service == "payment"`,
labelSet: model.LabelSet{
"service": "auth",
"env": "production",
},
expected: false,
},
{
name: "simple equality check - no match",
expression: `service = "payment"`,
labelSet: model.LabelSet{
"service": "auth",
"env": "production",
},
expected: false,
},
{
name: "multiple conditions with AND - both match",
expression: `service == "auth" && env == "production"`,
labelSet: model.LabelSet{
"service": "auth",
"env": "production",
},
expected: true,
},
{
name: "multiple conditions with AND - both match",
expression: `service = "auth" AND env = "production"`,
labelSet: model.LabelSet{
"service": "auth",
"env": "production",
},
expected: true,
},
{
name: "multiple conditions with AND - one doesn't match",
expression: `service == "auth" && env == "staging"`,
labelSet: model.LabelSet{
"service": "auth",
"env": "production",
},
expected: false,
},
{
name: "multiple conditions with AND - one doesn't match",
expression: `service = "auth" AND env = "staging"`,
labelSet: model.LabelSet{
"service": "auth",
"env": "production",
},
expected: false,
},
{
name: "multiple conditions with OR - one matches",
expression: `service == "payment" || env == "production"`,
labelSet: model.LabelSet{
"service": "auth",
"env": "production",
},
expected: true,
},
{
name: "multiple conditions with OR - one matches",
expression: `service = "payment" OR env = "production"`,
labelSet: model.LabelSet{
"service": "auth",
"env": "production",
},
expected: true,
},
{
name: "multiple conditions with OR - none match",
expression: `service == "payment" || env == "staging"`,
labelSet: model.LabelSet{
"service": "auth",
"env": "production",
},
expected: false,
},
{
name: "multiple conditions with OR - none match",
expression: `service = "payment" OR env = "staging"`,
labelSet: model.LabelSet{
"service": "auth",
"env": "production",
},
expected: false,
},
{
name: "in operator - value in list",
expression: `service in ["auth", "payment", "notification"]`,
labelSet: model.LabelSet{
"service": "auth",
},
expected: true,
},
{
name: "in operator - value in list",
expression: `service IN ["auth", "payment", "notification"]`,
labelSet: model.LabelSet{
"service": "auth",
},
expected: true,
},
{
name: "in operator - value not in list",
expression: `service in ["payment", "notification"]`,
labelSet: model.LabelSet{
"service": "auth",
},
expected: false,
},
{
name: "in operator - value not in list",
expression: `service IN ["payment", "notification"]`,
labelSet: model.LabelSet{
"service": "auth",
},
expected: false,
},
{
name: "contains operator - substring match",
expression: `host contains "prod"`,
labelSet: model.LabelSet{
"host": "prod-server-01",
},
expected: true,
},
{
name: "contains operator - substring match",
expression: `host CONTAINS "prod"`,
labelSet: model.LabelSet{
"host": "prod-server-01",
},
expected: true,
},
{
name: "contains operator - no substring match",
expression: `host contains "staging"`,
labelSet: model.LabelSet{
"host": "prod-server-01",
},
expected: false,
},
{
name: "contains operator - no substring match",
expression: `host CONTAINS "staging"`,
labelSet: model.LabelSet{
"host": "prod-server-01",
},
expected: false,
},
{
name: "complex expression with parentheses",
expression: `(service == "auth" && env == "production") || critical == "true"`,
labelSet: model.LabelSet{
"service": "payment",
"env": "staging",
"critical": "true",
},
expected: true,
},
{
name: "complex expression with parentheses",
expression: `(service = "auth" AND env = "production") OR critical = "true"`,
labelSet: model.LabelSet{
"service": "payment",
"env": "staging",
"critical": "true",
},
expected: true,
},
{
name: "missing label key",
expression: `"missing_key" == "value"`,
labelSet: model.LabelSet{
"service": "auth",
},
expected: false,
},
{
name: "missing label key",
expression: `"missing_key" = "value"`,
labelSet: model.LabelSet{
"service": "auth",
},
expected: false,
},
{
name: "rule-based expression with threshold name and ruleId",
expression: `'threshold.name' == "high-cpu" && ruleId == "rule-123"`,
labelSet: model.LabelSet{
"threshold.name": "high-cpu",
"ruleId": "rule-123",
"service": "auth",
},
expected: false, //no commas
},
{
name: "rule-based expression with threshold name and ruleId",
expression: `'threshold.name' = "high-cpu" AND ruleId == "rule-123"`,
labelSet: model.LabelSet{
"threshold.name": "high-cpu",
"ruleId": "rule-123",
"service": "auth",
},
expected: false, //no commas
},
{
name: "alertname and ruleId combination",
expression: `alertname == "HighCPUUsage" && ruleId == "cpu-alert-001"`,
labelSet: model.LabelSet{
"alertname": "HighCPUUsage",
"ruleId": "cpu-alert-001",
"severity": "critical",
},
expected: true,
},
{
name: "alertname and ruleId combination",
expression: `alertname = "HighCPUUsage" AND ruleId = "cpu-alert-001"`,
labelSet: model.LabelSet{
"alertname": "HighCPUUsage",
"ruleId": "cpu-alert-001",
"severity": "critical",
},
expected: true,
},
{
name: "kubernetes namespace filtering",
expression: `k8s.namespace.name == "auth" && service in ["auth", "payment"]`,
labelSet: model.LabelSet{
"k8s.namespace.name": "auth",
"service": "auth",
"host": "k8s-node-1",
},
expected: true,
},
{
name: "kubernetes namespace filtering",
expression: `k8s.namespace.name = "auth" && service IN ["auth", "payment"]`,
labelSet: model.LabelSet{
"k8s.namespace.name": "auth",
"service": "auth",
"host": "k8s-node-1",
},
expected: true,
},
{
name: "migration expression format from SQL migration",
expression: `threshold.name == "HighCPUUsage" && ruleId == "rule-uuid-123"`,
labelSet: model.LabelSet{
"threshold.name": "HighCPUUsage",
"ruleId": "rule-uuid-123",
"severity": "warning",
},
expected: true,
},
{
name: "migration expression format from SQL migration",
expression: `threshold.name = "HighCPUUsage" && ruleId = "rule-uuid-123"`,
labelSet: model.LabelSet{
"threshold.name": "HighCPUUsage",
"ruleId": "rule-uuid-123",
"severity": "warning",
},
expected: true,
},
{
name: "case sensitive matching",
expression: `service == "Auth"`, // capital A
labelSet: model.LabelSet{
"service": "auth", // lowercase a
},
expected: false,
},
{
name: "case sensitive matching",
expression: `service = "Auth"`, // capital A
labelSet: model.LabelSet{
"service": "auth", // lowercase a
},
expected: false,
},
{
name: "numeric comparison as strings",
expression: `port == "8080"`,
labelSet: model.LabelSet{
"port": "8080",
},
expected: true,
},
{
name: "numeric comparison as strings",
expression: `port = "8080"`,
labelSet: model.LabelSet{
"port": "8080",
},
expected: true,
},
{
name: "quoted string with special characters",
expression: `service == "auth-service-v2"`,
labelSet: model.LabelSet{
"service": "auth-service-v2",
},
expected: true,
},
{
name: "quoted string with special characters",
expression: `service = "auth-service-v2"`,
labelSet: model.LabelSet{
"service": "auth-service-v2",
},
expected: true,
},
{
name: "boolean operators precedence",
expression: `service == "auth" && env == "prod" || critical == "true"`,
labelSet: model.LabelSet{
"service": "payment",
"env": "staging",
"critical": "true",
},
expected: true,
},
{
name: "boolean operators precedence",
expression: `service = "auth" AND env = "prod" OR critical = "true"`,
labelSet: model.LabelSet{
"service": "payment",
"env": "staging",
"critical": "true",
},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := provider.evaluateExpr(tt.expression, tt.labelSet)
assert.NoError(t, err)
assert.Equal(t, tt.expected, result, "Expression: %s", tt.expression)
})
}
}
func TestProvider_DeleteRoute(t *testing.T) {
ctx := context.Background()
providerSettings := createTestProviderSettings()
config := nfmanager.Config{}
tests := []struct {
name string
orgID string
routeID string
wantErr bool
}{
{
name: "valid parameters",
orgID: "test-org-123",
routeID: "route-uuid-456",
wantErr: false,
},
{
name: "empty routeID",
orgID: "test-org-123",
routeID: "",
wantErr: true,
},
{
name: "valid orgID with valid routeID",
orgID: "another-org",
routeID: "another-route-id",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
routeStore := nfroutingstoretest.NewMockSQLRouteStore()
provider, err := New(ctx, providerSettings, config, routeStore)
require.NoError(t, err)
if !tt.wantErr {
routeStore.ExpectDelete(tt.orgID, tt.routeID)
}
err = provider.DeleteRoutePolicy(ctx, tt.orgID, tt.routeID)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.NoError(t, routeStore.ExpectationsWereMet())
}
})
}
}
func TestProvider_CreateRoute(t *testing.T) {
ctx := context.Background()
providerSettings := createTestProviderSettings()
config := nfmanager.Config{}
tests := []struct {
name string
orgID string
route *alertmanagertypes.RoutePolicy
wantErr bool
}{
{
name: "valid route",
orgID: "test-org-123",
route: &alertmanagertypes.RoutePolicy{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
Expression: `service == "auth"`,
ExpressionKind: alertmanagertypes.PolicyBasedExpression,
Name: "auth-service-route",
Description: "Route for auth service alerts",
Enabled: true,
OrgID: "test-org-123",
Channels: []string{"slack-channel"},
},
wantErr: false,
},
{
name: "valid route qb format",
orgID: "test-org-123",
route: &alertmanagertypes.RoutePolicy{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
Expression: `service = "auth"`,
ExpressionKind: alertmanagertypes.PolicyBasedExpression,
Name: "auth-service-route",
Description: "Route for auth service alerts",
Enabled: true,
OrgID: "test-org-123",
Channels: []string{"slack-channel"},
},
wantErr: false,
},
{
name: "nil route",
orgID: "test-org-123",
route: nil,
wantErr: true,
},
{
name: "invalid route - missing expression",
orgID: "test-org-123",
route: &alertmanagertypes.RoutePolicy{
Expression: "", // empty expression
ExpressionKind: alertmanagertypes.PolicyBasedExpression,
Name: "invalid-route",
OrgID: "test-org-123",
},
wantErr: true,
},
{
name: "invalid route - missing name",
orgID: "test-org-123",
route: &alertmanagertypes.RoutePolicy{
Expression: `service == "auth"`,
ExpressionKind: alertmanagertypes.PolicyBasedExpression,
Name: "", // empty name
OrgID: "test-org-123",
},
wantErr: true,
},
{
name: "invalid route - missing name",
orgID: "test-org-123",
route: &alertmanagertypes.RoutePolicy{
Expression: `service = "auth"`,
ExpressionKind: alertmanagertypes.PolicyBasedExpression,
Name: "", // empty name
OrgID: "test-org-123",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
routeStore := nfroutingstoretest.NewMockSQLRouteStore()
provider, err := New(ctx, providerSettings, config, routeStore)
require.NoError(t, err)
if !tt.wantErr && tt.route != nil {
routeStore.ExpectCreate(tt.route)
}
err = provider.CreateRoutePolicy(ctx, tt.orgID, tt.route)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.NoError(t, routeStore.ExpectationsWereMet())
}
})
}
}
func TestProvider_CreateRoutes(t *testing.T) {
ctx := context.Background()
providerSettings := createTestProviderSettings()
config := nfmanager.Config{}
routeStore := nfroutingstoretest.NewMockSQLRouteStore()
provider, err := New(ctx, providerSettings, config, routeStore)
require.NoError(t, err)
validRoute1 := &alertmanagertypes.RoutePolicy{
Expression: `service == "auth"`,
ExpressionKind: alertmanagertypes.PolicyBasedExpression,
Name: "auth-route",
Description: "Auth service route",
Enabled: true,
OrgID: "test-org",
Channels: []string{"slack-auth"},
}
validRoute2 := &alertmanagertypes.RoutePolicy{
Expression: `service == "payment"`,
ExpressionKind: alertmanagertypes.PolicyBasedExpression,
Name: "payment-route",
Description: "Payment service route",
Enabled: true,
OrgID: "test-org",
Channels: []string{"slack-payment"},
}
invalidRoute := &alertmanagertypes.RoutePolicy{
Expression: "", // empty expression - invalid
ExpressionKind: alertmanagertypes.PolicyBasedExpression,
Name: "invalid-route",
OrgID: "test-org",
}
tests := []struct {
name string
orgID string
routes []*alertmanagertypes.RoutePolicy
wantErr bool
}{
{
name: "valid routes",
orgID: "test-org",
routes: []*alertmanagertypes.RoutePolicy{validRoute1, validRoute2},
wantErr: false,
},
{
name: "empty routes list",
orgID: "test-org",
routes: []*alertmanagertypes.RoutePolicy{},
wantErr: true,
},
{
name: "nil routes list",
orgID: "test-org",
routes: nil,
wantErr: true,
},
{
name: "routes with nil route",
orgID: "test-org",
routes: []*alertmanagertypes.RoutePolicy{validRoute1, nil},
wantErr: true,
},
{
name: "routes with invalid route",
orgID: "test-org",
routes: []*alertmanagertypes.RoutePolicy{validRoute1, invalidRoute},
wantErr: true,
},
{
name: "single valid route",
orgID: "test-org",
routes: []*alertmanagertypes.RoutePolicy{validRoute1},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if !tt.wantErr && len(tt.routes) > 0 {
routeStore.ExpectCreateBatch(tt.routes)
}
err := provider.CreateRoutePolicies(ctx, tt.orgID, tt.routes)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.NoError(t, routeStore.ExpectationsWereMet())
}
})
}
}

View File

@ -4,6 +4,9 @@ import (
"context"
"sync"
"github.com/prometheus/alertmanager/featurecontrol"
"github.com/prometheus/alertmanager/matcher/compat"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/errors"
@ -61,6 +64,7 @@ func New(
}
func (service *Service) SyncServers(ctx context.Context) error {
compat.InitFromFlags(service.settings.Logger(), featurecontrol.NoopFlags{})
orgs, err := service.orgGetter.ListByOwnedKeyRange(ctx)
if err != nil {
return err
@ -142,7 +146,7 @@ func (service *Service) TestReceiver(ctx context.Context, orgID string, receiver
return server.TestReceiver(ctx, receiver)
}
func (service *Service) TestAlert(ctx context.Context, orgID string, alert *alertmanagertypes.PostableAlert, receivers []string) error {
func (service *Service) TestAlert(ctx context.Context, orgID string, receiversMap map[*alertmanagertypes.PostableAlert][]string, config *alertmanagertypes.NotificationConfig) error {
service.serversMtx.RLock()
defer service.serversMtx.RUnlock()
@ -151,7 +155,7 @@ func (service *Service) TestAlert(ctx context.Context, orgID string, alert *aler
return err
}
return server.TestAlert(ctx, alert, receivers)
return server.TestAlert(ctx, receiversMap, config)
}
func (service *Service) Stop(ctx context.Context) error {

View File

@ -2,8 +2,12 @@ package signozalertmanager
import (
"context"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
"github.com/prometheus/common/model"
"time"
amConfig "github.com/prometheus/alertmanager/config"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
@ -11,7 +15,9 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@ -94,8 +100,29 @@ func (provider *provider) TestReceiver(ctx context.Context, orgID string, receiv
return provider.service.TestReceiver(ctx, orgID, receiver)
}
func (provider *provider) TestAlert(ctx context.Context, orgID string, alert *alertmanagertypes.PostableAlert, receivers []string) error {
return provider.service.TestAlert(ctx, orgID, alert, receivers)
func (provider *provider) TestAlert(ctx context.Context, orgID string, ruleID string, receiversMap map[*alertmanagertypes.PostableAlert][]string) error {
config, err := provider.notificationManager.GetNotificationConfig(orgID, ruleID)
if err != nil {
return err
}
if config.UsePolicy {
for alert := range receiversMap {
set := make(model.LabelSet)
for k, v := range alert.Labels {
set[model.LabelName(k)] = model.LabelValue(v)
}
match, err := provider.notificationManager.Match(ctx, orgID, alert.Labels[labels.AlertRuleIdLabel], set)
if err != nil {
return err
}
if len(match) == 0 {
delete(receiversMap, alert)
} else {
receiversMap[alert] = match
}
}
}
return provider.service.TestAlert(ctx, orgID, receiversMap, config)
}
func (provider *provider) ListChannels(ctx context.Context, orgID string) ([]*alertmanagertypes.Channel, error) {
@ -211,3 +238,316 @@ func (provider *provider) DeleteNotificationConfig(ctx context.Context, orgID va
}
return nil
}
func (provider *provider) CreateRoutePolicy(ctx context.Context, routeRequest *alertmanagertypes.PostableRoutePolicy) (*alertmanagertypes.GettableRoutePolicy, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return nil, err
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
return nil, err
}
if err := routeRequest.Validate(); err != nil {
return nil, err
}
route := alertmanagertypes.RoutePolicy{
Expression: routeRequest.Expression,
ExpressionKind: routeRequest.ExpressionKind,
Name: routeRequest.Name,
Description: routeRequest.Description,
Enabled: true,
Tags: routeRequest.Tags,
Channels: routeRequest.Channels,
OrgID: claims.OrgID,
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
UserAuditable: types.UserAuditable{
CreatedBy: claims.Email,
UpdatedBy: claims.Email,
},
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
}
err = provider.notificationManager.CreateRoutePolicy(ctx, orgID.String(), &route)
if err != nil {
return nil, err
}
return &alertmanagertypes.GettableRoutePolicy{
PostableRoutePolicy: *routeRequest,
ID: route.ID.StringValue(),
CreatedAt: &route.CreatedAt,
UpdatedAt: &route.UpdatedAt,
CreatedBy: &route.CreatedBy,
UpdatedBy: &route.UpdatedBy,
}, nil
}
func (provider *provider) CreateRoutePolicies(ctx context.Context, routeRequests []*alertmanagertypes.PostableRoutePolicy) ([]*alertmanagertypes.GettableRoutePolicy, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return nil, err
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
return nil, err
}
if len(routeRequests) == 0 {
return []*alertmanagertypes.GettableRoutePolicy{}, nil
}
routes := make([]*alertmanagertypes.RoutePolicy, 0, len(routeRequests))
results := make([]*alertmanagertypes.GettableRoutePolicy, 0, len(routeRequests))
for _, routeRequest := range routeRequests {
if err := routeRequest.Validate(); err != nil {
return nil, err
}
route := &alertmanagertypes.RoutePolicy{
Expression: routeRequest.Expression,
ExpressionKind: routeRequest.ExpressionKind,
Name: routeRequest.Name,
Description: routeRequest.Description,
Enabled: true,
Tags: routeRequest.Tags,
Channels: routeRequest.Channels,
OrgID: claims.OrgID,
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
UserAuditable: types.UserAuditable{
CreatedBy: claims.Email,
UpdatedBy: claims.Email,
},
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
}
routes = append(routes, route)
results = append(results, &alertmanagertypes.GettableRoutePolicy{
PostableRoutePolicy: *routeRequest,
ID: route.ID.StringValue(),
CreatedAt: &route.CreatedAt,
UpdatedAt: &route.UpdatedAt,
CreatedBy: &route.CreatedBy,
UpdatedBy: &route.UpdatedBy,
})
}
err = provider.notificationManager.CreateRoutePolicies(ctx, orgID.String(), routes)
if err != nil {
return nil, err
}
return results, nil
}
func (provider *provider) GetRoutePolicyByID(ctx context.Context, routeID string) (*alertmanagertypes.GettableRoutePolicy, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return nil, err
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
return nil, err
}
route, err := provider.notificationManager.GetRoutePolicyByID(ctx, orgID.String(), routeID)
if err != nil {
return nil, err
}
return &alertmanagertypes.GettableRoutePolicy{
PostableRoutePolicy: alertmanagertypes.PostableRoutePolicy{
Expression: route.Expression,
ExpressionKind: route.ExpressionKind,
Channels: route.Channels,
Name: route.Name,
Description: route.Description,
Tags: route.Tags,
},
ID: route.ID.StringValue(),
CreatedAt: &route.CreatedAt,
UpdatedAt: &route.UpdatedAt,
CreatedBy: &route.CreatedBy,
UpdatedBy: &route.UpdatedBy,
}, nil
}
func (provider *provider) GetAllRoutePolicies(ctx context.Context) ([]*alertmanagertypes.GettableRoutePolicy, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return nil, err
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
return nil, err
}
routes, err := provider.notificationManager.GetAllRoutePolicies(ctx, orgID.String())
if err != nil {
return nil, err
}
results := make([]*alertmanagertypes.GettableRoutePolicy, 0, len(routes))
for _, route := range routes {
results = append(results, &alertmanagertypes.GettableRoutePolicy{
PostableRoutePolicy: alertmanagertypes.PostableRoutePolicy{
Expression: route.Expression,
ExpressionKind: route.ExpressionKind,
Channels: route.Channels,
Name: route.Name,
Description: route.Description,
Tags: route.Tags,
},
ID: route.ID.StringValue(),
CreatedAt: &route.CreatedAt,
UpdatedAt: &route.UpdatedAt,
CreatedBy: &route.CreatedBy,
UpdatedBy: &route.UpdatedBy,
})
}
return results, nil
}
func (provider *provider) UpdateRoutePolicyByID(ctx context.Context, routeID string, route *alertmanagertypes.PostableRoutePolicy) (*alertmanagertypes.GettableRoutePolicy, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return nil, errors.NewInvalidInputf(errors.CodeUnauthenticated, "invalid claims: %v", err)
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
return nil, err
}
if routeID == "" {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "routeID cannot be empty")
}
if route == nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "route cannot be nil")
}
if err := route.Validate(); err != nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid route: %v", err)
}
existingRoute, err := provider.notificationManager.GetRoutePolicyByID(ctx, claims.OrgID, routeID)
if err != nil {
return nil, errors.NewInvalidInputf(errors.CodeNotFound, "route not found: %v", err)
}
updatedRoute := &alertmanagertypes.RoutePolicy{
Expression: route.Expression,
ExpressionKind: route.ExpressionKind,
Name: route.Name,
Description: route.Description,
Tags: route.Tags,
Channels: route.Channels,
OrgID: claims.OrgID,
Identifiable: existingRoute.Identifiable,
UserAuditable: types.UserAuditable{
CreatedBy: existingRoute.CreatedBy,
UpdatedBy: claims.Email,
},
TimeAuditable: types.TimeAuditable{
CreatedAt: existingRoute.CreatedAt,
UpdatedAt: time.Now(),
},
}
err = provider.notificationManager.DeleteRoutePolicy(ctx, orgID.String(), routeID)
if err != nil {
return nil, errors.NewInvalidInputf(errors.CodeInternal, "error deleting existing route: %v", err)
}
err = provider.notificationManager.CreateRoutePolicy(ctx, orgID.String(), updatedRoute)
if err != nil {
return nil, err
}
return &alertmanagertypes.GettableRoutePolicy{
PostableRoutePolicy: *route,
ID: updatedRoute.ID.StringValue(),
CreatedAt: &updatedRoute.CreatedAt,
UpdatedAt: &updatedRoute.UpdatedAt,
CreatedBy: &updatedRoute.CreatedBy,
UpdatedBy: &updatedRoute.UpdatedBy,
}, nil
}
func (provider *provider) DeleteRoutePolicyByID(ctx context.Context, routeID string) error {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return errors.NewInvalidInputf(errors.CodeUnauthenticated, "invalid claims: %v", err)
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
return err
}
if routeID == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "routeID cannot be empty")
}
return provider.notificationManager.DeleteRoutePolicy(ctx, orgID.String(), routeID)
}
func (provider *provider) CreateInhibitRules(ctx context.Context, orgID valuer.UUID, rules []amConfig.InhibitRule) error {
config, err := provider.configStore.Get(ctx, orgID.String())
if err != nil {
return err
}
if err := config.AddInhibitRules(rules); err != nil {
return err
}
return provider.configStore.Set(ctx, config)
}
func (provider *provider) DeleteAllRoutePoliciesByRuleId(ctx context.Context, names string) error {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return errors.NewInvalidInputf(errors.CodeUnauthenticated, "invalid claims: %v", err)
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
return err
}
return provider.notificationManager.DeleteAllRoutePoliciesByName(ctx, orgID.String(), names)
}
func (provider *provider) UpdateAllRoutePoliciesByRuleId(ctx context.Context, names string, routes []*alertmanagertypes.PostableRoutePolicy) error {
err := provider.DeleteAllRoutePoliciesByRuleId(ctx, names)
if err != nil {
return errors.NewInvalidInputf(errors.CodeInternal, "error deleting the routes: %v", err)
}
_, err = provider.CreateRoutePolicies(ctx, routes)
return err
}
func (provider *provider) DeleteAllInhibitRulesByRuleId(ctx context.Context, orgID valuer.UUID, ruleId string) error {
config, err := provider.configStore.Get(ctx, orgID.String())
if err != nil {
return err
}
if err := config.DeleteRuleIDInhibitor(ruleId); err != nil {
return err
}
return provider.configStore.Set(ctx, config)
}

View File

@ -10,7 +10,6 @@ import (
"fmt"
"github.com/SigNoz/signoz/pkg/modules/thirdpartyapi"
//qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"io"
"math"
"net/http"
@ -492,6 +491,12 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/channels", am.EditAccess(aH.AlertmanagerAPI.CreateChannel)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/testChannel", am.EditAccess(aH.AlertmanagerAPI.TestReceiver)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/route_policies", am.ViewAccess(aH.AlertmanagerAPI.GetAllRoutePolicies)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/route_policies/{id}", am.ViewAccess(aH.AlertmanagerAPI.GetRoutePolicyByID)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/route_policies", am.AdminAccess(aH.AlertmanagerAPI.CreateRoutePolicy)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/route_policies/{id}", am.AdminAccess(aH.AlertmanagerAPI.DeleteRoutePolicyByID)).Methods(http.MethodDelete)
router.HandleFunc("/api/v1/route_policies/{id}", am.AdminAccess(aH.AlertmanagerAPI.UpdateRoutePolicy)).Methods(http.MethodPut)
router.HandleFunc("/api/v1/alerts", am.ViewAccess(aH.AlertmanagerAPI.GetAlerts)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules", am.ViewAccess(aH.listRules)).Methods(http.MethodGet)
@ -616,6 +621,7 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
// Export
router.HandleFunc("/api/v1/export_raw_data", am.ViewAccess(aH.Signoz.Handlers.RawDataExport.ExportRawData)).Methods(http.MethodGet)
}
func (ah *APIHandler) MetricExplorerRoutes(router *mux.Router, am *middleware.AuthZ) {

View File

@ -4,13 +4,11 @@ import (
"context"
"fmt"
"log/slog"
"math"
"net/url"
"sync"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/query-service/converter"
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
@ -167,22 +165,6 @@ func NewBaseRule(id string, orgID valuer.UUID, p *ruletypes.PostableRule, reader
return baseRule, nil
}
func (r *BaseRule) targetVal() float64 {
if r.ruleCondition == nil || r.ruleCondition.Target == nil {
return 0
}
// get the converter for the target unit
unitConverter := converter.FromUnit(converter.Unit(r.ruleCondition.TargetUnit))
// convert the target value to the y-axis unit
value := unitConverter.Convert(converter.Value{
F: *r.ruleCondition.Target,
U: converter.Unit(r.ruleCondition.TargetUnit),
}, converter.Unit(r.Unit()))
return value.F
}
func (r *BaseRule) matchType() ruletypes.MatchType {
if r.ruleCondition == nil {
return ruletypes.AtleastOnce
@ -221,10 +203,6 @@ func (r *BaseRule) HoldDuration() time.Duration {
return r.holdDuration
}
func (r *BaseRule) TargetVal() float64 {
return r.targetVal()
}
func (r *ThresholdRule) hostFromSource() string {
parsedUrl, err := url.Parse(r.source)
if err != nil {
@ -380,232 +358,6 @@ func (r *BaseRule) ForEachActiveAlert(f func(*ruletypes.Alert)) {
}
}
func (r *BaseRule) ShouldAlert(series v3.Series) (ruletypes.Sample, bool) {
var alertSmpl ruletypes.Sample
var shouldAlert bool
var lbls qslabels.Labels
for name, value := range series.Labels {
lbls = append(lbls, qslabels.Label{Name: name, Value: value})
}
series.Points = removeGroupinSetPoints(series)
// nothing to evaluate
if len(series.Points) == 0 {
return alertSmpl, false
}
if r.ruleCondition.RequireMinPoints {
if len(series.Points) < r.ruleCondition.RequiredNumPoints {
zap.L().Info("not enough data points to evaluate series, skipping", zap.String("ruleid", r.ID()), zap.Int("numPoints", len(series.Points)), zap.Int("requiredPoints", r.ruleCondition.RequiredNumPoints))
return alertSmpl, false
}
}
switch r.matchType() {
case ruletypes.AtleastOnce:
// If any sample matches the condition, the rule is firing.
if r.compareOp() == ruletypes.ValueIsAbove {
for _, smpl := range series.Points {
if smpl.Value > r.targetVal() {
alertSmpl = ruletypes.Sample{Point: ruletypes.Point{V: smpl.Value}, Metric: lbls}
shouldAlert = true
break
}
}
} else if r.compareOp() == ruletypes.ValueIsBelow {
for _, smpl := range series.Points {
if smpl.Value < r.targetVal() {
alertSmpl = ruletypes.Sample{Point: ruletypes.Point{V: smpl.Value}, Metric: lbls}
shouldAlert = true
break
}
}
} else if r.compareOp() == ruletypes.ValueIsEq {
for _, smpl := range series.Points {
if smpl.Value == r.targetVal() {
alertSmpl = ruletypes.Sample{Point: ruletypes.Point{V: smpl.Value}, Metric: lbls}
shouldAlert = true
break
}
}
} else if r.compareOp() == ruletypes.ValueIsNotEq {
for _, smpl := range series.Points {
if smpl.Value != r.targetVal() {
alertSmpl = ruletypes.Sample{Point: ruletypes.Point{V: smpl.Value}, Metric: lbls}
shouldAlert = true
break
}
}
} else if r.compareOp() == ruletypes.ValueOutsideBounds {
for _, smpl := range series.Points {
if math.Abs(smpl.Value) >= r.targetVal() {
alertSmpl = ruletypes.Sample{Point: ruletypes.Point{V: smpl.Value}, Metric: lbls}
shouldAlert = true
break
}
}
}
case ruletypes.AllTheTimes:
// If all samples match the condition, the rule is firing.
shouldAlert = true
alertSmpl = ruletypes.Sample{Point: ruletypes.Point{V: r.targetVal()}, Metric: lbls}
if r.compareOp() == ruletypes.ValueIsAbove {
for _, smpl := range series.Points {
if smpl.Value <= r.targetVal() {
shouldAlert = false
break
}
}
// use min value from the series
if shouldAlert {
var minValue float64 = math.Inf(1)
for _, smpl := range series.Points {
if smpl.Value < minValue {
minValue = smpl.Value
}
}
alertSmpl = ruletypes.Sample{Point: ruletypes.Point{V: minValue}, Metric: lbls}
}
} else if r.compareOp() == ruletypes.ValueIsBelow {
for _, smpl := range series.Points {
if smpl.Value >= r.targetVal() {
shouldAlert = false
break
}
}
if shouldAlert {
var maxValue float64 = math.Inf(-1)
for _, smpl := range series.Points {
if smpl.Value > maxValue {
maxValue = smpl.Value
}
}
alertSmpl = ruletypes.Sample{Point: ruletypes.Point{V: maxValue}, Metric: lbls}
}
} else if r.compareOp() == ruletypes.ValueIsEq {
for _, smpl := range series.Points {
if smpl.Value != r.targetVal() {
shouldAlert = false
break
}
}
} else if r.compareOp() == ruletypes.ValueIsNotEq {
for _, smpl := range series.Points {
if smpl.Value == r.targetVal() {
shouldAlert = false
break
}
}
// use any non-inf or nan value from the series
if shouldAlert {
for _, smpl := range series.Points {
if !math.IsInf(smpl.Value, 0) && !math.IsNaN(smpl.Value) {
alertSmpl = ruletypes.Sample{Point: ruletypes.Point{V: smpl.Value}, Metric: lbls}
break
}
}
}
} else if r.compareOp() == ruletypes.ValueOutsideBounds {
for _, smpl := range series.Points {
if math.Abs(smpl.Value) < r.targetVal() {
alertSmpl = ruletypes.Sample{Point: ruletypes.Point{V: smpl.Value}, Metric: lbls}
shouldAlert = false
break
}
}
}
case ruletypes.OnAverage:
// If the average of all samples matches the condition, the rule is firing.
var sum, count float64
for _, smpl := range series.Points {
if math.IsNaN(smpl.Value) || math.IsInf(smpl.Value, 0) {
continue
}
sum += smpl.Value
count++
}
avg := sum / count
alertSmpl = ruletypes.Sample{Point: ruletypes.Point{V: avg}, Metric: lbls}
if r.compareOp() == ruletypes.ValueIsAbove {
if avg > r.targetVal() {
shouldAlert = true
}
} else if r.compareOp() == ruletypes.ValueIsBelow {
if avg < r.targetVal() {
shouldAlert = true
}
} else if r.compareOp() == ruletypes.ValueIsEq {
if avg == r.targetVal() {
shouldAlert = true
}
} else if r.compareOp() == ruletypes.ValueIsNotEq {
if avg != r.targetVal() {
shouldAlert = true
}
} else if r.compareOp() == ruletypes.ValueOutsideBounds {
if math.Abs(avg) >= r.targetVal() {
shouldAlert = true
}
}
case ruletypes.InTotal:
// If the sum of all samples matches the condition, the rule is firing.
var sum float64
for _, smpl := range series.Points {
if math.IsNaN(smpl.Value) || math.IsInf(smpl.Value, 0) {
continue
}
sum += smpl.Value
}
alertSmpl = ruletypes.Sample{Point: ruletypes.Point{V: sum}, Metric: lbls}
if r.compareOp() == ruletypes.ValueIsAbove {
if sum > r.targetVal() {
shouldAlert = true
}
} else if r.compareOp() == ruletypes.ValueIsBelow {
if sum < r.targetVal() {
shouldAlert = true
}
} else if r.compareOp() == ruletypes.ValueIsEq {
if sum == r.targetVal() {
shouldAlert = true
}
} else if r.compareOp() == ruletypes.ValueIsNotEq {
if sum != r.targetVal() {
shouldAlert = true
}
} else if r.compareOp() == ruletypes.ValueOutsideBounds {
if math.Abs(sum) >= r.targetVal() {
shouldAlert = true
}
}
case ruletypes.Last:
// If the last sample matches the condition, the rule is firing.
shouldAlert = false
alertSmpl = ruletypes.Sample{Point: ruletypes.Point{V: series.Points[len(series.Points)-1].Value}, Metric: lbls}
if r.compareOp() == ruletypes.ValueIsAbove {
if series.Points[len(series.Points)-1].Value > r.targetVal() {
shouldAlert = true
}
} else if r.compareOp() == ruletypes.ValueIsBelow {
if series.Points[len(series.Points)-1].Value < r.targetVal() {
shouldAlert = true
}
} else if r.compareOp() == ruletypes.ValueIsEq {
if series.Points[len(series.Points)-1].Value == r.targetVal() {
shouldAlert = true
}
} else if r.compareOp() == ruletypes.ValueIsNotEq {
if series.Points[len(series.Points)-1].Value != r.targetVal() {
shouldAlert = true
}
}
}
return alertSmpl, shouldAlert
}
func (r *BaseRule) RecordRuleStateHistory(ctx context.Context, prevState, currentState model.AlertState, itemsToAdd []model.RuleStateHistory) error {
zap.L().Debug("recording rule state history", zap.String("ruleid", r.ID()), zap.Any("prevState", prevState), zap.Any("currentState", currentState), zap.Any("itemsToAdd", itemsToAdd))
revisedItemsToAdd := map[uint64]model.RuleStateHistory{}

View File

@ -1,6 +1,7 @@
package rules
import (
"github.com/stretchr/testify/require"
"testing"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
@ -22,6 +23,15 @@ func TestBaseRule_RequireMinPoints(t *testing.T) {
RequireMinPoints: true,
RequiredNumPoints: 4,
},
Threshold: ruletypes.BasicRuleThresholds{
{
Name: "test-threshold",
TargetValue: &threshold,
CompareOp: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
},
},
},
series: &v3.Series{
Points: []v3.Point{
@ -41,6 +51,14 @@ func TestBaseRule_RequireMinPoints(t *testing.T) {
MatchType: ruletypes.AtleastOnce,
Target: &threshold,
},
Threshold: ruletypes.BasicRuleThresholds{
{
Name: "test-threshold",
TargetValue: &threshold,
CompareOp: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
},
},
},
series: &v3.Series{
Points: []v3.Point{
@ -56,10 +74,9 @@ func TestBaseRule_RequireMinPoints(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, shouldAlert := test.rule.ShouldAlert(*test.series)
if shouldAlert != test.shouldAlert {
t.Errorf("expected shouldAlert to be %v, got %v", test.shouldAlert, shouldAlert)
}
_, err := test.rule.Threshold.ShouldAlert(*test.series, "")
require.NoError(t, err)
require.Equal(t, len(test.series.Points) >= test.rule.ruleCondition.RequiredNumPoints, test.shouldAlert)
})
}
}

View File

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
"log/slog"
"sort"
"strings"
@ -350,40 +351,36 @@ func (m *Manager) EditRule(ctx context.Context, ruleStr string, id valuer.UUID)
existingRule.Data = ruleStr
return m.ruleStore.EditRule(ctx, existingRule, func(ctx context.Context) error {
cfg, err := m.alertmanager.GetConfig(ctx, claims.OrgID)
if err != nil {
return err
}
var preferredChannels []string
if len(parsedRule.PreferredChannels) == 0 {
channels, err := m.alertmanager.ListChannels(ctx, claims.OrgID)
if err != nil {
return err
}
for _, channel := range channels {
preferredChannels = append(preferredChannels, channel.Name)
}
} else {
preferredChannels = parsedRule.PreferredChannels
}
err = cfg.UpdateRuleIDMatcher(id.StringValue(), preferredChannels)
if err != nil {
return err
}
if parsedRule.NotificationSettings != nil {
config := parsedRule.NotificationSettings.GetAlertManagerNotificationConfig()
err = m.alertmanager.SetNotificationConfig(ctx, orgID, existingRule.ID.StringValue(), &config)
err = m.alertmanager.SetNotificationConfig(ctx, orgID, id.StringValue(), &config)
if err != nil {
return err
}
if !parsedRule.NotificationSettings.UsePolicy {
request, err := parsedRule.GetRuleRouteRequest(id.StringValue())
if err != nil {
return err
}
err = m.alertmanager.UpdateAllRoutePoliciesByRuleId(ctx, id.StringValue(), request)
if err != nil {
return err
}
err = m.alertmanager.DeleteAllInhibitRulesByRuleId(ctx, orgID, id.StringValue())
if err != nil {
return err
}
err = m.alertmanager.SetConfig(ctx, cfg)
inhibitRules, err := parsedRule.GetInhibitRules(id.StringValue())
if err != nil {
return err
}
err = m.alertmanager.CreateInhibitRules(ctx, orgID, inhibitRules)
if err != nil {
return err
}
}
}
err = m.syncRuleStateWithTask(ctx, orgID, prepareTaskName(existingRule.ID.StringValue()), &parsedRule)
if err != nil {
return err
@ -488,6 +485,19 @@ func (m *Manager) DeleteRule(ctx context.Context, idStr string) error {
}
err = m.alertmanager.DeleteNotificationConfig(ctx, orgID, id.String())
if err != nil {
return err
}
err = m.alertmanager.DeleteAllRoutePoliciesByRuleId(ctx, id.String())
if err != nil {
return err
}
err = m.alertmanager.DeleteAllInhibitRulesByRuleId(ctx, orgID, id.String())
if err != nil {
return err
}
taskName := prepareTaskName(id.StringValue())
m.deleteTask(taskName)
@ -548,41 +558,30 @@ func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.Ge
}
id, err := m.ruleStore.CreateRule(ctx, storedRule, func(ctx context.Context, id valuer.UUID) error {
cfg, err := m.alertmanager.GetConfig(ctx, claims.OrgID)
if err != nil {
return err
}
var preferredChannels []string
if len(parsedRule.PreferredChannels) == 0 {
channels, err := m.alertmanager.ListChannels(ctx, claims.OrgID)
if err != nil {
return err
}
for _, channel := range channels {
preferredChannels = append(preferredChannels, channel.Name)
}
} else {
preferredChannels = parsedRule.PreferredChannels
}
if parsedRule.NotificationSettings != nil {
config := parsedRule.NotificationSettings.GetAlertManagerNotificationConfig()
err = m.alertmanager.SetNotificationConfig(ctx, orgID, storedRule.ID.StringValue(), &config)
err = m.alertmanager.SetNotificationConfig(ctx, orgID, id.StringValue(), &config)
if err != nil {
return err
}
if !parsedRule.NotificationSettings.UsePolicy {
request, err := parsedRule.GetRuleRouteRequest(id.StringValue())
if err != nil {
return err
}
_, err = m.alertmanager.CreateRoutePolicies(ctx, request)
if err != nil {
return err
}
inhibitRules, err := parsedRule.GetInhibitRules(id.StringValue())
if err != nil {
return err
}
err = m.alertmanager.CreateInhibitRules(ctx, orgID, inhibitRules)
if err != nil {
return err
}
}
err = cfg.CreateRuleIDMatcher(id.StringValue(), preferredChannels)
if err != nil {
return err
}
err = m.alertmanager.SetConfig(ctx, cfg)
if err != nil {
return err
}
taskName := prepareTaskName(id.StringValue())
@ -756,8 +755,9 @@ func (m *Manager) prepareTestNotifyFunc() NotifyFunc {
if len(alerts) == 0 {
return
}
alert := alerts[0]
ruleID := alerts[0].Labels.Map()[labels.AlertRuleIdLabel]
receiverMap := make(map[*alertmanagertypes.PostableAlert][]string)
for _, alert := range alerts {
generatorURL := alert.GeneratorURL
a := &alertmanagertypes.PostableAlert{}
@ -772,20 +772,13 @@ func (m *Manager) prepareTestNotifyFunc() NotifyFunc {
} else {
a.EndsAt = strfmt.DateTime(alert.ValidUntil)
}
if len(alert.Receivers) == 0 {
channels, err := m.alertmanager.ListChannels(ctx, orgID)
receiverMap[a] = alert.Receivers
}
err := m.alertmanager.TestAlert(ctx, orgID, ruleID, receiverMap)
if err != nil {
zap.L().Error("failed to list channels while sending test notification", zap.Error(err))
zap.L().Error("failed to send test notification", zap.Error(err))
return
}
for _, channel := range channels {
alert.Receivers = append(alert.Receivers, channel.Name)
}
}
m.alertmanager.TestAlert(ctx, orgID, a, alert.Receivers)
}
}
@ -983,6 +976,17 @@ func (m *Manager) TestNotification(ctx context.Context, orgID valuer.UUID, ruleS
if err != nil {
return 0, model.BadRequest(err)
}
if !parsedRule.NotificationSettings.UsePolicy {
parsedRule.NotificationSettings.GroupBy = append(parsedRule.NotificationSettings.GroupBy, ruletypes.LabelThresholdName)
}
config := parsedRule.NotificationSettings.GetAlertManagerNotificationConfig()
err = m.alertmanager.SetNotificationConfig(ctx, orgID, parsedRule.AlertName, &config)
if err != nil {
return 0, &model.ApiError{
Typ: model.ErrorBadData,
Err: err,
}
}
alertCount, apiErr := m.prepareTestRuleFunc(PrepareTestRuleOptions{
Rule: &parsedRule,

View File

@ -2,10 +2,15 @@ package rules
import (
"context"
"fmt"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfroutingstore/nfroutingstoretest"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/rulebasednotification"
"github.com/prometheus/common/model"
"strings"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/stretchr/testify/assert"
"go.uber.org/zap"
@ -32,19 +37,38 @@ func TestManager_PatchRule_PayloadVariations(t *testing.T) {
Email: "test@example.com",
Role: "admin",
}
manager, mockSQLRuleStore, orgId := setupTestManager(t)
manager, mockSQLRuleStore, mockRouteStore, nfmanager, orgId := setupTestManager(t)
claims.OrgID = orgId
testCases := []struct {
name string
originalData string
patchData string
Route []*alertmanagertypes.RoutePolicy
Config *alertmanagertypes.NotificationConfig
expectedResult func(*ruletypes.GettableRule) bool
expectError bool
description string
}{
{
name: "patch complete rule with task sync validation",
Route: []*alertmanagertypes.RoutePolicy{
{
Expression: fmt.Sprintf("ruleId == \"{{.ruleId}}\" && threshold.name == \"warning\""),
ExpressionKind: alertmanagertypes.RuleBasedExpression,
Channels: []string{"test-alerts"},
Name: "{{.ruleId}}",
Enabled: true,
},
},
Config: &alertmanagertypes.NotificationConfig{
NotificationGroup: map[model.LabelName]struct{}{model.LabelName("ruleId"): {}},
Renotify: alertmanagertypes.ReNotificationConfig{
RenotifyInterval: 4 * time.Hour,
NoDataInterval: 4 * time.Hour,
},
UsePolicy: false,
},
originalData: `{
"schemaVersion":"v1",
"alert": "test-original-alert",
@ -95,6 +119,23 @@ func TestManager_PatchRule_PayloadVariations(t *testing.T) {
},
{
name: "patch rule to disabled state",
Route: []*alertmanagertypes.RoutePolicy{
{
Expression: fmt.Sprintf("ruleId == \"{{.ruleId}}\" && threshold.name == \"warning\""),
ExpressionKind: alertmanagertypes.RuleBasedExpression,
Channels: []string{"test-alerts"},
Name: "{{.ruleId}}",
Enabled: true,
},
},
Config: &alertmanagertypes.NotificationConfig{
NotificationGroup: map[model.LabelName]struct{}{model.LabelName("ruleId"): {}},
Renotify: alertmanagertypes.ReNotificationConfig{
RenotifyInterval: 4 * time.Hour,
NoDataInterval: 4 * time.Hour,
},
UsePolicy: false,
},
originalData: `{
"schemaVersion":"v2",
"alert": "test-disable-alert",
@ -179,6 +220,20 @@ func TestManager_PatchRule_PayloadVariations(t *testing.T) {
OrgID: claims.OrgID,
}
// Update route expectations with actual rule ID
routesWithRuleID := make([]*alertmanagertypes.RoutePolicy, len(tc.Route))
for i, route := range tc.Route {
routesWithRuleID[i] = &alertmanagertypes.RoutePolicy{
Expression: strings.Replace(route.Expression, "{{.ruleId}}", ruleID.String(), -1),
ExpressionKind: route.ExpressionKind,
Channels: route.Channels,
Name: strings.Replace(route.Name, "{{.ruleId}}", ruleID.String(), -1),
Enabled: route.Enabled,
}
}
mockRouteStore.ExpectDeleteRouteByName(existingRule.OrgID, ruleID.String())
mockRouteStore.ExpectCreateBatch(routesWithRuleID)
mockSQLRuleStore.ExpectGetStoredRule(ruleID, existingRule)
mockSQLRuleStore.ExpectEditRule(existingRule)
@ -200,6 +255,12 @@ func TestManager_PatchRule_PayloadVariations(t *testing.T) {
assert.Nil(t, findTaskByName(manager.RuleTasks(), taskName), "Task should be removed for disabled rule")
} else {
syncCompleted := waitForTaskSync(manager, taskName, true, 2*time.Second)
// Verify notification config
config, err := nfmanager.GetNotificationConfig(orgId, result.Id)
assert.NoError(t, err)
assert.Equal(t, tc.Config, config)
assert.True(t, syncCompleted, "Task synchronization should complete within timeout")
assert.NotNil(t, findTaskByName(manager.RuleTasks(), taskName), "Task should be created/updated for enabled rule")
assert.Greater(t, len(manager.Rules()), 0, "Rules should be updated in manager")
@ -234,7 +295,7 @@ func findTaskByName(tasks []Task, taskName string) Task {
return nil
}
func setupTestManager(t *testing.T) (*Manager, *rulestoretest.MockSQLRuleStore, string) {
func setupTestManager(t *testing.T) (*Manager, *rulestoretest.MockSQLRuleStore, *nfroutingstoretest.MockSQLRouteStore, nfmanager.NotificationManager, string) {
settings := instrumentationtest.New().ToProviderSettings()
testDB := utils.NewQueryServiceDBForTests(t)
@ -266,7 +327,11 @@ func setupTestManager(t *testing.T) (*Manager, *rulestoretest.MockSQLRuleStore,
t.Fatalf("Failed to create noop sharder: %v", err)
}
orgGetter := implorganization.NewGetter(implorganization.NewStore(testDB), noopSharder)
notificationManager := nfmanagertest.NewMock()
routeStore := nfroutingstoretest.NewMockSQLRouteStore()
notificationManager, err := rulebasednotification.New(t.Context(), settings, nfmanager.Config{}, routeStore)
if err != nil {
t.Fatalf("Failed to create alert manager: %v", err)
}
alertManager, err := signozalertmanager.New(context.TODO(), settings, alertmanager.Config{Provider: "signoz", Signoz: alertmanager.Signoz{PollInterval: 10 * time.Second, Config: alertmanagerserver.NewConfig()}}, testDB, orgGetter, notificationManager)
if err != nil {
t.Fatalf("Failed to create alert manager: %v", err)
@ -290,21 +355,40 @@ func setupTestManager(t *testing.T) (*Manager, *rulestoretest.MockSQLRuleStore,
}
close(manager.block)
return manager, mockSQLRuleStore, testOrgID.StringValue()
return manager, mockSQLRuleStore, routeStore, notificationManager, testOrgID.StringValue()
}
func TestCreateRule(t *testing.T) {
claims := &authtypes.Claims{
Email: "test@example.com",
}
manager, mockSQLRuleStore, orgId := setupTestManager(t)
manager, mockSQLRuleStore, mockRouteStore, nfmanager, orgId := setupTestManager(t)
claims.OrgID = orgId
testCases := []struct {
name string
Route []*alertmanagertypes.RoutePolicy
Config *alertmanagertypes.NotificationConfig
ruleStr string
}{
{
name: "validate stored rule data structure",
Route: []*alertmanagertypes.RoutePolicy{
{
Expression: fmt.Sprintf("ruleId == \"{{.ruleId}}\" && threshold.name == \"warning\""),
ExpressionKind: alertmanagertypes.RuleBasedExpression,
Channels: []string{"test-alerts"},
Name: "{{.ruleId}}",
Enabled: true,
},
},
Config: &alertmanagertypes.NotificationConfig{
NotificationGroup: map[model.LabelName]struct{}{model.LabelName("ruleId"): {}},
Renotify: alertmanagertypes.ReNotificationConfig{
RenotifyInterval: 4 * time.Hour,
NoDataInterval: 4 * time.Hour,
},
UsePolicy: false,
},
ruleStr: `{
"alert": "cpu usage",
"ruleType": "threshold_rule",
@ -341,6 +425,30 @@ func TestCreateRule(t *testing.T) {
},
{
name: "create complete v2 rule with thresholds",
Route: []*alertmanagertypes.RoutePolicy{
{
Expression: fmt.Sprintf("ruleId == \"{{.ruleId}}\" && threshold.name == \"critical\""),
ExpressionKind: alertmanagertypes.RuleBasedExpression,
Channels: []string{"test-alerts"},
Name: "{{.ruleId}}",
Enabled: true,
},
{
Expression: fmt.Sprintf("ruleId == \"{{.ruleId}}\" && threshold.name == \"warning\""),
ExpressionKind: alertmanagertypes.RuleBasedExpression,
Channels: []string{"test-alerts"},
Name: "{{.ruleId}}",
Enabled: true,
},
},
Config: &alertmanagertypes.NotificationConfig{
NotificationGroup: map[model.LabelName]struct{}{model.LabelName("k8s.node.name"): {}, model.LabelName("ruleId"): {}},
Renotify: alertmanagertypes.ReNotificationConfig{
RenotifyInterval: 10 * time.Minute,
NoDataInterval: 4 * time.Hour,
},
UsePolicy: false,
},
ruleStr: `{
"schemaVersion":"v2",
"state": "firing",
@ -398,6 +506,18 @@ func TestCreateRule(t *testing.T) {
"evalWindow": "6m",
"frequency": "1m"
}
},
"notificationSettings": {
"GroupBy": [
"k8s.node.name"
],
"renotify": {
"interval": "10m",
"enabled": true,
"alertStates": [
"firing"
]
}
},
"labels": {
"severity": "warning"
@ -429,6 +549,20 @@ func TestCreateRule(t *testing.T) {
},
OrgID: claims.OrgID,
}
// Update route expectations with actual rule ID
routesWithRuleID := make([]*alertmanagertypes.RoutePolicy, len(tc.Route))
for i, route := range tc.Route {
routesWithRuleID[i] = &alertmanagertypes.RoutePolicy{
Expression: strings.Replace(route.Expression, "{{.ruleId}}", rule.ID.String(), -1),
ExpressionKind: route.ExpressionKind,
Channels: route.Channels,
Name: strings.Replace(route.Name, "{{.ruleId}}", rule.ID.String(), -1),
Enabled: route.Enabled,
}
}
mockRouteStore.ExpectCreateBatch(routesWithRuleID)
mockSQLRuleStore.ExpectCreateRule(rule)
ctx := authtypes.NewContextWithClaims(context.Background(), *claims)
@ -441,6 +575,12 @@ func TestCreateRule(t *testing.T) {
// Wait for task creation with proper synchronization
taskName := prepareTaskName(result.Id)
syncCompleted := waitForTaskSync(manager, taskName, true, 2*time.Second)
// Verify notification config
config, err := nfmanager.GetNotificationConfig(orgId, result.Id)
assert.NoError(t, err)
assert.Equal(t, tc.Config, config)
assert.True(t, syncCompleted, "Task creation should complete within timeout")
assert.NotNil(t, findTaskByName(manager.RuleTasks(), taskName), "Task should be created with correct name")
assert.Greater(t, len(manager.Rules()), 0, "Rules should be added to manager")
@ -455,14 +595,35 @@ func TestEditRule(t *testing.T) {
claims := &authtypes.Claims{
Email: "test@example.com",
}
manager, mockSQLRuleStore, orgId := setupTestManager(t)
manager, mockSQLRuleStore, mockRouteStore, nfmanager, orgId := setupTestManager(t)
claims.OrgID = orgId
testCases := []struct {
ruleID string
name string
Route []*alertmanagertypes.RoutePolicy
Config *alertmanagertypes.NotificationConfig
ruleStr string
}{
{
ruleID: "12345678-1234-1234-1234-123456789012",
name: "validate edit rule functionality",
Route: []*alertmanagertypes.RoutePolicy{
{
Expression: fmt.Sprintf("ruleId == \"rule1\" && threshold.name == \"critical\""),
ExpressionKind: alertmanagertypes.RuleBasedExpression,
Channels: []string{"critical-alerts"},
Name: "12345678-1234-1234-1234-123456789012",
Enabled: true,
},
},
Config: &alertmanagertypes.NotificationConfig{
NotificationGroup: map[model.LabelName]struct{}{model.LabelName("ruleId"): {}},
Renotify: alertmanagertypes.ReNotificationConfig{
RenotifyInterval: 4 * time.Hour,
NoDataInterval: 4 * time.Hour,
},
UsePolicy: false,
},
ruleStr: `{
"alert": "updated cpu usage",
"ruleType": "threshold_rule",
@ -498,7 +659,32 @@ func TestEditRule(t *testing.T) {
}`,
},
{
ruleID: "12345678-1234-1234-1234-123456789013",
name: "edit complete v2 rule with thresholds",
Route: []*alertmanagertypes.RoutePolicy{
{
Expression: fmt.Sprintf("ruleId == \"rule2\" && threshold.name == \"critical\""),
ExpressionKind: alertmanagertypes.RuleBasedExpression,
Channels: []string{"test-alerts"},
Name: "12345678-1234-1234-1234-123456789013",
Enabled: true,
},
{
Expression: fmt.Sprintf("ruleId == \"rule2\" && threshold.name == \"warning\""),
ExpressionKind: alertmanagertypes.RuleBasedExpression,
Channels: []string{"test-alerts"},
Name: "12345678-1234-1234-1234-123456789013",
Enabled: true,
},
},
Config: &alertmanagertypes.NotificationConfig{
NotificationGroup: map[model.LabelName]struct{}{model.LabelName("ruleId"): {}, model.LabelName("k8s.node.name"): {}},
Renotify: alertmanagertypes.ReNotificationConfig{
RenotifyInterval: 10 * time.Minute,
NoDataInterval: 4 * time.Hour,
},
UsePolicy: false,
},
ruleStr: `{
"schemaVersion":"v2",
"state": "firing",
@ -559,6 +745,18 @@ func TestEditRule(t *testing.T) {
},
"labels": {
"severity": "critical"
},
"notificationSettings": {
"GroupBy": [
"k8s.node.name"
],
"renotify": {
"interval": "10m",
"enabled": true,
"alertStates": [
"firing"
]
}
},
"annotations": {
"description": "This alert is fired when memory usage crosses the threshold",
@ -573,11 +771,13 @@ func TestEditRule(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ruleID := valuer.GenerateUUID()
ruleId, err := valuer.NewUUID(tc.ruleID)
if err != nil {
t.Errorf("error creating ruleId: %s", err)
}
existingRule := &ruletypes.Rule{
Identifiable: types.Identifiable{
ID: ruleID,
ID: ruleId,
},
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
@ -590,18 +790,24 @@ func TestEditRule(t *testing.T) {
Data: `{"alert": "original cpu usage", "disabled": false}`,
OrgID: claims.OrgID,
}
mockSQLRuleStore.ExpectGetStoredRule(ruleID, existingRule)
mockRouteStore.ExpectDeleteRouteByName(existingRule.OrgID, ruleId.String())
mockRouteStore.ExpectCreateBatch(tc.Route)
mockSQLRuleStore.ExpectGetStoredRule(ruleId, existingRule)
mockSQLRuleStore.ExpectEditRule(existingRule)
ctx := authtypes.NewContextWithClaims(context.Background(), *claims)
err := manager.EditRule(ctx, tc.ruleStr, ruleID)
err = manager.EditRule(ctx, tc.ruleStr, ruleId)
assert.NoError(t, err)
// Wait for task update with proper synchronization
taskName := prepareTaskName(ruleID.StringValue())
taskName := prepareTaskName(ruleId.String())
syncCompleted := waitForTaskSync(manager, taskName, true, 2*time.Second)
config, err := nfmanager.GetNotificationConfig(orgId, ruleId.String())
assert.NoError(t, err)
assert.Equal(t, tc.Config, config)
assert.True(t, syncCompleted, "Task update should complete within timeout")
assert.NotNil(t, findTaskByName(manager.RuleTasks(), taskName), "Task should be updated with correct name")
assert.Greater(t, len(manager.Rules()), 0, "Rules should be updated in manager")

View File

@ -147,13 +147,19 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (interface{}, error)
var alerts = make(map[uint64]*ruletypes.Alert, len(res))
ruleReceivers := r.Threshold.GetRuleReceivers()
ruleReceiverMap := make(map[string][]string)
for _, value := range ruleReceivers {
ruleReceiverMap[value.Name] = value.Channels
}
for _, series := range res {
if len(series.Floats) == 0 {
continue
}
results, err := r.Threshold.ShouldAlert(toCommonSeries(series))
results, err := r.Threshold.ShouldAlert(toCommonSeries(series), r.Unit())
if err != nil {
return nil, err
}
@ -165,7 +171,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (interface{}, error)
}
r.logger.DebugContext(ctx, "alerting for series", "rule_name", r.Name(), "series", series)
threshold := valueFormatter.Format(r.targetVal(), r.Unit())
threshold := valueFormatter.Format(result.Target, result.TargetUnit)
tmplData := ruletypes.AlertTemplateData(l, valueFormatter.Format(result.V, r.Unit()), threshold)
// Inject some convenience variables that are easier to remember for users
@ -218,7 +224,6 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (interface{}, error)
r.lastError = err
return nil, err
}
alerts[h] = &ruletypes.Alert{
Labels: lbs,
QueryResultLables: resultLabels,
@ -227,13 +232,12 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (interface{}, error)
State: model.StatePending,
Value: result.V,
GeneratorURL: r.GeneratorURL(),
Receivers: r.preferredChannels,
Receivers: ruleReceiverMap[lbs.Map()[ruletypes.LabelThresholdName]],
}
}
}
r.logger.InfoContext(ctx, "number of alerts found", "rule_name", r.Name(), "alerts_count", len(alerts))
// alerts[h] is ready, add or update active list now
for h, a := range alerts {
// Check whether we already have alerting state for the identifying label set.
@ -241,7 +245,9 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (interface{}, error)
if alert, ok := r.Active[h]; ok && alert.State != model.StateInactive {
alert.Value = a.Value
alert.Annotations = a.Annotations
alert.Receivers = r.preferredChannels
if v, ok := alert.Labels.Map()[ruletypes.LabelThresholdName]; ok {
alert.Receivers = ruleReceiverMap[v]
}
continue
}

View File

@ -696,7 +696,7 @@ func TestPromRuleShouldAlert(t *testing.T) {
assert.NoError(t, err)
}
resultVectors, err := rule.Threshold.ShouldAlert(toCommonSeries(c.values))
resultVectors, err := rule.Threshold.ShouldAlert(toCommonSeries(c.values), rule.Unit())
assert.NoError(t, err)
// Compare full result vector with expected vector

View File

@ -38,7 +38,6 @@ func defaultTestNotification(opts PrepareTestRuleOptions) (int, *model.ApiError)
if parsedRule.RuleType == ruletypes.RuleTypeThreshold {
// add special labels for test alerts
parsedRule.Annotations[labels.AlertSummaryLabel] = fmt.Sprintf("The rule threshold is set to %.4f, and the observed metric value is {{$value}}.", *parsedRule.RuleCondition.Target)
parsedRule.Labels[labels.RuleSourceLabel] = ""
parsedRule.Labels[labels.AlertRuleIdLabel] = ""

View File

@ -488,7 +488,7 @@ func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID,
continue
}
}
resultSeries, err := r.Threshold.ShouldAlert(*series)
resultSeries, err := r.Threshold.ShouldAlert(*series, r.Unit())
if err != nil {
return nil, err
}
@ -565,7 +565,7 @@ func (r *ThresholdRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUI
continue
}
}
resultSeries, err := r.Threshold.ShouldAlert(*series)
resultSeries, err := r.Threshold.ShouldAlert(*series, r.Unit())
if err != nil {
return nil, err
}
@ -602,6 +602,12 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (interface{}, er
resultFPs := map[uint64]struct{}{}
var alerts = make(map[uint64]*ruletypes.Alert, len(res))
ruleReceivers := r.Threshold.GetRuleReceivers()
ruleReceiverMap := make(map[string][]string)
for _, value := range ruleReceivers {
ruleReceiverMap[value.Name] = value.Channels
}
for _, smpl := range res {
l := make(map[string]string, len(smpl.Metric))
for _, lbl := range smpl.Metric {
@ -610,7 +616,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (interface{}, er
value := valueFormatter.Format(smpl.V, r.Unit())
//todo(aniket): handle different threshold
threshold := valueFormatter.Format(r.targetVal(), r.Unit())
threshold := valueFormatter.Format(smpl.Target, smpl.TargetUnit)
r.logger.DebugContext(ctx, "Alert template data for rule", "rule_name", r.Name(), "formatter", valueFormatter.Name(), "value", value, "threshold", threshold)
tmplData := ruletypes.AlertTemplateData(l, value, threshold)
@ -690,7 +696,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (interface{}, er
State: model.StatePending,
Value: smpl.V,
GeneratorURL: r.GeneratorURL(),
Receivers: r.preferredChannels,
Receivers: ruleReceiverMap[lbs.Map()[ruletypes.LabelThresholdName]],
Missing: smpl.IsMissing,
}
}
@ -705,7 +711,9 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (interface{}, er
alert.Value = a.Value
alert.Annotations = a.Annotations
alert.Receivers = r.preferredChannels
if v, ok := alert.Labels.Map()[ruletypes.LabelThresholdName]; ok {
alert.Receivers = ruleReceiverMap[v]
}
continue
}

View File

@ -824,7 +824,7 @@ func TestThresholdRuleShouldAlert(t *testing.T) {
values.Points[i].Timestamp = time.Now().UnixMilli()
}
resultVectors, err := rule.Threshold.ShouldAlert(c.values)
resultVectors, err := rule.Threshold.ShouldAlert(c.values, rule.Unit())
assert.NoError(t, err, "Test case %d", idx)
// Compare result vectors with expected behavior
@ -1201,7 +1201,7 @@ func TestThresholdRuleLabelNormalization(t *testing.T) {
values.Points[i].Timestamp = time.Now().UnixMilli()
}
vector, err := rule.Threshold.ShouldAlert(c.values)
vector, err := rule.Threshold.ShouldAlert(c.values, rule.Unit())
assert.NoError(t, err)
for name, value := range c.values.Labels {
@ -1211,7 +1211,7 @@ func TestThresholdRuleLabelNormalization(t *testing.T) {
}
// Get result vectors from threshold evaluation
resultVectors, err := rule.Threshold.ShouldAlert(c.values)
resultVectors, err := rule.Threshold.ShouldAlert(c.values, rule.Unit())
assert.NoError(t, err, "Test case %d", idx)
// Compare result vectors with expected behavior
@ -1504,10 +1504,8 @@ func TestThresholdRuleUnitCombinations(t *testing.T) {
Name: postableRule.AlertName,
TargetValue: &c.target,
TargetUnit: c.targetUnit,
RuleUnit: postableRule.RuleCondition.CompositeQuery.Unit,
MatchType: ruletypes.MatchType(c.matchType),
CompareOp: ruletypes.CompareOp(c.compareOp),
SelectedQuery: postableRule.RuleCondition.SelectedQuery,
},
},
}
@ -1614,10 +1612,8 @@ func TestThresholdRuleNoData(t *testing.T) {
{
Name: postableRule.AlertName,
TargetValue: &target,
RuleUnit: postableRule.RuleCondition.CompositeQuery.Unit,
MatchType: ruletypes.AtleastOnce,
CompareOp: ruletypes.ValueIsEq,
SelectedQuery: postableRule.RuleCondition.SelectedQuery,
},
},
}
@ -1737,10 +1733,8 @@ func TestThresholdRuleTracesLink(t *testing.T) {
Name: postableRule.AlertName,
TargetValue: &c.target,
TargetUnit: c.targetUnit,
RuleUnit: postableRule.RuleCondition.CompositeQuery.Unit,
MatchType: ruletypes.MatchType(c.matchType),
CompareOp: ruletypes.CompareOp(c.compareOp),
SelectedQuery: postableRule.RuleCondition.SelectedQuery,
},
},
}
@ -1876,10 +1870,8 @@ func TestThresholdRuleLogsLink(t *testing.T) {
Name: postableRule.AlertName,
TargetValue: &c.target,
TargetUnit: c.targetUnit,
RuleUnit: postableRule.RuleCondition.CompositeQuery.Unit,
MatchType: ruletypes.MatchType(c.matchType),
CompareOp: ruletypes.CompareOp(c.compareOp),
SelectedQuery: postableRule.RuleCondition.SelectedQuery,
},
},
}
@ -2128,19 +2120,15 @@ func TestMultipleThresholdRule(t *testing.T) {
Name: "first_threshold",
TargetValue: &c.target,
TargetUnit: c.targetUnit,
RuleUnit: postableRule.RuleCondition.CompositeQuery.Unit,
MatchType: ruletypes.MatchType(c.matchType),
CompareOp: ruletypes.CompareOp(c.compareOp),
SelectedQuery: postableRule.RuleCondition.SelectedQuery,
},
{
Name: "second_threshold",
TargetValue: &c.secondTarget,
TargetUnit: c.targetUnit,
RuleUnit: postableRule.RuleCondition.CompositeQuery.Unit,
MatchType: ruletypes.MatchType(c.matchType),
CompareOp: ruletypes.CompareOp(c.compareOp),
SelectedQuery: postableRule.RuleCondition.SelectedQuery,
},
},
}

View File

@ -853,7 +853,7 @@ func (v *filterExpressionVisitor) VisitKey(ctx *grammar.KeyContext) any {
}
}
if len(fieldKeysForName) > 1 && !v.keysWithWarnings[keyName] {
if len(fieldKeysForName) > 1 {
warnMsg := fmt.Sprintf(
"Key `%s` is ambiguous, found %d different combinations of field context / data type: %v.",
fieldKey.Name,
@ -865,6 +865,7 @@ func (v *filterExpressionVisitor) VisitKey(ctx *grammar.KeyContext) any {
mixedFieldContext[item.FieldContext.StringValue()] = true
}
// when there is both resource and attribute context, default to resource only
if mixedFieldContext[telemetrytypes.FieldContextResource.StringValue()] &&
mixedFieldContext[telemetrytypes.FieldContextAttribute.StringValue()] {
filteredKeys := []*telemetrytypes.TelemetryFieldKey{}
@ -878,9 +879,12 @@ func (v *filterExpressionVisitor) VisitKey(ctx *grammar.KeyContext) any {
warnMsg += " " + "Using `resource` context by default. To query attributes explicitly, " +
fmt.Sprintf("use the fully qualified name (e.g., 'attribute.%s')", fieldKey.Name)
}
if !v.keysWithWarnings[keyName] {
v.mainWarnURL = "https://signoz.io/docs/userguide/field-context-data-types/"
// this is warning state, we must have a unambiguous key
v.warnings = append(v.warnings, warnMsg)
}
v.keysWithWarnings[keyName] = true
v.logger.Warn("ambiguous key", "field_key_name", fieldKey.Name) //nolint:sloglint
}

View File

@ -38,6 +38,7 @@ import (
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/clickhousetelemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystorehook"
routeTypes "github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/SigNoz/signoz/pkg/web"
"github.com/SigNoz/signoz/pkg/web/noopweb"
@ -133,6 +134,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewQueryBuilderV5MigrationFactory(sqlstore, telemetryStore),
sqlmigration.NewAddMeterQuickFiltersFactory(sqlstore, sqlschema),
sqlmigration.NewUpdateTTLSettingForCustomRetentionFactory(sqlstore, sqlschema),
sqlmigration.NewAddRoutePolicyFactory(sqlstore, sqlschema),
)
}
@ -155,9 +157,9 @@ func NewPrometheusProviderFactories(telemetryStore telemetrystore.TelemetryStore
)
}
func NewNotificationManagerProviderFactories() factory.NamedMap[factory.ProviderFactory[nfmanager.NotificationManager, nfmanager.Config]] {
func NewNotificationManagerProviderFactories(routeStore routeTypes.RouteStore) factory.NamedMap[factory.ProviderFactory[nfmanager.NotificationManager, nfmanager.Config]] {
return factory.MustNewNamedMap(
rulebasednotification.NewFactory(),
rulebasednotification.NewFactory(routeStore),
)
}

View File

@ -4,6 +4,7 @@ import (
"context"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfroutingstore/sqlroutingstore"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/emailing"
@ -230,12 +231,14 @@ func New(
// Initialize user getter
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings))
// will need to create factory for all stores
routeStore := sqlroutingstore.NewStore(sqlstore)
// shared NotificationManager instance for both alertmanager and rules
notificationManager, err := factory.NewProviderFromNamedMap(
ctx,
providerSettings,
nfmanager.Config{},
NewNotificationManagerProviderFactories(),
NewNotificationManagerProviderFactories(routeStore),
"rulebased",
)
if err != nil {

View File

@ -0,0 +1,260 @@
package sqlmigration
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
"log/slog"
"time"
)
// Shared types for migration
type expressionRoute struct {
bun.BaseModel `bun:"table:route_policy"`
types.Identifiable
types.TimeAuditable
types.UserAuditable
Expression string `bun:"expression,type:text"`
ExpressionKind string `bun:"kind,type:text"`
Channels []string `bun:"channels,type:text"`
Name string `bun:"name,type:text"`
Description string `bun:"description,type:text"`
Enabled bool `bun:"enabled,type:boolean,default:true"`
Tags []string `bun:"tags,type:text"`
OrgID string `bun:"org_id,type:text"`
}
type rule struct {
bun.BaseModel `bun:"table:rule"`
types.Identifiable
types.TimeAuditable
types.UserAuditable
Deleted int `bun:"deleted,default:0"`
Data string `bun:"data,type:text"`
OrgID string `bun:"org_id,type:text"`
}
type addRoutePolicies struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
logger *slog.Logger
}
func NewAddRoutePolicyFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_route_policy"), func(ctx context.Context, providerSettings factory.ProviderSettings, config Config) (SQLMigration, error) {
return newAddRoutePolicy(ctx, providerSettings, config, sqlstore, sqlschema)
})
}
func newAddRoutePolicy(_ context.Context, settings factory.ProviderSettings, _ Config, sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) (SQLMigration, error) {
return &addRoutePolicies{
sqlstore: sqlstore,
sqlschema: sqlschema,
logger: settings.Logger,
}, nil
}
func (migration *addRoutePolicies) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *addRoutePolicies) Up(ctx context.Context, db *bun.DB) error {
_, _, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("route_policy"))
if err == nil {
return nil
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
sqls := [][]byte{}
// Create the route_policy table
table := &sqlschema.Table{
Name: "route_policy",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "created_by", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "updated_by", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "expression", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "kind", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "channels", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "name", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "description", DataType: sqlschema.DataTypeText, Nullable: true},
{Name: "enabled", DataType: sqlschema.DataTypeBoolean, Nullable: false, Default: "true"},
{Name: "tags", DataType: sqlschema.DataTypeText, Nullable: true},
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
ColumnNames: []sqlschema.ColumnName{"id"},
},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: "org_id",
ReferencedTableName: "organizations",
ReferencedColumnName: "id",
},
},
}
tableSQLs := migration.sqlschema.Operator().CreateTable(table)
sqls = append(sqls, tableSQLs...)
for _, sqlStmt := range sqls {
if _, err := tx.ExecContext(ctx, string(sqlStmt)); err != nil {
return err
}
}
err = migration.migrateRulesToRoutePolicies(ctx, tx)
if err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func (migration *addRoutePolicies) migrateRulesToRoutePolicies(ctx context.Context, tx bun.Tx) error {
var rules []*rule
err := tx.NewSelect().
Model(&rules).
Where("deleted = ?", 0).
Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil // No rules to migrate
}
return errors.NewInternalf(errors.CodeInternal, "failed to fetch rules")
}
channelsByOrg, err := migration.getAllChannels(ctx, tx)
if err != nil {
return errors.NewInternalf(errors.CodeInternal, "fetching channels error: %v", err)
}
var routesToInsert []*expressionRoute
routesToInsert, err = migration.convertRulesToRoutes(rules, channelsByOrg)
if err != nil {
return errors.NewInternalf(errors.CodeInternal, "converting rules to routes error: %v", err)
}
// Insert all routes in a single batch operation
if len(routesToInsert) > 0 {
_, err = tx.NewInsert().
Model(&routesToInsert).
Exec(ctx)
if err != nil {
return errors.NewInternalf(errors.CodeInternal, "failed to insert notification routes")
}
}
return nil
}
func (migration *addRoutePolicies) convertRulesToRoutes(rules []*rule, channelsByOrg map[string][]string) ([]*expressionRoute, error) {
var routes []*expressionRoute
for _, r := range rules {
var gettableRule ruletypes.GettableRule
if err := json.Unmarshal([]byte(r.Data), &gettableRule); err != nil {
return nil, errors.NewInternalf(errors.CodeInternal, "failed to unmarshal rule data for rule ID %s: %v", r.ID, err)
}
if len(gettableRule.PreferredChannels) == 0 {
channels, exists := channelsByOrg[r.OrgID]
if !exists || len(channels) == 0 {
continue
}
gettableRule.PreferredChannels = channels
}
severity := "critical"
if v, ok := gettableRule.Labels["severity"]; ok {
severity = v
}
expression := fmt.Sprintf(`%s == "%s" && %s == "%s"`, "threshold.name", severity, "ruleId", r.ID.String())
route := &expressionRoute{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
UserAuditable: types.UserAuditable{
CreatedBy: r.CreatedBy,
UpdatedBy: r.UpdatedBy,
},
Expression: expression,
ExpressionKind: "rule",
Channels: gettableRule.PreferredChannels,
Name: r.ID.StringValue(),
Enabled: true,
OrgID: r.OrgID,
}
routes = append(routes, route)
}
return routes, nil
}
func (migration *addRoutePolicies) getAllChannels(ctx context.Context, tx bun.Tx) (map[string][]string, error) {
type channel struct {
bun.BaseModel `bun:"table:notification_channel"`
types.Identifiable
types.TimeAuditable
Name string `json:"name" bun:"name"`
Type string `json:"type" bun:"type"`
Data string `json:"data" bun:"data"`
OrgID string `json:"org_id" bun:"org_id"`
}
var channels []*channel
err := tx.NewSelect().
Model(&channels).
Scan(ctx)
if err != nil {
return nil, errors.NewInternalf(errors.CodeInternal, "failed to fetch all channels")
}
// Group channels by org ID
channelsByOrg := make(map[string][]string)
for _, ch := range channels {
channelsByOrg[ch.OrgID] = append(channelsByOrg[ch.OrgID], ch.Name)
}
return channelsByOrg, nil
}
func (migration *addRoutePolicies) Down(ctx context.Context, db *bun.DB) error {
return nil
}

View File

@ -166,11 +166,10 @@ func (c *conditionBuilder) conditionFor(
var value any
switch column.Type {
case schema.JSONColumnType{}:
value = "NULL"
if operator == qbtypes.FilterOperatorExists {
return sb.NE(tblFieldName, value), nil
return sb.IsNotNull(tblFieldName), nil
} else {
return sb.E(tblFieldName, value), nil
return sb.IsNull(tblFieldName), nil
}
case schema.ColumnTypeString, schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString}:
value = ""

View File

@ -233,6 +233,30 @@ func TestConditionFor(t *testing.T) {
expectedArgs: []any{true},
expectedError: nil,
},
{
name: "Exists operator - json field",
key: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
operator: qbtypes.FilterOperatorExists,
value: nil,
expectedSQL: "WHERE multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL",
expectedError: nil,
},
{
name: "Not Exists operator - json field",
key: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
operator: qbtypes.FilterOperatorNotExists,
value: nil,
expectedSQL: "WHERE multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NULL",
expectedError: nil,
},
{
name: "Non-existent column",
key: telemetrytypes.TelemetryFieldKey{

View File

@ -452,8 +452,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "FREETEXT with conditions",
query: "error service.name=authentication",
shouldPass: true,
expectedQuery: "WHERE (match(LOWER(body), LOWER(?)) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?))",
expectedArgs: []any{"error", "authentication", "NULL"},
expectedQuery: "WHERE (match(LOWER(body), LOWER(?)) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL))",
expectedArgs: []any{"error", "authentication"},
expectedErrorContains: "",
},
@ -810,8 +810,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "Basic equality",
query: "service.name=\"api\"",
shouldPass: true,
expectedQuery: "WHERE (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?)",
expectedArgs: []any{"api", "NULL"},
expectedQuery: "WHERE (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL)",
expectedArgs: []any{"api"},
expectedErrorContains: "",
},
{
@ -1170,16 +1170,16 @@ func TestFilterExprLogs(t *testing.T) {
category: "IN operator (parentheses)",
query: "service.name IN (\"api\", \"web\", \"auth\")",
shouldPass: true,
expectedQuery: "WHERE ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? OR multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? OR multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ?) AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?)",
expectedArgs: []any{"api", "web", "auth", "NULL"},
expectedQuery: "WHERE ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? OR multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? OR multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ?) AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL)",
expectedArgs: []any{"api", "web", "auth"},
expectedErrorContains: "",
},
{
category: "IN operator (parentheses)",
query: "environment IN (\"dev\", \"test\", \"staging\", \"prod\")",
shouldPass: true,
expectedQuery: "WHERE ((multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) = ? OR multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) = ? OR multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) = ? OR multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) = ?) AND multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) <> ?)",
expectedArgs: []any{"dev", "test", "staging", "prod", "NULL"},
expectedQuery: "WHERE ((multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) = ? OR multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) = ? OR multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) = ? OR multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) = ?) AND multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) IS NOT NULL)",
expectedArgs: []any{"dev", "test", "staging", "prod"},
expectedErrorContains: "",
},
@ -1204,16 +1204,16 @@ func TestFilterExprLogs(t *testing.T) {
category: "IN operator (brackets)",
query: "service.name IN [\"api\", \"web\", \"auth\"]",
shouldPass: true,
expectedQuery: "WHERE ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? OR multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? OR multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ?) AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?)",
expectedArgs: []any{"api", "web", "auth", "NULL"},
expectedQuery: "WHERE ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? OR multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? OR multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ?) AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL)",
expectedArgs: []any{"api", "web", "auth"},
expectedErrorContains: "",
},
{
category: "IN operator (brackets)",
query: "environment IN [\"dev\", \"test\", \"staging\", \"prod\"]",
shouldPass: true,
expectedQuery: "WHERE ((multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) = ? OR multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) = ? OR multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) = ? OR multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) = ?) AND multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) <> ?)",
expectedArgs: []any{"dev", "test", "staging", "prod", "NULL"},
expectedQuery: "WHERE ((multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) = ? OR multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) = ? OR multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) = ? OR multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) = ?) AND multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) IS NOT NULL)",
expectedArgs: []any{"dev", "test", "staging", "prod"},
expectedErrorContains: "",
},
@ -1318,6 +1318,13 @@ func TestFilterExprLogs(t *testing.T) {
expectedArgs: []any{true},
expectedErrorContains: "",
},
{
category: "EXISTS operator on resource",
query: "service.name EXISTS",
shouldPass: true,
expectedQuery: "WHERE multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL",
expectedErrorContains: "",
},
// NOT EXISTS
{
@ -1360,6 +1367,13 @@ func TestFilterExprLogs(t *testing.T) {
expectedArgs: []any{true},
expectedErrorContains: "",
},
{
category: "EXISTS operator on resource",
query: "service.name NOT EXISTS",
shouldPass: true,
expectedQuery: "WHERE multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NULL",
expectedErrorContains: "",
},
// Basic REGEXP
{
@ -1530,8 +1544,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "Explicit AND",
query: "status=200 AND service.name=\"api\"",
shouldPass: true,
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?))",
expectedArgs: []any{float64(200), true, "api", "NULL"},
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL))",
expectedArgs: []any{float64(200), true, "api"},
expectedErrorContains: "",
},
{
@ -1564,8 +1578,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "Explicit OR",
query: "service.name=\"api\" OR service.name=\"web\"",
shouldPass: true,
expectedQuery: "WHERE ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?) OR (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?))",
expectedArgs: []any{"api", "NULL", "web", "NULL"},
expectedQuery: "WHERE ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL) OR (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL))",
expectedArgs: []any{"api", "web"},
expectedErrorContains: "",
},
{
@ -1590,8 +1604,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "NOT with expressions",
query: "NOT service.name=\"api\"",
shouldPass: true,
expectedQuery: "WHERE NOT ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?))",
expectedArgs: []any{"api", "NULL"},
expectedQuery: "WHERE NOT ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL))",
expectedArgs: []any{"api"},
expectedErrorContains: "",
},
{
@ -1608,8 +1622,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "AND + OR combinations",
query: "status=200 AND (service.name=\"api\" OR service.name=\"web\")",
shouldPass: true,
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) AND (((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?) OR (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?))))",
expectedArgs: []any{float64(200), true, "api", "NULL", "web", "NULL"},
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) AND (((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL) OR (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL))))",
expectedArgs: []any{float64(200), true, "api", "web"},
expectedErrorContains: "",
},
{
@ -1634,8 +1648,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "AND + NOT combinations",
query: "status=200 AND NOT service.name=\"api\"",
shouldPass: true,
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) AND NOT ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?)))",
expectedArgs: []any{float64(200), true, "api", "NULL"},
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) AND NOT ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL)))",
expectedArgs: []any{float64(200), true, "api"},
expectedErrorContains: "",
},
{
@ -1652,8 +1666,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "OR + NOT combinations",
query: "NOT status=200 OR NOT service.name=\"api\"",
shouldPass: true,
expectedQuery: "WHERE (NOT ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?)) OR NOT ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?)))",
expectedArgs: []any{float64(200), true, "api", "NULL"},
expectedQuery: "WHERE (NOT ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?)) OR NOT ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL)))",
expectedArgs: []any{float64(200), true, "api"},
expectedErrorContains: "",
},
{
@ -1670,8 +1684,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "AND + OR + NOT combinations",
query: "status=200 AND (service.name=\"api\" OR NOT duration>1000)",
shouldPass: true,
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) AND (((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?) OR NOT ((toFloat64(attributes_number['duration']) > ? AND mapContains(attributes_number, 'duration') = ?)))))",
expectedArgs: []any{float64(200), true, "api", "NULL", float64(1000), true},
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) AND (((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL) OR NOT ((toFloat64(attributes_number['duration']) > ? AND mapContains(attributes_number, 'duration') = ?)))))",
expectedArgs: []any{float64(200), true, "api", float64(1000), true},
expectedErrorContains: "",
},
{
@ -1686,8 +1700,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "AND + OR + NOT combinations",
query: "NOT (status=200 AND service.name=\"api\") OR count>0",
shouldPass: true,
expectedQuery: "WHERE (NOT ((((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?)))) OR (toFloat64(attributes_number['count']) > ? AND mapContains(attributes_number, 'count') = ?))",
expectedArgs: []any{float64(200), true, "api", "NULL", float64(0), true},
expectedQuery: "WHERE (NOT ((((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL)))) OR (toFloat64(attributes_number['count']) > ? AND mapContains(attributes_number, 'count') = ?))",
expectedArgs: []any{float64(200), true, "api", float64(0), true},
expectedErrorContains: "",
},
@ -1696,8 +1710,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "Implicit AND",
query: "status=200 service.name=\"api\"",
shouldPass: true,
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?))",
expectedArgs: []any{float64(200), true, "api", "NULL"},
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL))",
expectedArgs: []any{float64(200), true, "api"},
expectedErrorContains: "",
},
{
@ -1722,8 +1736,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "Mixed implicit/explicit AND",
query: "status=200 AND service.name=\"api\" duration<1000",
shouldPass: true,
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?) AND (toFloat64(attributes_number['duration']) < ? AND mapContains(attributes_number, 'duration') = ?))",
expectedArgs: []any{float64(200), true, "api", "NULL", float64(1000), true},
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL) AND (toFloat64(attributes_number['duration']) < ? AND mapContains(attributes_number, 'duration') = ?))",
expectedArgs: []any{float64(200), true, "api", float64(1000), true},
expectedErrorContains: "",
},
{
@ -1748,8 +1762,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "Simple grouping",
query: "service.name=\"api\"",
shouldPass: true,
expectedQuery: "WHERE (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?)",
expectedArgs: []any{"api", "NULL"},
expectedQuery: "WHERE (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL)",
expectedArgs: []any{"api"},
expectedErrorContains: "",
},
{
@ -1774,8 +1788,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "Nested grouping",
query: "(((service.name=\"api\")))",
shouldPass: true,
expectedQuery: "WHERE ((((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?))))",
expectedArgs: []any{"api", "NULL"},
expectedQuery: "WHERE ((((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL))))",
expectedArgs: []any{"api"},
expectedErrorContains: "",
},
{
@ -1792,8 +1806,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "Complex nested grouping",
query: "(status=200 AND (service.name=\"api\" OR service.name=\"web\"))",
shouldPass: true,
expectedQuery: "WHERE (((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) AND (((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?) OR (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?)))))",
expectedArgs: []any{float64(200), true, "api", "NULL", "web", "NULL"},
expectedQuery: "WHERE (((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) AND (((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL) OR (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL)))))",
expectedArgs: []any{float64(200), true, "api", "web"},
expectedErrorContains: "",
},
{
@ -1818,16 +1832,16 @@ func TestFilterExprLogs(t *testing.T) {
category: "Deep nesting",
query: "(((status=200 OR status=201) AND service.name=\"api\") OR ((status=202 OR status=203) AND service.name=\"web\"))",
shouldPass: true,
expectedQuery: "WHERE (((((((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) OR (toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?))) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?))) OR (((((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) OR (toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?))) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?)))))",
expectedArgs: []any{float64(200), true, float64(201), true, "api", "NULL", float64(202), true, float64(203), true, "web", "NULL"},
expectedQuery: "WHERE (((((((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) OR (toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?))) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL))) OR (((((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) OR (toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?))) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL)))))",
expectedArgs: []any{float64(200), true, float64(201), true, "api", float64(202), true, float64(203), true, "web"},
expectedErrorContains: "",
},
{
category: "Deep nesting",
query: "(count>0 AND ((duration<1000 AND service.name=\"api\") OR (duration<500 AND service.name=\"web\")))",
shouldPass: true,
expectedQuery: "WHERE (((toFloat64(attributes_number['count']) > ? AND mapContains(attributes_number, 'count') = ?) AND (((((toFloat64(attributes_number['duration']) < ? AND mapContains(attributes_number, 'duration') = ?) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?))) OR (((toFloat64(attributes_number['duration']) < ? AND mapContains(attributes_number, 'duration') = ?) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?)))))))",
expectedArgs: []any{float64(0), true, float64(1000), true, "api", "NULL", float64(500), true, "web", "NULL"},
expectedQuery: "WHERE (((toFloat64(attributes_number['count']) > ? AND mapContains(attributes_number, 'count') = ?) AND (((((toFloat64(attributes_number['duration']) < ? AND mapContains(attributes_number, 'duration') = ?) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL))) OR (((toFloat64(attributes_number['duration']) < ? AND mapContains(attributes_number, 'duration') = ?) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL)))))))",
expectedArgs: []any{float64(0), true, float64(1000), true, "api", float64(500), true, "web"},
expectedErrorContains: "",
},
@ -1836,16 +1850,16 @@ func TestFilterExprLogs(t *testing.T) {
category: "String quote styles",
query: "service.name=\"api\"",
shouldPass: true,
expectedQuery: "WHERE (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?)",
expectedArgs: []any{"api", "NULL"},
expectedQuery: "WHERE (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL)",
expectedArgs: []any{"api"},
expectedErrorContains: "",
},
{
category: "String quote styles",
query: "service.name='api'",
shouldPass: true,
expectedQuery: "WHERE (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?)",
expectedArgs: []any{"api", "NULL"},
expectedQuery: "WHERE (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL)",
expectedArgs: []any{"api"},
expectedErrorContains: "",
},
{
@ -2004,29 +2018,29 @@ func TestFilterExprLogs(t *testing.T) {
category: "Operator precedence",
query: "NOT status=200 AND service.name=\"api\"",
shouldPass: true,
expectedQuery: "WHERE (NOT ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?)) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?))",
expectedArgs: []any{float64(200), true, "api", "NULL"}, // Should be (NOT status=200) AND service.name="api"
expectedQuery: "WHERE (NOT ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?)) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL))",
expectedArgs: []any{float64(200), true, "api"}, // Should be (NOT status=200) AND service.name="api"
},
{
category: "Operator precedence",
query: "status=200 AND service.name=\"api\" OR service.name=\"web\"",
shouldPass: true,
expectedQuery: "WHERE (((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?)) OR (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?))",
expectedArgs: []any{float64(200), true, "api", "NULL", "web", "NULL"}, // Should be (status=200 AND service.name="api") OR service.name="web"
expectedQuery: "WHERE (((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL)) OR (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL))",
expectedArgs: []any{float64(200), true, "api", "web"}, // Should be (status=200 AND service.name="api") OR service.name="web"
},
{
category: "Operator precedence",
query: "NOT status=200 OR NOT service.name=\"api\"",
shouldPass: true,
expectedQuery: "WHERE (NOT ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?)) OR NOT ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?)))",
expectedArgs: []any{float64(200), true, "api", "NULL"}, // Should be (NOT status=200) OR (NOT service.name="api")
expectedQuery: "WHERE (NOT ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?)) OR NOT ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL)))",
expectedArgs: []any{float64(200), true, "api"}, // Should be (NOT status=200) OR (NOT service.name="api")
},
{
category: "Operator precedence",
query: "status=200 OR service.name=\"api\" AND level=\"ERROR\"",
shouldPass: true,
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) OR ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?) AND (attributes_string['level'] = ? AND mapContains(attributes_string, 'level') = ?)))",
expectedArgs: []any{float64(200), true, "api", "NULL", "ERROR", true}, // Should be status=200 OR (service.name="api" AND level="ERROR")
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) OR ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL) AND (attributes_string['level'] = ? AND mapContains(attributes_string, 'level') = ?)))",
expectedArgs: []any{float64(200), true, "api", "ERROR", true}, // Should be status=200 OR (service.name="api" AND level="ERROR")
},
// Different whitespace patterns
@ -2050,8 +2064,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "Whitespace patterns",
query: "status=200 AND service.name=\"api\"",
shouldPass: true,
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?))",
expectedArgs: []any{float64(200), true, "api", "NULL"}, // Multiple spaces
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL))",
expectedArgs: []any{float64(200), true, "api"}, // Multiple spaces
},
// More Unicode characters
@ -2220,8 +2234,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "More common filters",
query: "service.name=\"api\" AND (status>=500 OR duration>1000) AND NOT message CONTAINS \"expected\"",
shouldPass: true,
expectedQuery: "WHERE ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?) AND (((toFloat64(attributes_number['status']) >= ? AND mapContains(attributes_number, 'status') = ?) OR (toFloat64(attributes_number['duration']) > ? AND mapContains(attributes_number, 'duration') = ?))) AND NOT ((LOWER(attributes_string['message']) LIKE LOWER(?) AND mapContains(attributes_string, 'message') = ?)))",
expectedArgs: []any{"api", "NULL", float64(500), true, float64(1000), true, "%expected%", true},
expectedQuery: "WHERE ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL) AND (((toFloat64(attributes_number['status']) >= ? AND mapContains(attributes_number, 'status') = ?) OR (toFloat64(attributes_number['duration']) > ? AND mapContains(attributes_number, 'duration') = ?))) AND NOT ((LOWER(attributes_string['message']) LIKE LOWER(?) AND mapContains(attributes_string, 'message') = ?)))",
expectedArgs: []any{"api", float64(500), true, float64(1000), true, "%expected%", true},
},
// Edge cases
@ -2286,8 +2300,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "Unusual whitespace",
query: "status = 200 AND service.name = \"api\"",
shouldPass: true,
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?))",
expectedArgs: []any{float64(200), true, "api", "NULL"},
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL))",
expectedArgs: []any{float64(200), true, "api"},
},
{
category: "Unusual whitespace",
@ -2347,13 +2361,13 @@ func TestFilterExprLogs(t *testing.T) {
)
`,
shouldPass: true,
expectedQuery: "WHERE ((((((((toFloat64(attributes_number['status']) >= ? AND mapContains(attributes_number, 'status') = ?) AND (toFloat64(attributes_number['status']) < ? AND mapContains(attributes_number, 'status') = ?))) OR (((toFloat64(attributes_number['status']) >= ? AND mapContains(attributes_number, 'status') = ?) AND (toFloat64(attributes_number['status']) < ? AND mapContains(attributes_number, 'status') = ?) AND NOT ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?)))))) AND ((((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? OR multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? OR multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ?) AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?) OR (((multiIf(resource.`service.type` IS NOT NULL, resource.`service.type`::String, mapContains(resources_string, 'service.type'), resources_string['service.type'], NULL) = ? AND multiIf(resource.`service.type` IS NOT NULL, resource.`service.type`::String, mapContains(resources_string, 'service.type'), resources_string['service.type'], NULL) <> ?) AND NOT ((multiIf(resource.`service.deprecated` IS NOT NULL, resource.`service.deprecated`::String, mapContains(resources_string, 'service.deprecated'), resources_string['service.deprecated'], NULL) = ? AND multiIf(resource.`service.deprecated` IS NOT NULL, resource.`service.deprecated`::String, mapContains(resources_string, 'service.deprecated'), resources_string['service.deprecated'], NULL) <> ?)))))))) AND (((((toFloat64(attributes_number['duration']) < ? AND mapContains(attributes_number, 'duration') = ?) OR ((toFloat64(attributes_number['duration']) BETWEEN ? AND ? AND mapContains(attributes_number, 'duration') = ?)))) AND ((multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) <> ? OR (((multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) = ? AND multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) <> ?) AND (attributes_bool['is_automated_test'] = ? AND mapContains(attributes_bool, 'is_automated_test') = ?))))))) AND NOT ((((((LOWER(attributes_string['message']) LIKE LOWER(?) AND mapContains(attributes_string, 'message') = ?) OR (LOWER(attributes_string['message']) LIKE LOWER(?) AND mapContains(attributes_string, 'message') = ?))) AND (attributes_string['severity'] = ? AND mapContains(attributes_string, 'severity') = ?)))))",
expectedQuery: "WHERE ((((((((toFloat64(attributes_number['status']) >= ? AND mapContains(attributes_number, 'status') = ?) AND (toFloat64(attributes_number['status']) < ? AND mapContains(attributes_number, 'status') = ?))) OR (((toFloat64(attributes_number['status']) >= ? AND mapContains(attributes_number, 'status') = ?) AND (toFloat64(attributes_number['status']) < ? AND mapContains(attributes_number, 'status') = ?) AND NOT ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status') = ?)))))) AND ((((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? OR multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? OR multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ?) AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL) OR (((multiIf(resource.`service.type` IS NOT NULL, resource.`service.type`::String, mapContains(resources_string, 'service.type'), resources_string['service.type'], NULL) = ? AND multiIf(resource.`service.type` IS NOT NULL, resource.`service.type`::String, mapContains(resources_string, 'service.type'), resources_string['service.type'], NULL) IS NOT NULL) AND NOT ((multiIf(resource.`service.deprecated` IS NOT NULL, resource.`service.deprecated`::String, mapContains(resources_string, 'service.deprecated'), resources_string['service.deprecated'], NULL) = ? AND multiIf(resource.`service.deprecated` IS NOT NULL, resource.`service.deprecated`::String, mapContains(resources_string, 'service.deprecated'), resources_string['service.deprecated'], NULL) IS NOT NULL)))))))) AND (((((toFloat64(attributes_number['duration']) < ? AND mapContains(attributes_number, 'duration') = ?) OR ((toFloat64(attributes_number['duration']) BETWEEN ? AND ? AND mapContains(attributes_number, 'duration') = ?)))) AND ((multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) <> ? OR (((multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) = ? AND multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) IS NOT NULL) AND (attributes_bool['is_automated_test'] = ? AND mapContains(attributes_bool, 'is_automated_test') = ?))))))) AND NOT ((((((LOWER(attributes_string['message']) LIKE LOWER(?) AND mapContains(attributes_string, 'message') = ?) OR (LOWER(attributes_string['message']) LIKE LOWER(?) AND mapContains(attributes_string, 'message') = ?))) AND (attributes_string['severity'] = ? AND mapContains(attributes_string, 'severity') = ?)))))",
expectedArgs: []any{
float64(200), true, float64(300), true, float64(400), true, float64(500), true, float64(404), true,
"api", "web", "auth", "NULL",
"internal", "NULL", true, "NULL",
"api", "web", "auth",
"internal", true,
float64(1000), true, float64(1000), float64(5000), true,
"test", "test", "NULL", true, true,
"test", "test", true, true,
"%warning%", true, "%deprecated%", true,
"low", true,
},

View File

@ -69,8 +69,8 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), "NULL", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10, "NULL", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
expectedErr: nil,
},
@ -98,8 +98,8 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE ((simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) OR true) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?) OR (attributes_string['http.method'] = ? AND mapContains(attributes_string, 'http.method') = ?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?) OR (attributes_string['http.method'] = ? AND mapContains(attributes_string, 'http.method') = ?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "NULL", "redis-manual", "NULL", "GET", true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10, "NULL", "redis-manual", "NULL", "GET", true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE ((simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) OR true) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL) OR (attributes_string['http.method'] = ? AND mapContains(attributes_string, 'http.method') = ?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL) OR (attributes_string['http.method'] = ? AND mapContains(attributes_string, 'http.method') = ?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "redis-manual", "GET", true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10, "redis-manual", "GET", true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
expectedErr: nil,
},
@ -137,8 +137,8 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY `service.name` desc LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name` ORDER BY `service.name` desc, ts desc",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), "NULL", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10, "NULL", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY `service.name` desc LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name` ORDER BY `service.name` desc, ts desc",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
expectedErr: nil,
},
@ -488,3 +488,101 @@ func TestStatementBuilderTimeSeriesBodyGroupBy(t *testing.T) {
})
}
}
func TestStatementBuilderListQueryServiceCollision(t *testing.T) {
cases := []struct {
name string
requestType qbtypes.RequestType
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
expected qbtypes.Statement
expectedErr error
expectWarn bool
}{
{
name: "default list",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{
Expression: "(service.name = 'cartservice' AND body CONTAINS 'error')",
},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (((simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND true)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((LOWER(body) LIKE LOWER(?))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), "%error%", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
expectWarn: true,
},
{
name: "list query with mat col order by",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{
Expression: "service.name = 'cartservice' AND body CONTAINS 'error'",
},
Limit: 10,
Order: []qbtypes.OrderBy{
{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "materialized.key.name",
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
Direction: qbtypes.OrderDirectionDesc,
},
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE ((simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND true) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (LOWER(body) LIKE LOWER(?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY `attribute_string_materialized$$key$$name` AS `materialized.key.name` desc LIMIT ?",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), "%error%", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
expectWarn: true,
},
}
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = buildCompleteFieldKeyMapCollision()
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, "", nil)
resourceFilterStmtBuilder := resourceFilterStmtBuilder()
statementBuilder := NewLogQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
fm,
cb,
resourceFilterStmtBuilder,
aggExprRewriter,
DefaultFullTextColumn,
BodyJSONStringSearchPrefix,
GetBodyJSONKey,
)
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
if c.expectedErr != nil {
require.Error(t, err)
require.Contains(t, err.Error(), c.expectedErr.Error())
} else {
require.NoError(t, err)
require.Equal(t, c.expected.Query, q.Query)
require.Equal(t, c.expected.Args, q.Args)
if c.expectWarn {
require.True(t, len(q.Warnings) > 0)
}
}
})
}
}

View File

@ -899,3 +899,55 @@ func buildCompleteFieldKeyMap() map[string][]*telemetrytypes.TelemetryFieldKey {
}
return keysMap
}
func buildCompleteFieldKeyMapCollision() map[string][]*telemetrytypes.TelemetryFieldKey {
keysMap := map[string][]*telemetrytypes.TelemetryFieldKey{
"service.name": {
{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
"body": {
{
Name: "body",
FieldContext: telemetrytypes.FieldContextLog,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
"error.code": {
{
Name: "error.code",
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeInt64,
},
},
"environment": {
{
Name: "environment",
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
"user.id": {
{
Name: "user.id",
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
}
for _, keys := range keysMap {
for _, key := range keys {
key.Signal = telemetrytypes.SignalLogs
}
}
return keysMap
}

View File

@ -164,11 +164,10 @@ func (c *conditionBuilder) conditionFor(
var value any
switch column.Type {
case schema.JSONColumnType{}:
value = "NULL"
if operator == qbtypes.FilterOperatorExists {
return sb.NE(tblFieldName, value), nil
return sb.IsNotNull(tblFieldName), nil
} else {
return sb.E(tblFieldName, value), nil
return sb.IsNull(tblFieldName), nil
}
case schema.ColumnTypeString,
schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString},

View File

@ -207,6 +207,30 @@ func TestConditionFor(t *testing.T) {
expectedSQL: "mapContains(attributes_string, 'user.id') <> ?",
expectedError: nil,
},
{
name: "Exists operator - json field",
key: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
operator: qbtypes.FilterOperatorExists,
value: nil,
expectedSQL: "WHERE multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL",
expectedError: nil,
},
{
name: "Not Exists operator - json field",
key: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
operator: qbtypes.FilterOperatorNotExists,
value: nil,
expectedSQL: "WHERE multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NULL",
expectedError: nil,
},
{
name: "Contains operator - map field",
key: telemetrytypes.TelemetryFieldKey{

View File

@ -68,12 +68,14 @@ func TestGetFieldKeyName(t *testing.T) {
expectedError: nil,
},
{
name: "Map column type - resource attribute - legacy",
name: "Map column type - resource attribute - materialized",
key: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
Name: "deployment.environment",
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
Materialized: true,
},
expectedResult: "multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)",
expectedResult: "multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, `resource_string_deployment$$environment_exists`==true, `resource_string_deployment$$environment`, NULL)",
expectedError: nil,
},
{

View File

@ -60,8 +60,8 @@ func TestStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "NULL", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "NULL", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
},
expectedErr: nil,
},
@ -89,8 +89,8 @@ func TestStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE ((simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) OR true) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?) OR (attributes_string['http.request.method'] = ? AND mapContains(attributes_string, 'http.request.method') = ?)) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?) OR (attributes_string['http.request.method'] = ? AND mapContains(attributes_string, 'http.request.method') = ?)) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "NULL", "redis-manual", "NULL", "GET", true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "NULL", "redis-manual", "NULL", "GET", true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE ((simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) OR true) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL) OR (attributes_string['http.request.method'] = ? AND mapContains(attributes_string, 'http.request.method') = ?)) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL) OR (attributes_string['http.request.method'] = ? AND mapContains(attributes_string, 'http.request.method') = ?)) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "redis-manual", "GET", true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "redis-manual", "GET", true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
},
expectedErr: nil,
},
@ -187,8 +187,8 @@ func TestStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, sum(multiIf(mapContains(attributes_number, 'metric.max_count') = ?, toFloat64(attributes_number['metric.max_count']), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, sum(multiIf(mapContains(attributes_number, 'metric.max_count') = ?, toFloat64(attributes_number['metric.max_count']), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "NULL", true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "NULL", true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, sum(multiIf(mapContains(attributes_number, 'metric.max_count') = ?, toFloat64(attributes_number['metric.max_count']), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, sum(multiIf(mapContains(attributes_number, 'metric.max_count') = ?, toFloat64(attributes_number['metric.max_count']), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
},
expectedErr: nil,
},
@ -216,8 +216,8 @@ func TestStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, sum(multiIf(`attribute_number_cart$$items_count_exists` = ?, toFloat64(`attribute_number_cart$$items_count`), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, sum(multiIf(`attribute_number_cart$$items_count_exists` = ?, toFloat64(`attribute_number_cart$$items_count`), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "NULL", true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "NULL", true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, sum(multiIf(`attribute_number_cart$$items_count_exists` = ?, toFloat64(`attribute_number_cart$$items_count`), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, sum(multiIf(`attribute_number_cart$$items_count_exists` = ?, toFloat64(`attribute_number_cart$$items_count`), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
},
expectedErr: nil,
},
@ -255,8 +255,8 @@ func TestStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, sum(multiIf(`attribute_number_cart$$items_count_exists` = ?, toFloat64(`attribute_number_cart$$items_count`), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY `service.name` desc LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, sum(multiIf(`attribute_number_cart$$items_count_exists` = ?, toFloat64(`attribute_number_cart$$items_count`), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name` ORDER BY `service.name` desc, ts desc",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "NULL", true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "NULL", true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, sum(multiIf(`attribute_number_cart$$items_count_exists` = ?, toFloat64(`attribute_number_cart$$items_count`), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY `service.name` desc LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, sum(multiIf(`attribute_number_cart$$items_count_exists` = ?, toFloat64(`attribute_number_cart$$items_count`), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name` ORDER BY `service.name` desc, ts desc",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
},
expectedErr: nil,
},

View File

@ -263,8 +263,8 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND true), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND true), A_DIR_DESC_B AS (SELECT p.* FROM A AS p INNER JOIN B AS c ON p.trace_id = c.trace_id AND p.span_id = c.parent_span_id) SELECT toStartOfInterval(timestamp, INTERVAL 60 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM A_DIR_DESC_B GROUP BY ts, `service.name` ORDER BY ts desc SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "backend", "%service.name%", "%service.name\":\"backend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "NULL"},
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND true), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND true), A_DIR_DESC_B AS (SELECT p.* FROM A AS p INNER JOIN B AS c ON p.trace_id = c.trace_id AND p.span_id = c.parent_span_id) SELECT toStartOfInterval(timestamp, INTERVAL 60 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM A_DIR_DESC_B GROUP BY ts, `service.name` ORDER BY ts desc SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "backend", "%service.name%", "%service.name\":\"backend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
},
expectedErr: nil,
},
@ -322,8 +322,8 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND true), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND toFloat64(response_status_code) < ?), A_AND_B AS (SELECT l.* FROM A AS l INNER JOIN B AS r ON l.trace_id = r.trace_id) SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) <> ?, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, avg(multiIf(duration_nano <> ?, duration_nano, NULL)) AS __result_0 FROM A_AND_B GROUP BY `service.name` ORDER BY __result_0 desc SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), float64(400), "NULL", 0},
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND true), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND toFloat64(response_status_code) < ?), A_AND_B AS (SELECT l.* FROM A AS l INNER JOIN B AS r ON l.trace_id = r.trace_id) SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, avg(multiIf(duration_nano <> ?, duration_nano, NULL)) AS __result_0 FROM A_AND_B GROUP BY `service.name` ORDER BY __result_0 desc SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), float64(400), 0},
},
expectedErr: nil,
},

View File

@ -27,6 +27,8 @@ type (
// An alias for the Alert type from the alertmanager package.
Alert = types.Alert
AlertSlice = types.AlertSlice
PostableAlert = models.PostableAlert
PostableAlerts = models.PostableAlerts
@ -38,6 +40,10 @@ type (
GettableAlerts = models.GettableAlerts
)
const (
NoDataLabel = model.LabelName("nodata")
)
type DeprecatedGettableAlert struct {
*model.Alert
Status types.AlertStatus `json:"status"`
@ -307,3 +313,11 @@ func receiversMatchFilter(receivers []string, filter *regexp.Regexp) bool {
return false
}
func NoDataAlert(alert *types.Alert) bool {
if _, ok := alert.Labels[NoDataLabel]; ok {
return true
} else {
return false
}
}

View File

@ -21,6 +21,7 @@ import (
const (
DefaultReceiverName string = "default-receiver"
DefaultGroupBy string = "ruleId"
DefaultGroupByAll string = "__all__"
)
var (
@ -193,6 +194,20 @@ func (c *Config) SetRouteConfig(routeConfig RouteConfig) error {
return nil
}
func (c *Config) AddInhibitRules(rules []config.InhibitRule) error {
if c.alertmanagerConfig == nil {
return errors.New(errors.TypeInvalidInput, ErrCodeAlertmanagerConfigInvalid, "config is nil")
}
c.alertmanagerConfig.InhibitRules = append(c.alertmanagerConfig.InhibitRules, rules...)
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
c.storeableConfig.UpdatedAt = time.Now()
return nil
}
func (c *Config) AlertmanagerConfig() *config.Config {
return c.alertmanagerConfig
}
@ -304,6 +319,27 @@ func (c *Config) CreateRuleIDMatcher(ruleID string, receiverNames []string) erro
return nil
}
func (c *Config) DeleteRuleIDInhibitor(ruleID string) error {
if c.alertmanagerConfig.InhibitRules == nil {
return nil // already nil
}
var filteredRules []config.InhibitRule
for _, inhibitor := range c.alertmanagerConfig.InhibitRules {
sourceContainsRuleID := matcherContainsRuleID(inhibitor.SourceMatchers, ruleID)
targetContainsRuleID := matcherContainsRuleID(inhibitor.TargetMatchers, ruleID)
if !sourceContainsRuleID && !targetContainsRuleID {
filteredRules = append(filteredRules, inhibitor)
}
}
c.alertmanagerConfig.InhibitRules = filteredRules
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
c.storeableConfig.UpdatedAt = time.Now()
return nil
}
func (c *Config) UpdateRuleIDMatcher(ruleID string, receiverNames []string) error {
err := c.DeleteRuleIDMatcher(ruleID)
if err != nil {
@ -405,6 +441,8 @@ func init() {
type NotificationConfig struct {
NotificationGroup map[model.LabelName]struct{}
Renotify ReNotificationConfig
UsePolicy bool
GroupByAll bool
}
func (nc *NotificationConfig) DeepCopy() NotificationConfig {
@ -415,6 +453,7 @@ func (nc *NotificationConfig) DeepCopy() NotificationConfig {
for k, v := range nc.NotificationGroup {
deepCopy.NotificationGroup[k] = v
}
deepCopy.UsePolicy = nc.UsePolicy
return deepCopy
}
@ -423,7 +462,7 @@ type ReNotificationConfig struct {
RenotifyInterval time.Duration
}
func NewNotificationConfig(groups []string, renotifyInterval time.Duration, noDataRenotifyInterval time.Duration) NotificationConfig {
func NewNotificationConfig(groups []string, renotifyInterval time.Duration, noDataRenotifyInterval time.Duration, policy bool) NotificationConfig {
notificationConfig := GetDefaultNotificationConfig()
if renotifyInterval != 0 {
@ -435,7 +474,12 @@ func NewNotificationConfig(groups []string, renotifyInterval time.Duration, noDa
}
for _, group := range groups {
notificationConfig.NotificationGroup[model.LabelName(group)] = struct{}{}
if group == DefaultGroupByAll {
notificationConfig.GroupByAll = true
}
}
notificationConfig.UsePolicy = policy
return notificationConfig
}

View File

@ -0,0 +1,139 @@
package alertmanagertypes
import (
"context"
"github.com/expr-lang/expr"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
type PostableRoutePolicy struct {
Expression string `json:"expression"`
ExpressionKind ExpressionKind `json:"kind"`
Channels []string `json:"channels"`
Name string `json:"name"`
Description string `json:"description"`
Tags []string `json:"tags,omitempty"`
}
func (p *PostableRoutePolicy) Validate() error {
if p.Expression == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "expression is required")
}
if p.Name == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "name is required")
}
if len(p.Channels) == 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "at least one channel is required")
}
// Validate channels are not empty
for i, channel := range p.Channels {
if channel == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "channel at index %d cannot be empty", i)
}
}
if p.ExpressionKind != PolicyBasedExpression && p.ExpressionKind != RuleBasedExpression {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported expression kind: %s", p.ExpressionKind.StringValue())
}
_, err := expr.Compile(p.Expression)
if err != nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid expression syntax: %v", err)
}
return nil
}
type GettableRoutePolicy struct {
PostableRoutePolicy // Embedded
ID string `json:"id"`
// Audit fields
CreatedAt *time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
CreatedBy *string `json:"createdBy"`
UpdatedBy *string `json:"updatedBy"`
}
type ExpressionKind struct {
valuer.String
}
var (
RuleBasedExpression = ExpressionKind{valuer.NewString("rule")}
PolicyBasedExpression = ExpressionKind{valuer.NewString("policy")}
)
// RoutePolicy represents the database model for expression routes
type RoutePolicy struct {
bun.BaseModel `bun:"table:route_policy"`
types.Identifiable
types.TimeAuditable
types.UserAuditable
Expression string `bun:"expression,type:text,notnull" json:"expression"`
ExpressionKind ExpressionKind `bun:"kind,type:text" json:"kind"`
Channels []string `bun:"channels,type:jsonb" json:"channels"`
Name string `bun:"name,type:text" json:"name"`
Description string `bun:"description,type:text" json:"description"`
Enabled bool `bun:"enabled,type:boolean,default:true" json:"enabled"`
Tags []string `bun:"tags,type:jsonb" json:"tags,omitempty"`
OrgID string `bun:"org_id,type:text,notnull" json:"orgId"`
}
func (er *RoutePolicy) Validate() error {
if er == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "route_policy cannot be nil")
}
if er.Expression == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "expression is required")
}
if er.Name == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "name is required")
}
if er.OrgID == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "organization ID is required")
}
if len(er.Channels) == 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "at least one channel is required")
}
// Validate channels are not empty
for i, channel := range er.Channels {
if channel == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "channel at index %d cannot be empty", i)
}
}
if er.ExpressionKind != PolicyBasedExpression && er.ExpressionKind != RuleBasedExpression {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported expression kind: %s", er.ExpressionKind.StringValue())
}
return nil
}
type RouteStore interface {
GetByID(ctx context.Context, orgId string, id string) (*RoutePolicy, error)
Create(ctx context.Context, route *RoutePolicy) error
CreateBatch(ctx context.Context, routes []*RoutePolicy) error
Delete(ctx context.Context, orgId string, id string) error
GetAllByKind(ctx context.Context, orgID string, kind ExpressionKind) ([]*RoutePolicy, error)
GetAllByName(ctx context.Context, orgID string, name string) ([]*RoutePolicy, error)
DeleteRouteByName(ctx context.Context, orgID string, name string) error
}

Some files were not shown because too many files have changed in this diff Show More