Compare commits

..

65 Commits

Author SHA1 Message Date
Naman Verma
982c175819 Merge branch 'main' into nv/alerts-migration 2026-11-09 12:19:41 +05:30
Naman Verma
73226370ae fix: remove omitempty from alertonabsent 2026-11-06 16:37:54 +05:30
Naman Verma
10950a6cc3 Merge branch 'main' into nv/alerts-migration 2026-11-06 16:27:08 +05:30
Naman Verma
65020e11cd fix: migrate v1 rules w nil rule condition as disabled 2026-11-05 12:11:38 +05:30
Naman Verma
bc09e22bda fix: backfill selected query name in migrated rules if absent 2026-11-05 11:07:24 +05:30
Naman Verma
c0f1ce32cc Merge branch 'nv/alerts-migration' into nv/rules-v2-validations 2026-11-04 21:21:30 +05:30
Naman Verma
c21f9f679d test: update file number 2026-11-04 21:21:22 +05:30
Naman Verma
08f37d3b26 Merge branch 'main' into nv/alerts-migration 2026-11-04 21:20:35 +05:30
Naman Verma
15fe270867 Merge branch 'nv/alerts-migration' into nv/rules-v2-validations 2026-11-03 18:25:00 +05:30
Naman Verma
602d959c6d Merge branch 'main' into nv/alerts-migration 2026-11-03 18:24:38 +05:30
Naman Verma
5f46464c44 Merge branch 'nv/alerts-migration' into nv/rules-v2-validations 2026-11-02 11:31:56 +05:30
Naman Verma
f5e9786f91 Merge branch 'main' into nv/alerts-migration 2026-11-02 11:25:18 +05:30
Naman Verma
1b2a5e54f2 fix: make selected query mandatory 2026-10-29 13:00:32 +05:30
Naman Verma
f1624b5d2f test: integration tests 2026-10-28 01:22:12 +05:30
Naman Verma
0e25da12c3 fix: remove -v7 from err msgs 2026-10-28 01:22:00 +05:30
Naman Verma
4ad572a8c2 fix: proper err msg if internal name of channel is referred 2026-10-28 01:21:25 +05:30
Naman Verma
d3d53c8b98 chore: reduce diff 2026-10-27 23:13:44 +05:30
Naman Verma
b380686577 chore: inline rule type extraction 2026-10-27 23:05:45 +05:30
Naman Verma
1d0328fae5 chore: no rejection of empty string 2026-10-27 22:54:56 +05:30
Naman Verma
01b2b49521 chore: keep schemaversion as string type 2026-10-27 22:42:51 +05:30
Naman Verma
52f4657f57 chore: move rule unmarshall to types 2026-10-27 22:18:12 +05:30
Naman Verma
ba475bdb48 Merge branch 'nv/alerts-migration' into nv/rules-v2-validations 2026-10-27 22:07:53 +05:30
Naman Verma
94113eeed5 fix: make version field noop 2026-10-27 19:20:53 +05:30
Naman Verma
680f7de69e fix: empty query rules should migrate as disabled 2026-10-27 18:23:50 +05:30
Naman Verma
2237dbde9a fix: migrate rules with nil query as disabled 2026-10-27 18:06:38 +05:30
Naman Verma
aac321de58 chore: -ve eval window should migrate as disabled 2026-10-27 17:55:27 +05:30
Naman Verma
7a7c35eca0 chore: method reordering 2026-10-27 17:33:54 +05:30
Naman Verma
41d81cfcbc chore: method reordering 2026-10-27 17:29:43 +05:30
Naman Verma
00afd873e3 chore: split err checks 2026-10-27 17:28:51 +05:30
Naman Verma
428acb6f23 chore: clearer method name 2026-10-27 17:27:16 +05:30
Naman Verma
736ad2aba3 chore: no inline conversions in convertRule 2026-10-27 17:19:13 +05:30
Naman Verma
99f21adadd chore: simplify route validation 2026-10-27 17:11:32 +05:30
Naman Verma
bdfadbbe87 chore: remove unneeded check for schema version 2026-10-27 17:03:18 +05:30
Naman Verma
e23f96c7a1 chore: var renames 2026-10-27 16:59:14 +05:30
Naman Verma
3e1d76f3c6 chore: move type inline to its only usage 2026-10-27 16:55:08 +05:30
Naman Verma
6b06bacb91 Merge branch 'nv/alerts-migration' into nv/rules-v2-validations 2026-10-27 16:28:53 +05:30
Naman Verma
5b91346a27 Merge branch 'main' into nv/alerts-migration 2026-10-27 16:26:05 +05:30
Naman Verma
a66d6000e5 feat(ruler): reject unknown keys, numeric durations and inverted recovery targets on write 2026-10-27 16:15:25 +05:30
Naman Verma
f42084ad31 Merge branch 'nv/alerts-migration' into nv/rules-v2-validations 2026-10-27 15:25:51 +05:30
Naman Verma
7525667f28 fix: check for nil evaluation envelope 2026-10-27 15:25:39 +05:30
Naman Verma
bdad1235aa chore: reduce v2 schema migration scope 2026-10-27 15:24:56 +05:30
Naman Verma
a7e0ac1f4d chore: increase migration number 2026-10-27 12:25:30 +05:30
Naman Verma
3a6ae85dee Merge branch 'nv/alerts-migration' into nv/rules-v2-validations 2026-10-27 12:25:02 +05:30
Naman Verma
1e4c96907d Merge branch 'main' into nv/alerts-migration 2026-10-27 12:24:29 +05:30
Naman Verma
b4191b9e10 fix: repair v2 rules in another migration 2026-10-26 18:27:41 +05:30
Naman Verma
7e043b1221 Merge branch 'nv/alerts-migration' into nv/rules-v2-validations 2026-10-26 16:58:06 +05:30
Naman Verma
55e76a182d Merge branch 'main' into nv/alerts-migration 2026-10-26 16:57:45 +05:30
Naman Verma
9135807532 chore: reduce diff 2026-10-26 11:22:55 +05:30
Naman Verma
a0d671e0e8 Merge branch 'nv/alerts-migration' into nv/rules-v2-validations 2026-10-26 11:02:46 +05:30
Naman Verma
a5f5dc4fb6 Merge branch 'main' into nv/alerts-migration 2026-10-26 11:02:32 +05:30
Naman Verma
b18258e76f Merge branch 'nv/alerts-migration' into nv/rules-v2-validations 2026-10-26 05:46:28 +05:30
Naman Verma
872b4172d3 fix: migrate broken v1 rules as disabled rules 2026-10-26 05:44:15 +05:30
Naman Verma
08f4c099f6 fix: add more validations on v2 schema rules 2026-10-26 04:17:37 +05:30
Naman Verma
87b1eed20a chore: remove extra v2 validations 2026-10-26 04:14:03 +05:30
Naman Verma
f6cfcdfc78 chore: more rule validations 2026-10-26 03:52:16 +05:30
Naman Verma
37cf5ae407 fix: fix all non-query related post-migration errors 2026-10-25 23:59:21 +05:30
Naman Verma
9a8f66be63 Merge branch 'main' into nv/alerts-migration 2026-10-25 22:06:04 +05:30
Naman Verma
3a5078e29b Merge branch 'main' into nv/alerts-migration 2026-10-22 02:05:57 +05:30
Naman Verma
7ebb1910b9 fix: remove nilnil return 2026-10-21 13:50:18 +05:30
Naman Verma
1e1d6f8d24 Merge branch 'main' into nv/alerts-migration 2026-10-21 13:39:57 +05:30
Naman Verma
6f6eea7187 fix: 400 on malformed JSON, missing alertType, unknown enums 2026-10-19 12:00:16 +05:30
Naman Verma
0bec6c5494 Merge branch 'main' into nv/alerts-migration 2026-10-18 09:41:38 +05:30
Naman Verma
d6744c1d8e chore: remove v1 rule apis 2026-10-16 15:20:38 +05:30
Naman Verma
6476be26bb Merge branch 'main' into nv/alerts-migration
# Conflicts:
#	pkg/query-service/app/http_handler.go
2026-10-16 14:56:00 +05:30
Naman Verma
06c54c6483 feat: migrate all rules to v2 schema 2026-10-16 11:17:10 +05:30
61 changed files with 1984 additions and 3075 deletions

9
.github/CODEOWNERS vendored
View File

@@ -209,10 +209,6 @@ 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
@@ -221,11 +217,6 @@ 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.25
GO_VERSION: 1.24
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.25
GO_VERSION: 1.24
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.25
GO_VERSION: 1.24
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.25
GO_VERSION: 1.24
fmt:
if: |
github.event_name == 'merge_group' ||
@@ -32,7 +32,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.25
GO_VERSION: 1.24
lint:
if: |
github.event_name == 'merge_group' ||
@@ -42,7 +42,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.25
GO_VERSION: 1.24
deps:
if: |
github.event_name == 'merge_group' ||
@@ -52,7 +52,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.25
GO_VERSION: 1.24
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.25"
go-version: "1.24"
- 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.25"
go-version: "1.24"
- 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.25"
go-version: "1.24"
- 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.25"
go-version: "1.24"
- 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.25"
go-version: "1.24"
- 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.25"
go-version: "1.24"
- 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.25"
go-version: "1.24"
- 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.25"
go-version: "1.24"
# 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.25"
go-version: "1.24"
- 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.25"
go-version: "1.24"
# copy the caches from build
- name: get-sha

View File

@@ -11743,20 +11743,10 @@ components:
description: Whether the rule is paused. A disabled rule is neither evaluated
nor notifies. Defaults to false.
type: boolean
evalWindow:
description: The lookback window as a Go duration such as 5m. Only read
when schemaVersion is v1, defaulting to 5m; v2alpha1 rules set it in evaluation.spec
instead.
type: string
evaluation:
allOf:
- $ref: '#/components/schemas/RuletypesEvaluationEnvelope'
description: The evaluation window, as a kind and spec.
frequency:
description: How often the rule is evaluated, as a Go duration such as 1m.
Only read when schemaVersion is v1, defaulting to 1m; v2alpha1 rules set
it in evaluation.spec instead.
type: string
labels:
additionalProperties:
type: string
@@ -11768,13 +11758,6 @@ components:
allOf:
- $ref: '#/components/schemas/RuletypesNotificationSettings'
description: Grouping, re-notification and routing behaviour.
preferredChannels:
description: The displayName of each notification channel to notify, for
schemaVersion v1 rules. Ignored for v2alpha1, where each threshold carries
its own channels.
items:
type: string
type: array
ruleType:
allOf:
- $ref: '#/components/schemas/RuletypesRuleType'
@@ -11884,20 +11867,10 @@ components:
description: Whether the rule is paused. A disabled rule is neither evaluated
nor notifies. Defaults to false.
type: boolean
evalWindow:
description: The lookback window as a Go duration such as 5m. Only read
when schemaVersion is v1, defaulting to 5m; v2alpha1 rules set it in evaluation.spec
instead.
type: string
evaluation:
allOf:
- $ref: '#/components/schemas/RuletypesEvaluationEnvelope'
description: The evaluation window, as a kind and spec.
frequency:
description: How often the rule is evaluated, as a Go duration such as 1m.
Only read when schemaVersion is v1, defaulting to 1m; v2alpha1 rules set
it in evaluation.spec instead.
type: string
id:
description: The unique identifier of the rule.
type: string
@@ -11912,13 +11885,6 @@ components:
allOf:
- $ref: '#/components/schemas/RuletypesNotificationSettings'
description: Grouping, re-notification and routing behaviour.
preferredChannels:
description: The displayName of each notification channel to notify, for
schemaVersion v1 rules. Ignored for v2alpha1, where each threshold carries
its own channels.
items:
type: string
type: array
ruleType:
allOf:
- $ref: '#/components/schemas/RuletypesRuleType'
@@ -11972,28 +11938,10 @@ components:
description: Whether to fire a nodata alert when the query returns no data.
Defaults to false.
type: boolean
algorithm:
description: 'anomaly_rule only: the anomaly detection algorithm. The supported
value is standard.'
type: string
compositeQuery:
allOf:
- $ref: '#/components/schemas/RuletypesAlertCompositeQuery'
description: The queries whose result is compared against the thresholds.
matchType:
allOf:
- $ref: '#/components/schemas/RuletypesMatchType'
description: 'schemaVersion v1 only: which points in the window must satisfy
op. Accepted as at_least_once, all_the_times, on_average, in_total or
last, their aliases avg and sum, or the legacy codes as strings, ''1''
to ''5''. Required for v1; v2alpha1 sets matchType on each threshold.'
op:
allOf:
- $ref: '#/components/schemas/RuletypesCompareOperator'
description: 'schemaVersion v1 only: how the value is compared with target.
Accepted as above, below, equal, not_equal, above_or_equal, below_or_equal
or outside_bounds, their symbols, or the legacy codes as strings, ''1''
to ''7''. Required for v1; v2alpha1 sets op on each threshold.'
requireMinPoints:
description: Whether a series is skipped until it has at least requiredNumPoints
points in the window. Defaults to false.
@@ -12011,17 +11959,6 @@ components:
description: The name of the query whose result is evaluated, such as A
or F1. Must match a query in compositeQuery.
type: string
target:
description: 'schemaVersion v1 only: the value compared against, in targetUnit.
Required for v1; v2alpha1 sets target on each threshold.'
format: double
nullable: true
type: number
targetUnit:
description: 'schemaVersion v1 only: the unit of target, such as percent
or ms. Converted to compositeQuery.unit before comparison when the two
differ.'
type: string
thresholds:
allOf:
- $ref: '#/components/schemas/RuletypesRuleThresholdData'
@@ -25528,56 +25465,6 @@ paths:
- rules
x-signoz-stability: alpha
/api/v2/rules:
get:
deprecated: true
description: 'This endpoint lists all alert rules with their current evaluation
state. Deprecated: use ListRulesV3, which supports filtering, sorting and
pagination.'
operationId: ListRules
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/RuletypesRule'
type: array
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List alert rules
tags:
- rules
x-signoz-stability: alpha
post:
deprecated: false
description: This endpoint creates a new alert rule
@@ -32729,305 +32616,6 @@ paths:
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_anomaly_request_rate:
description: 'anomaly is the one function with named arguments. seasonality
says which past periods predict the current value: hourly, daily
or weekly, daily when absent. z_score_threshold is how many standard
deviations from the prediction count as an anomaly. It has no default:
a request without it gets bounds of zero width, so send it. 3 is
the value the UI sends. The response carries, next to the values,
a predicted series, an upper and a lower bound series and an anomaly
score series. The scoring runs in the enterprise build; the community
build returns the series unchanged.'
summary: 'anomaly: request rate scored against its daily pattern'
value:
compositeQuery:
queries:
- spec:
aggregations:
- metricName: http.server.duration.count
spaceAggregation: sum
timeAggregation: rate
functions:
- args:
- name: seasonality
value: daily
- name: z_score_threshold
value: 3
name: anomaly
groupBy:
- fieldContext: resource
name: service.name
name: A
signal: metrics
stepInterval: 300s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_cumulativesum_errors_so_far:
description: cumulativeSum replaces each point with the sum of every
point up to and including it, so a per-step count becomes a running
total whose first point is the first count. It takes no arguments.
The total resets with the window, so a longer time range gives a
larger final value; it is a view of the window, not a counter.
summary: 'cumulativeSum: 5xx responses since the start of the window'
value:
compositeQuery:
queries:
- spec:
aggregations:
- alias: server_errors
expression: count()
filter:
expression: http.response.status_code >= 500
functions:
- name: cumulativeSum
groupBy:
- fieldContext: resource
name: service.name
name: A
signal: traces
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_cutoffmin_hide_idle_hosts:
description: The four threshold functions take one number. cutOffMin
and cutOffMax remove the points below or above the threshold, so
an idle host disappears from the chart instead of drawing a line
along the bottom. clampMin and clampMax keep the points and replace
the value with the threshold, which caps spikes without hiding them.
system.cpu.utilization is a ratio, so 0.05 is 5%.
summary: 'cutOffMin: drop hosts below 5% CPU from the chart'
value:
compositeQuery:
queries:
- spec:
aggregations:
- metricName: system.cpu.utilization
spaceAggregation: avg
timeAggregation: avg
functions:
- args:
- value: 0.05
name: cutOffMin
groupBy:
- fieldContext: resource
name: host.name
name: A
signal: metrics
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_ewma_smoothed_latency:
description: 'ewma3, ewma5 and ewma7 replace each point with an exponentially
weighted average of the point and the points before it. The number
in the name is the window the default weight is derived from: alpha
= 2 / (n + 1), so 0.5, 0.333 and 0.25. One optional argument overrides
alpha; a value close to 1 follows the raw series, a value close
to 0 smooths harder. 0.3 here smooths a little more than ewma5''s
default.'
summary: 'ewma5: smoothed p95 latency'
value:
compositeQuery:
queries:
- spec:
aggregations:
- metricName: http.server.duration.bucket
spaceAggregation: p95
functions:
- args:
- value: 0.3
name: ewma5
groupBy:
- fieldContext: resource
name: service.name
name: A
signal: metrics
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_fillzero_sparse_errors:
description: A count over sparse events returns a point only for steps
that had a match, so a chart of failed spans has gaps where nothing
failed. fillZero adds a zero at every missing step. With no arguments
the server fills the request window at the query's step, which is
the common case. The three optional arguments are start, end and
step in milliseconds, for callers that want to fill a different
grid.
summary: 'fillZero: a zero at every step with no failed spans'
value:
compositeQuery:
queries:
- spec:
aggregations:
- alias: failed_spans
expression: count()
filter:
expression: has_error = true
functions:
- name: fillZero
groupBy:
- fieldContext: resource
name: service.name
name: A
signal: traces
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_log10_network_bytes:
description: log2 and log10 replace each value with its logarithm,
so a loopback device moving kilobytes and an uplink moving gigabytes
fit on one chart. They take no arguments. Zero and negative values
have no logarithm and are not drawn. absolute, also without arguments,
is the usual companion for a signed series before a logarithm.
summary: 'log10: network throughput across devices that differ by
orders of magnitude'
value:
compositeQuery:
queries:
- spec:
aggregations:
- metricName: system.network.io
spaceAggregation: sum
timeAggregation: rate
filter:
expression: direction = 'transmit'
functions:
- name: log10
groupBy:
- fieldContext: attribute
name: device
name: A
signal: metrics
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_median_despiked_request_rate:
description: median3, median5 and median7 replace each point with
the median of the surrounding 3, 5 or 7 points. A spike that lasts
one step vanishes, a shift that lasts longer than half the window
stays. The first and last half window, 2 points each for median5,
have no full window and keep their values, so the newest spike stays
visible; a series shorter than the window is returned unchanged.
They take no arguments. Use them when the raw series is noisy and
the chart is for a trend, not for catching the spike.
summary: 'median5: request rate without one-step spikes'
value:
compositeQuery:
queries:
- spec:
aggregations:
- metricName: http.server.duration.count
spaceAggregation: sum
timeAggregation: rate
functions:
- name: median5
groupBy:
- fieldContext: resource
name: service.name
name: A
signal: metrics
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_runningdiff_disk_growth:
description: 'runningDiff turns a gauge into the change between consecutive
points, so a filesystem usage gauge becomes bytes written per step.
It takes no arguments and drops the first point of the window, since
that point has nothing to difference against; the server reads one
step before the window to keep the first interval. Functions apply
in order: clampMin with 0 runs on the differences and replaces the
negative value a cleanup or a volume reset produces with zero.'
summary: 'runningDiff then clampMin: bytes written to disk per step'
value:
compositeQuery:
queries:
- spec:
aggregations:
- metricName: system.filesystem.usage
spaceAggregation: sum
timeAggregation: latest
filter:
expression: state = 'used'
functions:
- name: runningDiff
- args:
- value: 0
name: clampMin
groupBy:
- fieldContext: resource
name: host.name
name: A
signal: metrics
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_timeshift_week_over_week:
description: timeShift moves every point of its query forward by the
argument, in seconds, so a series read from the past lands on the
current timestamps. B here reads the hour exactly seven days (604800
s) before the requested window and comes back on this week's timestamps,
so it overlays A on a chart and the formula can subtract it point
by point. The argument is positive; it says how far back to read.
summary: 'timeShift: this week''s error logs next to last week''s'
value:
compositeQuery:
queries:
- spec:
aggregations:
- alias: errors
expression: count()
filter:
expression: severity_text = 'ERROR'
name: A
signal: logs
stepInterval: 60s
type: builder_query
- spec:
aggregations:
- alias: errors
expression: count()
filter:
expression: severity_text = 'ERROR'
functions:
- args:
- value: 604800
name: timeShift
name: B
signal: logs
stepInterval: 60s
type: builder_query
- spec:
expression: A - B
name: F1
type: builder_formula
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
logs_raw:
summary: 'Raw: fetch raw log records'
value:

View File

@@ -34,8 +34,7 @@ type AnomalyRule struct {
provider anomaly.Provider
version string
logger *slog.Logger
logger *slog.Logger
seasonality anomaly.Seasonality
}
@@ -63,7 +62,6 @@ func NewAnomalyRule(
r := AnomalyRule{
BaseRule: baseRule,
querier: querier,
version: p.Version,
logger: logger.With(slog.String("rule.id", id)),
}
@@ -105,15 +103,15 @@ func (r *AnomalyRule) Type() ruletypes.RuleType {
}
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) *qbtypes.QueryRangeRequest {
r.logger.InfoContext(
ctx, "prepare query range request", slog.Int64("ts", ts.UnixMilli()),
slog.Int64("eval.window_ms", r.EvalWindow().Milliseconds()),
slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()),
)
startTs, endTs := r.Timestamps(ts)
start, end := startTs.UnixMilli(), endTs.UnixMilli()
r.logger.InfoContext(
ctx, "prepare query range request", slog.Int64("ts", ts.UnixMilli()),
slog.Int64("eval.window_ms", end-start),
slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()),
)
req := &qbtypes.QueryRangeRequest{
Start: uint64(start),
End: uint64(end),
@@ -420,12 +418,10 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
func (r *AnomalyRule) String() string {
ar := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName(r.Name()),
RuleCondition: r.Condition(),
EvalWindow: r.EvalWindow(),
Labels: r.Labels().Map(),
Annotations: r.Annotations().Map(),
PreferredChannels: r.PreferredChannels(),
AlertName: valuer.NewDisplayName(r.Name()),
RuleCondition: r.Condition(),
Labels: r.Labels().Map(),
Annotations: r.Annotations().Map(),
}
byt, err := json.Marshal(ar)

View File

@@ -54,9 +54,6 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
Target: &target,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
@@ -157,10 +154,7 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
AlertOnAbsent: true,
Target: &target,
AlertOnAbsent: true,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{

View File

@@ -21,15 +21,14 @@ import {
useListDashboardsForUserV2,
} from 'api/generated/services/dashboard';
import {
getListRulesV3QueryKey,
useListRulesV3,
getListRulesQueryKey,
useListRules,
} from 'api/generated/services/rules';
import type {
DashboardtypesListedDashboardForUserV2DTO,
ListDashboardsForUserV2200,
ListDashboardsForUserV2Params,
ListRulesV3200,
ListRulesV3Params,
ListRules200,
} from 'api/generated/services/sigNoz.schemas';
import logEvent from 'api/common/logEvent';
import { useQueryService } from 'hooks/useQueryService';
@@ -180,9 +179,6 @@ const VOICE_UNAVAILABLE_KEY = 'ai-assistant-voice-unavailable';
*/
const DASHBOARD_LIST_PARAMS: ListDashboardsForUserV2Params = { limit: 1000 };
/** Same as dashboards. 200 is the most the rules list returns per call. */
const ALERT_RULE_LIST_PARAMS: ListRulesV3Params = { limit: 200 };
function dashboardTitle(
dashboard: DashboardtypesListedDashboardForUserV2DTO,
): string {
@@ -759,7 +755,7 @@ export default function ChatInput({
data: alertsResponse,
isLoading: isAlertsLoading,
isError: isAlertsError,
} = useListRulesV3(ALERT_RULE_LIST_PARAMS, {
} = useListRules({
query: {
enabled: isContextPickerOpen && activeContextCategory === 'Alerts',
staleTime: Infinity,
@@ -808,10 +804,10 @@ export default function ChatInput({
}
}
if (ctx.type === 'alert' && ctx.resourceId) {
const cached = queryClient.getQueryData<ListRulesV3200>(
getListRulesV3QueryKey(ALERT_RULE_LIST_PARAMS),
const cached = queryClient.getQueryData<ListRules200>(
getListRulesQueryKey(),
);
const rule = cached?.data.rules.find((r) => r.id === ctx.resourceId);
const rule = cached?.data?.find((r) => r.id === ctx.resourceId);
if (rule?.alert) {
return rule.alert;
}
@@ -839,8 +835,8 @@ export default function ChatInput({
value: dashboardTitle(dashboard),
})) ?? [],
Alerts:
alertsResponse?.data.rules
.filter((alertRule) => Boolean(alertRule.alert))
alertsResponse?.data
?.filter((alertRule) => Boolean(alertRule.alert))
.map((alertRule) => ({
id: alertRule.id,
value: alertRule.alert,
@@ -1063,7 +1059,6 @@ export default function ChatInput({
color="secondary"
size="sm"
id={`ai-context-tab-${category}`}
testId={`ai-context-tab-${category}`}
// Single stable panel id shared by every tab: only the
// active category's tabpanel is rendered, so per-category
// `aria-controls` ids would point at nonexistent nodes

View File

@@ -1,70 +0,0 @@
import {
alertRulesFixture,
listRulesV3Response,
} from 'mocks-server/__mockdata__/alert_rules';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent } from 'tests/test-utils';
jest.mock('api/generated/services/dashboard', () => ({
useListDashboardsForUserV2: (): unknown => ({
data: undefined,
isLoading: false,
isError: false,
}),
getListDashboardsForUserV2QueryKey: (): string[] => ['dashboards'],
}));
jest.mock('hooks/useQueryService', () => ({
useQueryService: (): unknown => ({
data: [],
isLoading: false,
isFetching: false,
isError: false,
}),
}));
jest.mock('../../../hooks/useSpeechRecognition', () => ({
useSpeechRecognition: (): unknown => ({
isListening: false,
isSupported: false,
permission: 'prompt',
start: jest.fn(),
discard: jest.fn(),
}),
}));
jest.mock('../../../hooks/useAIAssistantAnalyticsContext', () => ({
useAIAssistantAnalyticsContext: (): unknown => ({
threadId: undefined,
page: '/',
mode: 'sidepane',
}),
}));
// eslint-disable-next-line import/first
import ChatInput from '../ChatInput';
describe('ChatInput — alerts context picker', () => {
it('lists the rules from one v3 rules request', async () => {
const [first, second] = alertRulesFixture;
const requests: string[] = [];
server.use(
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
requests.push(req.url.search);
return res(
ctx.status(200),
ctx.json(listRulesV3Response([first, second], req.url.searchParams)),
);
}),
);
render(<ChatInput onSend={jest.fn()} />);
await userEvent.click(screen.getByRole('button', { name: /add context/i }));
await userEvent.click(await screen.findByTestId('ai-context-tab-Alerts'));
await expect(screen.findByText(first.alert)).resolves.toBeInTheDocument();
expect(screen.getByText(second.alert)).toBeInTheDocument();
expect(requests).toStrictEqual(['?limit=200']);
});
});

View File

@@ -12,12 +12,8 @@ jest.mock('api/generated/services/dashboard', () => ({
}));
jest.mock('api/generated/services/rules', () => ({
useListRulesV3: (): unknown => ({
data: undefined,
isLoading: false,
isError: false,
}),
getListRulesV3QueryKey: (): string[] => ['rules'],
useListRules: (): unknown => ({ data: [], isLoading: false, isError: false }),
getListRulesQueryKey: (): string[] => ['rules'],
}));
jest.mock('hooks/useQueryService', () => ({

View File

@@ -18,7 +18,7 @@ import { buildCreateAlertRulePayload, validateCreateAlertState } from './utils';
import './styles.scss';
import {
invalidateGetRuleByID,
invalidateListRulesV3,
invalidateListRules,
} from 'api/generated/services/rules';
import { useQueryClient } from 'react-query';
@@ -137,7 +137,7 @@ function Footer(): JSX.Element {
{
onSuccess: () => {
void invalidateGetRuleByID(queryClient, { id: ruleId });
void invalidateListRulesV3(queryClient);
void invalidateListRules(queryClient);
toast.success('Alert rule updated successfully');
safeNavigate('/alerts');

View File

@@ -1,22 +1,24 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { Button, Skeleton } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import logEvent from 'api/common/logEvent';
import type { RuletypesListableRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { useListRules } from 'api/generated/services/rules';
import type { RuletypesRuleDTO } 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,
@@ -26,19 +28,49 @@ 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 { rules: alertRules, isError, isLoading } = useHomeAlertRules();
const rulesExist = alertRules.length > 0;
const {
data: alerts,
isError,
isLoading,
} = useListRules({
query: { cacheTime: 0 },
});
useEffect(() => {
if (rulesExist && !loadingUserPreferences) {
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) {
onUpdateChecklistDoneItem('SETUP_ALERTS');
}
}, [rulesExist, onUpdateChecklistDoneItem, loadingUserPreferences]);
setSortedAlertRules(sortedRules.slice(0, 5));
}, [alerts, onUpdateChecklistDoneItem, loadingUserPreferences]);
const emptyStateCard = (): JSX.Element => (
<div className="empty-state-container">
@@ -63,7 +95,7 @@ export default function AlertRules({
className="periscope-btn secondary"
icon={<Plus size={16} />}
onClick={(): void => {
void logEvent('Homepage: Create alert rule clicked', {});
logEvent('Homepage: Create alert rule clicked', {});
}}
>
Create Alert Rule
@@ -74,7 +106,7 @@ export default function AlertRules({
type="link"
className="learn-more-link"
onClick={(): void => {
void logEvent('Homepage: Learn more clicked', {
logEvent('Homepage: Learn more clicked', {
source: 'Alert Rules',
});
@@ -93,13 +125,26 @@ export default function AlertRules({
</div>
);
const onEditHandler = (record: RuletypesListableRuleDTO) => (): void => {
void logEvent('Homepage: Alert clicked', {
const onEditHandler = (record: RuletypesRuleDTO) => (): 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()}`);
@@ -108,12 +153,11 @@ export default function AlertRules({
const renderAlertRules = (): JSX.Element => (
<div className="alert-rules-container home-data-item-container">
<div className="alert-rules-list">
{alertRules.map((rule) => (
{sortedAlertRules.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 => {
@@ -195,7 +239,7 @@ export default function AlertRules({
type="link"
className="periscope-btn link learn-more-link"
onClick={(): void => {
void logEvent('Homepage: All alert rules clicked', {});
logEvent('Homepage: All alert rules clicked', {});
}}
>
All Alert Rules <ArrowRight size={12} />

View File

@@ -1,64 +0,0 @@
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

@@ -1,119 +0,0 @@
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,7 +55,9 @@ export function PlannedDowntime(): JSX.Element {
isError,
isLoading,
isFetching: isFetchingAlerts,
} = useListAllRules({ cacheTime: 0 });
} = useListRules({
query: { cacheTime: 0 },
});
const [isOpen, setIsOpen] = React.useState(false);
const [form] = Form.useForm();
const { user } = useAppContext();
@@ -71,7 +73,7 @@ export function PlannedDowntime(): JSX.Element {
const downtimeSchedules = useListDowntimeSchedules();
const alertOptions = React.useMemo(
() =>
alertsData?.map((i) => ({
alertsData?.data?.map((i) => ({
label: i.alert,
value: i.id,
})),
@@ -179,7 +181,7 @@ export function PlannedDowntime(): JSX.Element {
};
const refetchAllSchedules = (): void => {
void downtimeSchedules.refetch();
downtimeSchedules.refetch();
};
const {

View File

@@ -4,7 +4,6 @@ import { fireEvent, screen } from '@testing-library/react';
import type {
ListDowntimeSchedules200,
RenderErrorResponseDTO,
RuletypesListableRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
AlertmanagertypesRepeatOnDTO,
@@ -127,19 +126,19 @@ jest.mock('api/generated/services/downtimeschedules', () => ({
}));
let mockRulesState: {
data?: Pick<RuletypesListableRuleDTO, 'id' | 'alert'>[];
data?: { data: Array<{ id: number | string; alert: string }> };
isError: boolean;
isLoading: boolean;
isFetching: boolean;
} = {
data: [],
data: { data: [] },
isError: false,
isLoading: false,
isFetching: false,
};
jest.mock('hooks/alerts/useListAllRules', () => ({
useListAllRules: (): typeof mockRulesState => mockRulesState,
jest.mock('api/generated/services/rules', () => ({
useListRules: (): typeof mockRulesState => mockRulesState,
}));
describe('PlannedDowntime Component', () => {
@@ -152,7 +151,7 @@ describe('PlannedDowntime Component', () => {
});
mockDowntimeQueryResult.data = MOCK_DOWNTIME_RESPONSE;
mockRulesState = {
data: [],
data: { data: [] },
isError: false,
isLoading: false,
isFetching: false,
@@ -363,7 +362,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,
@@ -388,7 +387,14 @@ describe('PlannedDowntime Component', () => {
it('shows resolved alert rule name when scoped rule ID exists', async () => {
mockRulesState = {
data: [{ id: 'rule-42', alert: 'Disk Space High' }],
data: {
data: [
{
id: 'rule-42',
alert: 'Disk Space High',
} as any,
],
},
isError: false,
isLoading: false,
isFetching: false,

View File

@@ -1,40 +0,0 @@
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

@@ -1,40 +0,0 @@
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

@@ -10,7 +10,7 @@ import {
getGetRuleByIDQueryKey,
getGetRuleHistoryTimelineQueryOptions,
invalidateGetRuleByID,
invalidateListRulesV3,
invalidateListRules,
updateRuleByID,
useGetRuleByID,
useGetRuleHistoryOverallStatus,
@@ -443,7 +443,7 @@ export const useAlertRuleClone = ({
message: `Success`,
});
await invalidateListRulesV3(queryClient);
await invalidateListRules(queryClient);
params.set(QueryParams.ruleId, response.data.id);
navigate(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
@@ -494,7 +494,7 @@ export const useAlertRuleUpdate = ({
data: { ...existingRule.data, alert: intermediateName },
});
}
void invalidateListRulesV3(queryClient);
void invalidateListRules(queryClient);
notifications.success({ message: 'Alert renamed successfully' });
},
onError: (error) => {

View File

@@ -17,8 +17,10 @@ import {
FIRST_DOWNTIME_NAME,
} from './__story_mockdata__/plannedDowntime';
import { alertRulesPage } from '../../AlertRules/stories/__story_mockdata__/ruleViews';
import { RULE_MAX } from '../../stories/__story_mockdata__/alerts';
import {
alertRulesResponse,
RULE_MAX,
} from '../../stories/__story_mockdata__/alerts';
import { AlertListSubTabs, AlertListTabs } from '../../types';
const LIST = 'Planned downtime · list';
@@ -84,20 +86,18 @@ export const plannedDowntimeMocks = defineStoryMocks({
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
),
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,
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',
}),
),
),
),
);
}),
),
],
config: () => ({
route: `/alerts?tab=${AlertListTabs.CONFIGURATION}&subTab=${AlertListSubTabs.PLANNED_DOWNTIME}`,

View File

@@ -3,7 +3,6 @@
* Do not hand-edit: regenerate instead.
*/
import { listRulesV3Response } from 'mocks-server/__mockdata__/alert_rules';
import { rest } from 'msw';
import {
@@ -157,13 +156,11 @@ export const homeMocks = defineStoryMocks({
),
rest.get(
'http://localhost/api/v3/rules',
response.json((req) =>
listRulesV3Response(
buildAlertRules(values.alertRules),
req.url.searchParams,
),
),
'http://localhost/api/v2/rules',
response.json(() => ({
status: 'success',
data: buildAlertRules(values.alertRules),
})),
),
rest.get(

View File

@@ -11,7 +11,6 @@ import (
"log/slog"
"net"
"net/http"
"net/textproto"
"net/url"
"os"
"strconv"
@@ -707,9 +706,7 @@ func TestEmailRejected(t *testing.T) {
// Send the alert to mock SMTP server.
retry, err := e.Notify(context.Background(), firingAlert)
var smtpErr *textproto.Error
require.ErrorAs(t, err, &smtpErr)
assert.Equal(t, 501, smtpErr.Code)
require.ErrorContains(t, err, "501 5.5.4 Rejected!")
assert.True(t, retry)
require.NoError(t, srv.Shutdown(ctx))

View File

@@ -9,8 +9,6 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/tidwall/gjson"
"github.com/uptrace/bun"
)
type config struct {
@@ -239,37 +237,6 @@ func (store *config) ListAllChannels(ctx context.Context) ([]*alertmanagertypes.
return channels, nil
}
func (store *config) GetMatchers(ctx context.Context, orgID string) (map[string][]string, error) {
type matcher struct {
bun.BaseModel `bun:"table:rule"`
ID valuer.UUID `bun:"id,pk"`
Data string `bun:"data"`
}
matchers := []matcher{}
err := store.
sqlstore.
BunDB().
NewSelect().
Column("id", "data").
Model(&matchers).
Scan(ctx)
if err != nil {
return nil, err
}
matchersMap := make(map[string][]string)
for _, matcher := range matchers {
receivers := gjson.Get(matcher.Data, "preferredChannels").Array()
for _, receiver := range receivers {
matchersMap[matcher.ID.StringValue()] = append(matchersMap[matcher.ID.StringValue()], receiver.String())
}
}
return matchersMap, nil
}
func (store *config) wrap(ctx context.Context, fn func(ctx context.Context) error, opts ...alertmanagertypes.StoreOption) error {
storeOpts := alertmanagertypes.NewStoreOptions(opts...)

View File

@@ -245,25 +245,13 @@ func (service *Service) compareAndSelectConfig(ctx context.Context, incomingConf
return nil, err
}
matchers, err := service.configStore.GetMatchers(ctx, incomingConfig.StoreableConfig().OrgID)
if err != nil {
return nil, err
}
config, err := alertmanagertypes.NewConfigFromChannels(service.config.Global, service.config.Route, channels, incomingConfig.StoreableConfig().OrgID)
if err != nil {
return nil, err
}
for ruleID, receivers := range matchers {
err = config.CreateRuleIDMatcher(ruleID, receivers)
if err != nil {
return nil, err
}
}
if incomingConfig.StoreableConfig().Hash != config.StoreableConfig().Hash {
service.settings.Logger().InfoContext(ctx, "mismatch found, updating config to match channels and matchers")
service.settings.Logger().InfoContext(ctx, "mismatch found, updating config to match channels")
return config, nil
}

View File

@@ -30,7 +30,7 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
Description: "Execute a composite query over a time range. Supports builder queries (traces, logs, metrics), formulas, trace operators, PromQL, and ClickHouse SQL.",
Request: new(qbtypes.QueryRangeRequest),
RequestContentType: "application/json",
RequestExamples: append([]handler.OpenAPIExample{
RequestExamples: []handler.OpenAPIExample{
{
Name: "traces_time_series",
Summary: "Time series: count spans grouped by service",
@@ -598,7 +598,7 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
},
},
},
}, queryRangeFunctionExamples()...),
},
Response: new(qbtypes.QueryRangeResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,

View File

@@ -1,202 +0,0 @@
package signozapiserver
import "github.com/SigNoz/signoz/pkg/http/handler"
// timeSeriesExample wraps builder query specs in a one hour time series
// request, the shape every function example shares.
func timeSeriesExample(name, summary, description string, queries ...map[string]any) handler.OpenAPIExample {
list := make([]any, 0, len(queries))
for _, query := range queries {
list = append(list, query)
}
return handler.OpenAPIExample{
Name: name,
Summary: summary,
Description: description,
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "time_series",
"compositeQuery": map[string]any{"queries": list},
},
}
}
func builderQueryEnvelope(spec map[string]any) map[string]any {
return map[string]any{"type": "builder_query", "spec": spec}
}
// function builds a function entry with positional arguments; anomaly is the
// one function that takes named arguments and is written out in full.
func function(name string, args ...any) map[string]any {
list := make([]any, 0, len(args))
for _, arg := range args {
list = append(list, map[string]any{"value": arg})
}
entry := map[string]any{"name": name}
if len(list) > 0 {
entry["args"] = list
}
return entry
}
// queryRangeFunctionExamples shows every function a builder query can carry,
// what its arguments mean and the values they take. Functions run in the
// order listed, after the aggregation and before formulas.
func queryRangeFunctionExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
timeSeriesExample(
"function_timeshift_week_over_week",
"timeShift: this week's error logs next to last week's",
"timeShift moves every point of its query forward by the argument, in seconds, so a series read from the past lands on the current timestamps. B here reads the hour exactly seven days (604800 s) before the requested window and comes back on this week's timestamps, so it overlays A on a chart and the formula can subtract it point by point. The argument is positive; it says how far back to read.",
builderQueryEnvelope(map[string]any{
"name": "A",
"signal": "logs",
"aggregations": []any{map[string]any{"expression": "count()", "alias": "errors"}},
"stepInterval": "60s",
"filter": map[string]any{"expression": "severity_text = 'ERROR'"},
}),
builderQueryEnvelope(map[string]any{
"name": "B",
"signal": "logs",
"aggregations": []any{map[string]any{"expression": "count()", "alias": "errors"}},
"stepInterval": "60s",
"filter": map[string]any{"expression": "severity_text = 'ERROR'"},
"functions": []any{function("timeShift", 604800)},
}),
map[string]any{
"type": "builder_formula",
"spec": map[string]any{"name": "F1", "expression": "A - B"},
},
),
timeSeriesExample(
"function_fillzero_sparse_errors",
"fillZero: a zero at every step with no failed spans",
"A count over sparse events returns a point only for steps that had a match, so a chart of failed spans has gaps where nothing failed. fillZero adds a zero at every missing step. With no arguments the server fills the request window at the query's step, which is the common case. The three optional arguments are start, end and step in milliseconds, for callers that want to fill a different grid.",
builderQueryEnvelope(map[string]any{
"name": "A",
"signal": "traces",
"aggregations": []any{map[string]any{"expression": "count()", "alias": "failed_spans"}},
"stepInterval": "60s",
"filter": map[string]any{"expression": "has_error = true"},
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
"functions": []any{function("fillZero")},
}),
),
timeSeriesExample(
"function_cutoffmin_hide_idle_hosts",
"cutOffMin: drop hosts below 5% CPU from the chart",
"The four threshold functions take one number. cutOffMin and cutOffMax remove the points below or above the threshold, so an idle host disappears from the chart instead of drawing a line along the bottom. clampMin and clampMax keep the points and replace the value with the threshold, which caps spikes without hiding them. system.cpu.utilization is a ratio, so 0.05 is 5%.",
builderQueryEnvelope(map[string]any{
"name": "A",
"signal": "metrics",
"aggregations": []any{
map[string]any{"metricName": "system.cpu.utilization", "timeAggregation": "avg", "spaceAggregation": "avg"},
},
"stepInterval": "60s",
"groupBy": []any{map[string]any{"name": "host.name", "fieldContext": "resource"}},
"functions": []any{function("cutOffMin", 0.05)},
}),
),
timeSeriesExample(
"function_runningdiff_disk_growth",
"runningDiff then clampMin: bytes written to disk per step",
"runningDiff turns a gauge into the change between consecutive points, so a filesystem usage gauge becomes bytes written per step. It takes no arguments and drops the first point of the window, since that point has nothing to difference against; the server reads one step before the window to keep the first interval. Functions apply in order: clampMin with 0 runs on the differences and replaces the negative value a cleanup or a volume reset produces with zero.",
builderQueryEnvelope(map[string]any{
"name": "A",
"signal": "metrics",
"aggregations": []any{
map[string]any{"metricName": "system.filesystem.usage", "timeAggregation": "latest", "spaceAggregation": "sum"},
},
"stepInterval": "60s",
"filter": map[string]any{"expression": "state = 'used'"},
"groupBy": []any{map[string]any{"name": "host.name", "fieldContext": "resource"}},
"functions": []any{function("runningDiff"), function("clampMin", 0)},
}),
),
timeSeriesExample(
"function_ewma_smoothed_latency",
"ewma5: smoothed p95 latency",
"ewma3, ewma5 and ewma7 replace each point with an exponentially weighted average of the point and the points before it. The number in the name is the window the default weight is derived from: alpha = 2 / (n + 1), so 0.5, 0.333 and 0.25. One optional argument overrides alpha; a value close to 1 follows the raw series, a value close to 0 smooths harder. 0.3 here smooths a little more than ewma5's default.",
builderQueryEnvelope(map[string]any{
"name": "A",
"signal": "metrics",
"aggregations": []any{
map[string]any{"metricName": "http.server.duration.bucket", "spaceAggregation": "p95"},
},
"stepInterval": "60s",
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
"functions": []any{function("ewma5", 0.3)},
}),
),
timeSeriesExample(
"function_median_despiked_request_rate",
"median5: request rate without one-step spikes",
"median3, median5 and median7 replace each point with the median of the surrounding 3, 5 or 7 points. A spike that lasts one step vanishes, a shift that lasts longer than half the window stays. The first and last half window, 2 points each for median5, have no full window and keep their values, so the newest spike stays visible; a series shorter than the window is returned unchanged. They take no arguments. Use them when the raw series is noisy and the chart is for a trend, not for catching the spike.",
builderQueryEnvelope(map[string]any{
"name": "A",
"signal": "metrics",
"aggregations": []any{
map[string]any{"metricName": "http.server.duration.count", "timeAggregation": "rate", "spaceAggregation": "sum"},
},
"stepInterval": "60s",
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
"functions": []any{function("median5")},
}),
),
timeSeriesExample(
"function_cumulativesum_errors_so_far",
"cumulativeSum: 5xx responses since the start of the window",
"cumulativeSum replaces each point with the sum of every point up to and including it, so a per-step count becomes a running total whose first point is the first count. It takes no arguments. The total resets with the window, so a longer time range gives a larger final value; it is a view of the window, not a counter.",
builderQueryEnvelope(map[string]any{
"name": "A",
"signal": "traces",
"aggregations": []any{map[string]any{"expression": "count()", "alias": "server_errors"}},
"stepInterval": "60s",
"filter": map[string]any{"expression": "http.response.status_code >= 500"},
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
"functions": []any{function("cumulativeSum")},
}),
),
timeSeriesExample(
"function_log10_network_bytes",
"log10: network throughput across devices that differ by orders of magnitude",
"log2 and log10 replace each value with its logarithm, so a loopback device moving kilobytes and an uplink moving gigabytes fit on one chart. They take no arguments. Zero and negative values have no logarithm and are not drawn. absolute, also without arguments, is the usual companion for a signed series before a logarithm.",
builderQueryEnvelope(map[string]any{
"name": "A",
"signal": "metrics",
"aggregations": []any{
map[string]any{"metricName": "system.network.io", "timeAggregation": "rate", "spaceAggregation": "sum"},
},
"stepInterval": "60s",
"filter": map[string]any{"expression": "direction = 'transmit'"},
"groupBy": []any{map[string]any{"name": "device", "fieldContext": "attribute"}},
"functions": []any{function("log10")},
}),
),
timeSeriesExample(
"function_anomaly_request_rate",
"anomaly: request rate scored against its daily pattern",
"anomaly is the one function with named arguments. seasonality says which past periods predict the current value: hourly, daily or weekly, daily when absent. z_score_threshold is how many standard deviations from the prediction count as an anomaly. It has no default: a request without it gets bounds of zero width, so send it. 3 is the value the UI sends. The response carries, next to the values, a predicted series, an upper and a lower bound series and an anomaly score series. The scoring runs in the enterprise build; the community build returns the series unchanged.",
builderQueryEnvelope(map[string]any{
"name": "A",
"signal": "metrics",
"aggregations": []any{
map[string]any{"metricName": "http.server.duration.count", "timeAggregation": "rate", "spaceAggregation": "sum"},
},
"stepInterval": "300s",
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
"functions": []any{
map[string]any{
"name": "anomaly",
"args": []any{
map[string]any{"name": "seasonality", "value": "daily"},
map[string]any{"name": "z_score_threshold", "value": 3},
},
},
},
}),
),
}
}

View File

@@ -1,23 +0,0 @@
package signozapiserver
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
func TestQueryRangeFunctionExamplesAreValidRequests(t *testing.T) {
for _, example := range queryRangeFunctionExamples() {
t.Run(example.Name, func(t *testing.T) {
raw, err := json.Marshal(example.Value)
require.NoError(t, err)
var req qbtypes.QueryRangeRequest
require.NoError(t, json.Unmarshal(raw, &req))
assert.NoError(t, req.Validate())
})
}
}

View File

@@ -14,20 +14,6 @@ import (
)
func (provider *provider) addRulerRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/rules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRules), handler.OpenAPIDef{
ID: "ListRules",
Tags: []string{"rules"},
Summary: "List alert rules",
Description: "This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.",
Response: make([]*ruletypes.Rule, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
Deprecated: true,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v3/rules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRulesV3), handler.OpenAPIDef{
ID: "ListRulesV3",
Tags: []string{"rules"},

View File

@@ -59,11 +59,7 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
}
}
if builder.evaluation == nil {
evalWindow := rule.EvalWindow
if evalWindow.IsZero() {
evalWindow = valuer.MustParseTextDuration("5m")
}
builder.evaluation = ruletypes.RollingWindow{EvalWindow: evalWindow}
return nil
}
return builder

View File

@@ -3,10 +3,8 @@ package app
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"github.com/SigNoz/signoz/pkg/clickhousesql"
"github.com/SigNoz/signoz/pkg/errors"
@@ -360,14 +358,6 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/query_range", am.ViewAccess(aH.queryRangeMetrics)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/query", am.ViewAccess(aH.queryMetrics)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules", am.ViewAccess(aH.listRules)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules/{id}", am.ViewAccess(aH.getRule)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules", am.EditAccess(aH.createRule)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/rules/{id}", am.EditAccess(aH.editRule)).Methods(http.MethodPut)
router.HandleFunc("/api/v1/rules/{id}", am.EditAccess(aH.deleteRule)).Methods(http.MethodDelete)
router.HandleFunc("/api/v1/rules/{id}", am.EditAccess(aH.patchRule)).Methods(http.MethodPatch)
router.HandleFunc("/api/v1/testRule", am.EditAccess(aH.testRule)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/variables/query", am.ViewAccess(aH.queryDashboardVarsV2)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/explorer/views", am.ViewAccess(aH.Signoz.Handlers.SavedView.List)).Methods(http.MethodGet)
@@ -496,166 +486,6 @@ func (aH *APIHandler) PopulateTemporality(ctx context.Context, orgID valuer.UUID
return nil
}
func (aH *APIHandler) listRules(w http.ResponseWriter, r *http.Request) {
rules, err := aH.ruleManager.ListRuleStates(r.Context())
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
// todo(amol): need to add sorter
aH.Respond(w, rules)
}
func (aH *APIHandler) getRule(w http.ResponseWriter, r *http.Request) {
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
ruleResponse, err := aH.ruleManager.GetRule(r.Context(), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
return
}
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
aH.Respond(w, ruleResponse)
}
func (aH *APIHandler) createRule(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
aH.logger.ErrorContext(r.Context(), "error reading request body for create rule", errors.Attr(err))
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
rule, err := aH.ruleManager.CreateRule(r.Context(), string(body))
if err != nil {
RespondError(w, toApiError(err), nil)
return
}
aH.Respond(w, rule)
}
func (aH *APIHandler) editRule(w http.ResponseWriter, r *http.Request) {
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
aH.logger.ErrorContext(r.Context(), "error reading request body for edit rule", errors.Attr(err))
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
err = aH.ruleManager.EditRule(r.Context(), string(body), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
return
}
RespondError(w, toApiError(err), nil)
return
}
aH.Respond(w, "rule successfully edited")
}
func (aH *APIHandler) deleteRule(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
err := aH.ruleManager.DeleteRule(r.Context(), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
return
}
RespondError(w, toApiError(err), nil)
return
}
aH.Respond(w, "rule successfully deleted")
}
func (aH *APIHandler) patchRule(w http.ResponseWriter, r *http.Request) {
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
aH.logger.ErrorContext(r.Context(), "error reading request body for patch rule", errors.Attr(err))
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
gettableRule, err := aH.ruleManager.PatchRule(r.Context(), string(body), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
return
}
RespondError(w, toApiError(err), nil)
return
}
aH.Respond(w, gettableRule)
}
func (aH *APIHandler) testRule(w http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(w, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(w, err)
return
}
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
aH.logger.ErrorContext(r.Context(), "error reading request body for test rule", errors.Attr(err))
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
defer cancel()
alertCount, err := aH.ruleManager.TestNotification(ctx, orgID, string(body))
if err != nil {
RespondError(w, toApiError(err), nil)
return
}
response := map[string]interface{}{
"alertCount": alertCount,
"message": "notification sent",
}
aH.Respond(w, response)
}
func prepareQuery(r *http.Request) (string, error) {
var postData *model.DashboardVars

View File

@@ -34,10 +34,6 @@ type BaseRule struct {
ruleCondition *ruletypes.RuleCondition
Threshold ruletypes.RuleThreshold
// evalWindow is the time window used for evaluating the rule
// i.e. each time we lookback from the current time, we look at data for the last
// evalWindow duration
evalWindow valuer.TextDuration
// holdDuration is the duration for which the alert waits before firing
holdDuration valuer.TextDuration
@@ -49,10 +45,7 @@ type BaseRule struct {
// these are the same for all alerts created for this rule
labels ruletypes.Labels
annotations ruletypes.Labels
// preferredChannels is the list of channels to send the alert to
// if the rule is triggered
preferredChannels []string
mtx sync.Mutex
mtx sync.Mutex
// the time it took to evaluate the rule (most recent evaluation)
evaluationDuration time.Duration
// the timestamp of the last evaluation
@@ -151,26 +144,27 @@ func NewBaseRule(
if err != nil {
return nil, err
}
if err := threshold.Validate(); err != nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid rule threshold spec: %v", err)
}
evaluation, err := p.Evaluation.GetEvaluation()
if err != nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to get evaluation: %v", err)
}
baseRule := &BaseRule{
id: id,
orgID: orgID,
externalURL: externalURL,
name: p.AlertName.StringValue(),
typ: p.AlertType,
ruleCondition: p.RuleCondition,
evalWindow: p.EvalWindow,
labels: ruletypes.FromMap(p.Labels),
annotations: ruletypes.FromMap(p.Annotations),
preferredChannels: p.PreferredChannels,
health: ruletypes.HealthUnknown,
Active: map[uint64]*ruletypes.Alert{},
Threshold: threshold,
evaluation: evaluation,
id: id,
orgID: orgID,
externalURL: externalURL,
name: p.AlertName.StringValue(),
typ: p.AlertType,
ruleCondition: p.RuleCondition,
labels: ruletypes.FromMap(p.Labels),
annotations: ruletypes.FromMap(p.Annotations),
health: ruletypes.HealthUnknown,
Active: map[uint64]*ruletypes.Alert{},
Threshold: threshold,
evaluation: evaluation,
}
// Store newGroupEvalDelay and groupBy keys from NotificationSettings
@@ -178,10 +172,6 @@ func NewBaseRule(
baseRule.newGroupEvalDelay = p.NotificationSettings.NewGroupEvalDelay
}
if baseRule.evalWindow.IsZero() {
baseRule.evalWindow = valuer.MustParseTextDuration("5m")
}
for _, opt := range opts {
opt(baseRule)
}
@@ -232,10 +222,6 @@ func (r *BaseRule) EvalDelay() valuer.TextDuration {
return r.evalDelay
}
func (r *BaseRule) EvalWindow() valuer.TextDuration {
return r.evalWindow
}
func (r *BaseRule) HoldDuration() valuer.TextDuration {
return r.holdDuration
}
@@ -246,7 +232,6 @@ func (r *BaseRule) Name() string { return r.name }
func (r *BaseRule) Condition() *ruletypes.RuleCondition { return r.ruleCondition }
func (r *BaseRule) Labels() ruletypes.Labels { return r.labels }
func (r *BaseRule) Annotations() ruletypes.Labels { return r.annotations }
func (r *BaseRule) PreferredChannels() []string { return r.preferredChannels }
func (r *BaseRule) GeneratorURL() string {
params := url.Values{}

View File

@@ -332,17 +332,28 @@ func (m *Manager) validateChannels(ctx context.Context, orgID string, rule *rule
}
known := make(map[string]struct{}, len(orgChannels))
displayNameByName := make(map[string]string, len(orgChannels))
for _, ch := range orgChannels {
known[ch.DisplayName] = struct{}{}
displayNameByName[ch.Name] = ch.DisplayName
}
var unknown []string
var unknown, byName []string
for _, name := range channels {
if _, ok := known[name]; !ok {
unknown = append(unknown, name)
if _, ok := known[name]; ok {
continue
}
if displayName, ok := displayNameByName[name]; ok {
byName = append(byName, fmt.Sprintf("%s (display name %q)", name, displayName))
continue
}
unknown = append(unknown, name)
}
if len(byName) > 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"channels: rules reference channels by display name, not name: %v", byName)
}
if len(unknown) > 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"channels: the following channels do not exist: %v", unknown)
@@ -359,7 +370,7 @@ func (m *Manager) EditRule(ctx context.Context, ruleStr string, id valuer.UUID)
}
orgID := valuer.MustNewUUID(claims.OrgID)
parsedRule := ruletypes.PostableRule{}
err = json.Unmarshal([]byte(ruleStr), &parsedRule)
err = parsedRule.DecodeStrict(ruleStr)
if err != nil {
return err
}
@@ -438,7 +449,7 @@ func (m *Manager) editTask(_ context.Context, orgID valuer.UUID, rule *ruletypes
})
if err != nil {
m.logger.Error("loading tasks failed", errors.Attr(err))
return errors.NewInvalidInputf(errors.CodeInvalidInput, "error preparing rule with given parameters, previous rule set restored")
return err
}
for _, r := range newTask.Rules() {
@@ -547,7 +558,7 @@ func (m *Manager) deleteTask(taskName string) {
// starts an executor for the rule, unless the rule is disabled
func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.GettableRule, error) {
parsedRule := ruletypes.PostableRule{}
if err := json.Unmarshal([]byte(ruleStr), &parsedRule); err != nil {
if err := parsedRule.DecodeStrict(ruleStr); err != nil {
return nil, err
}
@@ -659,7 +670,7 @@ func (m *Manager) addTask(_ context.Context, orgID valuer.UUID, rule *ruletypes.
})
if err != nil {
m.logger.Error("creating rule task failed", "name", taskName, errors.Attr(err))
return errors.NewInvalidInputf(errors.CodeInvalidInput, "error loading rules, previous rule set restored")
return err
}
for _, r := range newTask.Rules() {
@@ -1048,8 +1059,10 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
m.logger.ErrorContext(ctx, "failed to unmarshal rule from db", slog.String("rule.id", id.StringValue()), errors.Attr(err))
return nil, err
}
// a rule stored before selectedQueryName was required resolves it the way evaluation does
storedRule.RuleCondition.SelectedQuery = storedRule.RuleCondition.SelectedQueryName()
if err := json.Unmarshal([]byte(ruleStr), &storedRule); err != nil {
if err := storedRule.DecodeStrict(ruleStr); err != nil {
m.logger.ErrorContext(ctx, "failed to unmarshal patched rule with given id", slog.String("rule.id", id.StringValue()), errors.Attr(err))
return nil, err
}
@@ -1105,9 +1118,9 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
// sends a test notification. returns alert count and error (if any)
func (m *Manager) TestNotification(ctx context.Context, orgID valuer.UUID, ruleStr string) (int, error) {
parsedRule := ruletypes.PostableRule{}
err := json.Unmarshal([]byte(ruleStr), &parsedRule)
err := parsedRule.DecodeStrict(ruleStr)
if err != nil {
return 0, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to unmarshal rule")
return 0, err
}
if err := parsedRule.Validate(); err != nil {
return 0, err

View File

@@ -49,13 +49,13 @@ func ThresholdRuleAtLeastOnceValueAbove(target float64, recovery *float64) rulet
Annotations: map[string]string{
"value": "{{$value}}",
},
Version: "v5",
Version: "v5",
SchemaVersion: ruletypes.SchemaVersionV2Alpha1,
RuleCondition: &ruletypes.RuleCondition{
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
Target: &target,
SelectedQuery: "A",
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
PanelType: ruletypes.PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
@@ -108,12 +108,10 @@ func BuildPromAtLeastOnceValueAbove(target float64, recovery *float64) ruletypes
Annotations: map[string]string{
"value": "{{$value}}",
},
Version: "v5",
Version: "v5",
SchemaVersion: ruletypes.SchemaVersionV2Alpha1,
RuleCondition: &ruletypes.RuleCondition{
MatchType: ruletypes.AtleastOnce,
SelectedQuery: "A",
CompareOperator: ruletypes.ValueIsAbove,
Target: &target,
SelectedQuery: "A",
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypePromQL,
PanelType: ruletypes.PanelTypeGraph,
@@ -138,7 +136,6 @@ func BuildPromAtLeastOnceValueAbove(target float64, recovery *float64) ruletypes
RecoveryTarget: recovery,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
Channels: []string{"slack"},
},
},
},

View File

@@ -23,7 +23,6 @@ import (
type PromRule struct {
*BaseRule
version string
prometheus prometheus.Prometheus
}
@@ -47,7 +46,6 @@ func NewPromRule(
p := PromRule{
BaseRule: baseRule,
version: postableRule.Version,
prometheus: prometheus,
}
@@ -356,12 +354,10 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
func (r *PromRule) String() string {
ar := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName(r.name),
RuleCondition: r.ruleCondition,
EvalWindow: r.evalWindow,
Labels: r.labels.Map(),
Annotations: r.annotations.Map(),
PreferredChannels: r.preferredChannels,
AlertName: valuer.NewDisplayName(r.name),
RuleCondition: r.ruleCondition,
Labels: r.labels.Map(),
Annotations: r.annotations.Map(),
}
byt, err := json.Marshal(ar)

View File

@@ -690,13 +690,11 @@ func TestPromRuleEval(t *testing.T) {
logger := instrumentationtest.New().Logger()
for idx, c := range cases {
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: "critical",
TargetValue: &c.target,
MatchType: c.matchType,
CompareOperator: c.compareOperator,
@@ -905,11 +903,7 @@ func TestPromRuleUnitCombinations(t *testing.T) {
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = ruletypes.MatchType(c.matchType)
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -1198,11 +1192,7 @@ func TestMultipleThresholdPromRule(t *testing.T) {
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -1259,6 +1249,7 @@ func TestMultipleThresholdPromRule(t *testing.T) {
func TestPromRule_NoData(t *testing.T) {
evalTime := time.Now()
target := 200.0
postableRule := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName("Test no data"),
@@ -1269,8 +1260,6 @@ func TestPromRule_NoData(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypePromQL,
Queries: []qbtypes.QueryEnvelope{
@@ -1282,7 +1271,7 @@ func TestPromRule_NoData(t *testing.T) {
},
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{{Name: "Test no data"}},
Spec: ruletypes.BasicRuleThresholds{{Name: "Test no data", TargetValue: &target, CompareOperator: ruletypes.ValueIsAbove, MatchType: ruletypes.AtleastOnce}},
},
},
}
@@ -1365,10 +1354,7 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
AlertOnAbsent: true,
Target: &target,
AlertOnAbsent: true,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypePromQL,
Queries: []qbtypes.QueryEnvelope{
@@ -1491,8 +1477,6 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypePromQL,
Queries: []qbtypes.QueryEnvelope{
@@ -1562,9 +1546,9 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
for _, c := range cases {
rc := postableRule.RuleCondition
rc.Target = &targetForNoAlert
target := &targetForNoAlert
if c.alertCondition {
rc.Target = &targetForAlert
target = &targetForAlert
}
rc.RequireMinPoints = c.requireMinPoints
rc.RequiredNumPoints = c.requiredNumPoints
@@ -1573,9 +1557,9 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
Spec: ruletypes.BasicRuleThresholds{
{
Name: postableRule.AlertName.StringValue(),
TargetValue: rc.Target,
MatchType: rc.MatchType,
CompareOperator: rc.CompareOperator,
TargetValue: target,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
},
},
}

View File

@@ -20,15 +20,12 @@ type Rule interface {
Annotations() ruletypes.Labels
Condition() *ruletypes.RuleCondition
EvalDelay() valuer.TextDuration
EvalWindow() valuer.TextDuration
HoldDuration() valuer.TextDuration
State() ruletypes.AlertState
ActiveAlerts() []*ruletypes.Alert
// ActiveAlertsLabelFP returns a map of active alert labels fingerprint
ActiveAlertsLabelFP() map[uint64]struct{}
PreferredChannels() []string
// Eval evaluates the rule at the given timestamp and returns the number of active alerts.
Eval(context.Context, time.Time) (int, error)
String() string

View File

@@ -61,16 +61,16 @@ func (r *ThresholdRule) Type() ruletypes.RuleType {
}
func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*qbtypes.QueryRangeRequest, error) {
startTs, endTs := r.Timestamps(ts)
start, end := startTs.UnixMilli(), endTs.UnixMilli()
r.logger.InfoContext(
ctx, "prepare query range request v5",
slog.Int64("ts", ts.UnixMilli()),
slog.Int64("eval_window", r.evalWindow.Milliseconds()),
slog.Int64("eval_window", end-start),
slog.Int64("eval_delay", r.evalDelay.Milliseconds()),
)
startTs, endTs := r.Timestamps(ts)
start, end := startTs.UnixMilli(), endTs.UnixMilli()
req := &qbtypes.QueryRangeRequest{
Start: uint64(start),
End: uint64(end),
@@ -455,12 +455,10 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
func (r *ThresholdRule) String() string {
ar := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName(r.name),
RuleCondition: r.ruleCondition,
EvalWindow: r.evalWindow,
Labels: r.labels.Map(),
Annotations: r.annotations.Map(),
PreferredChannels: r.preferredChannels,
AlertName: valuer.NewDisplayName(r.name),
RuleCondition: r.ruleCondition,
Labels: r.labels.Map(),
Annotations: r.annotations.Map(),
}
byt, err := json.Marshal(ar)

View File

@@ -63,6 +63,7 @@ func TestThresholdRuleEvalWithoutRecoveryTarget(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: "critical",
TargetValue: &c.target,
MatchType: c.matchType,
CompareOperator: c.compareOperator,
@@ -171,10 +172,7 @@ func TestPrepareParamsForLogs(t *testing.T) {
},
},
},
CompareOperator: ruletypes.ValueIsNotEq,
MatchType: ruletypes.AtleastOnce,
Target: &[]float64{0.0}[0],
SelectedQuery: "A",
SelectedQuery: "A",
},
}
@@ -183,9 +181,10 @@ func TestPrepareParamsForLogs(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
TargetValue: postableRule.RuleCondition.Target,
MatchType: postableRule.RuleCondition.MatchType,
CompareOperator: postableRule.RuleCondition.CompareOperator,
Name: "critical",
TargetValue: &[]float64{0.0}[0],
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsNotEq,
},
},
}
@@ -239,10 +238,7 @@ func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
},
},
},
CompareOperator: ruletypes.ValueIsNotEq,
MatchType: ruletypes.AtleastOnce,
Target: &[]float64{0.0}[0],
SelectedQuery: "A",
SelectedQuery: "A",
},
Version: "v5",
}
@@ -252,9 +248,10 @@ func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
TargetValue: postableRule.RuleCondition.Target,
MatchType: postableRule.RuleCondition.MatchType,
CompareOperator: postableRule.RuleCondition.CompareOperator,
Name: "critical",
TargetValue: &[]float64{0.0}[0],
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsNotEq,
},
},
}
@@ -304,10 +301,7 @@ func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
},
},
},
CompareOperator: ruletypes.ValueIsNotEq,
MatchType: ruletypes.AtleastOnce,
Target: &[]float64{0.0}[0],
SelectedQuery: "A",
SelectedQuery: "A",
},
Version: "v5",
}
@@ -317,9 +311,10 @@ func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
TargetValue: postableRule.RuleCondition.Target,
MatchType: postableRule.RuleCondition.MatchType,
CompareOperator: postableRule.RuleCondition.CompareOperator,
Name: "critical",
TargetValue: &[]float64{0.0}[0],
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsNotEq,
},
},
}
@@ -366,10 +361,7 @@ func TestPrepareParamsForTraces(t *testing.T) {
},
},
},
CompareOperator: ruletypes.ValueIsNotEq,
MatchType: ruletypes.AtleastOnce,
Target: &[]float64{0.0}[0],
SelectedQuery: "A",
SelectedQuery: "A",
},
}
@@ -378,9 +370,10 @@ func TestPrepareParamsForTraces(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
TargetValue: postableRule.RuleCondition.Target,
MatchType: postableRule.RuleCondition.MatchType,
CompareOperator: postableRule.RuleCondition.CompareOperator,
Name: "critical",
TargetValue: &[]float64{0.0}[0],
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsNotEq,
},
},
}
@@ -470,6 +463,7 @@ func TestThresholdRuleLabelNormalization(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: "critical",
TargetValue: &c.target,
MatchType: c.matchType,
CompareOperator: c.compareOperator,
@@ -653,11 +647,7 @@ func TestThresholdRuleUnitCombinations(t *testing.T) {
ExpectQuery(queryString).
WithArgs(nil, nil, nil, nil, nil, nil, nil, nil).
WillReturnRows(rows)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -867,11 +857,7 @@ func TestThresholdRuleTracesLink(t *testing.T) {
querier := prepareQuerierForTraces(t, telemetryStore, keysMap)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -975,11 +961,7 @@ func TestThresholdRuleAITracesLink(t *testing.T) {
querier := prepareQuerierForAITraces(t, telemetryStore, keysMap)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -1083,11 +1065,7 @@ func TestThresholdRuleLogsLink(t *testing.T) {
querier := prepareQuerierForLogs(t, telemetryStore, keysMap)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -1256,11 +1234,7 @@ func TestMultipleThresholdRule(t *testing.T) {
querier, mockMetadataStore := prepareQuerierForMetrics(t, telemetryStore)
mockMetadataStore.TypeMap["signoz_calls_total"] = metrictypes.SumType
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -1930,8 +1904,6 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
@@ -2050,7 +2022,6 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
mockMetadataStore.TypeMap["signoz_calls_total"] = metrictypes.SumType
rc := postableRule.RuleCondition
rc.Target = &c.target
rc.RequireMinPoints = c.requireMinPoints
rc.RequiredNumPoints = c.requiredNumPoints
rc.Thresholds = &ruletypes.RuleThresholdData{
@@ -2059,8 +2030,8 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
{
Name: postableRule.AlertName.StringValue(),
TargetValue: &c.target,
MatchType: rc.MatchType,
CompareOperator: rc.CompareOperator,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
},
},
}

View File

@@ -3,7 +3,6 @@ package ruler
import "net/http"
type Handler interface {
ListRules(http.ResponseWriter, *http.Request)
ListRulesV3(http.ResponseWriter, *http.Request)
GetRuleByID(http.ResponseWriter, *http.Request)
CreateRule(http.ResponseWriter, *http.Request)

View File

@@ -25,24 +25,6 @@ func NewHandler(ruler ruler.Ruler) ruler.Handler {
return &handler{ruler: ruler}
}
func (handler *handler) ListRules(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
rules, err := handler.ruler.ListRuleStates(ctx)
if err != nil {
render.Error(rw, err)
return
}
view := make([]*ruletypes.Rule, 0, len(rules.Rules))
for _, rule := range rules.Rules {
view = append(view, ruletypes.NewRule(rule))
}
render.Success(rw, http.StatusOK, view)
}
func (handler *handler) ListRulesV3(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
@@ -72,7 +54,7 @@ func (handler *handler) GetRuleByID(rw http.ResponseWriter, req *http.Request) {
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
return
}
@@ -111,7 +93,7 @@ func (handler *handler) CloneRuleByID(rw http.ResponseWriter, req *http.Request)
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
return
}
@@ -130,7 +112,7 @@ func (handler *handler) UpdateRuleByID(rw http.ResponseWriter, req *http.Request
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
return
}
@@ -156,7 +138,7 @@ func (handler *handler) DeleteRuleByID(rw http.ResponseWriter, req *http.Request
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
return
}
@@ -175,7 +157,7 @@ func (handler *handler) PatchRuleByID(rw http.ResponseWriter, req *http.Request)
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
return
}
@@ -279,7 +261,7 @@ func (handler *handler) GetDowntimeScheduleByID(rw http.ResponseWriter, req *htt
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
return
}
@@ -328,7 +310,7 @@ func (handler *handler) UpdateDowntimeScheduleByID(rw http.ResponseWriter, req *
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
return
}
@@ -358,7 +340,7 @@ func (handler *handler) DeleteDowntimeScheduleByID(rw http.ResponseWriter, req *
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
return
}
@@ -448,7 +430,7 @@ func (handler *handler) UpdateRuleView(rw http.ResponseWriter, req *http.Request
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
return
}
@@ -484,7 +466,7 @@ func (handler *handler) DeleteRuleView(rw http.ResponseWriter, req *http.Request
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
return
}

View File

@@ -272,6 +272,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddDowntimeTuplesFactory(sqlstore),
sqlmigration.NewAddRuleTuplesFactory(sqlstore),
sqlmigration.NewRestructureJiraChannelSpecFactory(sqlstore),
sqlmigration.NewMigrateRulesV1ToV2Alpha1Factory(sqlstore),
)
}

View File

@@ -0,0 +1,629 @@
package sqlmigration
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"maps"
"slices"
"strings"
"time"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
)
const (
schemaVersionV2Alpha1 = "v2alpha1"
ruleTypeThreshold = "threshold_rule"
ruleTypeProm = "promql_rule"
ruleTypeAnomaly = "anomaly_rule"
queryTypeBuilder = "builder"
queryTypePromQL = "promql"
queryTypeClickHouseSQL = "clickhouse_sql"
panelTypeGraph = "graph"
alertTypeMetric = "METRIC_BASED_ALERT"
alertTypeTraces = "TRACES_BASED_ALERT"
alertTypeLogs = "LOGS_BASED_ALERT"
signalLogs = "logs"
signalTraces = "traces"
compareOperatorBelow = "2"
criticalThresholdName = "critical"
labelSeverity = "severity"
defaultEvalWindow = "5m"
defaultFrequency = "1m"
)
// The values the write path accepts; a migrated rule outside them is written
// disabled, for the user to fix.
var (
supportedCompareOperators = []string{"1", "above", ">", "2", "below", "<", "3", "equal", "eq", "=", "4", "not_equal", "not_eq", "!=", "5", "above_or_equal", "above_or_eq", ">=", "6", "below_or_equal", "below_or_eq", "<=", "7", "outside_bounds"}
supportedMatchTypes = []string{"1", "at_least_once", "2", "all_the_times", "3", "on_average", "avg", "4", "in_total", "sum", "5", "last"}
)
// The values the write path accepts; a migrated row outside them cannot be
// edited until the field is supplied, so the migration derives them.
var (
supportedAlertTypes = []string{"METRIC_BASED_ALERT", "TRACES_BASED_ALERT", "LOGS_BASED_ALERT", "EXCEPTIONS_BASED_ALERT", "AI_TRACES_BASED_ALERT"}
supportedQueryTypes = []string{"builder", "clickhouse_sql", "promql"}
supportedPanelTypes = []string{"value", "table", "graph"}
)
type migrateRulesV1ToV2Alpha1 struct {
sqlstore sqlstore.SQLStore
logger *slog.Logger
}
func NewMigrateRulesV1ToV2Alpha1Factory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("migrate_rules_v1_to_v2alpha1"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &migrateRulesV1ToV2Alpha1{sqlstore: sqlstore, logger: ps.Logger}, nil
})
}
func (migration *migrateRulesV1ToV2Alpha1) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
// Up rewrites every v1 rule in every org as v2alpha1 in place. A row that does
// not decode, or a v1 rule that cannot be rewritten, is logged and left as is;
// a failed write aborts the run and rolls back every org.
func (migration *migrateRulesV1ToV2Alpha1) Up(ctx context.Context, _ *bun.DB) error {
return migration.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
var orgIDs []string
if err := migration.sqlstore.BunDBCtx(ctx).NewSelect().Table("organizations").Column("id").Scan(ctx, &orgIDs); err != nil {
return err
}
for _, orgID := range orgIDs {
if err := migration.convertOrg(ctx, orgID); err != nil {
return err
}
}
return nil
})
}
func (migration *migrateRulesV1ToV2Alpha1) Down(context.Context, *bun.DB) error {
return nil
}
func (migration *migrateRulesV1ToV2Alpha1) convertOrg(ctx context.Context, orgID string) error {
db := migration.sqlstore.BunDBCtx(ctx)
type ruleRow struct {
bun.BaseModel `bun:"table:rule"`
ID string `bun:"id,pk"`
OrgID string `bun:"org_id"`
Data string `bun:"data"`
}
allRules := make([]*ruleRow, 0)
if err := db.NewSelect().Model(&allRules).Where("org_id = ?", orgID).Scan(ctx); err != nil {
return err
}
allRoutePolicies := make([]*routePolicyRow, 0)
if err := db.NewSelect().Model(&allRoutePolicies).Where("org_id = ?", orgID).Scan(ctx); err != nil {
return err
}
// the name is not unique; a v1 rule has one threshold so one row, and any
// duplicate is as good as another
routePolicyNameToRoute := make(map[string]*routePolicyRow)
for _, route := range allRoutePolicies {
routePolicyNameToRoute[route.Name] = route
}
var migrated, disabled, skipped, undecodable, unmigratable int
for _, storedRule := range allRules {
storedRuleData := new(postableRule)
if err := json.Unmarshal([]byte(storedRule.Data), storedRuleData); err != nil {
undecodable++
migration.logger.WarnContext(ctx, "failed to decode stored rule", slog.String("org_id", orgID), slog.String("rule_id", storedRule.ID), errors.Attr(err))
continue
}
if storedRuleData.SchemaVersion == schemaVersionV2Alpha1 {
skipped++
continue
}
storedRoute := routePolicyNameToRoute[storedRule.ID]
if err := validateRoute(storedRoute, storedRuleData.PreferredChannels); err != nil {
unmigratable++
migration.logger.WarnContext(ctx, "leaving rule at v1", slog.String("org_id", orgID), slog.String("rule_id", storedRule.ID), errors.Attr(err))
continue
}
convertedRule, convertedRoute := convertRule(storedRule.ID, storedRuleData, storedRoute)
if cause := convertedRule.disableIfNeverFired(); cause != "" {
disabled++
migration.logger.WarnContext(ctx, "migrating rule as disabled, it never fired in v1", slog.String("org_id", orgID), slog.String("rule_id", storedRule.ID), slog.String("cause", cause))
}
data, err := marshalUnescaped(convertedRule)
if err != nil {
return err
}
storedRule.Data = string(data)
if _, err := db.NewUpdate().Model(storedRule).Column("data").WherePK().Exec(ctx); err != nil {
return err
}
if convertedRoute != nil && convertedRoute.Expression != storedRoute.Expression {
if _, err := db.NewUpdate().Model(convertedRoute).Column("expression").WherePK().Exec(ctx); err != nil {
return err
}
}
migrated++
}
migration.logger.InfoContext(ctx, "migrated rules from v1 to v2alpha1", slog.String("org_id", orgID), slog.Int("total", len(allRules)), slog.Int("migrated", migrated), slog.Int("disabled", disabled), slog.Int("skipped", skipped), slog.Int("undecodable", undecodable), slog.Int("unmigratable", unmigratable))
return nil
}
// validateRoute checks the rule's route_policy row, named after the rule. A
// rule that never had channels got no row from migration 049 and migrates with
// none; a rule with preferred channels but no row would lose them, so it is
// left at v1.
func validateRoute(route *routePolicyRow, preferredChannels []string) error {
if route == nil && len(preferredChannels) > 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "no route_policy row for a rule with %d preferred channels", len(preferredChannels))
}
return nil
}
// convertRule returns a v2alpha1 copy of storedRuleData, derived in the order
// the v1 decode derived the same values, with the rule's route_policy row as it
// must be stored alongside. Version and source are left as stored. A nil
// storedRoute is a rule that never had channels.
func convertRule(ruleID string, storedRuleData *postableRule, storedRoute *routePolicyRow) (*postableRule, *routePolicyRow) {
channels := []string{}
if storedRoute != nil {
channels = storedRoute.Channels
}
rule := storedRuleData.clone()
rule.fillDurationDefaults()
convertedRoute := storedRoute
// a null condition is written back null: nothing derives from it, the v2
// read path rejects it as v1 did, and the list still shows the rule, disabled
if rule.RuleCondition != nil {
rule.deriveAlertType()
rule.deriveQueryType()
rule.repairPanelType()
rule.disableUnmetMinPoints()
rule.deriveRuleType()
rule.deriveSelectedQuery()
rule.negateAnomalyBelowTarget()
convertedRoute = rule.repairEmptySeverity(ruleID, storedRoute)
rule.relocateThreshold(channels)
}
rule.relocateEvaluation()
rule.synthesizeNotificationSettings()
rule.SchemaVersion = schemaVersionV2Alpha1
rule.clearV1Fields()
return rule, convertedRoute
}
type routePolicyRow struct {
bun.BaseModel `bun:"table:route_policy"`
ID string `bun:"id,pk"`
OrgID string `bun:"org_id"`
Name string `bun:"name"`
Expression string `bun:"expression"`
Channels []string `bun:"channels,type:jsonb"`
}
// postableRule mirrors PostableRule key for key, in its order. Keys the
// crosswalk reads or writes are typed; the rest pass through as stored.
type postableRule struct {
AlertName json.RawMessage `json:"alert,omitempty"`
AlertType string `json:"alertType"`
Description json.RawMessage `json:"description,omitempty"`
RuleType string `json:"ruleType,omitempty"`
EvalWindow textDuration `json:"evalWindow,omitzero"`
Frequency textDuration `json:"frequency,omitzero"`
RuleCondition *ruleCondition `json:"condition"`
Labels map[string]string `json:"labels,omitempty"`
Annotations json.RawMessage `json:"annotations,omitempty"`
Disabled json.RawMessage `json:"disabled,omitempty"`
Source json.RawMessage `json:"source,omitempty"`
PreferredChannels []string `json:"preferredChannels,omitempty"`
Version json.RawMessage `json:"version,omitempty"`
Evaluation *evaluationEnvelope `json:"evaluation,omitempty"`
SchemaVersion string `json:"schemaVersion,omitempty"`
NotificationSettings *notificationSettings `json:"notificationSettings,omitempty"`
}
type ruleCondition struct {
CompositeQuery *compositeQuery `json:"compositeQuery"`
CompareOperator string `json:"op,omitempty"`
Target *float64 `json:"target,omitempty"`
AlertOnAbsent bool `json:"alertOnAbsent,omitempty"`
AbsentFor json.RawMessage `json:"absentFor,omitempty"`
MatchType string `json:"matchType,omitempty"`
TargetUnit string `json:"targetUnit,omitempty"`
Seasonality string `json:"seasonality,omitempty"`
SelectedQuery string `json:"selectedQueryName,omitempty"`
RequireMinPoints json.RawMessage `json:"requireMinPoints,omitempty"`
RequiredNumPoints json.RawMessage `json:"requiredNumPoints,omitempty"`
Thresholds *ruleThresholds `json:"thresholds,omitempty"`
}
type compositeQuery struct {
Queries json.RawMessage `json:"queries,omitempty"`
PanelType string `json:"panelType"`
QueryType string `json:"queryType"`
Unit json.RawMessage `json:"unit,omitempty"`
}
// ruleQueryEnvelope reads the envelope keys the derivations need.
type ruleQueryEnvelope struct {
Type string `json:"type"`
Spec struct {
Name string `json:"name"`
Signal string `json:"signal"`
Disabled bool `json:"disabled"`
} `json:"spec"`
}
type ruleThresholds struct {
Kind string `json:"kind"`
Spec []basicThreshold `json:"spec"`
}
type basicThreshold struct {
Name string `json:"name"`
TargetValue *float64 `json:"target"`
TargetUnit string `json:"targetUnit"`
RecoveryTarget *float64 `json:"recoveryTarget"`
MatchType string `json:"matchType"`
CompareOperator string `json:"op"`
Channels []string `json:"channels"`
}
type evaluationEnvelope struct {
Kind string `json:"kind"`
Spec rollingWindow `json:"spec"`
}
type rollingWindow struct {
EvalWindow textDuration `json:"evalWindow"`
Frequency textDuration `json:"frequency"`
}
type notificationSettings struct {
Renotify *renotify `json:"renotify,omitempty"`
UsePolicy bool `json:"usePolicy"`
}
type renotify struct {
Enabled bool `json:"enabled"`
ReNotifyInterval textDuration `json:"interval,omitzero"`
AlertStates []string `json:"alertStates,omitzero"`
}
// clone copies what the conversion writes to: the rule, its condition, the
// composite query and the labels. Raw keys share their bytes.
func (rule *postableRule) clone() *postableRule {
cloned := *rule
if rule.RuleCondition != nil {
condition := *rule.RuleCondition
if condition.CompositeQuery != nil {
query := *condition.CompositeQuery
condition.CompositeQuery = &query
}
cloned.RuleCondition = &condition
}
cloned.Labels = maps.Clone(rule.Labels)
return &cloned
}
// disableIfNeverFired disables a rule v1 stored but could never fire: a
// negative evalWindow or frequency, which is reset to its default since no
// read of a rolling window accepts it, a null condition or compositeQuery, one
// with no queries or with every query disabled, or a threshold with a null target or
// an op or matchType outside the supported sets, which the v2 write path
// rejects. It reads the relocated fields, so it runs after convertRule, and
// returns the offending fields, or "" when the rule stays enabled.
func (rule *postableRule) disableIfNeverFired() string {
var causes []string
window := &rule.Evaluation.Spec
if !window.EvalWindow.IsPositive() {
causes = append(causes, fmt.Sprintf("evalWindow %s is negative", window.EvalWindow))
window.EvalWindow = mustParseTextDuration(defaultEvalWindow)
}
if !window.Frequency.IsPositive() {
causes = append(causes, fmt.Sprintf("frequency %s is negative", window.Frequency))
window.Frequency = mustParseTextDuration(defaultFrequency)
}
switch {
case rule.RuleCondition == nil:
causes = append(causes, "condition is null")
case rule.RuleCondition.CompositeQuery == nil:
causes = append(causes, "condition.compositeQuery is null")
case len(rule.decodeQueryEnvelopes()) == 0:
causes = append(causes, "condition.compositeQuery.queries is empty")
case rule.RuleCondition.SelectedQuery == "":
causes = append(causes, "condition.selectedQueryName is omitted and every query is disabled")
}
if rule.RuleCondition != nil {
threshold := rule.RuleCondition.Thresholds.Spec[0]
switch {
case threshold.TargetValue == nil:
causes = append(causes, "condition.target is null")
case !slices.Contains(supportedCompareOperators, threshold.CompareOperator):
causes = append(causes, fmt.Sprintf("condition.op: unsupported value %q", threshold.CompareOperator))
case !slices.Contains(supportedMatchTypes, threshold.MatchType):
causes = append(causes, fmt.Sprintf("condition.matchType: unsupported value %q", threshold.MatchType))
}
}
if len(causes) == 0 {
return ""
}
rule.Disabled = json.RawMessage("true")
return strings.Join(causes, ", ")
}
func (rule *postableRule) fillDurationDefaults() {
if rule.EvalWindow.IsZero() {
rule.EvalWindow = mustParseTextDuration(defaultEvalWindow)
}
if rule.Frequency.IsZero() {
rule.Frequency = mustParseTextDuration(defaultFrequency)
}
}
func (rule *postableRule) deriveAlertType() {
if slices.Contains(supportedAlertTypes, rule.AlertType) {
return
}
// promql, clickhouse and formula queries carry no signal; they alert on metrics
rule.AlertType = alertTypeMetric
for _, envelope := range rule.decodeQueryEnvelopes() {
switch envelope.Spec.Signal {
case signalLogs:
rule.AlertType = alertTypeLogs
return
case signalTraces:
rule.AlertType = alertTypeTraces
return
}
}
}
func (rule *postableRule) deriveQueryType() {
query := rule.RuleCondition.CompositeQuery
if query == nil || slices.Contains(supportedQueryTypes, query.QueryType) {
return
}
envelopes := rule.decodeQueryEnvelopes()
if len(envelopes) == 0 {
return
}
// a promql or clickhouse_sql envelope spells its type like the composite query does; every other envelope is a builder one
switch envelopes[0].Type {
case queryTypePromQL, queryTypeClickHouseSQL:
query.QueryType = envelopes[0].Type
default:
query.QueryType = queryTypeBuilder
}
}
func (rule *postableRule) repairPanelType() {
query := rule.RuleCondition.CompositeQuery
// nothing reads panelType; both alert forms write graph
if query != nil && !slices.Contains(supportedPanelTypes, query.PanelType) {
query.PanelType = panelTypeGraph
}
}
func (rule *postableRule) disableUnmetMinPoints() {
condition := rule.RuleCondition
if string(condition.RequireMinPoints) != "true" {
return
}
points := 0.0
if len(condition.RequiredNumPoints) > 0 {
if err := json.Unmarshal(condition.RequiredNumPoints, &points); err != nil {
return
}
}
if points > 0 {
return
}
// zero required points is a check every series passes
condition.RequireMinPoints = json.RawMessage("false")
}
func (rule *postableRule) decodeQueryEnvelopes() []ruleQueryEnvelope {
query := rule.RuleCondition.CompositeQuery
if query == nil {
return nil
}
var envelopes []ruleQueryEnvelope
if err := json.Unmarshal(query.Queries, &envelopes); err != nil {
return nil
}
return envelopes
}
func (rule *postableRule) deriveRuleType() {
query := rule.RuleCondition.CompositeQuery
if query == nil {
return
}
switch query.QueryType {
case queryTypeBuilder, queryTypeClickHouseSQL:
if rule.RuleType == "" {
rule.RuleType = ruleTypeThreshold
}
case queryTypePromQL:
rule.RuleType = ruleTypeProm
}
}
// deriveSelectedQuery fills an omitted selectedQueryName the way v1 resolved it
// at evaluation: F1 when enabled, else the enabled query with the highest name.
func (rule *postableRule) deriveSelectedQuery() {
condition := rule.RuleCondition
if condition.SelectedQuery != "" {
return
}
for _, envelope := range rule.decodeQueryEnvelopes() {
if envelope.Spec.Disabled {
continue
}
if envelope.Spec.Name == "F1" {
condition.SelectedQuery = "F1"
return
}
condition.SelectedQuery = max(condition.SelectedQuery, envelope.Spec.Name)
}
}
// negateAnomalyBelowTarget stores "below 3" as -3, since anomaly scores are
// signed. Only the raw "2" negates; "below" and "<" keep the sign the rule
// evaluates with today.
func (rule *postableRule) negateAnomalyBelowTarget() {
condition := rule.RuleCondition
if rule.RuleType != ruleTypeAnomaly || condition.CompareOperator != compareOperatorBelow || condition.Target == nil {
return
}
negated := -*condition.Target
condition.Target = &negated
}
// repairEmptySeverity rewrites a severity of "" as critical in labels and in
// the route expression that matches alerts on it, since a threshold named ""
// fails every later read. It returns storedRoute, or a copy with the new
// expression.
func (rule *postableRule) repairEmptySeverity(ruleID string, storedRoute *routePolicyRow) *routePolicyRow {
if severity, ok := rule.Labels[labelSeverity]; !ok || severity != "" {
return storedRoute
}
rule.Labels[labelSeverity] = criticalThresholdName
if storedRoute == nil {
return nil
}
route := *storedRoute
route.Expression = fmt.Sprintf(`threshold.name == "%s" && ruleId == "%s"`, criticalThresholdName, ruleID)
return &route
}
func (rule *postableRule) relocateThreshold(channels []string) {
thresholdName := criticalThresholdName
if severity, ok := rule.Labels[labelSeverity]; ok {
thresholdName = severity
}
condition := rule.RuleCondition
condition.Thresholds = &ruleThresholds{
Kind: "basic",
Spec: []basicThreshold{{
Name: thresholdName,
TargetValue: condition.Target,
TargetUnit: condition.TargetUnit,
MatchType: condition.MatchType,
CompareOperator: condition.CompareOperator,
Channels: channels,
}},
}
}
func (rule *postableRule) relocateEvaluation() {
rule.Evaluation = &evaluationEnvelope{
Kind: "rolling",
Spec: rollingWindow{EvalWindow: rule.EvalWindow, Frequency: rule.Frequency},
}
}
func (rule *postableRule) synthesizeNotificationSettings() {
alertStates := []string{"firing"}
if rule.RuleCondition != nil && rule.RuleCondition.AlertOnAbsent {
alertStates = append(alertStates, "nodata")
}
rule.NotificationSettings = &notificationSettings{
Renotify: &renotify{
Enabled: true,
ReNotifyInterval: mustParseTextDuration("4h"),
AlertStates: alertStates,
},
UsePolicy: false,
}
}
func (rule *postableRule) clearV1Fields() {
rule.EvalWindow = textDuration{}
rule.Frequency = textDuration{}
rule.PreferredChannels = nil
if condition := rule.RuleCondition; condition != nil {
condition.Target = nil
condition.CompareOperator = ""
condition.MatchType = ""
condition.TargetUnit = ""
}
}
// textDuration decodes a duration the way the app's TextDuration does, a
// string or a number of nanoseconds, and writes a string back in its stored
// spelling.
type textDuration struct {
text string
value time.Duration
}
func mustParseTextDuration(text string) textDuration {
value, err := time.ParseDuration(text)
if err != nil {
panic(err)
}
return textDuration{text: text, value: value}
}
func (d textDuration) IsZero() bool {
return d.value == 0
}
func (d textDuration) IsPositive() bool {
return d.value > 0
}
func (d textDuration) String() string {
if d.text != "" {
return d.text
}
return d.value.String()
}
func (d textDuration) MarshalJSON() ([]byte, error) {
return json.Marshal(d.String())
}
func (d *textDuration) UnmarshalJSON(data []byte) error {
var raw any
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
switch value := raw.(type) {
case float64:
*d = textDuration{value: time.Duration(value)}
return nil
case string:
parsed, err := time.ParseDuration(value)
if err != nil {
return err
}
*d = textDuration{text: value, value: parsed}
return nil
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid duration %s", data)
}
}

View File

@@ -619,10 +619,6 @@ type ConfigStore interface {
// ListAllChannels returns the list of channels for all organizations.
ListAllChannels(context.Context) ([]*Channel, error)
// GetMatchers gets a list of matchers per organization.
// Matchers is an array of ruleId to receiver names.
GetMatchers(context.Context, string) (map[string][]string, error)
}
// MarshalSecretValue if set to true will expose Secret type

View File

@@ -32,5 +32,10 @@ func (q *Query) Validate() error {
if q.Order != qbtypes.OrderDirectionAsc && q.Order != qbtypes.OrderDirectionDesc {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "order must be asc or desc")
}
if !q.State.IsZero() {
if err := q.State.Validate(); err != nil {
return err
}
}
return nil
}

View File

@@ -1,6 +1,9 @@
package ruletypes
import "github.com/SigNoz/signoz/pkg/valuer"
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
type AlertState struct {
valuer.String
@@ -26,6 +29,15 @@ func (AlertState) Enum() []any {
}
}
func (a AlertState) Validate() error {
switch a {
case StateInactive, StatePending, StateRecovering, StateFiring, StateNoData, StateDisabled:
return nil
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "state: unsupported value %q; must be one of inactive, pending, recovering, firing, nodata, disabled", a.StringValue())
}
}
var alertStateSeverity = map[AlertState]int{
StateInactive: 0,
StatePending: 1,

View File

@@ -5,6 +5,7 @@ import (
"sort"
"time"
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -77,6 +78,15 @@ func (PanelType) Enum() []any {
}
}
func (p PanelType) Validate() error {
switch p {
case PanelTypeValue, PanelTypeTable, PanelTypeGraph:
return nil
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery.panelType: unsupported value %q; must be one of value, table, graph", p.StringValue())
}
}
// Note: this is used to represent the state of the alert query
// i.e the active tab which should be used to represent the selection
@@ -99,6 +109,15 @@ func (QueryType) Enum() []any {
}
}
func (q QueryType) Validate() error {
switch q {
case QueryTypeBuilder, QueryTypeClickHouseSQL, QueryTypePromQL:
return nil
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery.queryType: unsupported value %q; must be one of builder, clickhouse_sql, promql", q.StringValue())
}
}
type AlertCompositeQuery struct {
Queries []qbtypes.QueryEnvelope `json:"queries" required:"true" description:"The queries to evaluate, at least one. Every envelope type must match queryType: builder_query and builder_formula for builder, promql for promql, clickhouse_sql for clickhouse_sql."`
@@ -109,18 +128,13 @@ type AlertCompositeQuery struct {
type RuleCondition struct {
CompositeQuery *AlertCompositeQuery `json:"compositeQuery" required:"true" description:"The queries whose result is compared against the thresholds."`
CompareOperator CompareOperator `json:"op,omitzero" description:"schemaVersion v1 only: how the value is compared with target. Accepted as above, below, equal, not_equal, above_or_equal, below_or_equal or outside_bounds, their symbols, or the legacy codes as strings, '1' to '7'. Required for v1; v2alpha1 sets op on each threshold."`
Target *float64 `json:"target,omitempty" format:"double" description:"schemaVersion v1 only: the value compared against, in targetUnit. Required for v1; v2alpha1 sets target on each threshold."`
AlertOnAbsent bool `json:"alertOnAbsent,omitempty" description:"Whether to fire a nodata alert when the query returns no data. Defaults to false."`
AbsentFor uint64 `json:"absentFor,omitempty" description:"How many minutes without data pass before the nodata alert fires. Ignored unless alertOnAbsent is true."`
MatchType MatchType `json:"matchType,omitzero" description:"schemaVersion v1 only: which points in the window must satisfy op. Accepted as at_least_once, all_the_times, on_average, in_total or last, their aliases avg and sum, or the legacy codes as strings, '1' to '5'. Required for v1; v2alpha1 sets matchType on each threshold."`
TargetUnit string `json:"targetUnit,omitempty" description:"schemaVersion v1 only: the unit of target, such as percent or ms. Converted to compositeQuery.unit before comparison when the two differ."`
Algorithm string `json:"algorithm,omitempty" description:"anomaly_rule only: the anomaly detection algorithm. The supported value is standard."`
AlertOnAbsent bool `json:"alertOnAbsent" description:"Whether to fire a nodata alert when the query returns no data. Defaults to false."`
AbsentFor uint64 `json:"absentFor" description:"How many minutes without data pass before the nodata alert fires. Ignored unless alertOnAbsent is true."`
Seasonality Seasonality `json:"seasonality,omitzero" description:"anomaly_rule only: the seasonal period the history is compared against, hourly, daily or weekly."`
SelectedQuery string `json:"selectedQueryName,omitempty" required:"true" description:"The name of the query whose result is evaluated, such as A or F1. Must match a query in compositeQuery."`
SelectedQuery string `json:"selectedQueryName" required:"true" description:"The name of the query whose result is evaluated, such as A or F1. Must match a query in compositeQuery."`
RequireMinPoints bool `json:"requireMinPoints,omitempty" description:"Whether a series is skipped until it has at least requiredNumPoints points in the window. Defaults to false."`
RequiredNumPoints int `json:"requiredNumPoints,omitempty" description:"The minimum number of points a series needs before it is evaluated. Must be greater than 0 when requireMinPoints is true."`
Thresholds *RuleThresholdData `json:"thresholds,omitempty" required:"true" description:"The thresholds to evaluate, as a kind and spec."`
Thresholds *RuleThresholdData `json:"thresholds" required:"true" description:"The thresholds to evaluate, as a kind and spec."`
}
func (rc *RuleCondition) SelectedQueryName() string {

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"slices"
"strings"
"time"
"unicode/utf8"
@@ -12,6 +13,7 @@ import (
"github.com/swaggest/jsonschema-go"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
@@ -49,7 +51,6 @@ func (t AlertType) BuilderQueryType() qbtypes.QueryType {
}
const (
DefaultSchemaVersion = "v1"
SchemaVersionV2Alpha1 = "v2alpha1"
)
@@ -61,12 +62,10 @@ const (
// PostableRule is used to create alerting rule from HTTP api.
type PostableRule struct {
AlertName valuer.DisplayName `json:"alert" required:"true" description:"The display name of the alert rule."`
AlertType AlertType `json:"alertType" required:"true" description:"The signal the rule evaluates: METRIC_BASED_ALERT, TRACES_BASED_ALERT, LOGS_BASED_ALERT, EXCEPTIONS_BASED_ALERT or AI_TRACES_BASED_ALERT."`
Description string `json:"description,omitempty" description:"A human readable description of what the rule monitors."`
RuleType RuleType `json:"ruleType" required:"true" description:"The evaluation strategy: threshold_rule compares against thresholds, promql_rule evaluates a PromQL expression, anomaly_rule scores deviation from seasonal history. Defaults to threshold_rule for builder and clickhouse_sql queries and is forced to promql_rule for promql queries."`
EvalWindow valuer.TextDuration `json:"evalWindow,omitzero" description:"The lookback window as a Go duration such as 5m. Only read when schemaVersion is v1, defaulting to 5m; v2alpha1 rules set it in evaluation.spec instead."`
Frequency valuer.TextDuration `json:"frequency,omitzero" description:"How often the rule is evaluated, as a Go duration such as 1m. Only read when schemaVersion is v1, defaulting to 1m; v2alpha1 rules set it in evaluation.spec instead."`
AlertName valuer.DisplayName `json:"alert" required:"true" description:"The display name of the alert rule."`
AlertType AlertType `json:"alertType" required:"true" description:"The signal the rule evaluates: METRIC_BASED_ALERT, TRACES_BASED_ALERT, LOGS_BASED_ALERT, EXCEPTIONS_BASED_ALERT or AI_TRACES_BASED_ALERT."`
Description string `json:"description,omitempty" description:"A human readable description of what the rule monitors."`
RuleType RuleType `json:"ruleType" required:"true" description:"The evaluation strategy: threshold_rule compares against thresholds, promql_rule evaluates a PromQL expression, anomaly_rule scores deviation from seasonal history. Defaults to threshold_rule for builder and clickhouse_sql queries and is forced to promql_rule for promql queries."`
RuleCondition *RuleCondition `json:"condition" required:"true" description:"The query to evaluate and the thresholds applied to its result."`
Labels map[string]string `json:"labels,omitempty" description:"Labels attached to every alert the rule fires. Keys match [a-zA-Z_.][a-zA-Z0-9_.]*; values may use Go templates such as {{$labels.service_name}}. severity is the conventional routing label."`
@@ -76,14 +75,12 @@ type PostableRule struct {
Source string `json:"source,omitempty" description:"The URL of the page the rule was created from. Informational; it is not used for evaluation."`
PreferredChannels []string `json:"preferredChannels,omitempty" description:"The displayName of each notification channel to notify, for schemaVersion v1 rules. Ignored for v2alpha1, where each threshold carries its own channels."`
Version string `json:"version" description:"The query payload version. Nothing reads it."`
Evaluation *EvaluationEnvelope `json:"evaluation,omitempty" required:"true" description:"The evaluation window, as a kind and spec."`
SchemaVersion string `json:"schemaVersion,omitempty" required:"true" enum:"v2alpha1" description:"The rule payload schema."`
Evaluation *EvaluationEnvelope `json:"evaluation" required:"true" description:"The evaluation window, as a kind and spec."`
SchemaVersion string `json:"schemaVersion" required:"true" enum:"v2alpha1" description:"The rule payload schema."`
NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty" required:"true" description:"Grouping, re-notification and routing behaviour."`
NotificationSettings *NotificationSettings `json:"notificationSettings" required:"true" description:"Grouping, re-notification and routing behaviour."`
}
type NotificationSettings struct {
@@ -228,101 +225,15 @@ func (ns *NotificationSettings) UnmarshalJSON(data []byte) error {
// processRuleDefaults applies the default values
// for the rule options that are blank or unset.
func (r *PostableRule) processRuleDefaults() {
if r.SchemaVersion == "" {
r.SchemaVersion = DefaultSchemaVersion
}
// TODO(srikanthccv): remove as this is now a legacy field
if r.Version == "" {
r.Version = "v5"
}
// v2alpha1 uses the Evaluation envelope for window/frequency;
// only default top-level fields for v1.
if r.SchemaVersion != SchemaVersionV2Alpha1 {
if r.EvalWindow.IsZero() {
r.EvalWindow = valuer.MustParseTextDuration("5m")
}
if r.Frequency.IsZero() {
r.Frequency = valuer.MustParseTextDuration("1m")
}
}
if r.RuleCondition != nil && r.RuleCondition.CompositeQuery != nil {
switch r.RuleCondition.CompositeQuery.QueryType {
case QueryTypeBuilder:
case QueryTypeBuilder, QueryTypeClickHouseSQL:
if r.RuleType.IsZero() {
r.RuleType = RuleTypeThreshold
}
case QueryTypePromQL:
r.RuleType = RuleTypeProm
}
if r.SchemaVersion == DefaultSchemaVersion {
thresholdName := CriticalThresholdName
if r.Labels != nil {
if severity, ok := r.Labels["severity"]; ok {
thresholdName = severity
}
}
// For anomaly detection with ValueIsBelow, negate the target
targetValue := r.RuleCondition.Target
if r.RuleType == RuleTypeAnomaly && r.RuleCondition.CompareOperator == ValueIsBelow && targetValue != nil {
negated := -1 * *targetValue
targetValue = &negated
}
thresholdData := RuleThresholdData{
Kind: BasicThresholdKind,
Spec: BasicRuleThresholds{{
Name: thresholdName,
TargetUnit: r.RuleCondition.TargetUnit,
TargetValue: targetValue,
MatchType: r.RuleCondition.MatchType,
CompareOperator: r.RuleCondition.CompareOperator,
Channels: r.PreferredChannels,
}},
}
r.RuleCondition.Thresholds = &thresholdData
r.Evaluation = &EvaluationEnvelope{RollingEvaluation, RollingWindow{EvalWindow: r.EvalWindow, Frequency: r.Frequency}}
r.NotificationSettings = &NotificationSettings{
Renotify: &Renotify{
Enabled: true,
ReNotifyInterval: valuer.MustParseTextDuration("4h"),
AlertStates: []AlertState{StateFiring},
},
}
if r.RuleCondition.AlertOnAbsent {
r.NotificationSettings.Renotify.AlertStates = append(r.NotificationSettings.Renotify.AlertStates, StateNoData)
}
}
}
}
func (r *PostableRule) MarshalJSON() ([]byte, error) {
type Alias PostableRule
switch r.SchemaVersion {
case DefaultSchemaVersion:
copyStruct := *r
aux := Alias(copyStruct)
if aux.RuleCondition != nil {
aux.RuleCondition.Thresholds = nil
}
aux.Evaluation = nil
aux.SchemaVersion = ""
aux.NotificationSettings = nil
return json.Marshal(aux)
case SchemaVersionV2Alpha1:
copyStruct := *r
aux := Alias(copyStruct)
return json.Marshal(aux)
default:
copyStruct := *r
aux := Alias(copyStruct)
return json.Marshal(aux)
}
}
@@ -336,6 +247,20 @@ func (r *PostableRule) UnmarshalJSON(bytes []byte) error {
return r.validate()
}
func (r *PostableRule) DecodeStrict(ruleStr string) error {
err := json.Unmarshal([]byte(ruleStr), r)
if err == nil {
// the alias has no UnmarshalJSON, so a strict pass sees every key the rule has no field for
type plainRule PostableRule
return binding.JSON.BindBody(strings.NewReader(ruleStr), new(plainRule), binding.WithDisallowUnknownFields(true), binding.WithUnknownFieldContext("alert rule"))
}
if errors.Ast(err, errors.TypeInvalidInput) {
return err
}
// a syntax error is raised before any UnmarshalJSON runs, so it is the one untyped error
return errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to parse json: %v", err)
}
var _ jsonschema.Preparer = PostableRule{}
// PrepareJSONSchema marks version and source deprecated: nothing reads them,
@@ -376,10 +301,6 @@ func (r *PostableRule) validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition: field is required")
}
if r.Version != "v5" {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "version: only v5 is supported, got %q", r.Version))
}
for k, v := range r.Labels {
if !isValidLabelName(k) {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid label name: %s", k))
@@ -423,11 +344,9 @@ func (r *PostableRule) Validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition: field is required")
}
if r.Version != "v5" {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "version: only v5 is supported, got %q", r.Version))
}
if r.AlertType != "" {
if r.AlertType == "" {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "alertType: field is required"))
} else {
switch r.AlertType {
case AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions, AlertTypeAITraces:
default:
@@ -452,6 +371,12 @@ func (r *PostableRule) Validate() error {
if r.RuleCondition.CompositeQuery == nil {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery: field is required"))
} else {
if err := r.RuleCondition.CompositeQuery.QueryType.Validate(); err != nil {
errs = append(errs, err)
}
if err := r.RuleCondition.CompositeQuery.PanelType.Validate(); err != nil {
errs = append(errs, err)
}
if len(r.RuleCondition.CompositeQuery.Queries) == 0 {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery.queries: must have at least one query"))
} else {
@@ -462,7 +387,9 @@ func (r *PostableRule) Validate() error {
}
}
if r.RuleCondition.SelectedQuery != "" && r.RuleCondition.CompositeQuery != nil && len(r.RuleCondition.CompositeQuery.Queries) > 0 {
if r.RuleCondition.SelectedQuery == "" {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.selectedQueryName: field is required"))
} else if r.RuleCondition.CompositeQuery != nil && len(r.RuleCondition.CompositeQuery.Queries) > 0 {
found := false
for _, query := range r.RuleCondition.CompositeQuery.Queries {
if query.GetQueryName() == r.RuleCondition.SelectedQuery {
@@ -514,74 +441,43 @@ func (r *PostableRule) Validate() error {
func (r *PostableRule) validateSchemaVersion() []error {
switch r.SchemaVersion {
case DefaultSchemaVersion:
return r.validateV1()
case SchemaVersionV2Alpha1:
return r.validateV2Alpha1()
default:
return []error{errors.NewInvalidInputf(errors.CodeInvalidInput,
"schemaVersion: unsupported value %q; must be one of %q, %q",
r.SchemaVersion, DefaultSchemaVersion, SchemaVersionV2Alpha1)}
"schemaVersion: unsupported value %q; must be %q",
r.SchemaVersion, SchemaVersionV2Alpha1)}
}
}
func (r *PostableRule) validateV1() []error {
var errs []error
if r.RuleCondition.Target == nil {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"condition.target: field is required for schemaVersion %q", DefaultSchemaVersion))
}
if r.RuleCondition.CompareOperator.IsZero() {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"condition.op: field is required for schemaVersion %q", DefaultSchemaVersion))
} else if err := r.RuleCondition.CompareOperator.Validate(); err != nil {
errs = append(errs, err)
}
if r.RuleCondition.MatchType.IsZero() {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"condition.matchType: field is required for schemaVersion %q", DefaultSchemaVersion))
} else if err := r.RuleCondition.MatchType.Validate(); err != nil {
errs = append(errs, err)
}
return errs
}
func (r *PostableRule) validateV2Alpha1() []error {
var errs []error
// TODO(srikanthccv): reject v1-only fields?
// if r.RuleCondition.Target != nil {
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
// "condition.target: field is not used in schemaVersion %q; set target in condition.thresholds entries instead",
// SchemaVersionV2Alpha1))
// }
// if !r.RuleCondition.CompareOperator.IsZero() {
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
// "condition.op: field is not used in schemaVersion %q; set op in condition.thresholds entries instead",
// SchemaVersionV2Alpha1))
// }
// if !r.RuleCondition.MatchType.IsZero() {
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
// "condition.matchType: field is not used in schemaVersion %q; set matchType in condition.thresholds entries instead",
// SchemaVersionV2Alpha1))
// }
// if len(r.PreferredChannels) > 0 {
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
// "preferredChannels: field is not used in schemaVersion %q; set channels in condition.thresholds entries instead",
// SchemaVersionV2Alpha1))
// }
// Require v2alpha1-specific fields
if r.RuleCondition.Thresholds == nil {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"condition.thresholds: field is required for schemaVersion %q", SchemaVersionV2Alpha1))
} else if threshold, err := r.RuleCondition.Thresholds.GetRuleThreshold(); err == nil {
if err := threshold.Validate(); err != nil {
errs = append(errs, err)
}
if thresholds, ok := threshold.(BasicRuleThresholds); ok {
if err := thresholds.validateUniqueNames(); err != nil {
errs = append(errs, err)
}
if err := thresholds.validateRecoveryTargets(); err != nil {
errs = append(errs, err)
}
}
}
if r.Evaluation == nil {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"evaluation: field is required for schemaVersion %q", SchemaVersionV2Alpha1))
} else if evaluation, err := r.Evaluation.GetEvaluation(); err == nil {
if err := evaluation.validateDurationText(); err != nil {
errs = append(errs, err)
}
}
if r.NotificationSettings == nil {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
@@ -591,11 +487,24 @@ func (r *PostableRule) validateV2Alpha1() []error {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"notificationSettings.renotify.interval: must be a positive duration when renotify is enabled"))
}
if r.NotificationSettings.Renotify != nil {
if err := validateDurationText("notificationSettings.renotify.interval", r.NotificationSettings.Renotify.ReNotifyInterval); err != nil {
errs = append(errs, err)
}
}
}
return errs
}
// validateDurationText rejects a bare number, which decodes as nanoseconds.
func validateDurationText(field string, duration valuer.TextDuration) error {
if duration.IsZero() || duration.HasText() {
return nil
}
return errors.NewInvalidInputf(errors.CodeInvalidInput, "%s: must be a duration string such as \"5m\"", field)
}
func testTemplateParsing(rl *PostableRule) (errs []error) {
if rl.AlertName.IsZero() {
// Not an alerting rule.
@@ -656,31 +565,6 @@ type GettableRule struct {
UpdatedBy *string `json:"updateBy" nullable:"true" description:"The identifier of the user who last updated the rule."`
}
func (g *GettableRule) MarshalJSON() ([]byte, error) {
type Alias GettableRule
switch g.SchemaVersion {
case DefaultSchemaVersion:
copyStruct := *g
aux := Alias(copyStruct)
if aux.RuleCondition != nil {
aux.RuleCondition.Thresholds = nil
}
aux.Evaluation = nil
aux.SchemaVersion = ""
aux.NotificationSettings = nil
return json.Marshal(aux)
case SchemaVersionV2Alpha1:
copyStruct := *g
aux := Alias(copyStruct)
return json.Marshal(aux)
default:
copyStruct := *g
aux := Alias(copyStruct)
return json.Marshal(aux)
}
}
// Rule is the v2 API read model for an alerting rule. It aligns audit fields
// with the canonical types.TimeAuditable / types.UserAuditable shape used by
// PlannedMaintenance and other entities. v1 handlers keep serializing
@@ -709,28 +593,3 @@ func NewRule(g *GettableRule) *Rule {
}
return r
}
func (r *Rule) MarshalJSON() ([]byte, error) {
type Alias Rule
switch r.SchemaVersion {
case DefaultSchemaVersion:
copyStruct := *r
aux := Alias(copyStruct)
if aux.RuleCondition != nil {
aux.RuleCondition.Thresholds = nil
}
aux.Evaluation = nil
aux.SchemaVersion = ""
aux.NotificationSettings = nil
return json.Marshal(aux)
case SchemaVersionV2Alpha1:
copyStruct := *r
aux := Alias(copyStruct)
return json.Marshal(aux)
default:
copyStruct := *r
aux := Alias(copyStruct)
return json.Marshal(aux)
}
}

View File

@@ -3,10 +3,8 @@ package ruletypes
import (
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
@@ -29,8 +27,7 @@ func TestParseIntoRule(t *testing.T) {
"alertType": "METRIC_BASED_ALERT",
"description": "Test description",
"ruleType": "threshold_rule",
"evalWindow": "5m",
"frequency": "1m",
"schemaVersion": "v2alpha1",
"version": "v5",
"condition": {
"compositeQuery": {
@@ -45,11 +42,11 @@ func TestParseIntoRule(t *testing.T) {
}
}]
},
"target": 10.0,
"matchType": "1",
"op": "1",
"selectedQuery": "A"
}
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 10.0, "matchType": "1", "op": "1"}]},
"selectedQueryName": "A"
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
}`),
kind: RuleDataKindJson,
expectError: false,
@@ -63,46 +60,6 @@ func TestParseIntoRule(t *testing.T) {
if rule.RuleCondition.Thresholds.Kind.IsZero() {
t.Error("Expected thresholds to be populated")
}
if rule.RuleCondition.Target == nil {
t.Error("Expected target to be populated")
}
},
},
{
name: "rule with default values applied",
initRule: PostableRule{},
content: []byte(`{
"alert": "DefaultsRule",
"ruleType": "threshold_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 5.0,
"matchType": "1",
"op": "1",
"selectedQuery": "A"
}
}`),
kind: RuleDataKindJson,
expectError: false,
validate: func(t *testing.T, rule *PostableRule) {
if rule.EvalWindow.Duration() != 5*time.Minute {
t.Errorf("Expected default eval window '5m', got '%v'", rule.EvalWindow)
}
if rule.Frequency.Duration() != time.Minute {
t.Errorf("Expected default frequency '1m', got '%v'", rule.Frequency)
}
},
},
{
@@ -110,6 +67,7 @@ func TestParseIntoRule(t *testing.T) {
initRule: PostableRule{},
content: []byte(`{
"alert": "PromQLRule",
"schemaVersion": "v2alpha1",
"version": "v5",
"condition": {
"compositeQuery": {
@@ -123,11 +81,11 @@ func TestParseIntoRule(t *testing.T) {
}
}]
},
"target": 10.0,
"matchType": "1",
"op": "1",
"selectedQuery": "A"
}
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 10.0, "matchType": "1", "op": "1"}]},
"selectedQueryName": "A"
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
}`),
kind: RuleDataKindJson,
expectError: false,
@@ -138,9 +96,6 @@ func TestParseIntoRule(t *testing.T) {
if rule.RuleCondition.Thresholds.Kind.IsZero() {
t.Error("Expected thresholds to be populated")
}
if rule.RuleCondition.Target == nil {
t.Error("Expected target to be populated")
}
},
},
}
@@ -177,233 +132,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
expectValidateError bool // Validate() error (write path only)
validate func(*testing.T, *PostableRule)
}{
{
name: "schema v1 - threshold name from severity label",
initRule: PostableRule{},
content: []byte(`{
"alert": "SeverityLabelTest",
"schemaVersion": "v1",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "cpu_usage", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}],
"unit": "percent"
},
"target": 85.0,
"targetUnit": "%",
"matchType": "1",
"op": "1"
},
"labels": {
"severity": "warning",
"team": "platform"
}
}`),
kind: RuleDataKindJson,
expectError: false,
validate: func(t *testing.T, rule *PostableRule) {
if rule.RuleCondition.Thresholds == nil {
t.Fatal("Expected Thresholds to be populated for v1")
}
threshold := rule.RuleCondition.Thresholds
if threshold.Kind != BasicThresholdKind {
t.Errorf("Expected BasicThresholdKind, got %s", threshold.Kind)
}
specs, ok := threshold.Spec.(BasicRuleThresholds)
if !ok {
t.Fatalf("Expected BasicRuleThresholds, got %T", threshold.Spec)
}
if len(specs) != 1 {
t.Fatalf("Expected 1 threshold spec, got %d", len(specs))
}
spec := specs[0]
if spec.Name != "warning" {
t.Errorf("Expected threshold name 'warning' from severity label, got '%s'", spec.Name)
}
if spec.TargetUnit != "%" {
t.Errorf("Expected TargetUnit '%%', got '%s'", spec.TargetUnit)
}
if *spec.TargetValue != 85.0 {
t.Errorf("Expected TargetValue 85.0, got %v", *spec.TargetValue)
}
if spec.MatchType != rule.RuleCondition.MatchType {
t.Error("Expected MatchType to be copied from RuleCondition")
}
if spec.CompareOperator != rule.RuleCondition.CompareOperator {
t.Error("Expected CompareOp to be copied from RuleCondition")
}
// Verify evaluation envelope is populated
if rule.Evaluation == nil {
t.Fatal("Expected Evaluation to be populated for v1")
}
if rule.Evaluation.Kind != RollingEvaluation {
t.Errorf("Expected RollingEvaluation, got %s", rule.Evaluation.Kind)
}
// Verify evaluation window matches rule settings
if window, ok := rule.Evaluation.Spec.(RollingWindow); ok {
if !window.EvalWindow.Equal(rule.EvalWindow) {
t.Errorf("Expected Evaluation EvalWindow %v, got %v", rule.EvalWindow, window.EvalWindow)
}
if !window.Frequency.Equal(rule.Frequency) {
t.Errorf("Expected Evaluation Frequency %v, got %v", rule.Frequency, window.Frequency)
}
} else {
t.Errorf("Expected RollingWindow spec, got %T", rule.Evaluation.Spec)
}
},
},
{
name: "schema v1 - uses critical threshold when no labels",
initRule: PostableRule{},
content: []byte(`{
"alert": "NoLabelsTest",
"schemaVersion": "v1",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "memory_usage", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 90.0,
"matchType": "1",
"op": "1"
}
}`),
kind: RuleDataKindJson,
expectError: false,
validate: func(t *testing.T, rule *PostableRule) {
if rule.RuleCondition.Thresholds == nil {
t.Fatal("Expected Thresholds to be populated")
}
specs, ok := rule.RuleCondition.Thresholds.Spec.(BasicRuleThresholds)
if !ok {
t.Fatalf("Expected BasicRuleThresholds, got %T", rule.RuleCondition.Thresholds.Spec)
}
spec := specs[0]
// Should default to CriticalThresholdName when no severity label
if spec.Name != CriticalThresholdName {
t.Errorf("Expected threshold name '%s', got '%s'", CriticalThresholdName, spec.Name)
}
},
},
{
name: "schema v1 - overwrites existing thresholds and evaluation",
initRule: PostableRule{},
content: []byte(`{
"alert": "OverwriteTest",
"schemaVersion": "v1",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "cpu_usage", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}],
"unit": "percent"
},
"target": 80.0,
"targetUnit": "%",
"matchType": "1",
"op": "1",
"thresholds": {
"kind": "basic",
"spec": [{
"name": "existing_threshold",
"target": 50.0,
"targetUnit": "MB",
"ruleUnit": "bytes",
"matchType": "1",
"op": "1"
}]
}
},
"evaluation": {
"kind": "rolling",
"spec": {
"evalWindow": "10m",
"frequency": "2m"
}
},
"frequency":"7m",
"evalWindow":"11m",
"labels": {
"severity": "critical"
}
}`),
kind: RuleDataKindJson,
expectError: false,
validate: func(t *testing.T, rule *PostableRule) {
if rule.RuleCondition.Thresholds == nil {
t.Fatal("Expected Thresholds to be populated")
}
specs, ok := rule.RuleCondition.Thresholds.Spec.(BasicRuleThresholds)
if !ok {
t.Fatalf("Expected BasicRuleThresholds, got %T", rule.RuleCondition.Thresholds.Spec)
}
if len(specs) != 1 {
t.Fatalf("Expected 1 threshold spec, got %d", len(specs))
}
spec := specs[0]
if spec.Name != "critical" {
t.Errorf("Expected threshold name 'critical' (overwritten), got '%s'", spec.Name)
}
if *spec.TargetValue != 80.0 {
t.Errorf("Expected TargetValue 80.0 (overwritten), got %v", *spec.TargetValue)
}
if spec.TargetUnit != "%" {
t.Errorf("Expected TargetUnit '%%' (overwritten), got '%s'", spec.TargetUnit)
}
if rule.Evaluation == nil {
t.Fatal("Expected Evaluation to be populated")
}
if window, ok := rule.Evaluation.Spec.(RollingWindow); ok {
if !window.EvalWindow.Equal(rule.EvalWindow) {
t.Errorf("Expected Evaluation EvalWindow to be overwritten to %v, got %v", rule.EvalWindow, window.EvalWindow)
}
if !window.Frequency.Equal(rule.Frequency) {
t.Errorf("Expected Evaluation Frequency to be overwritten to %v, got %v", rule.Frequency, window.Frequency)
}
} else {
t.Errorf("Expected RollingWindow spec, got %T", rule.Evaluation.Spec)
}
},
},
{
name: "schema v2alpha1 - uses explicit thresholds and evaluation",
initRule: PostableRule{},
@@ -645,19 +373,19 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
}
}]
},
"target": 100.0,
"matchType": "1",
"op": "1"
}
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 100.0, "matchType": "1", "op": "1"}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
}`),
kind: RuleDataKindJson,
expectValidateError: true,
},
{
name: "default schema version - defaults to v1 behavior",
name: "missing schema version - rejected on write",
initRule: PostableRule{},
content: []byte(`{
"alert": "DefaultSchemaTest",
"alert": "MissingSchemaTest",
"version": "v5",
"condition": {
"compositeQuery": {
@@ -672,24 +400,13 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
}
}]
},
"target": 75.0,
"matchType": "1",
"op": "1"
}
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 75.0, "matchType": "1", "op": "1"}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
}`),
kind: RuleDataKindJson,
expectError: false,
validate: func(t *testing.T, rule *PostableRule) {
if rule.SchemaVersion != DefaultSchemaVersion {
t.Errorf("Expected default schemaVersion '%s', got '%s'", DefaultSchemaVersion, rule.SchemaVersion)
}
if rule.RuleCondition.Thresholds == nil {
t.Error("Expected Thresholds to be populated for default schema version")
}
if rule.Evaluation == nil {
t.Error("Expected Evaluation to be populated for default schema version")
}
},
kind: RuleDataKindJson,
expectValidateError: true,
},
}
@@ -737,11 +454,7 @@ func TestParseIntoRuleThresholdGeneration(t *testing.T) {
}
}]
},
"target": 100.0,
"matchType": "1",
"op": "1",
"selectedQuery": "A",
"targetUnit": "ms",
"selectedQueryName": "A",
"thresholds": {
"kind": "basic",
"spec": [
@@ -906,449 +619,3 @@ func TestParseIntoRuleMultipleThresholds(t *testing.T) {
assert.Equal(t, 1, len(vector))
}
func TestAnomalyNegationEval(t *testing.T) {
tests := []struct {
name string
ruleJSON []byte
series *qbtypes.TimeSeries
shouldAlert bool
expectedValue float64
}{
{
name: "anomaly rule with ValueIsBelow - should alert",
ruleJSON: []byte(`{
"alert": "AnomalyBelowTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 2.0,
"matchType": "1",
"op": "2",
"selectedQuery": "A"
}
}`),
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: -2.1}, // below & at least once, should alert
{Timestamp: 2000, Value: -2.3},
},
},
shouldAlert: true,
expectedValue: -2.1,
},
{
name: "anomaly rule with ValueIsBelow; should not alert",
ruleJSON: []byte(`{
"alert": "AnomalyBelowTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 2.0,
"matchType": "1",
"op": "2",
"selectedQuery": "A"
}
}`), // below & at least once, no value below -2.0
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: -1.9},
{Timestamp: 2000, Value: -1.8},
},
},
shouldAlert: false,
},
{
name: "anomaly rule with ValueIsAbove; should alert",
ruleJSON: []byte(`{
"alert": "AnomalyAboveTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 2.0,
"matchType": "1",
"op": "1",
"selectedQuery": "A"
}
}`), // above & at least once, should alert
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: 2.1}, // above 2.0, should alert
{Timestamp: 2000, Value: 2.2},
},
},
shouldAlert: true,
expectedValue: 2.1,
},
{
name: "anomaly rule with ValueIsAbove; should not alert",
ruleJSON: []byte(`{
"alert": "AnomalyAboveTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 2.0,
"matchType": "1",
"op": "1",
"selectedQuery": "A"
}
}`),
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: 1.1},
{Timestamp: 2000, Value: 1.2},
},
},
shouldAlert: false,
},
{
name: "anomaly rule with ValueIsBelow and AllTheTimes; should alert",
ruleJSON: []byte(`{
"alert": "AnomalyBelowAllTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 2.0,
"matchType": "2",
"op": "2",
"selectedQuery": "A"
}
}`), // below and all the times
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: -2.1}, // all below -2
{Timestamp: 2000, Value: -2.2},
{Timestamp: 3000, Value: -2.5},
},
},
shouldAlert: true,
expectedValue: -2.1, // max value when all are below threshold
},
{
name: "anomaly rule with ValueIsBelow and AllTheTimes; should not alert",
ruleJSON: []byte(`{
"alert": "AnomalyBelowAllTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 2.0,
"matchType": "2",
"op": "2",
"selectedQuery": "A"
}
}`),
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: -3.0},
{Timestamp: 2000, Value: -1.0}, // above -2, breaks condition
{Timestamp: 3000, Value: -2.5},
},
},
shouldAlert: false,
},
{
name: "anomaly rule with ValueOutsideBounds; should alert",
ruleJSON: []byte(`{
"alert": "AnomalyOutOfBoundsTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 7.0,
"matchType": "1",
"op": "7",
"selectedQuery": "A"
}
}`),
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: -8.0}, // abs(−8) >= 7, alert
{Timestamp: 2000, Value: 5.0},
},
},
shouldAlert: true,
expectedValue: -8.0,
},
{
name: "non-anomaly threshold rule with ValueIsBelow; should alert",
ruleJSON: []byte(`{
"alert": "ThresholdTest",
"ruleType": "threshold_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 90.0,
"matchType": "1",
"op": "2",
"selectedQuery": "A"
}
}`),
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: 80.0}, // below 90, should alert
{Timestamp: 2000, Value: 85.0},
},
},
shouldAlert: true,
expectedValue: 80.0,
},
{
name: "non-anomaly rule with ValueIsBelow - should not alert",
ruleJSON: []byte(`{
"alert": "ThresholdTest",
"ruleType": "threshold_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 50.0,
"matchType": "1",
"op": "2",
"selectedQuery": "A"
}
}`),
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: 60.0}, // below, should alert
{Timestamp: 2000, Value: 90.0},
},
},
shouldAlert: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rule := PostableRule{}
err := json.Unmarshal(tt.ruleJSON, &rule)
if err != nil {
t.Fatalf("Failed to unmarshal rule: %v", err)
}
ruleThreshold, err := rule.RuleCondition.Thresholds.GetRuleThreshold()
if err != nil {
t.Fatalf("unexpected error from GetRuleThreshold: %v", err)
}
resultVector, err := ruleThreshold.Eval(tt.series, "", EvalData{})
if err != nil {
t.Fatalf("unexpected error from Eval: %v", err)
}
shouldAlert := len(resultVector) > 0
if shouldAlert != tt.shouldAlert {
t.Errorf("Expected shouldAlert=%v, got %v. %s",
tt.shouldAlert, shouldAlert, tt.name)
}
if tt.shouldAlert && len(resultVector) > 0 {
sample := resultVector[0]
if sample.V != tt.expectedValue {
t.Errorf("Expected alert value=%.2f, got %.2f. %s",
tt.expectedValue, sample.V, tt.name)
}
}
})
}
}
func TestVersionDefaultsToV5(t *testing.T) {
content := `{
"alert": "cpu high",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"schemaVersion": "v2alpha1",
"condition": {
"compositeQuery": {
"queries": [{"type": "promql", "spec": {"name": "A", "query": "up"}}],
"panelType": "graph",
"queryType": "promql"
},
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 90, "matchType": "1", "op": "1"}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"usePolicy": false}
}`
rule := PostableRule{}
require.NoError(t, json.Unmarshal([]byte(content), &rule))
assert.Equal(t, "v5", rule.Version)
assert.NoError(t, rule.Validate())
}

View File

@@ -29,6 +29,9 @@ func (EvaluationKind) Enum() []any {
type Evaluation interface {
NextWindowFor(curr time.Time) (time.Time, time.Time)
GetFrequency() valuer.TextDuration
// validateDurationText runs on the write path only; a stored window may
// hold a bare number and still evaluate.
validateDurationText() error
}
type RollingWindow struct {
@@ -36,6 +39,13 @@ type RollingWindow struct {
Frequency valuer.TextDuration `json:"frequency" required:"true" description:"How often the rule is evaluated, as a Go duration such as 1m. Must be positive."`
}
func (rollingWindow RollingWindow) validateDurationText() error {
if err := validateDurationText("evaluation.spec.evalWindow", rollingWindow.EvalWindow); err != nil {
return err
}
return validateDurationText("evaluation.spec.frequency", rollingWindow.Frequency)
}
func (rollingWindow RollingWindow) Validate() error {
if !rollingWindow.EvalWindow.IsPositive() {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "evalWindow must be greater than zero")
@@ -60,6 +70,10 @@ type CumulativeWindow struct {
Timezone string `json:"timezone" required:"true" description:"The IANA time zone the schedule is resolved in, such as UTC or Asia/Kolkata."`
}
func (cumulativeWindow CumulativeWindow) validateDurationText() error {
return validateDurationText("evaluation.spec.frequency", cumulativeWindow.Frequency)
}
type CumulativeSchedule struct {
Type ScheduleType `json:"type" required:"true" description:"How often the window resets: hourly, daily, weekly or monthly."`
Minute *int `json:"minute,omitempty" description:"The minute of the reset boundary, 0 to 59. Required for every type."`
@@ -328,6 +342,9 @@ func (e *EvaluationEnvelope) UnmarshalJSON(data []byte) error {
}
func (e *EvaluationEnvelope) GetEvaluation() (Evaluation, error) {
if e == nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "evaluation is nil")
}
if e.Kind.IsZero() {
e.Kind = RollingEvaluation
}

View File

@@ -33,6 +33,11 @@ type storedRuleData struct {
RuleType RuleType `json:"ruleType"`
Disabled bool `json:"disabled"`
Labels map[string]string `json:"labels"`
Condition struct {
CompositeQuery struct {
QueryType QueryType `json:"queryType"`
} `json:"compositeQuery"`
} `json:"condition"`
}
// ToListableRule leaves State zero; the caller overlays evaluation state.
@@ -42,6 +47,15 @@ func (rule *StorableRule) ToListableRule() (*ListableRule, error) {
return nil, err
}
switch data.Condition.CompositeQuery.QueryType {
case QueryTypeBuilder, QueryTypeClickHouseSQL:
if data.RuleType.IsZero() {
data.RuleType = RuleTypeThreshold
}
case QueryTypePromQL:
data.RuleType = RuleTypeProm
}
return &ListableRule{
Id: rule.ID.StringValue(),
AlertName: data.AlertName,

View File

@@ -147,7 +147,7 @@ func TestV2MinimalReadShape(t *testing.T) {
body := readBody(t, stored)
for _, field := range []string{"labels", "annotations", "description", "preferredChannels", "evalWindow", "frequency"} {
for _, field := range []string{"labels", "annotations", "description"} {
assert.NotContains(t, body, field)
}
@@ -159,7 +159,7 @@ func TestV2MinimalReadShape(t *testing.T) {
assert.JSONEq(t, `false`, string(ns["usePolicy"]))
assert.JSONEq(t, `false`, string(body["disabled"]))
assert.JSONEq(t, `"v5"`, string(body["version"]))
assert.JSONEq(t, `""`, string(body["version"]))
var condition struct {
Thresholds struct {
@@ -297,6 +297,7 @@ func TestPatchMergePreservesUnpatchedFields(t *testing.T) {
"panelType": "graph",
"queryType": "promql"
},
"selectedQueryName": "A",
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above"}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},

View File

@@ -85,9 +85,6 @@ func (r *RuleThresholdData) UnmarshalJSON(data []byte) error {
if err := json.Unmarshal(raw["spec"], &basicThresholds); err != nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to unmarshal rule threshold spec: %v", err)
}
if err := basicThresholds.Validate(); err != nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid rule threshold spec: %v", err)
}
r.Spec = basicThresholds
default:
@@ -130,6 +127,9 @@ type RuleThreshold interface {
// using the given EvalData and returns the matching series
Eval(series *qbtypes.TimeSeries, unit string, evalData EvalData) (Vector, error)
GetRuleReceivers() []RuleReceivers
// Validate runs on the write path and before a task is built; a stored
// rule may hold a threshold that fails it and still decode.
Validate() error
}
type BasicRuleThreshold struct {
@@ -170,6 +170,37 @@ func (r BasicRuleThresholds) Validate() error {
return errors.Join(errs...)
}
func (r BasicRuleThresholds) validateUniqueNames() error {
names := make(map[string]struct{}, len(r))
for _, basicThreshold := range r {
if _, seen := names[basicThreshold.Name]; seen {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.thresholds.spec: duplicate threshold name %q", basicThreshold.Name)
}
names[basicThreshold.Name] = struct{}{}
}
return nil
}
func (r BasicRuleThresholds) validateRecoveryTargets() error {
for _, basicThreshold := range r {
if basicThreshold.RecoveryTarget == nil || basicThreshold.TargetValue == nil {
continue
}
// equal, not_equal and outside_bounds have no recovering side
switch basicThreshold.CompareOperator.Normalize() {
case ValueIsAbove, ValueAboveOrEq:
if *basicThreshold.RecoveryTarget >= *basicThreshold.TargetValue {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.thresholds.spec: recoveryTarget must be below target for op %s", basicThreshold.CompareOperator.StringValue())
}
case ValueIsBelow, ValueBelowOrEq:
if *basicThreshold.RecoveryTarget <= *basicThreshold.TargetValue {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.thresholds.spec: recoveryTarget must be above target for op %s", basicThreshold.CompareOperator.StringValue())
}
}
}
return nil
}
func (r BasicRuleThresholds) Eval(s *qbtypes.TimeSeries, unit string, evalData EvalData) (Vector, error) {
series := &qbtypes.TimeSeries{

File diff suppressed because it is too large Load Diff

View File

@@ -54,6 +54,12 @@ func (d TextDuration) IsPositive() bool {
return d.value > 0
}
// HasText reports whether the duration came as text; a bare JSON number
// decodes to nanoseconds and keeps none.
func (d TextDuration) HasText() bool {
return d.text != ""
}
// String implements the [fmt.Stringer] interface.
func (d TextDuration) String() string {
if len(d.text) > 0 {

View File

@@ -32,12 +32,12 @@ def create_alert_rule(signoz: types.SigNoz, get_token: Callable[[str, str], str]
def _create_alert_rule(rule_data: dict) -> str:
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/rules"),
signoz.self.host_configs["8080"].get("/api/v2/rules"),
json=rule_data,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, f"Failed to create rule, api returned {response.status_code} with response: {response.text}"
assert response.status_code == HTTPStatus.CREATED, f"Failed to create rule, api returned {response.status_code} with response: {response.text}"
rule_id = response.json()["data"]["id"]
rule_ids.append(rule_id)
return rule_id
@@ -45,11 +45,11 @@ def create_alert_rule(signoz: types.SigNoz, get_token: Callable[[str, str], str]
def _delete_alert_rule(rule_id: str):
logger.info("Deleting rule: %s", {"rule_id": rule_id})
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/rules/{rule_id}"),
signoz.self.host_configs["8080"].get(f"/api/v2/rules/{rule_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
if response.status_code != HTTPStatus.OK:
if response.status_code != HTTPStatus.NO_CONTENT:
raise Exception( # pylint: disable=broad-exception-raised
f"Failed to delete rule, api returned {response.status_code} with response: {response.text}"
)
@@ -156,19 +156,22 @@ def create_downtime_schedule(signoz: types.SigNoz, get_token: Callable[[str, str
def delete_all_rules(signoz: types.SigNoz, token: str) -> None:
# the v3 list is paginated; 200 is its maximum page and more rules than
# that would mean a seeding bug, not a second page
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/rules"),
signoz.self.host_configs["8080"].get("/api/v3/rules"),
params={"limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
for rule in response.json()["data"]:
for rule in response.json()["data"]["rules"]:
delete_response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/rules/{rule['id']}"),
signoz.self.host_configs["8080"].get(f"/api/v2/rules/{rule['id']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert delete_response.status_code == HTTPStatus.OK, f"failed to delete rule {rule['id']}: {delete_response.text}"
assert delete_response.status_code == HTTPStatus.NO_CONTENT, f"failed to delete rule {rule['id']}: {delete_response.text}"
@pytest.fixture(name="seed_alert_rules", scope="function")

View File

@@ -573,24 +573,3 @@ def test_error_contract(
error = response.json()["error"]
assert error["code"] == expected_code, f"params {params!r}"
assert expected_message_part in error["message"], f"params {params!r}: {error['message']}"
def test_v2_list_still_serves_bare_array(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
seed_alert_rules: Callable[[str, list[dict]], None],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
seed_alert_rules(SEED_CHANNEL_NAME, SEED_RULES)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/rules"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
data = response.json()["data"]
assert isinstance(data, list), "deprecated v2 must keep returning a bare array"
assert {rule["alert"] for rule in data} == {r["alert"] for r in SEED_RULES}

View File

@@ -0,0 +1,446 @@
import time
from collections.abc import Callable
from http import HTTPStatus
import requests
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import Operation, SigNoz
BASE_URL = "/api/v2/rules"
VALID_RULE = {
"alert": "validation probe",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"condition": {
"thresholds": {
"kind": "basic",
"spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": []}],
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "validation_probe_cpu", "timeAggregation": "avg", "spaceAggregation": "max"}],
},
}
],
},
"selectedQueryName": "A",
},
"labels": {"severity": "critical"},
"annotations": {"summary": "s", "description": "d"},
"disabled": True,
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"usePolicy": False, "renotify": {"enabled": False}},
"schemaVersion": "v2alpha1",
}
def test_create_rejects_missing_alert_type(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
rule = {key: value for key, value in VALID_RULE.items() if key != "alertType"}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=rule,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "alert rule is not valid",
"errors": [{"message": "alertType: field is required", "suggestions": []}],
"suggestions": [],
},
}
def test_create_rejects_unknown_panel_type(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
rule = {**VALID_RULE, "condition": {**VALID_RULE["condition"], "compositeQuery": {**VALID_RULE["condition"]["compositeQuery"], "panelType": "pie"}}}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=rule,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "alert rule is not valid",
"errors": [{"message": 'condition.compositeQuery.panelType: unsupported value "pie"; must be one of value, table, graph', "suggestions": []}],
"suggestions": [],
},
}
def test_create_rejects_malformed_json(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
data='{"alert": ',
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "failed to parse json: unexpected end of JSON input",
"errors": [],
"suggestions": [],
},
}
def test_create_rejects_unnamed_threshold(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
rule = {**VALID_RULE, "condition": {**VALID_RULE["condition"], "thresholds": {"kind": "basic", "spec": [{"name": "", "target": 90, "matchType": "at_least_once", "op": "above", "channels": []}]}}}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=rule,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "alert rule is not valid",
"errors": [{"message": "threshold name cannot be empty", "suggestions": []}],
"suggestions": [],
},
}
def test_create_rejects_duplicate_threshold_names(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
threshold = {"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": []}
rule = {**VALID_RULE, "condition": {**VALID_RULE["condition"], "thresholds": {"kind": "basic", "spec": [threshold, {**threshold, "target": 95}]}}}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=rule,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "alert rule is not valid",
"errors": [{"message": 'condition.thresholds.spec: duplicate threshold name "critical"', "suggestions": []}],
"suggestions": [],
},
}
def test_create_rejects_empty_schema_version(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={**VALID_RULE, "schemaVersion": ""},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "alert rule is not valid",
"errors": [{"message": 'schemaVersion: unsupported value ""; must be "v2alpha1"', "suggestions": []}],
"suggestions": [],
},
}
def test_create_defaults_clickhouse_rule_type(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_alert_rule: Callable[[dict], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
rule = {key: value for key, value in VALID_RULE.items() if key != "ruleType"}
rule["condition"] = {**VALID_RULE["condition"], "compositeQuery": {"queryType": "clickhouse_sql", "panelType": "graph", "queries": [{"type": "clickhouse_sql", "spec": {"name": "A", "query": "SELECT now() AS ts, 1 AS value"}}]}}
rule_id = create_alert_rule(rule)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{rule_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["ruleType"] == "threshold_rule"
def test_get_echoes_alert_on_absent_false(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_alert_rule: Callable[[dict], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
rule_id = create_alert_rule({**VALID_RULE, "condition": {**VALID_RULE["condition"], "alertOnAbsent": False, "absentFor": 0}})
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{rule_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["condition"]["alertOnAbsent"] is False
assert response.json()["data"]["condition"]["absentFor"] == 0
def test_timeline_rejects_unknown_state(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_alert_rule: Callable[[dict], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
rule_id = create_alert_rule(VALID_RULE)
end_ms = int(time.time() * 1000)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{rule_id}/history/timeline"),
params={"start": end_ms - 3_600_000, "end": end_ms, "state": "bogus"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "invalid query parameters",
"errors": [],
"suggestions": [],
},
}
def test_create_rejects_unknown_field(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={**VALID_RULE, "preferredChannels": ["slack"]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": 'unknown field "preferredChannels" in alert rule',
"errors": [],
"suggestions": ["valid fields are `alert`, `alertType`, `description`, `ruleType`, `condition`, `labels`, `annotations`, `disabled`, `source`, `version`, `evaluation`, `schemaVersion`, `notificationSettings`"],
},
}
def test_create_rejects_numeric_eval_window(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={**VALID_RULE, "evaluation": {"kind": "rolling", "spec": {"evalWindow": 300, "frequency": "1m"}}},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "alert rule is not valid",
"errors": [{"message": 'evaluation.spec.evalWindow: must be a duration string such as "5m"', "suggestions": []}],
"suggestions": [],
},
}
def test_create_rejects_recovery_target_past_target(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
thresholds = {"kind": "basic", "spec": [{**VALID_RULE["condition"]["thresholds"]["spec"][0], "recoveryTarget": 95}]}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={**VALID_RULE, "condition": {**VALID_RULE["condition"], "thresholds": thresholds}},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "alert rule is not valid",
"errors": [{"message": "condition.thresholds.spec: recoveryTarget must be below target for op above", "suggestions": []}],
"suggestions": [],
},
}
def test_list_defaults_rule_type(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_alert_rule: Callable[[dict], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
rule_id = create_alert_rule({key: value for key, value in VALID_RULE.items() if key != "ruleType"})
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v3/rules"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
listed = next(rule for rule in response.json()["data"]["rules"] if rule["id"] == rule_id)
assert listed["ruleType"] == "threshold_rule"
def test_update_rejects_malformed_id(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/not-a-uuid"),
json=VALID_RULE,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "id is not a valid uuid",
"errors": [],
"suggestions": [],
},
}
def test_create_rejects_channel_by_name(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_webhook_notification_channel: Callable[[str, str, dict, bool], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
channel_id = create_webhook_notification_channel(f"Rule Validation {time.time_ns()}", "http://localhost:9/alert", {}, False)
channel = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/notification_channels/{channel_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).json()["data"]
thresholds = {"kind": "basic", "spec": [{**VALID_RULE["condition"]["thresholds"]["spec"][0], "channels": [channel["name"]]}]}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={**VALID_RULE, "condition": {**VALID_RULE["condition"], "thresholds": thresholds}},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": f'channels: rules reference channels by display name, not name: [{channel["name"]} (display name "{channel["displayName"]}")]',
"errors": [],
"suggestions": [],
},
}

View File

@@ -0,0 +1,119 @@
from collections.abc import Callable
from http import HTTPStatus
import pytest
import requests
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import Operation, SigNoz
BASE_URL = "/api/v2/rules"
VALID_RULE = {
"alert": "threshold probe",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"condition": {
"thresholds": {
"kind": "basic",
"spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": []}],
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "threshold_probe_cpu", "timeAggregation": "avg", "spaceAggregation": "max"}],
},
}
],
},
"selectedQueryName": "A",
},
"labels": {"severity": "critical"},
"annotations": {"summary": "s", "description": "d"},
"disabled": True,
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"usePolicy": False, "renotify": {"enabled": False}},
"schemaVersion": "v2alpha1",
}
@pytest.mark.parametrize(
("threshold", "expected_message"),
[
(
{"name": "critical", "target": None, "matchType": "at_least_once", "op": "above", "channels": []},
"target value cannot be nil",
),
(
{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "", "channels": []},
'condition.op: unsupported value ""; must be one of above, below, equal, not_equal, above_or_equal, below_or_equal, outside_bounds',
),
(
{"name": "critical", "target": 90, "matchType": "0", "op": "above", "channels": []},
'condition.matchType: unsupported value "0"; must be one of at_least_once, all_the_times, on_average, in_total, last',
),
],
ids=["null_target", "empty_op", "unknown_match_type"],
)
def test_create_rejects_invalid_threshold(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
threshold: dict,
expected_message: str,
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
rule = {**VALID_RULE, "condition": {**VALID_RULE["condition"], "thresholds": {"kind": "basic", "spec": [threshold]}}}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=rule,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "alert rule is not valid",
"errors": [{"message": expected_message, "suggestions": []}],
"suggestions": [],
},
}
def test_create_rejects_omitted_selected_query_name(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
rule = {**VALID_RULE, "condition": {key: value for key, value in VALID_RULE["condition"].items() if key != "selectedQueryName"}}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=rule,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "alert rule is not valid",
"errors": [{"message": "condition.selectedQueryName: field is required", "suggestions": []}],
"suggestions": [],
},
}