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
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
results, err := r.Threshold.ShouldAlert(*series)
|
results, err := r.Threshold.ShouldAlert(*series, r.Unit())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -301,7 +301,7 @@ func (r *AnomalyRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
results, err := r.Threshold.ShouldAlert(*series)
|
results, err := r.Threshold.ShouldAlert(*series, r.Unit())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -336,14 +336,19 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro
|
|||||||
resultFPs := map[uint64]struct{}{}
|
resultFPs := map[uint64]struct{}{}
|
||||||
var alerts = make(map[uint64]*ruletypes.Alert, len(res))
|
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 {
|
for _, smpl := range res {
|
||||||
l := make(map[string]string, len(smpl.Metric))
|
l := make(map[string]string, len(smpl.Metric))
|
||||||
for _, lbl := range smpl.Metric {
|
for _, lbl := range smpl.Metric {
|
||||||
l[lbl.Name] = lbl.Value
|
l[lbl.Name] = lbl.Value
|
||||||
}
|
}
|
||||||
|
|
||||||
value := valueFormatter.Format(smpl.V, r.Unit())
|
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)
|
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)
|
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,
|
State: model.StatePending,
|
||||||
Value: smpl.V,
|
Value: smpl.V,
|
||||||
GeneratorURL: r.GeneratorURL(),
|
GeneratorURL: r.GeneratorURL(),
|
||||||
Receivers: r.PreferredChannels(),
|
Receivers: ruleReceiverMap[lbs.Map()[ruletypes.LabelThresholdName]],
|
||||||
Missing: smpl.IsMissing,
|
Missing: smpl.IsMissing,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
r.logger.InfoContext(ctx, "number of alerts found", "rule_name", r.Name(), "alerts_count", len(alerts))
|
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
|
// alerts[h] is ready, add or update active list now
|
||||||
for h, a := range alerts {
|
for h, a := range alerts {
|
||||||
// Check whether we already have alerting state for the identifying label set.
|
// 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.Value = a.Value
|
||||||
alert.Annotations = a.Annotations
|
alert.Annotations = a.Annotations
|
||||||
alert.Receivers = r.PreferredChannels()
|
if v, ok := alert.Labels.Map()[ruletypes.LabelThresholdName]; ok {
|
||||||
|
alert.Receivers = ruleReceiverMap[v]
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -126,7 +126,6 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
|
|||||||
if parsedRule.RuleType == ruletypes.RuleTypeThreshold {
|
if parsedRule.RuleType == ruletypes.RuleTypeThreshold {
|
||||||
|
|
||||||
// add special labels for test alerts
|
// 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.RuleSourceLabel] = ""
|
||||||
parsedRule.Labels[labels.AlertRuleIdLabel] = ""
|
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 {
|
export interface CreateRoutingPolicyBody {
|
||||||
name: string;
|
name: string;
|
||||||
expression: string;
|
expression: string;
|
||||||
actions: {
|
channels: string[];
|
||||||
channels: string[];
|
|
||||||
};
|
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -23,7 +21,7 @@ const createRoutingPolicy = async (
|
|||||||
SuccessResponseV2<CreateRoutingPolicyResponse> | ErrorResponseV2
|
SuccessResponseV2<CreateRoutingPolicyResponse> | ErrorResponseV2
|
||||||
> => {
|
> => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(`/notification-policy`, props);
|
const response = await axios.post(`/route_policies`, props);
|
||||||
return {
|
return {
|
||||||
httpStatusCode: response.status,
|
httpStatusCode: response.status,
|
||||||
data: response.data,
|
data: response.data,
|
||||||
|
|||||||
@ -14,9 +14,7 @@ const deleteRoutingPolicy = async (
|
|||||||
SuccessResponseV2<DeleteRoutingPolicyResponse> | ErrorResponseV2
|
SuccessResponseV2<DeleteRoutingPolicyResponse> | ErrorResponseV2
|
||||||
> => {
|
> => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.delete(
|
const response = await axios.delete(`/route_policies/${routingPolicyId}`);
|
||||||
`/notification-policy/${routingPolicyId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
httpStatusCode: response.status,
|
httpStatusCode: response.status,
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export const getRoutingPolicies = async (
|
|||||||
headers?: Record<string, string>,
|
headers?: Record<string, string>,
|
||||||
): Promise<SuccessResponseV2<GetRoutingPoliciesResponse> | ErrorResponseV2> => {
|
): Promise<SuccessResponseV2<GetRoutingPoliciesResponse> | ErrorResponseV2> => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('/notification-policy', {
|
const response = await axios.get('/route_policies', {
|
||||||
signal,
|
signal,
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,9 +6,7 @@ import { ErrorResponseV2, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
|||||||
export interface UpdateRoutingPolicyBody {
|
export interface UpdateRoutingPolicyBody {
|
||||||
name: string;
|
name: string;
|
||||||
expression: string;
|
expression: string;
|
||||||
actions: {
|
channels: string[];
|
||||||
channels: string[];
|
|
||||||
};
|
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,7 +22,7 @@ const updateRoutingPolicy = async (
|
|||||||
SuccessResponseV2<UpdateRoutingPolicyResponse> | ErrorResponseV2
|
SuccessResponseV2<UpdateRoutingPolicyResponse> | ErrorResponseV2
|
||||||
> => {
|
> => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.put(`/notification-policy/${id}`, {
|
const response = await axios.put(`/route_policies/${id}`, {
|
||||||
...props,
|
...props,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import logEvent from 'api/common/logEvent';
|
|||||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||||
import { QueryParams } from 'constants/query';
|
import { QueryParams } from 'constants/query';
|
||||||
import CreateAlertV2 from 'container/CreateAlertV2';
|
import CreateAlertV2 from 'container/CreateAlertV2';
|
||||||
import { showNewCreateAlertsPage } from 'container/CreateAlertV2/utils';
|
|
||||||
import FormAlertRules, { AlertDetectionTypes } from 'container/FormAlertRules';
|
import FormAlertRules, { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||||
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
|
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
|
||||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||||
@ -127,7 +126,8 @@ function CreateRules(): JSX.Element {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const showNewCreateAlertsPageFlag = showNewCreateAlertsPage();
|
const showNewCreateAlertsPageFlag =
|
||||||
|
queryParams.get('showNewCreateAlertsPage') === 'true';
|
||||||
|
|
||||||
if (
|
if (
|
||||||
showNewCreateAlertsPageFlag &&
|
showNewCreateAlertsPageFlag &&
|
||||||
|
|||||||
@ -13,14 +13,12 @@ import APIError from 'types/api/error';
|
|||||||
import { useCreateAlertState } from '../context';
|
import { useCreateAlertState } from '../context';
|
||||||
import AdvancedOptions from '../EvaluationSettings/AdvancedOptions';
|
import AdvancedOptions from '../EvaluationSettings/AdvancedOptions';
|
||||||
import Stepper from '../Stepper';
|
import Stepper from '../Stepper';
|
||||||
import { showCondensedLayout } from '../utils';
|
|
||||||
import AlertThreshold from './AlertThreshold';
|
import AlertThreshold from './AlertThreshold';
|
||||||
import AnomalyThreshold from './AnomalyThreshold';
|
import AnomalyThreshold from './AnomalyThreshold';
|
||||||
import { ANOMALY_TAB_TOOLTIP, THRESHOLD_TAB_TOOLTIP } from './constants';
|
import { ANOMALY_TAB_TOOLTIP, THRESHOLD_TAB_TOOLTIP } from './constants';
|
||||||
|
|
||||||
function AlertCondition(): JSX.Element {
|
function AlertCondition(): JSX.Element {
|
||||||
const { alertType, setAlertType } = useCreateAlertState();
|
const { alertType, setAlertType } = useCreateAlertState();
|
||||||
const showCondensedLayoutFlag = showCondensedLayout();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@ -108,11 +106,9 @@ function AlertCondition(): JSX.Element {
|
|||||||
refreshChannels={refreshChannels}
|
refreshChannels={refreshChannels}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showCondensedLayoutFlag ? (
|
<div className="condensed-advanced-options-container">
|
||||||
<div className="condensed-advanced-options-container">
|
<AdvancedOptions />
|
||||||
<AdvancedOptions />
|
</div>
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,10 @@ import '../EvaluationSettings/styles.scss';
|
|||||||
import { Button, Select, Tooltip, Typography } from 'antd';
|
import { Button, Select, Tooltip, Typography } from 'antd';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
|
import getRandomColor from 'lib/getRandomColor';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { v4 } from 'uuid';
|
||||||
|
|
||||||
import { useCreateAlertState } from '../context';
|
import { useCreateAlertState } from '../context';
|
||||||
import {
|
import {
|
||||||
@ -15,7 +18,6 @@ import {
|
|||||||
THRESHOLD_OPERATOR_OPTIONS,
|
THRESHOLD_OPERATOR_OPTIONS,
|
||||||
} from '../context/constants';
|
} from '../context/constants';
|
||||||
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
|
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
|
||||||
import { showCondensedLayout } from '../utils';
|
|
||||||
import ThresholdItem from './ThresholdItem';
|
import ThresholdItem from './ThresholdItem';
|
||||||
import { AnomalyAndThresholdProps, UpdateThreshold } from './types';
|
import { AnomalyAndThresholdProps, UpdateThreshold } from './types';
|
||||||
import {
|
import {
|
||||||
@ -40,12 +42,23 @@ function AlertThreshold({
|
|||||||
setNotificationSettings,
|
setNotificationSettings,
|
||||||
} = useCreateAlertState();
|
} = useCreateAlertState();
|
||||||
|
|
||||||
const showCondensedLayoutFlag = showCondensedLayout();
|
|
||||||
|
|
||||||
const { currentQuery } = useQueryBuilder();
|
const { currentQuery } = useQueryBuilder();
|
||||||
|
|
||||||
const queryNames = getQueryNames(currentQuery);
|
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 selectedCategory = getCategoryByOptionId(alertState.yAxisUnit || '');
|
||||||
const categorySelectOptions = getCategorySelectOptionByName(
|
const categorySelectOptions = getCategorySelectOptionByName(
|
||||||
selectedCategory || '',
|
selectedCategory || '',
|
||||||
@ -54,11 +67,15 @@ function AlertThreshold({
|
|||||||
const addThreshold = (): void => {
|
const addThreshold = (): void => {
|
||||||
let newThreshold;
|
let newThreshold;
|
||||||
if (thresholdState.thresholds.length === 1) {
|
if (thresholdState.thresholds.length === 1) {
|
||||||
newThreshold = INITIAL_WARNING_THRESHOLD;
|
newThreshold = { ...INITIAL_WARNING_THRESHOLD, id: v4() };
|
||||||
} else if (thresholdState.thresholds.length === 2) {
|
} else if (thresholdState.thresholds.length === 2) {
|
||||||
newThreshold = INITIAL_INFO_THRESHOLD;
|
newThreshold = { ...INITIAL_INFO_THRESHOLD, id: v4() };
|
||||||
} else {
|
} else {
|
||||||
newThreshold = INITIAL_RANDOM_THRESHOLD;
|
newThreshold = {
|
||||||
|
...INITIAL_RANDOM_THRESHOLD,
|
||||||
|
id: v4(),
|
||||||
|
color: getRandomColor(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
setThresholdState({
|
setThresholdState({
|
||||||
type: 'SET_THRESHOLDS',
|
type: 'SET_THRESHOLDS',
|
||||||
@ -143,17 +160,12 @@ function AlertThreshold({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const evaluationWindowContext = showCondensedLayoutFlag ? (
|
|
||||||
<EvaluationSettings />
|
|
||||||
) : (
|
|
||||||
<strong>Evaluation Window.</strong>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames('alert-threshold-container', {
|
className={classNames(
|
||||||
'condensed-alert-threshold-container': showCondensedLayoutFlag,
|
'alert-threshold-container',
|
||||||
})}
|
'condensed-alert-threshold-container',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{/* Main condition sentence */}
|
{/* Main condition sentence */}
|
||||||
<div className="alert-condition-sentences">
|
<div className="alert-condition-sentences">
|
||||||
@ -199,7 +211,7 @@ function AlertThreshold({
|
|||||||
options={matchTypeOptionsWithTooltips}
|
options={matchTypeOptionsWithTooltips}
|
||||||
/>
|
/>
|
||||||
<Typography.Text className="sentence-text">
|
<Typography.Text className="sentence-text">
|
||||||
during the {evaluationWindowContext}
|
during the <EvaluationSettings />
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
</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 = {
|
const TEST_STRINGS = {
|
||||||
ADD_THRESHOLD: 'Add Threshold',
|
ADD_THRESHOLD: 'Add Threshold',
|
||||||
AT_LEAST_ONCE: 'AT LEAST ONCE',
|
AT_LEAST_ONCE: 'AT LEAST ONCE',
|
||||||
@ -154,7 +158,9 @@ describe('AlertThreshold', () => {
|
|||||||
expect(screen.getByText('Send a notification when')).toBeInTheDocument();
|
expect(screen.getByText('Send a notification when')).toBeInTheDocument();
|
||||||
expect(screen.getByText('the threshold(s)')).toBeInTheDocument();
|
expect(screen.getByText('the threshold(s)')).toBeInTheDocument();
|
||||||
expect(screen.getByText('during the')).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 () => {
|
it('renders query selection dropdown', async () => {
|
||||||
@ -204,11 +210,11 @@ describe('AlertThreshold', () => {
|
|||||||
|
|
||||||
// First addition should add WARNING threshold
|
// First addition should add WARNING threshold
|
||||||
fireEvent.click(addButton);
|
fireEvent.click(addButton);
|
||||||
expect(screen.getByText('WARNING')).toBeInTheDocument();
|
expect(screen.getByText('warning')).toBeInTheDocument();
|
||||||
|
|
||||||
// Second addition should add INFO threshold
|
// Second addition should add INFO threshold
|
||||||
fireEvent.click(addButton);
|
fireEvent.click(addButton);
|
||||||
expect(screen.getByText('INFO')).toBeInTheDocument();
|
expect(screen.getByText('info')).toBeInTheDocument();
|
||||||
|
|
||||||
// Third addition should add random threshold
|
// Third addition should add random threshold
|
||||||
fireEvent.click(addButton);
|
fireEvent.click(addButton);
|
||||||
@ -280,7 +286,7 @@ describe('AlertThreshold', () => {
|
|||||||
renderAlertThreshold();
|
renderAlertThreshold();
|
||||||
|
|
||||||
// Should have initial critical threshold
|
// Should have initial critical threshold
|
||||||
expect(screen.getByText('CRITICAL')).toBeInTheDocument();
|
expect(screen.getByText('critical')).toBeInTheDocument();
|
||||||
verifySelectRenders(TEST_STRINGS.IS_ABOVE);
|
verifySelectRenders(TEST_STRINGS.IS_ABOVE);
|
||||||
verifySelectRenders(TEST_STRINGS.AT_LEAST_ONCE);
|
verifySelectRenders(TEST_STRINGS.AT_LEAST_ONCE);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -494,15 +494,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.add-threshold-btn {
|
.add-threshold-btn,
|
||||||
border: 1px dashed var(--bg-vanilla-300);
|
.ant-btn.add-threshold-btn {
|
||||||
color: var(--bg-ink-300);
|
border: 1px dashed var(--bg-vanilla-300);
|
||||||
|
color: var(--bg-ink-300);
|
||||||
|
background-color: transparent;
|
||||||
|
|
||||||
&:hover {
|
.ant-typography {
|
||||||
border-color: var(--bg-ink-300);
|
color: var(--bg-ink-400);
|
||||||
color: var(--bg-ink-400);
|
}
|
||||||
}
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--bg-ink-300);
|
||||||
|
color: var(--bg-ink-400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import './styles.scss';
|
import './styles.scss';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { Labels } from 'types/api/alerts/def';
|
import { Labels } from 'types/api/alerts/def';
|
||||||
@ -8,7 +9,7 @@ import { useCreateAlertState } from '../context';
|
|||||||
import LabelsInput from './LabelsInput';
|
import LabelsInput from './LabelsInput';
|
||||||
|
|
||||||
function CreateAlertHeader(): JSX.Element {
|
function CreateAlertHeader(): JSX.Element {
|
||||||
const { alertState, setAlertState } = useCreateAlertState();
|
const { alertState, setAlertState, isEditMode } = useCreateAlertState();
|
||||||
|
|
||||||
const { currentQuery } = useQueryBuilder();
|
const { currentQuery } = useQueryBuilder();
|
||||||
|
|
||||||
@ -34,10 +35,14 @@ function CreateAlertHeader(): JSX.Element {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="alert-header">
|
<div
|
||||||
<div className="alert-header__tab-bar">
|
className={classNames('alert-header', { 'edit-alert-header': isEditMode })}
|
||||||
<div className="alert-header__tab">New Alert Rule</div>
|
>
|
||||||
</div>
|
{!isEditMode && (
|
||||||
|
<div className="alert-header__tab-bar">
|
||||||
|
<div className="alert-header__tab">New Alert Rule</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="alert-header__content">
|
<div className="alert-header__content">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
/* eslint-disable react/jsx-props-no-spreading */
|
/* eslint-disable react/jsx-props-no-spreading */
|
||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
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 { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||||
|
|
||||||
import * as useCreateAlertRuleHook from '../../../../hooks/alerts/useCreateAlertRule';
|
import * as useCreateAlertRuleHook from '../../../../hooks/alerts/useCreateAlertRule';
|
||||||
import * as useTestAlertRuleHook from '../../../../hooks/alerts/useTestAlertRule';
|
import * as useTestAlertRuleHook from '../../../../hooks/alerts/useTestAlertRule';
|
||||||
|
import * as useUpdateAlertRuleHook from '../../../../hooks/alerts/useUpdateAlertRule';
|
||||||
import { CreateAlertProvider } from '../../context';
|
import { CreateAlertProvider } from '../../context';
|
||||||
import CreateAlertHeader from '../CreateAlertHeader';
|
import CreateAlertHeader from '../CreateAlertHeader';
|
||||||
|
|
||||||
@ -15,6 +18,10 @@ jest.spyOn(useTestAlertRuleHook, 'useTestAlertRule').mockReturnValue({
|
|||||||
mutate: jest.fn(),
|
mutate: jest.fn(),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
} as any);
|
} as any);
|
||||||
|
jest.spyOn(useUpdateAlertRuleHook, 'useUpdateAlertRule').mockReturnValue({
|
||||||
|
mutate: jest.fn(),
|
||||||
|
isLoading: false,
|
||||||
|
} as any);
|
||||||
|
|
||||||
jest.mock('uplot', () => {
|
jest.mock('uplot', () => {
|
||||||
const paths = {
|
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> =>
|
const renderCreateAlertHeader = (): ReturnType<typeof render> =>
|
||||||
render(
|
render(
|
||||||
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
<CreateAlertProvider initialAlertType={AlertTypes.METRICS_BASED_ALERT}>
|
||||||
@ -52,7 +61,9 @@ describe('CreateAlertHeader', () => {
|
|||||||
|
|
||||||
it('renders name input with placeholder', () => {
|
it('renders name input with placeholder', () => {
|
||||||
renderCreateAlertHeader();
|
renderCreateAlertHeader();
|
||||||
const nameInput = screen.getByPlaceholderText('Enter alert rule name');
|
const nameInput = screen.getByPlaceholderText(
|
||||||
|
ENTER_ALERT_RULE_NAME_PLACEHOLDER,
|
||||||
|
);
|
||||||
expect(nameInput).toBeInTheDocument();
|
expect(nameInput).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -63,10 +74,30 @@ describe('CreateAlertHeader', () => {
|
|||||||
|
|
||||||
it('updates name when typing in name input', () => {
|
it('updates name when typing in name input', () => {
|
||||||
renderCreateAlertHeader();
|
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' } });
|
fireEvent.change(nameInput, { target: { value: 'Test Alert' } });
|
||||||
|
|
||||||
expect(nameInput).toHaveValue('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 {
|
.labels-input {
|
||||||
&__add-button {
|
&__add-button {
|
||||||
color: var(--bg-ink-400);
|
color: var(--bg-ink-400);
|
||||||
border: 1px solid var(--bg-vanilla-300);
|
border: 1px solid var(--bg-vanilla-300);
|
||||||
|
background-color: var(--bg-vanilla-100);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: var(--bg-ink-300);
|
border-color: var(--bg-ink-300);
|
||||||
|
|||||||
@ -8,12 +8,11 @@ import AlertCondition from './AlertCondition';
|
|||||||
import { CreateAlertProvider } from './context';
|
import { CreateAlertProvider } from './context';
|
||||||
import { buildInitialAlertDef } from './context/utils';
|
import { buildInitialAlertDef } from './context/utils';
|
||||||
import CreateAlertHeader from './CreateAlertHeader';
|
import CreateAlertHeader from './CreateAlertHeader';
|
||||||
import EvaluationSettings from './EvaluationSettings';
|
|
||||||
import Footer from './Footer';
|
import Footer from './Footer';
|
||||||
import NotificationSettings from './NotificationSettings';
|
import NotificationSettings from './NotificationSettings';
|
||||||
import QuerySection from './QuerySection';
|
import QuerySection from './QuerySection';
|
||||||
import { CreateAlertV2Props } from './types';
|
import { CreateAlertV2Props } from './types';
|
||||||
import { showCondensedLayout, Spinner } from './utils';
|
import { Spinner } from './utils';
|
||||||
|
|
||||||
function CreateAlertV2({ alertType }: CreateAlertV2Props): JSX.Element {
|
function CreateAlertV2({ alertType }: CreateAlertV2Props): JSX.Element {
|
||||||
const queryToRedirect = buildInitialAlertDef(alertType);
|
const queryToRedirect = buildInitialAlertDef(alertType);
|
||||||
@ -23,8 +22,6 @@ function CreateAlertV2({ alertType }: CreateAlertV2Props): JSX.Element {
|
|||||||
|
|
||||||
useShareBuilderUrl({ defaultValue: currentQueryToRedirect });
|
useShareBuilderUrl({ defaultValue: currentQueryToRedirect });
|
||||||
|
|
||||||
const showCondensedLayoutFlag = showCondensedLayout();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CreateAlertProvider initialAlertType={alertType}>
|
<CreateAlertProvider initialAlertType={alertType}>
|
||||||
<Spinner />
|
<Spinner />
|
||||||
@ -32,7 +29,6 @@ function CreateAlertV2({ alertType }: CreateAlertV2Props): JSX.Element {
|
|||||||
<CreateAlertHeader />
|
<CreateAlertHeader />
|
||||||
<QuerySection />
|
<QuerySection />
|
||||||
<AlertCondition />
|
<AlertCondition />
|
||||||
{!showCondensedLayoutFlag ? <EvaluationSettings /> : null}
|
|
||||||
<NotificationSettings />
|
<NotificationSettings />
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import './styles.scss';
|
|||||||
|
|
||||||
import { Switch, Tooltip, Typography } from 'antd';
|
import { Switch, Tooltip, Typography } from 'antd';
|
||||||
import { Info } from 'lucide-react';
|
import { Info } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { IAdvancedOptionItemProps } from '../types';
|
import { IAdvancedOptionItemProps } from '../types';
|
||||||
|
|
||||||
@ -12,9 +12,14 @@ function AdvancedOptionItem({
|
|||||||
input,
|
input,
|
||||||
tooltipText,
|
tooltipText,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
defaultShowInput,
|
||||||
}: IAdvancedOptionItemProps): JSX.Element {
|
}: IAdvancedOptionItemProps): JSX.Element {
|
||||||
const [showInput, setShowInput] = useState<boolean>(false);
|
const [showInput, setShowInput] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShowInput(defaultShowInput);
|
||||||
|
}, [defaultShowInput]);
|
||||||
|
|
||||||
const handleOnToggle = (): void => {
|
const handleOnToggle = (): void => {
|
||||||
onToggle?.();
|
onToggle?.();
|
||||||
setShowInput((currentShowInput) => !currentShowInput);
|
setShowInput((currentShowInput) => !currentShowInput);
|
||||||
@ -42,7 +47,7 @@ function AdvancedOptionItem({
|
|||||||
>
|
>
|
||||||
{input}
|
{input}
|
||||||
</div>
|
</div>
|
||||||
<Switch onChange={handleOnToggle} />
|
<Switch onChange={handleOnToggle} checked={showInput} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -42,6 +42,7 @@ function AdvancedOptions(): JSX.Element {
|
|||||||
payload: !advancedOptions.sendNotificationIfDataIsMissing.enabled,
|
payload: !advancedOptions.sendNotificationIfDataIsMissing.enabled,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
defaultShowInput={advancedOptions.sendNotificationIfDataIsMissing.enabled}
|
||||||
/>
|
/>
|
||||||
<AdvancedOptionItem
|
<AdvancedOptionItem
|
||||||
title="Minimum data required"
|
title="Minimum data required"
|
||||||
@ -72,6 +73,7 @@ function AdvancedOptions(): JSX.Element {
|
|||||||
payload: !advancedOptions.enforceMinimumDatapoints.enabled,
|
payload: !advancedOptions.enforceMinimumDatapoints.enabled,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
defaultShowInput={advancedOptions.enforceMinimumDatapoints.enabled}
|
||||||
/>
|
/>
|
||||||
{/* TODO: Add back when the functionality is implemented */}
|
{/* TODO: Add back when the functionality is implemented */}
|
||||||
{/* <AdvancedOptionItem
|
{/* <AdvancedOptionItem
|
||||||
|
|||||||
@ -1,28 +1,19 @@
|
|||||||
import './styles.scss';
|
import './styles.scss';
|
||||||
|
|
||||||
import { Button, Popover, Typography } from 'antd';
|
import { Button, Popover } from 'antd';
|
||||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
|
||||||
|
|
||||||
import { useCreateAlertState } from '../context';
|
import { useCreateAlertState } from '../context';
|
||||||
import Stepper from '../Stepper';
|
|
||||||
import { showCondensedLayout } from '../utils';
|
|
||||||
import AdvancedOptions from './AdvancedOptions';
|
|
||||||
import EvaluationWindowPopover from './EvaluationWindowPopover';
|
import EvaluationWindowPopover from './EvaluationWindowPopover';
|
||||||
import { getEvaluationWindowTypeText, getTimeframeText } from './utils';
|
import { getEvaluationWindowTypeText, getTimeframeText } from './utils';
|
||||||
|
|
||||||
function EvaluationSettings(): JSX.Element {
|
function EvaluationSettings(): JSX.Element {
|
||||||
const {
|
const { evaluationWindow, setEvaluationWindow } = useCreateAlertState();
|
||||||
alertType,
|
|
||||||
evaluationWindow,
|
|
||||||
setEvaluationWindow,
|
|
||||||
} = useCreateAlertState();
|
|
||||||
const [
|
const [
|
||||||
isEvaluationWindowPopoverOpen,
|
isEvaluationWindowPopoverOpen,
|
||||||
setIsEvaluationWindowPopoverOpen,
|
setIsEvaluationWindowPopoverOpen,
|
||||||
] = useState(false);
|
] = useState(false);
|
||||||
const showCondensedLayoutFlag = showCondensedLayout();
|
|
||||||
|
|
||||||
const popoverContent = (
|
const popoverContent = (
|
||||||
<Popover
|
<Popover
|
||||||
@ -57,33 +48,12 @@ function EvaluationSettings(): JSX.Element {
|
|||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Layout consists of only the evaluation window popover
|
|
||||||
if (showCondensedLayoutFlag) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="condensed-evaluation-settings-container"
|
|
||||||
data-testid="condensed-evaluation-settings-container"
|
|
||||||
>
|
|
||||||
{popoverContent}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layout consists of
|
|
||||||
// - Stepper header
|
|
||||||
// - Evaluation window popover
|
|
||||||
// - Advanced options
|
|
||||||
return (
|
return (
|
||||||
<div className="evaluation-settings-container">
|
<div
|
||||||
<Stepper stepNumber={3} label="Evaluation settings" />
|
className="condensed-evaluation-settings-container"
|
||||||
{alertType !== AlertTypes.ANOMALY_BASED_ALERT && (
|
data-testid="condensed-evaluation-settings-container"
|
||||||
<div className="evaluate-alert-conditions-container">
|
>
|
||||||
<Typography.Text>Check conditions using data from</Typography.Text>
|
{popoverContent}
|
||||||
<div className="evaluate-alert-conditions-separator" />
|
|
||||||
{popoverContent}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<AdvancedOptions />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,6 +33,7 @@ describe('AdvancedOptionItem', () => {
|
|||||||
title={defaultProps.title}
|
title={defaultProps.title}
|
||||||
description={defaultProps.description}
|
description={defaultProps.description}
|
||||||
input={defaultProps.input}
|
input={defaultProps.input}
|
||||||
|
defaultShowInput={false}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -50,6 +51,7 @@ describe('AdvancedOptionItem', () => {
|
|||||||
title={defaultProps.title}
|
title={defaultProps.title}
|
||||||
description={defaultProps.description}
|
description={defaultProps.description}
|
||||||
input={defaultProps.input}
|
input={defaultProps.input}
|
||||||
|
defaultShowInput={false}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -65,6 +67,7 @@ describe('AdvancedOptionItem', () => {
|
|||||||
title={defaultProps.title}
|
title={defaultProps.title}
|
||||||
description={defaultProps.description}
|
description={defaultProps.description}
|
||||||
input={defaultProps.input}
|
input={defaultProps.input}
|
||||||
|
defaultShowInput={false}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -88,6 +91,7 @@ describe('AdvancedOptionItem', () => {
|
|||||||
title={defaultProps.title}
|
title={defaultProps.title}
|
||||||
description={defaultProps.description}
|
description={defaultProps.description}
|
||||||
input={defaultProps.input}
|
input={defaultProps.input}
|
||||||
|
defaultShowInput={false}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -117,6 +121,7 @@ describe('AdvancedOptionItem', () => {
|
|||||||
title={defaultProps.title}
|
title={defaultProps.title}
|
||||||
description={defaultProps.description}
|
description={defaultProps.description}
|
||||||
input={defaultProps.input}
|
input={defaultProps.input}
|
||||||
|
defaultShowInput={false}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -146,6 +151,7 @@ describe('AdvancedOptionItem', () => {
|
|||||||
title={defaultProps.title}
|
title={defaultProps.title}
|
||||||
description={defaultProps.description}
|
description={defaultProps.description}
|
||||||
input={defaultProps.input}
|
input={defaultProps.input}
|
||||||
|
defaultShowInput={false}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -160,9 +166,24 @@ describe('AdvancedOptionItem', () => {
|
|||||||
description={defaultProps.description}
|
description={defaultProps.description}
|
||||||
input={defaultProps.input}
|
input={defaultProps.input}
|
||||||
tooltipText="mock tooltip text"
|
tooltipText="mock tooltip text"
|
||||||
|
defaultShowInput={false}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const tooltipIcon = screen.getByTestId('tooltip-icon');
|
const tooltipIcon = screen.getByTestId('tooltip-icon');
|
||||||
expect(tooltipIcon).toBeInTheDocument();
|
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 { render, screen } from '@testing-library/react';
|
||||||
import * as alertState from 'container/CreateAlertV2/context';
|
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 EvaluationSettings from '../EvaluationSettings';
|
||||||
import { createMockAlertContextState } from './testUtils';
|
import { createMockAlertContextState } from './testUtils';
|
||||||
|
|
||||||
|
jest.mock('container/CreateAlertV2/utils', () => ({
|
||||||
|
...jest.requireActual('container/CreateAlertV2/utils'),
|
||||||
|
}));
|
||||||
|
|
||||||
const mockSetEvaluationWindow = jest.fn();
|
const mockSetEvaluationWindow = jest.fn();
|
||||||
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
|
jest.spyOn(alertState, 'useCreateAlertState').mockReturnValue(
|
||||||
createMockAlertContextState({
|
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', () => {
|
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', () => {
|
it('should render the condensed evaluation settings layout', () => {
|
||||||
jest.spyOn(utils, 'showCondensedLayout').mockReturnValueOnce(true);
|
|
||||||
render(<EvaluationSettings />);
|
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(
|
expect(
|
||||||
screen.getByTestId('condensed-evaluation-settings-container'),
|
screen.getByTestId('condensed-evaluation-settings-container'),
|
||||||
).toBeInTheDocument();
|
).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,
|
isCreatingAlertRule: false,
|
||||||
isTestingAlertRule: false,
|
isTestingAlertRule: false,
|
||||||
createAlertRule: jest.fn(),
|
createAlertRule: jest.fn(),
|
||||||
|
isUpdatingAlertRule: false,
|
||||||
|
updateAlertRule: jest.fn(),
|
||||||
|
isEditMode: false,
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export interface IAdvancedOptionItemProps {
|
|||||||
input: JSX.Element;
|
input: JSX.Element;
|
||||||
tooltipText?: string;
|
tooltipText?: string;
|
||||||
onToggle?: () => void;
|
onToggle?: () => void;
|
||||||
|
defaultShowInput: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum RollingWindowTimeframes {
|
export enum RollingWindowTimeframes {
|
||||||
|
|||||||
@ -26,11 +26,17 @@ function Footer(): JSX.Element {
|
|||||||
isCreatingAlertRule,
|
isCreatingAlertRule,
|
||||||
testAlertRule,
|
testAlertRule,
|
||||||
isTestingAlertRule,
|
isTestingAlertRule,
|
||||||
|
updateAlertRule,
|
||||||
|
isUpdatingAlertRule,
|
||||||
|
isEditMode,
|
||||||
} = useCreateAlertState();
|
} = useCreateAlertState();
|
||||||
const { currentQuery } = useQueryBuilder();
|
const { currentQuery } = useQueryBuilder();
|
||||||
const { safeNavigate } = useSafeNavigate();
|
const { safeNavigate } = useSafeNavigate();
|
||||||
|
|
||||||
const handleDiscard = (): void => discardAlertRule();
|
const handleDiscard = (): void => {
|
||||||
|
discardAlertRule();
|
||||||
|
safeNavigate('/alerts');
|
||||||
|
};
|
||||||
|
|
||||||
const alertValidationMessage = useMemo(
|
const alertValidationMessage = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -99,15 +105,27 @@ function Footer(): JSX.Element {
|
|||||||
notificationSettings,
|
notificationSettings,
|
||||||
query: currentQuery,
|
query: currentQuery,
|
||||||
});
|
});
|
||||||
createAlertRule(payload, {
|
if (isEditMode) {
|
||||||
onSuccess: () => {
|
updateAlertRule(payload, {
|
||||||
toast.success('Alert rule created successfully');
|
onSuccess: () => {
|
||||||
safeNavigate('/alerts');
|
toast.success('Alert rule updated successfully');
|
||||||
},
|
safeNavigate('/alerts');
|
||||||
onError: (error) => {
|
},
|
||||||
toast.error(error.message);
|
onError: (error) => {
|
||||||
},
|
toast.error(error.message);
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
createAlertRule(payload, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Alert rule created successfully');
|
||||||
|
safeNavigate('/alerts');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}, [
|
}, [
|
||||||
alertType,
|
alertType,
|
||||||
basicAlertState,
|
basicAlertState,
|
||||||
@ -116,16 +134,22 @@ function Footer(): JSX.Element {
|
|||||||
evaluationWindow,
|
evaluationWindow,
|
||||||
notificationSettings,
|
notificationSettings,
|
||||||
currentQuery,
|
currentQuery,
|
||||||
|
isEditMode,
|
||||||
|
updateAlertRule,
|
||||||
createAlertRule,
|
createAlertRule,
|
||||||
safeNavigate,
|
safeNavigate,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const disableButtons =
|
const disableButtons =
|
||||||
isCreatingAlertRule || isTestingAlertRule || !!alertValidationMessage;
|
isCreatingAlertRule || isTestingAlertRule || isUpdatingAlertRule;
|
||||||
|
|
||||||
const saveAlertButton = useMemo(() => {
|
const saveAlertButton = useMemo(() => {
|
||||||
let button = (
|
let button = (
|
||||||
<Button type="primary" onClick={handleSaveAlert} disabled={disableButtons}>
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={handleSaveAlert}
|
||||||
|
disabled={disableButtons || Boolean(alertValidationMessage)}
|
||||||
|
>
|
||||||
<Check size={14} />
|
<Check size={14} />
|
||||||
<Typography.Text>Save Alert Rule</Typography.Text>
|
<Typography.Text>Save Alert Rule</Typography.Text>
|
||||||
</Button>
|
</Button>
|
||||||
@ -141,7 +165,7 @@ function Footer(): JSX.Element {
|
|||||||
<Button
|
<Button
|
||||||
type="default"
|
type="default"
|
||||||
onClick={handleTestNotification}
|
onClick={handleTestNotification}
|
||||||
disabled={disableButtons}
|
disabled={disableButtons || Boolean(alertValidationMessage)}
|
||||||
>
|
>
|
||||||
<Send size={14} />
|
<Send size={14} />
|
||||||
<Typography.Text>Test Notification</Typography.Text>
|
<Typography.Text>Test Notification</Typography.Text>
|
||||||
@ -155,7 +179,7 @@ function Footer(): JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="create-alert-v2-footer">
|
<div className="create-alert-v2-footer">
|
||||||
<Button type="text" onClick={handleDiscard} disabled={disableButtons}>
|
<Button type="default" onClick={handleDiscard} disabled={disableButtons}>
|
||||||
<X size={14} /> Discard
|
<X size={14} /> Discard
|
||||||
</Button>
|
</Button>
|
||||||
<div className="button-group">
|
<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';
|
import { BuildCreateAlertRulePayloadArgs } from './types';
|
||||||
|
|
||||||
// Get formatted time/unit pairs for create alert api payload
|
// 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> = {
|
const unitMap: Record<string, string> = {
|
||||||
[UniversalYAxisUnit.SECONDS]: 's',
|
[UniversalYAxisUnit.SECONDS]: 's',
|
||||||
[UniversalYAxisUnit.MINUTES]: 'm',
|
[UniversalYAxisUnit.MINUTES]: 'm',
|
||||||
@ -57,20 +57,18 @@ export function getNotificationSettingsProps(
|
|||||||
notificationSettings: NotificationSettingsState,
|
notificationSettings: NotificationSettingsState,
|
||||||
): PostableAlertRuleV2['notificationSettings'] {
|
): PostableAlertRuleV2['notificationSettings'] {
|
||||||
const notificationSettingsProps: PostableAlertRuleV2['notificationSettings'] = {
|
const notificationSettingsProps: PostableAlertRuleV2['notificationSettings'] = {
|
||||||
notificationGroupBy: notificationSettings.multipleNotifications || [],
|
groupBy: notificationSettings.multipleNotifications || [],
|
||||||
alertStates: notificationSettings.reNotification.enabled
|
usePolicy: notificationSettings.routingPolicies,
|
||||||
? notificationSettings.reNotification.conditions
|
renotify: {
|
||||||
: [],
|
enabled: notificationSettings.reNotification.enabled,
|
||||||
notificationPolicy: notificationSettings.routingPolicies,
|
interval: getFormattedTimeValue(
|
||||||
|
notificationSettings.reNotification.value,
|
||||||
|
notificationSettings.reNotification.unit,
|
||||||
|
),
|
||||||
|
alertStates: notificationSettings.reNotification.conditions,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (notificationSettings.reNotification.enabled) {
|
|
||||||
notificationSettingsProps.renotify = getFormattedTimeValue(
|
|
||||||
notificationSettings.reNotification.value,
|
|
||||||
notificationSettings.reNotification.unit,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return notificationSettingsProps;
|
return notificationSettingsProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,18 +4,15 @@ import { Input, Select, Typography } from 'antd';
|
|||||||
|
|
||||||
import { useCreateAlertState } from '../context';
|
import { useCreateAlertState } from '../context';
|
||||||
import {
|
import {
|
||||||
ADVANCED_OPTIONS_TIME_UNIT_OPTIONS as RE_NOTIFICATION_UNIT_OPTIONS,
|
|
||||||
RE_NOTIFICATION_CONDITION_OPTIONS,
|
RE_NOTIFICATION_CONDITION_OPTIONS,
|
||||||
|
RE_NOTIFICATION_TIME_UNIT_OPTIONS,
|
||||||
} from '../context/constants';
|
} from '../context/constants';
|
||||||
import AdvancedOptionItem from '../EvaluationSettings/AdvancedOptionItem';
|
import AdvancedOptionItem from '../EvaluationSettings/AdvancedOptionItem';
|
||||||
import Stepper from '../Stepper';
|
import Stepper from '../Stepper';
|
||||||
import { showCondensedLayout } from '../utils';
|
|
||||||
import MultipleNotifications from './MultipleNotifications';
|
import MultipleNotifications from './MultipleNotifications';
|
||||||
import NotificationMessage from './NotificationMessage';
|
import NotificationMessage from './NotificationMessage';
|
||||||
|
|
||||||
function NotificationSettings(): JSX.Element {
|
function NotificationSettings(): JSX.Element {
|
||||||
const showCondensedLayoutFlag = showCondensedLayout();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
notificationSettings,
|
notificationSettings,
|
||||||
setNotificationSettings,
|
setNotificationSettings,
|
||||||
@ -45,7 +42,7 @@ function NotificationSettings(): JSX.Element {
|
|||||||
value={notificationSettings.reNotification.unit || null}
|
value={notificationSettings.reNotification.unit || null}
|
||||||
placeholder="Select unit"
|
placeholder="Select unit"
|
||||||
disabled={!notificationSettings.reNotification.enabled}
|
disabled={!notificationSettings.reNotification.enabled}
|
||||||
options={RE_NOTIFICATION_UNIT_OPTIONS}
|
options={RE_NOTIFICATION_TIME_UNIT_OPTIONS}
|
||||||
onChange={(value): void => {
|
onChange={(value): void => {
|
||||||
setNotificationSettings({
|
setNotificationSettings({
|
||||||
type: 'SET_RE_NOTIFICATION',
|
type: 'SET_RE_NOTIFICATION',
|
||||||
@ -82,10 +79,7 @@ function NotificationSettings(): JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="notification-settings-container">
|
<div className="notification-settings-container">
|
||||||
<Stepper
|
<Stepper stepNumber={3} label="Notification settings" />
|
||||||
stepNumber={showCondensedLayoutFlag ? 3 : 4}
|
|
||||||
label="Notification settings"
|
|
||||||
/>
|
|
||||||
<NotificationMessage />
|
<NotificationMessage />
|
||||||
<div className="notification-settings-content">
|
<div className="notification-settings-content">
|
||||||
<MultipleNotifications />
|
<MultipleNotifications />
|
||||||
@ -103,6 +97,7 @@ function NotificationSettings(): JSX.Element {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
defaultShowInput={notificationSettings.reNotification.enabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
import * as createAlertContext from 'container/CreateAlertV2/context';
|
import * as createAlertContext from 'container/CreateAlertV2/context';
|
||||||
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
|
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
|
||||||
import * as utils from 'container/CreateAlertV2/utils';
|
|
||||||
|
|
||||||
import NotificationSettings from '../NotificationSettings';
|
import NotificationSettings from '../NotificationSettings';
|
||||||
|
|
||||||
@ -24,6 +23,10 @@ jest.mock(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
jest.mock('container/CreateAlertV2/utils', () => ({
|
||||||
|
...jest.requireActual('container/CreateAlertV2/utils'),
|
||||||
|
}));
|
||||||
|
|
||||||
const initialNotificationSettings = createMockAlertContextState()
|
const initialNotificationSettings = createMockAlertContextState()
|
||||||
.notificationSettings;
|
.notificationSettings;
|
||||||
const mockSetNotificationSettings = jest.fn();
|
const mockSetNotificationSettings = jest.fn();
|
||||||
@ -37,10 +40,10 @@ const REPEAT_NOTIFICATIONS_TEXT = 'Repeat notifications';
|
|||||||
const ENTER_TIME_INTERVAL_TEXT = 'Enter time interval...';
|
const ENTER_TIME_INTERVAL_TEXT = 'Enter time interval...';
|
||||||
|
|
||||||
describe('NotificationSettings', () => {
|
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 />);
|
render(<NotificationSettings />);
|
||||||
expect(screen.getByText('Notification settings')).toBeInTheDocument();
|
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('multiple-notifications')).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('notification-message')).toBeInTheDocument();
|
expect(screen.getByTestId('notification-message')).toBeInTheDocument();
|
||||||
expect(screen.getByText(REPEAT_NOTIFICATIONS_TEXT)).toBeInTheDocument();
|
expect(screen.getByText(REPEAT_NOTIFICATIONS_TEXT)).toBeInTheDocument();
|
||||||
@ -51,15 +54,6 @@ describe('NotificationSettings', () => {
|
|||||||
).toBeInTheDocument();
|
).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', () => {
|
describe('Repeat notifications', () => {
|
||||||
it('renders the repeat notifications with inputs hidden when the repeat notifications switch is off', () => {
|
it('renders the repeat notifications with inputs hidden when the repeat notifications switch is off', () => {
|
||||||
render(<NotificationSettings />);
|
render(<NotificationSettings />);
|
||||||
|
|||||||
@ -51,7 +51,6 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
|
|||||||
yAxisUnit={yAxisUnit || ''}
|
yAxisUnit={yAxisUnit || ''}
|
||||||
graphType={panelType || PANEL_TYPES.TIME_SERIES}
|
graphType={panelType || PANEL_TYPES.TIME_SERIES}
|
||||||
setQueryStatus={setQueryStatus}
|
setQueryStatus={setQueryStatus}
|
||||||
showSideLegend
|
|
||||||
additionalThresholds={thresholdState.thresholds}
|
additionalThresholds={thresholdState.thresholds}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@ -66,7 +65,6 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
|
|||||||
yAxisUnit={yAxisUnit || ''}
|
yAxisUnit={yAxisUnit || ''}
|
||||||
graphType={panelType || PANEL_TYPES.TIME_SERIES}
|
graphType={panelType || PANEL_TYPES.TIME_SERIES}
|
||||||
setQueryStatus={setQueryStatus}
|
setQueryStatus={setQueryStatus}
|
||||||
showSideLegend
|
|
||||||
additionalThresholds={thresholdState.thresholds}
|
additionalThresholds={thresholdState.thresholds}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -132,7 +132,7 @@
|
|||||||
border-bottom: 0.5px solid var(--bg-vanilla-300);
|
border-bottom: 0.5px solid var(--bg-vanilla-300);
|
||||||
|
|
||||||
&.active-tab {
|
&.active-tab {
|
||||||
background-color: var(--bg-vanilla-100);
|
background-color: var(--bg-vanilla-300);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--bg-vanilla-100) !important;
|
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 = {
|
export const INITIAL_CRITICAL_THRESHOLD: Threshold = {
|
||||||
id: v4(),
|
id: v4(),
|
||||||
label: 'CRITICAL',
|
label: 'critical',
|
||||||
thresholdValue: 0,
|
thresholdValue: 0,
|
||||||
recoveryThresholdValue: null,
|
recoveryThresholdValue: null,
|
||||||
unit: '',
|
unit: '',
|
||||||
@ -37,7 +37,7 @@ export const INITIAL_CRITICAL_THRESHOLD: Threshold = {
|
|||||||
|
|
||||||
export const INITIAL_WARNING_THRESHOLD: Threshold = {
|
export const INITIAL_WARNING_THRESHOLD: Threshold = {
|
||||||
id: v4(),
|
id: v4(),
|
||||||
label: 'WARNING',
|
label: 'warning',
|
||||||
thresholdValue: 0,
|
thresholdValue: 0,
|
||||||
recoveryThresholdValue: null,
|
recoveryThresholdValue: null,
|
||||||
unit: '',
|
unit: '',
|
||||||
@ -47,7 +47,7 @@ export const INITIAL_WARNING_THRESHOLD: Threshold = {
|
|||||||
|
|
||||||
export const INITIAL_INFO_THRESHOLD: Threshold = {
|
export const INITIAL_INFO_THRESHOLD: Threshold = {
|
||||||
id: v4(),
|
id: v4(),
|
||||||
label: 'INFO',
|
label: 'info',
|
||||||
thresholdValue: 0,
|
thresholdValue: 0,
|
||||||
recoveryThresholdValue: null,
|
recoveryThresholdValue: null,
|
||||||
unit: '',
|
unit: '',
|
||||||
@ -172,19 +172,24 @@ export const ADVANCED_OPTIONS_TIME_UNIT_OPTIONS = [
|
|||||||
{ value: UniversalYAxisUnit.HOURS, label: 'Hours' },
|
{ 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 =
|
export const NOTIFICATION_MESSAGE_PLACEHOLDER =
|
||||||
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})';
|
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})';
|
||||||
|
|
||||||
export const RE_NOTIFICATION_CONDITION_OPTIONS = [
|
export const RE_NOTIFICATION_CONDITION_OPTIONS = [
|
||||||
{ value: 'firing', label: 'Firing' },
|
{ value: 'firing', label: 'Firing' },
|
||||||
{ value: 'no-data', label: 'No Data' },
|
{ value: 'nodata', label: 'No Data' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const INITIAL_NOTIFICATION_SETTINGS_STATE: NotificationSettingsState = {
|
export const INITIAL_NOTIFICATION_SETTINGS_STATE: NotificationSettingsState = {
|
||||||
multipleNotifications: [],
|
multipleNotifications: [],
|
||||||
reNotification: {
|
reNotification: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
value: 1,
|
value: 30,
|
||||||
unit: UniversalYAxisUnit.MINUTES,
|
unit: UniversalYAxisUnit.MINUTES,
|
||||||
conditions: [],
|
conditions: [],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { QueryParams } from 'constants/query';
|
|||||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||||
import { useCreateAlertRule } from 'hooks/alerts/useCreateAlertRule';
|
import { useCreateAlertRule } from 'hooks/alerts/useCreateAlertRule';
|
||||||
import { useTestAlertRule } from 'hooks/alerts/useTestAlertRule';
|
import { useTestAlertRule } from 'hooks/alerts/useTestAlertRule';
|
||||||
|
import { useUpdateAlertRule } from 'hooks/alerts/useUpdateAlertRule';
|
||||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||||
import {
|
import {
|
||||||
@ -50,7 +51,7 @@ export const useCreateAlertState = (): ICreateAlertContextProps => {
|
|||||||
export function CreateAlertProvider(
|
export function CreateAlertProvider(
|
||||||
props: ICreateAlertProviderProps,
|
props: ICreateAlertProviderProps,
|
||||||
): JSX.Element {
|
): JSX.Element {
|
||||||
const { children } = props;
|
const { children, initialAlertState, isEditMode, ruleId } = props;
|
||||||
|
|
||||||
const [alertState, setAlertState] = useReducer(
|
const [alertState, setAlertState] = useReducer(
|
||||||
alertCreationReducer,
|
alertCreationReducer,
|
||||||
@ -114,6 +115,31 @@ export function CreateAlertProvider(
|
|||||||
});
|
});
|
||||||
}, [alertType]);
|
}, [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(() => {
|
const discardAlertRule = useCallback(() => {
|
||||||
setAlertState({
|
setAlertState({
|
||||||
type: 'RESET',
|
type: 'RESET',
|
||||||
@ -143,6 +169,11 @@ export function CreateAlertProvider(
|
|||||||
isLoading: isTestingAlertRule,
|
isLoading: isTestingAlertRule,
|
||||||
} = useTestAlertRule();
|
} = useTestAlertRule();
|
||||||
|
|
||||||
|
const {
|
||||||
|
mutate: updateAlertRule,
|
||||||
|
isLoading: isUpdatingAlertRule,
|
||||||
|
} = useUpdateAlertRule(ruleId || '');
|
||||||
|
|
||||||
const contextValue: ICreateAlertContextProps = useMemo(
|
const contextValue: ICreateAlertContextProps = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
alertState,
|
alertState,
|
||||||
@ -162,6 +193,9 @@ export function CreateAlertProvider(
|
|||||||
isCreatingAlertRule,
|
isCreatingAlertRule,
|
||||||
testAlertRule,
|
testAlertRule,
|
||||||
isTestingAlertRule,
|
isTestingAlertRule,
|
||||||
|
updateAlertRule,
|
||||||
|
isUpdatingAlertRule,
|
||||||
|
isEditMode: isEditMode || false,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
alertState,
|
alertState,
|
||||||
@ -176,6 +210,9 @@ export function CreateAlertProvider(
|
|||||||
isCreatingAlertRule,
|
isCreatingAlertRule,
|
||||||
testAlertRule,
|
testAlertRule,
|
||||||
isTestingAlertRule,
|
isTestingAlertRule,
|
||||||
|
updateAlertRule,
|
||||||
|
isUpdatingAlertRule,
|
||||||
|
isEditMode,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { CreateAlertRuleResponse } from 'api/alerts/createAlertRule';
|
import { CreateAlertRuleResponse } from 'api/alerts/createAlertRule';
|
||||||
import { TestAlertRuleResponse } from 'api/alerts/testAlertRule';
|
import { TestAlertRuleResponse } from 'api/alerts/testAlertRule';
|
||||||
|
import { UpdateAlertRuleResponse } from 'api/alerts/updateAlertRule';
|
||||||
import { Dayjs } from 'dayjs';
|
import { Dayjs } from 'dayjs';
|
||||||
import { Dispatch } from 'react';
|
import { Dispatch } from 'react';
|
||||||
import { UseMutateFunction } from 'react-query';
|
import { UseMutateFunction } from 'react-query';
|
||||||
@ -8,6 +9,8 @@ import { AlertTypes } from 'types/api/alerts/alertTypes';
|
|||||||
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
|
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
|
||||||
import { Labels } from 'types/api/alerts/def';
|
import { Labels } from 'types/api/alerts/def';
|
||||||
|
|
||||||
|
import { GetCreateAlertLocalStateFromAlertDefReturn } from '../types';
|
||||||
|
|
||||||
export interface ICreateAlertContextProps {
|
export interface ICreateAlertContextProps {
|
||||||
alertState: AlertState;
|
alertState: AlertState;
|
||||||
setAlertState: Dispatch<CreateAlertAction>;
|
setAlertState: Dispatch<CreateAlertAction>;
|
||||||
@ -36,11 +39,22 @@ export interface ICreateAlertContextProps {
|
|||||||
unknown
|
unknown
|
||||||
>;
|
>;
|
||||||
discardAlertRule: () => void;
|
discardAlertRule: () => void;
|
||||||
|
isUpdatingAlertRule: boolean;
|
||||||
|
updateAlertRule: UseMutateFunction<
|
||||||
|
SuccessResponse<UpdateAlertRuleResponse, unknown> | ErrorResponse,
|
||||||
|
Error,
|
||||||
|
PostableAlertRuleV2,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
isEditMode: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICreateAlertProviderProps {
|
export interface ICreateAlertProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
initialAlertType: AlertTypes;
|
initialAlertType: AlertTypes;
|
||||||
|
initialAlertState?: GetCreateAlertLocalStateFromAlertDefReturn;
|
||||||
|
isEditMode?: boolean;
|
||||||
|
ruleId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AlertCreationStep {
|
export enum AlertCreationStep {
|
||||||
@ -60,6 +74,7 @@ export type CreateAlertAction =
|
|||||||
| { type: 'SET_ALERT_NAME'; payload: string }
|
| { type: 'SET_ALERT_NAME'; payload: string }
|
||||||
| { type: 'SET_ALERT_LABELS'; payload: Labels }
|
| { type: 'SET_ALERT_LABELS'; payload: Labels }
|
||||||
| { type: 'SET_Y_AXIS_UNIT'; payload: string | undefined }
|
| { type: 'SET_Y_AXIS_UNIT'; payload: string | undefined }
|
||||||
|
| { type: 'SET_INITIAL_STATE'; payload: AlertState }
|
||||||
| { type: 'RESET' };
|
| { type: 'RESET' };
|
||||||
|
|
||||||
export interface Threshold {
|
export interface Threshold {
|
||||||
@ -127,6 +142,7 @@ export type AlertThresholdAction =
|
|||||||
| { type: 'SET_ALGORITHM'; payload: string }
|
| { type: 'SET_ALGORITHM'; payload: string }
|
||||||
| { type: 'SET_SEASONALITY'; payload: string }
|
| { type: 'SET_SEASONALITY'; payload: string }
|
||||||
| { type: 'SET_THRESHOLDS'; payload: Threshold[] }
|
| { type: 'SET_THRESHOLDS'; payload: Threshold[] }
|
||||||
|
| { type: 'SET_INITIAL_STATE'; payload: AlertThresholdState }
|
||||||
| { type: 'RESET' };
|
| { type: 'RESET' };
|
||||||
|
|
||||||
export interface AdvancedOptionsState {
|
export interface AdvancedOptionsState {
|
||||||
@ -198,6 +214,7 @@ export type AdvancedOptionsAction =
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
|
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
|
||||||
|
| { type: 'SET_INITIAL_STATE'; payload: AdvancedOptionsState }
|
||||||
| { type: 'RESET' };
|
| { type: 'RESET' };
|
||||||
|
|
||||||
export interface EvaluationWindowState {
|
export interface EvaluationWindowState {
|
||||||
@ -219,6 +236,7 @@ export type EvaluationWindowAction =
|
|||||||
payload: { time: string; number: string; timezone: string; unit: string };
|
payload: { time: string; number: string; timezone: string; unit: string };
|
||||||
}
|
}
|
||||||
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
|
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
|
||||||
|
| { type: 'SET_INITIAL_STATE'; payload: EvaluationWindowState }
|
||||||
| { type: 'RESET' };
|
| { type: 'RESET' };
|
||||||
|
|
||||||
export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule';
|
export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule';
|
||||||
@ -229,7 +247,7 @@ export interface NotificationSettingsState {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
value: number;
|
value: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
conditions: ('firing' | 'no-data')[];
|
conditions: ('firing' | 'nodata')[];
|
||||||
};
|
};
|
||||||
description: string;
|
description: string;
|
||||||
routingPolicies: boolean;
|
routingPolicies: boolean;
|
||||||
@ -246,9 +264,10 @@ export type NotificationSettingsAction =
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
value: number;
|
value: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
conditions: ('firing' | 'no-data')[];
|
conditions: ('firing' | 'nodata')[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
| { type: 'SET_DESCRIPTION'; payload: string }
|
| { type: 'SET_DESCRIPTION'; payload: string }
|
||||||
| { type: 'SET_ROUTING_POLICIES'; payload: boolean }
|
| { type: 'SET_ROUTING_POLICIES'; payload: boolean }
|
||||||
|
| { type: 'SET_INITIAL_STATE'; payload: NotificationSettingsState }
|
||||||
| { type: 'RESET' };
|
| { type: 'RESET' };
|
||||||
|
|||||||
@ -53,6 +53,8 @@ export const alertCreationReducer = (
|
|||||||
};
|
};
|
||||||
case 'RESET':
|
case 'RESET':
|
||||||
return INITIAL_ALERT_STATE;
|
return INITIAL_ALERT_STATE;
|
||||||
|
case 'SET_INITIAL_STATE':
|
||||||
|
return action.payload;
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@ -119,6 +121,8 @@ export const alertThresholdReducer = (
|
|||||||
return { ...state, thresholds: action.payload };
|
return { ...state, thresholds: action.payload };
|
||||||
case 'RESET':
|
case 'RESET':
|
||||||
return INITIAL_ALERT_THRESHOLD_STATE;
|
return INITIAL_ALERT_THRESHOLD_STATE;
|
||||||
|
case 'SET_INITIAL_STATE':
|
||||||
|
return action.payload;
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@ -174,6 +178,8 @@ export const advancedOptionsReducer = (
|
|||||||
...state,
|
...state,
|
||||||
evaluationCadence: { ...state.evaluationCadence, mode: action.payload },
|
evaluationCadence: { ...state.evaluationCadence, mode: action.payload },
|
||||||
};
|
};
|
||||||
|
case 'SET_INITIAL_STATE':
|
||||||
|
return action.payload;
|
||||||
case 'RESET':
|
case 'RESET':
|
||||||
return INITIAL_ADVANCED_OPTIONS_STATE;
|
return INITIAL_ADVANCED_OPTIONS_STATE;
|
||||||
default:
|
default:
|
||||||
@ -202,6 +208,8 @@ export const evaluationWindowReducer = (
|
|||||||
return { ...state, startingAt: action.payload };
|
return { ...state, startingAt: action.payload };
|
||||||
case 'RESET':
|
case 'RESET':
|
||||||
return INITIAL_EVALUATION_WINDOW_STATE;
|
return INITIAL_EVALUATION_WINDOW_STATE;
|
||||||
|
case 'SET_INITIAL_STATE':
|
||||||
|
return action.payload;
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@ -222,6 +230,8 @@ export const notificationSettingsReducer = (
|
|||||||
return { ...state, routingPolicies: action.payload };
|
return { ...state, routingPolicies: action.payload };
|
||||||
case 'RESET':
|
case 'RESET':
|
||||||
return INITIAL_NOTIFICATION_SETTINGS_STATE;
|
return INITIAL_NOTIFICATION_SETTINGS_STATE;
|
||||||
|
case 'SET_INITIAL_STATE':
|
||||||
|
return action.payload;
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,21 @@
|
|||||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AdvancedOptionsState,
|
||||||
|
AlertState,
|
||||||
|
AlertThresholdState,
|
||||||
|
EvaluationWindowState,
|
||||||
|
NotificationSettingsState,
|
||||||
|
} from './context/types';
|
||||||
|
|
||||||
export interface CreateAlertV2Props {
|
export interface CreateAlertV2Props {
|
||||||
alertType: AlertTypes;
|
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 { 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 { createPortal } from 'react-dom';
|
||||||
|
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
|
||||||
|
import { v4 } from 'uuid';
|
||||||
|
|
||||||
import { useCreateAlertState } from './context';
|
import { useCreateAlertState } from './context';
|
||||||
|
import {
|
||||||
// UI side feature flag
|
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||||
export const showNewCreateAlertsPage = (): boolean =>
|
INITIAL_ALERT_STATE,
|
||||||
localStorage.getItem('showNewCreateAlertsPage') === 'true';
|
INITIAL_ALERT_THRESHOLD_STATE,
|
||||||
|
INITIAL_EVALUATION_WINDOW_STATE,
|
||||||
// UI side FF to switch between the 2 layouts of the create alert page
|
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||||
// Layout 1 - Default layout
|
} from './context/constants';
|
||||||
// Layout 2 - Condensed layout
|
import {
|
||||||
export const showCondensedLayout = (): boolean =>
|
AdvancedOptionsState,
|
||||||
localStorage.getItem('showCondensedLayout') === 'true';
|
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 {
|
export function Spinner(): JSX.Element | null {
|
||||||
const { isCreatingAlertRule } = useCreateAlertState();
|
const { isCreatingAlertRule, isUpdatingAlertRule } = useCreateAlertState();
|
||||||
|
|
||||||
if (!isCreatingAlertRule) return null;
|
if (!isCreatingAlertRule && !isUpdatingAlertRule) return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="sticky-page-spinner">
|
<div className="sticky-page-spinner">
|
||||||
@ -25,3 +39,263 @@ export function Spinner(): JSX.Element | null {
|
|||||||
document.body,
|
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 { Form } from 'antd';
|
||||||
|
import EditAlertV2 from 'container/EditAlertV2';
|
||||||
import FormAlertRules from 'container/FormAlertRules';
|
import FormAlertRules from 'container/FormAlertRules';
|
||||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
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';
|
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();
|
const [formInstance] = Form.useForm();
|
||||||
|
|
||||||
|
if (
|
||||||
|
initialV2AlertValue !== null &&
|
||||||
|
initialV2AlertValue.schemaVersion === NEW_ALERT_SCHEMA_VERSION
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<EditAlertV2
|
||||||
|
initialAlert={initialV2AlertValue}
|
||||||
|
alertType={initialValue.alertType as AlertTypes}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormAlertRules
|
<FormAlertRules
|
||||||
alertType={
|
alertType={
|
||||||
@ -23,6 +44,7 @@ function EditRules({ initialValue, ruleId }: EditRulesProps): JSX.Element {
|
|||||||
interface EditRulesProps {
|
interface EditRulesProps {
|
||||||
initialValue: AlertDef;
|
initialValue: AlertDef;
|
||||||
ruleId: string;
|
ruleId: string;
|
||||||
|
initialV2AlertValue: PostableAlertRuleV2 | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default EditRules;
|
export default EditRules;
|
||||||
|
|||||||
@ -1,6 +1,14 @@
|
|||||||
/* eslint-disable react/display-name */
|
/* eslint-disable react/display-name */
|
||||||
import { PlusOutlined } from '@ant-design/icons';
|
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 type { ColumnsType } from 'antd/es/table/interface';
|
||||||
import saveAlertApi from 'api/alerts/save';
|
import saveAlertApi from 'api/alerts/save';
|
||||||
import logEvent from 'api/common/logEvent';
|
import logEvent from 'api/common/logEvent';
|
||||||
@ -31,7 +39,7 @@ import { ErrorResponse, SuccessResponse } from 'types/api';
|
|||||||
import { GettableAlert } from 'types/api/alerts/get';
|
import { GettableAlert } from 'types/api/alerts/get';
|
||||||
|
|
||||||
import DeleteAlert from './DeleteAlert';
|
import DeleteAlert from './DeleteAlert';
|
||||||
import { Button, ColumnButton, SearchContainer } from './styles';
|
import { ColumnButton, SearchContainer } from './styles';
|
||||||
import Status from './TableComponents/Status';
|
import Status from './TableComponents/Status';
|
||||||
import ToggleAlertState from './ToggleAlertState';
|
import ToggleAlertState from './ToggleAlertState';
|
||||||
import { alertActionLogEvent, filterAlerts } from './utils';
|
import { alertActionLogEvent, filterAlerts } from './utils';
|
||||||
@ -97,14 +105,41 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
|
|||||||
});
|
});
|
||||||
}, [notificationsApi, t]);
|
}, [notificationsApi, t]);
|
||||||
|
|
||||||
const onClickNewAlertHandler = useCallback(() => {
|
const onClickNewAlertV2Handler = useCallback(() => {
|
||||||
logEvent('Alert: New alert button clicked', {
|
logEvent('Alert: New alert button clicked', {
|
||||||
number: allAlertRules?.length,
|
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);
|
history.push(ROUTES.ALERTS_NEW);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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 onEditHandler = (record: GettableAlert, openInNewTab: boolean): void => {
|
||||||
const compositeQuery = mapQueryDataFromApi(record.condition.compositeQuery);
|
const compositeQuery = mapQueryDataFromApi(record.condition.compositeQuery);
|
||||||
params.set(
|
params.set(
|
||||||
@ -368,13 +403,11 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
<Flex gap={12}>
|
<Flex gap={12}>
|
||||||
{addNewAlert && (
|
{addNewAlert && (
|
||||||
<Button
|
<Dropdown menu={{ items: newAlertMenuItems }} trigger={['click']}>
|
||||||
type="primary"
|
<Button type="primary" icon={<PlusOutlined />}>
|
||||||
onClick={onClickNewAlertHandler}
|
New Alert
|
||||||
icon={<PlusOutlined />}
|
</Button>
|
||||||
>
|
</Dropdown>
|
||||||
New Alert
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
<TextToolTip
|
<TextToolTip
|
||||||
{...{
|
{...{
|
||||||
|
|||||||
@ -88,6 +88,7 @@ function RoutingPolicies(): JSX.Element {
|
|||||||
isRoutingPoliciesError={isErrorRoutingPolicies}
|
isRoutingPoliciesError={isErrorRoutingPolicies}
|
||||||
handlePolicyDetailsModalOpen={handlePolicyDetailsModalOpen}
|
handlePolicyDetailsModalOpen={handlePolicyDetailsModalOpen}
|
||||||
handleDeleteModalOpen={handleDeleteModalOpen}
|
handleDeleteModalOpen={handleDeleteModalOpen}
|
||||||
|
hasSearchTerm={(searchTerm?.length ?? 0) > 0}
|
||||||
/>
|
/>
|
||||||
{policyDetailsModalState.isOpen && (
|
{policyDetailsModalState.isOpen && (
|
||||||
<RoutingPolicyDetails
|
<RoutingPolicyDetails
|
||||||
|
|||||||
@ -10,6 +10,7 @@ function RoutingPolicyList({
|
|||||||
isRoutingPoliciesError,
|
isRoutingPoliciesError,
|
||||||
handlePolicyDetailsModalOpen,
|
handlePolicyDetailsModalOpen,
|
||||||
handleDeleteModalOpen,
|
handleDeleteModalOpen,
|
||||||
|
hasSearchTerm,
|
||||||
}: RoutingPolicyListProps): JSX.Element {
|
}: RoutingPolicyListProps): JSX.Element {
|
||||||
const columns: TableProps<RoutingPolicy>['columns'] = [
|
const columns: TableProps<RoutingPolicy>['columns'] = [
|
||||||
{
|
{
|
||||||
@ -25,6 +26,7 @@ function RoutingPolicyList({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/* eslint-disable no-nested-ternary */
|
||||||
const localeEmptyState = useMemo(
|
const localeEmptyState = useMemo(
|
||||||
() => (
|
() => (
|
||||||
<div className="no-routing-policies-message-container">
|
<div className="no-routing-policies-message-container">
|
||||||
@ -41,12 +43,23 @@ function RoutingPolicyList({
|
|||||||
<Typography.Text>
|
<Typography.Text>
|
||||||
Something went wrong while fetching routing policies.
|
Something went wrong while fetching routing policies.
|
||||||
</Typography.Text>
|
</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>
|
</div>
|
||||||
),
|
),
|
||||||
[isRoutingPoliciesError],
|
[isRoutingPoliciesError, hasSearchTerm],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -28,6 +28,7 @@ describe('RoutingPoliciesList', () => {
|
|||||||
isRoutingPoliciesError={useRoutingPolicesMockData.isErrorRoutingPolicies}
|
isRoutingPoliciesError={useRoutingPolicesMockData.isErrorRoutingPolicies}
|
||||||
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
|
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
|
||||||
handleDeleteModalOpen={mockHandleDeleteModalOpen}
|
handleDeleteModalOpen={mockHandleDeleteModalOpen}
|
||||||
|
hasSearchTerm={false}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -51,6 +52,7 @@ describe('RoutingPoliciesList', () => {
|
|||||||
isRoutingPoliciesError={false}
|
isRoutingPoliciesError={false}
|
||||||
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
|
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
|
||||||
handleDeleteModalOpen={mockHandleDeleteModalOpen}
|
handleDeleteModalOpen={mockHandleDeleteModalOpen}
|
||||||
|
hasSearchTerm={false}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
// Check for loading spinner by class name
|
// Check for loading spinner by class name
|
||||||
@ -67,6 +69,7 @@ describe('RoutingPoliciesList', () => {
|
|||||||
isRoutingPoliciesError
|
isRoutingPoliciesError
|
||||||
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
|
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
|
||||||
handleDeleteModalOpen={mockHandleDeleteModalOpen}
|
handleDeleteModalOpen={mockHandleDeleteModalOpen}
|
||||||
|
hasSearchTerm={false}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
@ -82,8 +85,9 @@ describe('RoutingPoliciesList', () => {
|
|||||||
isRoutingPoliciesError={false}
|
isRoutingPoliciesError={false}
|
||||||
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
|
handlePolicyDetailsModalOpen={mockHandlePolicyDetailsModalOpen}
|
||||||
handleDeleteModalOpen={mockHandleDeleteModalOpen}
|
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;
|
isRoutingPoliciesError: boolean;
|
||||||
handlePolicyDetailsModalOpen: HandlePolicyDetailsModalOpen;
|
handlePolicyDetailsModalOpen: HandlePolicyDetailsModalOpen;
|
||||||
handleDeleteModalOpen: HandleDeleteModalOpen;
|
handleDeleteModalOpen: HandleDeleteModalOpen;
|
||||||
|
hasSearchTerm: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoutingPolicyListItemProps {
|
export interface RoutingPolicyListItemProps {
|
||||||
|
|||||||
@ -5,10 +5,6 @@ import { SuccessResponseV2 } from 'types/api';
|
|||||||
|
|
||||||
import { RoutingPolicy } from './types';
|
import { RoutingPolicy } from './types';
|
||||||
|
|
||||||
export function showRoutingPoliciesPage(): boolean {
|
|
||||||
return localStorage.getItem('showRoutingPoliciesPage') === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapApiResponseToRoutingPolicies(
|
export function mapApiResponseToRoutingPolicies(
|
||||||
response: SuccessResponseV2<GetRoutingPoliciesResponse>,
|
response: SuccessResponseV2<GetRoutingPoliciesResponse>,
|
||||||
): RoutingPolicy[] {
|
): RoutingPolicy[] {
|
||||||
@ -36,9 +32,7 @@ export function mapRoutingPolicyToCreateApiPayload(
|
|||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
expression,
|
expression,
|
||||||
actions: {
|
channels,
|
||||||
channels,
|
|
||||||
},
|
|
||||||
description,
|
description,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -53,9 +47,7 @@ export function mapRoutingPolicyToUpdateApiPayload(
|
|||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
expression,
|
expression,
|
||||||
actions: {
|
channels,
|
||||||
channels,
|
|
||||||
},
|
|
||||||
description,
|
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 RouteTab from 'components/RouteTab';
|
||||||
import Spinner from 'components/Spinner';
|
import Spinner from 'components/Spinner';
|
||||||
import ROUTES from 'constants/routes';
|
import ROUTES from 'constants/routes';
|
||||||
|
import { CreateAlertProvider } from 'container/CreateAlertV2/context';
|
||||||
|
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
|
||||||
import history from 'lib/history';
|
import history from 'lib/history';
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocation } from 'react-router-dom';
|
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 AlertHeader from './AlertHeader/AlertHeader';
|
||||||
import { useGetAlertRuleDetails, useRouteTabUtils } from './hooks';
|
import { useGetAlertRuleDetails, useRouteTabUtils } from './hooks';
|
||||||
@ -85,6 +89,16 @@ function AlertDetails(): JSX.Element {
|
|||||||
document.title = alertTitle || document.title;
|
document.title = alertTitle || document.title;
|
||||||
}, [alertDetailsResponse?.payload?.data.alert, isRefetching]);
|
}, [alertDetailsResponse?.payload?.data.alert, isRefetching]);
|
||||||
|
|
||||||
|
const alertRuleDetails = useMemo(
|
||||||
|
() => alertDetailsResponse?.payload?.data as PostableAlertRuleV2 | undefined,
|
||||||
|
[alertDetailsResponse],
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialAlertState = useMemo(
|
||||||
|
() => getCreateAlertLocalStateFromAlertDef(alertRuleDetails),
|
||||||
|
[alertRuleDetails],
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isError ||
|
isError ||
|
||||||
!isValidRuleId ||
|
!isValidRuleId ||
|
||||||
@ -104,36 +118,43 @@ function AlertDetails(): JSX.Element {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="alert-details">
|
<CreateAlertProvider
|
||||||
<Breadcrumb
|
ruleId={ruleId || ''}
|
||||||
className="alert-details__breadcrumb"
|
isEditMode
|
||||||
items={[
|
initialAlertType={alertRuleDetails?.alertType as AlertTypes}
|
||||||
{
|
initialAlertState={initialAlertState}
|
||||||
title: (
|
>
|
||||||
<BreadCrumbItem title="Alert Rules" route={ROUTES.LIST_ALL_ALERT} />
|
<div className="alert-details">
|
||||||
),
|
<Breadcrumb
|
||||||
},
|
className="alert-details__breadcrumb"
|
||||||
{
|
items={[
|
||||||
title: <BreadCrumbItem title={ruleId} isLast />,
|
{
|
||||||
},
|
title: (
|
||||||
]}
|
<BreadCrumbItem title="Alert Rules" route={ROUTES.LIST_ALL_ALERT} />
|
||||||
/>
|
),
|
||||||
<Divider className="divider breadcrumb-divider" />
|
},
|
||||||
|
{
|
||||||
<AlertDetailsStatusRenderer
|
title: <BreadCrumbItem title={ruleId} isLast />,
|
||||||
{...{ isLoading, isError, isRefetching, data: alertDetailsResponse }}
|
},
|
||||||
/>
|
]}
|
||||||
<Divider className="divider" />
|
|
||||||
<div className="tabs-and-filters">
|
|
||||||
<RouteTab
|
|
||||||
routes={routes}
|
|
||||||
activeKey={pathname}
|
|
||||||
history={history}
|
|
||||||
onChangeHandler={handleTabChange}
|
|
||||||
tabBarExtraContent={<Filters />}
|
|
||||||
/>
|
/>
|
||||||
|
<Divider className="divider breadcrumb-divider" />
|
||||||
|
|
||||||
|
<AlertDetailsStatusRenderer
|
||||||
|
{...{ isLoading, isError, isRefetching, data: alertDetailsResponse }}
|
||||||
|
/>
|
||||||
|
<Divider className="divider" />
|
||||||
|
<div className="tabs-and-filters">
|
||||||
|
<RouteTab
|
||||||
|
routes={routes}
|
||||||
|
activeKey={pathname}
|
||||||
|
history={history}
|
||||||
|
onChangeHandler={handleTabChange}
|
||||||
|
tabBarExtraContent={<Filters />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CreateAlertProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import CopyToClipboard from 'periscope/components/CopyToClipboard';
|
|||||||
import { useAlertRule } from 'providers/Alert';
|
import { useAlertRule } from 'providers/Alert';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { CSSProperties } from 'styled-components';
|
import { CSSProperties } from 'styled-components';
|
||||||
|
import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
|
||||||
import { AlertDef } from 'types/api/alerts/def';
|
import { AlertDef } from 'types/api/alerts/def';
|
||||||
|
|
||||||
import { AlertHeaderProps } from '../AlertHeader';
|
import { AlertHeaderProps } from '../AlertHeader';
|
||||||
@ -60,14 +61,20 @@ function AlertActionButtons({
|
|||||||
setIsRenameAlertOpen(false);
|
setIsRenameAlertOpen(false);
|
||||||
}, [handleAlertUpdate]);
|
}, [handleAlertUpdate]);
|
||||||
|
|
||||||
|
const isV2Alert = alertDetails.schemaVersion === NEW_ALERT_SCHEMA_VERSION;
|
||||||
|
|
||||||
const menuItems: MenuProps['items'] = [
|
const menuItems: MenuProps['items'] = [
|
||||||
{
|
...(!isV2Alert
|
||||||
key: 'rename-rule',
|
? [
|
||||||
label: 'Rename',
|
{
|
||||||
icon: <PenLine size={16} color={Color.BG_VANILLA_400} />,
|
key: 'rename-rule',
|
||||||
onClick: handleRename,
|
label: 'Rename',
|
||||||
style: menuItemStyle,
|
icon: <PenLine size={16} color={Color.BG_VANILLA_400} />,
|
||||||
},
|
onClick: handleRename,
|
||||||
|
style: menuItemStyle,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
key: 'duplicate-rule',
|
key: 'duplicate-rule',
|
||||||
label: 'Duplicate',
|
label: 'Duplicate',
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
import './AlertHeader.styles.scss';
|
import './AlertHeader.styles.scss';
|
||||||
|
|
||||||
|
import CreateAlertV2Header from 'container/CreateAlertV2/CreateAlertHeader';
|
||||||
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
|
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
|
||||||
import { useAlertRule } from 'providers/Alert';
|
import { useAlertRule } from 'providers/Alert';
|
||||||
import { useMemo, useState } from 'react';
|
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 AlertActionButtons from './ActionButtons/ActionButtons';
|
||||||
import AlertLabels from './AlertLabels/AlertLabels';
|
import AlertLabels from './AlertLabels/AlertLabels';
|
||||||
@ -10,13 +16,7 @@ import AlertSeverity from './AlertSeverity/AlertSeverity';
|
|||||||
import AlertState from './AlertState/AlertState';
|
import AlertState from './AlertState/AlertState';
|
||||||
|
|
||||||
export type AlertHeaderProps = {
|
export type AlertHeaderProps = {
|
||||||
alertDetails: {
|
alertDetails: GettableAlert | PostableAlertRuleV2;
|
||||||
state: string;
|
|
||||||
alert: string;
|
|
||||||
id: string;
|
|
||||||
labels: Record<string, string | undefined> | undefined;
|
|
||||||
disabled: boolean;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
|
function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
|
||||||
const { state, alert: alertName, labels } = alertDetails;
|
const { state, alert: alertName, labels } = alertDetails;
|
||||||
@ -32,32 +32,38 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
|
|||||||
return {};
|
return {};
|
||||||
}, [labels]);
|
}, [labels]);
|
||||||
|
|
||||||
return (
|
const isV2Alert = alertDetails.schemaVersion === NEW_ALERT_SCHEMA_VERSION;
|
||||||
<div className="alert-info">
|
|
||||||
<div className="alert-info__info-wrapper">
|
const CreateAlertV1Header = (
|
||||||
<div className="top-section">
|
<div className="alert-info__info-wrapper">
|
||||||
<div className="alert-title-wrapper">
|
<div className="top-section">
|
||||||
<AlertState state={alertRuleState ?? state} />
|
<div className="alert-title-wrapper">
|
||||||
<div className="alert-title">
|
<AlertState state={alertRuleState ?? state ?? ''} />
|
||||||
<LineClampedText text={updatedName || alertName} />
|
<div className="alert-title">
|
||||||
</div>
|
<LineClampedText text={updatedName || alertName} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bottom-section">
|
</div>
|
||||||
{labels?.severity && <AlertSeverity severity={labels.severity} />}
|
<div className="bottom-section">
|
||||||
|
{labels?.severity && <AlertSeverity severity={labels.severity} />}
|
||||||
|
|
||||||
{/* // TODO(shaheer): Get actual data when we are able to get alert firing from state from API */}
|
{/* // TODO(shaheer): Get actual data when we are able to get alert firing from state from API */}
|
||||||
{/* <AlertStatus
|
{/* <AlertStatus
|
||||||
status="firing"
|
status="firing"
|
||||||
timestamp={dayjs().subtract(1, 'd').valueOf()}
|
timestamp={dayjs().subtract(1, 'd').valueOf()}
|
||||||
/> */}
|
/> */}
|
||||||
<AlertLabels labels={labelsWithoutSeverity} />
|
<AlertLabels labels={labelsWithoutSeverity} />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="alert-info">
|
||||||
|
{isV2Alert ? <CreateAlertV2Header /> : CreateAlertV1Header}
|
||||||
<div className="alert-info__action-buttons">
|
<div className="alert-info__action-buttons">
|
||||||
<AlertActionButtons
|
<AlertActionButtons
|
||||||
alertDetails={alertDetails}
|
alertDetails={alertDetails}
|
||||||
ruleId={alertDetails.id}
|
ruleId={alertDetails?.id || ''}
|
||||||
setUpdatedName={setUpdatedName}
|
setUpdatedName={setUpdatedName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import ROUTES from 'constants/routes';
|
|||||||
import AllAlertRules from 'container/ListAlertRules';
|
import AllAlertRules from 'container/ListAlertRules';
|
||||||
import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime';
|
import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime';
|
||||||
import RoutingPolicies from 'container/RoutingPolicies';
|
import RoutingPolicies from 'container/RoutingPolicies';
|
||||||
import { showRoutingPoliciesPage } from 'container/RoutingPolicies/utils';
|
|
||||||
import TriggeredAlerts from 'container/TriggeredAlerts';
|
import TriggeredAlerts from 'container/TriggeredAlerts';
|
||||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||||
import useUrlQuery from 'hooks/useUrlQuery';
|
import useUrlQuery from 'hooks/useUrlQuery';
|
||||||
@ -28,36 +27,27 @@ function AllAlertList(): JSX.Element {
|
|||||||
|
|
||||||
const search = urlQuery.get('search');
|
const search = urlQuery.get('search');
|
||||||
|
|
||||||
const showRoutingPoliciesPageFlag = showRoutingPoliciesPage();
|
|
||||||
|
|
||||||
const configurationTab = useMemo(() => {
|
const configurationTab = useMemo(() => {
|
||||||
if (showRoutingPoliciesPageFlag) {
|
const tabs = [
|
||||||
const tabs = [
|
{
|
||||||
{
|
label: 'Planned Downtime',
|
||||||
label: 'Planned Downtime',
|
key: 'planned-downtime',
|
||||||
key: 'planned-downtime',
|
children: <PlannedDowntime />,
|
||||||
children: <PlannedDowntime />,
|
},
|
||||||
},
|
{
|
||||||
{
|
label: 'Routing Policies',
|
||||||
label: 'Routing Policies',
|
key: 'routing-policies',
|
||||||
key: 'routing-policies',
|
children: <RoutingPolicies />,
|
||||||
children: <RoutingPolicies />,
|
},
|
||||||
},
|
];
|
||||||
];
|
|
||||||
return (
|
|
||||||
<Tabs
|
|
||||||
className="configuration-tabs"
|
|
||||||
defaultActiveKey="planned-downtime"
|
|
||||||
items={tabs}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="planned-downtime-container">
|
<Tabs
|
||||||
<PlannedDowntime />
|
className="configuration-tabs"
|
||||||
</div>
|
defaultActiveKey="planned-downtime"
|
||||||
|
items={tabs}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}, [showRoutingPoliciesPageFlag]);
|
}, []);
|
||||||
|
|
||||||
const items: TabsProps['items'] = [
|
const items: TabsProps['items'] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -14,6 +14,10 @@ import history from 'lib/history';
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery } from 'react-query';
|
import { useQuery } from 'react-query';
|
||||||
|
import {
|
||||||
|
NEW_ALERT_SCHEMA_VERSION,
|
||||||
|
PostableAlertRuleV2,
|
||||||
|
} from 'types/api/alerts/alertTypesV2';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
errorMessageReceivedFromBackend,
|
errorMessageReceivedFromBackend,
|
||||||
@ -88,9 +92,18 @@ function EditRules(): JSX.Element {
|
|||||||
return <Spinner tip="Loading Rules..." />;
|
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 (
|
return (
|
||||||
<div className="edit-rules-container">
|
<div className="edit-rules-container">
|
||||||
<EditRulesContainer ruleId={ruleId || ''} initialValue={data.payload.data} />
|
<EditRulesContainer
|
||||||
|
ruleId={ruleId || ''}
|
||||||
|
initialValue={data.payload.data}
|
||||||
|
initialV2AlertValue={initialV2AlertValue}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,9 +13,10 @@ export interface BasicThreshold {
|
|||||||
|
|
||||||
export interface PostableAlertRuleV2 {
|
export interface PostableAlertRuleV2 {
|
||||||
schemaVersion: string;
|
schemaVersion: string;
|
||||||
|
id?: string;
|
||||||
alert: string;
|
alert: string;
|
||||||
alertType: AlertTypes;
|
alertType?: AlertTypes;
|
||||||
ruleType: string;
|
ruleType?: string;
|
||||||
condition: {
|
condition: {
|
||||||
thresholds?: {
|
thresholds?: {
|
||||||
kind: string;
|
kind: string;
|
||||||
@ -28,13 +29,13 @@ export interface PostableAlertRuleV2 {
|
|||||||
requireMinPoints?: boolean;
|
requireMinPoints?: boolean;
|
||||||
requiredNumPoints?: number;
|
requiredNumPoints?: number;
|
||||||
};
|
};
|
||||||
evaluation: {
|
evaluation?: {
|
||||||
kind: 'rolling' | 'cumulative';
|
kind?: 'rolling' | 'cumulative';
|
||||||
spec: {
|
spec?: {
|
||||||
evalWindow?: string;
|
evalWindow?: string;
|
||||||
frequency: string;
|
frequency?: string;
|
||||||
schedule?: {
|
schedule?: {
|
||||||
type: 'hourly' | 'daily' | 'monthly';
|
type?: 'hourly' | 'daily' | 'monthly';
|
||||||
minute?: number;
|
minute?: number;
|
||||||
hour?: number;
|
hour?: number;
|
||||||
day?: number;
|
day?: number;
|
||||||
@ -42,19 +43,24 @@ export interface PostableAlertRuleV2 {
|
|||||||
timezone?: string;
|
timezone?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
labels: Labels;
|
labels?: Labels;
|
||||||
annotations: {
|
annotations?: {
|
||||||
description: string;
|
description: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
};
|
};
|
||||||
notificationSettings: {
|
notificationSettings?: {
|
||||||
notificationGroupBy: string[];
|
groupBy?: string[];
|
||||||
renotify?: string;
|
renotify?: {
|
||||||
alertStates: string[];
|
enabled: boolean;
|
||||||
notificationPolicy: boolean;
|
interval?: string;
|
||||||
|
alertStates?: string[];
|
||||||
|
};
|
||||||
|
usePolicy?: boolean;
|
||||||
};
|
};
|
||||||
version: string;
|
version?: string;
|
||||||
source: string;
|
source?: string;
|
||||||
|
state?: string;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AlertRuleV2 extends PostableAlertRuleV2 {
|
export interface AlertRuleV2 extends PostableAlertRuleV2 {
|
||||||
@ -66,3 +72,5 @@ export interface AlertRuleV2 extends PostableAlertRuleV2 {
|
|||||||
updateAt: string;
|
updateAt: string;
|
||||||
updateBy: string;
|
updateBy: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const NEW_ALERT_SCHEMA_VERSION = 'v2alpha1';
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export interface GettableAlert extends AlertDef {
|
|||||||
createBy: string;
|
createBy: string;
|
||||||
updateAt: string;
|
updateAt: string;
|
||||||
updateBy: string;
|
updateBy: string;
|
||||||
|
schemaVersion: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PayloadProps = {
|
export type PayloadProps = {
|
||||||
|
|||||||
4
go.mod
4
go.mod
@ -127,7 +127,7 @@ require (
|
|||||||
github.com/elastic/lunes v0.1.0 // indirect
|
github.com/elastic/lunes v0.1.0 // indirect
|
||||||
github.com/emirpasic/gods v1.18.1 // indirect
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
github.com/envoyproxy/protoc-gen-validate v1.2.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/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
@ -338,3 +338,5 @@ require (
|
|||||||
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
|
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
|
||||||
sigs.k8s.io/yaml v1.6.0 // 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 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
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/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 h1:Bk43AsDYe0fhkbj57eGXx8H3ZJ4zhmQXBnrW523ktj8=
|
||||||
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd/go.mod h1:nxRcH/OEdM8QxzH37xkGzomr1O0JpYBRS6pwjsWW6Pc=
|
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=
|
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 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 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
|
||||||
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
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 h1:IT4JYU7k4ikYg1SCxNI1/Tieq/NFvh6dzLdgi7eu0tM=
|
||||||
github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb/go.mod h1:bH6Xx7IW64qjjJq8M2u4dxNaBiDfKK+z/3eGDpXEQhc=
|
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=
|
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||||
|
|||||||
@ -3,6 +3,8 @@ package alertmanager
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
amConfig "github.com/prometheus/alertmanager/config"
|
||||||
|
|
||||||
"github.com/SigNoz/signoz/pkg/errors"
|
"github.com/SigNoz/signoz/pkg/errors"
|
||||||
"github.com/SigNoz/signoz/pkg/factory"
|
"github.com/SigNoz/signoz/pkg/factory"
|
||||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||||
@ -26,7 +28,7 @@ type Alertmanager interface {
|
|||||||
TestReceiver(context.Context, string, alertmanagertypes.Receiver) error
|
TestReceiver(context.Context, string, alertmanagertypes.Receiver) error
|
||||||
|
|
||||||
// TestAlert sends an alert to a list of receivers.
|
// 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 lists all channels for the organization.
|
||||||
ListChannels(context.Context, string) ([]*alertmanagertypes.Channel, error)
|
ListChannels(context.Context, string) ([]*alertmanagertypes.Channel, error)
|
||||||
@ -59,6 +61,19 @@ type Alertmanager interface {
|
|||||||
|
|
||||||
DeleteNotificationConfig(ctx context.Context, orgID valuer.UUID, ruleId string) error
|
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.
|
// Collects stats for the organization.
|
||||||
statsreporter.StatsCollector
|
statsreporter.StatsCollector
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,19 +10,17 @@ import (
|
|||||||
|
|
||||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
|
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
|
||||||
"github.com/SigNoz/signoz/pkg/errors"
|
"github.com/SigNoz/signoz/pkg/errors"
|
||||||
|
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||||
|
|
||||||
"github.com/prometheus/alertmanager/dispatch"
|
"github.com/prometheus/alertmanager/dispatch"
|
||||||
"github.com/prometheus/alertmanager/notify"
|
"github.com/prometheus/alertmanager/notify"
|
||||||
|
"github.com/prometheus/alertmanager/pkg/labels"
|
||||||
"github.com/prometheus/alertmanager/provider"
|
"github.com/prometheus/alertmanager/provider"
|
||||||
"github.com/prometheus/alertmanager/store"
|
"github.com/prometheus/alertmanager/store"
|
||||||
"github.com/prometheus/alertmanager/types"
|
"github.com/prometheus/alertmanager/types"
|
||||||
"github.com/prometheus/common/model"
|
"github.com/prometheus/common/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
noDataLabel = model.LabelName("nodata")
|
|
||||||
)
|
|
||||||
|
|
||||||
// Dispatcher sorts incoming alerts into aggregation groups and
|
// Dispatcher sorts incoming alerts into aggregation groups and
|
||||||
// assigns the correct notifiers to each.
|
// assigns the correct notifiers to each.
|
||||||
type Dispatcher struct {
|
type Dispatcher struct {
|
||||||
@ -46,6 +44,7 @@ type Dispatcher struct {
|
|||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
notificationManager nfmanager.NotificationManager
|
notificationManager nfmanager.NotificationManager
|
||||||
orgID string
|
orgID string
|
||||||
|
receiverRoutes map[string]*dispatch.Route
|
||||||
}
|
}
|
||||||
|
|
||||||
// We use the upstream Limits interface from Prometheus
|
// We use the upstream Limits interface from Prometheus
|
||||||
@ -90,6 +89,7 @@ func (d *Dispatcher) Run() {
|
|||||||
|
|
||||||
d.mtx.Lock()
|
d.mtx.Lock()
|
||||||
d.aggrGroupsPerRoute = map[*dispatch.Route]map[model.Fingerprint]*aggrGroup{}
|
d.aggrGroupsPerRoute = map[*dispatch.Route]map[model.Fingerprint]*aggrGroup{}
|
||||||
|
d.receiverRoutes = map[string]*dispatch.Route{}
|
||||||
d.aggrGroupsNum = 0
|
d.aggrGroupsNum = 0
|
||||||
d.metrics.aggrGroups.Set(0)
|
d.metrics.aggrGroups.Set(0)
|
||||||
d.ctx, d.cancel = context.WithCancel(context.Background())
|
d.ctx, d.cancel = context.WithCancel(context.Background())
|
||||||
@ -125,8 +125,14 @@ func (d *Dispatcher) run(it provider.AlertIterator) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for _, r := range d.route.Match(alert.Labels) {
|
channels, err := d.notificationManager.Match(d.ctx, d.orgID, getRuleIDFromAlert(alert), alert.Labels)
|
||||||
d.processAlert(alert, r)
|
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())
|
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
|
// processAlert determines in which aggregation group the alert falls
|
||||||
// and inserts it.
|
// and inserts it.
|
||||||
|
// no data alert will only have ruleId and no data label
|
||||||
func (d *Dispatcher) processAlert(alert *types.Alert, route *dispatch.Route) {
|
func (d *Dispatcher) processAlert(alert *types.Alert, route *dispatch.Route) {
|
||||||
ruleId := getRuleIDFromAlert(alert)
|
ruleId := getRuleIDFromAlert(alert)
|
||||||
config, err := d.notificationManager.GetNotificationConfig(d.orgID, ruleId)
|
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)
|
d.logger.ErrorContext(d.ctx, "error getting alert notification config", "rule_id", ruleId, "error", err)
|
||||||
return
|
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()
|
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())
|
d.logger.ErrorContext(d.ctx, "Too many aggregation groups, cannot create new group for alert", "groups", d.aggrGroupsNum, "limit", limit, "alert", alert.Name())
|
||||||
return
|
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)
|
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
|
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{}
|
groupLabels := model.LabelSet{}
|
||||||
for ln, lv := range alert.Labels {
|
for ln, lv := range alert.Labels {
|
||||||
if _, ok := groups[ln]; ok {
|
if _, ok := groups[ln]; ok || groupByAll {
|
||||||
groupLabels[ln] = lv
|
groupLabels[ln] = lv
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return groupLabels
|
return groupLabels
|
||||||
}
|
}
|
||||||
|
|
||||||
func noDataAlert(alert *types.Alert) bool {
|
func (d *Dispatcher) getOrCreateRoute(receiver string) *dispatch.Route {
|
||||||
if _, ok := alert.Labels[noDataLabel]; ok {
|
d.mtx.Lock()
|
||||||
return true
|
defer d.mtx.Unlock()
|
||||||
} else {
|
if route, exists := d.receiverRoutes[receiver]; exists {
|
||||||
return false
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/prometheus/alertmanager/types"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"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 {
|
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 {
|
func (server *Server) TestAlert(ctx context.Context, receiversMap map[*alertmanagertypes.PostableAlert][]string, config *alertmanagertypes.NotificationConfig) error {
|
||||||
alerts, err := alertmanagertypes.NewAlertsFromPostableAlerts(alertmanagertypes.PostableAlerts{postableAlert}, time.Duration(server.srvConfig.Global.ResolveTimeout), time.Now())
|
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 {
|
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 {
|
type alertGroup struct {
|
||||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "expected 1 alert, got %d", len(alerts))
|
groupLabels model.LabelSet
|
||||||
|
alerts []*types.Alert
|
||||||
|
receivers map[string]struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
ch := make(chan error, len(receivers))
|
groupMap := make(map[model.Fingerprint]*alertGroup)
|
||||||
for _, receiverName := range receivers {
|
|
||||||
go func(receiverName string) {
|
for i, alert := range alerts {
|
||||||
receiver, err := server.alertmanagerConfig.GetReceiver(receiverName)
|
labels := getGroupLabels(alert, config.NotificationGroup, config.GroupByAll)
|
||||||
if err != nil {
|
fp := labels.Fingerprint()
|
||||||
ch <- err
|
|
||||||
return
|
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,
|
||||||
}
|
}
|
||||||
ch <- alertmanagertypes.TestReceiver(ctx, receiver, alertmanagernotify.NewReceiverIntegrations, server.alertmanagerConfig, server.tmpl, server.logger, alerts[0])
|
|
||||||
}(receiverName)
|
|
||||||
}
|
|
||||||
|
|
||||||
var errs []error
|
|
||||||
for i := 0; i < len(receivers); i++ {
|
|
||||||
if err := <-ch; err != nil {
|
|
||||||
errs = append(errs, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if errs != nil {
|
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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 len(errs) > 0 {
|
||||||
return errors.Join(errs...)
|
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/alertmanager/config"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
commoncfg "github.com/prometheus/common/config"
|
commoncfg "github.com/prometheus/common/config"
|
||||||
|
"github.com/prometheus/common/model"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"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.Equal(t, gettableAlerts[0].Alert.Labels["alertname"], "test-alert")
|
||||||
assert.NoError(t, server.Stop(context.Background()))
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
@ -273,3 +274,128 @@ func (api *API) CreateChannel(rw http.ResponseWriter, req *http.Request) {
|
|||||||
|
|
||||||
render.Success(rw, http.StatusNoContent, nil)
|
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,20 +1,29 @@
|
|||||||
package nfmanagertest
|
package nfmanagertest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||||
|
"github.com/prometheus/common/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockNotificationManager is a simple mock implementation of NotificationManager
|
// MockNotificationManager is a simple mock implementation of NotificationManager
|
||||||
type MockNotificationManager struct {
|
type MockNotificationManager struct {
|
||||||
configs map[string]*alertmanagertypes.NotificationConfig
|
configs map[string]*alertmanagertypes.NotificationConfig
|
||||||
errors map[string]error
|
routes map[string]*alertmanagertypes.RoutePolicy
|
||||||
|
routesByName map[string][]*alertmanagertypes.RoutePolicy
|
||||||
|
errors map[string]error
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMock creates a new mock notification manager
|
// NewMock creates a new mock notification manager
|
||||||
func NewMock() *MockNotificationManager {
|
func NewMock() *MockNotificationManager {
|
||||||
return &MockNotificationManager{
|
return &MockNotificationManager{
|
||||||
configs: make(map[string]*alertmanagertypes.NotificationConfig),
|
configs: make(map[string]*alertmanagertypes.NotificationConfig),
|
||||||
errors: make(map[string]error),
|
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() {
|
func (m *MockNotificationManager) ClearMockData() {
|
||||||
m.configs = make(map[string]*alertmanagertypes.NotificationConfig)
|
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)
|
m.errors = make(map[string]error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,3 +84,241 @@ func (m *MockNotificationManager) HasConfig(orgID, ruleID string) bool {
|
|||||||
_, exists := m.configs[key]
|
_, exists := m.configs[key]
|
||||||
return exists
|
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
|
package nfmanager
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
"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 {
|
type NotificationManager interface {
|
||||||
|
// Notification Config CRUD
|
||||||
GetNotificationConfig(orgID string, ruleID string) (*alertmanagertypes.NotificationConfig, error)
|
GetNotificationConfig(orgID string, ruleID string) (*alertmanagertypes.NotificationConfig, error)
|
||||||
SetNotificationConfig(orgID string, ruleID string, config *alertmanagertypes.NotificationConfig) error
|
SetNotificationConfig(orgID string, ruleID string, config *alertmanagertypes.NotificationConfig) error
|
||||||
DeleteNotificationConfig(orgID string, ruleID string) 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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
|
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
|
||||||
"github.com/SigNoz/signoz/pkg/errors"
|
"github.com/SigNoz/signoz/pkg/errors"
|
||||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||||
|
"github.com/expr-lang/expr"
|
||||||
|
"github.com/prometheus/common/model"
|
||||||
|
|
||||||
"github.com/SigNoz/signoz/pkg/factory"
|
"github.com/SigNoz/signoz/pkg/factory"
|
||||||
)
|
)
|
||||||
@ -14,26 +17,28 @@ import (
|
|||||||
type provider struct {
|
type provider struct {
|
||||||
settings factory.ScopedProviderSettings
|
settings factory.ScopedProviderSettings
|
||||||
orgToFingerprintToNotificationConfig map[string]map[string]alertmanagertypes.NotificationConfig
|
orgToFingerprintToNotificationConfig map[string]map[string]alertmanagertypes.NotificationConfig
|
||||||
|
routeStore alertmanagertypes.RouteStore
|
||||||
mutex sync.RWMutex
|
mutex sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFactory creates a new factory for the rule-based grouping strategy.
|
// 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(
|
return factory.NewProviderFactory(
|
||||||
factory.MustNewName("rulebased"),
|
factory.MustNewName("rulebased"),
|
||||||
func(ctx context.Context, settings factory.ProviderSettings, config nfmanager.Config) (nfmanager.NotificationManager, error) {
|
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.
|
// 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")
|
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/rulebasednotification")
|
||||||
|
|
||||||
return &provider{
|
return &provider{
|
||||||
settings: settings,
|
settings: settings,
|
||||||
orgToFingerprintToNotificationConfig: make(map[string]map[string]alertmanagertypes.NotificationConfig),
|
orgToFingerprintToNotificationConfig: make(map[string]map[string]alertmanagertypes.NotificationConfig),
|
||||||
|
routeStore: routeStore,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,6 +63,8 @@ func (r *provider) GetNotificationConfig(orgID string, ruleID string) (*alertman
|
|||||||
for k, v := range config.NotificationGroup {
|
for k, v := range config.NotificationGroup {
|
||||||
notificationConfig.NotificationGroup[k] = v
|
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
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"github.com/prometheus/common/model"
|
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
|
"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/factory"
|
||||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
"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/types/alertmanagertypes"
|
||||||
"github.com/prometheus/alertmanager/types"
|
"github.com/SigNoz/signoz/pkg/valuer"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/prometheus/common/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func createTestProviderSettings() factory.ProviderSettings {
|
func createTestProviderSettings() factory.ProviderSettings {
|
||||||
@ -21,7 +25,8 @@ func createTestProviderSettings() factory.ProviderSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewFactory(t *testing.T) {
|
func TestNewFactory(t *testing.T) {
|
||||||
providerFactory := NewFactory()
|
routeStore := nfroutingstoretest.NewMockSQLRouteStore()
|
||||||
|
providerFactory := NewFactory(routeStore)
|
||||||
assert.NotNil(t, providerFactory)
|
assert.NotNil(t, providerFactory)
|
||||||
assert.Equal(t, "rulebased", providerFactory.Name().String())
|
assert.Equal(t, "rulebased", providerFactory.Name().String())
|
||||||
}
|
}
|
||||||
@ -31,7 +36,8 @@ func TestNew(t *testing.T) {
|
|||||||
providerSettings := createTestProviderSettings()
|
providerSettings := createTestProviderSettings()
|
||||||
config := nfmanager.Config{}
|
config := nfmanager.Config{}
|
||||||
|
|
||||||
provider, err := New(ctx, providerSettings, config)
|
routeStore := nfroutingstoretest.NewMockSQLRouteStore()
|
||||||
|
provider, err := New(ctx, providerSettings, config, routeStore)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotNil(t, provider)
|
assert.NotNil(t, provider)
|
||||||
|
|
||||||
@ -44,7 +50,8 @@ func TestProvider_SetNotificationConfig(t *testing.T) {
|
|||||||
providerSettings := createTestProviderSettings()
|
providerSettings := createTestProviderSettings()
|
||||||
config := nfmanager.Config{}
|
config := nfmanager.Config{}
|
||||||
|
|
||||||
provider, err := New(ctx, providerSettings, config)
|
routeStore := nfroutingstoretest.NewMockSQLRouteStore()
|
||||||
|
provider, err := New(ctx, providerSettings, config, routeStore)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@ -124,11 +131,12 @@ func TestProvider_GetNotificationConfig(t *testing.T) {
|
|||||||
providerSettings := createTestProviderSettings()
|
providerSettings := createTestProviderSettings()
|
||||||
config := nfmanager.Config{}
|
config := nfmanager.Config{}
|
||||||
|
|
||||||
provider, err := New(ctx, providerSettings, config)
|
routeStore := nfroutingstoretest.NewMockSQLRouteStore()
|
||||||
|
provider, err := New(ctx, providerSettings, config, routeStore)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
orgID := "test-org"
|
orgID := "test-org"
|
||||||
ruleID := "rule1"
|
ruleID := "ruleId"
|
||||||
customConfig := &alertmanagertypes.NotificationConfig{
|
customConfig := &alertmanagertypes.NotificationConfig{
|
||||||
Renotify: alertmanagertypes.ReNotificationConfig{
|
Renotify: alertmanagertypes.ReNotificationConfig{
|
||||||
RenotifyInterval: 30 * time.Minute,
|
RenotifyInterval: 30 * time.Minute,
|
||||||
@ -144,7 +152,6 @@ func TestProvider_GetNotificationConfig(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set config for alert1
|
|
||||||
err = provider.SetNotificationConfig(orgID, ruleID, customConfig)
|
err = provider.SetNotificationConfig(orgID, ruleID, customConfig)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@ -155,7 +162,7 @@ func TestProvider_GetNotificationConfig(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
orgID string
|
orgID string
|
||||||
ruleID string
|
ruleID string
|
||||||
alert *types.Alert
|
alert *alertmanagertypes.Alert
|
||||||
expectedConfig *alertmanagertypes.NotificationConfig
|
expectedConfig *alertmanagertypes.NotificationConfig
|
||||||
shouldFallback bool
|
shouldFallback bool
|
||||||
}{
|
}{
|
||||||
@ -165,7 +172,7 @@ func TestProvider_GetNotificationConfig(t *testing.T) {
|
|||||||
ruleID: ruleID,
|
ruleID: ruleID,
|
||||||
expectedConfig: &alertmanagertypes.NotificationConfig{
|
expectedConfig: &alertmanagertypes.NotificationConfig{
|
||||||
NotificationGroup: map[model.LabelName]struct{}{
|
NotificationGroup: map[model.LabelName]struct{}{
|
||||||
model.LabelName("ruleId"): {},
|
model.LabelName(ruleID): {},
|
||||||
},
|
},
|
||||||
Renotify: alertmanagertypes.ReNotificationConfig{
|
Renotify: alertmanagertypes.ReNotificationConfig{
|
||||||
RenotifyInterval: 30 * time.Minute,
|
RenotifyInterval: 30 * time.Minute,
|
||||||
@ -182,13 +189,13 @@ func TestProvider_GetNotificationConfig(t *testing.T) {
|
|||||||
NotificationGroup: map[model.LabelName]struct{}{
|
NotificationGroup: map[model.LabelName]struct{}{
|
||||||
model.LabelName("group1"): {},
|
model.LabelName("group1"): {},
|
||||||
model.LabelName("group2"): {},
|
model.LabelName("group2"): {},
|
||||||
model.LabelName("ruleId"): {},
|
model.LabelName(ruleID): {},
|
||||||
},
|
},
|
||||||
Renotify: alertmanagertypes.ReNotificationConfig{
|
Renotify: alertmanagertypes.ReNotificationConfig{
|
||||||
RenotifyInterval: 4 * time.Hour,
|
RenotifyInterval: 4 * time.Hour,
|
||||||
NoDataInterval: 4 * time.Hour,
|
NoDataInterval: 4 * time.Hour,
|
||||||
},
|
},
|
||||||
}, // Will get fallback from standardnotification
|
},
|
||||||
shouldFallback: false,
|
shouldFallback: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -231,7 +238,8 @@ func TestProvider_ConcurrentAccess(t *testing.T) {
|
|||||||
providerSettings := createTestProviderSettings()
|
providerSettings := createTestProviderSettings()
|
||||||
config := nfmanager.Config{}
|
config := nfmanager.Config{}
|
||||||
|
|
||||||
provider, err := New(ctx, providerSettings, config)
|
routeStore := nfroutingstoretest.NewMockSQLRouteStore()
|
||||||
|
provider, err := New(ctx, providerSettings, config, routeStore)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
orgID := "test-org"
|
orgID := "test-org"
|
||||||
@ -268,3 +276,634 @@ func TestProvider_ConcurrentAccess(t *testing.T) {
|
|||||||
// Wait for both goroutines to complete
|
// Wait for both goroutines to complete
|
||||||
wg.Wait()
|
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"
|
"context"
|
||||||
"sync"
|
"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/alertmanagerserver"
|
||||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
|
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
|
||||||
"github.com/SigNoz/signoz/pkg/errors"
|
"github.com/SigNoz/signoz/pkg/errors"
|
||||||
@ -61,6 +64,7 @@ func New(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) SyncServers(ctx context.Context) error {
|
func (service *Service) SyncServers(ctx context.Context) error {
|
||||||
|
compat.InitFromFlags(service.settings.Logger(), featurecontrol.NoopFlags{})
|
||||||
orgs, err := service.orgGetter.ListByOwnedKeyRange(ctx)
|
orgs, err := service.orgGetter.ListByOwnedKeyRange(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -142,7 +146,7 @@ func (service *Service) TestReceiver(ctx context.Context, orgID string, receiver
|
|||||||
return server.TestReceiver(ctx, 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()
|
service.serversMtx.RLock()
|
||||||
defer service.serversMtx.RUnlock()
|
defer service.serversMtx.RUnlock()
|
||||||
|
|
||||||
@ -151,7 +155,7 @@ func (service *Service) TestAlert(ctx context.Context, orgID string, alert *aler
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return server.TestAlert(ctx, alert, receivers)
|
return server.TestAlert(ctx, receiversMap, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) Stop(ctx context.Context) error {
|
func (service *Service) Stop(ctx context.Context) error {
|
||||||
|
|||||||
@ -2,8 +2,12 @@ package signozalertmanager
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
|
||||||
|
"github.com/prometheus/common/model"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
amConfig "github.com/prometheus/alertmanager/config"
|
||||||
|
|
||||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
|
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
|
||||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
|
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
|
||||||
@ -11,7 +15,9 @@ import (
|
|||||||
"github.com/SigNoz/signoz/pkg/factory"
|
"github.com/SigNoz/signoz/pkg/factory"
|
||||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
"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/alertmanagertypes"
|
||||||
|
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||||
"github.com/SigNoz/signoz/pkg/valuer"
|
"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)
|
return provider.service.TestReceiver(ctx, orgID, receiver)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *provider) TestAlert(ctx context.Context, orgID string, alert *alertmanagertypes.PostableAlert, receivers []string) error {
|
func (provider *provider) TestAlert(ctx context.Context, orgID string, ruleID string, receiversMap map[*alertmanagertypes.PostableAlert][]string) error {
|
||||||
return provider.service.TestAlert(ctx, orgID, alert, receivers)
|
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) {
|
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
|
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"
|
"fmt"
|
||||||
"github.com/SigNoz/signoz/pkg/modules/thirdpartyapi"
|
"github.com/SigNoz/signoz/pkg/modules/thirdpartyapi"
|
||||||
|
|
||||||
//qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"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/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/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/alerts", am.ViewAccess(aH.AlertmanagerAPI.GetAlerts)).Methods(http.MethodGet)
|
||||||
|
|
||||||
router.HandleFunc("/api/v1/rules", am.ViewAccess(aH.listRules)).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
|
// Export
|
||||||
router.HandleFunc("/api/v1/export_raw_data", am.ViewAccess(aH.Signoz.Handlers.RawDataExport.ExportRawData)).Methods(http.MethodGet)
|
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) {
|
func (ah *APIHandler) MetricExplorerRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||||
|
|||||||
@ -4,13 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/SigNoz/signoz/pkg/errors"
|
"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/interfaces"
|
||||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
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
|
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 {
|
func (r *BaseRule) matchType() ruletypes.MatchType {
|
||||||
if r.ruleCondition == nil {
|
if r.ruleCondition == nil {
|
||||||
return ruletypes.AtleastOnce
|
return ruletypes.AtleastOnce
|
||||||
@ -221,10 +203,6 @@ func (r *BaseRule) HoldDuration() time.Duration {
|
|||||||
return r.holdDuration
|
return r.holdDuration
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *BaseRule) TargetVal() float64 {
|
|
||||||
return r.targetVal()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ThresholdRule) hostFromSource() string {
|
func (r *ThresholdRule) hostFromSource() string {
|
||||||
parsedUrl, err := url.Parse(r.source)
|
parsedUrl, err := url.Parse(r.source)
|
||||||
if err != nil {
|
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 {
|
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))
|
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{}
|
revisedItemsToAdd := map[uint64]model.RuleStateHistory{}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package rules
|
package rules
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||||
@ -22,6 +23,15 @@ func TestBaseRule_RequireMinPoints(t *testing.T) {
|
|||||||
RequireMinPoints: true,
|
RequireMinPoints: true,
|
||||||
RequiredNumPoints: 4,
|
RequiredNumPoints: 4,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
Threshold: ruletypes.BasicRuleThresholds{
|
||||||
|
{
|
||||||
|
Name: "test-threshold",
|
||||||
|
TargetValue: &threshold,
|
||||||
|
CompareOp: ruletypes.ValueIsAbove,
|
||||||
|
MatchType: ruletypes.AtleastOnce,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
series: &v3.Series{
|
series: &v3.Series{
|
||||||
Points: []v3.Point{
|
Points: []v3.Point{
|
||||||
@ -41,6 +51,14 @@ func TestBaseRule_RequireMinPoints(t *testing.T) {
|
|||||||
MatchType: ruletypes.AtleastOnce,
|
MatchType: ruletypes.AtleastOnce,
|
||||||
Target: &threshold,
|
Target: &threshold,
|
||||||
},
|
},
|
||||||
|
Threshold: ruletypes.BasicRuleThresholds{
|
||||||
|
{
|
||||||
|
Name: "test-threshold",
|
||||||
|
TargetValue: &threshold,
|
||||||
|
CompareOp: ruletypes.ValueIsAbove,
|
||||||
|
MatchType: ruletypes.AtleastOnce,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
series: &v3.Series{
|
series: &v3.Series{
|
||||||
Points: []v3.Point{
|
Points: []v3.Point{
|
||||||
@ -56,10 +74,9 @@ func TestBaseRule_RequireMinPoints(t *testing.T) {
|
|||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
_, shouldAlert := test.rule.ShouldAlert(*test.series)
|
_, err := test.rule.Threshold.ShouldAlert(*test.series, "")
|
||||||
if shouldAlert != test.shouldAlert {
|
require.NoError(t, err)
|
||||||
t.Errorf("expected shouldAlert to be %v, got %v", test.shouldAlert, shouldAlert)
|
require.Equal(t, len(test.series.Points) >= test.rule.ruleCondition.RequiredNumPoints, test.shouldAlert)
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@ -350,39 +351,35 @@ func (m *Manager) EditRule(ctx context.Context, ruleStr string, id valuer.UUID)
|
|||||||
existingRule.Data = ruleStr
|
existingRule.Data = ruleStr
|
||||||
|
|
||||||
return m.ruleStore.EditRule(ctx, existingRule, func(ctx context.Context) error {
|
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 {
|
if parsedRule.NotificationSettings != nil {
|
||||||
config := parsedRule.NotificationSettings.GetAlertManagerNotificationConfig()
|
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 {
|
if err != nil {
|
||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
err = m.alertmanager.CreateInhibitRules(ctx, orgID, inhibitRules)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
err = m.syncRuleStateWithTask(ctx, orgID, prepareTaskName(existingRule.ID.StringValue()), &parsedRule)
|
err = m.syncRuleStateWithTask(ctx, orgID, prepareTaskName(existingRule.ID.StringValue()), &parsedRule)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -488,6 +485,19 @@ func (m *Manager) DeleteRule(ctx context.Context, idStr string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err = m.alertmanager.DeleteNotificationConfig(ctx, orgID, id.String())
|
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())
|
taskName := prepareTaskName(id.StringValue())
|
||||||
m.deleteTask(taskName)
|
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 {
|
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 {
|
if parsedRule.NotificationSettings != nil {
|
||||||
config := parsedRule.NotificationSettings.GetAlertManagerNotificationConfig()
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
if !parsedRule.NotificationSettings.UsePolicy {
|
||||||
|
request, err := parsedRule.GetRuleRouteRequest(id.StringValue())
|
||||||
err = cfg.CreateRuleIDMatcher(id.StringValue(), preferredChannels)
|
if err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
}
|
_, err = m.alertmanager.CreateRoutePolicies(ctx, request)
|
||||||
|
if err != nil {
|
||||||
err = m.alertmanager.SetConfig(ctx, cfg)
|
return err
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
taskName := prepareTaskName(id.StringValue())
|
taskName := prepareTaskName(id.StringValue())
|
||||||
@ -756,36 +755,30 @@ func (m *Manager) prepareTestNotifyFunc() NotifyFunc {
|
|||||||
if len(alerts) == 0 {
|
if len(alerts) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ruleID := alerts[0].Labels.Map()[labels.AlertRuleIdLabel]
|
||||||
|
receiverMap := make(map[*alertmanagertypes.PostableAlert][]string)
|
||||||
|
for _, alert := range alerts {
|
||||||
|
generatorURL := alert.GeneratorURL
|
||||||
|
|
||||||
alert := alerts[0]
|
a := &alertmanagertypes.PostableAlert{}
|
||||||
generatorURL := alert.GeneratorURL
|
a.Annotations = alert.Annotations.Map()
|
||||||
|
a.StartsAt = strfmt.DateTime(alert.FiredAt)
|
||||||
a := &alertmanagertypes.PostableAlert{}
|
a.Alert = alertmanagertypes.AlertModel{
|
||||||
a.Annotations = alert.Annotations.Map()
|
Labels: alert.Labels.Map(),
|
||||||
a.StartsAt = strfmt.DateTime(alert.FiredAt)
|
GeneratorURL: strfmt.URI(generatorURL),
|
||||||
a.Alert = alertmanagertypes.AlertModel{
|
|
||||||
Labels: alert.Labels.Map(),
|
|
||||||
GeneratorURL: strfmt.URI(generatorURL),
|
|
||||||
}
|
|
||||||
if !alert.ResolvedAt.IsZero() {
|
|
||||||
a.EndsAt = strfmt.DateTime(alert.ResolvedAt)
|
|
||||||
} else {
|
|
||||||
a.EndsAt = strfmt.DateTime(alert.ValidUntil)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(alert.Receivers) == 0 {
|
|
||||||
channels, err := m.alertmanager.ListChannels(ctx, orgID)
|
|
||||||
if err != nil {
|
|
||||||
zap.L().Error("failed to list channels while sending test notification", zap.Error(err))
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
if !alert.ResolvedAt.IsZero() {
|
||||||
for _, channel := range channels {
|
a.EndsAt = strfmt.DateTime(alert.ResolvedAt)
|
||||||
alert.Receivers = append(alert.Receivers, channel.Name)
|
} else {
|
||||||
|
a.EndsAt = strfmt.DateTime(alert.ValidUntil)
|
||||||
}
|
}
|
||||||
|
receiverMap[a] = alert.Receivers
|
||||||
|
}
|
||||||
|
err := m.alertmanager.TestAlert(ctx, orgID, ruleID, receiverMap)
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Error("failed to send test notification", zap.Error(err))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
if err != nil {
|
||||||
return 0, model.BadRequest(err)
|
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{
|
alertCount, apiErr := m.prepareTestRuleFunc(PrepareTestRuleOptions{
|
||||||
Rule: &parsedRule,
|
Rule: &parsedRule,
|
||||||
|
|||||||
@ -2,10 +2,15 @@ package rules
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"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"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
@ -32,19 +37,38 @@ func TestManager_PatchRule_PayloadVariations(t *testing.T) {
|
|||||||
Email: "test@example.com",
|
Email: "test@example.com",
|
||||||
Role: "admin",
|
Role: "admin",
|
||||||
}
|
}
|
||||||
manager, mockSQLRuleStore, orgId := setupTestManager(t)
|
manager, mockSQLRuleStore, mockRouteStore, nfmanager, orgId := setupTestManager(t)
|
||||||
claims.OrgID = orgId
|
claims.OrgID = orgId
|
||||||
|
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
originalData string
|
originalData string
|
||||||
patchData string
|
patchData string
|
||||||
|
Route []*alertmanagertypes.RoutePolicy
|
||||||
|
Config *alertmanagertypes.NotificationConfig
|
||||||
expectedResult func(*ruletypes.GettableRule) bool
|
expectedResult func(*ruletypes.GettableRule) bool
|
||||||
expectError bool
|
expectError bool
|
||||||
description string
|
description string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "patch complete rule with task sync validation",
|
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: `{
|
originalData: `{
|
||||||
"schemaVersion":"v1",
|
"schemaVersion":"v1",
|
||||||
"alert": "test-original-alert",
|
"alert": "test-original-alert",
|
||||||
@ -95,6 +119,23 @@ func TestManager_PatchRule_PayloadVariations(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "patch rule to disabled state",
|
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: `{
|
originalData: `{
|
||||||
"schemaVersion":"v2",
|
"schemaVersion":"v2",
|
||||||
"alert": "test-disable-alert",
|
"alert": "test-disable-alert",
|
||||||
@ -179,6 +220,20 @@ func TestManager_PatchRule_PayloadVariations(t *testing.T) {
|
|||||||
OrgID: claims.OrgID,
|
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.ExpectGetStoredRule(ruleID, existingRule)
|
||||||
mockSQLRuleStore.ExpectEditRule(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")
|
assert.Nil(t, findTaskByName(manager.RuleTasks(), taskName), "Task should be removed for disabled rule")
|
||||||
} else {
|
} else {
|
||||||
syncCompleted := waitForTaskSync(manager, taskName, true, 2*time.Second)
|
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.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.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")
|
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
|
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()
|
settings := instrumentationtest.New().ToProviderSettings()
|
||||||
testDB := utils.NewQueryServiceDBForTests(t)
|
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)
|
t.Fatalf("Failed to create noop sharder: %v", err)
|
||||||
}
|
}
|
||||||
orgGetter := implorganization.NewGetter(implorganization.NewStore(testDB), noopSharder)
|
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)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create alert manager: %v", err)
|
t.Fatalf("Failed to create alert manager: %v", err)
|
||||||
@ -290,21 +355,40 @@ func setupTestManager(t *testing.T) (*Manager, *rulestoretest.MockSQLRuleStore,
|
|||||||
}
|
}
|
||||||
|
|
||||||
close(manager.block)
|
close(manager.block)
|
||||||
return manager, mockSQLRuleStore, testOrgID.StringValue()
|
return manager, mockSQLRuleStore, routeStore, notificationManager, testOrgID.StringValue()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateRule(t *testing.T) {
|
func TestCreateRule(t *testing.T) {
|
||||||
claims := &authtypes.Claims{
|
claims := &authtypes.Claims{
|
||||||
Email: "test@example.com",
|
Email: "test@example.com",
|
||||||
}
|
}
|
||||||
manager, mockSQLRuleStore, orgId := setupTestManager(t)
|
manager, mockSQLRuleStore, mockRouteStore, nfmanager, orgId := setupTestManager(t)
|
||||||
claims.OrgID = orgId
|
claims.OrgID = orgId
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
|
Route []*alertmanagertypes.RoutePolicy
|
||||||
|
Config *alertmanagertypes.NotificationConfig
|
||||||
ruleStr string
|
ruleStr string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "validate stored rule data structure",
|
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: `{
|
ruleStr: `{
|
||||||
"alert": "cpu usage",
|
"alert": "cpu usage",
|
||||||
"ruleType": "threshold_rule",
|
"ruleType": "threshold_rule",
|
||||||
@ -341,6 +425,30 @@ func TestCreateRule(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "create complete v2 rule with thresholds",
|
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: `{
|
ruleStr: `{
|
||||||
"schemaVersion":"v2",
|
"schemaVersion":"v2",
|
||||||
"state": "firing",
|
"state": "firing",
|
||||||
@ -399,6 +507,18 @@ func TestCreateRule(t *testing.T) {
|
|||||||
"frequency": "1m"
|
"frequency": "1m"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"notificationSettings": {
|
||||||
|
"GroupBy": [
|
||||||
|
"k8s.node.name"
|
||||||
|
],
|
||||||
|
"renotify": {
|
||||||
|
"interval": "10m",
|
||||||
|
"enabled": true,
|
||||||
|
"alertStates": [
|
||||||
|
"firing"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"labels": {
|
"labels": {
|
||||||
"severity": "warning"
|
"severity": "warning"
|
||||||
},
|
},
|
||||||
@ -429,6 +549,20 @@ func TestCreateRule(t *testing.T) {
|
|||||||
},
|
},
|
||||||
OrgID: claims.OrgID,
|
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)
|
mockSQLRuleStore.ExpectCreateRule(rule)
|
||||||
|
|
||||||
ctx := authtypes.NewContextWithClaims(context.Background(), *claims)
|
ctx := authtypes.NewContextWithClaims(context.Background(), *claims)
|
||||||
@ -441,6 +575,12 @@ func TestCreateRule(t *testing.T) {
|
|||||||
// Wait for task creation with proper synchronization
|
// Wait for task creation with proper synchronization
|
||||||
taskName := prepareTaskName(result.Id)
|
taskName := prepareTaskName(result.Id)
|
||||||
syncCompleted := waitForTaskSync(manager, taskName, true, 2*time.Second)
|
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.True(t, syncCompleted, "Task creation should complete within timeout")
|
||||||
assert.NotNil(t, findTaskByName(manager.RuleTasks(), taskName), "Task should be created with correct name")
|
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")
|
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{
|
claims := &authtypes.Claims{
|
||||||
Email: "test@example.com",
|
Email: "test@example.com",
|
||||||
}
|
}
|
||||||
manager, mockSQLRuleStore, orgId := setupTestManager(t)
|
manager, mockSQLRuleStore, mockRouteStore, nfmanager, orgId := setupTestManager(t)
|
||||||
claims.OrgID = orgId
|
claims.OrgID = orgId
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
|
ruleID string
|
||||||
name string
|
name string
|
||||||
|
Route []*alertmanagertypes.RoutePolicy
|
||||||
|
Config *alertmanagertypes.NotificationConfig
|
||||||
ruleStr string
|
ruleStr string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "validate edit rule functionality",
|
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: `{
|
ruleStr: `{
|
||||||
"alert": "updated cpu usage",
|
"alert": "updated cpu usage",
|
||||||
"ruleType": "threshold_rule",
|
"ruleType": "threshold_rule",
|
||||||
@ -498,7 +659,32 @@ func TestEditRule(t *testing.T) {
|
|||||||
}`,
|
}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "edit complete v2 rule with thresholds",
|
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: `{
|
ruleStr: `{
|
||||||
"schemaVersion":"v2",
|
"schemaVersion":"v2",
|
||||||
"state": "firing",
|
"state": "firing",
|
||||||
@ -560,6 +746,18 @@ func TestEditRule(t *testing.T) {
|
|||||||
"labels": {
|
"labels": {
|
||||||
"severity": "critical"
|
"severity": "critical"
|
||||||
},
|
},
|
||||||
|
"notificationSettings": {
|
||||||
|
"GroupBy": [
|
||||||
|
"k8s.node.name"
|
||||||
|
],
|
||||||
|
"renotify": {
|
||||||
|
"interval": "10m",
|
||||||
|
"enabled": true,
|
||||||
|
"alertStates": [
|
||||||
|
"firing"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"annotations": {
|
"annotations": {
|
||||||
"description": "This alert is fired when memory usage crosses the threshold",
|
"description": "This alert is fired when memory usage crosses the threshold",
|
||||||
"summary": "Memory usage threshold exceeded"
|
"summary": "Memory usage threshold exceeded"
|
||||||
@ -573,11 +771,13 @@ func TestEditRule(t *testing.T) {
|
|||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
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{
|
existingRule := &ruletypes.Rule{
|
||||||
Identifiable: types.Identifiable{
|
Identifiable: types.Identifiable{
|
||||||
ID: ruleID,
|
ID: ruleId,
|
||||||
},
|
},
|
||||||
TimeAuditable: types.TimeAuditable{
|
TimeAuditable: types.TimeAuditable{
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
@ -590,18 +790,24 @@ func TestEditRule(t *testing.T) {
|
|||||||
Data: `{"alert": "original cpu usage", "disabled": false}`,
|
Data: `{"alert": "original cpu usage", "disabled": false}`,
|
||||||
OrgID: claims.OrgID,
|
OrgID: claims.OrgID,
|
||||||
}
|
}
|
||||||
|
mockRouteStore.ExpectDeleteRouteByName(existingRule.OrgID, ruleId.String())
|
||||||
mockSQLRuleStore.ExpectGetStoredRule(ruleID, existingRule)
|
mockRouteStore.ExpectCreateBatch(tc.Route)
|
||||||
|
mockSQLRuleStore.ExpectGetStoredRule(ruleId, existingRule)
|
||||||
mockSQLRuleStore.ExpectEditRule(existingRule)
|
mockSQLRuleStore.ExpectEditRule(existingRule)
|
||||||
|
|
||||||
ctx := authtypes.NewContextWithClaims(context.Background(), *claims)
|
ctx := authtypes.NewContextWithClaims(context.Background(), *claims)
|
||||||
err := manager.EditRule(ctx, tc.ruleStr, ruleID)
|
err = manager.EditRule(ctx, tc.ruleStr, ruleId)
|
||||||
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Wait for task update with proper synchronization
|
// Wait for task update with proper synchronization
|
||||||
taskName := prepareTaskName(ruleID.StringValue())
|
|
||||||
|
taskName := prepareTaskName(ruleId.String())
|
||||||
syncCompleted := waitForTaskSync(manager, taskName, true, 2*time.Second)
|
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.True(t, syncCompleted, "Task update should complete within timeout")
|
||||||
assert.NotNil(t, findTaskByName(manager.RuleTasks(), taskName), "Task should be updated with correct name")
|
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")
|
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))
|
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 {
|
for _, series := range res {
|
||||||
|
|
||||||
if len(series.Floats) == 0 {
|
if len(series.Floats) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
results, err := r.Threshold.ShouldAlert(toCommonSeries(series))
|
results, err := r.Threshold.ShouldAlert(toCommonSeries(series), r.Unit())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
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)
|
tmplData := ruletypes.AlertTemplateData(l, valueFormatter.Format(result.V, r.Unit()), threshold)
|
||||||
// Inject some convenience variables that are easier to remember for users
|
// 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
|
r.lastError = err
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
alerts[h] = &ruletypes.Alert{
|
alerts[h] = &ruletypes.Alert{
|
||||||
Labels: lbs,
|
Labels: lbs,
|
||||||
QueryResultLables: resultLabels,
|
QueryResultLables: resultLabels,
|
||||||
@ -227,13 +232,12 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (interface{}, error)
|
|||||||
State: model.StatePending,
|
State: model.StatePending,
|
||||||
Value: result.V,
|
Value: result.V,
|
||||||
GeneratorURL: r.GeneratorURL(),
|
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))
|
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
|
// alerts[h] is ready, add or update active list now
|
||||||
for h, a := range alerts {
|
for h, a := range alerts {
|
||||||
// Check whether we already have alerting state for the identifying label set.
|
// 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 {
|
if alert, ok := r.Active[h]; ok && alert.State != model.StateInactive {
|
||||||
alert.Value = a.Value
|
alert.Value = a.Value
|
||||||
alert.Annotations = a.Annotations
|
alert.Annotations = a.Annotations
|
||||||
alert.Receivers = r.preferredChannels
|
if v, ok := alert.Labels.Map()[ruletypes.LabelThresholdName]; ok {
|
||||||
|
alert.Receivers = ruleReceiverMap[v]
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -696,7 +696,7 @@ func TestPromRuleShouldAlert(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
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)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Compare full result vector with expected vector
|
// Compare full result vector with expected vector
|
||||||
|
|||||||
@ -38,7 +38,6 @@ func defaultTestNotification(opts PrepareTestRuleOptions) (int, *model.ApiError)
|
|||||||
if parsedRule.RuleType == ruletypes.RuleTypeThreshold {
|
if parsedRule.RuleType == ruletypes.RuleTypeThreshold {
|
||||||
|
|
||||||
// add special labels for test alerts
|
// 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.RuleSourceLabel] = ""
|
||||||
parsedRule.Labels[labels.AlertRuleIdLabel] = ""
|
parsedRule.Labels[labels.AlertRuleIdLabel] = ""
|
||||||
|
|
||||||
|
|||||||
@ -488,7 +488,7 @@ func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resultSeries, err := r.Threshold.ShouldAlert(*series)
|
resultSeries, err := r.Threshold.ShouldAlert(*series, r.Unit())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -565,7 +565,7 @@ func (r *ThresholdRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUI
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resultSeries, err := r.Threshold.ShouldAlert(*series)
|
resultSeries, err := r.Threshold.ShouldAlert(*series, r.Unit())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -602,6 +602,12 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (interface{}, er
|
|||||||
resultFPs := map[uint64]struct{}{}
|
resultFPs := map[uint64]struct{}{}
|
||||||
var alerts = make(map[uint64]*ruletypes.Alert, len(res))
|
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 {
|
for _, smpl := range res {
|
||||||
l := make(map[string]string, len(smpl.Metric))
|
l := make(map[string]string, len(smpl.Metric))
|
||||||
for _, lbl := range 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())
|
value := valueFormatter.Format(smpl.V, r.Unit())
|
||||||
//todo(aniket): handle different threshold
|
//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)
|
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)
|
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,
|
State: model.StatePending,
|
||||||
Value: smpl.V,
|
Value: smpl.V,
|
||||||
GeneratorURL: r.GeneratorURL(),
|
GeneratorURL: r.GeneratorURL(),
|
||||||
Receivers: r.preferredChannels,
|
Receivers: ruleReceiverMap[lbs.Map()[ruletypes.LabelThresholdName]],
|
||||||
Missing: smpl.IsMissing,
|
Missing: smpl.IsMissing,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -705,7 +711,9 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (interface{}, er
|
|||||||
|
|
||||||
alert.Value = a.Value
|
alert.Value = a.Value
|
||||||
alert.Annotations = a.Annotations
|
alert.Annotations = a.Annotations
|
||||||
alert.Receivers = r.preferredChannels
|
if v, ok := alert.Labels.Map()[ruletypes.LabelThresholdName]; ok {
|
||||||
|
alert.Receivers = ruleReceiverMap[v]
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -824,7 +824,7 @@ func TestThresholdRuleShouldAlert(t *testing.T) {
|
|||||||
values.Points[i].Timestamp = time.Now().UnixMilli()
|
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)
|
assert.NoError(t, err, "Test case %d", idx)
|
||||||
|
|
||||||
// Compare result vectors with expected behavior
|
// Compare result vectors with expected behavior
|
||||||
@ -1201,7 +1201,7 @@ func TestThresholdRuleLabelNormalization(t *testing.T) {
|
|||||||
values.Points[i].Timestamp = time.Now().UnixMilli()
|
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)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
for name, value := range c.values.Labels {
|
for name, value := range c.values.Labels {
|
||||||
@ -1211,7 +1211,7 @@ func TestThresholdRuleLabelNormalization(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get result vectors from threshold evaluation
|
// 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)
|
assert.NoError(t, err, "Test case %d", idx)
|
||||||
|
|
||||||
// Compare result vectors with expected behavior
|
// Compare result vectors with expected behavior
|
||||||
@ -1501,13 +1501,11 @@ func TestThresholdRuleUnitCombinations(t *testing.T) {
|
|||||||
Kind: ruletypes.BasicThresholdKind,
|
Kind: ruletypes.BasicThresholdKind,
|
||||||
Spec: ruletypes.BasicRuleThresholds{
|
Spec: ruletypes.BasicRuleThresholds{
|
||||||
{
|
{
|
||||||
Name: postableRule.AlertName,
|
Name: postableRule.AlertName,
|
||||||
TargetValue: &c.target,
|
TargetValue: &c.target,
|
||||||
TargetUnit: c.targetUnit,
|
TargetUnit: c.targetUnit,
|
||||||
RuleUnit: postableRule.RuleCondition.CompositeQuery.Unit,
|
MatchType: ruletypes.MatchType(c.matchType),
|
||||||
MatchType: ruletypes.MatchType(c.matchType),
|
CompareOp: ruletypes.CompareOp(c.compareOp),
|
||||||
CompareOp: ruletypes.CompareOp(c.compareOp),
|
|
||||||
SelectedQuery: postableRule.RuleCondition.SelectedQuery,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -1612,12 +1610,10 @@ func TestThresholdRuleNoData(t *testing.T) {
|
|||||||
Kind: ruletypes.BasicThresholdKind,
|
Kind: ruletypes.BasicThresholdKind,
|
||||||
Spec: ruletypes.BasicRuleThresholds{
|
Spec: ruletypes.BasicRuleThresholds{
|
||||||
{
|
{
|
||||||
Name: postableRule.AlertName,
|
Name: postableRule.AlertName,
|
||||||
TargetValue: &target,
|
TargetValue: &target,
|
||||||
RuleUnit: postableRule.RuleCondition.CompositeQuery.Unit,
|
MatchType: ruletypes.AtleastOnce,
|
||||||
MatchType: ruletypes.AtleastOnce,
|
CompareOp: ruletypes.ValueIsEq,
|
||||||
CompareOp: ruletypes.ValueIsEq,
|
|
||||||
SelectedQuery: postableRule.RuleCondition.SelectedQuery,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -1734,13 +1730,11 @@ func TestThresholdRuleTracesLink(t *testing.T) {
|
|||||||
Kind: ruletypes.BasicThresholdKind,
|
Kind: ruletypes.BasicThresholdKind,
|
||||||
Spec: ruletypes.BasicRuleThresholds{
|
Spec: ruletypes.BasicRuleThresholds{
|
||||||
{
|
{
|
||||||
Name: postableRule.AlertName,
|
Name: postableRule.AlertName,
|
||||||
TargetValue: &c.target,
|
TargetValue: &c.target,
|
||||||
TargetUnit: c.targetUnit,
|
TargetUnit: c.targetUnit,
|
||||||
RuleUnit: postableRule.RuleCondition.CompositeQuery.Unit,
|
MatchType: ruletypes.MatchType(c.matchType),
|
||||||
MatchType: ruletypes.MatchType(c.matchType),
|
CompareOp: ruletypes.CompareOp(c.compareOp),
|
||||||
CompareOp: ruletypes.CompareOp(c.compareOp),
|
|
||||||
SelectedQuery: postableRule.RuleCondition.SelectedQuery,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -1873,13 +1867,11 @@ func TestThresholdRuleLogsLink(t *testing.T) {
|
|||||||
Kind: ruletypes.BasicThresholdKind,
|
Kind: ruletypes.BasicThresholdKind,
|
||||||
Spec: ruletypes.BasicRuleThresholds{
|
Spec: ruletypes.BasicRuleThresholds{
|
||||||
{
|
{
|
||||||
Name: postableRule.AlertName,
|
Name: postableRule.AlertName,
|
||||||
TargetValue: &c.target,
|
TargetValue: &c.target,
|
||||||
TargetUnit: c.targetUnit,
|
TargetUnit: c.targetUnit,
|
||||||
RuleUnit: postableRule.RuleCondition.CompositeQuery.Unit,
|
MatchType: ruletypes.MatchType(c.matchType),
|
||||||
MatchType: ruletypes.MatchType(c.matchType),
|
CompareOp: ruletypes.CompareOp(c.compareOp),
|
||||||
CompareOp: ruletypes.CompareOp(c.compareOp),
|
|
||||||
SelectedQuery: postableRule.RuleCondition.SelectedQuery,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -2125,22 +2117,18 @@ func TestMultipleThresholdRule(t *testing.T) {
|
|||||||
Kind: ruletypes.BasicThresholdKind,
|
Kind: ruletypes.BasicThresholdKind,
|
||||||
Spec: ruletypes.BasicRuleThresholds{
|
Spec: ruletypes.BasicRuleThresholds{
|
||||||
{
|
{
|
||||||
Name: "first_threshold",
|
Name: "first_threshold",
|
||||||
TargetValue: &c.target,
|
TargetValue: &c.target,
|
||||||
TargetUnit: c.targetUnit,
|
TargetUnit: c.targetUnit,
|
||||||
RuleUnit: postableRule.RuleCondition.CompositeQuery.Unit,
|
MatchType: ruletypes.MatchType(c.matchType),
|
||||||
MatchType: ruletypes.MatchType(c.matchType),
|
CompareOp: ruletypes.CompareOp(c.compareOp),
|
||||||
CompareOp: ruletypes.CompareOp(c.compareOp),
|
|
||||||
SelectedQuery: postableRule.RuleCondition.SelectedQuery,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "second_threshold",
|
Name: "second_threshold",
|
||||||
TargetValue: &c.secondTarget,
|
TargetValue: &c.secondTarget,
|
||||||
TargetUnit: c.targetUnit,
|
TargetUnit: c.targetUnit,
|
||||||
RuleUnit: postableRule.RuleCondition.CompositeQuery.Unit,
|
MatchType: ruletypes.MatchType(c.matchType),
|
||||||
MatchType: ruletypes.MatchType(c.matchType),
|
CompareOp: ruletypes.CompareOp(c.compareOp),
|
||||||
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(
|
warnMsg := fmt.Sprintf(
|
||||||
"Key `%s` is ambiguous, found %d different combinations of field context / data type: %v.",
|
"Key `%s` is ambiguous, found %d different combinations of field context / data type: %v.",
|
||||||
fieldKey.Name,
|
fieldKey.Name,
|
||||||
@ -865,6 +865,7 @@ func (v *filterExpressionVisitor) VisitKey(ctx *grammar.KeyContext) any {
|
|||||||
mixedFieldContext[item.FieldContext.StringValue()] = true
|
mixedFieldContext[item.FieldContext.StringValue()] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// when there is both resource and attribute context, default to resource only
|
||||||
if mixedFieldContext[telemetrytypes.FieldContextResource.StringValue()] &&
|
if mixedFieldContext[telemetrytypes.FieldContextResource.StringValue()] &&
|
||||||
mixedFieldContext[telemetrytypes.FieldContextAttribute.StringValue()] {
|
mixedFieldContext[telemetrytypes.FieldContextAttribute.StringValue()] {
|
||||||
filteredKeys := []*telemetrytypes.TelemetryFieldKey{}
|
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, " +
|
warnMsg += " " + "Using `resource` context by default. To query attributes explicitly, " +
|
||||||
fmt.Sprintf("use the fully qualified name (e.g., 'attribute.%s')", fieldKey.Name)
|
fmt.Sprintf("use the fully qualified name (e.g., 'attribute.%s')", fieldKey.Name)
|
||||||
}
|
}
|
||||||
v.mainWarnURL = "https://signoz.io/docs/userguide/field-context-data-types/"
|
|
||||||
// this is warning state, we must have a unambiguous key
|
if !v.keysWithWarnings[keyName] {
|
||||||
v.warnings = append(v.warnings, warnMsg)
|
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.keysWithWarnings[keyName] = true
|
||||||
v.logger.Warn("ambiguous key", "field_key_name", fieldKey.Name) //nolint:sloglint
|
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"
|
||||||
"github.com/SigNoz/signoz/pkg/telemetrystore/clickhousetelemetrystore"
|
"github.com/SigNoz/signoz/pkg/telemetrystore/clickhousetelemetrystore"
|
||||||
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystorehook"
|
"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/version"
|
||||||
"github.com/SigNoz/signoz/pkg/web"
|
"github.com/SigNoz/signoz/pkg/web"
|
||||||
"github.com/SigNoz/signoz/pkg/web/noopweb"
|
"github.com/SigNoz/signoz/pkg/web/noopweb"
|
||||||
@ -133,6 +134,7 @@ func NewSQLMigrationProviderFactories(
|
|||||||
sqlmigration.NewQueryBuilderV5MigrationFactory(sqlstore, telemetryStore),
|
sqlmigration.NewQueryBuilderV5MigrationFactory(sqlstore, telemetryStore),
|
||||||
sqlmigration.NewAddMeterQuickFiltersFactory(sqlstore, sqlschema),
|
sqlmigration.NewAddMeterQuickFiltersFactory(sqlstore, sqlschema),
|
||||||
sqlmigration.NewUpdateTTLSettingForCustomRetentionFactory(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(
|
return factory.MustNewNamedMap(
|
||||||
rulebasednotification.NewFactory(),
|
rulebasednotification.NewFactory(routeStore),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
|
"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/analytics"
|
||||||
"github.com/SigNoz/signoz/pkg/cache"
|
"github.com/SigNoz/signoz/pkg/cache"
|
||||||
"github.com/SigNoz/signoz/pkg/emailing"
|
"github.com/SigNoz/signoz/pkg/emailing"
|
||||||
@ -230,12 +231,14 @@ func New(
|
|||||||
// Initialize user getter
|
// Initialize user getter
|
||||||
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings))
|
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
|
// shared NotificationManager instance for both alertmanager and rules
|
||||||
notificationManager, err := factory.NewProviderFromNamedMap(
|
notificationManager, err := factory.NewProviderFromNamedMap(
|
||||||
ctx,
|
ctx,
|
||||||
providerSettings,
|
providerSettings,
|
||||||
nfmanager.Config{},
|
nfmanager.Config{},
|
||||||
NewNotificationManagerProviderFactories(),
|
NewNotificationManagerProviderFactories(routeStore),
|
||||||
"rulebased",
|
"rulebased",
|
||||||
)
|
)
|
||||||
if err != nil {
|
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
|
var value any
|
||||||
switch column.Type {
|
switch column.Type {
|
||||||
case schema.JSONColumnType{}:
|
case schema.JSONColumnType{}:
|
||||||
value = "NULL"
|
|
||||||
if operator == qbtypes.FilterOperatorExists {
|
if operator == qbtypes.FilterOperatorExists {
|
||||||
return sb.NE(tblFieldName, value), nil
|
return sb.IsNotNull(tblFieldName), nil
|
||||||
} else {
|
} else {
|
||||||
return sb.E(tblFieldName, value), nil
|
return sb.IsNull(tblFieldName), nil
|
||||||
}
|
}
|
||||||
case schema.ColumnTypeString, schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString}:
|
case schema.ColumnTypeString, schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString}:
|
||||||
value = ""
|
value = ""
|
||||||
|
|||||||
@ -233,6 +233,30 @@ func TestConditionFor(t *testing.T) {
|
|||||||
expectedArgs: []any{true},
|
expectedArgs: []any{true},
|
||||||
expectedError: nil,
|
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",
|
name: "Non-existent column",
|
||||||
key: telemetrytypes.TelemetryFieldKey{
|
key: telemetrytypes.TelemetryFieldKey{
|
||||||
|
|||||||
@ -452,8 +452,8 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
category: "FREETEXT with conditions",
|
category: "FREETEXT with conditions",
|
||||||
query: "error service.name=authentication",
|
query: "error service.name=authentication",
|
||||||
shouldPass: true,
|
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) <> ?))",
|
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", "NULL"},
|
expectedArgs: []any{"error", "authentication"},
|
||||||
expectedErrorContains: "",
|
expectedErrorContains: "",
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -810,8 +810,8 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
category: "Basic equality",
|
category: "Basic equality",
|
||||||
query: "service.name=\"api\"",
|
query: "service.name=\"api\"",
|
||||||
shouldPass: true,
|
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) <> ?)",
|
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", "NULL"},
|
expectedArgs: []any{"api"},
|
||||||
expectedErrorContains: "",
|
expectedErrorContains: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -1170,16 +1170,16 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
category: "IN operator (parentheses)",
|
category: "IN operator (parentheses)",
|
||||||
query: "service.name IN (\"api\", \"web\", \"auth\")",
|
query: "service.name IN (\"api\", \"web\", \"auth\")",
|
||||||
shouldPass: true,
|
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) <> ?)",
|
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", "NULL"},
|
expectedArgs: []any{"api", "web", "auth"},
|
||||||
expectedErrorContains: "",
|
expectedErrorContains: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "IN operator (parentheses)",
|
category: "IN operator (parentheses)",
|
||||||
query: "environment IN (\"dev\", \"test\", \"staging\", \"prod\")",
|
query: "environment IN (\"dev\", \"test\", \"staging\", \"prod\")",
|
||||||
shouldPass: true,
|
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) <> ?)",
|
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", "NULL"},
|
expectedArgs: []any{"dev", "test", "staging", "prod"},
|
||||||
expectedErrorContains: "",
|
expectedErrorContains: "",
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -1204,16 +1204,16 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
category: "IN operator (brackets)",
|
category: "IN operator (brackets)",
|
||||||
query: "service.name IN [\"api\", \"web\", \"auth\"]",
|
query: "service.name IN [\"api\", \"web\", \"auth\"]",
|
||||||
shouldPass: true,
|
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) <> ?)",
|
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", "NULL"},
|
expectedArgs: []any{"api", "web", "auth"},
|
||||||
expectedErrorContains: "",
|
expectedErrorContains: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "IN operator (brackets)",
|
category: "IN operator (brackets)",
|
||||||
query: "environment IN [\"dev\", \"test\", \"staging\", \"prod\"]",
|
query: "environment IN [\"dev\", \"test\", \"staging\", \"prod\"]",
|
||||||
shouldPass: true,
|
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) <> ?)",
|
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", "NULL"},
|
expectedArgs: []any{"dev", "test", "staging", "prod"},
|
||||||
expectedErrorContains: "",
|
expectedErrorContains: "",
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -1318,6 +1318,13 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
expectedArgs: []any{true},
|
expectedArgs: []any{true},
|
||||||
expectedErrorContains: "",
|
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
|
// NOT EXISTS
|
||||||
{
|
{
|
||||||
@ -1360,6 +1367,13 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
expectedArgs: []any{true},
|
expectedArgs: []any{true},
|
||||||
expectedErrorContains: "",
|
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
|
// Basic REGEXP
|
||||||
{
|
{
|
||||||
@ -1530,8 +1544,8 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
category: "Explicit AND",
|
category: "Explicit AND",
|
||||||
query: "status=200 AND service.name=\"api\"",
|
query: "status=200 AND service.name=\"api\"",
|
||||||
shouldPass: true,
|
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) <> ?))",
|
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", "NULL"},
|
expectedArgs: []any{float64(200), true, "api"},
|
||||||
expectedErrorContains: "",
|
expectedErrorContains: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -1564,8 +1578,8 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
category: "Explicit OR",
|
category: "Explicit OR",
|
||||||
query: "service.name=\"api\" OR service.name=\"web\"",
|
query: "service.name=\"api\" OR service.name=\"web\"",
|
||||||
shouldPass: true,
|
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) <> ?))",
|
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", "NULL", "web", "NULL"},
|
expectedArgs: []any{"api", "web"},
|
||||||
expectedErrorContains: "",
|
expectedErrorContains: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -1590,8 +1604,8 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
category: "NOT with expressions",
|
category: "NOT with expressions",
|
||||||
query: "NOT service.name=\"api\"",
|
query: "NOT service.name=\"api\"",
|
||||||
shouldPass: true,
|
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) <> ?))",
|
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", "NULL"},
|
expectedArgs: []any{"api"},
|
||||||
expectedErrorContains: "",
|
expectedErrorContains: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -1608,8 +1622,8 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
category: "AND + OR combinations",
|
category: "AND + OR combinations",
|
||||||
query: "status=200 AND (service.name=\"api\" OR service.name=\"web\")",
|
query: "status=200 AND (service.name=\"api\" OR service.name=\"web\")",
|
||||||
shouldPass: true,
|
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) <> ?))))",
|
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", "NULL", "web", "NULL"},
|
expectedArgs: []any{float64(200), true, "api", "web"},
|
||||||
expectedErrorContains: "",
|
expectedErrorContains: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -1634,8 +1648,8 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
category: "AND + NOT combinations",
|
category: "AND + NOT combinations",
|
||||||
query: "status=200 AND NOT service.name=\"api\"",
|
query: "status=200 AND NOT service.name=\"api\"",
|
||||||
shouldPass: true,
|
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) <> ?)))",
|
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", "NULL"},
|
expectedArgs: []any{float64(200), true, "api"},
|
||||||
expectedErrorContains: "",
|
expectedErrorContains: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -1652,8 +1666,8 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
category: "OR + NOT combinations",
|
category: "OR + NOT combinations",
|
||||||
query: "NOT status=200 OR NOT service.name=\"api\"",
|
query: "NOT status=200 OR NOT service.name=\"api\"",
|
||||||
shouldPass: true,
|
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) <> ?)))",
|
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", "NULL"},
|
expectedArgs: []any{float64(200), true, "api"},
|
||||||
expectedErrorContains: "",
|
expectedErrorContains: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -1670,8 +1684,8 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
category: "AND + OR + NOT combinations",
|
category: "AND + OR + NOT combinations",
|
||||||
query: "status=200 AND (service.name=\"api\" OR NOT duration>1000)",
|
query: "status=200 AND (service.name=\"api\" OR NOT duration>1000)",
|
||||||
shouldPass: true,
|
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') = ?)))))",
|
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", "NULL", float64(1000), true},
|
expectedArgs: []any{float64(200), true, "api", float64(1000), true},
|
||||||
expectedErrorContains: "",
|
expectedErrorContains: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -1686,8 +1700,8 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
category: "AND + OR + NOT combinations",
|
category: "AND + OR + NOT combinations",
|
||||||
query: "NOT (status=200 AND service.name=\"api\") OR count>0",
|
query: "NOT (status=200 AND service.name=\"api\") OR count>0",
|
||||||
shouldPass: true,
|
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') = ?))",
|
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", "NULL", float64(0), true},
|
expectedArgs: []any{float64(200), true, "api", float64(0), true},
|
||||||
expectedErrorContains: "",
|
expectedErrorContains: "",
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -1696,8 +1710,8 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
category: "Implicit AND",
|
category: "Implicit AND",
|
||||||
query: "status=200 service.name=\"api\"",
|
query: "status=200 service.name=\"api\"",
|
||||||
shouldPass: true,
|
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) <> ?))",
|
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", "NULL"},
|
expectedArgs: []any{float64(200), true, "api"},
|
||||||
expectedErrorContains: "",
|
expectedErrorContains: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -1722,8 +1736,8 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
category: "Mixed implicit/explicit AND",
|
category: "Mixed implicit/explicit AND",
|
||||||
query: "status=200 AND service.name=\"api\" duration<1000",
|
query: "status=200 AND service.name=\"api\" duration<1000",
|
||||||
shouldPass: true,
|
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') = ?))",
|
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", "NULL", float64(1000), true},
|
expectedArgs: []any{float64(200), true, "api", float64(1000), true},
|
||||||
expectedErrorContains: "",
|
expectedErrorContains: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -1748,8 +1762,8 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
category: "Simple grouping",
|
category: "Simple grouping",
|
||||||
query: "service.name=\"api\"",
|
query: "service.name=\"api\"",
|
||||||
shouldPass: true,
|
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) <> ?)",
|
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", "NULL"},
|
expectedArgs: []any{"api"},
|
||||||
expectedErrorContains: "",
|
expectedErrorContains: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -1774,8 +1788,8 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
category: "Nested grouping",
|
category: "Nested grouping",
|
||||||
query: "(((service.name=\"api\")))",
|
query: "(((service.name=\"api\")))",
|
||||||
shouldPass: true,
|
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) <> ?))))",
|
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", "NULL"},
|
expectedArgs: []any{"api"},
|
||||||
expectedErrorContains: "",
|
expectedErrorContains: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -1792,8 +1806,8 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
category: "Complex nested grouping",
|
category: "Complex nested grouping",
|
||||||
query: "(status=200 AND (service.name=\"api\" OR service.name=\"web\"))",
|
query: "(status=200 AND (service.name=\"api\" OR service.name=\"web\"))",
|
||||||
shouldPass: true,
|
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) <> ?)))))",
|
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", "NULL", "web", "NULL"},
|
expectedArgs: []any{float64(200), true, "api", "web"},
|
||||||
expectedErrorContains: "",
|
expectedErrorContains: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -1818,16 +1832,16 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
category: "Deep nesting",
|
category: "Deep nesting",
|
||||||
query: "(((status=200 OR status=201) AND service.name=\"api\") OR ((status=202 OR status=203) AND service.name=\"web\"))",
|
query: "(((status=200 OR status=201) AND service.name=\"api\") OR ((status=202 OR status=203) AND service.name=\"web\"))",
|
||||||
shouldPass: true,
|
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) <> ?)))))",
|
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", "NULL", float64(202), true, float64(203), true, "web", "NULL"},
|
expectedArgs: []any{float64(200), true, float64(201), true, "api", float64(202), true, float64(203), true, "web"},
|
||||||
expectedErrorContains: "",
|
expectedErrorContains: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "Deep nesting",
|
category: "Deep nesting",
|
||||||
query: "(count>0 AND ((duration<1000 AND service.name=\"api\") OR (duration<500 AND service.name=\"web\")))",
|
query: "(count>0 AND ((duration<1000 AND service.name=\"api\") OR (duration<500 AND service.name=\"web\")))",
|
||||||
shouldPass: true,
|
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) <> ?)))))))",
|
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", "NULL", float64(500), true, "web", "NULL"},
|
expectedArgs: []any{float64(0), true, float64(1000), true, "api", float64(500), true, "web"},
|
||||||
expectedErrorContains: "",
|
expectedErrorContains: "",
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -1836,16 +1850,16 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
category: "String quote styles",
|
category: "String quote styles",
|
||||||
query: "service.name=\"api\"",
|
query: "service.name=\"api\"",
|
||||||
shouldPass: true,
|
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) <> ?)",
|
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", "NULL"},
|
expectedArgs: []any{"api"},
|
||||||
expectedErrorContains: "",
|
expectedErrorContains: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "String quote styles",
|
category: "String quote styles",
|
||||||
query: "service.name='api'",
|
query: "service.name='api'",
|
||||||
shouldPass: true,
|
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) <> ?)",
|
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", "NULL"},
|
expectedArgs: []any{"api"},
|
||||||
expectedErrorContains: "",
|
expectedErrorContains: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -2004,29 +2018,29 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
category: "Operator precedence",
|
category: "Operator precedence",
|
||||||
query: "NOT status=200 AND service.name=\"api\"",
|
query: "NOT status=200 AND service.name=\"api\"",
|
||||||
shouldPass: true,
|
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) <> ?))",
|
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", "NULL"}, // Should be (NOT status=200) AND service.name="api"
|
expectedArgs: []any{float64(200), true, "api"}, // Should be (NOT status=200) AND service.name="api"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "Operator precedence",
|
category: "Operator precedence",
|
||||||
query: "status=200 AND service.name=\"api\" OR service.name=\"web\"",
|
query: "status=200 AND service.name=\"api\" OR service.name=\"web\"",
|
||||||
shouldPass: true,
|
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) <> ?))",
|
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", "NULL", "web", "NULL"}, // Should be (status=200 AND service.name="api") OR service.name="web"
|
expectedArgs: []any{float64(200), true, "api", "web"}, // Should be (status=200 AND service.name="api") OR service.name="web"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "Operator precedence",
|
category: "Operator precedence",
|
||||||
query: "NOT status=200 OR NOT service.name=\"api\"",
|
query: "NOT status=200 OR NOT service.name=\"api\"",
|
||||||
shouldPass: true,
|
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) <> ?)))",
|
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", "NULL"}, // Should be (NOT status=200) OR (NOT service.name="api")
|
expectedArgs: []any{float64(200), true, "api"}, // Should be (NOT status=200) OR (NOT service.name="api")
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "Operator precedence",
|
category: "Operator precedence",
|
||||||
query: "status=200 OR service.name=\"api\" AND level=\"ERROR\"",
|
query: "status=200 OR service.name=\"api\" AND level=\"ERROR\"",
|
||||||
shouldPass: true,
|
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') = ?)))",
|
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", "NULL", "ERROR", true}, // Should be status=200 OR (service.name="api" AND level="ERROR")
|
expectedArgs: []any{float64(200), true, "api", "ERROR", true}, // Should be status=200 OR (service.name="api" AND level="ERROR")
|
||||||
},
|
},
|
||||||
|
|
||||||
// Different whitespace patterns
|
// Different whitespace patterns
|
||||||
@ -2050,8 +2064,8 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
category: "Whitespace patterns",
|
category: "Whitespace patterns",
|
||||||
query: "status=200 AND service.name=\"api\"",
|
query: "status=200 AND service.name=\"api\"",
|
||||||
shouldPass: true,
|
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) <> ?))",
|
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", "NULL"}, // Multiple spaces
|
expectedArgs: []any{float64(200), true, "api"}, // Multiple spaces
|
||||||
},
|
},
|
||||||
|
|
||||||
// More Unicode characters
|
// More Unicode characters
|
||||||
@ -2220,8 +2234,8 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
category: "More common filters",
|
category: "More common filters",
|
||||||
query: "service.name=\"api\" AND (status>=500 OR duration>1000) AND NOT message CONTAINS \"expected\"",
|
query: "service.name=\"api\" AND (status>=500 OR duration>1000) AND NOT message CONTAINS \"expected\"",
|
||||||
shouldPass: true,
|
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') = ?)))",
|
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", "NULL", float64(500), true, float64(1000), true, "%expected%", true},
|
expectedArgs: []any{"api", float64(500), true, float64(1000), true, "%expected%", true},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Edge cases
|
// Edge cases
|
||||||
@ -2286,8 +2300,8 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
category: "Unusual whitespace",
|
category: "Unusual whitespace",
|
||||||
query: "status = 200 AND service.name = \"api\"",
|
query: "status = 200 AND service.name = \"api\"",
|
||||||
shouldPass: true,
|
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) <> ?))",
|
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", "NULL"},
|
expectedArgs: []any{float64(200), true, "api"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "Unusual whitespace",
|
category: "Unusual whitespace",
|
||||||
@ -2347,13 +2361,13 @@ func TestFilterExprLogs(t *testing.T) {
|
|||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
shouldPass: true,
|
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{
|
expectedArgs: []any{
|
||||||
float64(200), true, float64(300), true, float64(400), true, float64(500), true, float64(404), true,
|
float64(200), true, float64(300), true, float64(400), true, float64(500), true, float64(404), true,
|
||||||
"api", "web", "auth", "NULL",
|
"api", "web", "auth",
|
||||||
"internal", "NULL", true, "NULL",
|
"internal", true,
|
||||||
float64(1000), true, float64(1000), float64(5000), true,
|
float64(1000), true, float64(1000), float64(5000), true,
|
||||||
"test", "test", "NULL", true, true,
|
"test", "test", true, true,
|
||||||
"%warning%", true, "%deprecated%", true,
|
"%warning%", true, "%deprecated%", true,
|
||||||
"low", true,
|
"low", true,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -69,8 +69,8 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
expected: qbtypes.Statement{
|
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`",
|
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), "NULL", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10, "NULL", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
|
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,
|
expectedErr: nil,
|
||||||
},
|
},
|
||||||
@ -98,8 +98,8 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
expected: qbtypes.Statement{
|
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`",
|
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), "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)},
|
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,
|
expectedErr: nil,
|
||||||
},
|
},
|
||||||
@ -137,8 +137,8 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
expected: qbtypes.Statement{
|
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",
|
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), "NULL", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10, "NULL", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
|
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,
|
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
|
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
|
var value any
|
||||||
switch column.Type {
|
switch column.Type {
|
||||||
case schema.JSONColumnType{}:
|
case schema.JSONColumnType{}:
|
||||||
value = "NULL"
|
|
||||||
if operator == qbtypes.FilterOperatorExists {
|
if operator == qbtypes.FilterOperatorExists {
|
||||||
return sb.NE(tblFieldName, value), nil
|
return sb.IsNotNull(tblFieldName), nil
|
||||||
} else {
|
} else {
|
||||||
return sb.E(tblFieldName, value), nil
|
return sb.IsNull(tblFieldName), nil
|
||||||
}
|
}
|
||||||
case schema.ColumnTypeString,
|
case schema.ColumnTypeString,
|
||||||
schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString},
|
schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString},
|
||||||
|
|||||||
@ -207,6 +207,30 @@ func TestConditionFor(t *testing.T) {
|
|||||||
expectedSQL: "mapContains(attributes_string, 'user.id') <> ?",
|
expectedSQL: "mapContains(attributes_string, 'user.id') <> ?",
|
||||||
expectedError: nil,
|
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",
|
name: "Contains operator - map field",
|
||||||
key: telemetrytypes.TelemetryFieldKey{
|
key: telemetrytypes.TelemetryFieldKey{
|
||||||
|
|||||||
@ -68,12 +68,14 @@ func TestGetFieldKeyName(t *testing.T) {
|
|||||||
expectedError: nil,
|
expectedError: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Map column type - resource attribute - legacy",
|
name: "Map column type - resource attribute - materialized",
|
||||||
key: telemetrytypes.TelemetryFieldKey{
|
key: telemetrytypes.TelemetryFieldKey{
|
||||||
Name: "service.name",
|
Name: "deployment.environment",
|
||||||
FieldContext: telemetrytypes.FieldContextResource,
|
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,
|
expectedError: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -60,8 +60,8 @@ func TestStatementBuilder(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
expected: qbtypes.Statement{
|
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`",
|
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), "NULL", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "NULL", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
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,
|
expectedErr: nil,
|
||||||
},
|
},
|
||||||
@ -89,8 +89,8 @@ func TestStatementBuilder(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
expected: qbtypes.Statement{
|
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`",
|
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), "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)},
|
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,
|
expectedErr: nil,
|
||||||
},
|
},
|
||||||
@ -187,8 +187,8 @@ func TestStatementBuilder(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
expected: qbtypes.Statement{
|
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`",
|
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), "NULL", true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "NULL", true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
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,
|
expectedErr: nil,
|
||||||
},
|
},
|
||||||
@ -216,8 +216,8 @@ func TestStatementBuilder(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
expected: qbtypes.Statement{
|
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`",
|
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), "NULL", true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "NULL", true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
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,
|
expectedErr: nil,
|
||||||
},
|
},
|
||||||
@ -255,8 +255,8 @@ func TestStatementBuilder(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
expected: qbtypes.Statement{
|
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",
|
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), "NULL", true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "NULL", true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
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,
|
expectedErr: nil,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -263,8 +263,8 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
expected: qbtypes.Statement{
|
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",
|
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), "NULL"},
|
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,
|
expectedErr: nil,
|
||||||
},
|
},
|
||||||
@ -322,8 +322,8 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
expected: qbtypes.Statement{
|
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",
|
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), "NULL", 0},
|
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,
|
expectedErr: nil,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -27,6 +27,8 @@ type (
|
|||||||
// An alias for the Alert type from the alertmanager package.
|
// An alias for the Alert type from the alertmanager package.
|
||||||
Alert = types.Alert
|
Alert = types.Alert
|
||||||
|
|
||||||
|
AlertSlice = types.AlertSlice
|
||||||
|
|
||||||
PostableAlert = models.PostableAlert
|
PostableAlert = models.PostableAlert
|
||||||
|
|
||||||
PostableAlerts = models.PostableAlerts
|
PostableAlerts = models.PostableAlerts
|
||||||
@ -38,6 +40,10 @@ type (
|
|||||||
GettableAlerts = models.GettableAlerts
|
GettableAlerts = models.GettableAlerts
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
NoDataLabel = model.LabelName("nodata")
|
||||||
|
)
|
||||||
|
|
||||||
type DeprecatedGettableAlert struct {
|
type DeprecatedGettableAlert struct {
|
||||||
*model.Alert
|
*model.Alert
|
||||||
Status types.AlertStatus `json:"status"`
|
Status types.AlertStatus `json:"status"`
|
||||||
@ -307,3 +313,11 @@ func receiversMatchFilter(receivers []string, filter *regexp.Regexp) bool {
|
|||||||
|
|
||||||
return false
|
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 (
|
const (
|
||||||
DefaultReceiverName string = "default-receiver"
|
DefaultReceiverName string = "default-receiver"
|
||||||
DefaultGroupBy string = "ruleId"
|
DefaultGroupBy string = "ruleId"
|
||||||
|
DefaultGroupByAll string = "__all__"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -193,6 +194,20 @@ func (c *Config) SetRouteConfig(routeConfig RouteConfig) error {
|
|||||||
return nil
|
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 {
|
func (c *Config) AlertmanagerConfig() *config.Config {
|
||||||
return c.alertmanagerConfig
|
return c.alertmanagerConfig
|
||||||
}
|
}
|
||||||
@ -304,6 +319,27 @@ func (c *Config) CreateRuleIDMatcher(ruleID string, receiverNames []string) erro
|
|||||||
return nil
|
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 {
|
func (c *Config) UpdateRuleIDMatcher(ruleID string, receiverNames []string) error {
|
||||||
err := c.DeleteRuleIDMatcher(ruleID)
|
err := c.DeleteRuleIDMatcher(ruleID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -405,6 +441,8 @@ func init() {
|
|||||||
type NotificationConfig struct {
|
type NotificationConfig struct {
|
||||||
NotificationGroup map[model.LabelName]struct{}
|
NotificationGroup map[model.LabelName]struct{}
|
||||||
Renotify ReNotificationConfig
|
Renotify ReNotificationConfig
|
||||||
|
UsePolicy bool
|
||||||
|
GroupByAll bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (nc *NotificationConfig) DeepCopy() NotificationConfig {
|
func (nc *NotificationConfig) DeepCopy() NotificationConfig {
|
||||||
@ -415,6 +453,7 @@ func (nc *NotificationConfig) DeepCopy() NotificationConfig {
|
|||||||
for k, v := range nc.NotificationGroup {
|
for k, v := range nc.NotificationGroup {
|
||||||
deepCopy.NotificationGroup[k] = v
|
deepCopy.NotificationGroup[k] = v
|
||||||
}
|
}
|
||||||
|
deepCopy.UsePolicy = nc.UsePolicy
|
||||||
return deepCopy
|
return deepCopy
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -423,7 +462,7 @@ type ReNotificationConfig struct {
|
|||||||
RenotifyInterval time.Duration
|
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()
|
notificationConfig := GetDefaultNotificationConfig()
|
||||||
|
|
||||||
if renotifyInterval != 0 {
|
if renotifyInterval != 0 {
|
||||||
@ -435,8 +474,13 @@ func NewNotificationConfig(groups []string, renotifyInterval time.Duration, noDa
|
|||||||
}
|
}
|
||||||
for _, group := range groups {
|
for _, group := range groups {
|
||||||
notificationConfig.NotificationGroup[model.LabelName(group)] = struct{}{}
|
notificationConfig.NotificationGroup[model.LabelName(group)] = struct{}{}
|
||||||
|
if group == DefaultGroupByAll {
|
||||||
|
notificationConfig.GroupByAll = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notificationConfig.UsePolicy = policy
|
||||||
|
|
||||||
return notificationConfig
|
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