mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-27 19:40:41 +00:00
Compare commits
49 Commits
fix/error-
...
nv/rules-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3d53c8b98 | ||
|
|
b380686577 | ||
|
|
1d0328fae5 | ||
|
|
01b2b49521 | ||
|
|
52f4657f57 | ||
|
|
ba475bdb48 | ||
|
|
94113eeed5 | ||
|
|
680f7de69e | ||
|
|
2237dbde9a | ||
|
|
aac321de58 | ||
|
|
7a7c35eca0 | ||
|
|
41d81cfcbc | ||
|
|
00afd873e3 | ||
|
|
428acb6f23 | ||
|
|
736ad2aba3 | ||
|
|
99f21adadd | ||
|
|
bdfadbbe87 | ||
|
|
e23f96c7a1 | ||
|
|
3e1d76f3c6 | ||
|
|
6b06bacb91 | ||
|
|
5b91346a27 | ||
|
|
a66d6000e5 | ||
|
|
f42084ad31 | ||
|
|
7525667f28 | ||
|
|
bdad1235aa | ||
|
|
a7e0ac1f4d | ||
|
|
3a6ae85dee | ||
|
|
1e4c96907d | ||
|
|
b4191b9e10 | ||
|
|
7e043b1221 | ||
|
|
55e76a182d | ||
|
|
9135807532 | ||
|
|
a0d671e0e8 | ||
|
|
a5f5dc4fb6 | ||
|
|
b18258e76f | ||
|
|
872b4172d3 | ||
|
|
08f4c099f6 | ||
|
|
87b1eed20a | ||
|
|
f6cfcdfc78 | ||
|
|
37cf5ae407 | ||
|
|
9a8f66be63 | ||
|
|
3a5078e29b | ||
|
|
7ebb1910b9 | ||
|
|
1e1d6f8d24 | ||
|
|
6f6eea7187 | ||
|
|
0bec6c5494 | ||
|
|
d6744c1d8e | ||
|
|
6476be26bb | ||
|
|
06c54c6483 |
@@ -11381,21 +11381,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. Required when schemaVersion
|
||||
is v2alpha1; derived from evalWindow and frequency for v1.
|
||||
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
|
||||
description: The evaluation window, as a kind and spec.
|
||||
labels:
|
||||
additionalProperties:
|
||||
type: string
|
||||
@@ -11406,40 +11395,36 @@ components:
|
||||
notificationSettings:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesNotificationSettings'
|
||||
description: Grouping, re-notification and routing behaviour. Required when
|
||||
schemaVersion is v2alpha1; derived from the v1 fields otherwise.
|
||||
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
|
||||
description: Grouping, re-notification and routing behaviour.
|
||||
ruleType:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesRuleType'
|
||||
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 queries
|
||||
and is forced to promql_rule for promql queries.'
|
||||
from seasonal history. Defaults to threshold_rule for builder and clickhouse_sql
|
||||
queries and is forced to promql_rule for promql queries.'
|
||||
schemaVersion:
|
||||
description: 'The rule payload schema: v1 (the default when omitted) uses
|
||||
the flat condition fields, v2alpha1 uses condition.thresholds, evaluation
|
||||
and notificationSettings.'
|
||||
description: The rule payload schema.
|
||||
enum:
|
||||
- v2alpha1
|
||||
type: string
|
||||
source:
|
||||
deprecated: true
|
||||
description: The URL of the page the rule was created from. Informational;
|
||||
it is not used for evaluation.
|
||||
type: string
|
||||
version:
|
||||
description: The query payload version. Only v5 is accepted and it is filled
|
||||
in when omitted.
|
||||
deprecated: true
|
||||
description: The query payload version. Nothing reads it.
|
||||
type: string
|
||||
required:
|
||||
- alert
|
||||
- alertType
|
||||
- ruleType
|
||||
- condition
|
||||
- evaluation
|
||||
- schemaVersion
|
||||
- notificationSettings
|
||||
type: object
|
||||
RuletypesPostableRuleView:
|
||||
properties:
|
||||
@@ -11520,21 +11505,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. Required when schemaVersion
|
||||
is v2alpha1; derived from evalWindow and frequency for v1.
|
||||
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
|
||||
description: The evaluation window, as a kind and spec.
|
||||
id:
|
||||
description: The unique identifier of the rule.
|
||||
type: string
|
||||
@@ -11548,28 +11522,21 @@ components:
|
||||
notificationSettings:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesNotificationSettings'
|
||||
description: Grouping, re-notification and routing behaviour. Required when
|
||||
schemaVersion is v2alpha1; derived from the v1 fields otherwise.
|
||||
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
|
||||
description: Grouping, re-notification and routing behaviour.
|
||||
ruleType:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesRuleType'
|
||||
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 queries
|
||||
and is forced to promql_rule for promql queries.'
|
||||
from seasonal history. Defaults to threshold_rule for builder and clickhouse_sql
|
||||
queries and is forced to promql_rule for promql queries.'
|
||||
schemaVersion:
|
||||
description: 'The rule payload schema: v1 (the default when omitted) uses
|
||||
the flat condition fields, v2alpha1 uses condition.thresholds, evaluation
|
||||
and notificationSettings.'
|
||||
description: The rule payload schema.
|
||||
enum:
|
||||
- v2alpha1
|
||||
type: string
|
||||
source:
|
||||
deprecated: true
|
||||
description: The URL of the page the rule was created from. Informational;
|
||||
it is not used for evaluation.
|
||||
type: string
|
||||
@@ -11584,8 +11551,8 @@ components:
|
||||
updatedBy:
|
||||
type: string
|
||||
version:
|
||||
description: The query payload version. Only v5 is accepted and it is filled
|
||||
in when omitted.
|
||||
deprecated: true
|
||||
description: The query payload version. Nothing reads it.
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
@@ -11594,6 +11561,9 @@ components:
|
||||
- alertType
|
||||
- ruleType
|
||||
- condition
|
||||
- evaluation
|
||||
- schemaVersion
|
||||
- notificationSettings
|
||||
type: object
|
||||
RuletypesRuleCondition:
|
||||
properties:
|
||||
@@ -11606,28 +11576,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.
|
||||
@@ -11646,24 +11598,13 @@ components:
|
||||
or F1. Must match a query in compositeQuery. When omitted, F1 is used
|
||||
if present, otherwise the enabled query with the highest name.
|
||||
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'
|
||||
description: The thresholds to evaluate, as a kind and spec. Required when
|
||||
schemaVersion is v2alpha1; derived from op, target and matchType for v1.
|
||||
description: The thresholds to evaluate, as a kind and spec.
|
||||
required:
|
||||
- compositeQuery
|
||||
- thresholds
|
||||
type: object
|
||||
RuletypesRuleThresholdData:
|
||||
discriminator:
|
||||
@@ -25104,56 +25045,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
|
||||
@@ -25312,13 +25203,13 @@ paths:
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_anomaly:
|
||||
description: Anomaly rules are not yet supported under schemaVersion
|
||||
v2alpha1, so this example uses the v1 shape. Wraps a builder query
|
||||
in the `anomaly` function with daily seasonality SigNoz compares
|
||||
each point against the forecast for that time of day. Fires when
|
||||
the anomaly score stays below the threshold for the entire window;
|
||||
`requireMinPoints` guards against noisy intervals.
|
||||
summary: Metric anomaly rule (v1 only)
|
||||
description: Wraps a builder query in the `anomaly` function with
|
||||
daily seasonality, so SigNoz compares each point against the forecast
|
||||
for that time of day. The threshold is compared against the signed
|
||||
anomaly score, so `below` with a target of -2 fires when an abrupt
|
||||
drop holds for the entire window; `requireMinPoints` guards against
|
||||
noisy intervals.
|
||||
summary: Metric anomaly rule
|
||||
value:
|
||||
alert: Anomalous drop in ingested spans
|
||||
alertType: METRIC_BASED_ALERT
|
||||
@@ -25353,22 +25244,36 @@ paths:
|
||||
stepInterval: 21600
|
||||
type: builder_query
|
||||
queryType: builder
|
||||
matchType: all_the_times
|
||||
op: below
|
||||
requireMinPoints: true
|
||||
requiredNumPoints: 3
|
||||
seasonality: daily
|
||||
selectedQueryName: A
|
||||
target: 2
|
||||
thresholds:
|
||||
kind: basic
|
||||
spec:
|
||||
- channels:
|
||||
- slack-ingestion
|
||||
matchType: all_the_times
|
||||
name: warning
|
||||
op: below
|
||||
target: -2
|
||||
description: Detect an abrupt drop in span ingestion using a z-score
|
||||
anomaly function
|
||||
evalWindow: 24h
|
||||
frequency: 3h
|
||||
evaluation:
|
||||
kind: rolling
|
||||
spec:
|
||||
evalWindow: 24h
|
||||
frequency: 3h
|
||||
labels:
|
||||
severity: warning
|
||||
preferredChannels:
|
||||
- slack-ingestion
|
||||
notificationSettings:
|
||||
renotify:
|
||||
alertStates:
|
||||
- firing
|
||||
enabled: true
|
||||
interval: 4h
|
||||
ruleType: anomaly_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_promql:
|
||||
description: PromQL expression instead of the builder. Dotted OTEL
|
||||
@@ -26264,13 +26169,13 @@ paths:
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_anomaly:
|
||||
description: Anomaly rules are not yet supported under schemaVersion
|
||||
v2alpha1, so this example uses the v1 shape. Wraps a builder query
|
||||
in the `anomaly` function with daily seasonality SigNoz compares
|
||||
each point against the forecast for that time of day. Fires when
|
||||
the anomaly score stays below the threshold for the entire window;
|
||||
`requireMinPoints` guards against noisy intervals.
|
||||
summary: Metric anomaly rule (v1 only)
|
||||
description: Wraps a builder query in the `anomaly` function with
|
||||
daily seasonality, so SigNoz compares each point against the forecast
|
||||
for that time of day. The threshold is compared against the signed
|
||||
anomaly score, so `below` with a target of -2 fires when an abrupt
|
||||
drop holds for the entire window; `requireMinPoints` guards against
|
||||
noisy intervals.
|
||||
summary: Metric anomaly rule
|
||||
value:
|
||||
alert: Anomalous drop in ingested spans
|
||||
alertType: METRIC_BASED_ALERT
|
||||
@@ -26305,22 +26210,36 @@ paths:
|
||||
stepInterval: 21600
|
||||
type: builder_query
|
||||
queryType: builder
|
||||
matchType: all_the_times
|
||||
op: below
|
||||
requireMinPoints: true
|
||||
requiredNumPoints: 3
|
||||
seasonality: daily
|
||||
selectedQueryName: A
|
||||
target: 2
|
||||
thresholds:
|
||||
kind: basic
|
||||
spec:
|
||||
- channels:
|
||||
- slack-ingestion
|
||||
matchType: all_the_times
|
||||
name: warning
|
||||
op: below
|
||||
target: -2
|
||||
description: Detect an abrupt drop in span ingestion using a z-score
|
||||
anomaly function
|
||||
evalWindow: 24h
|
||||
frequency: 3h
|
||||
evaluation:
|
||||
kind: rolling
|
||||
spec:
|
||||
evalWindow: 24h
|
||||
frequency: 3h
|
||||
labels:
|
||||
severity: warning
|
||||
preferredChannels:
|
||||
- slack-ingestion
|
||||
notificationSettings:
|
||||
renotify:
|
||||
alertStates:
|
||||
- firing
|
||||
enabled: true
|
||||
interval: 4h
|
||||
ruleType: anomaly_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_promql:
|
||||
description: PromQL expression instead of the builder. Dotted OTEL
|
||||
@@ -27117,13 +27036,13 @@ paths:
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_anomaly:
|
||||
description: Anomaly rules are not yet supported under schemaVersion
|
||||
v2alpha1, so this example uses the v1 shape. Wraps a builder query
|
||||
in the `anomaly` function with daily seasonality SigNoz compares
|
||||
each point against the forecast for that time of day. Fires when
|
||||
the anomaly score stays below the threshold for the entire window;
|
||||
`requireMinPoints` guards against noisy intervals.
|
||||
summary: Metric anomaly rule (v1 only)
|
||||
description: Wraps a builder query in the `anomaly` function with
|
||||
daily seasonality, so SigNoz compares each point against the forecast
|
||||
for that time of day. The threshold is compared against the signed
|
||||
anomaly score, so `below` with a target of -2 fires when an abrupt
|
||||
drop holds for the entire window; `requireMinPoints` guards against
|
||||
noisy intervals.
|
||||
summary: Metric anomaly rule
|
||||
value:
|
||||
alert: Anomalous drop in ingested spans
|
||||
alertType: METRIC_BASED_ALERT
|
||||
@@ -27158,22 +27077,36 @@ paths:
|
||||
stepInterval: 21600
|
||||
type: builder_query
|
||||
queryType: builder
|
||||
matchType: all_the_times
|
||||
op: below
|
||||
requireMinPoints: true
|
||||
requiredNumPoints: 3
|
||||
seasonality: daily
|
||||
selectedQueryName: A
|
||||
target: 2
|
||||
thresholds:
|
||||
kind: basic
|
||||
spec:
|
||||
- channels:
|
||||
- slack-ingestion
|
||||
matchType: all_the_times
|
||||
name: warning
|
||||
op: below
|
||||
target: -2
|
||||
description: Detect an abrupt drop in span ingestion using a z-score
|
||||
anomaly function
|
||||
evalWindow: 24h
|
||||
frequency: 3h
|
||||
evaluation:
|
||||
kind: rolling
|
||||
spec:
|
||||
evalWindow: 24h
|
||||
frequency: 3h
|
||||
labels:
|
||||
severity: warning
|
||||
preferredChannels:
|
||||
- slack-ingestion
|
||||
notificationSettings:
|
||||
renotify:
|
||||
alertStates:
|
||||
- firing
|
||||
enabled: true
|
||||
interval: 4h
|
||||
ruleType: anomaly_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_promql:
|
||||
description: PromQL expression instead of the builder. Dotted OTEL
|
||||
@@ -28676,13 +28609,13 @@ paths:
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_anomaly:
|
||||
description: Anomaly rules are not yet supported under schemaVersion
|
||||
v2alpha1, so this example uses the v1 shape. Wraps a builder query
|
||||
in the `anomaly` function with daily seasonality SigNoz compares
|
||||
each point against the forecast for that time of day. Fires when
|
||||
the anomaly score stays below the threshold for the entire window;
|
||||
`requireMinPoints` guards against noisy intervals.
|
||||
summary: Metric anomaly rule (v1 only)
|
||||
description: Wraps a builder query in the `anomaly` function with
|
||||
daily seasonality, so SigNoz compares each point against the forecast
|
||||
for that time of day. The threshold is compared against the signed
|
||||
anomaly score, so `below` with a target of -2 fires when an abrupt
|
||||
drop holds for the entire window; `requireMinPoints` guards against
|
||||
noisy intervals.
|
||||
summary: Metric anomaly rule
|
||||
value:
|
||||
alert: Anomalous drop in ingested spans
|
||||
alertType: METRIC_BASED_ALERT
|
||||
@@ -28717,22 +28650,36 @@ paths:
|
||||
stepInterval: 21600
|
||||
type: builder_query
|
||||
queryType: builder
|
||||
matchType: all_the_times
|
||||
op: below
|
||||
requireMinPoints: true
|
||||
requiredNumPoints: 3
|
||||
seasonality: daily
|
||||
selectedQueryName: A
|
||||
target: 2
|
||||
thresholds:
|
||||
kind: basic
|
||||
spec:
|
||||
- channels:
|
||||
- slack-ingestion
|
||||
matchType: all_the_times
|
||||
name: warning
|
||||
op: below
|
||||
target: -2
|
||||
description: Detect an abrupt drop in span ingestion using a z-score
|
||||
anomaly function
|
||||
evalWindow: 24h
|
||||
frequency: 3h
|
||||
evaluation:
|
||||
kind: rolling
|
||||
spec:
|
||||
evalWindow: 24h
|
||||
frequency: 3h
|
||||
labels:
|
||||
severity: warning
|
||||
preferredChannels:
|
||||
- slack-ingestion
|
||||
notificationSettings:
|
||||
renotify:
|
||||
alertStates:
|
||||
- firing
|
||||
enabled: true
|
||||
interval: 4h
|
||||
ruleType: anomaly_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_promql:
|
||||
description: PromQL expression instead of the builder. Dotted OTEL
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{{
|
||||
|
||||
@@ -338,7 +338,7 @@ export function buildCreateAnomalyAlertRulePayload(
|
||||
notificationSettings: notificationSettingsProps,
|
||||
evaluation: evaluationProps,
|
||||
version: '',
|
||||
schemaVersion: '',
|
||||
schemaVersion: 'v2alpha1',
|
||||
source: window?.location.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -11,20 +11,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"},
|
||||
|
||||
@@ -221,16 +221,15 @@ func postableRuleExamples() []handler.OpenAPIExample {
|
||||
},
|
||||
{
|
||||
Name: "metric_anomaly",
|
||||
Summary: "Metric anomaly rule (v1 only)",
|
||||
Description: "Anomaly rules are not yet supported under schemaVersion v2alpha1, so this example uses the v1 shape. Wraps a builder query in the `anomaly` function with daily seasonality SigNoz compares each point against the forecast for that time of day. Fires when the anomaly score stays below the threshold for the entire window; `requireMinPoints` guards against noisy intervals.",
|
||||
Summary: "Metric anomaly rule",
|
||||
Description: "Wraps a builder query in the `anomaly` function with daily seasonality, so SigNoz compares each point against the forecast for that time of day. The threshold is compared against the signed anomaly score, so `below` with a target of -2 fires when an abrupt drop holds for the entire window; `requireMinPoints` guards against noisy intervals.",
|
||||
Value: map[string]any{
|
||||
"alert": "Anomalous drop in ingested spans",
|
||||
"alertType": "METRIC_BASED_ALERT",
|
||||
"description": "Detect an abrupt drop in span ingestion using a z-score anomaly function",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"evalWindow": "24h",
|
||||
"frequency": "3h",
|
||||
"alert": "Anomalous drop in ingested spans",
|
||||
"alertType": "METRIC_BASED_ALERT",
|
||||
"description": "Detect an abrupt drop in span ingestion using a z-score anomaly function",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"condition": map[string]any{
|
||||
"compositeQuery": map[string]any{
|
||||
"queryType": "builder",
|
||||
@@ -256,17 +255,29 @@ func postableRuleExamples() []handler.OpenAPIExample {
|
||||
},
|
||||
},
|
||||
},
|
||||
"op": "below",
|
||||
"matchType": "all_the_times",
|
||||
"target": 2,
|
||||
"algorithm": "standard",
|
||||
"seasonality": "daily",
|
||||
"selectedQueryName": "A",
|
||||
"requireMinPoints": true,
|
||||
"requiredNumPoints": 3,
|
||||
"thresholds": map[string]any{
|
||||
"kind": "basic",
|
||||
"spec": []any{
|
||||
map[string]any{
|
||||
"name": "warning",
|
||||
"op": "below",
|
||||
"matchType": "all_the_times",
|
||||
"target": -2,
|
||||
"channels": []any{"slack-ingestion"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"labels": map[string]any{"severity": "warning"},
|
||||
"preferredChannels": []any{"slack-ingestion"},
|
||||
"evaluation": rolling("24h", "3h"),
|
||||
"notificationSettings": map[string]any{
|
||||
"renotify": renotify("4h", "firing"),
|
||||
},
|
||||
"labels": map[string]any{"severity": "warning"},
|
||||
"annotations": map[string]any{
|
||||
"description": "Ingestion rate for tenant {{$tenant_id}} is anomalously low (z-score {{$value}}).",
|
||||
"summary": "Span ingestion anomaly",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -359,7 +359,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 +438,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 +547,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 +659,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() {
|
||||
@@ -1049,7 +1049,7 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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 +1105,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
|
||||
|
||||
@@ -49,13 +49,12 @@ 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,
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypeBuilder,
|
||||
PanelType: ruletypes.PanelTypeGraph,
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
@@ -108,12 +107,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 +135,6 @@ func BuildPromAtLeastOnceValueAbove(target float64, recovery *float64) ruletypes
|
||||
RecoveryTarget: recovery,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
Channels: []string{"slack"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -266,6 +266,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddMetricsFieldTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddGCPConnectionMethodFactory(),
|
||||
sqlmigration.NewDeleteOrphanSARolesFactory(),
|
||||
sqlmigration.NewMigrateRulesV1ToV2Alpha1Factory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
602
pkg/sqlmigration/140_migrate_rules_v1_to_v2alpha1.go
Normal file
602
pkg/sqlmigration/140_migrate_rules_v1_to_v2alpha1.go
Normal file
@@ -0,0 +1,602 @@
|
||||
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, err := convertRule(storedRule.ID, storedRuleData, storedRoute)
|
||||
if 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
|
||||
}
|
||||
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, error) {
|
||||
if storedRuleData.RuleCondition == nil {
|
||||
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition in stored rule data is null")
|
||||
}
|
||||
channels := []string{}
|
||||
if storedRoute != nil {
|
||||
channels = storedRoute.Channels
|
||||
}
|
||||
|
||||
rule := storedRuleData.clone()
|
||||
rule.fillDurationDefaults()
|
||||
rule.deriveAlertType()
|
||||
rule.deriveQueryType()
|
||||
rule.repairPanelType()
|
||||
rule.disableUnmetMinPoints()
|
||||
rule.deriveRuleType()
|
||||
rule.negateAnomalyBelowTarget()
|
||||
convertedRoute := rule.repairEmptySeverity(ruleID, storedRoute)
|
||||
rule.relocateThreshold(channels)
|
||||
rule.relocateEvaluation()
|
||||
rule.synthesizeNotificationSettings()
|
||||
rule.SchemaVersion = schemaVersionV2Alpha1
|
||||
rule.clearV1Fields()
|
||||
|
||||
return rule, convertedRoute, nil
|
||||
}
|
||||
|
||||
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 json.RawMessage `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 two envelope keys the derivations need.
|
||||
type ruleQueryEnvelope struct {
|
||||
Type string `json:"type"`
|
||||
Spec struct {
|
||||
Signal string `json:"signal"`
|
||||
} `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 compositeQuery or one with no
|
||||
// queries, 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.CompositeQuery == nil:
|
||||
causes = append(causes, "condition.compositeQuery is null")
|
||||
case len(rule.decodeQueryEnvelopes()) == 0:
|
||||
causes = append(causes, "condition.compositeQuery.queries is empty")
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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.AlertOnAbsent {
|
||||
alertStates = append(alertStates, "nodata")
|
||||
}
|
||||
rule.NotificationSettings = ¬ificationSettings{
|
||||
Renotify: &renotify{
|
||||
Enabled: true,
|
||||
ReNotifyInterval: mustParseTextDuration("4h"),
|
||||
AlertStates: alertStates,
|
||||
},
|
||||
UsePolicy: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (rule *postableRule) clearV1Fields() {
|
||||
rule.EvalWindow = textDuration{}
|
||||
rule.Frequency = textDuration{}
|
||||
rule.PreferredChannels = nil
|
||||
rule.RuleCondition.Target = nil
|
||||
rule.RuleCondition.CompareOperator = ""
|
||||
rule.RuleCondition.MatchType = ""
|
||||
rule.RuleCondition.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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."`
|
||||
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" description:"The name of the query whose result is evaluated, such as A or F1. Must match a query in compositeQuery. When omitted, F1 is used if present, otherwise the enabled query with the highest name."`
|
||||
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" description:"The thresholds to evaluate, as a kind and spec. Required when schemaVersion is v2alpha1; derived from op, target and matchType for v1."`
|
||||
Thresholds *RuleThresholdData `json:"thresholds" required:"true" description:"The thresholds to evaluate, as a kind and spec."`
|
||||
}
|
||||
|
||||
func (rc *RuleCondition) SelectedQueryName() string {
|
||||
|
||||
@@ -5,12 +5,15 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"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"
|
||||
@@ -48,7 +51,6 @@ func (t AlertType) BuilderQueryType() qbtypes.QueryType {
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultSchemaVersion = "v1"
|
||||
SchemaVersionV2Alpha1 = "v2alpha1"
|
||||
)
|
||||
|
||||
@@ -60,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 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."`
|
||||
@@ -75,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."`
|
||||
|
||||
Version string `json:"version" description:"The query payload version. Only v5 is accepted and it is filled in when omitted."`
|
||||
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."`
|
||||
|
||||
Evaluation *EvaluationEnvelope `json:"evaluation,omitempty" description:"The evaluation window, as a kind and spec. Required when schemaVersion is v2alpha1; derived from evalWindow and frequency for v1."`
|
||||
SchemaVersion string `json:"schemaVersion,omitempty" description:"The rule payload schema: v1 (the default when omitted) uses the flat condition fields, v2alpha1 uses condition.thresholds, evaluation and notificationSettings."`
|
||||
|
||||
NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty" description:"Grouping, re-notification and routing behaviour. Required when schemaVersion is v2alpha1; derived from the v1 fields otherwise."`
|
||||
NotificationSettings *NotificationSettings `json:"notificationSettings" required:"true" description:"Grouping, re-notification and routing behaviour."`
|
||||
}
|
||||
|
||||
type NotificationSettings struct {
|
||||
@@ -227,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,6 +247,33 @@ 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,
|
||||
// they stay only so existing clients keep a stable schema.
|
||||
func (PostableRule) PrepareJSONSchema(schema *jsonschema.Schema) error {
|
||||
for _, name := range []string{"version", "source"} {
|
||||
if property, ok := schema.Properties[name]; ok && property.TypeObject != nil {
|
||||
property.TypeObject.WithDeprecated(true)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isValidLabelName(ln string) bool {
|
||||
if len(ln) == 0 {
|
||||
return false
|
||||
@@ -362,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))
|
||||
@@ -409,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:
|
||||
@@ -438,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 {
|
||||
@@ -500,74 +439,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,
|
||||
@@ -577,11 +485,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.
|
||||
@@ -642,31 +563,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
|
||||
@@ -695,28 +591,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
19
tests/fixtures/alerts.py
vendored
19
tests/fixtures/alerts.py
vendored
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
|
||||
364
tests/integration/tests/ruler/08_rule_validation.py
Normal file
364
tests/integration/tests/ruler/08_rule_validation.py
Normal file
@@ -0,0 +1,364 @@
|
||||
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_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"
|
||||
91
tests/integration/tests/ruler/08_threshold_validation.py
Normal file
91
tests/integration/tests/ruler/08_threshold_validation.py
Normal file
@@ -0,0 +1,91 @@
|
||||
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": [],
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user