mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-27 11:30:41 +00:00
Compare commits
10 Commits
nv/rules-v
...
feat/scatt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcc98a7bea | ||
|
|
e013ebfa08 | ||
|
|
f79d9f5429 | ||
|
|
3b6cf789a8 | ||
|
|
35da6e6c90 | ||
|
|
92312176f6 | ||
|
|
527e480142 | ||
|
|
685777c2a1 | ||
|
|
0578feab29 | ||
|
|
29c3341ebb |
@@ -4224,6 +4224,13 @@ components:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesAxisScale:
|
||||
enum:
|
||||
- auto
|
||||
- linear
|
||||
- log
|
||||
- symlog
|
||||
type: string
|
||||
DashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
@@ -5250,6 +5257,7 @@ components:
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
|
||||
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
|
||||
signoz/ScatterPlotPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
|
||||
@@ -5265,6 +5273,7 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -5278,6 +5287,7 @@ components:
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
- signoz/ScatterPlotPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
@@ -5384,6 +5394,18 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/ScatterPlotPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -5494,6 +5516,16 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
description: The time range the slices are computed over.
|
||||
type: object
|
||||
DashboardtypesPointDiameter:
|
||||
maximum: 40
|
||||
minimum: 2
|
||||
nullable: true
|
||||
type: integer
|
||||
DashboardtypesPointOpacity:
|
||||
maximum: 1
|
||||
minimum: 0.1
|
||||
nullable: true
|
||||
type: number
|
||||
DashboardtypesPostableDashboardV2:
|
||||
properties:
|
||||
generateName:
|
||||
@@ -5748,6 +5780,104 @@ components:
|
||||
required:
|
||||
- queryValue
|
||||
type: object
|
||||
DashboardtypesScatterPlotAxes:
|
||||
properties:
|
||||
x:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
|
||||
"y":
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
|
||||
type: object
|
||||
DashboardtypesScatterPlotAxis:
|
||||
properties:
|
||||
label:
|
||||
description: Axis title. Empty draws none.
|
||||
type: string
|
||||
scale:
|
||||
$ref: '#/components/schemas/DashboardtypesAxisScale'
|
||||
softMax:
|
||||
nullable: true
|
||||
type: number
|
||||
softMin:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesScatterPlotChartAppearance:
|
||||
properties:
|
||||
points:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
|
||||
type: object
|
||||
DashboardtypesScatterPlotDimensions:
|
||||
properties:
|
||||
colorBy:
|
||||
description: Group-by label names (e.g. k8s.namespace.name) whose combined
|
||||
values colour dots and drive the legend. Empty colours by every group-by
|
||||
label.
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
sizeBy:
|
||||
description: Value column key that scales dot size. Empty draws every dot
|
||||
at the default size.
|
||||
type: string
|
||||
x:
|
||||
description: Value column key (queryName, or queryName.expression for a
|
||||
multi-aggregation query) plotted on the x axis. Empty uses the first value
|
||||
column.
|
||||
type: string
|
||||
"y":
|
||||
description: Value column key plotted on the y axis. Empty uses the second
|
||||
value column.
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesScatterPlotPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
|
||||
chartAppearance:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
|
||||
dimensions:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
|
||||
formatting:
|
||||
$ref: '#/components/schemas/DashboardtypesTableFormatting'
|
||||
legend:
|
||||
$ref: '#/components/schemas/DashboardtypesLegend'
|
||||
thresholds:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotThreshold'
|
||||
nullable: true
|
||||
type: array
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesScatterPlotPoints:
|
||||
properties:
|
||||
maxSize:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
minSize:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
opacity:
|
||||
$ref: '#/components/schemas/DashboardtypesPointOpacity'
|
||||
size:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
type: object
|
||||
DashboardtypesScatterPlotThreshold:
|
||||
properties:
|
||||
axis:
|
||||
$ref: '#/components/schemas/DashboardtypesThresholdAxis'
|
||||
color:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
unit:
|
||||
type: string
|
||||
value:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- value
|
||||
- color
|
||||
type: object
|
||||
DashboardtypesSource:
|
||||
enum:
|
||||
- user
|
||||
@@ -5914,6 +6044,11 @@ components:
|
||||
- value
|
||||
- name
|
||||
type: object
|
||||
DashboardtypesThresholdAxis:
|
||||
enum:
|
||||
- x
|
||||
- "y"
|
||||
type: string
|
||||
DashboardtypesThresholdFormat:
|
||||
enum:
|
||||
- text
|
||||
@@ -11381,10 +11516,21 @@ 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.
|
||||
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
|
||||
labels:
|
||||
additionalProperties:
|
||||
type: string
|
||||
@@ -11395,26 +11541,32 @@ components:
|
||||
notificationSettings:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesNotificationSettings'
|
||||
description: Grouping, re-notification and routing behaviour.
|
||||
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
|
||||
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 and clickhouse_sql
|
||||
queries and is forced to promql_rule for promql queries.'
|
||||
from seasonal history. Defaults to threshold_rule for builder queries
|
||||
and is forced to promql_rule for promql queries.'
|
||||
schemaVersion:
|
||||
description: The rule payload schema.
|
||||
enum:
|
||||
- v2alpha1
|
||||
description: 'The rule payload schema: v1 (the default when omitted) uses
|
||||
the flat condition fields, v2alpha1 uses condition.thresholds, evaluation
|
||||
and notificationSettings.'
|
||||
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:
|
||||
deprecated: true
|
||||
description: The query payload version. Only v5 is accepted and it is filled
|
||||
in when omitted.
|
||||
type: string
|
||||
@@ -11423,9 +11575,6 @@ components:
|
||||
- alertType
|
||||
- ruleType
|
||||
- condition
|
||||
- evaluation
|
||||
- schemaVersion
|
||||
- notificationSettings
|
||||
type: object
|
||||
RuletypesPostableRuleView:
|
||||
properties:
|
||||
@@ -11506,10 +11655,21 @@ 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.
|
||||
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
|
||||
id:
|
||||
description: The unique identifier of the rule.
|
||||
type: string
|
||||
@@ -11523,21 +11683,28 @@ components:
|
||||
notificationSettings:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesNotificationSettings'
|
||||
description: Grouping, re-notification and routing behaviour.
|
||||
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
|
||||
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 and clickhouse_sql
|
||||
queries and is forced to promql_rule for promql queries.'
|
||||
from seasonal history. Defaults to threshold_rule for builder queries
|
||||
and is forced to promql_rule for promql queries.'
|
||||
schemaVersion:
|
||||
description: The rule payload schema.
|
||||
enum:
|
||||
- v2alpha1
|
||||
description: 'The rule payload schema: v1 (the default when omitted) uses
|
||||
the flat condition fields, v2alpha1 uses condition.thresholds, evaluation
|
||||
and notificationSettings.'
|
||||
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
|
||||
@@ -11552,7 +11719,6 @@ components:
|
||||
updatedBy:
|
||||
type: string
|
||||
version:
|
||||
deprecated: true
|
||||
description: The query payload version. Only v5 is accepted and it is filled
|
||||
in when omitted.
|
||||
type: string
|
||||
@@ -11563,9 +11729,6 @@ components:
|
||||
- alertType
|
||||
- ruleType
|
||||
- condition
|
||||
- evaluation
|
||||
- schemaVersion
|
||||
- notificationSettings
|
||||
type: object
|
||||
RuletypesRuleCondition:
|
||||
properties:
|
||||
@@ -11578,10 +11741,28 @@ 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.
|
||||
@@ -11600,13 +11781,24 @@ 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.
|
||||
description: The thresholds to evaluate, as a kind and spec. Required when
|
||||
schemaVersion is v2alpha1; derived from op, target and matchType for v1.
|
||||
required:
|
||||
- compositeQuery
|
||||
- thresholds
|
||||
type: object
|
||||
RuletypesRuleThresholdData:
|
||||
discriminator:
|
||||
@@ -25047,6 +25239,56 @@ 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
|
||||
@@ -25205,13 +25447,13 @@ paths:
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_anomaly:
|
||||
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
|
||||
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)
|
||||
value:
|
||||
alert: Anomalous drop in ingested spans
|
||||
alertType: METRIC_BASED_ALERT
|
||||
@@ -25246,36 +25488,22 @@ paths:
|
||||
stepInterval: 21600
|
||||
type: builder_query
|
||||
queryType: builder
|
||||
matchType: all_the_times
|
||||
op: below
|
||||
requireMinPoints: true
|
||||
requiredNumPoints: 3
|
||||
seasonality: daily
|
||||
selectedQueryName: A
|
||||
thresholds:
|
||||
kind: basic
|
||||
spec:
|
||||
- channels:
|
||||
- slack-ingestion
|
||||
matchType: all_the_times
|
||||
name: warning
|
||||
op: below
|
||||
target: -2
|
||||
target: 2
|
||||
description: Detect an abrupt drop in span ingestion using a z-score
|
||||
anomaly function
|
||||
evaluation:
|
||||
kind: rolling
|
||||
spec:
|
||||
evalWindow: 24h
|
||||
frequency: 3h
|
||||
evalWindow: 24h
|
||||
frequency: 3h
|
||||
labels:
|
||||
severity: warning
|
||||
notificationSettings:
|
||||
renotify:
|
||||
alertStates:
|
||||
- firing
|
||||
enabled: true
|
||||
interval: 4h
|
||||
preferredChannels:
|
||||
- slack-ingestion
|
||||
ruleType: anomaly_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_promql:
|
||||
description: PromQL expression instead of the builder. Dotted OTEL
|
||||
@@ -26171,13 +26399,13 @@ paths:
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_anomaly:
|
||||
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
|
||||
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)
|
||||
value:
|
||||
alert: Anomalous drop in ingested spans
|
||||
alertType: METRIC_BASED_ALERT
|
||||
@@ -26212,36 +26440,22 @@ paths:
|
||||
stepInterval: 21600
|
||||
type: builder_query
|
||||
queryType: builder
|
||||
matchType: all_the_times
|
||||
op: below
|
||||
requireMinPoints: true
|
||||
requiredNumPoints: 3
|
||||
seasonality: daily
|
||||
selectedQueryName: A
|
||||
thresholds:
|
||||
kind: basic
|
||||
spec:
|
||||
- channels:
|
||||
- slack-ingestion
|
||||
matchType: all_the_times
|
||||
name: warning
|
||||
op: below
|
||||
target: -2
|
||||
target: 2
|
||||
description: Detect an abrupt drop in span ingestion using a z-score
|
||||
anomaly function
|
||||
evaluation:
|
||||
kind: rolling
|
||||
spec:
|
||||
evalWindow: 24h
|
||||
frequency: 3h
|
||||
evalWindow: 24h
|
||||
frequency: 3h
|
||||
labels:
|
||||
severity: warning
|
||||
notificationSettings:
|
||||
renotify:
|
||||
alertStates:
|
||||
- firing
|
||||
enabled: true
|
||||
interval: 4h
|
||||
preferredChannels:
|
||||
- slack-ingestion
|
||||
ruleType: anomaly_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_promql:
|
||||
description: PromQL expression instead of the builder. Dotted OTEL
|
||||
@@ -27038,13 +27252,13 @@ paths:
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_anomaly:
|
||||
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
|
||||
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)
|
||||
value:
|
||||
alert: Anomalous drop in ingested spans
|
||||
alertType: METRIC_BASED_ALERT
|
||||
@@ -27079,36 +27293,22 @@ paths:
|
||||
stepInterval: 21600
|
||||
type: builder_query
|
||||
queryType: builder
|
||||
matchType: all_the_times
|
||||
op: below
|
||||
requireMinPoints: true
|
||||
requiredNumPoints: 3
|
||||
seasonality: daily
|
||||
selectedQueryName: A
|
||||
thresholds:
|
||||
kind: basic
|
||||
spec:
|
||||
- channels:
|
||||
- slack-ingestion
|
||||
matchType: all_the_times
|
||||
name: warning
|
||||
op: below
|
||||
target: -2
|
||||
target: 2
|
||||
description: Detect an abrupt drop in span ingestion using a z-score
|
||||
anomaly function
|
||||
evaluation:
|
||||
kind: rolling
|
||||
spec:
|
||||
evalWindow: 24h
|
||||
frequency: 3h
|
||||
evalWindow: 24h
|
||||
frequency: 3h
|
||||
labels:
|
||||
severity: warning
|
||||
notificationSettings:
|
||||
renotify:
|
||||
alertStates:
|
||||
- firing
|
||||
enabled: true
|
||||
interval: 4h
|
||||
preferredChannels:
|
||||
- slack-ingestion
|
||||
ruleType: anomaly_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_promql:
|
||||
description: PromQL expression instead of the builder. Dotted OTEL
|
||||
@@ -28611,13 +28811,13 @@ paths:
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_anomaly:
|
||||
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
|
||||
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)
|
||||
value:
|
||||
alert: Anomalous drop in ingested spans
|
||||
alertType: METRIC_BASED_ALERT
|
||||
@@ -28652,36 +28852,22 @@ paths:
|
||||
stepInterval: 21600
|
||||
type: builder_query
|
||||
queryType: builder
|
||||
matchType: all_the_times
|
||||
op: below
|
||||
requireMinPoints: true
|
||||
requiredNumPoints: 3
|
||||
seasonality: daily
|
||||
selectedQueryName: A
|
||||
thresholds:
|
||||
kind: basic
|
||||
spec:
|
||||
- channels:
|
||||
- slack-ingestion
|
||||
matchType: all_the_times
|
||||
name: warning
|
||||
op: below
|
||||
target: -2
|
||||
target: 2
|
||||
description: Detect an abrupt drop in span ingestion using a z-score
|
||||
anomaly function
|
||||
evaluation:
|
||||
kind: rolling
|
||||
spec:
|
||||
evalWindow: 24h
|
||||
frequency: 3h
|
||||
evalWindow: 24h
|
||||
frequency: 3h
|
||||
labels:
|
||||
severity: warning
|
||||
notificationSettings:
|
||||
renotify:
|
||||
alertStates:
|
||||
- firing
|
||||
enabled: true
|
||||
interval: 4h
|
||||
preferredChannels:
|
||||
- slack-ingestion
|
||||
ruleType: anomaly_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_promql:
|
||||
description: PromQL expression instead of the builder. Dotted OTEL
|
||||
|
||||
@@ -105,15 +105,15 @@ func (r *AnomalyRule) Type() ruletypes.RuleType {
|
||||
}
|
||||
|
||||
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) *qbtypes.QueryRangeRequest {
|
||||
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.window_ms", r.EvalWindow().Milliseconds()),
|
||||
slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()),
|
||||
)
|
||||
|
||||
startTs, endTs := r.Timestamps(ts)
|
||||
start, end := startTs.UnixMilli(), endTs.UnixMilli()
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
Start: uint64(start),
|
||||
End: uint64(end),
|
||||
@@ -420,10 +420,12 @@ 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(),
|
||||
Labels: r.Labels().Map(),
|
||||
Annotations: r.Annotations().Map(),
|
||||
AlertName: valuer.NewDisplayName(r.Name()),
|
||||
RuleCondition: r.Condition(),
|
||||
EvalWindow: r.EvalWindow(),
|
||||
Labels: r.Labels().Map(),
|
||||
Annotations: r.Annotations().Map(),
|
||||
PreferredChannels: r.PreferredChannels(),
|
||||
}
|
||||
|
||||
byt, err := json.Marshal(ar)
|
||||
|
||||
@@ -54,6 +54,9 @@ 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{{
|
||||
@@ -154,7 +157,10 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
|
||||
Frequency: valuer.MustParseTextDuration("1m"),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
AlertOnAbsent: true,
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
AlertOnAbsent: true,
|
||||
Target: &target,
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypeBuilder,
|
||||
Queries: []qbtypes.QueryEnvelope{{
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const DEFAULT_VISIBLE_ITEMS_COUNT = 10;
|
||||
@@ -2,9 +2,10 @@ import { useMemo, useState } from 'react';
|
||||
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
|
||||
import { DEFAULT_VISIBLE_ITEMS_COUNT } from './constants';
|
||||
import { isKeyMatch } from './utils';
|
||||
|
||||
const DEFAULT_VISIBLE_ITEMS_COUNT = 10;
|
||||
|
||||
interface UseCheckboxDisclosureProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
activeQueryIndex: number;
|
||||
|
||||
@@ -6,6 +6,44 @@
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.sectionActions {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease,
|
||||
width 0s linear 0.16s;
|
||||
}
|
||||
|
||||
.checkboxFilter:hover .sectionActions,
|
||||
.sectionActions.sectionActionsPinned {
|
||||
width: auto;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease;
|
||||
}
|
||||
|
||||
.sectionActionsPinned .sectionActionHoverOnly {
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease;
|
||||
}
|
||||
|
||||
.checkboxFilter:hover .sectionActionsPinned .sectionActionHoverOnly {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.search {
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
QuickFiltersSource,
|
||||
} from '../../../types';
|
||||
import CheckboxFilterV2 from './CheckboxFilterV2';
|
||||
import { DEFAULT_VISIBLE_ITEMS_COUNT } from '../constants';
|
||||
|
||||
export const DEFAULT_FILTER: IQuickFiltersConfig = {
|
||||
type: FiltersType.CHECKBOX,
|
||||
@@ -31,13 +30,6 @@ export const DEFAULT_USE_FIELD_APIS: QuickFilterCheckboxUseFieldApis = {
|
||||
existingQuery: null,
|
||||
};
|
||||
|
||||
// Enough extra values to push a section past the count that shows its search box.
|
||||
// Named to sort last, so a test's own values stay within the first page.
|
||||
export const EXTRA_VALUES = Array.from(
|
||||
{ length: DEFAULT_VISIBLE_ITEMS_COUNT },
|
||||
(_, index) => `zz-extra-value-${index + 1}`,
|
||||
);
|
||||
|
||||
export function mockFieldsValuesAPI(response: {
|
||||
relatedValues?: (string | null)[];
|
||||
stringValues?: (string | null)[];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Skeleton } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { LoaderCircle } from '@signozhq/icons';
|
||||
import {
|
||||
@@ -16,7 +17,6 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { NON_SELECTED_OPERATORS } from '../checkboxFilterQuery';
|
||||
import useActiveQueryIndex from 'components/QuickFilters/hooks/useActiveQueryIndex';
|
||||
import { DEFAULT_VISIBLE_ITEMS_COUNT } from '../constants';
|
||||
import useCheckboxDisclosure from '../useCheckboxDisclosure';
|
||||
import useCheckboxFilterActions from '../useCheckboxFilterActions';
|
||||
import useCheckboxFilterState from '../useCheckboxFilterState';
|
||||
@@ -45,8 +45,14 @@ export default function CheckboxFilterV2(
|
||||
const { source, filter, onFilterChange, onQuickFilterChange, useFieldApis } =
|
||||
props;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [isSearchOpen, setIsSearchOpen] = useState<boolean>(false);
|
||||
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
|
||||
|
||||
const handleToggleSearch = (): void => {
|
||||
setIsSearchOpen((prev) => !prev);
|
||||
setSearchText('');
|
||||
};
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
|
||||
@@ -115,14 +121,6 @@ export default function CheckboxFilterV2(
|
||||
setSearchText(args[0] as string);
|
||||
}, DEBOUNCE_DELAY);
|
||||
|
||||
const handleToggleOpen = (): void => {
|
||||
if (isOpen) {
|
||||
setSearchTextDebounced.cancel();
|
||||
setSearchText('');
|
||||
}
|
||||
onToggleOpen();
|
||||
};
|
||||
|
||||
const currentFilterOp = useMemo(() => {
|
||||
const filterSync = currentQuery?.builder.queryData?.[
|
||||
activeQueryIndex
|
||||
@@ -168,22 +166,19 @@ export default function CheckboxFilterV2(
|
||||
relatedExclusions,
|
||||
});
|
||||
|
||||
// Kept while a search is typed, so narrowing the list does not remove the box,
|
||||
// and while a refetch still shows the previous, narrowed results.
|
||||
const wasSearchShown = useRef(false);
|
||||
const isSearchShown =
|
||||
searchText !== '' ||
|
||||
totalCount > DEFAULT_VISIBLE_ITEMS_COUNT ||
|
||||
(isFetching && wasSearchShown.current);
|
||||
wasSearchShown.current = isSearchShown;
|
||||
|
||||
return (
|
||||
<div className={styles.checkboxFilter} data-testid="checkbox-filter-v2">
|
||||
<CheckboxFilterV2Header
|
||||
title={filter.title}
|
||||
isOpen={isOpen}
|
||||
canReset={isSomeFilterPresentForCurrentAttribute}
|
||||
onToggleOpen={handleToggleOpen}
|
||||
actionsClassName={classNames(styles.sectionActions, {
|
||||
[styles.sectionActionsPinned]: isSearchOpen,
|
||||
})}
|
||||
resetActionClassName={
|
||||
isSearchOpen ? styles.sectionActionHoverOnly : undefined
|
||||
}
|
||||
onToggleOpen={onToggleOpen}
|
||||
onToggleSearch={handleToggleSearch}
|
||||
onClear={onClear}
|
||||
/>
|
||||
{isOpen && isLoading && !hasLoadedOnce.current && (
|
||||
@@ -193,9 +188,10 @@ export default function CheckboxFilterV2(
|
||||
)}
|
||||
{isOpen && (!isLoading || hasLoadedOnce.current) && (
|
||||
<>
|
||||
{isSearchShown && (
|
||||
{isSearchOpen && (
|
||||
<section className={styles.search}>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Filter values"
|
||||
onChange={(e): void => setSearchTextDebounced(e.target.value)}
|
||||
disabled={isFilterDisabled}
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
import { useState } from 'react';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ChevronDown, ChevronRight, Undo2 } from '@signozhq/icons';
|
||||
import { ChevronDown, ChevronRight, Search, Undo2 } from '@signozhq/icons';
|
||||
|
||||
import { SectionActionButton } from '../../shared/SectionActionButton/SectionActionButton';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import styles from './CheckboxFilterV2Header.module.scss';
|
||||
|
||||
interface CheckboxFilterHeaderProps {
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
canReset: boolean;
|
||||
actionsClassName?: string;
|
||||
resetActionClassName?: string;
|
||||
onToggleOpen: () => void;
|
||||
onToggleSearch: () => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function CheckboxFilterV2Header({
|
||||
title,
|
||||
isOpen,
|
||||
canReset,
|
||||
actionsClassName,
|
||||
resetActionClassName,
|
||||
onToggleOpen,
|
||||
onToggleSearch,
|
||||
onClear,
|
||||
}: CheckboxFilterHeaderProps): JSX.Element {
|
||||
const [isTitleTruncated, setIsTitleTruncated] = useState(false);
|
||||
@@ -58,14 +64,21 @@ export function CheckboxFilterV2Header({
|
||||
)}
|
||||
<Tooltip title={isTitleTruncated ? title : undefined}>{titleText}</Tooltip>
|
||||
</section>
|
||||
{isOpen && canReset && (
|
||||
<section className={styles.rightAction}>
|
||||
{isOpen && (
|
||||
<section className={classNames(styles.rightAction, actionsClassName)}>
|
||||
<SectionActionButton
|
||||
icon={<Undo2 size={14} />}
|
||||
className={resetActionClassName}
|
||||
tooltip="Reset"
|
||||
onClick={onClear}
|
||||
testId="checkbox-filter-clear-all"
|
||||
/>
|
||||
<SectionActionButton
|
||||
icon={<Search size={14} />}
|
||||
tooltip="Search"
|
||||
onClick={onToggleSearch}
|
||||
testId="checkbox-filter-search-toggle"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
buildQueryBuilderOverrides,
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
EXTRA_VALUES,
|
||||
getFilterFromCall,
|
||||
mockFieldsValuesAPI,
|
||||
renderWithFilter,
|
||||
@@ -50,7 +49,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
const values =
|
||||
searchTextReceived === ''
|
||||
? ['production', 'staging', 'development', ...EXTRA_VALUES]
|
||||
? ['production', 'staging', 'development']
|
||||
: ['production'];
|
||||
|
||||
return res(
|
||||
@@ -80,6 +79,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
expect(screen.getByTestId('checkbox-value-row-staging')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
@@ -92,41 +92,6 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
screen.queryByTestId('checkbox-value-row-staging'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('checkbox-filter-search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no search box when every value fits', async () => {
|
||||
mockFieldsValuesAPI({ stringValues: EXTRA_VALUES });
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId(`checkbox-value-row-${EXTRA_VALUES[0]}`);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-search'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the search box once there are more values than fit', async () => {
|
||||
mockFieldsValuesAPI({ stringValues: ['production', ...EXTRA_VALUES] });
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('checkbox-filter-search'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters values via search while preserving existingQuery context', async () => {
|
||||
@@ -146,7 +111,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: ['production'],
|
||||
stringValues: ['staging', 'development', ...EXTRA_VALUES],
|
||||
stringValues: ['staging', 'development'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
@@ -198,6 +163,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
// Related values now appear in "Related" section (no badge, uses divider instead)
|
||||
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
@@ -225,10 +191,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues:
|
||||
searchText === ''
|
||||
? ['prod', 'staging', ...EXTRA_VALUES]
|
||||
: ['prod-match'],
|
||||
stringValues: searchText === '' ? ['prod', 'staging'] : ['prod-match'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
@@ -250,6 +213,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-prod');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
@@ -275,8 +239,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues:
|
||||
searchText === '' ? ['prod', 'staging', ...EXTRA_VALUES] : [],
|
||||
stringValues: searchText === '' ? ['prod', 'staging'] : [],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
@@ -295,6 +258,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-prod');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'xyz-no-match');
|
||||
|
||||
@@ -371,9 +335,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
const relatedValues =
|
||||
searchText === '' ? ['pod-a-v1', 'pod-b-v1', 'pod-c-v1'] : ['pod-a-v1']; // filtered to match 'pod-a'
|
||||
const stringValues =
|
||||
searchText === ''
|
||||
? ['pod-a-v2', 'pod-b-v2', 'pod-c-v2', ...EXTRA_VALUES]
|
||||
: ['pod-a-v2']; // filtered to match 'pod-a'
|
||||
searchText === '' ? ['pod-a-v2', 'pod-b-v2', 'pod-c-v2'] : ['pod-a-v2']; // filtered to match 'pod-a'
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
@@ -404,6 +366,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-pod-a-v1');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'pod-a');
|
||||
|
||||
@@ -538,56 +501,6 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
expect(header).toHaveAttribute('data-state', 'open');
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
});
|
||||
|
||||
it('clears the search when the filter is collapsed', async () => {
|
||||
const user = userEvent.setup();
|
||||
const values = Array.from(
|
||||
{ length: 12 },
|
||||
(_, i) => `value-${String(i).padStart(2, '0')}`,
|
||||
);
|
||||
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
const searchText = req.url.searchParams.get('searchText') || '';
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: values.filter((value) => value.includes(searchText)),
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-value-00');
|
||||
await user.type(screen.getByTestId('checkbox-filter-search'), '11');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-value-row-value-00'),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const header = screen.getByTestId('checkbox-filter-header');
|
||||
await user.click(header);
|
||||
await user.click(header);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-value-00');
|
||||
expect(screen.getByTestId('checkbox-filter-search')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('show more functionality', () => {
|
||||
@@ -683,7 +596,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides reset when the section has no filter applied', async () => {
|
||||
it('shows the reset action when expanded even with no active filter', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
@@ -698,9 +611,45 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
// Reset is always available on an expanded section now (hover-gated via
|
||||
// CSS), not conditional on an active filter.
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not dispatch on clear when the key has no filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: '' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
|
||||
|
||||
expect(onFilterChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onFilterChange when clear clicked', async () => {
|
||||
|
||||
@@ -9,7 +9,6 @@ import CheckboxFilterV2 from '../CheckboxFilterV2';
|
||||
import {
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
EXTRA_VALUES,
|
||||
mockFieldsValuesAPI,
|
||||
mockFieldsValuesAPILoading,
|
||||
setupServer,
|
||||
@@ -90,7 +89,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: ['production', 'staging', ...EXTRA_VALUES],
|
||||
stringValues: ['production', 'staging'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
@@ -111,6 +110,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ describe('CheckboxFilterV2Header', () => {
|
||||
const defaultProps = {
|
||||
title: 'Environment',
|
||||
isOpen: false,
|
||||
canReset: true,
|
||||
onToggleOpen: jest.fn(),
|
||||
onToggleSearch: jest.fn(),
|
||||
onClear: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -30,9 +30,12 @@ describe('CheckboxFilterV2Header', () => {
|
||||
expect(header).toHaveAttribute('data-state', 'closed');
|
||||
});
|
||||
|
||||
it('does not render reset when collapsed', () => {
|
||||
it('does not render the section actions when collapsed', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen={false} />);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-search-toggle'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -47,18 +50,13 @@ describe('CheckboxFilterV2Header', () => {
|
||||
expect(header).toHaveAttribute('data-state', 'open');
|
||||
});
|
||||
|
||||
it('renders reset when the section has a filter applied', () => {
|
||||
it('renders both search and reset actions when expanded', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen />);
|
||||
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides reset when there is nothing to reset', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen canReset={false} />);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
screen.getByTestId('checkbox-filter-search-toggle'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,6 +99,25 @@ describe('CheckboxFilterV2Header', () => {
|
||||
expect(onToggleOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onToggleSearch on search click without toggling open', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleSearch = jest.fn();
|
||||
const onToggleOpen = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header
|
||||
{...defaultProps}
|
||||
isOpen
|
||||
onToggleSearch={onToggleSearch}
|
||||
onToggleOpen={onToggleOpen}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
|
||||
|
||||
expect(onToggleSearch).toHaveBeenCalledTimes(1);
|
||||
expect(onToggleOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClear on reset click without toggling open', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClear = jest.fn();
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
import { SIDEBAR_TOOLTIP_DELAY_MS } from './constants';
|
||||
import { useCloseOnOutsidePress } from './useCloseOnOutsidePress';
|
||||
|
||||
import styles from './QuickFiltersLayout.module.scss';
|
||||
|
||||
@@ -62,20 +61,12 @@ function QuickFiltersLayout({
|
||||
|
||||
const isSliding = hasQuickFilters && isViewsListOpen;
|
||||
|
||||
const openViews = (): void => setIsViewsListOpen(true);
|
||||
const toggleViews = (): void => setIsViewsListOpen((isOpen) => !isOpen);
|
||||
const closeViews = (): void => setIsViewsListOpen(false);
|
||||
const markSidebarPress = useCloseOnOutsidePress(
|
||||
showSidebar && isSliding,
|
||||
closeViews,
|
||||
);
|
||||
|
||||
const savedViewsHeader = hasSavedViews ? (
|
||||
<SavedViewsHeader
|
||||
{...savedViewProps}
|
||||
onOpenViews={isViewsListVisible ? undefined : openViews}
|
||||
isViewsOpen={isViewsListOpen}
|
||||
onToggleViews={isViewsListPinned ? undefined : toggleViews}
|
||||
onOpenViews={
|
||||
isViewsListVisible ? undefined : (): void => setIsViewsListOpen(true)
|
||||
}
|
||||
onCollapse={hasQuickFilters ? undefined : onToggleFilters}
|
||||
/>
|
||||
) : undefined;
|
||||
@@ -86,7 +77,6 @@ function QuickFiltersLayout({
|
||||
{showSidebar && (
|
||||
<aside
|
||||
className={cx(styles.sidebar, { [styles.isStatic]: !hasQuickFilters })}
|
||||
onPointerDownCapture={markSidebarPress}
|
||||
data-testid="quick-filters-layout-filters"
|
||||
>
|
||||
<TooltipProvider delay={SIDEBAR_TOOLTIP_DELAY_MS}>
|
||||
@@ -108,7 +98,9 @@ function QuickFiltersLayout({
|
||||
{hasSavedViews && isViewsListVisible && (
|
||||
<SavedViewsPanel
|
||||
{...savedViewProps}
|
||||
onClose={isViewsListPinned ? undefined : closeViews}
|
||||
onClose={
|
||||
isViewsListPinned ? undefined : (): void => setIsViewsListOpen(false)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -218,62 +218,6 @@ describe('QuickFiltersLayout', () => {
|
||||
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens and closes the panel from the view name', async () => {
|
||||
render(
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
quickFilterProps={quickFilterProps}
|
||||
savedViewProps={savedViewProps}
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const toggle = await screen.findByTestId('saved-views-name-toggle');
|
||||
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
await user.click(toggle);
|
||||
|
||||
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
|
||||
expect(drawer()).toHaveClass('isOpen');
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
await user.click(toggle);
|
||||
|
||||
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
|
||||
expect(drawer()).not.toHaveClass('isOpen');
|
||||
});
|
||||
|
||||
it('closes the panel on a click outside the sidebar, not inside it', async () => {
|
||||
render(
|
||||
<>
|
||||
<button type="button" data-testid="page-tab">
|
||||
Pipelines
|
||||
</button>
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
quickFilterProps={quickFilterProps}
|
||||
savedViewProps={savedViewProps}
|
||||
>
|
||||
<div data-testid="page-content">content</div>
|
||||
</QuickFiltersLayout>
|
||||
</>,
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByTestId('saved-views-name-toggle'));
|
||||
await user.click(screen.getByTestId('saved-views-panel-header'));
|
||||
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('page-tab'));
|
||||
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
|
||||
expect(drawer()).not.toHaveClass('isOpen');
|
||||
|
||||
await user.click(await screen.findByTestId('saved-views-name-toggle'));
|
||||
await user.click(screen.getByTestId('page-content'));
|
||||
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hands the source to the header and the panel', async () => {
|
||||
render(
|
||||
<QuickFiltersLayout
|
||||
@@ -314,9 +258,6 @@ describe('QuickFiltersLayout', () => {
|
||||
expect(screen.getByTestId('saved-views-header')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('saved-views-name-toggle'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('saved-views-panel-header'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import { useCloseOnOutsidePress } from '../useCloseOnOutsidePress';
|
||||
|
||||
function Harness({
|
||||
enabled,
|
||||
onClose,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
onClose: () => void;
|
||||
}): JSX.Element {
|
||||
const markInside = useCloseOnOutsidePress(enabled, onClose);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div onPointerDownCapture={markInside}>
|
||||
<button type="button" data-testid="inside">
|
||||
inside
|
||||
</button>
|
||||
{createPortal(
|
||||
<button type="button" data-testid="portal">
|
||||
menu item
|
||||
</button>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
<button type="button" data-testid="outside">
|
||||
outside
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useCloseOnOutsidePress', () => {
|
||||
it('closes on a press outside', async () => {
|
||||
const onClose: jest.MockedFunction<() => void> = jest.fn();
|
||||
render(<Harness enabled onClose={onClose} />);
|
||||
|
||||
await userEvent.click(screen.getByTestId('outside'));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stays open on a press inside, including a portal rendered from inside', async () => {
|
||||
const onClose: jest.MockedFunction<() => void> = jest.fn();
|
||||
render(<Harness enabled onClose={onClose} />);
|
||||
|
||||
await userEvent.click(screen.getByTestId('inside'));
|
||||
await userEvent.click(screen.getByTestId('portal'));
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing while disabled', async () => {
|
||||
const onClose: jest.MockedFunction<() => void> = jest.fn();
|
||||
render(<Harness enabled={false} onClose={onClose} />);
|
||||
|
||||
await userEvent.click(screen.getByTestId('outside'));
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Calls `onClose` on any press outside the element given the returned handler as
|
||||
* `onPointerDownCapture`. Menus and dialogs rendered in portals still count as
|
||||
* inside: React events follow the component tree, so their presses reach the
|
||||
* handler before the document listener runs.
|
||||
*/
|
||||
export function useCloseOnOutsidePress(
|
||||
enabled: boolean,
|
||||
onClose: () => void,
|
||||
): () => void {
|
||||
const isPressInside = useRef(false);
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return undefined;
|
||||
}
|
||||
isPressInside.current = false;
|
||||
|
||||
const handlePointerDown = (): void => {
|
||||
if (isPressInside.current) {
|
||||
isPressInside.current = false;
|
||||
return;
|
||||
}
|
||||
onCloseRef.current();
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
return (): void =>
|
||||
document.removeEventListener('pointerdown', handlePointerDown);
|
||||
}, [enabled]);
|
||||
|
||||
return useCallback((): void => {
|
||||
isPressInside.current = true;
|
||||
}, []);
|
||||
}
|
||||
@@ -338,7 +338,7 @@ export function buildCreateAnomalyAlertRulePayload(
|
||||
notificationSettings: notificationSettingsProps,
|
||||
evaluation: evaluationProps,
|
||||
version: '',
|
||||
schemaVersion: 'v2alpha1',
|
||||
schemaVersion: '',
|
||||
source: window?.location.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
SavedViewCreatePermission,
|
||||
SavedViewListPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
import {
|
||||
MY_VIEW_NAME,
|
||||
@@ -26,7 +25,6 @@ import { useSaveNewView } from './hooks/useSaveNewView';
|
||||
import { useSavedViewActions } from './hooks/useSavedViewActions';
|
||||
import SaveChangesMenu from './SaveChangesMenu';
|
||||
import SavedViewsIconButton from './SavedViewsIconButton';
|
||||
import SavedViewsNameToggle from './SavedViewsNameToggle';
|
||||
import SaveViewModal from './SaveViewModal';
|
||||
import { SaveViewModalMode } from './types';
|
||||
|
||||
@@ -35,16 +33,11 @@ import styles from './SavedViewsHeader.module.scss';
|
||||
function SavedViewsHeader({
|
||||
source,
|
||||
onOpenViews,
|
||||
isViewsOpen = false,
|
||||
onToggleViews,
|
||||
onCollapse,
|
||||
}: {
|
||||
source: SavedviewtypesSourceDTO;
|
||||
// Absent while the list is on screen.
|
||||
onOpenViews?: () => void;
|
||||
isViewsOpen?: boolean;
|
||||
// Absent while the list is pinned open.
|
||||
onToggleViews?: () => void;
|
||||
onCollapse?: () => void;
|
||||
}): JSX.Element {
|
||||
const {
|
||||
@@ -57,11 +50,6 @@ function SavedViewsHeader({
|
||||
} = useActiveSavedView(source);
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const readPermission = buildSavedViewReadPermission(viewKey);
|
||||
const { isLoading: isCheckingList, deniedPermissions: deniedList } = useAuthZ(
|
||||
[SavedViewListPermission],
|
||||
{ enabled: !!onToggleViews },
|
||||
);
|
||||
const canListViews = !isCheckingList && deniedList.length === 0;
|
||||
const { revertView, clearView, createView, updateView, isSaving } =
|
||||
useSavedViewActions(source);
|
||||
|
||||
@@ -95,9 +83,6 @@ function SavedViewsHeader({
|
||||
name = SAVED_VIEW_LOAD_FAILED_NAME;
|
||||
}
|
||||
const isDirty = !isError && !!view && hasUnsavedChanges;
|
||||
// Same gates as the All views button; an open panel can always be closed.
|
||||
const canToggleViews =
|
||||
isViewsOpen || (canListViews && !isDirty && !isReadDenied);
|
||||
|
||||
const nameChip = (
|
||||
<span
|
||||
@@ -138,17 +123,10 @@ function SavedViewsHeader({
|
||||
className={styles.skeleton}
|
||||
testId="saved-views-loading"
|
||||
/>
|
||||
) : isReadDenied ? (
|
||||
<AuthZTooltip checks={[readPermission]}>{nameChip}</AuthZTooltip>
|
||||
) : (
|
||||
<SavedViewsNameToggle
|
||||
isOpen={isViewsOpen}
|
||||
onToggle={canToggleViews ? onToggleViews : undefined}
|
||||
>
|
||||
{isReadDenied ? (
|
||||
<AuthZTooltip checks={[readPermission]}>{nameChip}</AuthZTooltip>
|
||||
) : (
|
||||
<Tooltip title={errorMessage ?? name}>{nameChip}</Tooltip>
|
||||
)}
|
||||
</SavedViewsNameToggle>
|
||||
<Tooltip title={errorMessage ?? name}>{nameChip}</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
.toggle {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
--button-height: auto;
|
||||
// A flex label lets the chip shrink and use its own ellipsis.
|
||||
--button-label-display: flex;
|
||||
}
|
||||
|
||||
// The chip inherits its type from the button; doubled to outrank the button's
|
||||
// size rule so the chip reads the same as when it is not a toggle.
|
||||
.toggle.toggle > button {
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import styles from './SavedViewsNameToggle.module.scss';
|
||||
|
||||
function SavedViewsNameToggle({
|
||||
isOpen,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onToggle?: () => void;
|
||||
children: ReactElement;
|
||||
}): JSX.Element {
|
||||
if (!onToggle) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={styles.toggle}>
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
textOverflow="hidden"
|
||||
onClick={onToggle}
|
||||
aria-expanded={isOpen}
|
||||
testId="saved-views-name-toggle"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default SavedViewsNameToggle;
|
||||
@@ -52,7 +52,6 @@ function renderHeader(
|
||||
<SavedViewsHeader
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={jest.fn()}
|
||||
onToggleViews={jest.fn()}
|
||||
/>,
|
||||
url,
|
||||
);
|
||||
@@ -263,46 +262,4 @@ describe('SavedViews - AuthZ', () => {
|
||||
expect(screen.queryAllByText(message).length).toBeGreaterThan(0),
|
||||
);
|
||||
});
|
||||
|
||||
describe('view name toggle', () => {
|
||||
it('toggles the views from the name when list is allowed', async () => {
|
||||
mockSavedViewsApi([]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('saved-views-name-toggle'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves the name plain without list, the All views button says why', async () => {
|
||||
mockSavedViewsApi([]);
|
||||
server.use(setupAuthzDeny(SavedViewListPermission));
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-open')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId('saved-views-name-toggle'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves the name plain on a view it cannot read', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
|
||||
renderHeader(viewUrl(PATH, errors));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
|
||||
'No view access',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId('saved-views-name-toggle'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,14 +30,11 @@ const PATH = ROUTES.TRACES_EXPLORER;
|
||||
|
||||
function renderHeader(
|
||||
url: string,
|
||||
isViewsOpen = false,
|
||||
): ReturnType<typeof renderWithExplorerProviders> {
|
||||
return renderWithExplorerProviders(
|
||||
<SavedViewsHeader
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={jest.fn()}
|
||||
isViewsOpen={isViewsOpen}
|
||||
onToggleViews={jest.fn()}
|
||||
/>,
|
||||
url,
|
||||
);
|
||||
@@ -48,9 +45,8 @@ const isModalOpen = (): boolean => !!screen.queryByTestId('save-view-modal');
|
||||
|
||||
async function openDirtyView(
|
||||
view: ReturnType<typeof makeView>,
|
||||
isViewsOpen = false,
|
||||
): Promise<ReturnType<typeof renderWithExplorerProviders>> {
|
||||
const rendered = renderHeader(viewUrl(PATH, view), isViewsOpen);
|
||||
const rendered = renderHeader(viewUrl(PATH, view));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
|
||||
view.spec.displayName,
|
||||
@@ -230,36 +226,4 @@ describe('SavedViewsHeader saving', () => {
|
||||
);
|
||||
expect(requests.created).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('view name toggle with unsaved changes', () => {
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
|
||||
it('stops opening the views from the name once the view changes', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(viewUrl(PATH, errors));
|
||||
await screen.findByTestId('saved-views-name-toggle');
|
||||
|
||||
act(() => {
|
||||
history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'has_error = false'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: errors.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await screen.findByTestId('saved-views-discard');
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('saved-views-name-toggle'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still closes open views from the name', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
await openDirtyView(errors, true);
|
||||
|
||||
expect(screen.getByTestId('saved-views-name-toggle')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ 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 {
|
||||
@@ -237,6 +239,37 @@ 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,13 +245,25 @@ 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")
|
||||
service.settings.Logger().InfoContext(ctx, "mismatch found, updating config to match channels and matchers")
|
||||
return config, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,20 @@ 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,15 +221,16 @@ func postableRuleExamples() []handler.OpenAPIExample {
|
||||
},
|
||||
{
|
||||
Name: "metric_anomaly",
|
||||
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.",
|
||||
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.",
|
||||
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",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"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",
|
||||
"condition": map[string]any{
|
||||
"compositeQuery": map[string]any{
|
||||
"queryType": "builder",
|
||||
@@ -255,29 +256,17 @@ 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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"evaluation": rolling("24h", "3h"),
|
||||
"notificationSettings": map[string]any{
|
||||
"renotify": renotify("4h", "firing"),
|
||||
},
|
||||
"labels": map[string]any{"severity": "warning"},
|
||||
"labels": map[string]any{"severity": "warning"},
|
||||
"preferredChannels": []any{"slack-ingestion"},
|
||||
"annotations": map[string]any{
|
||||
"description": "Ingestion rate for tenant {{$tenant_id}} is anomalously low (z-score {{$value}}).",
|
||||
"summary": "Span ingestion anomaly",
|
||||
|
||||
@@ -59,7 +59,11 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
|
||||
}
|
||||
}
|
||||
if builder.evaluation == nil {
|
||||
return nil
|
||||
evalWindow := rule.EvalWindow
|
||||
if evalWindow.IsZero() {
|
||||
evalWindow = valuer.MustParseTextDuration("5m")
|
||||
}
|
||||
builder.evaluation = ruletypes.RollingWindow{EvalWindow: evalWindow}
|
||||
}
|
||||
|
||||
return builder
|
||||
|
||||
@@ -3,8 +3,10 @@ package app
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/clickhousesql"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -358,6 +360,14 @@ 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)
|
||||
@@ -486,6 +496,166 @@ 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,6 +34,10 @@ 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
|
||||
|
||||
@@ -45,7 +49,10 @@ type BaseRule struct {
|
||||
// these are the same for all alerts created for this rule
|
||||
labels ruletypes.Labels
|
||||
annotations ruletypes.Labels
|
||||
mtx sync.Mutex
|
||||
// preferredChannels is the list of channels to send the alert to
|
||||
// if the rule is triggered
|
||||
preferredChannels []string
|
||||
mtx sync.Mutex
|
||||
// the time it took to evaluate the rule (most recent evaluation)
|
||||
evaluationDuration time.Duration
|
||||
// the timestamp of the last evaluation
|
||||
@@ -144,27 +151,26 @@ 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,
|
||||
labels: ruletypes.FromMap(p.Labels),
|
||||
annotations: ruletypes.FromMap(p.Annotations),
|
||||
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,
|
||||
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,
|
||||
}
|
||||
|
||||
// Store newGroupEvalDelay and groupBy keys from NotificationSettings
|
||||
@@ -172,6 +178,10 @@ func NewBaseRule(
|
||||
baseRule.newGroupEvalDelay = p.NotificationSettings.NewGroupEvalDelay
|
||||
}
|
||||
|
||||
if baseRule.evalWindow.IsZero() {
|
||||
baseRule.evalWindow = valuer.MustParseTextDuration("5m")
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(baseRule)
|
||||
}
|
||||
@@ -222,6 +232,10 @@ 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
|
||||
}
|
||||
@@ -232,6 +246,7 @@ 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{}
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/cache"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
@@ -351,20 +350,6 @@ func (m *Manager) validateChannels(ctx context.Context, orgID string, rule *rule
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeRule(ruleStr string, rule *ruletypes.PostableRule) error {
|
||||
err := json.Unmarshal([]byte(ruleStr), rule)
|
||||
if err == nil {
|
||||
// the alias has no UnmarshalJSON, so a strict pass sees every key the rule has no field for
|
||||
type plainRule ruletypes.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)
|
||||
}
|
||||
|
||||
// EditRule writes the rule definition to the
|
||||
// datastore and also updates the rule executor
|
||||
func (m *Manager) EditRule(ctx context.Context, ruleStr string, id valuer.UUID) error {
|
||||
@@ -374,7 +359,7 @@ func (m *Manager) EditRule(ctx context.Context, ruleStr string, id valuer.UUID)
|
||||
}
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
parsedRule := ruletypes.PostableRule{}
|
||||
err = decodeRule(ruleStr, &parsedRule)
|
||||
err = json.Unmarshal([]byte(ruleStr), &parsedRule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -453,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 err
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "error preparing rule with given parameters, previous rule set restored")
|
||||
}
|
||||
|
||||
for _, r := range newTask.Rules() {
|
||||
@@ -562,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 := decodeRule(ruleStr, &parsedRule); err != nil {
|
||||
if err := json.Unmarshal([]byte(ruleStr), &parsedRule); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -674,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 err
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "error loading rules, previous rule set restored")
|
||||
}
|
||||
|
||||
for _, r := range newTask.Rules() {
|
||||
@@ -1064,7 +1049,7 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := decodeRule(ruleStr, &storedRule); err != nil {
|
||||
if err := json.Unmarshal([]byte(ruleStr), &storedRule); 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
|
||||
}
|
||||
@@ -1120,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 := decodeRule(ruleStr, &parsedRule)
|
||||
err := json.Unmarshal([]byte(ruleStr), &parsedRule)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to unmarshal rule")
|
||||
}
|
||||
if err := parsedRule.Validate(); err != nil {
|
||||
return 0, err
|
||||
|
||||
@@ -49,12 +49,13 @@ func ThresholdRuleAtLeastOnceValueAbove(target float64, recovery *float64) rulet
|
||||
Annotations: map[string]string{
|
||||
"value": "{{$value}}",
|
||||
},
|
||||
Version: "v5",
|
||||
SchemaVersion: valuer.MustNewUnsetOrNonEmptyString(ruletypes.SchemaVersionV2Alpha1),
|
||||
Version: "v5",
|
||||
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,
|
||||
@@ -107,10 +108,12 @@ func BuildPromAtLeastOnceValueAbove(target float64, recovery *float64) ruletypes
|
||||
Annotations: map[string]string{
|
||||
"value": "{{$value}}",
|
||||
},
|
||||
Version: "v5",
|
||||
SchemaVersion: valuer.MustNewUnsetOrNonEmptyString(ruletypes.SchemaVersionV2Alpha1),
|
||||
Version: "v5",
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
SelectedQuery: "A",
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
SelectedQuery: "A",
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
Target: &target,
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypePromQL,
|
||||
PanelType: ruletypes.PanelTypeGraph,
|
||||
@@ -135,6 +138,7 @@ func BuildPromAtLeastOnceValueAbove(target float64, recovery *float64) ruletypes
|
||||
RecoveryTarget: recovery,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
Channels: []string{"slack"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -356,10 +356,12 @@ 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,
|
||||
Labels: r.labels.Map(),
|
||||
Annotations: r.annotations.Map(),
|
||||
AlertName: valuer.NewDisplayName(r.name),
|
||||
RuleCondition: r.ruleCondition,
|
||||
EvalWindow: r.evalWindow,
|
||||
Labels: r.labels.Map(),
|
||||
Annotations: r.annotations.Map(),
|
||||
PreferredChannels: r.preferredChannels,
|
||||
}
|
||||
|
||||
byt, err := json.Marshal(ar)
|
||||
|
||||
@@ -690,11 +690,13 @@ 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,
|
||||
@@ -903,7 +905,11 @@ 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{
|
||||
@@ -1192,7 +1198,11 @@ 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{
|
||||
@@ -1249,7 +1259,6 @@ 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"),
|
||||
@@ -1260,6 +1269,8 @@ 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{
|
||||
@@ -1271,7 +1282,7 @@ func TestPromRule_NoData(t *testing.T) {
|
||||
},
|
||||
Thresholds: &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{{Name: "Test no data", TargetValue: &target, CompareOperator: ruletypes.ValueIsAbove, MatchType: ruletypes.AtleastOnce}},
|
||||
Spec: ruletypes.BasicRuleThresholds{{Name: "Test no data"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1354,7 +1365,10 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) {
|
||||
Frequency: valuer.MustParseTextDuration("1m"),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
AlertOnAbsent: true,
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
AlertOnAbsent: true,
|
||||
Target: &target,
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypePromQL,
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
@@ -1477,6 +1491,8 @@ 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{
|
||||
@@ -1546,9 +1562,9 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
|
||||
for _, c := range cases {
|
||||
rc := postableRule.RuleCondition
|
||||
|
||||
target := &targetForNoAlert
|
||||
rc.Target = &targetForNoAlert
|
||||
if c.alertCondition {
|
||||
target = &targetForAlert
|
||||
rc.Target = &targetForAlert
|
||||
}
|
||||
rc.RequireMinPoints = c.requireMinPoints
|
||||
rc.RequiredNumPoints = c.requiredNumPoints
|
||||
@@ -1557,9 +1573,9 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
Name: postableRule.AlertName.StringValue(),
|
||||
TargetValue: target,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
TargetValue: rc.Target,
|
||||
MatchType: rc.MatchType,
|
||||
CompareOperator: rc.CompareOperator,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -20,12 +20,15 @@ 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", end-start),
|
||||
slog.Int64("eval_window", r.evalWindow.Milliseconds()),
|
||||
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,10 +455,12 @@ 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,
|
||||
Labels: r.labels.Map(),
|
||||
Annotations: r.annotations.Map(),
|
||||
AlertName: valuer.NewDisplayName(r.name),
|
||||
RuleCondition: r.ruleCondition,
|
||||
EvalWindow: r.evalWindow,
|
||||
Labels: r.labels.Map(),
|
||||
Annotations: r.annotations.Map(),
|
||||
PreferredChannels: r.preferredChannels,
|
||||
}
|
||||
|
||||
byt, err := json.Marshal(ar)
|
||||
|
||||
@@ -63,7 +63,6 @@ func TestThresholdRuleEvalWithoutRecoveryTarget(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
Name: "critical",
|
||||
TargetValue: &c.target,
|
||||
MatchType: c.matchType,
|
||||
CompareOperator: c.compareOperator,
|
||||
@@ -172,7 +171,10 @@ func TestPrepareParamsForLogs(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
SelectedQuery: "A",
|
||||
CompareOperator: ruletypes.ValueIsNotEq,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
Target: &[]float64{0.0}[0],
|
||||
SelectedQuery: "A",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -181,10 +183,9 @@ func TestPrepareParamsForLogs(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
Name: "critical",
|
||||
TargetValue: &[]float64{0.0}[0],
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsNotEq,
|
||||
TargetValue: postableRule.RuleCondition.Target,
|
||||
MatchType: postableRule.RuleCondition.MatchType,
|
||||
CompareOperator: postableRule.RuleCondition.CompareOperator,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -238,7 +239,10 @@ func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
SelectedQuery: "A",
|
||||
CompareOperator: ruletypes.ValueIsNotEq,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
Target: &[]float64{0.0}[0],
|
||||
SelectedQuery: "A",
|
||||
},
|
||||
Version: "v5",
|
||||
}
|
||||
@@ -248,10 +252,9 @@ func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
Name: "critical",
|
||||
TargetValue: &[]float64{0.0}[0],
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsNotEq,
|
||||
TargetValue: postableRule.RuleCondition.Target,
|
||||
MatchType: postableRule.RuleCondition.MatchType,
|
||||
CompareOperator: postableRule.RuleCondition.CompareOperator,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -301,7 +304,10 @@ func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
SelectedQuery: "A",
|
||||
CompareOperator: ruletypes.ValueIsNotEq,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
Target: &[]float64{0.0}[0],
|
||||
SelectedQuery: "A",
|
||||
},
|
||||
Version: "v5",
|
||||
}
|
||||
@@ -311,10 +317,9 @@ func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
Name: "critical",
|
||||
TargetValue: &[]float64{0.0}[0],
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsNotEq,
|
||||
TargetValue: postableRule.RuleCondition.Target,
|
||||
MatchType: postableRule.RuleCondition.MatchType,
|
||||
CompareOperator: postableRule.RuleCondition.CompareOperator,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -361,7 +366,10 @@ func TestPrepareParamsForTraces(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
SelectedQuery: "A",
|
||||
CompareOperator: ruletypes.ValueIsNotEq,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
Target: &[]float64{0.0}[0],
|
||||
SelectedQuery: "A",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -370,10 +378,9 @@ func TestPrepareParamsForTraces(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
Name: "critical",
|
||||
TargetValue: &[]float64{0.0}[0],
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsNotEq,
|
||||
TargetValue: postableRule.RuleCondition.Target,
|
||||
MatchType: postableRule.RuleCondition.MatchType,
|
||||
CompareOperator: postableRule.RuleCondition.CompareOperator,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -463,7 +470,6 @@ func TestThresholdRuleLabelNormalization(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
Name: "critical",
|
||||
TargetValue: &c.target,
|
||||
MatchType: c.matchType,
|
||||
CompareOperator: c.compareOperator,
|
||||
@@ -647,7 +653,11 @@ 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{
|
||||
@@ -857,7 +867,11 @@ 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{
|
||||
@@ -961,7 +975,11 @@ 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{
|
||||
@@ -1065,7 +1083,11 @@ 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{
|
||||
@@ -1234,7 +1256,11 @@ 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{
|
||||
@@ -1904,6 +1930,8 @@ 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{{
|
||||
@@ -2022,6 +2050,7 @@ 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{
|
||||
@@ -2030,8 +2059,8 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
|
||||
{
|
||||
Name: postableRule.AlertName.StringValue(),
|
||||
TargetValue: &c.target,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
MatchType: rc.MatchType,
|
||||
CompareOperator: rc.CompareOperator,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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,6 +25,24 @@ 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,8 +266,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddMetricsFieldTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddGCPConnectionMethodFactory(),
|
||||
sqlmigration.NewDeleteOrphanSARolesFactory(),
|
||||
sqlmigration.NewMigrateRulesV1ToV2Alpha1Factory(sqlstore),
|
||||
sqlmigration.NewRepairRulesV2Alpha1Factory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,573 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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) convertOrg(ctx context.Context, orgID string) error {
|
||||
db := migration.sqlstore.BunDBCtx(ctx)
|
||||
|
||||
rules := make([]*ruleRow, 0)
|
||||
if err := db.NewSelect().Model(&rules).Where("org_id = ?", orgID).Scan(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
routes := make([]*routePolicyRow, 0)
|
||||
if err := db.NewSelect().Model(&routes).Where("org_id = ?", orgID).Scan(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
routesByName := make(map[string][]*routePolicyRow)
|
||||
for _, route := range routes {
|
||||
routesByName[route.Name] = append(routesByName[route.Name], route)
|
||||
}
|
||||
|
||||
var migrated, disabled, skipped, undecodable, unmigratable int
|
||||
for _, stored := range rules {
|
||||
var header struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stored.Data), &header); err != nil {
|
||||
undecodable++
|
||||
migration.logger.WarnContext(ctx, "failed to decode stored rule", slog.String("org_id", orgID), slog.String("rule_id", stored.ID), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
if header.SchemaVersion == schemaVersionV2Alpha1 {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
rule := new(postableRule)
|
||||
if err := json.Unmarshal([]byte(stored.Data), rule); err != nil {
|
||||
undecodable++
|
||||
migration.logger.WarnContext(ctx, "failed to decode stored rule", slog.String("org_id", orgID), slog.String("rule_id", stored.ID), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
|
||||
routes := routesByName[stored.ID]
|
||||
if err := validateRoutes(routes, rule.PreferredChannels); err != nil {
|
||||
unmigratable++
|
||||
migration.logger.WarnContext(ctx, "leaving rule at v1", slog.String("org_id", orgID), slog.String("rule_id", stored.ID), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
var route *routePolicyRow
|
||||
expression := ""
|
||||
if len(routes) == 1 {
|
||||
route = routes[0]
|
||||
expression = route.Expression
|
||||
}
|
||||
|
||||
if err := convertRule(stored.ID, rule, route); err != nil {
|
||||
unmigratable++
|
||||
migration.logger.WarnContext(ctx, "leaving rule at v1", slog.String("org_id", orgID), slog.String("rule_id", stored.ID), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
if reason := rule.validateThreshold(); reason != nil {
|
||||
rule.Disabled = json.RawMessage("true")
|
||||
disabled++
|
||||
migration.logger.WarnContext(ctx, "migrating rule as disabled, its threshold fails the write path", slog.String("org_id", orgID), slog.String("rule_id", stored.ID), errors.Attr(reason))
|
||||
}
|
||||
|
||||
data, err := marshalUnescaped(rule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stored.Data = string(data)
|
||||
if _, err := db.NewUpdate().Model(stored).Column("data").WherePK().Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if route != nil && route.Expression != expression {
|
||||
if _, err := db.NewUpdate().Model(route).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(rules)), slog.Int("migrated", migrated), slog.Int("disabled", disabled), slog.Int("skipped", skipped), slog.Int("undecodable", undecodable), slog.Int("unmigratable", unmigratable))
|
||||
return nil
|
||||
}
|
||||
|
||||
// convertRule rewrites rule in place, in the order the v1 decode derived the
|
||||
// same values. Version and source are left as stored. A nil route is a rule
|
||||
// that never had channels.
|
||||
func convertRule(ruleID string, rule *postableRule, route *routePolicyRow) error {
|
||||
if rule.RuleCondition == nil || rule.RuleCondition.CompositeQuery == nil {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition or condition.compositeQuery is null")
|
||||
}
|
||||
channels := []string{}
|
||||
if route != nil {
|
||||
channels = route.Channels
|
||||
}
|
||||
|
||||
rule.fillDurationDefaults()
|
||||
if err := rule.validateDurations(); err != nil {
|
||||
return err
|
||||
}
|
||||
rule.deriveAlertType()
|
||||
rule.deriveQueryType()
|
||||
rule.repairPanelType()
|
||||
rule.disableUnmetMinPoints()
|
||||
rule.deriveRuleType()
|
||||
rule.negateAnomalyBelowTarget()
|
||||
rule.repairEmptySeverity(ruleID, route)
|
||||
rule.relocateThreshold(channels)
|
||||
rule.relocateEvaluation()
|
||||
rule.synthesizeNotificationSettings()
|
||||
rule.SchemaVersion = schemaVersionV2Alpha1
|
||||
rule.clearV1Fields()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRoutes checks the rule's route_policy rows, 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 validateRoutes(routes []*routePolicyRow, preferredChannels []string) error {
|
||||
if len(routes) > 1 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "expected one route_policy row, found %d", len(routes))
|
||||
}
|
||||
if len(routes) == 0 && len(preferredChannels) > 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "no route_policy row for a rule with %d preferred channels", len(preferredChannels))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ruleRow struct {
|
||||
bun.BaseModel `bun:"table:rule"`
|
||||
|
||||
ID string `bun:"id,pk"`
|
||||
OrgID string `bun:"org_id"`
|
||||
Data string `bun:"data"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
// validateThreshold reports what the v1 write path once let through: a missing
|
||||
// target, or an op or matchType the v2 write path rejects. It reads the
|
||||
// relocated threshold, so it runs after convertRule.
|
||||
func (rule *postableRule) validateThreshold() error {
|
||||
threshold := rule.RuleCondition.Thresholds.Spec[0]
|
||||
if threshold.TargetValue == nil {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.target is null")
|
||||
}
|
||||
if !slices.Contains(supportedCompareOperators, threshold.CompareOperator) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.op: unsupported value %q", threshold.CompareOperator)
|
||||
}
|
||||
if !slices.Contains(supportedMatchTypes, threshold.MatchType) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.matchType: unsupported value %q", threshold.MatchType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rule *postableRule) fillDurationDefaults() {
|
||||
if rule.EvalWindow.IsZero() {
|
||||
rule.EvalWindow = mustParseTextDuration("5m")
|
||||
}
|
||||
if rule.Frequency.IsZero() {
|
||||
rule.Frequency = mustParseTextDuration("1m")
|
||||
}
|
||||
}
|
||||
|
||||
// validateDurations rejects what the v1 write path never checked: a negative
|
||||
// evalWindow or frequency, which no read of a rolling window accepts.
|
||||
func (rule *postableRule) validateDurations() error {
|
||||
if !rule.EvalWindow.IsPositive() || !rule.Frequency.IsPositive() {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "evalWindow %s or frequency %s is negative", rule.EvalWindow, rule.Frequency)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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 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 !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 {
|
||||
var envelopes []ruleQueryEnvelope
|
||||
if err := json.Unmarshal(rule.RuleCondition.CompositeQuery.Queries, &envelopes); err != nil {
|
||||
return nil
|
||||
}
|
||||
return envelopes
|
||||
}
|
||||
|
||||
func (rule *postableRule) deriveRuleType() {
|
||||
switch rule.RuleCondition.CompositeQuery.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.
|
||||
func (rule *postableRule) repairEmptySeverity(ruleID string, route *routePolicyRow) {
|
||||
if severity, ok := rule.Labels[labelSeverity]; !ok || severity != "" {
|
||||
return
|
||||
}
|
||||
rule.Labels[labelSeverity] = criticalThresholdName
|
||||
if route != nil {
|
||||
route.Expression = fmt.Sprintf(`threshold.name == "%s" && ruleId == "%s"`, criticalThresholdName, ruleID)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func (migration *migrateRulesV1ToV2Alpha1) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
type repairRulesV2Alpha1 struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewRepairRulesV2Alpha1Factory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("repair_rules_v2alpha1"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &repairRulesV2Alpha1{sqlstore: sqlstore, logger: ps.Logger}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *repairRulesV2Alpha1) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
// Up rewrites every v2alpha1 rule this release no longer decodes: a ruleType
|
||||
// or condition.seasonality of "" is dropped, so the read path fills it the way
|
||||
// it filled "", and a cumulative window with no timezone, or "", gets the UTC
|
||||
// an empty one always resolved to. Every other key keeps its stored bytes, and
|
||||
// a rule that needs nothing is not written.
|
||||
func (migration *repairRulesV2Alpha1) 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.repairOrg(ctx, orgID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *repairRulesV2Alpha1) repairOrg(ctx context.Context, orgID string) error {
|
||||
db := migration.sqlstore.BunDBCtx(ctx)
|
||||
|
||||
rules := make([]*ruleRow, 0)
|
||||
if err := db.NewSelect().Model(&rules).Where("org_id = ?", orgID).Scan(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var repaired, skipped, undecodable int
|
||||
for _, stored := range rules {
|
||||
var document map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(stored.Data), &document); err != nil {
|
||||
undecodable++
|
||||
migration.logger.WarnContext(ctx, "failed to decode stored rule", slog.String("org_id", orgID), slog.String("rule_id", stored.ID), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
// a rule still at v1 was reported by migration 140
|
||||
var schemaVersion string
|
||||
if err := json.Unmarshal(document["schemaVersion"], &schemaVersion); err != nil || schemaVersion != schemaVersionV2Alpha1 {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := repairEmptyStrings(document)
|
||||
if err != nil {
|
||||
undecodable++
|
||||
migration.logger.WarnContext(ctx, "failed to decode stored rule", slog.String("org_id", orgID), slog.String("rule_id", stored.ID), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
if data == "" {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
stored.Data = data
|
||||
if _, err := db.NewUpdate().Model(stored).Column("data").WherePK().Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
repaired++
|
||||
}
|
||||
|
||||
migration.logger.InfoContext(ctx, "repaired v2alpha1 rules", slog.String("org_id", orgID), slog.Int("total", len(rules)), slog.Int("repaired", repaired), slog.Int("skipped", skipped), slog.Int("undecodable", undecodable))
|
||||
return nil
|
||||
}
|
||||
|
||||
// repairEmptyStrings returns the rewritten document, or "" when it holds no
|
||||
// value that needs rewriting.
|
||||
func repairEmptyStrings(document map[string]json.RawMessage) (string, error) {
|
||||
changed := false
|
||||
if string(document["ruleType"]) == `""` {
|
||||
delete(document, "ruleType")
|
||||
changed = true
|
||||
}
|
||||
|
||||
var condition map[string]json.RawMessage
|
||||
if raw, ok := document["condition"]; ok {
|
||||
if err := json.Unmarshal(raw, &condition); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if string(condition["seasonality"]) == `""` {
|
||||
delete(condition, "seasonality")
|
||||
raw, err := marshalUnescaped(condition)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
document["condition"] = raw
|
||||
changed = true
|
||||
}
|
||||
|
||||
var evaluation struct {
|
||||
Kind string `json:"kind"`
|
||||
Spec map[string]json.RawMessage `json:"spec"`
|
||||
}
|
||||
if raw, ok := document["evaluation"]; ok {
|
||||
if err := json.Unmarshal(raw, &evaluation); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if timezone := string(evaluation.Spec["timezone"]); evaluation.Kind == "cumulative" && evaluation.Spec != nil && (timezone == "" || timezone == `""`) {
|
||||
evaluation.Spec["timezone"] = json.RawMessage(`"UTC"`)
|
||||
raw, err := marshalUnescaped(evaluation)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
document["evaluation"] = raw
|
||||
changed = true
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return "", nil
|
||||
}
|
||||
raw, err := marshalUnescaped(document)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
func (migration *repairRulesV2Alpha1) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -619,6 +619,10 @@ 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
|
||||
|
||||
@@ -123,10 +123,22 @@ func (d *DashboardSpec) validatePanels() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// validatePanelQueryCount left exactly one query for a spec that reads it.
|
||||
if spec, ok := panel.Spec.Plugin.Spec.(queryBoundPanelSpec); ok {
|
||||
if err := spec.validateAgainstQuery(panel.Spec.Queries[0], path+".spec.plugin.spec"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// queryBoundPanelSpec is a panel spec with fields that depend on the panel's
|
||||
// query. It is checked after the query itself validates.
|
||||
type queryBoundPanelSpec interface {
|
||||
validateAgainstQuery(query Query, path string) error
|
||||
}
|
||||
|
||||
func validatePanelQueryCount(queries []Query, panelKind PanelPluginKind, path string) error {
|
||||
if queries == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: is required and must not be null; use [] for a panel that renders without a query", path)
|
||||
|
||||
@@ -1870,6 +1870,326 @@ func TestInvalidateAreaChartPanelSpecValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScatterPlotPanelDefaults(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/ScatterPlotPanel",
|
||||
"spec": {}
|
||||
},
|
||||
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
d, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "unmarshal and validate failed")
|
||||
|
||||
require.IsType(t, &ScatterPlotPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
|
||||
spec := d.Panels["p1"].Spec.Plugin.Spec.(*ScatterPlotPanelSpec)
|
||||
|
||||
assert.Empty(t, spec.Dimensions.X, "an omitted x stays empty so the renderer picks the first value column")
|
||||
assert.Empty(t, spec.Dimensions.Y, "an omitted y stays empty so the renderer picks the second value column")
|
||||
assert.Empty(t, spec.Dimensions.ColorBy, "an omitted colorBy stays empty so the renderer colours by every group-by label")
|
||||
assert.Equal(t, "auto", spec.Axes.X.Scale.ValueOrDefault(), "expected x scale default auto")
|
||||
assert.Equal(t, "auto", spec.Axes.Y.Scale.ValueOrDefault(), "expected y scale default auto")
|
||||
assert.Nil(t, spec.Axes.X.SoftMin, "expected x softMin unset")
|
||||
assert.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
|
||||
assert.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
|
||||
assert.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
|
||||
|
||||
output, err := json.Marshal(d)
|
||||
require.NoError(t, err, "marshal dashboard failed")
|
||||
assert.Contains(t, string(output), `"axes":{"x":{"softMin":null,"softMax":null,"scale":"auto","label":""},"y":{"softMin":null,"softMax":null,"scale":"auto","label":""}}`, "expected default axes in stored/response JSON")
|
||||
assert.Contains(t, string(output), `"dimensions":{"x":"","y":"","sizeBy":"","colorBy":null}`, "expected empty dimensions in stored/response JSON")
|
||||
assert.Contains(t, string(output), `"chartAppearance":{"points":{"size":null,"minSize":null,"maxSize":null,"opacity":null}}`, "omitted point settings stay null so the renderer applies its defaults")
|
||||
}
|
||||
|
||||
func TestValidateScatterPlotDashboard(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/perses_scatter_plot_panel.json")
|
||||
require.NoError(t, err, "reading example file")
|
||||
|
||||
spec, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "unmarshal and validate failed")
|
||||
|
||||
require.IsType(t, &ScatterPlotPanelSpec{}, spec.Panels["p1"].Spec.Plugin.Spec)
|
||||
panelSpec := spec.Panels["p1"].Spec.Plugin.Spec.(*ScatterPlotPanelSpec)
|
||||
softMin, softMax := 0.0, 1000.0
|
||||
size, minSize, maxSize, opacity := PointDiameter(8), PointDiameter(4), PointDiameter(24), PointOpacity(0.1)
|
||||
assert.Equal(t, &ScatterPlotPanelSpec{
|
||||
Visualization: BasicVisualization{TimePreference: TimePreferenceLast1Hr},
|
||||
Dimensions: ScatterPlotDimensions{X: "A.count()", Y: "A.p99(duration_nano)", SizeBy: "A.countIf(has_error = true)", ColorBy: []string{"k8s.namespace.name", "k8s.pod.name"}},
|
||||
Formatting: TableFormatting{ColumnUnits: map[string]string{"A.p99(duration_nano)": "ns"}, DecimalPrecision: PrecisionOption1},
|
||||
Axes: ScatterPlotAxes{
|
||||
X: ScatterPlotAxis{SoftMin: &softMin, Scale: AxisScaleLog, Label: "Throughput"},
|
||||
Y: ScatterPlotAxis{SoftMax: &softMax, Scale: AxisScaleSymlog, Label: "p99 latency"},
|
||||
},
|
||||
ChartAppearance: ScatterPlotChartAppearance{Points: ScatterPlotPoints{Size: &size, MinSize: &minSize, MaxSize: &maxSize, Opacity: &opacity}},
|
||||
Legend: Legend{Position: LegendPositionRight, Mode: LegendModeList, CustomColors: map[string]string{"checkout": "#1f77b4"}},
|
||||
Thresholds: []ScatterPlotThreshold{
|
||||
{ThresholdWithLabel: ThresholdWithLabel{Value: 300, Unit: "ms", Color: "#f00", Label: "p99 SLO"}, Axis: ThresholdAxisY},
|
||||
{ThresholdWithLabel: ThresholdWithLabel{Value: 50, Color: "#0f0"}, Axis: ThresholdAxisX},
|
||||
},
|
||||
}, panelSpec)
|
||||
|
||||
dashboard := &DashboardV2{Spec: *spec}
|
||||
request, err := dashboard.GetPanelQuery(1, 2, "p1")
|
||||
require.NoError(t, err, "building the panel's query failed")
|
||||
assert.Equal(t, qb.RequestTypeScalar, request.RequestType)
|
||||
require.NotNil(t, request.FormatOptions)
|
||||
assert.True(t, request.FormatOptions.FormatTableResultForUI, "scatter rows are joined the way a table's are")
|
||||
|
||||
require.Len(t, request.CompositeQuery.Queries, 1)
|
||||
query, ok := request.CompositeQuery.Queries[0].Spec.(qb.QueryBuilderQuery[qb.TraceAggregation])
|
||||
require.True(t, ok, "expected a traces builder query")
|
||||
assert.Len(t, query.Aggregations, 3)
|
||||
assert.Len(t, query.GroupBy, 2)
|
||||
|
||||
require.NoError(t, request.Validate(), "the request built from the panel is not a valid scalar request")
|
||||
|
||||
// the panel read back out of storage draws the same scatter plot
|
||||
stored, err := json.Marshal(spec)
|
||||
require.NoError(t, err, "marshal dashboard failed")
|
||||
reread, err := unmarshalDashboard(stored)
|
||||
require.NoError(t, err, "the stored dashboard does not validate")
|
||||
assert.Equal(t, panelSpec, reread.Panels["p1"].Spec.Plugin.Spec)
|
||||
rereadRequest, err := (&DashboardV2{Spec: *reread}).GetPanelQuery(1, 2, "p1")
|
||||
require.NoError(t, err, "building the stored panel's query failed")
|
||||
assert.Equal(t, request, rereadRequest)
|
||||
}
|
||||
|
||||
func TestInvalidateScatterPlotPanelSpecValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
panelKind string
|
||||
panelSpec string
|
||||
expectedErrorSubstring string
|
||||
}{
|
||||
{
|
||||
scenario: "unknown axis scale",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"x": {"scale": "sqrt"}}}`,
|
||||
expectedErrorSubstring: "invalid axis scale \"sqrt\": must be `auto`, `linear`, `log`, or `symlog`",
|
||||
},
|
||||
{
|
||||
scenario: "non-string axis scale",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"y": {"scale": true}}}`,
|
||||
expectedErrorSubstring: "cannot unmarshal bool",
|
||||
},
|
||||
{
|
||||
scenario: "isLogScale on a scatter plot axis",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"x": {"isLogScale": true}}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "single-axis axes shape on a scatter plot panel",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"softMin": 0}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "unknown threshold axis",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"thresholds": [{"value": 1, "color": "#f00", "axis": "z"}]}`,
|
||||
expectedErrorSubstring: "invalid threshold axis \"z\": must be `x` or `y`",
|
||||
},
|
||||
{
|
||||
scenario: "unknown dimension",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"dimensions": {"z": "A"}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "point size below the minimum",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"size": 1}}}`,
|
||||
expectedErrorSubstring: "invalid point diameter 1: must be a whole number between 2 and 40",
|
||||
},
|
||||
{
|
||||
scenario: "max point size above the maximum",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"maxSize": 41}}}`,
|
||||
expectedErrorSubstring: "invalid point diameter 41",
|
||||
},
|
||||
{
|
||||
scenario: "fractional point size",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"minSize": 4.5}}}`,
|
||||
expectedErrorSubstring: "cannot unmarshal number 4.5",
|
||||
},
|
||||
{
|
||||
scenario: "min point size above max",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"minSize": 20, "maxSize": 10}}}`,
|
||||
expectedErrorSubstring: "minSize 20 must not exceed maxSize 10",
|
||||
},
|
||||
{
|
||||
scenario: "point opacity below the floor",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"opacity": 0.05}}}`,
|
||||
expectedErrorSubstring: "invalid point opacity 0.05: must be between 0.1 and 1",
|
||||
},
|
||||
{
|
||||
scenario: "point opacity as a percentage",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"opacity": 70}}}`,
|
||||
expectedErrorSubstring: "invalid point opacity 70",
|
||||
},
|
||||
{
|
||||
scenario: "area fill fields on a scatter plot",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"fillOpacity": 0.5}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "a single colour key as a string",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"dimensions": {"colorBy": "service.name"}}`,
|
||||
expectedErrorSubstring: "cannot unmarshal string",
|
||||
},
|
||||
{
|
||||
scenario: "panel-wide unit on a scatter plot panel",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"formatting": {"unit": "ms"}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "threshold without a color",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"thresholds": [{"value": 300}]}`,
|
||||
expectedErrorSubstring: "Color' Error:Field validation for 'Color' failed on the 'required' tag",
|
||||
},
|
||||
{
|
||||
scenario: "scale on a time series panel",
|
||||
panelKind: "signoz/TimeSeriesPanel",
|
||||
panelSpec: `{"axes": {"scale": "log"}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.scenario, func(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "` + test.panelKind + `", "spec": ` + test.panelSpec + `},
|
||||
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected the spec to be rejected")
|
||||
assert.Contains(t, err.Error(), test.expectedErrorSubstring, "unexpected error message: %s", err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScatterPlotDimensionsBindToQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
queryPlugin string
|
||||
dimensions string
|
||||
expectedErrorSubstring string
|
||||
}{
|
||||
{
|
||||
scenario: "a multi-aggregation query keys each value column by name and expression",
|
||||
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}, {"expression": "p99(duration_nano)"}]}}`,
|
||||
dimensions: `{"x": "A"}`,
|
||||
expectedErrorSubstring: "dimensions.x: \"A\" is not a value column of the panel's query; value columns: `A.count()`, `A.p99(duration_nano)`",
|
||||
},
|
||||
{
|
||||
scenario: "a colour key the query does not group by",
|
||||
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}}`,
|
||||
dimensions: `{"colorBy": ["service.name", "k8s.pod.name"]}`,
|
||||
expectedErrorSubstring: "dimensions.colorBy: \"k8s.pod.name\" is not a group-by key of the panel's query; group-by keys: `service.name`",
|
||||
},
|
||||
{
|
||||
scenario: "a formula is a value column and carries the labels of the disabled queries it reads",
|
||||
queryPlugin: `{"kind": "signoz/CompositeQuery", "spec": {"queries": [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "disabled": true, "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}},
|
||||
{"type": "builder_query", "spec": {"name": "B", "signal": "traces", "disabled": true, "aggregations": [{"expression": "countIf(has_error = true)"}], "groupBy": [{"name": "service.name"}]}},
|
||||
{"type": "builder_formula", "spec": {"name": "F1", "expression": "B / A"}}
|
||||
]}}`,
|
||||
dimensions: `{"x": "F1", "colorBy": ["service.name"]}`,
|
||||
},
|
||||
{
|
||||
scenario: "clickhouse columns are only known at run time, so the bindings go unchecked",
|
||||
queryPlugin: `{"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}`,
|
||||
dimensions: `{"x": "throughput", "colorBy": ["pod"]}`,
|
||||
},
|
||||
{
|
||||
scenario: "queries grouped by different keys have no rows that join into dots",
|
||||
queryPlugin: `{"kind": "signoz/CompositeQuery", "spec": {"queries": [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}},
|
||||
{"type": "builder_query", "spec": {"name": "B", "signal": "traces", "aggregations": [{"expression": "p99(duration_nano)"}], "groupBy": [{"name": "k8s.pod.name"}]}}
|
||||
]}}`,
|
||||
dimensions: `{"x": "A", "y": "B"}`,
|
||||
expectedErrorSubstring: "queries must group by the same keys so their rows join into dots; \"A\" groups by [`service.name`], \"B\" groups by [`k8s.pod.name`]",
|
||||
},
|
||||
{
|
||||
scenario: "group-by order does not matter, since rows join on the label set",
|
||||
queryPlugin: `{"kind": "signoz/CompositeQuery", "spec": {"queries": [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}, {"name": "k8s.pod.name"}]}},
|
||||
{"type": "builder_query", "spec": {"name": "B", "signal": "traces", "aggregations": [{"expression": "p99(duration_nano)"}], "groupBy": [{"name": "k8s.pod.name"}, {"name": "service.name"}]}}
|
||||
]}}`,
|
||||
dimensions: `{"x": "A", "y": "B", "colorBy": ["service.name"]}`,
|
||||
},
|
||||
{
|
||||
scenario: "every query counts toward the shared group-by, even one disabled and unread",
|
||||
queryPlugin: `{"kind": "signoz/CompositeQuery", "spec": {"queries": [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}},
|
||||
{"type": "builder_query", "spec": {"name": "B", "signal": "traces", "aggregations": [{"expression": "p99(duration_nano)"}], "groupBy": [{"name": "service.name"}]}},
|
||||
{"type": "builder_query", "spec": {"name": "C", "signal": "traces", "disabled": true, "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "k8s.pod.name"}]}}
|
||||
]}}`,
|
||||
dimensions: `{"x": "A", "y": "B"}`,
|
||||
expectedErrorSubstring: "\"A\" groups by [`service.name`], \"C\" groups by [`k8s.pod.name`]",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.scenario, func(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/ScatterPlotPanel", "spec": {"dimensions": ` + test.dimensions + `}},
|
||||
"queries": [{"kind": "scalar", "spec": {"plugin": ` + test.queryPlugin + `}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
if test.expectedErrorSubstring == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err, "expected the binding to be rejected")
|
||||
assert.Contains(t, err.Error(), test.expectedErrorSubstring, "unexpected error message: %s", err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumberPanelDefaults(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
@@ -2228,7 +2548,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
switch panelKind {
|
||||
case "signoz/ListPanel":
|
||||
return "raw"
|
||||
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel":
|
||||
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel", "signoz/ScatterPlotPanel":
|
||||
return "scalar"
|
||||
default:
|
||||
return "time_series"
|
||||
@@ -2272,14 +2592,17 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
{"List+Builder", mkQuery("signoz/ListPanel", "signoz/BuilderQuery", `{"name":"A","signal":"logs"}`), false},
|
||||
{"TimeSeries+AIBuilder", mkQuery("signoz/TimeSeriesPanel", "signoz/AIBuilderQuery", `{"name":"A","aggregations":[{"expression":"count()"}]}`), false},
|
||||
{"List+AIBuilder", mkQuery("signoz/ListPanel", "signoz/AIBuilderQuery", `{"name":"A"}`), false},
|
||||
{"ScatterPlot+ClickHouse", mkQuery("signoz/ScatterPlotPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), false},
|
||||
// Top-level: rejected
|
||||
{"Table+PromQL", mkQuery("signoz/TablePanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
|
||||
{"ScatterPlot+PromQL", mkQuery("signoz/ScatterPlotPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
|
||||
{"List+ClickHouse", mkQuery("signoz/ListPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), true},
|
||||
{"List+PromQL", mkQuery("signoz/ListPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
|
||||
{"List+Composite", mkQuery("signoz/ListPanel", "signoz/CompositeQuery", `{"queries":[]}`), true},
|
||||
{"List+Formula", mkQuery("signoz/ListPanel", "signoz/Formula", `{"name":"F1","expression":"A+B"}`), true},
|
||||
// Composite sub-queries
|
||||
{"Table+Composite(promql)", mkComposite("signoz/TablePanel", "promql", `{"name":"A","query":"up"}`), true},
|
||||
{"ScatterPlot+Composite(promql)", mkComposite("signoz/ScatterPlotPanel", "promql", `{"name":"A","query":"up"}`), true},
|
||||
{"Table+Composite(clickhouse)", mkComposite("signoz/TablePanel", "clickhouse_sql", `{"name":"A","query":"SELECT 1"}`), false},
|
||||
{"Table+Composite(builder_ai)", mkComposite("signoz/TablePanel", "builder_ai_query", `{"name":"A","aggregations":[{"expression":"count()"}]}`), false},
|
||||
}
|
||||
|
||||
@@ -28,16 +28,17 @@ type PanelPlugin struct {
|
||||
// (and strips the duplicate parent properties) after reflection.
|
||||
func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
return markDiscriminator(s, "kind", map[string]string{
|
||||
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
|
||||
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
|
||||
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
|
||||
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
|
||||
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
|
||||
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
|
||||
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
|
||||
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
|
||||
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
|
||||
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
|
||||
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
|
||||
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
|
||||
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
|
||||
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
|
||||
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
|
||||
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
|
||||
string(PanelKindScatterPlot): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -71,6 +72,7 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
|
||||
PanelPluginVariant[ListPanelSpec]{Kind: string(PanelKindList)},
|
||||
PanelPluginVariant[TextPanelSpec]{Kind: string(PanelKindText)},
|
||||
PanelPluginVariant[HeatmapPanelSpec]{Kind: string(PanelKindHeatmap)},
|
||||
PanelPluginVariant[ScatterPlotPanelSpec]{Kind: string(PanelKindScatterPlot)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,16 +236,17 @@ func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error
|
||||
|
||||
var (
|
||||
panelPluginSpecs = map[PanelPluginKind]func() any{
|
||||
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
|
||||
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
|
||||
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
|
||||
PanelKindNumber: func() any { return new(NumberPanelSpec) },
|
||||
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
PanelKindText: func() any { return new(TextPanelSpec) },
|
||||
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
|
||||
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
|
||||
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
|
||||
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
|
||||
PanelKindNumber: func() any { return new(NumberPanelSpec) },
|
||||
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
PanelKindText: func() any { return new(TextPanelSpec) },
|
||||
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
|
||||
PanelKindScatterPlot: func() any { return new(ScatterPlotPanelSpec) },
|
||||
}
|
||||
queryPluginSpecs = map[QueryPluginKind]func() any{
|
||||
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
|
||||
@@ -260,16 +263,17 @@ var (
|
||||
VariableKindCustom: func() any { return new(CustomVariableSpec) },
|
||||
}
|
||||
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindAreaChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindNumber: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindHistogram: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder, QueryKindAIBuilder},
|
||||
PanelKindText: {},
|
||||
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindAreaChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindNumber: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindHistogram: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder, QueryKindAIBuilder},
|
||||
PanelKindText: {},
|
||||
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindScatterPlot: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -221,7 +221,7 @@ func (d *DashboardV2) GetPanelQuery(startTime, endTime uint64, panelKey string)
|
||||
CompositeQuery: composite,
|
||||
FormatOptions: &qb.FormatOptions{
|
||||
FillGaps: fillGaps,
|
||||
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable,
|
||||
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable || panel.Spec.Plugin.Kind == PanelKindScatterPlot,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -251,34 +251,38 @@ func TestDashboardV2GetPanelQuery(t *testing.T) {
|
||||
assert.Equal(t, telemetrytypes.SignalTraces, spec.Signal)
|
||||
})
|
||||
|
||||
t.Run("sets FormatTableResultForUI only for table panels", func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: PanelKindTable},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeScalar,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
|
||||
t.Run("sets FormatTableResultForUI for panels that join scalar rows", func(t *testing.T) {
|
||||
for _, kind := range []PanelPluginKind{PanelKindTable, PanelKindScatterPlot} {
|
||||
t.Run(string(kind), func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: kind},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeScalar,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.FormatOptions)
|
||||
assert.True(t, req.FormatOptions.FormatTableResultForUI)
|
||||
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.FormatOptions)
|
||||
assert.True(t, req.FormatOptions.FormatTableResultForUI)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sets FillGaps from the panel visualization", func(t *testing.T) {
|
||||
|
||||
@@ -2,6 +2,8 @@ package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -186,20 +188,21 @@ func (AIBuilderQuerySpec) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
type PanelPluginKind string
|
||||
|
||||
const (
|
||||
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
|
||||
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
|
||||
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
|
||||
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
|
||||
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
|
||||
PanelKindTable PanelPluginKind = "signoz/TablePanel"
|
||||
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
|
||||
PanelKindList PanelPluginKind = "signoz/ListPanel"
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
|
||||
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
|
||||
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
|
||||
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
|
||||
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
|
||||
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
|
||||
PanelKindTable PanelPluginKind = "signoz/TablePanel"
|
||||
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
|
||||
PanelKindList PanelPluginKind = "signoz/ListPanel"
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
|
||||
PanelKindScatterPlot PanelPluginKind = "signoz/ScatterPlotPanel"
|
||||
)
|
||||
|
||||
func (PanelPluginKind) Enum() []any {
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap}
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap, PanelKindScatterPlot}
|
||||
}
|
||||
|
||||
func (k PanelPluginKind) rendersWithoutQuery() bool {
|
||||
@@ -349,6 +352,138 @@ type TextPanelSpec struct {
|
||||
HeaderOptions HeaderOptions `json:"headerOptions" description:"Whether the panel header is shown."`
|
||||
}
|
||||
|
||||
type ScatterPlotPanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Dimensions ScatterPlotDimensions `json:"dimensions"`
|
||||
Formatting TableFormatting `json:"formatting"`
|
||||
Axes ScatterPlotAxes `json:"axes"`
|
||||
ChartAppearance ScatterPlotChartAppearance `json:"chartAppearance"`
|
||||
Legend Legend `json:"legend"`
|
||||
Thresholds []ScatterPlotThreshold `json:"thresholds" validate:"dive"`
|
||||
}
|
||||
|
||||
func (s *ScatterPlotPanelSpec) validateAgainstQuery(query Query, path string) error {
|
||||
composite, err := query.Spec.Plugin.buildV5CompositeQueryFromPlugin()
|
||||
if err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.dimensions: %s", path, err.Error())
|
||||
}
|
||||
keys, known := composite.CollectResultColumnKeys()
|
||||
if !known {
|
||||
return nil
|
||||
}
|
||||
if err := s.validateQueriesShareGroupBy(composite, path); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.validateValueDimensions(keys, path); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.validateColorDimensions(keys, path); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Order is ignored since rows join on sorted labels; disabled queries count, as formulas carry their keys.
|
||||
func (*ScatterPlotPanelSpec) validateQueriesShareGroupBy(composite qb.CompositeQuery, path string) error {
|
||||
var queries []qb.QueryEnvelope
|
||||
for _, envelope := range composite.Queries {
|
||||
if envelope.GetType() != qb.QueryTypeFormula {
|
||||
queries = append(queries, envelope)
|
||||
}
|
||||
}
|
||||
for i := 1; i < len(queries); i++ {
|
||||
first, other := groupByKeyNames(queries[0]), groupByKeyNames(queries[i])
|
||||
if !slices.Equal(first, other) {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: queries must group by the same keys so their rows join into dots; %q groups by [%s], %q groups by [%s]", path, queries[0].GetQueryName(), allowedValuesForKind(first), queries[i].GetQueryName(), allowedValuesForKind(other))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ScatterPlotPanelSpec) validateValueDimensions(keys qb.ResultColumnKeys, path string) error {
|
||||
for _, binding := range []struct{ field, key string }{{"x", s.Dimensions.X}, {"y", s.Dimensions.Y}, {"sizeBy", s.Dimensions.SizeBy}} {
|
||||
// Empty is unset: the renderer picks a column or a fixed size.
|
||||
if binding.key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := keys.Aggregations[binding.key]; !ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.dimensions.%s: %q is not a value column of the panel's query; value columns: %s", path, binding.field, binding.key, allowedValuesForKind(slices.Sorted(maps.Keys(keys.Aggregations))))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ScatterPlotPanelSpec) validateColorDimensions(keys qb.ResultColumnKeys, path string) error {
|
||||
for _, label := range s.Dimensions.ColorBy {
|
||||
if _, ok := keys.Groups[label]; !ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.dimensions.colorBy: %q is not a group-by key of the panel's query; group-by keys: %s", path, label, allowedValuesForKind(slices.Sorted(maps.Keys(keys.Groups))))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func groupByKeyNames(envelope qb.QueryEnvelope) []string {
|
||||
names := make([]string, 0, len(envelope.GetGroupBy()))
|
||||
for _, key := range envelope.GetGroupBy() {
|
||||
names = append(names, key.Name)
|
||||
}
|
||||
slices.Sort(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// ScatterPlotDimensions binds result columns to what a dot encodes. Value columns
|
||||
// use the Table column key, so formatting.columnUnits carries over between the
|
||||
// two kinds.
|
||||
type ScatterPlotDimensions struct {
|
||||
X string `json:"x" description:"Value column key (queryName, or queryName.expression for a multi-aggregation query) plotted on the x axis. Empty uses the first value column."`
|
||||
Y string `json:"y" description:"Value column key plotted on the y axis. Empty uses the second value column."`
|
||||
SizeBy string `json:"sizeBy" description:"Value column key that scales dot size. Empty draws every dot at the default size."`
|
||||
ColorBy []string `json:"colorBy" description:"Group-by label names (e.g. k8s.namespace.name) whose combined values colour dots and drive the legend. Empty colours by every group-by label."`
|
||||
}
|
||||
|
||||
type ScatterPlotAxes struct {
|
||||
X ScatterPlotAxis `json:"x"`
|
||||
Y ScatterPlotAxis `json:"y"`
|
||||
}
|
||||
|
||||
type ScatterPlotAxis struct {
|
||||
SoftMin *float64 `json:"softMin"`
|
||||
SoftMax *float64 `json:"softMax"`
|
||||
Scale AxisScale `json:"scale"`
|
||||
Label string `json:"label" description:"Axis title. Empty draws none."`
|
||||
}
|
||||
|
||||
type ScatterPlotChartAppearance struct {
|
||||
Points ScatterPlotPoints `json:"points"`
|
||||
}
|
||||
|
||||
// ScatterPlotPoints keeps the fixed size and the size range side by side so
|
||||
// binding or unbinding dimensions.sizeBy restores the other's last setting. Nil
|
||||
// fields resolve to the renderer default.
|
||||
type ScatterPlotPoints struct {
|
||||
Size *PointDiameter `json:"size" description:"Diameter of every dot when dimensions.sizeBy is unset."`
|
||||
MinSize *PointDiameter `json:"minSize" description:"Diameter of the smallest dot when dimensions.sizeBy is set."`
|
||||
MaxSize *PointDiameter `json:"maxSize" description:"Diameter of the largest dot when dimensions.sizeBy is set."`
|
||||
Opacity *PointOpacity `json:"opacity"`
|
||||
}
|
||||
|
||||
func (p *ScatterPlotPoints) UnmarshalJSON(data []byte) error {
|
||||
type alias ScatterPlotPoints
|
||||
var tmp alias
|
||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid points")
|
||||
}
|
||||
*p = ScatterPlotPoints(tmp)
|
||||
return p.validate()
|
||||
}
|
||||
|
||||
func (p ScatterPlotPoints) validate() error {
|
||||
if p.MinSize != nil && p.MaxSize != nil && *p.MinSize > *p.MaxSize {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid points: minSize %d must not exceed maxSize %d", *p.MinSize, *p.MaxSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type TextPresentation struct {
|
||||
TextAlign TextAlign `json:"textAlign" description:"Horizontal alignment: left (default), center or right."`
|
||||
VerticalAlign VerticalAlign `json:"verticalAlign" description:"Vertical alignment: top (default), center or bottom."`
|
||||
@@ -430,6 +565,11 @@ type ComparisonThreshold struct {
|
||||
Format ThresholdFormat `json:"format" description:"What the color is applied to: text (default) or background."`
|
||||
}
|
||||
|
||||
type ScatterPlotThreshold struct {
|
||||
ThresholdWithLabel
|
||||
Axis ThresholdAxis `json:"axis"`
|
||||
}
|
||||
|
||||
type TableThreshold struct {
|
||||
ComparisonThreshold
|
||||
ColumnName string `json:"columnName" validate:"required" required:"true" description:"The key of the value column the rule applies to: the query name for a builder query with one aggregation (A), the query name dotted with the aggregation expression for several (A.count()), or the SQL alias for a ClickHouse SQL column. Group-by columns are never colored, and aggregation aliases are display labels, not keys."`
|
||||
@@ -715,6 +855,86 @@ func (ls *LineStyle) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
// AxisScale `auto` leaves the choice to the renderer; `symlog` is log-like but
|
||||
// defined at and below zero.
|
||||
type AxisScale struct{ valuer.String }
|
||||
|
||||
var (
|
||||
AxisScaleAuto = AxisScale{valuer.NewString("auto")} // default
|
||||
AxisScaleLinear = AxisScale{valuer.NewString("linear")}
|
||||
AxisScaleLog = AxisScale{valuer.NewString("log")}
|
||||
AxisScaleSymlog = AxisScale{valuer.NewString("symlog")}
|
||||
)
|
||||
|
||||
func (AxisScale) Enum() []any {
|
||||
return []any{AxisScaleAuto, AxisScaleLinear, AxisScaleLog, AxisScaleSymlog}
|
||||
}
|
||||
|
||||
func (as AxisScale) ValueOrDefault() string {
|
||||
if as.IsZero() {
|
||||
return AxisScaleAuto.StringValue()
|
||||
}
|
||||
return as.StringValue()
|
||||
}
|
||||
|
||||
func (as AxisScale) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(as.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (as *AxisScale) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid axis scale: must be a string, one of `auto`, `linear`, `log`, or `symlog`")
|
||||
}
|
||||
val := AxisScale{valuer.NewString(v)}
|
||||
switch val {
|
||||
case AxisScaleAuto, AxisScaleLinear, AxisScaleLog, AxisScaleSymlog:
|
||||
*as = val
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid axis scale %q: must be `auto`, `linear`, `log`, or `symlog`", v)
|
||||
}
|
||||
}
|
||||
|
||||
// ThresholdAxis is the axis a scatter plot threshold line marks: `y` draws it
|
||||
// horizontally, `x` vertically.
|
||||
type ThresholdAxis struct{ valuer.String }
|
||||
|
||||
var (
|
||||
ThresholdAxisX = ThresholdAxis{valuer.NewString("x")}
|
||||
ThresholdAxisY = ThresholdAxis{valuer.NewString("y")} // default
|
||||
)
|
||||
|
||||
func (ThresholdAxis) Enum() []any {
|
||||
return []any{ThresholdAxisX, ThresholdAxisY}
|
||||
}
|
||||
|
||||
func (ta ThresholdAxis) ValueOrDefault() string {
|
||||
if ta.IsZero() {
|
||||
return ThresholdAxisY.StringValue()
|
||||
}
|
||||
return ta.StringValue()
|
||||
}
|
||||
|
||||
func (ta ThresholdAxis) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(ta.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (ta *ThresholdAxis) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid threshold axis: must be a string, one of `x` or `y`")
|
||||
}
|
||||
val := ThresholdAxis{valuer.NewString(v)}
|
||||
switch val {
|
||||
case ThresholdAxisX, ThresholdAxisY:
|
||||
*ta = val
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid threshold axis %q: must be `x` or `y`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type FillMode struct{ valuer.String }
|
||||
|
||||
var (
|
||||
@@ -853,6 +1073,53 @@ func (o *FillOpacity) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
minPointDiameter = 2
|
||||
maxPointDiameter = 40
|
||||
)
|
||||
|
||||
// PointDiameter is a scatter dot's diameter in whole CSS pixels.
|
||||
type PointDiameter int
|
||||
|
||||
func (PointDiameter) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
s.WithMinimum(minPointDiameter).WithMaximum(maxPointDiameter)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *PointDiameter) UnmarshalJSON(data []byte) error {
|
||||
var v int
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid point diameter: must be a whole number between %d and %d", minPointDiameter, maxPointDiameter)
|
||||
}
|
||||
if v < minPointDiameter || v > maxPointDiameter {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid point diameter %d: must be a whole number between %d and %d", v, minPointDiameter, maxPointDiameter)
|
||||
}
|
||||
*d = PointDiameter(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
const minPointOpacity = 0.1
|
||||
|
||||
// PointOpacity is a scatter dot's fill alpha. The floor keeps a dot visible.
|
||||
type PointOpacity float64
|
||||
|
||||
func (PointOpacity) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
s.WithMinimum(minPointOpacity).WithMaximum(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *PointOpacity) UnmarshalJSON(data []byte) error {
|
||||
var v float64
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid point opacity: must be a number between %v and 1", minPointOpacity)
|
||||
}
|
||||
if v < minPointOpacity || v > 1 {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid point opacity %v: must be between %v and 1", v, minPointOpacity)
|
||||
}
|
||||
*o = PointOpacity(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
type SpanGaps struct {
|
||||
FillOnlyBelow bool `json:"fillOnlyBelow" description:"Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected."`
|
||||
FillLessThan string `json:"fillLessThan" description:"The largest gap still connected when fillOnlyBelow is true; longer gaps are left disconnected. Required in that case, as a positive Prometheus duration such as 30s, 5m or 1d."`
|
||||
|
||||
134
pkg/types/dashboardtypes/testdata/perses_scatter_plot_panel.json
vendored
Normal file
134
pkg/types/dashboardtypes/testdata/perses_scatter_plot_panel.json
vendored
Normal file
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"display": {
|
||||
"name": "service health",
|
||||
"description": "throughput against tail latency per pod"
|
||||
},
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "throughput vs p99",
|
||||
"description": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/ScatterPlotPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "last_1_hr"
|
||||
},
|
||||
"dimensions": {
|
||||
"x": "A.count()",
|
||||
"y": "A.p99(duration_nano)",
|
||||
"sizeBy": "A.countIf(has_error = true)",
|
||||
"colorBy": ["k8s.namespace.name", "k8s.pod.name"]
|
||||
},
|
||||
"formatting": {
|
||||
"columnUnits": {
|
||||
"A.p99(duration_nano)": "ns"
|
||||
},
|
||||
"decimalPrecision": "1"
|
||||
},
|
||||
"axes": {
|
||||
"x": {
|
||||
"softMin": 0,
|
||||
"softMax": null,
|
||||
"scale": "log",
|
||||
"label": "Throughput"
|
||||
},
|
||||
"y": {
|
||||
"softMin": null,
|
||||
"softMax": 1000,
|
||||
"scale": "symlog",
|
||||
"label": "p99 latency"
|
||||
}
|
||||
},
|
||||
"chartAppearance": {
|
||||
"points": {
|
||||
"size": 8,
|
||||
"minSize": 4,
|
||||
"maxSize": 24,
|
||||
"opacity": 0.1
|
||||
}
|
||||
},
|
||||
"legend": {
|
||||
"position": "right",
|
||||
"mode": "list",
|
||||
"customColors": {
|
||||
"checkout": "#1f77b4"
|
||||
}
|
||||
},
|
||||
"thresholds": [
|
||||
{
|
||||
"value": 300,
|
||||
"unit": "ms",
|
||||
"color": "#f00",
|
||||
"label": "p99 SLO",
|
||||
"axis": "y"
|
||||
},
|
||||
{
|
||||
"value": 50,
|
||||
"color": "#0f0",
|
||||
"axis": "x"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"queries": [
|
||||
{
|
||||
"kind": "scalar",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count()"
|
||||
},
|
||||
{
|
||||
"expression": "p99(duration_nano)"
|
||||
},
|
||||
{
|
||||
"expression": "countIf(has_error = true)"
|
||||
}
|
||||
],
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "k8s.namespace.name"
|
||||
},
|
||||
{
|
||||
"name": "k8s.pod.name"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 12,
|
||||
"height": 8,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/p1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -263,6 +263,43 @@ type CompositeQuery struct {
|
||||
Queries []QueryEnvelope `json:"queries" description:"The queries to run, each wrapped in a type-discriminated envelope. Names must be unique; formulas and trace operators reference other queries by name."`
|
||||
}
|
||||
|
||||
// ResultColumnKeys is what a scalar query yields per row, keyed the way the
|
||||
// UI keys columns: an aggregation column is the query name, or
|
||||
// queryName.expression when the query has several aggregations; a group
|
||||
// column is a group-by key name.
|
||||
type ResultColumnKeys struct {
|
||||
Aggregations map[string]struct{}
|
||||
Groups map[string]struct{}
|
||||
}
|
||||
|
||||
// CollectResultColumnKeys reports false when a query's columns are only known
|
||||
// at run time, as for ClickHouse SQL and PromQL. A disabled query yields no
|
||||
// aggregation column but keeps its group columns, since a formula over it
|
||||
// still carries them.
|
||||
func (c *CompositeQuery) CollectResultColumnKeys() (ResultColumnKeys, bool) {
|
||||
keys := ResultColumnKeys{Aggregations: map[string]struct{}{}, Groups: map[string]struct{}{}}
|
||||
for _, envelope := range c.Queries {
|
||||
if envelope.GetType() == QueryTypeClickHouseSQL || envelope.GetType() == QueryTypePromQL {
|
||||
return ResultColumnKeys{}, false
|
||||
}
|
||||
for _, key := range envelope.GetGroupBy() {
|
||||
keys.Groups[key.Name] = struct{}{}
|
||||
}
|
||||
if envelope.IsDisabled() {
|
||||
continue
|
||||
}
|
||||
expressions := envelope.GetAggregationExpressions()
|
||||
if len(expressions) <= 1 {
|
||||
keys.Aggregations[envelope.GetQueryName()] = struct{}{}
|
||||
continue
|
||||
}
|
||||
for _, expression := range expressions {
|
||||
keys.Aggregations[envelope.GetQueryName()+"."+expression] = struct{}{}
|
||||
}
|
||||
}
|
||||
return keys, true
|
||||
}
|
||||
|
||||
// PrepareJSONSchema adds description to the CompositeQuery schema.
|
||||
func (c *CompositeQuery) PrepareJSONSchema(schema *jsonschema.Schema) error {
|
||||
schema.WithDescription("Composite query containing one or more query envelopes. Each query envelope specifies its type and corresponding spec.")
|
||||
|
||||
@@ -225,6 +225,34 @@ func (q *QueryEnvelope) GetGroupBy() []GroupByKey {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAggregationExpressions returns one entry per aggregation. A metric
|
||||
// aggregation has no expression, so it contributes an empty entry.
|
||||
func (q *QueryEnvelope) GetAggregationExpressions() []string {
|
||||
switch spec := q.Spec.(type) {
|
||||
case QueryBuilderTraceOperator:
|
||||
expressions := make([]string, len(spec.Aggregations))
|
||||
for i, aggregation := range spec.Aggregations {
|
||||
expressions[i] = aggregation.Expression
|
||||
}
|
||||
return expressions
|
||||
case QueryBuilderQuery[TraceAggregation]:
|
||||
expressions := make([]string, len(spec.Aggregations))
|
||||
for i, aggregation := range spec.Aggregations {
|
||||
expressions[i] = aggregation.Expression
|
||||
}
|
||||
return expressions
|
||||
case QueryBuilderQuery[LogAggregation]:
|
||||
expressions := make([]string, len(spec.Aggregations))
|
||||
for i, aggregation := range spec.Aggregations {
|
||||
expressions[i] = aggregation.Expression
|
||||
}
|
||||
return expressions
|
||||
case QueryBuilderQuery[MetricAggregation]:
|
||||
return make([]string, len(spec.Aggregations))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFilter returns the filter.
|
||||
func (q *QueryEnvelope) GetFilter() *Filter {
|
||||
switch spec := q.Spec.(type) {
|
||||
|
||||
@@ -1903,3 +1903,63 @@ func TestQueryRangeRequest_StepIntervalForQuery(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeQueryCollectResultColumnKeys(t *testing.T) {
|
||||
testCases := []struct {
|
||||
scenario string
|
||||
composite CompositeQuery
|
||||
expectedKeys ResultColumnKeys
|
||||
expectedKnown bool
|
||||
}{
|
||||
{
|
||||
scenario: "a single aggregation is keyed by the query name",
|
||||
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[TraceAggregation]{
|
||||
Name: "A",
|
||||
Aggregations: []TraceAggregation{{Expression: "count()"}},
|
||||
GroupBy: []GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
|
||||
}}}},
|
||||
expectedKeys: ResultColumnKeys{Aggregations: map[string]struct{}{"A": {}}, Groups: map[string]struct{}{"service.name": {}}},
|
||||
expectedKnown: true,
|
||||
},
|
||||
{
|
||||
scenario: "several aggregations are keyed by query name and expression",
|
||||
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[LogAggregation]{
|
||||
Name: "A",
|
||||
Aggregations: []LogAggregation{{Expression: "count()"}, {Expression: "p99(duration_nano)"}},
|
||||
}}}},
|
||||
expectedKeys: ResultColumnKeys{Aggregations: map[string]struct{}{"A.count()": {}, "A.p99(duration_nano)": {}}, Groups: map[string]struct{}{}},
|
||||
expectedKnown: true,
|
||||
},
|
||||
{
|
||||
scenario: "a disabled query yields no aggregation but keeps its groups for the formula over it",
|
||||
composite: CompositeQuery{Queries: []QueryEnvelope{
|
||||
{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[MetricAggregation]{
|
||||
Name: "A",
|
||||
Disabled: true,
|
||||
Aggregations: []MetricAggregation{{MetricName: "http.server.request.count"}},
|
||||
GroupBy: []GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
|
||||
}},
|
||||
{Type: QueryTypeFormula, Spec: QueryBuilderFormula{Name: "F1", Expression: "A * 2"}},
|
||||
}},
|
||||
expectedKeys: ResultColumnKeys{Aggregations: map[string]struct{}{"F1": {}}, Groups: map[string]struct{}{"service.name": {}}},
|
||||
expectedKnown: true,
|
||||
},
|
||||
{
|
||||
scenario: "clickhouse columns are only known at run time",
|
||||
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeClickHouseSQL, Spec: ClickHouseQuery{
|
||||
Name: "A",
|
||||
Query: "SELECT 1",
|
||||
}}}},
|
||||
expectedKeys: ResultColumnKeys{},
|
||||
expectedKnown: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.scenario, func(t *testing.T) {
|
||||
keys, known := testCase.composite.CollectResultColumnKeys()
|
||||
assert.Equal(t, testCase.expectedKnown, known)
|
||||
assert.Equal(t, testCase.expectedKeys, keys)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,10 +32,5 @@ 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,9 +1,6 @@
|
||||
package ruletypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
import "github.com/SigNoz/signoz/pkg/valuer"
|
||||
|
||||
type AlertState struct {
|
||||
valuer.String
|
||||
@@ -29,15 +26,6 @@ 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,7 +5,6 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -78,15 +77,6 @@ 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
|
||||
|
||||
@@ -109,15 +99,6 @@ 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."`
|
||||
|
||||
@@ -128,13 +109,18 @@ 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" required:"true" description:"The thresholds to evaluate, as a kind and spec."`
|
||||
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."`
|
||||
}
|
||||
|
||||
func (rc *RuleCondition) SelectedQueryName() string {
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
@@ -49,6 +48,7 @@ func (t AlertType) BuilderQueryType() qbtypes.QueryType {
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultSchemaVersion = "v1"
|
||||
SchemaVersionV2Alpha1 = "v2alpha1"
|
||||
)
|
||||
|
||||
@@ -60,10 +60,12 @@ const (
|
||||
|
||||
// PostableRule is used to create alerting rule from HTTP api.
|
||||
type PostableRule struct {
|
||||
AlertName valuer.DisplayName `json:"alert" required:"true" description:"The display name of the alert rule."`
|
||||
AlertType AlertType `json:"alertType" required:"true" description:"The signal the rule evaluates: METRIC_BASED_ALERT, TRACES_BASED_ALERT, LOGS_BASED_ALERT, EXCEPTIONS_BASED_ALERT or AI_TRACES_BASED_ALERT."`
|
||||
Description string `json:"description,omitempty" description:"A human readable description of what the rule monitors."`
|
||||
RuleType RuleType `json:"ruleType" required:"true" description:"The evaluation strategy: threshold_rule compares against thresholds, promql_rule evaluates a PromQL expression, anomaly_rule scores deviation from seasonal history. Defaults to threshold_rule for builder and clickhouse_sql queries and is forced to promql_rule for promql queries."`
|
||||
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."`
|
||||
|
||||
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."`
|
||||
@@ -73,12 +75,14 @@ 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. 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 valuer.UnsetOrNonEmptyString `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" required:"true" description:"Grouping, re-notification and routing behaviour."`
|
||||
NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty" description:"Grouping, re-notification and routing behaviour. Required when schemaVersion is v2alpha1; derived from the v1 fields otherwise."`
|
||||
}
|
||||
|
||||
type NotificationSettings struct {
|
||||
@@ -223,13 +227,101 @@ 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 {
|
||||
r.RuleType = deriveRuleType(r.RuleType, r.RuleCondition.CompositeQuery.QueryType)
|
||||
switch r.RuleCondition.CompositeQuery.QueryType {
|
||||
case QueryTypeBuilder:
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,19 +335,6 @@ func (r *PostableRule) UnmarshalJSON(bytes []byte) error {
|
||||
return r.validate()
|
||||
}
|
||||
|
||||
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
|
||||
@@ -283,6 +362,10 @@ 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))
|
||||
@@ -326,14 +409,18 @@ func (r *PostableRule) Validate() error {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition: field is required")
|
||||
}
|
||||
|
||||
switch r.AlertType {
|
||||
case AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions, AlertTypeAITraces:
|
||||
case "":
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "alertType: field is required"))
|
||||
default:
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"alertType: unsupported value %q; must be one of %q, %q, %q, %q, %q",
|
||||
r.AlertType, AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions, AlertTypeAITraces))
|
||||
if r.Version != "v5" {
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "version: only v5 is supported, got %q", r.Version))
|
||||
}
|
||||
|
||||
if r.AlertType != "" {
|
||||
switch r.AlertType {
|
||||
case AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions, AlertTypeAITraces:
|
||||
default:
|
||||
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"alertType: unsupported value %q; must be one of %q, %q, %q, %q, %q",
|
||||
r.AlertType, AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions, AlertTypeAITraces))
|
||||
}
|
||||
}
|
||||
|
||||
if !r.RuleType.IsZero() {
|
||||
@@ -351,12 +438,6 @@ 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 {
|
||||
@@ -418,44 +499,75 @@ func (r *PostableRule) Validate() error {
|
||||
}
|
||||
|
||||
func (r *PostableRule) validateSchemaVersion() []error {
|
||||
switch r.SchemaVersion.StringValue() {
|
||||
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 %q",
|
||||
r.SchemaVersion.StringValue(), SchemaVersionV2Alpha1)}
|
||||
"schemaVersion: unsupported value %q; must be one of %q, %q",
|
||||
r.SchemaVersion, DefaultSchemaVersion, 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,
|
||||
@@ -465,24 +577,11 @@ 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.
|
||||
@@ -543,6 +642,31 @@ 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
|
||||
@@ -571,3 +695,28 @@ 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,6 +3,7 @@ package ruletypes
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -28,7 +29,8 @@ func TestParseIntoRule(t *testing.T) {
|
||||
"alertType": "METRIC_BASED_ALERT",
|
||||
"description": "Test description",
|
||||
"ruleType": "threshold_rule",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"evalWindow": "5m",
|
||||
"frequency": "1m",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
@@ -43,11 +45,11 @@ func TestParseIntoRule(t *testing.T) {
|
||||
}
|
||||
}]
|
||||
},
|
||||
"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"]}}
|
||||
"target": 10.0,
|
||||
"matchType": "1",
|
||||
"op": "1",
|
||||
"selectedQuery": "A"
|
||||
}
|
||||
}`),
|
||||
kind: RuleDataKindJson,
|
||||
expectError: false,
|
||||
@@ -61,6 +63,46 @@ 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)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -68,7 +110,6 @@ func TestParseIntoRule(t *testing.T) {
|
||||
initRule: PostableRule{},
|
||||
content: []byte(`{
|
||||
"alert": "PromQLRule",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
@@ -82,11 +123,11 @@ func TestParseIntoRule(t *testing.T) {
|
||||
}
|
||||
}]
|
||||
},
|
||||
"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"]}}
|
||||
"target": 10.0,
|
||||
"matchType": "1",
|
||||
"op": "1",
|
||||
"selectedQuery": "A"
|
||||
}
|
||||
}`),
|
||||
kind: RuleDataKindJson,
|
||||
expectError: false,
|
||||
@@ -97,6 +138,9 @@ 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")
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -133,6 +177,233 @@ 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{},
|
||||
@@ -182,8 +453,8 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
kind: RuleDataKindJson,
|
||||
expectError: false,
|
||||
validate: func(t *testing.T, rule *PostableRule) {
|
||||
if rule.SchemaVersion.StringValue() != SchemaVersionV2Alpha1 {
|
||||
t.Errorf("Expected schemaVersion %q, got %q", SchemaVersionV2Alpha1, rule.SchemaVersion.StringValue())
|
||||
if rule.SchemaVersion != SchemaVersionV2Alpha1 {
|
||||
t.Errorf("Expected schemaVersion %q, got %q", SchemaVersionV2Alpha1, rule.SchemaVersion)
|
||||
}
|
||||
|
||||
if rule.RuleCondition.Thresholds == nil {
|
||||
@@ -374,19 +645,19 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
}
|
||||
}]
|
||||
},
|
||||
"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"]}}
|
||||
"target": 100.0,
|
||||
"matchType": "1",
|
||||
"op": "1"
|
||||
}
|
||||
}`),
|
||||
kind: RuleDataKindJson,
|
||||
expectValidateError: true,
|
||||
},
|
||||
{
|
||||
name: "missing schema version - rejected on write",
|
||||
name: "default schema version - defaults to v1 behavior",
|
||||
initRule: PostableRule{},
|
||||
content: []byte(`{
|
||||
"alert": "MissingSchemaTest",
|
||||
"alert": "DefaultSchemaTest",
|
||||
"version": "v5",
|
||||
"condition": {
|
||||
"compositeQuery": {
|
||||
@@ -401,13 +672,24 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
|
||||
}
|
||||
}]
|
||||
},
|
||||
"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"]}}
|
||||
"target": 75.0,
|
||||
"matchType": "1",
|
||||
"op": "1"
|
||||
}
|
||||
}`),
|
||||
kind: RuleDataKindJson,
|
||||
expectValidateError: true,
|
||||
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")
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -455,7 +737,11 @@ func TestParseIntoRuleThresholdGeneration(t *testing.T) {
|
||||
}
|
||||
}]
|
||||
},
|
||||
"selectedQueryName": "A",
|
||||
"target": 100.0,
|
||||
"matchType": "1",
|
||||
"op": "1",
|
||||
"selectedQuery": "A",
|
||||
"targetUnit": "ms",
|
||||
"thresholds": {
|
||||
"kind": "basic",
|
||||
"spec": [
|
||||
@@ -621,6 +907,428 @@ 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",
|
||||
|
||||
@@ -10,12 +10,12 @@ import (
|
||||
)
|
||||
|
||||
type EvaluationKind struct {
|
||||
valuer.UnsetOrNonEmptyString
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
RollingEvaluation = EvaluationKind{valuer.MustNewUnsetOrNonEmptyString("rolling")}
|
||||
CumulativeEvaluation = EvaluationKind{valuer.MustNewUnsetOrNonEmptyString("cumulative")}
|
||||
RollingEvaluation = EvaluationKind{valuer.NewString("rolling")}
|
||||
CumulativeEvaluation = EvaluationKind{valuer.NewString("cumulative")}
|
||||
)
|
||||
|
||||
// Enum implements jsonschema.Enum; returns the acceptable values for EvaluationKind.
|
||||
@@ -29,9 +29,6 @@ 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 {
|
||||
@@ -39,13 +36,6 @@ 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")
|
||||
@@ -65,13 +55,9 @@ func (rollingWindow RollingWindow) GetFrequency() valuer.TextDuration {
|
||||
}
|
||||
|
||||
type CumulativeWindow struct {
|
||||
Schedule CumulativeSchedule `json:"schedule" required:"true" description:"The boundary the accumulation window starts from. Each evaluation queries from the most recent boundary to now."`
|
||||
Frequency valuer.TextDuration `json:"frequency" required:"true" description:"How often the rule is evaluated, as a Go duration such as 1m. Must be positive."`
|
||||
Timezone valuer.UnsetOrNonEmptyString `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)
|
||||
Schedule CumulativeSchedule `json:"schedule" required:"true" description:"The boundary the accumulation window starts from. Each evaluation queries from the most recent boundary to now."`
|
||||
Frequency valuer.TextDuration `json:"frequency" required:"true" description:"How often the rule is evaluated, as a Go duration such as 1m. Must be positive."`
|
||||
Timezone string `json:"timezone" required:"true" description:"The IANA time zone the schedule is resolved in, such as UTC or Asia/Kolkata."`
|
||||
}
|
||||
|
||||
type CumulativeSchedule struct {
|
||||
@@ -83,14 +69,14 @@ type CumulativeSchedule struct {
|
||||
}
|
||||
|
||||
type ScheduleType struct {
|
||||
valuer.UnsetOrNonEmptyString
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
ScheduleTypeHourly = ScheduleType{valuer.MustNewUnsetOrNonEmptyString("hourly")}
|
||||
ScheduleTypeDaily = ScheduleType{valuer.MustNewUnsetOrNonEmptyString("daily")}
|
||||
ScheduleTypeWeekly = ScheduleType{valuer.MustNewUnsetOrNonEmptyString("weekly")}
|
||||
ScheduleTypeMonthly = ScheduleType{valuer.MustNewUnsetOrNonEmptyString("monthly")}
|
||||
ScheduleTypeHourly = ScheduleType{valuer.NewString("hourly")}
|
||||
ScheduleTypeDaily = ScheduleType{valuer.NewString("daily")}
|
||||
ScheduleTypeWeekly = ScheduleType{valuer.NewString("weekly")}
|
||||
ScheduleTypeMonthly = ScheduleType{valuer.NewString("monthly")}
|
||||
)
|
||||
|
||||
// Enum implements jsonschema.Enum; returns the acceptable values for ScheduleType.
|
||||
@@ -109,10 +95,7 @@ func (cumulativeWindow CumulativeWindow) Validate() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if cumulativeWindow.Timezone.IsZero() {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "timezone is required")
|
||||
}
|
||||
if _, err := time.LoadLocation(cumulativeWindow.Timezone.StringValue()); err != nil {
|
||||
if _, err := time.LoadLocation(cumulativeWindow.Timezone); err != nil {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "timezone is invalid")
|
||||
}
|
||||
if !cumulativeWindow.Frequency.IsPositive() {
|
||||
@@ -174,8 +157,8 @@ func (cs CumulativeSchedule) Validate() error {
|
||||
|
||||
func (cumulativeWindow CumulativeWindow) NextWindowFor(curr time.Time) (time.Time, time.Time) {
|
||||
loc := time.UTC
|
||||
if !cumulativeWindow.Timezone.IsZero() {
|
||||
if tz, err := time.LoadLocation(cumulativeWindow.Timezone.StringValue()); err == nil {
|
||||
if cumulativeWindow.Timezone != "" {
|
||||
if tz, err := time.LoadLocation(cumulativeWindow.Timezone); err == nil {
|
||||
loc = tz
|
||||
}
|
||||
}
|
||||
@@ -345,9 +328,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestCumulativeWindow_NewScheduleSystem(t *testing.T) {
|
||||
Minute: intPtr(15),
|
||||
},
|
||||
Frequency: valuer.MustParseTextDuration("5m"),
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
current: time.Date(2025, 3, 15, 14, 30, 0, 0, time.UTC),
|
||||
wantErr: false,
|
||||
@@ -86,7 +86,7 @@ func TestCumulativeWindow_NewScheduleSystem(t *testing.T) {
|
||||
Minute: intPtr(30),
|
||||
},
|
||||
Frequency: valuer.MustParseTextDuration("1h"),
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("Asia/Kolkata"),
|
||||
Timezone: "Asia/Kolkata",
|
||||
},
|
||||
current: time.Date(2025, 3, 15, 15, 30, 0, 0, time.UTC),
|
||||
wantErr: false,
|
||||
@@ -101,7 +101,7 @@ func TestCumulativeWindow_NewScheduleSystem(t *testing.T) {
|
||||
Minute: intPtr(0),
|
||||
},
|
||||
Frequency: valuer.MustParseTextDuration("24h"),
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("America/New_York"),
|
||||
Timezone: "America/New_York",
|
||||
},
|
||||
current: time.Date(2025, 3, 18, 19, 0, 0, 0, time.UTC), // Tuesday
|
||||
wantErr: false,
|
||||
@@ -116,7 +116,7 @@ func TestCumulativeWindow_NewScheduleSystem(t *testing.T) {
|
||||
Minute: intPtr(0),
|
||||
},
|
||||
Frequency: valuer.MustParseTextDuration("24h"),
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
current: time.Date(2025, 3, 15, 12, 0, 0, 0, time.UTC),
|
||||
wantErr: false,
|
||||
@@ -128,7 +128,7 @@ func TestCumulativeWindow_NewScheduleSystem(t *testing.T) {
|
||||
Type: ScheduleTypeHourly,
|
||||
},
|
||||
Frequency: valuer.MustParseTextDuration("5m"),
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
current: time.Date(2025, 3, 15, 14, 30, 0, 0, time.UTC),
|
||||
wantErr: true,
|
||||
@@ -181,7 +181,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Type: ScheduleTypeHourly,
|
||||
Minute: intPtr(30),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
current: time.Date(2025, 3, 15, 14, 30, 0, 0, time.UTC),
|
||||
wantStart: time.Date(2025, 3, 15, 14, 30, 0, 0, time.UTC),
|
||||
@@ -194,7 +194,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Type: ScheduleTypeHourly,
|
||||
Minute: intPtr(15),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
current: time.Date(2025, 3, 15, 14, 45, 0, 0, time.UTC),
|
||||
wantStart: time.Date(2025, 3, 15, 14, 15, 0, 0, time.UTC),
|
||||
@@ -207,7 +207,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Type: ScheduleTypeHourly,
|
||||
Minute: intPtr(30),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
current: time.Date(2025, 3, 15, 14, 15, 0, 0, time.UTC),
|
||||
wantStart: time.Date(2025, 3, 15, 13, 30, 0, 0, time.UTC), // Previous hour
|
||||
@@ -220,7 +220,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Type: ScheduleTypeHourly,
|
||||
Minute: intPtr(30),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
current: time.Date(2025, 3, 15, 13, 14, 0, 0, time.UTC),
|
||||
wantStart: time.Date(2025, 3, 15, 12, 30, 0, 0, time.UTC), // Previous hour
|
||||
@@ -233,7 +233,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Type: ScheduleTypeHourly,
|
||||
Minute: intPtr(30),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("Asia/Kolkata"),
|
||||
Timezone: "Asia/Kolkata",
|
||||
},
|
||||
current: time.Date(2025, 3, 15, 13, 14, 0, 0, time.UTC),
|
||||
wantStart: time.Date(2025, 3, 15, 13, 00, 0, 0, time.UTC), // Previous hour
|
||||
@@ -249,7 +249,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(9),
|
||||
Minute: intPtr(30),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
current: time.Date(2025, 3, 15, 9, 30, 0, 0, time.UTC),
|
||||
wantStart: time.Date(2025, 3, 15, 9, 30, 0, 0, time.UTC),
|
||||
@@ -263,7 +263,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(9),
|
||||
Minute: intPtr(30),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
current: time.Date(2025, 3, 15, 15, 45, 0, 0, time.UTC),
|
||||
wantStart: time.Date(2025, 3, 15, 9, 30, 0, 0, time.UTC),
|
||||
@@ -277,7 +277,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(9),
|
||||
Minute: intPtr(30),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
current: time.Date(2025, 3, 15, 8, 15, 0, 0, time.UTC),
|
||||
wantStart: time.Date(2025, 3, 14, 9, 30, 0, 0, time.UTC), // Previous day
|
||||
@@ -291,7 +291,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(9),
|
||||
Minute: intPtr(30),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("Asia/Kolkata"),
|
||||
Timezone: "Asia/Kolkata",
|
||||
},
|
||||
current: time.Date(2025, 3, 15, 15, 30, 0, 0, time.UTC), // 9:00 PM IST
|
||||
wantStart: time.Date(2025, 3, 15, 4, 0, 0, 0, time.UTC), // 9:30 AM IST in UTC
|
||||
@@ -308,7 +308,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(14),
|
||||
Minute: intPtr(0),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
current: time.Date(2025, 3, 17, 14, 0, 0, 0, time.UTC), // Monday
|
||||
wantStart: time.Date(2025, 3, 17, 14, 0, 0, 0, time.UTC),
|
||||
@@ -323,7 +323,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(14),
|
||||
Minute: intPtr(0),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
current: time.Date(2025, 3, 19, 10, 30, 0, 0, time.UTC), // Wednesday
|
||||
wantStart: time.Date(2025, 3, 17, 14, 0, 0, 0, time.UTC), // Previous Monday
|
||||
@@ -338,7 +338,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(14),
|
||||
Minute: intPtr(0),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
current: time.Date(2025, 3, 18, 10, 0, 0, 0, time.UTC), // Tuesday before 2 PM
|
||||
wantStart: time.Date(2025, 3, 11, 14, 0, 0, 0, time.UTC), // Previous Tuesday
|
||||
@@ -355,7 +355,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(12),
|
||||
Minute: intPtr(0),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
current: time.Date(2025, 3, 15, 12, 0, 0, 0, time.UTC),
|
||||
wantStart: time.Date(2025, 3, 15, 12, 0, 0, 0, time.UTC),
|
||||
@@ -370,7 +370,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(0),
|
||||
Minute: intPtr(0),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
current: time.Date(2025, 3, 15, 16, 30, 0, 0, time.UTC),
|
||||
wantStart: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC),
|
||||
@@ -385,7 +385,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(12),
|
||||
Minute: intPtr(0),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
current: time.Date(2025, 3, 10, 10, 0, 0, 0, time.UTC),
|
||||
wantStart: time.Date(2025, 2, 15, 12, 0, 0, 0, time.UTC), // Previous month
|
||||
@@ -400,7 +400,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(12),
|
||||
Minute: intPtr(0),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
current: time.Date(2025, 3, 15, 10, 0, 0, 0, time.UTC),
|
||||
wantStart: time.Date(2025, 2, 28, 12, 0, 0, 0, time.UTC), // Feb 28 (last day of Feb)
|
||||
@@ -415,7 +415,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Type: ScheduleTypeHourly,
|
||||
Minute: intPtr(45),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("Asia/Tokyo"),
|
||||
Timezone: "Asia/Tokyo",
|
||||
},
|
||||
current: time.Date(2023, 12, 15, 2, 30, 0, 0, time.UTC), // 11:30 AM JST
|
||||
wantStart: time.Date(2023, 12, 15, 1, 45, 0, 0, time.UTC), // 10:45 AM JST in UTC
|
||||
@@ -429,7 +429,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(8), // 8 AM EST
|
||||
Minute: intPtr(0),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("America/New_York"),
|
||||
Timezone: "America/New_York",
|
||||
},
|
||||
current: time.Date(2023, 12, 15, 20, 30, 0, 0, time.UTC), // 3:30 PM EST
|
||||
wantStart: time.Date(2023, 12, 15, 13, 0, 0, 0, time.UTC), // 8 AM EST in UTC
|
||||
@@ -444,7 +444,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(12),
|
||||
Minute: intPtr(0),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("Europe/London"),
|
||||
Timezone: "Europe/London",
|
||||
},
|
||||
current: time.Date(2023, 12, 15, 15, 0, 0, 0, time.UTC), // Friday 3 PM GMT
|
||||
wantStart: time.Date(2023, 12, 11, 12, 0, 0, 0, time.UTC), // Previous Monday 12 PM GMT
|
||||
@@ -459,7 +459,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(0), // Midnight AEDT
|
||||
Minute: intPtr(0),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("Australia/Sydney"),
|
||||
Timezone: "Australia/Sydney",
|
||||
},
|
||||
current: time.Date(2023, 12, 15, 5, 0, 0, 0, time.UTC), // 4 PM AEDT on 15th
|
||||
wantStart: time.Date(2023, 11, 30, 13, 0, 0, 0, time.UTC), // Midnight AEDT on Dec 1st in UTC (Nov 30 13:00 UTC)
|
||||
@@ -472,7 +472,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Type: ScheduleTypeHourly,
|
||||
Minute: intPtr(30),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("Pacific/Honolulu"),
|
||||
Timezone: "Pacific/Honolulu",
|
||||
},
|
||||
current: time.Date(2023, 12, 15, 22, 45, 0, 0, time.UTC), // 12:45 PM HST
|
||||
wantStart: time.Date(2023, 12, 15, 22, 30, 0, 0, time.UTC), // 12:30 PM HST in UTC
|
||||
@@ -486,7 +486,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(2), // 2 AM PST/PDT
|
||||
Minute: intPtr(0),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("America/Los_Angeles"),
|
||||
Timezone: "America/Los_Angeles",
|
||||
},
|
||||
current: time.Date(2023, 3, 12, 15, 0, 0, 0, time.UTC), // Day after DST starts
|
||||
wantStart: time.Date(2023, 3, 12, 9, 0, 0, 0, time.UTC), // 2 AM PDT in UTC (PDT = UTC-7)
|
||||
@@ -501,7 +501,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(16), // 4 PM CET
|
||||
Minute: intPtr(30),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("Europe/Berlin"),
|
||||
Timezone: "Europe/Berlin",
|
||||
},
|
||||
current: time.Date(2023, 12, 18, 10, 0, 0, 0, time.UTC), // Monday 11 AM CET
|
||||
wantStart: time.Date(2023, 12, 15, 15, 30, 0, 0, time.UTC), // Previous Friday 4:30 PM CET
|
||||
@@ -516,7 +516,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(23),
|
||||
Minute: intPtr(59),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("Asia/Kolkata"),
|
||||
Timezone: "Asia/Kolkata",
|
||||
},
|
||||
current: time.Date(2023, 3, 10, 12, 0, 0, 0, time.UTC), // March 10th 5:30 PM IST
|
||||
wantStart: time.Date(2023, 2, 28, 18, 29, 0, 0, time.UTC), // Feb 28 11:59 PM IST (last day of Feb)
|
||||
@@ -529,7 +529,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Type: ScheduleTypeHourly,
|
||||
Minute: intPtr(0), // Top of hour
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("America/Chicago"),
|
||||
Timezone: "America/Chicago",
|
||||
},
|
||||
current: time.Date(2023, 12, 15, 6, 30, 0, 0, time.UTC), // 12:30 AM CST
|
||||
wantStart: time.Date(2023, 12, 15, 6, 0, 0, 0, time.UTC), // Midnight CST in UTC
|
||||
@@ -545,7 +545,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(0),
|
||||
Minute: intPtr(0),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
current: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), // Jan 1st noon
|
||||
wantStart: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), // Jan 1st midnight
|
||||
@@ -560,7 +560,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(15),
|
||||
Minute: intPtr(30),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
current: time.Date(2024, 3, 10, 10, 0, 0, 0, time.UTC), // March 10th (leap year)
|
||||
wantStart: time.Date(2024, 2, 29, 15, 30, 0, 0, time.UTC), // Feb 29th exists in leap year
|
||||
@@ -575,7 +575,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(15),
|
||||
Minute: intPtr(30),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
current: time.Date(2023, 3, 10, 10, 0, 0, 0, time.UTC), // March 10th (non-leap year)
|
||||
wantStart: time.Date(2023, 2, 28, 15, 30, 0, 0, time.UTC), // Feb 28th (fallback)
|
||||
@@ -590,7 +590,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(12),
|
||||
Minute: intPtr(0),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
current: time.Date(2023, 5, 15, 10, 0, 0, 0, time.UTC), // May 15th
|
||||
wantStart: time.Date(2023, 4, 30, 12, 0, 0, 0, time.UTC), // April 30th (fallback from 31st)
|
||||
@@ -605,7 +605,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(23),
|
||||
Minute: intPtr(59),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
current: time.Date(2023, 12, 11, 1, 0, 0, 0, time.UTC), // Monday 1 AM
|
||||
wantStart: time.Date(2023, 12, 10, 23, 59, 0, 0, time.UTC), // Previous Sunday 11:59 PM
|
||||
@@ -618,7 +618,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Type: ScheduleTypeHourly,
|
||||
Minute: intPtr(59),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
current: time.Date(2023, 12, 15, 14, 5, 0, 0, time.UTC), // 14:05
|
||||
wantStart: time.Date(2023, 12, 15, 13, 59, 0, 0, time.UTC), // 13:59 (previous hour)
|
||||
@@ -632,7 +632,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(2), // 2 AM (skipped during DST)
|
||||
Minute: intPtr(30),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("America/New_York"),
|
||||
Timezone: "America/New_York",
|
||||
},
|
||||
current: time.Date(2023, 3, 12, 15, 0, 0, 0, time.UTC), // Day DST starts
|
||||
wantStart: time.Date(2023, 3, 12, 6, 30, 0, 0, time.UTC), // Same day 2:30 AM EDT (adjusted for DST)
|
||||
@@ -646,7 +646,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(2), // 2 AM (occurs twice)
|
||||
Minute: intPtr(30),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("America/New_York"),
|
||||
Timezone: "America/New_York",
|
||||
},
|
||||
current: time.Date(2023, 11, 5, 15, 0, 0, 0, time.UTC), // Day DST ends
|
||||
wantStart: time.Date(2023, 11, 5, 7, 30, 0, 0, time.UTC), // Same day 2:30 AM EST (after fall back)
|
||||
@@ -661,7 +661,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(0),
|
||||
Minute: intPtr(0),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
current: time.Date(2023, 2, 15, 12, 0, 0, 0, time.UTC), // February 15th
|
||||
wantStart: time.Date(2023, 1, 31, 0, 0, 0, 0, time.UTC), // January 31st (exists)
|
||||
@@ -675,7 +675,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(12),
|
||||
Minute: intPtr(0),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("Pacific/Kiritimati"), // UTC+14
|
||||
Timezone: "Pacific/Kiritimati", // UTC+14
|
||||
},
|
||||
current: time.Date(2023, 12, 15, 5, 0, 0, 0, time.UTC), // 7 PM local time
|
||||
wantStart: time.Date(2023, 12, 14, 22, 0, 0, 0, time.UTC), // 12 PM local time (previous day in UTC)
|
||||
@@ -689,7 +689,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(12),
|
||||
Minute: intPtr(0),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("Etc/GMT+12"), // UTC-12 (use standard timezone name)
|
||||
Timezone: "Etc/GMT+12", // UTC-12 (use standard timezone name)
|
||||
},
|
||||
current: time.Date(2023, 12, 15, 5, 0, 0, 0, time.UTC), // 5 PM previous day local time
|
||||
wantStart: time.Date(2023, 12, 15, 0, 0, 0, 0, time.UTC), // 12 PM local time (same day in UTC)
|
||||
@@ -704,7 +704,7 @@ func TestCumulativeWindow_NextWindowFor(t *testing.T) {
|
||||
Hour: intPtr(0),
|
||||
Minute: intPtr(0),
|
||||
},
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
current: time.Date(2023, 12, 17, 12, 0, 0, 0, time.UTC), // Sunday noon
|
||||
wantStart: time.Date(2023, 12, 16, 0, 0, 0, 0, time.UTC), // Saturday midnight
|
||||
@@ -771,7 +771,7 @@ func TestEvaluationEnvelope_UnmarshalJSON(t *testing.T) {
|
||||
Minute: intPtr(30),
|
||||
},
|
||||
Frequency: valuer.MustParseTextDuration("2m"),
|
||||
Timezone: valuer.MustNewUnsetOrNonEmptyString("UTC"),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -33,11 +33,6 @@ 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.
|
||||
@@ -52,7 +47,7 @@ func (rule *StorableRule) ToListableRule() (*ListableRule, error) {
|
||||
AlertName: data.AlertName,
|
||||
Description: data.Description,
|
||||
AlertType: data.AlertType,
|
||||
RuleType: deriveRuleType(data.RuleType, data.Condition.CompositeQuery.QueryType),
|
||||
RuleType: data.RuleType,
|
||||
Disabled: data.Disabled,
|
||||
Labels: data.Labels,
|
||||
TimeAuditable: rule.TimeAuditable,
|
||||
|
||||
@@ -147,7 +147,7 @@ func TestV2MinimalReadShape(t *testing.T) {
|
||||
|
||||
body := readBody(t, stored)
|
||||
|
||||
for _, field := range []string{"labels", "annotations", "description"} {
|
||||
for _, field := range []string{"labels", "annotations", "description", "preferredChannels", "evalWindow", "frequency"} {
|
||||
assert.NotContains(t, body, field)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ import (
|
||||
)
|
||||
|
||||
type RuleType struct {
|
||||
valuer.UnsetOrNonEmptyString
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
RuleTypeThreshold = RuleType{valuer.MustNewUnsetOrNonEmptyString("threshold_rule")}
|
||||
RuleTypeProm = RuleType{valuer.MustNewUnsetOrNonEmptyString("promql_rule")}
|
||||
RuleTypeAnomaly = RuleType{valuer.MustNewUnsetOrNonEmptyString("anomaly_rule")}
|
||||
RuleTypeThreshold = RuleType{valuer.NewString("threshold_rule")}
|
||||
RuleTypeProm = RuleType{valuer.NewString("promql_rule")}
|
||||
RuleTypeAnomaly = RuleType{valuer.NewString("anomaly_rule")}
|
||||
)
|
||||
|
||||
func (RuleType) Enum() []any {
|
||||
@@ -23,19 +23,6 @@ func (RuleType) Enum() []any {
|
||||
}
|
||||
}
|
||||
|
||||
func deriveRuleType(ruleType RuleType, queryType QueryType) RuleType {
|
||||
switch queryType {
|
||||
case QueryTypeBuilder, QueryTypeClickHouseSQL:
|
||||
if ruleType.IsZero() {
|
||||
return RuleTypeThreshold
|
||||
}
|
||||
case QueryTypePromQL:
|
||||
// a promql query is a promql rule whatever was sent
|
||||
return RuleTypeProm
|
||||
}
|
||||
return ruleType
|
||||
}
|
||||
|
||||
func (r RuleType) Validate() error {
|
||||
switch r {
|
||||
case
|
||||
|
||||
@@ -6,13 +6,13 @@ import (
|
||||
)
|
||||
|
||||
type Seasonality struct {
|
||||
valuer.UnsetOrNonEmptyString
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
SeasonalityHourly = Seasonality{valuer.MustNewUnsetOrNonEmptyString("hourly")}
|
||||
SeasonalityDaily = Seasonality{valuer.MustNewUnsetOrNonEmptyString("daily")}
|
||||
SeasonalityWeekly = Seasonality{valuer.MustNewUnsetOrNonEmptyString("weekly")}
|
||||
SeasonalityHourly = Seasonality{valuer.NewString("hourly")}
|
||||
SeasonalityDaily = Seasonality{valuer.NewString("daily")}
|
||||
SeasonalityWeekly = Seasonality{valuer.NewString("weekly")}
|
||||
)
|
||||
|
||||
func (Seasonality) Enum() []any {
|
||||
|
||||
@@ -15,11 +15,11 @@ import (
|
||||
)
|
||||
|
||||
type ThresholdKind struct {
|
||||
valuer.UnsetOrNonEmptyString
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
BasicThresholdKind = ThresholdKind{valuer.MustNewUnsetOrNonEmptyString("basic")}
|
||||
BasicThresholdKind = ThresholdKind{valuer.NewString("basic")}
|
||||
)
|
||||
|
||||
// Enum implements jsonschema.Enum; returns the acceptable values for ThresholdKind.
|
||||
@@ -85,6 +85,9 @@ 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:
|
||||
@@ -127,9 +130,6 @@ 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,37 +170,6 @@ 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,12 +54,6 @@ 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/v2/rules"),
|
||||
signoz.self.host_configs["8080"].get("/api/v1/rules"),
|
||||
json=rule_data,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, f"Failed to create rule, api returned {response.status_code} with response: {response.text}"
|
||||
assert response.status_code == HTTPStatus.OK, 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/v2/rules/{rule_id}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/rules/{rule_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
if response.status_code != HTTPStatus.NO_CONTENT:
|
||||
if response.status_code != HTTPStatus.OK:
|
||||
raise Exception( # pylint: disable=broad-exception-raised
|
||||
f"Failed to delete rule, api returned {response.status_code} with response: {response.text}"
|
||||
)
|
||||
@@ -156,22 +156,19 @@ 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/v3/rules"),
|
||||
params={"limit": 200},
|
||||
signoz.self.host_configs["8080"].get("/api/v2/rules"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
for rule in response.json()["data"]["rules"]:
|
||||
for rule in response.json()["data"]:
|
||||
delete_response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/rules/{rule['id']}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/rules/{rule['id']}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert delete_response.status_code == HTTPStatus.NO_CONTENT, f"failed to delete rule {rule['id']}: {delete_response.text}"
|
||||
assert delete_response.status_code == HTTPStatus.OK, f"failed to delete rule {rule['id']}: {delete_response.text}"
|
||||
|
||||
|
||||
@pytest.fixture(name="seed_alert_rules", scope="function")
|
||||
|
||||
@@ -573,3 +573,24 @@ 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}
|
||||
|
||||
@@ -1,418 +0,0 @@
|
||||
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_rule_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)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json={**VALID_RULE, "ruleType": ""},
|
||||
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": "failed to parse json: string must not be empty",
|
||||
"errors": [],
|
||||
"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": "failed to parse json: string must not be empty",
|
||||
"errors": [],
|
||||
"suggestions": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_create_rejects_empty_seasonality(
|
||||
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, "condition": {**VALID_RULE["condition"], "seasonality": ""}},
|
||||
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": "failed to parse json: string must not be empty",
|
||||
"errors": [],
|
||||
"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"
|
||||
@@ -1,91 +0,0 @@
|
||||
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