mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-29 16:14:42 +00:00
Merge branch 'main' into tvats-custom-ttl-for-attributes
This commit is contained in:
commit
690b4c91b1
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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] = ""
|
||||
|
||||
|
||||
26
frontend/src/api/alerts/updateAlertRule.ts
Normal file
26
frontend/src/api/alerts/updateAlertRule.ts
Normal 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;
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
@ -69,23 +58,4 @@ function EvaluationSettings(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -31,6 +31,9 @@ export const createMockAlertContextState = (
|
||||
isCreatingAlertRule: false,
|
||||
isTestingAlertRule: false,
|
||||
createAlertRule: jest.fn(),
|
||||
isUpdatingAlertRule: false,
|
||||
updateAlertRule: jest.fn(),
|
||||
isEditMode: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ export interface IAdvancedOptionItemProps {
|
||||
input: JSX.Element;
|
||||
tooltipText?: string;
|
||||
onToggle?: () => void;
|
||||
defaultShowInput: boolean;
|
||||
}
|
||||
|
||||
export enum RollingWindowTimeframes {
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 />);
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
358
frontend/src/container/CreateAlertV2/__tests__/utils.test.tsx
Normal file
358
frontend/src/container/CreateAlertV2/__tests__/utils.test.tsx
Normal 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),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
74
frontend/src/container/CreateAlertV2/constants.ts
Normal file
74
frontend/src/container/CreateAlertV2/constants.ts
Normal 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,
|
||||
};
|
||||
@ -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: [],
|
||||
},
|
||||
|
||||
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@ -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' };
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
52
frontend/src/container/EditAlertV2/EditAlertV2.tsx
Normal file
52
frontend/src/container/EditAlertV2/EditAlertV2.tsx
Normal 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;
|
||||
3
frontend/src/container/EditAlertV2/index.ts
Normal file
3
frontend/src/container/EditAlertV2/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import EditAlertV2 from './EditAlertV2';
|
||||
|
||||
export default EditAlertV2;
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
{...{
|
||||
|
||||
@ -88,6 +88,7 @@ function RoutingPolicies(): JSX.Element {
|
||||
isRoutingPoliciesError={isErrorRoutingPolicies}
|
||||
handlePolicyDetailsModalOpen={handlePolicyDetailsModalOpen}
|
||||
handleDeleteModalOpen={handleDeleteModalOpen}
|
||||
hasSearchTerm={(searchTerm?.length ?? 0) > 0}
|
||||
/>
|
||||
{policyDetailsModalState.isOpen && (
|
||||
<RoutingPolicyDetails
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -37,6 +37,7 @@ export interface RoutingPolicyListProps {
|
||||
isRoutingPoliciesError: boolean;
|
||||
handlePolicyDetailsModalOpen: HandlePolicyDetailsModalOpen;
|
||||
handleDeleteModalOpen: HandleDeleteModalOpen;
|
||||
hasSearchTerm: boolean;
|
||||
}
|
||||
|
||||
export interface RoutingPolicyListItemProps {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
22
frontend/src/hooks/alerts/useUpdateAlertRule.ts
Normal file
22
frontend/src/hooks/alerts/useUpdateAlertRule.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'] = [
|
||||
{
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -13,6 +13,7 @@ export interface GettableAlert extends AlertDef {
|
||||
createBy: string;
|
||||
updateAt: string;
|
||||
updateBy: string;
|
||||
schemaVersion: string;
|
||||
}
|
||||
|
||||
export type PayloadProps = {
|
||||
|
||||
4
go.mod
4
go.mod
@ -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
4
go.sum
@ -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=
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
@ -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...)
|
||||
}
|
||||
|
||||
|
||||
223
pkg/alertmanager/alertmanagerserver/server_e2e_test.go
Normal file
223
pkg/alertmanager/alertmanagerserver/server_e2e_test.go
Normal 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", ¬ifConfig)
|
||||
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)
|
||||
})
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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{}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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] = ""
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
260
pkg/sqlmigration/049_add_route_policy.go
Normal file
260
pkg/sqlmigration/049_add_route_policy.go
Normal 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
|
||||
}
|
||||
@ -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 = ""
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
139
pkg/types/alertmanagertypes/expressionroute.go
Normal file
139
pkg/types/alertmanagertypes/expressionroute.go
Normal 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
Loading…
x
Reference in New Issue
Block a user