mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-14 04:11:19 +00:00
Compare commits
14 Commits
nv/exec-st
...
feat/alert
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58e2f4f316 | ||
|
|
83a9df7ddf | ||
|
|
963bbefc85 | ||
|
|
10066cb8d6 | ||
|
|
fe86b433be | ||
|
|
1725589261 | ||
|
|
3c0375c1cb | ||
|
|
b1a7136a24 | ||
|
|
bd21c2f5c1 | ||
|
|
38328ab43a | ||
|
|
f8421e81e9 | ||
|
|
e938a10ad5 | ||
|
|
93cc1957d4 | ||
|
|
313ec2d657 |
9
.github/CODEOWNERS
vendored
9
.github/CODEOWNERS
vendored
@@ -209,6 +209,10 @@ go.mod @therealpandey
|
||||
/frontend/src/types/api/alerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/providers/Alert.tsx @SigNoz/pulse-frontend
|
||||
/frontend/src/constants/alerts.ts @SigNoz/pulse-frontend
|
||||
/frontend/src/container/PlannedDowntime/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/alerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/Home/AlertRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/Home/__tests__/AlertRules.test.tsx @SigNoz/pulse-frontend
|
||||
|
||||
## Notification Channels
|
||||
/frontend/src/pages/ChannelsEdit/ @SigNoz/pulse-frontend
|
||||
@@ -217,6 +221,11 @@ go.mod @therealpandey
|
||||
/frontend/src/container/FormAlertChannels/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/notificationChannels/ @SigNoz/pulse-frontend
|
||||
|
||||
## List filter + views (dashboards and alerts lists)
|
||||
/frontend/src/components/ListFilter/ @SigNoz/pulse-frontend
|
||||
/frontend/src/components/ListViews/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/listViews/ @SigNoz/pulse-frontend
|
||||
|
||||
## OpenAPI Schema - Generated
|
||||
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
|
||||
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv
|
||||
|
||||
2
.github/workflows/build-community.yaml
vendored
2
.github/workflows/build-community.yaml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
GO_NAME: signoz-community
|
||||
GO_INPUT_ARTIFACT_CACHE_KEY: community-jsbuild-${{ github.sha }}
|
||||
GO_INPUT_ARTIFACT_PATH: frontend/build
|
||||
|
||||
2
.github/workflows/build-enterprise.yaml
vendored
2
.github/workflows/build-enterprise.yaml
vendored
@@ -89,7 +89,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
GO_INPUT_ARTIFACT_CACHE_KEY: enterprise-jsbuild-${{ github.sha }}
|
||||
GO_INPUT_ARTIFACT_PATH: frontend/build
|
||||
GO_BUILD_CONTEXT: ./cmd/enterprise
|
||||
|
||||
2
.github/workflows/build-staging.yaml
vendored
2
.github/workflows/build-staging.yaml
vendored
@@ -95,7 +95,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
GO_INPUT_ARTIFACT_CACHE_KEY: staging-jsbuild-${{ github.sha }}
|
||||
GO_INPUT_ARTIFACT_PATH: frontend/build
|
||||
GO_BUILD_CONTEXT: ./cmd/enterprise
|
||||
|
||||
20
.github/workflows/goci.yaml
vendored
20
.github/workflows/goci.yaml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_TEST_CONTEXT: ./...
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
fmt:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
lint:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
deps:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
semconv-generated:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- name: check-semconv-generated-files
|
||||
run: make semconv-check
|
||||
build:
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- name: qemu-install
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: aarch64-install
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- name: generate-openapi
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate openapi
|
||||
@@ -133,7 +133,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- name: generate-authz
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate authz
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- name: generate-web-settings
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate config web-settings
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- name: generate-transaction-groups
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate config transaction-groups
|
||||
|
||||
4
.github/workflows/gor-signoz-community.yaml
vendored
4
.github/workflows/gor-signoz-community.yaml
vendored
@@ -66,7 +66,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- name: cross-compilation-tools
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
@@ -130,7 +130,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
|
||||
# copy the caches from build
|
||||
- name: get-sha
|
||||
|
||||
4
.github/workflows/gor-signoz.yaml
vendored
4
.github/workflows/gor-signoz.yaml
vendored
@@ -75,7 +75,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- name: cross-compilation-tools
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
|
||||
# copy the caches from build
|
||||
- name: get-sha
|
||||
|
||||
@@ -11693,12 +11693,20 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
includeSamples:
|
||||
description: Whether notifications include sample data from the alert window.
|
||||
Not read yet. Defaults to false.
|
||||
type: boolean
|
||||
newGroupEvalDelay:
|
||||
description: A grace period, as a Go duration such as 2m, during which a
|
||||
newly seen label group is excluded from evaluation. Applies to metric
|
||||
rules only; log and trace rules are never delayed. A group whose values
|
||||
are not yet known is evaluated immediately.
|
||||
type: string
|
||||
notificationBody:
|
||||
description: The notification body template, read when useTemplates is true.
|
||||
Channels fall back to their own body when it is empty.
|
||||
type: string
|
||||
renotify:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesRenotify'
|
||||
@@ -11709,6 +11717,12 @@ components:
|
||||
policies instead of the channels named on the thresholds. Defaults to
|
||||
false.
|
||||
type: boolean
|
||||
useTemplates:
|
||||
description: When true, the alert name is the notification title and notificationBody
|
||||
the body, both rendered as templates with variables such as $labels.service.name
|
||||
and $alert.value. When false, channels use their own title and body. Defaults
|
||||
to false.
|
||||
type: boolean
|
||||
type: object
|
||||
RuletypesPanelType:
|
||||
enum:
|
||||
|
||||
@@ -277,7 +277,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
|
||||
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
|
||||
|
||||
annotations := r.ExpandAnnotations(expand)
|
||||
annotations := r.AlertAnnotations(expand, smpl, value, threshold)
|
||||
if smpl.IsMissing {
|
||||
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
|
||||
lb.Set(ruletypes.NoDataLabel, "true")
|
||||
|
||||
@@ -132,10 +132,97 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
|
||||
alertsFound, err := rule.Eval(context.Background(), evalTime)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, c.expectAlerts, alertsFound)
|
||||
for _, item := range rule.Active {
|
||||
assert.Empty(t, item.Annotations.Get(ruletypes.AnnotationValue))
|
||||
assert.Empty(t, item.Annotations.Get(ruletypes.AnnotationThresholdValue))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnomalyRule_ValueAndThresholdAnnotations(t *testing.T) {
|
||||
baseTime := time.Unix(1700000000, 0)
|
||||
evalTime := baseTime.Add(5 * time.Minute)
|
||||
|
||||
target := 2.0
|
||||
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: valuer.NewDisplayName("Test anomaly value"),
|
||||
AlertType: ruletypes.AlertTypeMetric,
|
||||
RuleType: ruletypes.RuleTypeAnomaly,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
EvalWindow: valuer.MustParseTextDuration("5m"),
|
||||
Frequency: valuer.MustParseTextDuration("1m"),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
Target: &target,
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypeBuilder,
|
||||
Queries: []qbtypes.QueryEnvelope{{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
},
|
||||
}},
|
||||
},
|
||||
SelectedQuery: "A",
|
||||
Seasonality: ruletypes.SeasonalityDaily,
|
||||
Thresholds: &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{{
|
||||
Name: "Test anomaly value",
|
||||
TargetValue: &target,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
}},
|
||||
},
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
"summary": "{{$value}}",
|
||||
"description": "{{$threshold}}",
|
||||
},
|
||||
}
|
||||
|
||||
response := &anomaly.AnomaliesResponse{
|
||||
Results: []*qbtypes.TimeSeriesData{
|
||||
{
|
||||
QueryName: "A",
|
||||
Aggregations: []*qbtypes.AggregationBucket{{
|
||||
AnomalyScores: []*qbtypes.TimeSeries{{
|
||||
Labels: []*qbtypes.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "service.name"}, Value: "checkout"}},
|
||||
Values: []*qbtypes.TimeSeriesValue{
|
||||
{Timestamp: baseTime.Add(1 * time.Minute).UnixMilli(), Value: 1.5},
|
||||
{Timestamp: baseTime.Add(2 * time.Minute).UnixMilli(), Value: 3.5},
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
rule, err := NewAnomalyRule("test-anomaly-rule", valuer.GenerateUUID(), &postableRule, nil, instrumentationtest.New().Logger(), mustParseURL(t, "http://localhost:8000"))
|
||||
require.NoError(t, err)
|
||||
|
||||
rule.provider = &mockAnomalyProvider{responses: []*anomaly.AnomaliesResponse{response}}
|
||||
|
||||
alertsFound, err := rule.Eval(context.Background(), evalTime)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, alertsFound)
|
||||
|
||||
for _, item := range rule.Active {
|
||||
value := item.Annotations.Get(ruletypes.AnnotationValue)
|
||||
assert.NotEmpty(t, value)
|
||||
assert.Equal(t, item.Annotations.Get("summary"), value)
|
||||
|
||||
threshold := item.Annotations.Get(ruletypes.AnnotationThresholdValue)
|
||||
assert.NotEmpty(t, threshold)
|
||||
assert.Equal(t, item.Annotations.Get("description"), threshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
|
||||
// Test missing data alert with AbsentFor grace period
|
||||
// 1. Call Eval with data at time t1, to populate lastTimestampWithDatapoints
|
||||
|
||||
@@ -12891,11 +12891,21 @@ export interface RuletypesNotificationSettingsDTO {
|
||||
* @description Label names whose values group alerts into one notification, such as service.name.
|
||||
*/
|
||||
groupBy?: string[] | null;
|
||||
/**
|
||||
* @type boolean
|
||||
* @description Whether notifications include sample data from the alert window. Not read yet. Defaults to false.
|
||||
*/
|
||||
includeSamples?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
* @description A grace period, as a Go duration such as 2m, during which a newly seen label group is excluded from evaluation. Applies to metric rules only; log and trace rules are never delayed. A group whose values are not yet known is evaluated immediately.
|
||||
*/
|
||||
newGroupEvalDelay?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The notification body template, read when useTemplates is true. Channels fall back to their own body when it is empty.
|
||||
*/
|
||||
notificationBody?: string;
|
||||
/**
|
||||
* @description Whether and how often to re-send a notification for an alert that stays active.
|
||||
*/
|
||||
@@ -12905,6 +12915,11 @@ export interface RuletypesNotificationSettingsDTO {
|
||||
* @description When true, alerts are routed by the organisation level notification policies instead of the channels named on the thresholds. Defaults to false.
|
||||
*/
|
||||
usePolicy?: boolean;
|
||||
/**
|
||||
* @type boolean
|
||||
* @description When true, the alert name is the notification title and notificationBody the body, both rendered as templates with variables such as $labels.service.name and $alert.value. When false, channels use their own title and body. Defaults to false.
|
||||
*/
|
||||
useTemplates?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useListRules } from 'api/generated/services/rules';
|
||||
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { RuletypesListableRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { AppLink } from 'lib/router/AppLink';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
|
||||
import Card from 'periscope/components/Card/Card';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import beaconUrl from '@/assets/Icons/beacon.svg';
|
||||
|
||||
import { getItemIcon } from '../constants';
|
||||
import { useHomeAlertRules } from './useHomeAlertRules';
|
||||
|
||||
export default function AlertRules({
|
||||
onUpdateChecklistDoneItem,
|
||||
@@ -28,49 +26,19 @@ export default function AlertRules({
|
||||
loadingUserPreferences: boolean;
|
||||
}): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const [rulesExist, setRulesExist] = useState(false);
|
||||
|
||||
const [sortedAlertRules, setSortedAlertRules] = useState<RuletypesRuleDTO[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const location = useAppLocation();
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
// Fetch Alerts
|
||||
const {
|
||||
data: alerts,
|
||||
isError,
|
||||
isLoading,
|
||||
} = useListRules({
|
||||
query: { cacheTime: 0 },
|
||||
});
|
||||
const { rules: alertRules, isError, isLoading } = useHomeAlertRules();
|
||||
const rulesExist = alertRules.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const rules = alerts?.data ?? [];
|
||||
setRulesExist(rules.length > 0);
|
||||
|
||||
const sortedRules = [...rules].sort((a, b) => {
|
||||
// First, prioritize firing alerts
|
||||
if (a.state === 'firing' && b.state !== 'firing') {
|
||||
return -1;
|
||||
}
|
||||
if (a.state !== 'firing' && b.state === 'firing') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Then sort by updatedAt timestamp
|
||||
return (
|
||||
new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
if (sortedRules.length > 0 && !loadingUserPreferences) {
|
||||
if (rulesExist && !loadingUserPreferences) {
|
||||
onUpdateChecklistDoneItem('SETUP_ALERTS');
|
||||
}
|
||||
|
||||
setSortedAlertRules(sortedRules.slice(0, 5));
|
||||
}, [alerts, onUpdateChecklistDoneItem, loadingUserPreferences]);
|
||||
}, [rulesExist, onUpdateChecklistDoneItem, loadingUserPreferences]);
|
||||
|
||||
const emptyStateCard = (): JSX.Element => (
|
||||
<div className="empty-state-container">
|
||||
@@ -95,7 +63,7 @@ export default function AlertRules({
|
||||
className="periscope-btn secondary"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={(): void => {
|
||||
logEvent('Homepage: Create alert rule clicked', {});
|
||||
void logEvent('Homepage: Create alert rule clicked', {});
|
||||
}}
|
||||
>
|
||||
Create Alert Rule
|
||||
@@ -106,7 +74,7 @@ export default function AlertRules({
|
||||
type="link"
|
||||
className="learn-more-link"
|
||||
onClick={(): void => {
|
||||
logEvent('Homepage: Learn more clicked', {
|
||||
void logEvent('Homepage: Learn more clicked', {
|
||||
source: 'Alert Rules',
|
||||
});
|
||||
|
||||
@@ -125,26 +93,13 @@ export default function AlertRules({
|
||||
</div>
|
||||
);
|
||||
|
||||
const onEditHandler = (record: RuletypesRuleDTO) => (): void => {
|
||||
logEvent('Homepage: Alert clicked', {
|
||||
const onEditHandler = (record: RuletypesListableRuleDTO) => (): void => {
|
||||
void logEvent('Homepage: Alert clicked', {
|
||||
ruleId: record.id,
|
||||
ruleName: record.alert,
|
||||
ruleState: record.state,
|
||||
});
|
||||
|
||||
const compositeQuery = mapQueryDataFromApi(
|
||||
toCompositeMetricQuery(record.condition.compositeQuery),
|
||||
);
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
|
||||
const panelType = record.condition.compositeQuery.panelType;
|
||||
if (panelType) {
|
||||
params.set(QueryParams.panelTypes, panelType);
|
||||
}
|
||||
|
||||
params.set(QueryParams.ruleId, record.id);
|
||||
|
||||
navigate(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
|
||||
@@ -153,11 +108,12 @@ export default function AlertRules({
|
||||
const renderAlertRules = (): JSX.Element => (
|
||||
<div className="alert-rules-container home-data-item-container">
|
||||
<div className="alert-rules-list">
|
||||
{sortedAlertRules.map((rule) => (
|
||||
{alertRules.map((rule) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="alert-rule-item home-data-item"
|
||||
data-testid={`home-alert-rule-${rule.id}`}
|
||||
key={rule.id}
|
||||
onClick={onEditHandler(rule)}
|
||||
onKeyDown={(e): void => {
|
||||
@@ -239,7 +195,7 @@ export default function AlertRules({
|
||||
type="link"
|
||||
className="periscope-btn link learn-more-link"
|
||||
onClick={(): void => {
|
||||
logEvent('Homepage: All alert rules clicked', {});
|
||||
void logEvent('Homepage: All alert rules clicked', {});
|
||||
}}
|
||||
>
|
||||
All Alert Rules <ArrowRight size={12} />
|
||||
|
||||
64
frontend/src/container/Home/AlertRules/useHomeAlertRules.ts
Normal file
64
frontend/src/container/Home/AlertRules/useHomeAlertRules.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useListRulesV3 } from 'api/generated/services/rules';
|
||||
import {
|
||||
type ListRulesV3200,
|
||||
type ListRulesV3Params,
|
||||
RuletypesAlertStateDTO,
|
||||
type RuletypesListableRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { GeneratedAPIInstance } from 'api/generatedAPIInstance';
|
||||
|
||||
const HOME_ALERT_RULES_LIMIT = 5;
|
||||
|
||||
const FIRING_RULES_PARAMS: ListRulesV3Params = {
|
||||
states: [RuletypesAlertStateDTO.firing],
|
||||
limit: HOME_ALERT_RULES_LIMIT,
|
||||
};
|
||||
|
||||
interface HomeAlertRules {
|
||||
rules: RuletypesListableRuleDTO[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
export function useHomeAlertRules(): HomeAlertRules {
|
||||
const firing = useListRulesV3(FIRING_RULES_PARAMS, {
|
||||
query: {
|
||||
cacheTime: 0,
|
||||
// The backend binds repeated `states=` keys; axios' default sends `states[]=`.
|
||||
queryFn: ({ signal }) =>
|
||||
GeneratedAPIInstance<ListRulesV3200>({
|
||||
url: '/api/v3/rules',
|
||||
method: 'GET',
|
||||
params: FIRING_RULES_PARAMS,
|
||||
paramsSerializer: { indexes: null },
|
||||
signal,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const firingRules = firing.data?.data.rules;
|
||||
const latest = useListRulesV3(
|
||||
{ limit: HOME_ALERT_RULES_LIMIT },
|
||||
{
|
||||
query: {
|
||||
cacheTime: 0,
|
||||
enabled:
|
||||
firingRules !== undefined && firingRules.length < HOME_ALERT_RULES_LIMIT,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const rules = useMemo(() => {
|
||||
const firingIds = new Set(firingRules?.map(({ id }) => id));
|
||||
const others = (latest.data?.data.rules ?? []).filter(
|
||||
({ id }) => !firingIds.has(id),
|
||||
);
|
||||
return [...(firingRules ?? []), ...others].slice(0, HOME_ALERT_RULES_LIMIT);
|
||||
}, [firingRules, latest.data]);
|
||||
|
||||
return {
|
||||
rules,
|
||||
isLoading: firing.isLoading || latest.isLoading,
|
||||
isError: firing.isError || latest.isError,
|
||||
};
|
||||
}
|
||||
119
frontend/src/container/Home/__tests__/AlertRules.test.tsx
Normal file
119
frontend/src/container/Home/__tests__/AlertRules.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
RuletypesAlertStateDTO,
|
||||
type RuletypesRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import {
|
||||
alertRulesFixture,
|
||||
listRulesV3Response,
|
||||
} from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import AlertRules from '../AlertRules/AlertRules';
|
||||
|
||||
jest.mock('lib/router/navigation', () => ({
|
||||
...jest.requireActual('lib/router/navigation'),
|
||||
navigate: jest.fn(),
|
||||
}));
|
||||
|
||||
const rule = (id: string, state: RuletypesAlertStateDTO): RuletypesRuleDTO => ({
|
||||
...alertRulesFixture[0],
|
||||
id,
|
||||
alert: `${id} rule`,
|
||||
state,
|
||||
});
|
||||
|
||||
const burning = rule('burning', RuletypesAlertStateDTO.firing);
|
||||
const quiet = rule('quiet', RuletypesAlertStateDTO.inactive);
|
||||
const waiting = rule('waiting', RuletypesAlertStateDTO.pending);
|
||||
|
||||
const serveRules = (
|
||||
rules: RuletypesRuleDTO[],
|
||||
rulesForLatest = rules,
|
||||
): string[] => {
|
||||
const requestedStates: string[] = [];
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
const states = req.url.searchParams.getAll('states').join();
|
||||
requestedStates.push(states);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
listRulesV3Response(
|
||||
states === 'firing' ? rules : rulesForLatest,
|
||||
req.url.searchParams,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
return requestedStates;
|
||||
};
|
||||
|
||||
const renderAlertRules = (): void => {
|
||||
render(
|
||||
<AlertRules
|
||||
onUpdateChecklistDoneItem={jest.fn()}
|
||||
loadingUserPreferences={false}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
const renderedRuleIds = (): (string | undefined)[] =>
|
||||
screen.getAllByTestId(/^home-alert-rule-/).map((row) => row.dataset.testid);
|
||||
|
||||
describe('Home alert rules', () => {
|
||||
it('lists firing rules first, then the latest others, and opens the clicked rule', async () => {
|
||||
const requestedStates = serveRules([quiet, burning, waiting]);
|
||||
renderAlertRules();
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('home-alert-rule-waiting'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(renderedRuleIds()).toStrictEqual([
|
||||
'home-alert-rule-burning',
|
||||
'home-alert-rule-quiet',
|
||||
'home-alert-rule-waiting',
|
||||
]);
|
||||
expect(requestedStates).toStrictEqual(['firing', '']);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(screen.getByTestId('home-alert-rule-burning'));
|
||||
expect(navigate).toHaveBeenCalledWith(
|
||||
`${ROUTES.ALERT_OVERVIEW}?${QueryParams.ruleId}=burning`,
|
||||
);
|
||||
});
|
||||
|
||||
it('skips the latest rules request when 5 rules are firing', async () => {
|
||||
const firing = Array.from({ length: 5 }, (_, index) =>
|
||||
rule(`firing-${index}`, RuletypesAlertStateDTO.firing),
|
||||
);
|
||||
const requestedStates = serveRules([quiet, ...firing]);
|
||||
renderAlertRules();
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('home-alert-rule-firing-4'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(renderedRuleIds()).toStrictEqual(
|
||||
firing.map(({ id }) => `home-alert-rule-${id}`),
|
||||
);
|
||||
expect(requestedStates).toStrictEqual(['firing']);
|
||||
});
|
||||
|
||||
it('shows a rule once when it stops firing between the two requests', async () => {
|
||||
const settled = { ...burning, state: RuletypesAlertStateDTO.inactive };
|
||||
serveRules([burning], [settled, quiet]);
|
||||
renderAlertRules();
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('home-alert-rule-quiet'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(renderedRuleIds()).toStrictEqual([
|
||||
'home-alert-rule-burning',
|
||||
'home-alert-rule-quiet',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -16,11 +16,11 @@ import {
|
||||
useDeleteDowntimeScheduleByID,
|
||||
useListDowntimeSchedules,
|
||||
} from 'api/generated/services/downtimeschedules';
|
||||
import { useListRules } from 'api/generated/services/rules';
|
||||
import type { AlertmanagertypesPlannedMaintenanceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ColumnSelector } from 'components/TanStackTableView';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import dayjs from 'dayjs';
|
||||
import { useListAllRules } from 'hooks/alerts/useListAllRules';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
@@ -55,9 +55,7 @@ export function PlannedDowntime(): JSX.Element {
|
||||
isError,
|
||||
isLoading,
|
||||
isFetching: isFetchingAlerts,
|
||||
} = useListRules({
|
||||
query: { cacheTime: 0 },
|
||||
});
|
||||
} = useListAllRules({ cacheTime: 0 });
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const { user } = useAppContext();
|
||||
@@ -73,7 +71,7 @@ export function PlannedDowntime(): JSX.Element {
|
||||
const downtimeSchedules = useListDowntimeSchedules();
|
||||
const alertOptions = React.useMemo(
|
||||
() =>
|
||||
alertsData?.data?.map((i) => ({
|
||||
alertsData?.map((i) => ({
|
||||
label: i.alert,
|
||||
value: i.id,
|
||||
})),
|
||||
@@ -181,7 +179,7 @@ export function PlannedDowntime(): JSX.Element {
|
||||
};
|
||||
|
||||
const refetchAllSchedules = (): void => {
|
||||
downtimeSchedules.refetch();
|
||||
void downtimeSchedules.refetch();
|
||||
};
|
||||
|
||||
const {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fireEvent, screen } from '@testing-library/react';
|
||||
import type {
|
||||
ListDowntimeSchedules200,
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesListableRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
AlertmanagertypesRepeatOnDTO,
|
||||
@@ -126,19 +127,19 @@ jest.mock('api/generated/services/downtimeschedules', () => ({
|
||||
}));
|
||||
|
||||
let mockRulesState: {
|
||||
data?: { data: Array<{ id: number | string; alert: string }> };
|
||||
data?: Pick<RuletypesListableRuleDTO, 'id' | 'alert'>[];
|
||||
isError: boolean;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
} = {
|
||||
data: { data: [] },
|
||||
data: [],
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
};
|
||||
|
||||
jest.mock('api/generated/services/rules', () => ({
|
||||
useListRules: (): typeof mockRulesState => mockRulesState,
|
||||
jest.mock('hooks/alerts/useListAllRules', () => ({
|
||||
useListAllRules: (): typeof mockRulesState => mockRulesState,
|
||||
}));
|
||||
|
||||
describe('PlannedDowntime Component', () => {
|
||||
@@ -151,7 +152,7 @@ describe('PlannedDowntime Component', () => {
|
||||
});
|
||||
mockDowntimeQueryResult.data = MOCK_DOWNTIME_RESPONSE;
|
||||
mockRulesState = {
|
||||
data: { data: [] },
|
||||
data: [],
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
@@ -362,7 +363,7 @@ describe('PlannedDowntime Component', () => {
|
||||
|
||||
it('shows "Unknown rule" when scoped rule IDs cannot be resolved (Bug 56)', async () => {
|
||||
mockRulesState = {
|
||||
data: { data: [] },
|
||||
data: [],
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
@@ -387,14 +388,7 @@ describe('PlannedDowntime Component', () => {
|
||||
|
||||
it('shows resolved alert rule name when scoped rule ID exists', async () => {
|
||||
mockRulesState = {
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: 'rule-42',
|
||||
alert: 'Disk Space High',
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
data: [{ id: 'rule-42', alert: 'Disk Space High' }],
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
|
||||
40
frontend/src/hooks/alerts/__tests__/useListAllRules.test.tsx
Normal file
40
frontend/src/hooks/alerts/__tests__/useListAllRules.test.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
alertRulesFixture,
|
||||
listRulesV3Response,
|
||||
} from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { useListAllRules } from '../useListAllRules';
|
||||
|
||||
const TOTAL = 250;
|
||||
|
||||
const rules = Array.from({ length: TOTAL }, (_, index) => ({
|
||||
...alertRulesFixture[0],
|
||||
id: `rule-${index}`,
|
||||
alert: `Rule ${index}`,
|
||||
}));
|
||||
|
||||
describe('useListAllRules', () => {
|
||||
it('fetches every page past the 200-rule cap', async () => {
|
||||
const offsets: string[] = [];
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
offsets.push(req.url.searchParams.get('offset') ?? '');
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(listRulesV3Response(rules, req.url.searchParams)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useListAllRules(), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data).toHaveLength(TOTAL);
|
||||
expect(result.current.data?.[TOTAL - 1]?.id).toBe(`rule-${TOTAL - 1}`);
|
||||
expect(offsets.sort()).toStrictEqual(['0', '200']);
|
||||
});
|
||||
});
|
||||
40
frontend/src/hooks/alerts/useListAllRules.ts
Normal file
40
frontend/src/hooks/alerts/useListAllRules.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
|
||||
import {
|
||||
getListRulesV3QueryKey,
|
||||
listRulesV3,
|
||||
} from 'api/generated/services/rules';
|
||||
import type { RuletypesListableRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { range } from 'lodash-es';
|
||||
|
||||
// The list API clamps any larger limit to 200.
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
export const LIST_ALL_RULES_QUERY_KEY = [...getListRulesV3QueryKey(), 'all'];
|
||||
|
||||
function fetchPage(offset: number): ReturnType<typeof listRulesV3> {
|
||||
return listRulesV3({ limit: PAGE_SIZE, offset });
|
||||
}
|
||||
|
||||
// TODO(AshwinBhatkal): temporary. Replace with server-side search plus a lookup of saved rules by ID once
|
||||
// /api/v3/rules can filter by IDs: https://github.com/SigNoz/pulse-pod/issues/715
|
||||
async function fetchAllRules(): Promise<RuletypesListableRuleDTO[]> {
|
||||
const first = await fetchPage(0);
|
||||
const rest = await Promise.all(
|
||||
range(PAGE_SIZE, first.data.total, PAGE_SIZE).map(fetchPage),
|
||||
);
|
||||
|
||||
return [...first.data.rules, ...rest.flatMap((page) => page.data.rules)];
|
||||
}
|
||||
|
||||
export function useListAllRules(
|
||||
options?: Omit<
|
||||
UseQueryOptions<RuletypesListableRuleDTO[], Error>,
|
||||
'queryKey' | 'queryFn'
|
||||
>,
|
||||
): UseQueryResult<RuletypesListableRuleDTO[], Error> {
|
||||
return useQuery<RuletypesListableRuleDTO[], Error>({
|
||||
queryKey: LIST_ALL_RULES_QUERY_KEY,
|
||||
queryFn: fetchAllRules,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -17,10 +17,8 @@ import {
|
||||
FIRST_DOWNTIME_NAME,
|
||||
} from './__story_mockdata__/plannedDowntime';
|
||||
|
||||
import {
|
||||
alertRulesResponse,
|
||||
RULE_MAX,
|
||||
} from '../../stories/__story_mockdata__/alerts';
|
||||
import { alertRulesPage } from '../../AlertRules/stories/__story_mockdata__/ruleViews';
|
||||
import { RULE_MAX } from '../../stories/__story_mockdata__/alerts';
|
||||
import { AlertListSubTabs, AlertListTabs } from '../../types';
|
||||
|
||||
const LIST = 'Planned downtime · list';
|
||||
@@ -86,18 +84,20 @@ export const plannedDowntimeMocks = defineStoryMocks({
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v2/rules', (_req, res, ctx) =>
|
||||
values.rulesState === 'error'
|
||||
? res(ctx.status(500), ctx.json({ status: 'error' }))
|
||||
: res(
|
||||
ctx.json(
|
||||
alertRulesResponse(values.silencedRules, {
|
||||
severity: 'mixed',
|
||||
state: 'mixed',
|
||||
}),
|
||||
),
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
if (values.rulesState === 'error') {
|
||||
return res(ctx.status(500), ctx.json({ status: 'error' }));
|
||||
}
|
||||
return res(
|
||||
ctx.json(
|
||||
alertRulesPage(
|
||||
values.silencedRules,
|
||||
{ severity: 'mixed', state: 'mixed' },
|
||||
req.url.searchParams,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
config: () => ({
|
||||
route: `/alerts?tab=${AlertListTabs.CONFIGURATION}&subTab=${AlertListSubTabs.PLANNED_DOWNTIME}`,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { listRulesV3Response } from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { rest } from 'msw';
|
||||
|
||||
import {
|
||||
@@ -156,11 +157,13 @@ export const homeMocks = defineStoryMocks({
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/rules',
|
||||
response.json(() => ({
|
||||
status: 'success',
|
||||
data: buildAlertRules(values.alertRules),
|
||||
})),
|
||||
'http://localhost/api/v3/rules',
|
||||
response.json((req) =>
|
||||
listRulesV3Response(
|
||||
buildAlertRules(values.alertRules),
|
||||
req.url.searchParams,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
|
||||
@@ -59,7 +59,7 @@ type Email struct {
|
||||
|
||||
// layoutData is the value passed to the email.signoz.html layout
|
||||
// template. It embeds NotificationTemplateData so templates can reference
|
||||
// `.Alert.Status`, `.Alert.TotalFiring`, `.Alert.TotalResolved`,
|
||||
// `.Alert.Status`, `.Alert.TotalFiring`, `.Alert.TotalRecovering`, `.Alert.TotalResolved`,
|
||||
// `.NotificationTemplateData.ExternalURL`, etc. alongside the rendered
|
||||
// Title and per-alert Bodies.
|
||||
type layoutData struct {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -706,7 +707,9 @@ func TestEmailRejected(t *testing.T) {
|
||||
|
||||
// Send the alert to mock SMTP server.
|
||||
retry, err := e.Notify(context.Background(), firingAlert)
|
||||
require.ErrorContains(t, err, "501 5.5.4 Rejected!")
|
||||
var smtpErr *textproto.Error
|
||||
require.ErrorAs(t, err, &smtpErr)
|
||||
assert.Equal(t, 501, smtpErr.Code)
|
||||
assert.True(t, retry)
|
||||
require.NoError(t, srv.Shutdown(ctx))
|
||||
|
||||
|
||||
@@ -389,7 +389,7 @@ func TestPrepareContent(t *testing.T) {
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Current** | *$value* |
|
||||
| **Threshold** | *$threshold.value* |
|
||||
| **Threshold** | *$threshold* |
|
||||
|
||||
**Status:** $alert.status | **Severity:** $labels.severity`
|
||||
titleTemplate := "[$alert.status] $rule.name — $labels.service"
|
||||
@@ -401,10 +401,10 @@ func TestPrepareContent(t *testing.T) {
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
Annotations: model.LabelSet{
|
||||
ruletypes.AnnotationTitleTemplate: model.LabelValue(titleTemplate),
|
||||
ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
|
||||
"value": "100",
|
||||
"threshold.value": "200",
|
||||
ruletypes.AnnotationTitleTemplate: model.LabelValue(titleTemplate),
|
||||
ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
|
||||
ruletypes.AnnotationValue: "100",
|
||||
ruletypes.AnnotationThresholdValue: "200",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -414,10 +414,10 @@ func TestPrepareContent(t *testing.T) {
|
||||
StartsAt: time.Now().Add(-2 * time.Hour),
|
||||
EndsAt: time.Now().Add(-time.Hour),
|
||||
Annotations: model.LabelSet{
|
||||
ruletypes.AnnotationTitleTemplate: model.LabelValue(titleTemplate),
|
||||
ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
|
||||
"value": "50",
|
||||
"threshold.value": "200",
|
||||
ruletypes.AnnotationTitleTemplate: model.LabelValue(titleTemplate),
|
||||
ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
|
||||
ruletypes.AnnotationValue: "50",
|
||||
ruletypes.AnnotationThresholdValue: "200",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -205,16 +205,29 @@ func (at *templater) buildNotificationTemplateData(
|
||||
alertDataSlice = append(alertDataSlice, ad)
|
||||
}
|
||||
|
||||
// count the number of firing and resolved alerts
|
||||
var firing, resolved int
|
||||
// count the number of firing, recovering and resolved alerts
|
||||
var firing, recovering, resolved int
|
||||
var isTest bool
|
||||
for _, ad := range alertDataSlice {
|
||||
if ad.Alert.IsFiring {
|
||||
isTest = isTest || ad.Alert.IsTest
|
||||
switch {
|
||||
case ad.Alert.IsFiring:
|
||||
firing++
|
||||
} else if ad.Alert.IsResolved {
|
||||
case ad.Alert.IsRecovering:
|
||||
recovering++
|
||||
case ad.Alert.IsResolved:
|
||||
resolved++
|
||||
}
|
||||
}
|
||||
|
||||
// firing wins over recovering, which wins over resolved
|
||||
status := string(model.AlertResolved)
|
||||
if firing > 0 {
|
||||
status = string(model.AlertFiring)
|
||||
} else if recovering > 0 {
|
||||
status = ruletypes.StateRecovering.StringValue()
|
||||
}
|
||||
|
||||
// build the group labels
|
||||
gl := make(template.KV, len(groupLabels))
|
||||
for k, v := range groupLabels {
|
||||
@@ -224,10 +237,12 @@ func (at *templater) buildNotificationTemplateData(
|
||||
// build the notification template data
|
||||
return &alertmanagertypes.NotificationTemplateData{
|
||||
Alert: alertmanagertypes.NotificationAlert{
|
||||
Receiver: receiver,
|
||||
Status: string(types.Alerts(alerts...).Status()),
|
||||
TotalFiring: firing,
|
||||
TotalResolved: resolved,
|
||||
Receiver: receiver,
|
||||
Status: status,
|
||||
TotalFiring: firing,
|
||||
TotalRecovering: recovering,
|
||||
TotalResolved: resolved,
|
||||
IsTest: isTest,
|
||||
},
|
||||
Rule: buildRuleInfo(commonLabels, rawCommonAnnotations),
|
||||
GroupLabels: gl,
|
||||
@@ -252,27 +267,39 @@ func buildAlertData(a *types.Alert, receiver string) alertmanagertypes.AlertData
|
||||
annotations[string(k)] = string(v)
|
||||
}
|
||||
|
||||
// The alertmanager only knows firing and resolved; the rule manager flags a
|
||||
// firing alert whose value is between the threshold and the recovery
|
||||
// threshold.
|
||||
status := string(a.Status())
|
||||
if a.Status() == model.AlertFiring && annotations[ruletypes.AnnotationIsRecovering] == "true" {
|
||||
status = ruletypes.StateRecovering.StringValue()
|
||||
}
|
||||
|
||||
return alertmanagertypes.AlertData{
|
||||
Alert: alertmanagertypes.AlertInfo{
|
||||
Status: string(a.Status()),
|
||||
Status: status,
|
||||
Receiver: receiver,
|
||||
Value: annotations[ruletypes.AnnotationValue],
|
||||
StartsAt: a.StartsAt,
|
||||
EndsAt: a.EndsAt,
|
||||
GeneratorURL: a.GeneratorURL,
|
||||
Fingerprint: a.Fingerprint().String(),
|
||||
IsFiring: a.Status() == model.AlertFiring,
|
||||
IsResolved: a.Status() == model.AlertResolved,
|
||||
IsFiring: status == string(model.AlertFiring),
|
||||
IsResolved: status == string(model.AlertResolved),
|
||||
IsMissingData: labels[ruletypes.LabelNoData] == "true",
|
||||
IsRecovering: labels[ruletypes.LabelIsRecovering] == "true",
|
||||
IsRecovering: status == ruletypes.StateRecovering.StringValue(),
|
||||
IsTest: labels[ruletypes.LabelTestAlert] == "true",
|
||||
},
|
||||
Rule: buildRuleInfo(labels, annotations),
|
||||
Log: alertmanagertypes.LinkInfo{URL: annotations[ruletypes.AnnotationRelatedLogs]},
|
||||
Trace: alertmanagertypes.LinkInfo{URL: annotations[ruletypes.AnnotationRelatedTraces]},
|
||||
Labels: labels,
|
||||
Rule: buildRuleInfo(labels, annotations),
|
||||
Log: alertmanagertypes.LinkInfo{URL: annotations[ruletypes.AnnotationRelatedLogs]},
|
||||
Trace: alertmanagertypes.LinkInfo{URL: annotations[ruletypes.AnnotationRelatedTraces]},
|
||||
Exception: alertmanagertypes.LinkInfo{URL: annotations[ruletypes.AnnotationRelatedExceptions]},
|
||||
Labels: labels,
|
||||
// Strip private annotations once the structured fields above have
|
||||
// been populated from the raw map.
|
||||
Annotations: alertmanagertypes.FilterPublicAnnotations(annotations),
|
||||
Value: annotations[ruletypes.AnnotationValue],
|
||||
Threshold: annotations[ruletypes.AnnotationThresholdValue],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,10 +311,12 @@ func buildRuleInfo(labels, annotations template.KV) alertmanagertypes.RuleInfo {
|
||||
ID: labels[ruletypes.LabelRuleID],
|
||||
URL: labels[ruletypes.LabelRuleSource],
|
||||
Severity: labels[ruletypes.LabelSeverityName],
|
||||
Type: annotations[ruletypes.AnnotationRuleType],
|
||||
MatchType: annotations[ruletypes.AnnotationMatchType],
|
||||
Threshold: alertmanagertypes.Threshold{
|
||||
Value: annotations[ruletypes.AnnotationThresholdValue],
|
||||
Op: annotations[ruletypes.AnnotationCompareOp],
|
||||
Value: annotations[ruletypes.AnnotationThresholdValue],
|
||||
Op: annotations[ruletypes.AnnotationCompareOp],
|
||||
RecoveryValue: annotations[ruletypes.AnnotationRecoveryThreshold],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
@@ -247,6 +248,178 @@ Description: Request rate exceeded 10k/s`},
|
||||
wantBody: []string{"Runbook: https://runbook.example.com"},
|
||||
wantIsDefaultBody: true,
|
||||
},
|
||||
{
|
||||
name: "value annotation feeds alert.value and is hidden from annotations",
|
||||
alerts: []*types.Alert{
|
||||
createAlert(
|
||||
map[string]string{ruletypes.LabelAlertName: "HighErrors"},
|
||||
map[string]string{"summary": "errors are high", ruletypes.AnnotationValue: "523"},
|
||||
true,
|
||||
),
|
||||
},
|
||||
input: alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: "$rule.name",
|
||||
BodyTemplate: "Value: $alert.value, annotations: {{ len $annotations }}",
|
||||
},
|
||||
wantTitle: "HighErrors",
|
||||
wantBody: []string{"Value: 523, annotations: 1"},
|
||||
},
|
||||
{
|
||||
name: "value shortcut resolves bare and in braces and wins over a label named value",
|
||||
alerts: []*types.Alert{
|
||||
createAlert(
|
||||
map[string]string{ruletypes.LabelAlertName: "HighErrors", "value": "from-label"},
|
||||
map[string]string{ruletypes.AnnotationValue: "523"},
|
||||
true,
|
||||
),
|
||||
},
|
||||
input: alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: "$rule.name",
|
||||
BodyTemplate: "$value | {{ $value }} | {{ if $value }}set{{ end }} | $labels.value",
|
||||
},
|
||||
wantTitle: "HighErrors",
|
||||
wantBody: []string{"523 | 523 | set | from-label"},
|
||||
},
|
||||
{
|
||||
name: "threshold shortcut and rule.threshold.value read the threshold annotation and win over a label named threshold",
|
||||
alerts: []*types.Alert{
|
||||
createAlert(
|
||||
map[string]string{ruletypes.LabelAlertName: "HighErrors", "threshold": "from-label"},
|
||||
map[string]string{ruletypes.AnnotationThresholdValue: "500"},
|
||||
true,
|
||||
),
|
||||
},
|
||||
input: alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: "$rule.name above $rule.threshold.value",
|
||||
BodyTemplate: "$threshold | {{ $threshold }} | $rule.threshold.value | $labels.threshold | annotations: {{ len $annotations }}",
|
||||
},
|
||||
wantTitle: "HighErrors above 500",
|
||||
wantBody: []string{"500 | 500 | 500 | from-label | annotations: 0"},
|
||||
},
|
||||
{
|
||||
name: "rule threshold op and recovery value read their private annotations",
|
||||
alerts: []*types.Alert{
|
||||
createAlert(
|
||||
map[string]string{ruletypes.LabelAlertName: "HighErrors"},
|
||||
map[string]string{ruletypes.AnnotationThresholdValue: "500", ruletypes.AnnotationCompareOp: "above", ruletypes.AnnotationRecoveryThreshold: "400"},
|
||||
true,
|
||||
),
|
||||
},
|
||||
input: alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: "$rule.name $rule.threshold.op $rule.threshold.value",
|
||||
BodyTemplate: "$rule.threshold.op $rule.threshold.value (recovers at $rule.threshold.recovery_value), annotations: {{ len $annotations }}",
|
||||
},
|
||||
wantTitle: "HighErrors above 500",
|
||||
wantBody: []string{"above 500 (recovers at 400), annotations: 0"},
|
||||
},
|
||||
{
|
||||
name: "rule type and match type read their private annotations",
|
||||
alerts: []*types.Alert{
|
||||
createAlert(
|
||||
map[string]string{ruletypes.LabelAlertName: "HighErrors", "type": "from-label"},
|
||||
map[string]string{ruletypes.AnnotationRuleType: "logs", ruletypes.AnnotationMatchType: "on_average"},
|
||||
true,
|
||||
),
|
||||
},
|
||||
input: alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: "$rule.type: $rule.name",
|
||||
BodyTemplate: "$rule.type $rule.match_type | $type | annotations: {{ len $annotations }}",
|
||||
},
|
||||
wantTitle: "logs: HighErrors",
|
||||
wantBody: []string{"logs on_average | from-label | annotations: 0"},
|
||||
},
|
||||
{
|
||||
name: "test label sets is_test on the title and each alert",
|
||||
alerts: []*types.Alert{
|
||||
createAlert(
|
||||
map[string]string{ruletypes.LabelAlertName: "HighErrors", ruletypes.LabelTestAlert: "true"},
|
||||
nil,
|
||||
true,
|
||||
),
|
||||
},
|
||||
input: alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: "{{ if $alert.is_test }}[TEST] {{ end }}$rule.name",
|
||||
BodyTemplate: "is_test=$alert.is_test",
|
||||
},
|
||||
wantTitle: "[TEST] HighErrors",
|
||||
wantBody: []string{"is_test=true"},
|
||||
},
|
||||
{
|
||||
name: "alert without test label is not a test",
|
||||
alerts: []*types.Alert{
|
||||
createAlert(map[string]string{ruletypes.LabelAlertName: "HighErrors"}, nil, true),
|
||||
},
|
||||
input: alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: "{{ if $alert.is_test }}[TEST] {{ end }}$rule.name",
|
||||
BodyTemplate: "is_test=$alert.is_test",
|
||||
},
|
||||
wantTitle: "HighErrors",
|
||||
wantBody: []string{"is_test=false"},
|
||||
},
|
||||
{
|
||||
name: "exception url reads its private annotation",
|
||||
alerts: []*types.Alert{
|
||||
createAlert(
|
||||
map[string]string{ruletypes.LabelAlertName: "CheckoutExceptions"},
|
||||
map[string]string{ruletypes.AnnotationRelatedExceptions: "https://signoz.example/exceptions?serviceName=checkout"},
|
||||
true,
|
||||
),
|
||||
},
|
||||
input: alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: "$rule.name",
|
||||
BodyTemplate: "[View exceptions]($exception.url), annotations: {{ len $annotations }}",
|
||||
},
|
||||
wantTitle: "CheckoutExceptions",
|
||||
wantBody: []string{"[View exceptions](https://signoz.example/exceptions?serviceName=checkout), annotations: 0"},
|
||||
},
|
||||
{
|
||||
name: "recovering annotation sets status, flags and counts with firing winning the title",
|
||||
alerts: []*types.Alert{
|
||||
createAlert(
|
||||
map[string]string{ruletypes.LabelAlertName: "HighErrors", "team": "core"},
|
||||
map[string]string{ruletypes.AnnotationIsRecovering: "true"},
|
||||
true,
|
||||
),
|
||||
createAlert(
|
||||
map[string]string{ruletypes.LabelAlertName: "HighErrors", "team": "edge"},
|
||||
nil,
|
||||
true,
|
||||
),
|
||||
},
|
||||
input: alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: "$alert.status: $alert.total_firing firing, $alert.total_recovering recovering, $alert.total_resolved resolved",
|
||||
BodyTemplate: "$labels.team: $alert.status firing=$alert.is_firing recovering=$alert.is_recovering resolved=$alert.is_resolved, annotations: {{ len $annotations }}",
|
||||
},
|
||||
wantTitle: "firing: 1 firing, 1 recovering, 0 resolved",
|
||||
wantBody: []string{
|
||||
"core: recovering firing=false recovering=true resolved=false, annotations: 0",
|
||||
"edge: firing firing=true recovering=false resolved=false, annotations: 0",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recovering wins the title over resolved and a resolved alert ignores a stale recovering flag",
|
||||
alerts: []*types.Alert{
|
||||
createAlert(
|
||||
map[string]string{ruletypes.LabelAlertName: "HighErrors", "team": "core"},
|
||||
map[string]string{ruletypes.AnnotationIsRecovering: "true"},
|
||||
true,
|
||||
),
|
||||
createAlert(
|
||||
map[string]string{ruletypes.LabelAlertName: "HighErrors", "team": "edge"},
|
||||
map[string]string{ruletypes.AnnotationIsRecovering: "true"},
|
||||
false,
|
||||
),
|
||||
},
|
||||
input: alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: "$alert.status: $alert.total_firing firing, $alert.total_recovering recovering, $alert.total_resolved resolved",
|
||||
BodyTemplate: "$labels.team: $alert.status recovering=$alert.is_recovering",
|
||||
},
|
||||
wantTitle: "recovering: 0 firing, 1 recovering, 1 resolved",
|
||||
wantBody: []string{
|
||||
"core: recovering recovering=true",
|
||||
"edge: resolved recovering=false",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "using non-existing function in template",
|
||||
alerts: []*types.Alert{
|
||||
@@ -285,3 +458,61 @@ Description: Request rate exceeded 10k/s`},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandTemplateFunctions(t *testing.T) {
|
||||
at, ctx := testSetup(t)
|
||||
startsAt := time.Date(2026, time.November, 12, 5, 30, 0, 0, time.UTC)
|
||||
alert := &types.Alert{Alert: model.Alert{
|
||||
Labels: model.LabelSet{ruletypes.LabelAlertName: "HighLatency", ruletypes.LabelSeverityName: "critical", "env": "prod-eu", "host": "web-1.example.com"},
|
||||
Annotations: model.LabelSet{"summary": " p99 is high "},
|
||||
StartsAt: startsAt,
|
||||
EndsAt: startsAt.Add(time.Hour),
|
||||
}}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantBody string
|
||||
}{
|
||||
{name: "ToUpper", body: "{{ $env | toUpper }}", wantBody: "PROD-EU"},
|
||||
{name: "ToLower", body: "{{ $env | toLower }}", wantBody: "prod-eu"},
|
||||
{name: "Title", body: "{{ $rule.severity | title }}", wantBody: "Critical"},
|
||||
{name: "TrimSpace", body: "[{{ $annotations.summary | trimSpace }}]", wantBody: "[p99 is high]"},
|
||||
{name: "Match", body: `{{ if $env | match "^prod" }}production{{ end }}`, wantBody: "production"},
|
||||
{name: "ReReplaceAll", body: `{{ $host | reReplaceAll "^([^.]+)\\..*" "$1" }}`, wantBody: "web-1"},
|
||||
{name: "StringSlice_Join", body: `{{ stringSlice $env $host | join ", " }}`, wantBody: "prod-eu, web-1.example.com"},
|
||||
{name: "Urlescape", body: `q={{ "a b&c" | urlescape }}`, wantBody: "q=a+b%26c"},
|
||||
{name: "Date_AlertStartsAt", body: `{{ $alert.starts_at | date "Jan 2, 15:04 MST" }}`, wantBody: "Nov 12, 05:30 UTC"},
|
||||
{name: "Tz_Date_AlertStartsAt", body: `{{ $alert.starts_at | tz "Asia/Kolkata" | date "15:04 MST" }}`, wantBody: "11:00 IST"},
|
||||
{name: "Date_AlertEndsAt", body: `{{ $alert.ends_at | date "15:04" }}`, wantBody: "06:30"},
|
||||
{name: "HumanizeDuration_Seconds", body: "{{ humanizeDuration 3725 }}", wantBody: "1h 2m 5s"},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
result, err := at.Expand(ctx, alertmanagertypes.ExpandRequest{TitleTemplate: "$rule.name", BodyTemplate: testCase.body}, []*types.Alert{alert})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result.Body, 1)
|
||||
assert.Equal(t, testCase.wantBody, result.Body[0])
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Now_Tz_Date", func(t *testing.T) {
|
||||
result, err := at.Expand(ctx, alertmanagertypes.ExpandRequest{TitleTemplate: "$rule.name", BodyTemplate: `{{ now | tz "Asia/Kolkata" | date "2006 MST" }}`}, []*types.Alert{alert})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result.Body, 1)
|
||||
assert.Equal(t, time.Now().In(time.FixedZone("IST", 5*60*60+30*60)).Format("2006 MST"), result.Body[0])
|
||||
})
|
||||
|
||||
t.Run("Since_HumanizeDuration_AlertStartsAt", func(t *testing.T) {
|
||||
recent := &types.Alert{Alert: model.Alert{
|
||||
Labels: model.LabelSet{ruletypes.LabelAlertName: "HighLatency"},
|
||||
StartsAt: time.Now().Add(-time.Hour),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
}}
|
||||
result, err := at.Expand(ctx, alertmanagertypes.ExpandRequest{TitleTemplate: "$rule.name", BodyTemplate: "{{ $alert.starts_at | since | humanizeDuration }}"}, []*types.Alert{recent})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result.Body, 1)
|
||||
assert.Regexp(t, `^1h 0m \d+s$`, result.Body[0])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
)
|
||||
|
||||
// fieldPath is a dotted mapstructure path into the templating data map,
|
||||
@@ -131,16 +131,51 @@ func structRootSet(data any) map[string]bool {
|
||||
return roots
|
||||
}
|
||||
|
||||
// toTemplateMap converts a tagged struct into nested maps keyed by its
|
||||
// mapstructure tag names, the names templates reference. Unlike
|
||||
// mapstructure.Decode it keeps time.Time values as is, so date, tz and since
|
||||
// work on them. Fields that are untagged or tagged "-" are skipped.
|
||||
func toTemplateMap(val reflect.Value) map[string]any {
|
||||
typ := val.Type()
|
||||
result := make(map[string]any, typ.NumField())
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
if !field.IsExported() {
|
||||
continue
|
||||
}
|
||||
name := strings.Split(field.Tag.Get("mapstructure"), ",")[0]
|
||||
if name == "" || name == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
fv := val.Field(i)
|
||||
if fv.Kind() == reflect.Pointer {
|
||||
if fv.IsNil() {
|
||||
result[name] = nil
|
||||
continue
|
||||
}
|
||||
fv = fv.Elem()
|
||||
}
|
||||
if fv.Kind() == reflect.Struct && fv.Type() != reflect.TypeOf(time.Time{}) {
|
||||
result[name] = toTemplateMap(fv)
|
||||
continue
|
||||
}
|
||||
result[name] = fv.Interface()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// buildDataMap converts the typed data struct to the map[string]any that the
|
||||
// template engine indexes into. Each label and annotation is additionally
|
||||
// exposed at the root under its raw key, so $service.name resolves a flat
|
||||
// OTel-style label as a single-key index on the root. Struct-path keys
|
||||
// already present at the root take precedence on collisions.
|
||||
func buildDataMap(data any) (map[string]any, error) {
|
||||
var result map[string]any
|
||||
if err := mapstructure.Decode(data, &result); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to build template data map")
|
||||
val := reflect.Indirect(reflect.ValueOf(data))
|
||||
if val.Kind() != reflect.Struct {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to build template data map: expected a struct, got %T", data)
|
||||
}
|
||||
result := toTemplateMap(val)
|
||||
|
||||
flatten := func(labels, annotations map[string]string) {
|
||||
for k, v := range labels {
|
||||
|
||||
@@ -229,10 +229,12 @@ func TestPreProcessTemplateAndData(t *testing.T) {
|
||||
},
|
||||
expectedData: map[string]any{
|
||||
"alert": map[string]any{
|
||||
"receiver": "pagerduty",
|
||||
"status": "firing",
|
||||
"total_firing": 3,
|
||||
"total_resolved": 0,
|
||||
"receiver": "pagerduty",
|
||||
"status": "firing",
|
||||
"total_firing": 3,
|
||||
"total_recovering": 0,
|
||||
"total_resolved": 0,
|
||||
"is_test": false,
|
||||
},
|
||||
"severity": "critical",
|
||||
"http.request.method": "GET",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package contextlinks
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"slices"
|
||||
@@ -26,6 +27,25 @@ func PrepareParamsForLogsV5(start, end time.Time, whereClause string) url.Values
|
||||
return prepareExplorerParams("logs", qbtypes.QueryTypeBuilder, start.UnixMilli(), end.UnixMilli(), whereClause)
|
||||
}
|
||||
|
||||
// PrepareParamsForExceptions returns the exceptions page query params for the
|
||||
// given range, filtered to the service and exception type found in labels
|
||||
// (serviceName and exceptionType from SQL, or their builder keys). The page
|
||||
// writes its time params in milliseconds.
|
||||
// It doesn't reuse prepareExplorerParams: the exceptions page reads only the
|
||||
// older filters.items from compositeQuery, not filter.expression.
|
||||
func PrepareParamsForExceptions(start, end time.Time, labels map[string]string) url.Values {
|
||||
params := url.Values{}
|
||||
params.Set("startTime", strconv.FormatInt(start.UnixMilli(), 10))
|
||||
params.Set("endTime", strconv.FormatInt(end.UnixMilli(), 10))
|
||||
if serviceName := cmp.Or(labels["serviceName"], labels["service.name"]); serviceName != "" {
|
||||
params.Set("serviceName", serviceName)
|
||||
}
|
||||
if exceptionType := cmp.Or(labels["exceptionType"], labels["exception.type"]); exceptionType != "" {
|
||||
params.Set("exceptionType", exceptionType)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// The end link is double encoded because otherwise a filter expression with `%` somewhere in it breaks.
|
||||
func prepareExplorerParams(dataSource string, queryType qbtypes.QueryType, start, end int64, whereClause string) url.Values {
|
||||
// builder_query is the explorer default, so it is left out to keep existing links unchanged
|
||||
|
||||
@@ -2,6 +2,7 @@ package contextlinks
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
@@ -222,3 +223,30 @@ func TestPrepareRuleFilterExpression(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareParamsForExceptions(t *testing.T) {
|
||||
start := time.UnixMilli(1705468620000)
|
||||
end := time.UnixMilli(1705468920000)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
labels map[string]string
|
||||
serviceName string
|
||||
exceptionType string
|
||||
}{
|
||||
{name: "SQLColumns_SetsServiceAndExceptionType", labels: map[string]string{"serviceName": "checkout", "exceptionType": "TimeoutError"}, serviceName: "checkout", exceptionType: "TimeoutError"},
|
||||
{name: "BuilderKeys_SetsServiceAndExceptionType", labels: map[string]string{"service.name": "payments", "exception.type": "ValueError"}, serviceName: "payments", exceptionType: "ValueError"},
|
||||
{name: "NoMatchingLabels_SetsTimeOnly", labels: map[string]string{"host": "web-1"}},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
params := PrepareParamsForExceptions(start, end, testCase.labels)
|
||||
|
||||
assert.Equal(t, "1705468620000", params.Get("startTime"))
|
||||
assert.Equal(t, "1705468920000", params.Get("endTime"))
|
||||
assert.Equal(t, testCase.serviceName, params.Get("serviceName"))
|
||||
assert.Equal(t, testCase.exceptionType, params.Get("exceptionType"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/units"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
@@ -85,6 +86,9 @@ type BaseRule struct {
|
||||
// newGroupEvalDelay is the grace period for new alert groups
|
||||
newGroupEvalDelay valuer.TextDuration
|
||||
|
||||
useTemplates bool
|
||||
notificationBody string
|
||||
|
||||
ruleStateHistoryModule rulestatehistory.Module
|
||||
|
||||
queryParser queryparser.QueryParser
|
||||
@@ -173,9 +177,11 @@ func NewBaseRule(
|
||||
evaluation: evaluation,
|
||||
}
|
||||
|
||||
// Store newGroupEvalDelay and groupBy keys from NotificationSettings
|
||||
// Store newGroupEvalDelay, groupBy keys and template settings from NotificationSettings
|
||||
if p.NotificationSettings != nil {
|
||||
baseRule.newGroupEvalDelay = p.NotificationSettings.NewGroupEvalDelay
|
||||
baseRule.useTemplates = p.NotificationSettings.UseTemplates
|
||||
baseRule.notificationBody = p.NotificationSettings.NotificationBody
|
||||
}
|
||||
|
||||
if baseRule.evalWindow.IsZero() {
|
||||
@@ -272,6 +278,47 @@ func (r *BaseRule) ExpandAnnotations(expand func(string) string) ruletypes.Label
|
||||
return annotations
|
||||
}
|
||||
|
||||
// AlertAnnotations returns the expanded rule annotations plus the private ones
|
||||
// the notifier reads into template fields. value and threshold are smpl.V and
|
||||
// smpl.Target formatted for display.
|
||||
func (r *BaseRule) AlertAnnotations(expand func(string) string, smpl ruletypes.Sample, value, threshold string) ruletypes.Labels {
|
||||
annotations := r.ExpandAnnotations(expand)
|
||||
if !smpl.IsMissing {
|
||||
annotations = append(annotations,
|
||||
ruletypes.Label{Name: ruletypes.AnnotationValue, Value: value},
|
||||
ruletypes.Label{Name: ruletypes.AnnotationThresholdValue, Value: threshold},
|
||||
)
|
||||
if op := smpl.CompareOperator.Literal(); op != "" {
|
||||
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationCompareOp, Value: op})
|
||||
}
|
||||
if matchType := smpl.MatchType.Literal(); matchType != "" {
|
||||
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationMatchType, Value: matchType})
|
||||
}
|
||||
if smpl.RecoveryTarget != nil {
|
||||
recovery := units.FormatterFromUnit(r.Unit()).Format(*smpl.RecoveryTarget, smpl.TargetUnit)
|
||||
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRecoveryThreshold, Value: recovery})
|
||||
}
|
||||
}
|
||||
if smpl.IsRecovering {
|
||||
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationIsRecovering, Value: "true"})
|
||||
}
|
||||
if signal := r.typ.Signal(); signal != "" {
|
||||
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRuleType, Value: signal})
|
||||
}
|
||||
if r.useTemplates {
|
||||
title := r.name
|
||||
if smpl.IsMissing {
|
||||
title = "[No data] " + title
|
||||
}
|
||||
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationTitleTemplate, Value: title})
|
||||
if r.notificationBody != "" {
|
||||
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationBodyTemplate, Value: r.notificationBody})
|
||||
}
|
||||
}
|
||||
|
||||
return annotations
|
||||
}
|
||||
|
||||
func (r *BaseRule) ExternalURL(path string, params url.Values) string {
|
||||
u := r.externalURL.JoinPath(path)
|
||||
if len(params) > 0 {
|
||||
|
||||
@@ -885,3 +885,77 @@ func TestExpandAnnotations(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertAnnotations(t *testing.T) {
|
||||
recoveryTarget := 400.0
|
||||
testCases := []struct {
|
||||
name string
|
||||
alertType ruletypes.AlertType
|
||||
useTemplates bool
|
||||
notificationBody string
|
||||
smpl ruletypes.Sample
|
||||
want map[string]string
|
||||
}{
|
||||
{
|
||||
name: "Firing_AddsValueAndThreshold",
|
||||
smpl: ruletypes.Sample{},
|
||||
want: map[string]string{"summary": "high errors", ruletypes.AnnotationValue: "523 ms", ruletypes.AnnotationThresholdValue: "500 ms"},
|
||||
},
|
||||
{
|
||||
name: "Recovering_AddsValueThresholdAndRecoveringFlag",
|
||||
smpl: ruletypes.Sample{IsRecovering: true},
|
||||
want: map[string]string{"summary": "high errors", ruletypes.AnnotationValue: "523 ms", ruletypes.AnnotationThresholdValue: "500 ms", ruletypes.AnnotationIsRecovering: "true"},
|
||||
},
|
||||
{
|
||||
name: "OperatorAndRecoveryTarget_AddsLiteralOpAndRecoveryThreshold",
|
||||
smpl: ruletypes.Sample{CompareOperator: ruletypes.ValueIsAboveSymbol, RecoveryTarget: &recoveryTarget, TargetUnit: "ms"},
|
||||
want: map[string]string{"summary": "high errors", ruletypes.AnnotationValue: "523 ms", ruletypes.AnnotationThresholdValue: "500 ms", ruletypes.AnnotationCompareOp: "above", ruletypes.AnnotationRecoveryThreshold: "400 ms"},
|
||||
},
|
||||
{
|
||||
name: "MatchTypeAndAlertType_AddsLiteralMatchTypeAndSignal",
|
||||
alertType: ruletypes.AlertTypeLogs,
|
||||
smpl: ruletypes.Sample{MatchType: ruletypes.OnAverageShort},
|
||||
want: map[string]string{"summary": "high errors", ruletypes.AnnotationValue: "523 ms", ruletypes.AnnotationThresholdValue: "500 ms", ruletypes.AnnotationMatchType: "on_average", ruletypes.AnnotationRuleType: "logs"},
|
||||
},
|
||||
{
|
||||
name: "MissingData_OmitsValueAndThreshold_KeepsRuleType",
|
||||
alertType: ruletypes.AlertTypeMetric,
|
||||
smpl: ruletypes.Sample{IsMissing: true, CompareOperator: ruletypes.ValueIsAbove, MatchType: ruletypes.AtleastOnce, RecoveryTarget: &recoveryTarget},
|
||||
want: map[string]string{"summary": "high errors", ruletypes.AnnotationRuleType: "metrics"},
|
||||
},
|
||||
{
|
||||
name: "TemplatesOff_OmitsTitleAndBody",
|
||||
notificationBody: "$alert.value errors",
|
||||
smpl: ruletypes.Sample{},
|
||||
want: map[string]string{"summary": "high errors", ruletypes.AnnotationValue: "523 ms", ruletypes.AnnotationThresholdValue: "500 ms"},
|
||||
},
|
||||
{
|
||||
name: "TemplatesOn_AddsTitleAndBody",
|
||||
useTemplates: true,
|
||||
notificationBody: "$alert.value errors",
|
||||
smpl: ruletypes.Sample{},
|
||||
want: map[string]string{"summary": "high errors", ruletypes.AnnotationValue: "523 ms", ruletypes.AnnotationThresholdValue: "500 ms", ruletypes.AnnotationTitleTemplate: "High errors on $labels.service.name", ruletypes.AnnotationBodyTemplate: "$alert.value errors"},
|
||||
},
|
||||
{
|
||||
name: "TemplatesOnWithoutBody_AddsTitleOnly",
|
||||
useTemplates: true,
|
||||
smpl: ruletypes.Sample{},
|
||||
want: map[string]string{"summary": "high errors", ruletypes.AnnotationValue: "523 ms", ruletypes.AnnotationThresholdValue: "500 ms", ruletypes.AnnotationTitleTemplate: "High errors on $labels.service.name"},
|
||||
},
|
||||
{
|
||||
name: "TemplatesOnMissingData_PrefixesTitle",
|
||||
useTemplates: true,
|
||||
smpl: ruletypes.Sample{IsMissing: true},
|
||||
want: map[string]string{"summary": "high errors", ruletypes.AnnotationTitleTemplate: "[No data] High errors on $labels.service.name"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
rule := &BaseRule{name: "High errors on $labels.service.name", typ: testCase.alertType, useTemplates: testCase.useTemplates, notificationBody: testCase.notificationBody, annotations: ruletypes.FromMap(map[string]string{"summary": "high errors"}), ruleCondition: &ruletypes.RuleCondition{CompositeQuery: &ruletypes.AlertCompositeQuery{Unit: "ms"}}}
|
||||
expand := func(text string) string { return text }
|
||||
|
||||
assert.Equal(t, testCase.want, rule.AlertAnnotations(expand, testCase.smpl, "523 ms", "500 ms").Map())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,9 +177,10 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
}
|
||||
r.logger.DebugContext(ctx, "alerting for series", slog.Any("series", result))
|
||||
|
||||
value := valueFormatter.Format(result.V, r.Unit())
|
||||
threshold := valueFormatter.Format(result.Target, result.TargetUnit)
|
||||
|
||||
tmplData := ruletypes.AlertTemplateData(l, valueFormatter.Format(result.V, r.Unit()), threshold)
|
||||
tmplData := ruletypes.AlertTemplateData(l, value, threshold)
|
||||
// Inject some convenience variables that are easier to remember for users
|
||||
// who are not used to Go's templating system.
|
||||
defs := "{{$labels := .Labels}}{{$value := .Value}}{{$threshold := .Threshold}}"
|
||||
@@ -211,7 +212,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
|
||||
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
|
||||
|
||||
annotations := r.ExpandAnnotations(expand)
|
||||
annotations := r.AlertAnnotations(expand, result, value, threshold)
|
||||
if result.IsMissing {
|
||||
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
|
||||
lb.Set(ruletypes.NoDataLabel, "true")
|
||||
|
||||
@@ -944,6 +944,12 @@ func TestPromRuleUnitCombinations(t *testing.T) {
|
||||
if c.expectAlerts != 0 {
|
||||
foundCount := 0
|
||||
for _, item := range rule.Active {
|
||||
value := item.Annotations.Get(ruletypes.AnnotationValue)
|
||||
assert.NotEmpty(t, value, "case %d", idx)
|
||||
assert.Contains(t, item.Annotations.Get("summary"), value, "case %d", idx)
|
||||
threshold := item.Annotations.Get(ruletypes.AnnotationThresholdValue)
|
||||
assert.NotEmpty(t, threshold, "case %d", idx)
|
||||
assert.Contains(t, item.Annotations.Get("summary"), threshold, "case %d", idx)
|
||||
for _, summary := range c.summaryAny {
|
||||
if strings.Contains(item.Annotations.Get("summary"), summary) {
|
||||
foundCount++
|
||||
@@ -1048,6 +1054,8 @@ func TestPromRuleNoData(t *testing.T) {
|
||||
for _, item := range rule.Active {
|
||||
if c.expectNoData {
|
||||
assert.True(t, strings.Contains(item.Labels.Get(ruletypes.AlertNameLabel), "[No data]"), "case %d", idx)
|
||||
assert.Empty(t, item.Annotations.Get(ruletypes.AnnotationValue), "case %d", idx)
|
||||
assert.Empty(t, item.Annotations.Get(ruletypes.AnnotationThresholdValue), "case %d", idx)
|
||||
} else {
|
||||
assert.False(t, strings.Contains(item.Labels.Get(ruletypes.AlertNameLabel), "[No data]"), "case %d", idx)
|
||||
}
|
||||
|
||||
@@ -125,6 +125,17 @@ func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time
|
||||
return contextlinks.PrepareParamsForTracesV5(start, end, whereClause, r.typ.BuilderQueryType())
|
||||
}
|
||||
|
||||
func (r *ThresholdRule) prepareParamsForExceptions(ctx context.Context, ts time.Time, resultLabels ruletypes.Labels) url.Values {
|
||||
qr, err := r.prepareQueryRange(ctx, ts)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
start := time.UnixMilli(int64(qr.Start))
|
||||
end := time.UnixMilli(int64(qr.End))
|
||||
|
||||
return contextlinks.PrepareParamsForExceptions(start, end, resultLabels.Map())
|
||||
}
|
||||
|
||||
func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {
|
||||
params, err := r.prepareQueryRange(ctx, ts)
|
||||
if err != nil {
|
||||
@@ -285,7 +296,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
|
||||
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
|
||||
|
||||
annotations := r.ExpandAnnotations(expand)
|
||||
annotations := r.AlertAnnotations(expand, smpl, value, threshold)
|
||||
if smpl.IsMissing {
|
||||
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
|
||||
lb.Set(ruletypes.NoDataLabel, "true")
|
||||
@@ -313,6 +324,13 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
r.logger.InfoContext(ctx, "adding logs link to annotations", slog.String("annotation.link", link))
|
||||
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedLogs, Value: link})
|
||||
}
|
||||
case ruletypes.AlertTypeExceptions:
|
||||
params := r.prepareParamsForExceptions(ctx, ts, smpl.Metric)
|
||||
if len(params) > 0 {
|
||||
link := r.ExternalURL("exceptions", params)
|
||||
r.logger.InfoContext(ctx, "adding exceptions link to annotations", slog.String("annotation.link", link))
|
||||
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedExceptions, Value: link})
|
||||
}
|
||||
}
|
||||
|
||||
lbs := lb.Labels()
|
||||
|
||||
@@ -208,6 +208,60 @@ func TestPrepareParamsForLogs(t *testing.T) {
|
||||
assert.JSONEq(t, `{"queryType":"builder","builder":{"queryData":[{"dataSource":"logs","filter":{}}],"queryFormulas":[]}}`, compositeQuery)
|
||||
}
|
||||
|
||||
func TestPrepareParamsForExceptions(t *testing.T) {
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: valuer.NewDisplayName("Exceptions In Checkout"),
|
||||
AlertType: ruletypes.AlertTypeExceptions,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
EvalWindow: valuer.MustParseTextDuration("5m"),
|
||||
Frequency: valuer.MustParseTextDuration("1m"),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypeClickHouseSQL,
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeClickHouseSQL,
|
||||
Spec: qbtypes.ClickHouseQuery{
|
||||
Name: "A",
|
||||
Query: "SELECT count() AS value, toStartOfInterval(timestamp, toIntervalMinute(1)) AS interval, serviceName FROM signoz_traces.distributed_signoz_error_index_v2 WHERE timestamp BETWEEN {{.start_datetime}} AND {{.end_datetime}} GROUP BY serviceName, interval",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
Target: &[]float64{10.0}[0],
|
||||
SelectedQuery: "A",
|
||||
},
|
||||
}
|
||||
|
||||
logger := instrumentationtest.New().Logger()
|
||||
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
TargetValue: postableRule.RuleCondition.Target,
|
||||
MatchType: postableRule.RuleCondition.MatchType,
|
||||
CompareOperator: postableRule.RuleCondition.CompareOperator,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
externalURL := mustParseURL(t, "http://localhost:8080")
|
||||
rule, err := NewThresholdRule("70", valuer.GenerateUUID(), &postableRule, nil, logger, externalURL, WithEvalDelay(valuer.MustParseTextDuration("2m")))
|
||||
require.NoError(t, err)
|
||||
|
||||
ts := time.UnixMilli(1705469040000)
|
||||
|
||||
params := rule.prepareParamsForExceptions(context.Background(), ts, ruletypes.FromMap(map[string]string{"serviceName": "checkout"}))
|
||||
assert.Equal(t, "1705468620000", params.Get("startTime"))
|
||||
assert.Equal(t, "1705468920000", params.Get("endTime"))
|
||||
assert.Equal(t, "checkout", params.Get("serviceName"))
|
||||
assert.False(t, params.Has("exceptionType"))
|
||||
}
|
||||
|
||||
func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: valuer.NewDisplayName("Tricky Condition Tests"),
|
||||
@@ -690,6 +744,14 @@ func TestThresholdRuleUnitCombinations(t *testing.T) {
|
||||
if c.expectAlerts != 0 {
|
||||
foundCount := 0
|
||||
for _, item := range rule.Active {
|
||||
value := item.Annotations.Get(ruletypes.AnnotationValue)
|
||||
assert.NotEmpty(t, value, "case %d", idx)
|
||||
assert.Contains(t, item.Annotations.Get("summary"), value, "case %d", idx)
|
||||
threshold := item.Annotations.Get(ruletypes.AnnotationThresholdValue)
|
||||
assert.NotEmpty(t, threshold, "case %d", idx)
|
||||
assert.Contains(t, item.Annotations.Get("summary"), threshold, "case %d", idx)
|
||||
assert.Equal(t, c.compareOperator.Literal(), item.Annotations.Get(ruletypes.AnnotationCompareOp), "case %d", idx)
|
||||
assert.Equal(t, c.matchType.Literal(), item.Annotations.Get(ruletypes.AnnotationMatchType), "case %d", idx)
|
||||
for _, summary := range c.summaryAny {
|
||||
if strings.Contains(item.Annotations.Get("summary"), summary) {
|
||||
foundCount++
|
||||
@@ -799,6 +861,8 @@ func TestThresholdRuleNoData(t *testing.T) {
|
||||
for _, item := range rule.Active {
|
||||
if c.expectNoData {
|
||||
assert.True(t, strings.Contains(item.Labels.Get(ruletypes.AlertNameLabel), "[No data]"), "case %d", idx)
|
||||
assert.Empty(t, item.Annotations.Get(ruletypes.AnnotationValue), "case %d", idx)
|
||||
assert.Empty(t, item.Annotations.Get(ruletypes.AnnotationThresholdValue), "case %d", idx)
|
||||
} else {
|
||||
assert.False(t, strings.Contains(item.Labels.Get(ruletypes.AlertNameLabel), "[No data]"), "case %d", idx)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package alertmanagertypes
|
||||
import (
|
||||
"bytes"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
tmplhtml "html/template"
|
||||
tmpltext "text/template"
|
||||
@@ -19,6 +20,7 @@ func AdditionalFuncMap() tmpltext.FuncMap {
|
||||
"urlescape": func(value string) tmplhtml.HTML {
|
||||
return tmplhtml.HTML(url.QueryEscape(value))
|
||||
},
|
||||
"now": time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,8 +76,14 @@ type AlertData struct {
|
||||
Rule RuleInfo `json:"rule" mapstructure:"rule"`
|
||||
Log LinkInfo `json:"log" mapstructure:"log"`
|
||||
Trace LinkInfo `json:"trace" mapstructure:"trace"`
|
||||
Exception LinkInfo `json:"exception" mapstructure:"exception"`
|
||||
Labels template.KV `json:"labels" mapstructure:"labels"`
|
||||
Annotations template.KV `json:"annotations" mapstructure:"annotations"`
|
||||
|
||||
// Value and Threshold back the $value and $threshold shortcuts for
|
||||
// $alert.value and $rule.threshold.value.
|
||||
Value string `json:"value" mapstructure:"value"`
|
||||
Threshold string `json:"threshold" mapstructure:"threshold"`
|
||||
}
|
||||
|
||||
// AlertInfo holds the per-alert state and timing data.
|
||||
@@ -93,6 +99,7 @@ type AlertInfo struct {
|
||||
IsResolved bool `json:"isResolved" mapstructure:"is_resolved"`
|
||||
IsMissingData bool `json:"isMissingData" mapstructure:"is_missing_data"`
|
||||
IsRecovering bool `json:"isRecovering" mapstructure:"is_recovering"`
|
||||
IsTest bool `json:"isTest" mapstructure:"is_test"`
|
||||
}
|
||||
|
||||
// RuleInfo holds the rule metadata extracted from well-known labels and
|
||||
@@ -102,18 +109,20 @@ type RuleInfo struct {
|
||||
ID string `json:"id" mapstructure:"id"`
|
||||
URL string `json:"url" mapstructure:"url"`
|
||||
Severity string `json:"severity" mapstructure:"severity"`
|
||||
Type string `json:"type" mapstructure:"type"`
|
||||
MatchType string `json:"matchType" mapstructure:"match_type"`
|
||||
Threshold Threshold `json:"threshold" mapstructure:"threshold"`
|
||||
}
|
||||
|
||||
// Threshold describes the breach condition.
|
||||
type Threshold struct {
|
||||
Value string `json:"value" mapstructure:"value"`
|
||||
Op string `json:"op" mapstructure:"op"`
|
||||
Value string `json:"value" mapstructure:"value"`
|
||||
Op string `json:"op" mapstructure:"op"`
|
||||
RecoveryValue string `json:"recoveryValue" mapstructure:"recovery_value"`
|
||||
}
|
||||
|
||||
// LinkInfo groups a single URL so templates can reference $log.url and
|
||||
// $trace.url uniformly.
|
||||
// LinkInfo groups a single URL so templates can reference $log.url,
|
||||
// $trace.url and $exception.url uniformly.
|
||||
type LinkInfo struct {
|
||||
URL string `json:"url" mapstructure:"url"`
|
||||
}
|
||||
@@ -137,8 +146,10 @@ type NotificationTemplateData struct {
|
||||
// NotificationAlert holds the aggregate alert fields available to title
|
||||
// templates (counts, overall status, receiver).
|
||||
type NotificationAlert struct {
|
||||
Receiver string `json:"receiver" mapstructure:"receiver"`
|
||||
Status string `json:"status" mapstructure:"status"`
|
||||
TotalFiring int `json:"totalFiring" mapstructure:"total_firing"`
|
||||
TotalResolved int `json:"totalResolved" mapstructure:"total_resolved"`
|
||||
Receiver string `json:"receiver" mapstructure:"receiver"`
|
||||
Status string `json:"status" mapstructure:"status"`
|
||||
TotalFiring int `json:"totalFiring" mapstructure:"total_firing"`
|
||||
TotalRecovering int `json:"totalRecovering" mapstructure:"total_recovering"`
|
||||
TotalResolved int `json:"totalResolved" mapstructure:"total_resolved"`
|
||||
IsTest bool `json:"isTest" mapstructure:"is_test"`
|
||||
}
|
||||
|
||||
@@ -39,6 +39,25 @@ func (AlertType) Enum() []any {
|
||||
}
|
||||
}
|
||||
|
||||
// Signal returns the signal the alert type evaluates (metrics, logs, traces,
|
||||
// exceptions or ai_traces), or an empty string for an unknown type.
|
||||
func (t AlertType) Signal() string {
|
||||
switch t {
|
||||
case AlertTypeMetric:
|
||||
return "metrics"
|
||||
case AlertTypeLogs:
|
||||
return "logs"
|
||||
case AlertTypeTraces:
|
||||
return "traces"
|
||||
case AlertTypeExceptions:
|
||||
return "exceptions"
|
||||
case AlertTypeAITraces:
|
||||
return "ai_traces"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// BuilderQueryType returns the query type the alert type's builder queries
|
||||
// carry; only AI trace alerts use builder_ai_query.
|
||||
func (t AlertType) BuilderQueryType() qbtypes.QueryType {
|
||||
@@ -91,6 +110,9 @@ type NotificationSettings struct {
|
||||
Renotify *Renotify `json:"renotify,omitempty" description:"Whether and how often to re-send a notification for an alert that stays active."`
|
||||
UsePolicy bool `json:"usePolicy" description:"When true, alerts are routed by the organisation level notification policies instead of the channels named on the thresholds. Defaults to false."`
|
||||
NewGroupEvalDelay valuer.TextDuration `json:"newGroupEvalDelay,omitzero" description:"A grace period, as a Go duration such as 2m, during which a newly seen label group is excluded from evaluation. Applies to metric rules only; log and trace rules are never delayed. A group whose values are not yet known is evaluated immediately."`
|
||||
UseTemplates bool `json:"useTemplates,omitempty" description:"When true, the alert name is the notification title and notificationBody the body, both rendered as templates with variables such as $labels.service.name and $alert.value. When false, channels use their own title and body. Defaults to false."`
|
||||
NotificationBody string `json:"notificationBody,omitempty" description:"The notification body template, read when useTemplates is true. Channels fall back to their own body when it is empty."`
|
||||
IncludeSamples bool `json:"includeSamples,omitempty" description:"Whether notifications include sample data from the alert window. Not read yet. Defaults to false."`
|
||||
}
|
||||
|
||||
type Renotify struct {
|
||||
|
||||
@@ -1352,3 +1352,24 @@ func TestVersionDefaultsToV5(t *testing.T) {
|
||||
assert.Equal(t, "v5", rule.Version)
|
||||
assert.NoError(t, rule.Validate())
|
||||
}
|
||||
|
||||
func TestAlertTypeSignal(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
alertType AlertType
|
||||
want string
|
||||
}{
|
||||
{name: "Metric", alertType: AlertTypeMetric, want: "metrics"},
|
||||
{name: "Logs", alertType: AlertTypeLogs, want: "logs"},
|
||||
{name: "Traces", alertType: AlertTypeTraces, want: "traces"},
|
||||
{name: "Exceptions", alertType: AlertTypeExceptions, want: "exceptions"},
|
||||
{name: "AITraces", alertType: AlertTypeAITraces, want: "ai_traces"},
|
||||
{name: "Unknown_Empty", alertType: "", want: ""},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.want, testCase.alertType.Signal())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,29 +13,34 @@ const (
|
||||
LabelNoData = "nodata"
|
||||
LabelTestAlert = "testalert"
|
||||
LabelAlertName = "alertname"
|
||||
LabelIsRecovering = "is_recovering"
|
||||
)
|
||||
|
||||
// Annotations set by the rule manager and consumed by the alertmanager
|
||||
// templating layer.
|
||||
//
|
||||
// Those prefixed with "_" are private: they're stripped from
|
||||
// notification-visible surfaces by alertmanagertypes.FilterPublicAnnotations
|
||||
// before rendering. Only the raw template strings are private — echoing
|
||||
// them into a notification is circular and never useful.
|
||||
// Those prefixed with "_" are private: the alertmanager reads them into
|
||||
// structured template fields, then strips them from notification-visible
|
||||
// surfaces by alertmanagertypes.FilterPublicAnnotations before rendering.
|
||||
// The raw template strings, the breached value ($alert.value), the configured
|
||||
// threshold ($rule.threshold.value), its comparator ($rule.threshold.op), the
|
||||
// recovery threshold ($rule.threshold.recovery_value), the match type
|
||||
// ($rule.match_type), the rule's signal ($rule.type), the exceptions page link
|
||||
// ($exception.url) and the recovering flag ($alert.is_recovering) are private.
|
||||
//
|
||||
// The rest are public: they describe the firing alert (the breached value,
|
||||
// the configured threshold, the comparator, the match type, and deep links
|
||||
// to relevant logs/traces) and users may reference them directly as
|
||||
// {{ .Annotations.value }}, {{ .Annotations.threshold.value }}, etc. in
|
||||
// The rest are public: they are deep links to relevant logs/traces and users
|
||||
// may reference them directly as {{ .Annotations.related_logs }}, etc. in
|
||||
// their channel templates.
|
||||
const (
|
||||
AnnotationTitleTemplate = "_title_template"
|
||||
AnnotationBodyTemplate = "_body_template"
|
||||
AnnotationRelatedLogs = "related_logs"
|
||||
AnnotationRelatedTraces = "related_traces"
|
||||
AnnotationValue = "value"
|
||||
AnnotationThresholdValue = "threshold.value"
|
||||
AnnotationCompareOp = "compare_op"
|
||||
AnnotationMatchType = "match_type"
|
||||
AnnotationTitleTemplate = "_title_template"
|
||||
AnnotationBodyTemplate = "_body_template"
|
||||
AnnotationRelatedLogs = "related_logs"
|
||||
AnnotationRelatedTraces = "related_traces"
|
||||
AnnotationRelatedExceptions = "_related_exceptions"
|
||||
AnnotationValue = "_value"
|
||||
AnnotationIsRecovering = "_is_recovering"
|
||||
AnnotationThresholdValue = "_threshold"
|
||||
AnnotationRecoveryThreshold = "_recovery_threshold"
|
||||
AnnotationCompareOp = "_compare_op"
|
||||
AnnotationMatchType = "_match_type"
|
||||
AnnotationRuleType = "_rule_type"
|
||||
)
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
<p style="margin:0;font-size:14px;color:#666">
|
||||
Status: <strong>{{.Alert.Status}}</strong>
|
||||
{{if .Alert.TotalFiring}} | Firing: <strong style="color:#e53e3e">{{.Alert.TotalFiring}}</strong>{{end}}
|
||||
{{if .Alert.TotalRecovering}} | Recovering: <strong style="color:#dd6b20">{{.Alert.TotalRecovering}}</strong>{{end}}
|
||||
{{if .Alert.TotalResolved}} | Resolved: <strong style="color:#38a169">{{.Alert.TotalResolved}}</strong>{{end}}
|
||||
</p>
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user