Compare commits

...

14 Commits

Author SHA1 Message Date
Praneeth Lingam
58e2f4f316 chore(openapi): regenerate specs for notification templates
Assisted-by: Claude Opus 5.5
2026-11-13 17:54:57 +05:30
Praneeth Lingam
83a9df7ddf feat(ruler): write notification title and body templates
Gated by notificationSettings.useTemplates so existing rules keep
their channel titles and bodies. includeSamples is accepted but not
read yet.

Assisted-by: Claude Opus 5.5
2026-11-13 17:50:23 +05:30
Praneeth Lingam
963bbefc85 fix(alertmanager): make time functions work in custom templates
mapstructure turned time.Time into empty maps, so date, tz and since failed on $alert.starts_at.
buildDataMap now uses toTemplateMap, a tag-driven converter that keeps times; output is unchanged.
Adds now (time.Now) to the shared function map, so channel templates get it too.
Adds engine tests for every function in the Functions API list.
A bare $alert.starts_at now prints the time, and {{ if $alert.ends_at }} is always true.

Assisted-by: Claude Opus 5.5
2026-11-12 14:56:24 +05:30
Praneeth Lingam
10066cb8d6 feat(alertmanager): add $exception.url for exceptions alerts
The link filters the exceptions page by serviceName and exceptionType from the alert labels.
compositeQuery is not used: the page ignores filter.expression and SQL rules have no filter.

Assisted-by: Claude Opus 5.5
2026-11-12 12:11:57 +05:30
Praneeth Lingam
fe86b433be feat(alertmanager): add $alert.is_test for test notifications
Read from the testalert label the rule manager sets on test sends, so no ruler change.
The title's is_test is true when any alert in the group is a test.

Assisted-by: Claude Opus 5.5
2026-11-11 19:20:45 +05:30
Praneeth Lingam
1725589261 feat(alertmanager): fill rule type and match type in templates
match_type becomes private _match_type; nothing wrote it, so no template could depend on it.
The rule type is written even for no-data alerts, since it describes the rule, not the value.

Assisted-by: Claude Opus 5.5
2026-11-11 19:02:25 +05:30
Praneeth Lingam
3c0375c1cb feat(alertmanager): fill threshold op and recovery value in templates
The ruler writes the operator as a word (above, not >) and the recovery target in its unit.
compare_op becomes private _compare_op; nothing wrote it, so no template could depend on it.

Assisted-by: Claude Opus 5.5
2026-11-11 18:44:45 +05:30
Praneeth Lingam
b1a7136a24 feat(alertmanager): fill threshold and add $threshold shortcut
The ruler writes the formatted threshold as the private _threshold annotation.
Bare $threshold now shadows a label named threshold; $labels.threshold still works.

Assisted-by: Claude Opus 5.5
2026-11-11 17:00:37 +05:30
Praneeth Lingam
bd21c2f5c1 feat(alertmanager): add $value shortcut for alert value
Backed by a root field so it resolves even though the value annotation
is private, and wins over a label named value.

Assisted-by: Claude Opus 5.5
2026-11-11 14:19:02 +05:30
Praneeth Lingam
38328ab43a feat(alertmanager): fill alert value and recovering state in templates
The rule manager sends the value and recovering flag as private
annotations; as labels they would change the alert fingerprint.
$alert.status gains recovering, the is_* flags are mutually exclusive,
and total_recovering keeps the counts complete.

Assisted-by: Claude Opus 5.5
2026-11-10 21:31:35 +05:30
Ashwin Bhatkal
f8421e81e9 fix(planned-downtime): read alert rules from the v3 list API (#13440)
#### Description

- The planned downtime rule picker now reads `/api/v3/rules` through a
new `useListAllRules` hook instead of the deprecated `GET
/api/v2/rules`.
- The picker lists every rule, and v3 returns at most 200 per call, so
the hook fetches the remaining pages in parallel, like
`useChannelOptions` does for channels. Orgs with 200 rules or fewer make
one request.
- The planned downtime story now mocks `/api/v3/rules`.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/713
2026-11-09 12:45:49 +00:00
Ashwin Bhatkal
e938a10ad5 fix(home): read alert rules from the v3 list API (#13439)
#### Description

- `GET /api/v2/rules` is deprecated in favor of the paginated
`/api/v3/rules`, and #13158 removes it. This moves the home page alerts
widget to v3 ahead of that.
- The widget still shows firing rules first, then the most recently
updated others, up to 5. It asks v3 for the firing rules first and
fetches the latest rules only when fewer than 5 are firing, instead of
loading every rule.
- Clicking a rule now opens the overview with only `ruleId`, like the
alert list, triggered alerts and Metrics Explorer. The overview loads
the rule and builds its query from it, so the `compositeQuery` the old
link carried is not needed.
- The home page story now mocks `/api/v3/rules`.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/712
2026-11-09 11:52:40 +00:00
Ashwin Bhatkal
93cc1957d4 chore: make pulse-frontend the code owner of the list filter, views and alert rules folders (#13476)
#### Description

- `@SigNoz/pulse-frontend` now owns `components/ListFilter`,
`components/ListViews` and `hooks/listViews`. Only the dashboards list
and the alert rules list use them.
- It also owns `container/PlannedDowntime`, `hooks/alerts` and the home
page alert rules widget (`container/Home/AlertRules`).
- Until now these fell to the `/frontend/` catch-all, so changes to them
also needed `@SigNoz/frontend-maintainers`.
2026-11-09 11:26:04 +00:00
Swapnil Nakade
313ec2d657 chore: bump go version to 1.25 in github ci (#13480)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- bump go version in ci to 1.25

---------

Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
2026-11-09 10:08:41 +00:00
41 changed files with 1143 additions and 182 deletions

9
.github/CODEOWNERS vendored
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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")

View File

@@ -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

View File

@@ -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;
}
/**

View File

@@ -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} />

View 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,
};
}

View 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',
]);
});
});

View File

@@ -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 {

View File

@@ -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,

View 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']);
});
});

View 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,
});
}

View File

@@ -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}`,

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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))

View File

@@ -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",
},
},
}

View File

@@ -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],
},
}
}

View File

@@ -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])
})
}

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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

View File

@@ -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"))
})
}
}

View File

@@ -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 {

View File

@@ -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())
})
}
}

View File

@@ -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")

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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,
}
}

View File

@@ -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"`
}

View File

@@ -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 {

View File

@@ -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())
})
}
}

View File

@@ -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"
)

View File

@@ -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>