Alerting: Remove legacy alerting (#83671)
Removes legacy alerting, so long and thanks for all the fish! 🐟 --------- Co-authored-by: Matthew Jacobson <matthew.jacobson@grafana.com> Co-authored-by: Sonia Aguilar <soniaAguilarPeiron@users.noreply.github.com> Co-authored-by: Armand Grillet <armandgrillet@users.noreply.github.com> Co-authored-by: William Wernert <rwwiv@users.noreply.github.com> Co-authored-by: Yuri Tseretyan <yuriy.tseretyan@grafana.com>
This commit is contained in:
co-authored by
Matthew Jacobson
Sonia Aguilar
Armand Grillet
William Wernert
Yuri Tseretyan
parent
f26344e176
commit
8765c48389
+1
-174
@@ -1423,78 +1423,12 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Styles should be written using objects.", "1"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "2"]
|
||||
],
|
||||
"public/app/features/alerting/AlertTab.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
||||
],
|
||||
"public/app/features/alerting/AlertTabCtrl.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "6"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "7"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "8"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "9"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "10"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "11"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "12"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "13"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "14"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "15"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "16"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "17"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "18"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "19"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "20"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "21"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "22"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "23"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "24"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "25"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "26"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "27"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "28"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "29"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "30"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "31"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "32"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "33"]
|
||||
],
|
||||
"public/app/features/alerting/EditNotificationChannelPage.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/features/alerting/StateHistory.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "2"]
|
||||
],
|
||||
"public/app/features/alerting/TestRuleResult.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
|
||||
],
|
||||
"public/app/features/alerting/components/NotificationChannelForm.tsx:5381": [
|
||||
[0, 0, 0, "Styles should be written using objects.", "0"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "1"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "2"]
|
||||
],
|
||||
"public/app/features/alerting/components/NotificationChannelOptions.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/features/alerting/components/OptionElement.tsx:5381": [
|
||||
"public/app/features/alerting/routes.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/features/alerting/state/ThresholdMapper.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/features/alerting/state/actions.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
|
||||
],
|
||||
"public/app/features/alerting/state/alertDef.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
@@ -5602,38 +5536,10 @@ exports[`better eslint`] = {
|
||||
"public/app/plugins/datasource/zipkin/utils/transforms.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/plugins/panel/alertGroups/AlertGroup.tsx:5381": [
|
||||
[0, 0, 0, "Styles should be written using objects.", "0"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "1"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "2"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "3"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "4"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "5"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "6"]
|
||||
],
|
||||
"public/app/plugins/panel/alertlist/AlertInstances.tsx:5381": [
|
||||
[0, 0, 0, "Styles should be written using objects.", "0"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "1"]
|
||||
],
|
||||
"public/app/plugins/panel/alertlist/AlertList.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "3"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "4"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "5"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "6"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "7"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "8"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "9"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "10"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "11"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "12"]
|
||||
],
|
||||
"public/app/plugins/panel/alertlist/AlertListMigrationHandler.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
||||
],
|
||||
"public/app/plugins/panel/alertlist/UnifiedAlertList.tsx:5381": [
|
||||
[0, 0, 0, "Styles should be written using objects.", "0"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "1"],
|
||||
@@ -6508,85 +6414,6 @@ exports[`no gf-form usage`] = {
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
|
||||
],
|
||||
"public/app/features/alerting/AlertRuleList.tsx:5381": [
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
|
||||
],
|
||||
"public/app/features/alerting/partials/alert_tab.html:5381": [
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
|
||||
],
|
||||
"public/app/features/annotations/partials/event_editor.html:5381": [
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
|
||||
@@ -171,7 +171,6 @@
|
||||
|
||||
/devenv/bulk-dashboards/ @grafana/dashboards-squad
|
||||
/devenv/bulk-folders/ @grafana/grafana-frontend-platform
|
||||
/devenv/bulk_alerting_dashboards/ @grafana/alerting-backend-product
|
||||
/devenv/create_docker_compose.sh @grafana/backend-platform
|
||||
/devenv/dashboards.yaml @grafana/dashboards-squad
|
||||
/devenv/datasources.yaml @grafana/backend-platform
|
||||
@@ -283,7 +282,6 @@
|
||||
# Alerting
|
||||
/pkg/services/ngalert/ @grafana/alerting-backend-product
|
||||
/pkg/services/sqlstore/migrations/ualert/ @grafana/alerting-backend-product
|
||||
/pkg/services/alerting/ @grafana/alerting-backend-product
|
||||
/pkg/tests/api/alerting/ @grafana/alerting-backend-product
|
||||
/public/app/features/alerting/ @grafana/alerting-frontend
|
||||
|
||||
@@ -436,7 +434,6 @@ playwright.config.ts @grafana/plugins-platform-frontend
|
||||
/public/app/features/transformers/timeSeriesTable/ @grafana/dataviz-squad @grafana/app-o11y-visualizations
|
||||
/public/app/features/users/ @grafana/identity-access-team
|
||||
/public/app/features/variables/ @grafana/dashboards-squad
|
||||
/public/app/plugins/panel/alertGroups/ @grafana/alerting-frontend
|
||||
/public/app/plugins/panel/alertlist/ @grafana/alerting-frontend
|
||||
/public/app/plugins/panel/annolist/ @grafana/grafana-frontend-platform
|
||||
/public/app/plugins/panel/barchart/ @grafana/dataviz-squad
|
||||
@@ -711,5 +708,4 @@ embed.go @grafana/grafana-as-code
|
||||
/conf/provisioning/alerting/ @grafana/alerting-backend-product
|
||||
/conf/provisioning/dashboards/ @grafana/dashboards-squad
|
||||
/conf/provisioning/datasources/ @grafana/plugins-platform-backend
|
||||
/conf/provisioning/notifiers/ @bergquist
|
||||
/conf/provisioning/plugins/ @grafana/plugins-platform-backend
|
||||
|
||||
@@ -131,9 +131,7 @@ pkg/services/quota/quotaimpl/storage/storage.json
|
||||
|
||||
/devenv/bulk-dashboards/*.json
|
||||
/devenv/bulk-folders/*/*.json
|
||||
/devenv/bulk_alerting_dashboards/*.json
|
||||
/devenv/datasources_bulk.yaml
|
||||
/devenv/bulk_alerting_dashboards/bulk_alerting_datasources.yaml
|
||||
|
||||
/scripts/build/release_publisher/release_publisher
|
||||
*.patch
|
||||
|
||||
+1
-12
@@ -9,10 +9,6 @@ app_mode = production
|
||||
# instance name, defaults to HOSTNAME environment variable value or hostname if HOSTNAME var is empty
|
||||
instance_name = ${HOSTNAME}
|
||||
|
||||
# force migration will run migrations that might cause dataloss
|
||||
# Deprecated, use clean_upgrade option in [unified_alerting.upgrade] instead.
|
||||
force_migration = false
|
||||
|
||||
#################################### Paths ###############################
|
||||
[paths]
|
||||
# Path to where grafana can store temp files, sessions, and the sqlite3 db (if that is used)
|
||||
@@ -1139,7 +1135,7 @@ alerting_rule_group_rules = 100
|
||||
|
||||
#################################### Unified Alerting ####################
|
||||
[unified_alerting]
|
||||
# Enable the Unified Alerting sub-system and interface. When enabled we'll migrate all of your alert rules and notification channels to the new system. New alert rules will be created and your notification channels will be converted into an Alertmanager configuration. Previous data is preserved to enable backwards compatibility but new data is removed when switching. When this configuration section and flag are not defined, the state is defined at runtime. See the documentation for more details.
|
||||
# Enable the Alerting sub-system and interface.
|
||||
enabled =
|
||||
|
||||
# Comma-separated list of organization IDs for which to disable unified alerting. Only supported if unified alerting is enabled.
|
||||
@@ -1324,13 +1320,6 @@ max_age =
|
||||
# Configures max number of alert annotations that Grafana stores. Default value is 0, which keeps all alert annotations.
|
||||
max_annotations_to_keep =
|
||||
|
||||
[unified_alerting.upgrade]
|
||||
# If set to true when upgrading from legacy alerting to Unified Alerting, grafana will first delete all existing
|
||||
# Unified Alerting resources, thus re-upgrading all organizations from scratch. If false or unset, organizations that
|
||||
# have previously upgraded will not lose their existing Unified Alerting data when switching between legacy and
|
||||
# Unified Alerting. Should be kept false when not needed as it may cause unintended data-loss if left enabled.
|
||||
clean_upgrade = false
|
||||
|
||||
# NOTE: this configuration options are not used yet.
|
||||
[remote.alertmanager]
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
# # config file version
|
||||
apiVersion: 1
|
||||
|
||||
# notifiers:
|
||||
# - name: default-slack-temp
|
||||
# type: slack
|
||||
# org_name: Main Org.
|
||||
# is_default: true
|
||||
# uid: notifier1
|
||||
# settings:
|
||||
# recipient: "XXX"
|
||||
# token: "xoxb"
|
||||
# uploadImage: true
|
||||
# url: https://slack.com
|
||||
# - name: default-email
|
||||
# type: email
|
||||
# org_id: 1
|
||||
# uid: notifier2
|
||||
# is_default: false
|
||||
# settings:
|
||||
# addresses: example11111@example.com
|
||||
# delete_notifiers:
|
||||
# - name: default-slack-temp
|
||||
# org_name: Main Org.
|
||||
# uid: notifier1
|
||||
@@ -9,10 +9,6 @@
|
||||
# instance name, defaults to HOSTNAME environment variable value or hostname if HOSTNAME var is empty
|
||||
;instance_name = ${HOSTNAME}
|
||||
|
||||
# force migration will run migrations that might cause dataloss
|
||||
# Deprecated, use clean_upgrade option in [unified_alerting.upgrade] instead.
|
||||
;force_migration = false
|
||||
|
||||
#################################### Paths ####################################
|
||||
[paths]
|
||||
# Path to where grafana can store temp files, sessions, and the sqlite3 db (if that is used)
|
||||
@@ -1215,13 +1211,6 @@ max_age =
|
||||
# Configures max number of alert annotations that Grafana stores. Default value is 0, which keeps all alert annotations.
|
||||
max_annotations_to_keep =
|
||||
|
||||
[unified_alerting.upgrade]
|
||||
# If set to true when upgrading from legacy alerting to Unified Alerting, grafana will first delete all existing
|
||||
# Unified Alerting resources, thus re-upgrading all organizations from scratch. If false or unset, organizations that
|
||||
# have previously upgraded will not lose their existing Unified Alerting data when switching between legacy and
|
||||
# Unified Alerting. Should be kept false when not needed as it may cause unintended data-loss if left enabled.
|
||||
;clean_upgrade = false
|
||||
|
||||
#################################### Annotations #########################
|
||||
[annotations]
|
||||
# Configures the batch size for the annotation clean-up job. This setting is used for dashboard, API, and alert annotations.
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: 'Bulk alerting dashboards'
|
||||
folder: 'Bulk alerting dashboards'
|
||||
type: file
|
||||
options:
|
||||
path: devenv/bulk_alerting_dashboards
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
{
|
||||
alertingDashboard(dashboardCounter, datasourceCounter):: {
|
||||
title: "alerting-title-" + dashboardCounter,
|
||||
editable: true,
|
||||
gnetId: null,
|
||||
graphTooltip: 0,
|
||||
id: null,
|
||||
links: [],
|
||||
panels: [
|
||||
{
|
||||
alert: {
|
||||
conditions: [
|
||||
{
|
||||
evaluator: {
|
||||
params: [
|
||||
65
|
||||
],
|
||||
type: "gt"
|
||||
},
|
||||
operator: {
|
||||
type: "and"
|
||||
},
|
||||
query: {
|
||||
params: [
|
||||
"A",
|
||||
"5m",
|
||||
"now"
|
||||
]
|
||||
},
|
||||
reducer: {
|
||||
params: [],
|
||||
type: "avg"
|
||||
},
|
||||
type: "query"
|
||||
}
|
||||
],
|
||||
executionErrorState: "alerting",
|
||||
frequency: "24h",
|
||||
handler: 1,
|
||||
name: "bulk alerting " + dashboardCounter,
|
||||
noDataState: "no_data",
|
||||
notifications: []
|
||||
},
|
||||
aliasColors: {},
|
||||
bars: false,
|
||||
dashLength: 10,
|
||||
dashes: false,
|
||||
datasource: "gfdev-bulkalerting-" + datasourceCounter,
|
||||
fill: 1,
|
||||
gridPos: {
|
||||
h: 9,
|
||||
w: 12,
|
||||
x: 0,
|
||||
y: 0
|
||||
},
|
||||
id: 1,
|
||||
legend: {
|
||||
avg: false,
|
||||
current: false,
|
||||
max: false,
|
||||
min: false,
|
||||
show: true,
|
||||
total: false,
|
||||
values: false
|
||||
},
|
||||
lines: true,
|
||||
linewidth: 1,
|
||||
nullPointMode: "null",
|
||||
percentage: false,
|
||||
pointradius: 5,
|
||||
points: false,
|
||||
renderer: "flot",
|
||||
seriesOverrides: [],
|
||||
spaceLength: 10,
|
||||
stack: false,
|
||||
steppedLine: false,
|
||||
targets: [
|
||||
{
|
||||
expr: "go_goroutines",
|
||||
format: "time_series",
|
||||
intervalFactor: 1,
|
||||
refId: "A"
|
||||
}
|
||||
],
|
||||
thresholds: [
|
||||
{
|
||||
colorMode: "critical",
|
||||
fill: true,
|
||||
line: true,
|
||||
op: "gt",
|
||||
value: 50
|
||||
}
|
||||
],
|
||||
timeFrom: null,
|
||||
timeShift: null,
|
||||
title: "Panel Title",
|
||||
tooltip: {
|
||||
shared: true,
|
||||
sort: 0,
|
||||
value_type: "individual"
|
||||
},
|
||||
type: "graph",
|
||||
xaxis: {
|
||||
buckets: null,
|
||||
mode: "time",
|
||||
name: null,
|
||||
show: true,
|
||||
values: []
|
||||
},
|
||||
yaxes: [
|
||||
{
|
||||
format: "short",
|
||||
label: null,
|
||||
logBase: 1,
|
||||
max: null,
|
||||
min: null,
|
||||
show: true
|
||||
},
|
||||
{
|
||||
format: "short",
|
||||
label: null,
|
||||
logBase: 1,
|
||||
max: null,
|
||||
min: null,
|
||||
show: true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
schemaVersion: 16,
|
||||
tags: [],
|
||||
templating: {
|
||||
list: []
|
||||
},
|
||||
time: {
|
||||
from: "now-6h",
|
||||
to: "now"
|
||||
},
|
||||
timepicker: {
|
||||
refresh_intervals: [
|
||||
"5s",
|
||||
"10s",
|
||||
"30s",
|
||||
"1m",
|
||||
"5m",
|
||||
"15m",
|
||||
"30m",
|
||||
"1h",
|
||||
"2h",
|
||||
"1d"
|
||||
],
|
||||
time_options: [
|
||||
"5m",
|
||||
"15m",
|
||||
"1h",
|
||||
"6h",
|
||||
"12h",
|
||||
"24h",
|
||||
"2d",
|
||||
"7d",
|
||||
"30d"
|
||||
]
|
||||
},
|
||||
timezone: "",
|
||||
uid: null,
|
||||
version: 0
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
local arr = std.range(1, 100);
|
||||
|
||||
{
|
||||
"apiVersion": 1,
|
||||
"datasources": [
|
||||
{
|
||||
"name": 'gfdev-bulkalerting-' + counter,
|
||||
"type": "prometheus",
|
||||
"access": "proxy",
|
||||
"url": "http://localhost:9090"
|
||||
}
|
||||
for counter in arr
|
||||
],
|
||||
}
|
||||
@@ -1,806 +0,0 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "grafana"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"target": {
|
||||
"limit": 100,
|
||||
"matchAny": false,
|
||||
"tags": [],
|
||||
"type": "dashboard"
|
||||
},
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": 147,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"alert": {
|
||||
"alertRuleTags": {},
|
||||
"conditions": [
|
||||
{
|
||||
"evaluator": {
|
||||
"params": [
|
||||
177
|
||||
],
|
||||
"type": "gt"
|
||||
},
|
||||
"query": {
|
||||
"params": [
|
||||
"A",
|
||||
"5m",
|
||||
"now"
|
||||
]
|
||||
},
|
||||
"reducer": {
|
||||
"params": [],
|
||||
"type": "avg"
|
||||
},
|
||||
"type": "query"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"executionErrorState": "alerting",
|
||||
"for": "0m",
|
||||
"frequency": "60s",
|
||||
"handler": 1,
|
||||
"name": "TestData - Always Alerting",
|
||||
"noDataState": "no_data",
|
||||
"notifications": []
|
||||
},
|
||||
"aliasColors": {},
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
"uid": "PD8C576611E62080A"
|
||||
},
|
||||
"editable": true,
|
||||
"error": false,
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"links": []
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"fill": 1,
|
||||
"fillGradient": 0,
|
||||
"gridPos": {
|
||||
"h": 7,
|
||||
"w": 10,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"hiddenSeries": false,
|
||||
"id": 4,
|
||||
"isNew": true,
|
||||
"legend": {
|
||||
"show": true
|
||||
},
|
||||
"lines": true,
|
||||
"linewidth": 2,
|
||||
"links": [],
|
||||
"nullPointMode": "connected",
|
||||
"options": {
|
||||
"alertThreshold": true
|
||||
},
|
||||
"percentage": false,
|
||||
"pluginVersion": "9.4.0-pre",
|
||||
"pointradius": 5,
|
||||
"points": false,
|
||||
"renderer": "flot",
|
||||
"seriesOverrides": [],
|
||||
"spaceLength": 10,
|
||||
"stack": false,
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
"uid": "PD8C576611E62080A"
|
||||
},
|
||||
"refId": "A",
|
||||
"scenario": "random_walk",
|
||||
"scenarioId": "csv_metric_values",
|
||||
"stringInput": "200,445,100,150,200,220,190",
|
||||
"target": ""
|
||||
}
|
||||
],
|
||||
"thresholds": [
|
||||
{
|
||||
"colorMode": "critical",
|
||||
"fill": true,
|
||||
"line": true,
|
||||
"op": "gt",
|
||||
"value": 177
|
||||
}
|
||||
],
|
||||
"timeRegions": [],
|
||||
"title": "Always Alerting",
|
||||
"tooltip": {
|
||||
"msResolution": false,
|
||||
"shared": true,
|
||||
"sort": 0,
|
||||
"value_type": "cumulative"
|
||||
},
|
||||
"type": "graph",
|
||||
"xaxis": {
|
||||
"mode": "time",
|
||||
"show": true,
|
||||
"values": []
|
||||
},
|
||||
"yaxes": [
|
||||
{
|
||||
"format": "short",
|
||||
"label": "",
|
||||
"logBase": 1,
|
||||
"min": "0",
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"format": "short",
|
||||
"label": "",
|
||||
"logBase": 1,
|
||||
"show": true
|
||||
}
|
||||
],
|
||||
"yaxis": {
|
||||
"align": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"alert": {
|
||||
"alertRuleTags": {},
|
||||
"conditions": [
|
||||
{
|
||||
"evaluator": {
|
||||
"params": [
|
||||
100
|
||||
],
|
||||
"type": "gt"
|
||||
},
|
||||
"operator": {
|
||||
"type": "and"
|
||||
},
|
||||
"query": {
|
||||
"params": [
|
||||
"A",
|
||||
"5m",
|
||||
"now"
|
||||
]
|
||||
},
|
||||
"reducer": {
|
||||
"params": [],
|
||||
"type": "avg"
|
||||
},
|
||||
"type": "query"
|
||||
}
|
||||
],
|
||||
"executionErrorState": "alerting",
|
||||
"for": "900000h",
|
||||
"frequency": "1m",
|
||||
"handler": 1,
|
||||
"name": "TestData - Always Pending",
|
||||
"noDataState": "no_data",
|
||||
"notifications": []
|
||||
},
|
||||
"aliasColors": {},
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
"uid": "PD8C576611E62080A"
|
||||
},
|
||||
"editable": true,
|
||||
"error": false,
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"links": []
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"fill": 1,
|
||||
"fillGradient": 0,
|
||||
"gridPos": {
|
||||
"h": 7,
|
||||
"w": 10,
|
||||
"x": 10,
|
||||
"y": 0
|
||||
},
|
||||
"hiddenSeries": false,
|
||||
"id": 7,
|
||||
"isNew": true,
|
||||
"legend": {
|
||||
"show": true
|
||||
},
|
||||
"lines": true,
|
||||
"linewidth": 2,
|
||||
"links": [],
|
||||
"nullPointMode": "connected",
|
||||
"options": {
|
||||
"alertThreshold": true
|
||||
},
|
||||
"percentage": false,
|
||||
"pointradius": 5,
|
||||
"points": false,
|
||||
"renderer": "flot",
|
||||
"seriesOverrides": [],
|
||||
"spaceLength": 10,
|
||||
"stack": false,
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
"uid": "PD8C576611E62080A"
|
||||
},
|
||||
"refId": "A",
|
||||
"scenario": "random_walk",
|
||||
"scenarioId": "csv_metric_values",
|
||||
"stringInput": "200,445,100,150,200,220,190",
|
||||
"target": ""
|
||||
}
|
||||
],
|
||||
"thresholds": [
|
||||
{
|
||||
"colorMode": "critical",
|
||||
"fill": true,
|
||||
"line": true,
|
||||
"op": "gt",
|
||||
"value": 100
|
||||
}
|
||||
],
|
||||
"timeRegions": [],
|
||||
"title": "Always Pending with For",
|
||||
"tooltip": {
|
||||
"msResolution": false,
|
||||
"shared": true,
|
||||
"sort": 0,
|
||||
"value_type": "cumulative"
|
||||
},
|
||||
"type": "graph",
|
||||
"xaxis": {
|
||||
"mode": "time",
|
||||
"show": true,
|
||||
"values": []
|
||||
},
|
||||
"yaxes": [
|
||||
{
|
||||
"format": "short",
|
||||
"label": "",
|
||||
"logBase": 1,
|
||||
"min": "0",
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"format": "short",
|
||||
"label": "",
|
||||
"logBase": 1,
|
||||
"show": true
|
||||
}
|
||||
],
|
||||
"yaxis": {
|
||||
"align": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
"uid": "PD8C576611E62080A"
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 20,
|
||||
"w": 4,
|
||||
"x": 20,
|
||||
"y": 0
|
||||
},
|
||||
"id": 9,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
"uid": "PD8C576611E62080A"
|
||||
},
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Alert list",
|
||||
"type": "alertlist"
|
||||
},
|
||||
{
|
||||
"alert": {
|
||||
"alertRuleTags": {},
|
||||
"conditions": [
|
||||
{
|
||||
"evaluator": {
|
||||
"params": [
|
||||
177
|
||||
],
|
||||
"type": "gt"
|
||||
},
|
||||
"operator": {
|
||||
"type": "and"
|
||||
},
|
||||
"query": {
|
||||
"params": [
|
||||
"A",
|
||||
"15m",
|
||||
"now"
|
||||
]
|
||||
},
|
||||
"reducer": {
|
||||
"params": [],
|
||||
"type": "avg"
|
||||
},
|
||||
"type": "query"
|
||||
}
|
||||
],
|
||||
"executionErrorState": "alerting",
|
||||
"for": "1m",
|
||||
"frequency": "1m",
|
||||
"handler": 1,
|
||||
"name": "TestData - Always Alerting For",
|
||||
"noDataState": "no_data",
|
||||
"notifications": []
|
||||
},
|
||||
"aliasColors": {},
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
"uid": "PD8C576611E62080A"
|
||||
},
|
||||
"editable": true,
|
||||
"error": false,
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"links": []
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"fill": 1,
|
||||
"fillGradient": 0,
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 10,
|
||||
"x": 0,
|
||||
"y": 7
|
||||
},
|
||||
"hiddenSeries": false,
|
||||
"id": 6,
|
||||
"isNew": true,
|
||||
"legend": {
|
||||
"show": true
|
||||
},
|
||||
"lines": true,
|
||||
"linewidth": 2,
|
||||
"links": [],
|
||||
"nullPointMode": "connected",
|
||||
"options": {
|
||||
"alertThreshold": true
|
||||
},
|
||||
"percentage": false,
|
||||
"pluginVersion": "9.4.0-pre",
|
||||
"pointradius": 5,
|
||||
"points": false,
|
||||
"renderer": "flot",
|
||||
"seriesOverrides": [],
|
||||
"spaceLength": 10,
|
||||
"stack": false,
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
"uid": "PD8C576611E62080A"
|
||||
},
|
||||
"refId": "A",
|
||||
"scenario": "random_walk",
|
||||
"scenarioId": "csv_metric_values",
|
||||
"stringInput": "200,445,100,150,200,220,190",
|
||||
"target": ""
|
||||
}
|
||||
],
|
||||
"thresholds": [
|
||||
{
|
||||
"colorMode": "critical",
|
||||
"fill": true,
|
||||
"line": true,
|
||||
"op": "gt",
|
||||
"value": 177
|
||||
}
|
||||
],
|
||||
"timeRegions": [],
|
||||
"title": "Always Alerting with For",
|
||||
"tooltip": {
|
||||
"msResolution": false,
|
||||
"shared": true,
|
||||
"sort": 0,
|
||||
"value_type": "cumulative"
|
||||
},
|
||||
"type": "graph",
|
||||
"xaxis": {
|
||||
"mode": "time",
|
||||
"show": true,
|
||||
"values": []
|
||||
},
|
||||
"yaxes": [
|
||||
{
|
||||
"format": "short",
|
||||
"label": "",
|
||||
"logBase": 1,
|
||||
"min": "0",
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"format": "short",
|
||||
"label": "",
|
||||
"logBase": 1,
|
||||
"show": true
|
||||
}
|
||||
],
|
||||
"yaxis": {
|
||||
"align": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"alert": {
|
||||
"conditions": [
|
||||
{
|
||||
"evaluator": {
|
||||
"params": [
|
||||
60
|
||||
],
|
||||
"type": "gt"
|
||||
},
|
||||
"query": {
|
||||
"params": [
|
||||
"A",
|
||||
"5m",
|
||||
"now"
|
||||
]
|
||||
},
|
||||
"reducer": {
|
||||
"params": [],
|
||||
"type": "avg"
|
||||
},
|
||||
"type": "query"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"frequency": "60s",
|
||||
"handler": 1,
|
||||
"name": "TestData - Always OK",
|
||||
"noDataState": "no_data",
|
||||
"notifications": []
|
||||
},
|
||||
"aliasColors": {},
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
"uid": "PD8C576611E62080A"
|
||||
},
|
||||
"editable": true,
|
||||
"error": false,
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"links": []
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"fill": 1,
|
||||
"fillGradient": 0,
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 10,
|
||||
"x": 10,
|
||||
"y": 7
|
||||
},
|
||||
"hiddenSeries": false,
|
||||
"id": 3,
|
||||
"isNew": true,
|
||||
"legend": {
|
||||
"show": true
|
||||
},
|
||||
"lines": true,
|
||||
"linewidth": 2,
|
||||
"links": [],
|
||||
"nullPointMode": "connected",
|
||||
"options": {
|
||||
"alertThreshold": true
|
||||
},
|
||||
"percentage": false,
|
||||
"pluginVersion": "9.4.0-pre",
|
||||
"pointradius": 5,
|
||||
"points": false,
|
||||
"renderer": "flot",
|
||||
"seriesOverrides": [],
|
||||
"spaceLength": 10,
|
||||
"stack": false,
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
"uid": "PD8C576611E62080A"
|
||||
},
|
||||
"refId": "A",
|
||||
"scenario": "random_walk",
|
||||
"scenarioId": "csv_metric_values",
|
||||
"stringInput": "1,20,90,30,5,0",
|
||||
"target": ""
|
||||
}
|
||||
],
|
||||
"thresholds": [
|
||||
{
|
||||
"colorMode": "critical",
|
||||
"fill": true,
|
||||
"line": true,
|
||||
"op": "gt",
|
||||
"value": 60
|
||||
}
|
||||
],
|
||||
"timeRegions": [],
|
||||
"title": "Always OK",
|
||||
"tooltip": {
|
||||
"msResolution": false,
|
||||
"shared": true,
|
||||
"sort": 0,
|
||||
"value_type": "cumulative"
|
||||
},
|
||||
"type": "graph",
|
||||
"xaxis": {
|
||||
"mode": "time",
|
||||
"show": true,
|
||||
"values": []
|
||||
},
|
||||
"yaxes": [
|
||||
{
|
||||
"format": "short",
|
||||
"label": "",
|
||||
"logBase": 1,
|
||||
"max": "125",
|
||||
"min": "0",
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"format": "short",
|
||||
"logBase": 1,
|
||||
"show": true
|
||||
}
|
||||
],
|
||||
"yaxis": {
|
||||
"align": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"alert": {
|
||||
"conditions": [
|
||||
{
|
||||
"evaluator": {
|
||||
"params": [
|
||||
1
|
||||
],
|
||||
"type": "gt"
|
||||
},
|
||||
"operator": {
|
||||
"type": "and"
|
||||
},
|
||||
"query": {
|
||||
"params": [
|
||||
"A",
|
||||
"15m",
|
||||
"now"
|
||||
]
|
||||
},
|
||||
"reducer": {
|
||||
"params": [],
|
||||
"type": "avg"
|
||||
},
|
||||
"type": "query"
|
||||
}
|
||||
],
|
||||
"executionErrorState": "alerting",
|
||||
"for": "5m",
|
||||
"frequency": "1m",
|
||||
"handler": 1,
|
||||
"name": "TestData - No data",
|
||||
"noDataState": "no_data",
|
||||
"notifications": []
|
||||
},
|
||||
"aliasColors": {},
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
"uid": "PD8C576611E62080A"
|
||||
},
|
||||
"editable": true,
|
||||
"error": false,
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"links": []
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"fill": 1,
|
||||
"fillGradient": 0,
|
||||
"gridPos": {
|
||||
"h": 7,
|
||||
"w": 10,
|
||||
"x": 0,
|
||||
"y": 13
|
||||
},
|
||||
"hiddenSeries": false,
|
||||
"id": 5,
|
||||
"isNew": true,
|
||||
"legend": {
|
||||
"show": true
|
||||
},
|
||||
"lines": true,
|
||||
"linewidth": 2,
|
||||
"links": [],
|
||||
"nullPointMode": "connected",
|
||||
"options": {
|
||||
"alertThreshold": true
|
||||
},
|
||||
"percentage": false,
|
||||
"pluginVersion": "9.4.0-pre",
|
||||
"pointradius": 5,
|
||||
"points": false,
|
||||
"renderer": "flot",
|
||||
"seriesOverrides": [],
|
||||
"spaceLength": 10,
|
||||
"stack": false,
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "testdata",
|
||||
"uid": "PD8C576611E62080A"
|
||||
},
|
||||
"refId": "A",
|
||||
"scenario": "random_walk",
|
||||
"scenarioId": "no_data_points",
|
||||
"stringInput": "",
|
||||
"target": ""
|
||||
}
|
||||
],
|
||||
"thresholds": [
|
||||
{
|
||||
"colorMode": "critical",
|
||||
"fill": true,
|
||||
"line": true,
|
||||
"op": "gt",
|
||||
"value": 1
|
||||
}
|
||||
],
|
||||
"timeRegions": [],
|
||||
"title": "No data",
|
||||
"tooltip": {
|
||||
"msResolution": false,
|
||||
"shared": true,
|
||||
"sort": 0,
|
||||
"value_type": "cumulative"
|
||||
},
|
||||
"type": "graph",
|
||||
"xaxis": {
|
||||
"mode": "time",
|
||||
"show": true,
|
||||
"values": []
|
||||
},
|
||||
"yaxes": [
|
||||
{
|
||||
"format": "short",
|
||||
"label": "",
|
||||
"logBase": 1,
|
||||
"min": "0",
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"format": "short",
|
||||
"label": "",
|
||||
"logBase": 1,
|
||||
"show": true
|
||||
}
|
||||
],
|
||||
"yaxis": {
|
||||
"align": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"schemaVersion": 37,
|
||||
"tags": [
|
||||
"gdev",
|
||||
"alerting"
|
||||
],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {
|
||||
"text": "TestData",
|
||||
"value": "TestData"
|
||||
},
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"label": "alert name filter",
|
||||
"multi": false,
|
||||
"name": "namefilter",
|
||||
"options": [
|
||||
{
|
||||
"selected": true,
|
||||
"text": "TestData",
|
||||
"value": "TestData"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "Prometheus",
|
||||
"value": "Prometheus"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "Graphite",
|
||||
"value": "Graphite"
|
||||
}
|
||||
],
|
||||
"query": "TestData,Prometheus,Graphite",
|
||||
"skipUrlSync": false,
|
||||
"type": "custom"
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {
|
||||
"refresh_intervals": [
|
||||
"5s",
|
||||
"10s",
|
||||
"30s",
|
||||
"1m",
|
||||
"5m",
|
||||
"15m",
|
||||
"30m",
|
||||
"1h",
|
||||
"2h",
|
||||
"1d"
|
||||
],
|
||||
"time_options": [
|
||||
"5m",
|
||||
"15m",
|
||||
"1h",
|
||||
"6h",
|
||||
"12h",
|
||||
"24h",
|
||||
"2d",
|
||||
"7d",
|
||||
"30d"
|
||||
]
|
||||
},
|
||||
"timezone": "browser",
|
||||
"title": "Alerting with TestData",
|
||||
"uid": "7MeksYbmk",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
local numAlerts = std.extVar('alerts');
|
||||
local condition = std.extVar('condition');
|
||||
local arr = std.range(1, numAlerts);
|
||||
|
||||
local alertDashboardTemplate = {
|
||||
"editable": true,
|
||||
"gnetId": null,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"alert": {
|
||||
"conditions": [
|
||||
{
|
||||
"evaluator": {
|
||||
"params": [
|
||||
65
|
||||
],
|
||||
"type": "gt"
|
||||
},
|
||||
"operator": {
|
||||
"type": "and"
|
||||
},
|
||||
"query": {
|
||||
"params": [
|
||||
"A",
|
||||
"5m",
|
||||
"now"
|
||||
]
|
||||
},
|
||||
"reducer": {
|
||||
"params": [],
|
||||
"type": "avg"
|
||||
},
|
||||
"type": "query"
|
||||
}
|
||||
],
|
||||
"executionErrorState": "alerting",
|
||||
"frequency": "10s",
|
||||
"handler": 1,
|
||||
"for": "1m",
|
||||
"name": "bulk alerting",
|
||||
"noDataState": "no_data",
|
||||
"notifications": [
|
||||
{
|
||||
"id": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
"aliasColors": {},
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"datasource": "Prometheus",
|
||||
"fill": 1,
|
||||
"gridPos": {
|
||||
"h": 9,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 2,
|
||||
"legend": {
|
||||
"avg": false,
|
||||
"current": false,
|
||||
"max": false,
|
||||
"min": false,
|
||||
"show": true,
|
||||
"total": false,
|
||||
"values": false
|
||||
},
|
||||
"lines": true,
|
||||
"linewidth": 1,
|
||||
"nullPointMode": "null",
|
||||
"percentage": false,
|
||||
"pointradius": 5,
|
||||
"points": false,
|
||||
"renderer": "flot",
|
||||
"seriesOverrides": [],
|
||||
"spaceLength": 10,
|
||||
"stack": false,
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"$$hashKey": "object:117",
|
||||
"expr": "go_goroutines",
|
||||
"format": "time_series",
|
||||
"intervalFactor": 1,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"thresholds": [
|
||||
{
|
||||
"colorMode": "critical",
|
||||
"fill": true,
|
||||
"line": true,
|
||||
"op": "gt",
|
||||
"value": 50
|
||||
}
|
||||
],
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Panel Title",
|
||||
"tooltip": {
|
||||
"shared": true,
|
||||
"sort": 0,
|
||||
"value_type": "individual"
|
||||
},
|
||||
"type": "graph",
|
||||
"xaxis": {
|
||||
"buckets": null,
|
||||
"mode": "time",
|
||||
"name": null,
|
||||
"show": true,
|
||||
"values": []
|
||||
},
|
||||
"yaxes": [
|
||||
{
|
||||
"format": "short",
|
||||
"label": null,
|
||||
"logBase": 1,
|
||||
"max": null,
|
||||
"min": null,
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"format": "short",
|
||||
"label": null,
|
||||
"logBase": 1,
|
||||
"max": null,
|
||||
"min": null,
|
||||
"show": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"schemaVersion": 16,
|
||||
"tags": [],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {
|
||||
"refresh_intervals": [
|
||||
"5s",
|
||||
"10s",
|
||||
"30s",
|
||||
"1m",
|
||||
"5m",
|
||||
"15m",
|
||||
"30m",
|
||||
"1h",
|
||||
"2h",
|
||||
"1d"
|
||||
],
|
||||
"time_options": [
|
||||
"5m",
|
||||
"15m",
|
||||
"1h",
|
||||
"6h",
|
||||
"12h",
|
||||
"24h",
|
||||
"2d",
|
||||
"7d",
|
||||
"30d"
|
||||
]
|
||||
},
|
||||
"timezone": "",
|
||||
"title": "New dashboard",
|
||||
"uid": null,
|
||||
"version": 0
|
||||
};
|
||||
|
||||
|
||||
{
|
||||
['alert-' + std.toString(x) + '.json']:
|
||||
alertDashboardTemplate + {
|
||||
panels: [
|
||||
alertDashboardTemplate.panels[0] +
|
||||
{
|
||||
alert+: {
|
||||
name: 'Alert rule ' + x,
|
||||
conditions: [
|
||||
alertDashboardTemplate.panels[0].alert.conditions[0] +
|
||||
{
|
||||
evaluator+: {
|
||||
params: [condition]
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
uid: 'alert-' + x,
|
||||
title: 'Alert ' + x
|
||||
},
|
||||
for x in arr
|
||||
}
|
||||
@@ -84,27 +84,6 @@ slack() {
|
||||
http://admin:admin@grafana.loc/api/alert-notifications/2
|
||||
}
|
||||
|
||||
provision() {
|
||||
alerts=1
|
||||
condition=65
|
||||
while getopts ":a:c:" o; do
|
||||
case "${o}" in
|
||||
a)
|
||||
alerts=${OPTARG}
|
||||
;;
|
||||
c)
|
||||
condition=${OPTARG}
|
||||
;;
|
||||
esac
|
||||
done
|
||||
shift $((OPTIND-1))
|
||||
|
||||
requiresJsonnet
|
||||
|
||||
find grafana/provisioning/dashboards/alerts -maxdepth 1 -name 'alert*.json' -delete
|
||||
jsonnet -m grafana/provisioning/dashboards/alerts grafana/provisioning/alerts.jsonnet --ext-code alerts=$alerts --ext-code condition=$condition
|
||||
}
|
||||
|
||||
pause() {
|
||||
curl -H "Content-Type: application/json" \
|
||||
-d '{"paused":true}' \
|
||||
@@ -126,9 +105,6 @@ usage() {
|
||||
echo -e " [-u]\t\t\t url"
|
||||
echo -e " [-r]\t\t\t send reminders"
|
||||
echo -e " [-e <remind every>]\t\t default 10m\n"
|
||||
echo -e " provision\t provision alerts"
|
||||
echo -e " [-a <alert rule count>]\t default 1"
|
||||
echo -e " [-c <condition value>]\t default 65\n"
|
||||
echo -e " pause\t\t pause all alerts"
|
||||
echo -e " unpause\t unpause all alerts"
|
||||
}
|
||||
@@ -140,8 +116,6 @@ main() {
|
||||
setup
|
||||
elif [[ $cmd == "slack" ]]; then
|
||||
slack "${@:2}"
|
||||
elif [[ $cmd == "provision" ]]; then
|
||||
provision "${@:2}"
|
||||
elif [[ $cmd == "pause" ]]; then
|
||||
pause
|
||||
elif [[ $cmd == "unpause" ]]; then
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
local numAlerts = std.extVar('alerts');
|
||||
local condition = std.extVar('condition');
|
||||
local arr = std.range(1, numAlerts);
|
||||
|
||||
local alertDashboardTemplate = {
|
||||
"editable": true,
|
||||
"gnetId": null,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"alert": {
|
||||
"conditions": [
|
||||
{
|
||||
"evaluator": {
|
||||
"params": [
|
||||
65
|
||||
],
|
||||
"type": "gt"
|
||||
},
|
||||
"operator": {
|
||||
"type": "and"
|
||||
},
|
||||
"query": {
|
||||
"params": [
|
||||
"A",
|
||||
"5m",
|
||||
"now"
|
||||
]
|
||||
},
|
||||
"reducer": {
|
||||
"params": [],
|
||||
"type": "avg"
|
||||
},
|
||||
"type": "query"
|
||||
}
|
||||
],
|
||||
"executionErrorState": "alerting",
|
||||
"frequency": "10s",
|
||||
"handler": 1,
|
||||
"for": "1m",
|
||||
"name": "bulk alerting",
|
||||
"noDataState": "no_data",
|
||||
"notifications": [
|
||||
{
|
||||
"id": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
"aliasColors": {},
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"datasource": "Prometheus",
|
||||
"fill": 1,
|
||||
"gridPos": {
|
||||
"h": 9,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 2,
|
||||
"legend": {
|
||||
"avg": false,
|
||||
"current": false,
|
||||
"max": false,
|
||||
"min": false,
|
||||
"show": true,
|
||||
"total": false,
|
||||
"values": false
|
||||
},
|
||||
"lines": true,
|
||||
"linewidth": 1,
|
||||
"nullPointMode": "null",
|
||||
"percentage": false,
|
||||
"pointradius": 5,
|
||||
"points": false,
|
||||
"renderer": "flot",
|
||||
"seriesOverrides": [],
|
||||
"spaceLength": 10,
|
||||
"stack": false,
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"$$hashKey": "object:117",
|
||||
"expr": "go_goroutines",
|
||||
"format": "time_series",
|
||||
"intervalFactor": 1,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"thresholds": [
|
||||
{
|
||||
"colorMode": "critical",
|
||||
"fill": true,
|
||||
"line": true,
|
||||
"op": "gt",
|
||||
"value": 50
|
||||
}
|
||||
],
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Panel Title",
|
||||
"tooltip": {
|
||||
"shared": true,
|
||||
"sort": 0,
|
||||
"value_type": "individual"
|
||||
},
|
||||
"type": "graph",
|
||||
"xaxis": {
|
||||
"buckets": null,
|
||||
"mode": "time",
|
||||
"name": null,
|
||||
"show": true,
|
||||
"values": []
|
||||
},
|
||||
"yaxes": [
|
||||
{
|
||||
"format": "short",
|
||||
"label": null,
|
||||
"logBase": 1,
|
||||
"max": null,
|
||||
"min": null,
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"format": "short",
|
||||
"label": null,
|
||||
"logBase": 1,
|
||||
"max": null,
|
||||
"min": null,
|
||||
"show": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"schemaVersion": 16,
|
||||
"tags": [],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {
|
||||
"refresh_intervals": [
|
||||
"5s",
|
||||
"10s",
|
||||
"30s",
|
||||
"1m",
|
||||
"5m",
|
||||
"15m",
|
||||
"30m",
|
||||
"1h",
|
||||
"2h",
|
||||
"1d"
|
||||
],
|
||||
"time_options": [
|
||||
"5m",
|
||||
"15m",
|
||||
"1h",
|
||||
"6h",
|
||||
"12h",
|
||||
"24h",
|
||||
"2d",
|
||||
"7d",
|
||||
"30d"
|
||||
]
|
||||
},
|
||||
"timezone": "",
|
||||
"title": "New dashboard",
|
||||
"uid": null,
|
||||
"version": 0
|
||||
};
|
||||
|
||||
|
||||
{
|
||||
['alert-' + std.toString(x) + '.json']:
|
||||
alertDashboardTemplate + {
|
||||
panels: [
|
||||
alertDashboardTemplate.panels[0] +
|
||||
{
|
||||
alert+: {
|
||||
name: 'Alert rule ' + x,
|
||||
conditions: [
|
||||
alertDashboardTemplate.panels[0].alert.conditions[0] +
|
||||
{
|
||||
evaluator+: {
|
||||
params: [condition]
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
uid: 'alert-' + x,
|
||||
title: 'Alert ' + x
|
||||
},
|
||||
for x in arr
|
||||
}
|
||||
@@ -103,7 +103,6 @@
|
||||
"testdata-test-variable-output": (import '../dev-dashboards/feature-templating/testdata-test-variable-output.json'),
|
||||
"testdata-variables-textbox": (import '../dev-dashboards/feature-templating/testdata-variables-textbox.json'),
|
||||
"testdata-variables-that-update-on-time-c": (import '../dev-dashboards/feature-templating/testdata-variables-that-update-on-time-change.json'),
|
||||
"testdata_alerts": (import '../dev-dashboards/alerting/testdata_alerts.json'),
|
||||
"text-options": (import '../dev-dashboards/panel-text/text-options.json'),
|
||||
"time_zone_support": (import '../dev-dashboards/scenarios/time_zone_support.json'),
|
||||
"timeline-align-endtime": (import '../dev-dashboards/panel-timeline/timeline-align-endtime.json'),
|
||||
|
||||
+1
-31
@@ -14,27 +14,6 @@ bulkDashboard() {
|
||||
ln -s -f ../../../devenv/bulk-dashboards/bulk-dashboards.yaml ../conf/provisioning/dashboards/custom.yaml
|
||||
}
|
||||
|
||||
bulkAlertingDashboard() {
|
||||
|
||||
requiresJsonnet
|
||||
|
||||
jsonnet -o "bulk_alerting_dashboards/bulk_alerting_datasources.yaml" ./bulk_alerting_dashboards/datasources.jsonnet
|
||||
|
||||
COUNTER=1
|
||||
DS=1
|
||||
MAX=1000
|
||||
while [ $COUNTER -lt $MAX ]; do
|
||||
jsonnet -o "bulk_alerting_dashboards/alerting_dashboard${COUNTER}.json" \
|
||||
-e "local bulkDash = import 'bulk_alerting_dashboards/dashboard.libsonnet'; bulkDash.alertingDashboard(${COUNTER}, ${DS})"
|
||||
let COUNTER=COUNTER+1
|
||||
let DS=COUNTER/10
|
||||
let DS=DS+1
|
||||
done
|
||||
|
||||
ln -s -f ../../../devenv/bulk_alerting_dashboards/bulk_alerting_dashboards.yaml ../conf/provisioning/dashboards/custom.yaml
|
||||
ln -s -f ../../../devenv/bulk_alerting_dashboards/bulk_alerting_datasources.yaml ../conf/provisioning/datasources/custom.yaml
|
||||
}
|
||||
|
||||
bulkFolders() {
|
||||
./bulk-folders/bulk-folders.sh "$1"
|
||||
ln -s -f ../../../devenv/bulk-folders/bulk-folders.yaml ../conf/provisioning/dashboards/bulk-folders.yaml
|
||||
@@ -70,12 +49,6 @@ undev() {
|
||||
rm -rf bulk-folders/Bulk\ Folder*
|
||||
echo -e " \xE2\x9C\x94 Reverting bulk-folders provisioning"
|
||||
|
||||
|
||||
# Removing generated dashboard and datasource files from bulk-alerting-dashboards
|
||||
rm -f bulk_alerting_dashboards/alerting_dashboard*.json
|
||||
rm -f "bulk_alerting_dashboards/bulk_alerting_datasources.yaml"
|
||||
echo -e " \xE2\x9C\x94 Reverting bulk-alerting-dashboards provisioning"
|
||||
|
||||
# Removing the symlinks
|
||||
rm -f ../conf/provisioning/dashboards/custom.yaml
|
||||
rm -f ../conf/provisioning/dashboards/bulk-folders.yaml
|
||||
@@ -88,7 +61,6 @@ usage() {
|
||||
echo -e "\n"
|
||||
echo "Usage:"
|
||||
echo " bulk-dashboards - provision 400 dashboards"
|
||||
echo " bulk-alerting-dashboards - provision 400 dashboards with alerts"
|
||||
echo " bulk-folders [folders] [dashboards] - provision many folders with dashboards"
|
||||
echo " bulk-folders - provision 200 folders with 3 dashboards in each"
|
||||
echo " no args - provision core datasources and dev dashboards"
|
||||
@@ -104,9 +76,7 @@ main() {
|
||||
local cmd=$1
|
||||
local arg1=$2
|
||||
|
||||
if [[ $cmd == "bulk-alerting-dashboards" ]]; then
|
||||
bulkAlertingDashboard
|
||||
elif [[ $cmd == "bulk-dashboards" ]]; then
|
||||
if [[ $cmd == "bulk-dashboards" ]]; then
|
||||
bulkDashboard
|
||||
elif [[ $cmd == "bulk-folders" ]]; then
|
||||
bulkFolders "$arg1"
|
||||
|
||||
@@ -615,8 +615,6 @@ Content-Type: application/json
|
||||
|
||||
`POST /api/admin/provisioning/plugins/reload`
|
||||
|
||||
`POST /api/admin/provisioning/notifications/reload`
|
||||
|
||||
`POST /api/admin/provisioning/access-control/reload`
|
||||
|
||||
`POST /api/admin/provisioning/alerting/reload`
|
||||
@@ -637,7 +635,6 @@ See note in the [introduction]({{< ref "#admin-api" >}}) for an explanation.
|
||||
| provisioning:reload | provisioners:dashboards | dashboards |
|
||||
| provisioning:reload | provisioners:datasources | datasources |
|
||||
| provisioning:reload | provisioners:plugins | plugins |
|
||||
| provisioning:reload | provisioners:notifications | notifications |
|
||||
| provisioning:reload | provisioners:alerting | alerting |
|
||||
|
||||
**Example Request**:
|
||||
|
||||
@@ -55,10 +55,8 @@ Some features are enabled by default. You can disable these feature by setting t
|
||||
| `lokiStructuredMetadata` | Enables the loki data source to request structured metadata from the Loki server | Yes |
|
||||
| `logRowsPopoverMenu` | Enable filtering menu displayed when text of a log line is selected | Yes |
|
||||
| `lokiQueryHints` | Enables query hints for Loki | Yes |
|
||||
| `alertingPreviewUpgrade` | Show Unified Alerting preview and upgrade page in legacy alerting | Yes |
|
||||
| `alertingQueryOptimization` | Optimizes eligible queries in order to reduce load on datasources | |
|
||||
| `betterPageScrolling` | Removes CustomScrollbar from the UI, relying on native browser scrollbars | Yes |
|
||||
| `alertingUpgradeDryrunOnStart` | When activated in legacy alerting mode, this initiates a dry-run of the Unified Alerting upgrade during each startup. It logs any issues detected without implementing any actual changes. | Yes |
|
||||
|
||||
## Preview feature toggles
|
||||
|
||||
|
||||
@@ -303,7 +303,6 @@ external group.
|
||||
| Reload provisioned dashboards | `{"action": "provisioning-dashboards"}` |
|
||||
| Reload provisioned datasources | `{"action": "provisioning-datasources"}` |
|
||||
| Reload provisioned plugins | `{"action": "provisioning-plugins"}` |
|
||||
| Reload provisioned notifications | `{"action": "provisioning-notifications"}` |
|
||||
| Reload provisioned alerts | `{"action": "provisioning-alerts"}` |
|
||||
| Reload provisioned access control | `{"action": "provisioning-accesscontrol"}` |
|
||||
|
||||
|
||||
@@ -68,8 +68,6 @@ provisioning/
|
||||
<yaml files>
|
||||
dashboards/
|
||||
<yaml files>
|
||||
notifiers/
|
||||
<yaml files>
|
||||
```
|
||||
|
||||
Next, we'll look at how to provision a data source.
|
||||
|
||||
@@ -16,11 +16,11 @@ describe('Dashboard browse', () => {
|
||||
e2e.pages.BrowseDashboards.table.row('E2E Test - Import Dashboard').should('be.visible');
|
||||
|
||||
// gdev dashboards folder is collapsed - its content should not be visible
|
||||
e2e.pages.BrowseDashboards.table.row('Alerting with TestData').should('not.exist');
|
||||
e2e.pages.BrowseDashboards.table.row('Bar Gauge Demo').should('not.exist');
|
||||
|
||||
// should click a folder and see it's children
|
||||
e2e.pages.BrowseDashboards.table.row('gdev dashboards').find('[aria-label^="Expand folder"]').click();
|
||||
e2e.pages.BrowseDashboards.table.row('Alerting with TestData').should('be.visible');
|
||||
e2e.pages.BrowseDashboards.table.row('Bar Gauge Demo').should('be.visible');
|
||||
|
||||
// Open the new folder drawer
|
||||
cy.contains('button', 'New').click();
|
||||
|
||||
@@ -70,7 +70,7 @@ require (
|
||||
github.com/magefile/mage v1.15.0 // @grafana/grafana-release-guild
|
||||
github.com/mattn/go-isatty v0.0.19 // @grafana/backend-platform
|
||||
github.com/mattn/go-sqlite3 v1.14.19 // @grafana/backend-platform
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 // @grafana/alerting-squad-backend
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect; @grafana/alerting-squad-backend
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // @grafana/grafana-operator-experience-squad
|
||||
github.com/opentracing/opentracing-go v1.2.0 // indirect
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible // @grafana/alerting-squad-backend
|
||||
|
||||
@@ -166,10 +166,6 @@ export interface GrafanaConfig {
|
||||
allowOrgCreate: boolean;
|
||||
disableLoginForm: boolean;
|
||||
defaultDatasource: string;
|
||||
alertingEnabled: boolean;
|
||||
alertingErrorOrTimeout: string;
|
||||
alertingNoDataOrNullValues: string;
|
||||
alertingMinInterval: number;
|
||||
authProxyEnabled: boolean;
|
||||
exploreEnabled: boolean;
|
||||
queryHistoryEnabled: boolean;
|
||||
|
||||
@@ -160,7 +160,6 @@ export interface FeatureToggles {
|
||||
regressionTransformation?: boolean;
|
||||
lokiQueryHints?: boolean;
|
||||
kubernetesFeatureToggles?: boolean;
|
||||
alertingPreviewUpgrade?: boolean;
|
||||
enablePluginsTracingByDefault?: boolean;
|
||||
cloudRBACRoles?: boolean;
|
||||
alertingQueryOptimization?: boolean;
|
||||
@@ -177,7 +176,6 @@ export interface FeatureToggles {
|
||||
expressionParser?: boolean;
|
||||
groupByVariable?: boolean;
|
||||
betterPageScrolling?: boolean;
|
||||
alertingUpgradeDryrunOnStart?: boolean;
|
||||
scopeFilters?: boolean;
|
||||
emailVerificationEnforcement?: boolean;
|
||||
ssoSettingsSAML?: boolean;
|
||||
|
||||
@@ -63,10 +63,6 @@ export class GrafanaBootConfig implements GrafanaConfig {
|
||||
feedbackLinksEnabled = true;
|
||||
disableLoginForm = false;
|
||||
defaultDatasource = ''; // UID
|
||||
alertingEnabled = false;
|
||||
alertingErrorOrTimeout = '';
|
||||
alertingNoDataOrNullValues = '';
|
||||
alertingMinInterval = 1;
|
||||
angularSupportEnabled = false;
|
||||
authProxyEnabled = false;
|
||||
exploreEnabled = false;
|
||||
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
|
||||
//
|
||||
// Generated by:
|
||||
// public/app/plugins/gen.go
|
||||
// Using jennies:
|
||||
// TSTypesJenny
|
||||
// PluginTsTypesJenny
|
||||
//
|
||||
// Run 'make gen-cue' from repository root to regenerate.
|
||||
|
||||
export const pluginVersion = "11.0.0-pre";
|
||||
|
||||
export interface Options {
|
||||
/**
|
||||
* Name of the alertmanager used as a source for alerts
|
||||
*/
|
||||
alertmanager: string;
|
||||
/**
|
||||
* Expand all alert groups by default
|
||||
*/
|
||||
expandAll: boolean;
|
||||
/**
|
||||
* Comma-separated list of values used to filter alert results
|
||||
*/
|
||||
labels: string;
|
||||
}
|
||||
@@ -39,11 +39,6 @@ if [ "$1" = configure ]; then
|
||||
cp "${GRAFANA_HOME}/conf/provisioning/datasources/sample.yaml" $PROVISIONING_CFG_DIR/datasources/sample.yaml
|
||||
fi
|
||||
|
||||
if [ ! -d $PROVISIONING_CFG_DIR/notifiers ]; then
|
||||
mkdir -p $PROVISIONING_CFG_DIR/notifiers
|
||||
cp "${GRAFANA_HOME}/conf/provisioning/notifiers/sample.yaml" $PROVISIONING_CFG_DIR/notifiers/sample.yaml
|
||||
fi
|
||||
|
||||
if [ ! -d $PROVISIONING_CFG_DIR/plugins ]; then
|
||||
mkdir -p $PROVISIONING_CFG_DIR/plugins
|
||||
cp "${GRAFANA_HOME}/conf/provisioning/plugins/sample.yaml" $PROVISIONING_CFG_DIR/plugins/sample.yaml
|
||||
|
||||
@@ -56,11 +56,6 @@ if [ $1 -eq 1 ] ; then
|
||||
cp /usr/share/grafana/conf/provisioning/datasources/sample.yaml $PROVISIONING_CFG_DIR/datasources/sample.yaml
|
||||
fi
|
||||
|
||||
if [ ! -d $PROVISIONING_CFG_DIR/notifiers ]; then
|
||||
mkdir -p $PROVISIONING_CFG_DIR/notifiers
|
||||
cp /usr/share/grafana/conf/provisioning/notifiers/sample.yaml $PROVISIONING_CFG_DIR/notifiers/sample.yaml
|
||||
fi
|
||||
|
||||
if [ ! -d $PROVISIONING_CFG_DIR/plugins ]; then
|
||||
mkdir -p $PROVISIONING_CFG_DIR/plugins
|
||||
cp /usr/share/grafana/conf/provisioning/plugins/sample.yaml $PROVISIONING_CFG_DIR/plugins/sample.yaml
|
||||
|
||||
@@ -78,29 +78,6 @@ func (hs *HTTPServer) AdminProvisioningReloadPlugins(c *contextmodel.ReqContext)
|
||||
return response.Success("Plugins config reloaded")
|
||||
}
|
||||
|
||||
// swagger:route POST /admin/provisioning/notifications/reload admin_provisioning adminProvisioningReloadNotifications
|
||||
//
|
||||
// Reload legacy alert notifier provisioning configurations.
|
||||
//
|
||||
// Reloads the provisioning config files for legacy alert notifiers again. It won’t return until the new provisioned entities are already stored in the database. In case of dashboards, it will stop polling for changes in dashboard files and then restart it with new configurations after returning.
|
||||
// If you are running Grafana Enterprise and have Fine-grained access control enabled, you need to have a permission with action `provisioning:reload` and scope `provisioners:notifications`.
|
||||
//
|
||||
// Security:
|
||||
// - basic:
|
||||
//
|
||||
// Responses:
|
||||
// 200: okResponse
|
||||
// 401: unauthorisedError
|
||||
// 403: forbiddenError
|
||||
// 500: internalServerError
|
||||
func (hs *HTTPServer) AdminProvisioningReloadNotifications(c *contextmodel.ReqContext) response.Response {
|
||||
err := hs.ProvisioningService.ProvisionNotifications(c.Req.Context())
|
||||
if err != nil {
|
||||
return response.Error(http.StatusInternalServerError, "", err)
|
||||
}
|
||||
return response.Success("Notifications config reloaded")
|
||||
}
|
||||
|
||||
func (hs *HTTPServer) AdminProvisioningReloadAlerting(c *contextmodel.ReqContext) response.Response {
|
||||
err := hs.ProvisioningService.ProvisionAlerting(c.Req.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -71,26 +71,6 @@ func TestAPI_AdminProvisioningReload_AccessControl(t *testing.T) {
|
||||
expectedCode: http.StatusForbidden,
|
||||
url: "/api/admin/provisioning/dashboards/reload",
|
||||
},
|
||||
{
|
||||
desc: "should work for notifications with specific scope",
|
||||
expectedCode: http.StatusOK,
|
||||
expectedBody: `{"message":"Notifications config reloaded"}`,
|
||||
permissions: []accesscontrol.Permission{
|
||||
{
|
||||
Action: ActionProvisioningReload,
|
||||
Scope: ScopeProvisionersNotifications,
|
||||
},
|
||||
},
|
||||
url: "/api/admin/provisioning/notifications/reload",
|
||||
checkCall: func(mock provisioning.ProvisioningServiceMock) {
|
||||
assert.Len(t, mock.Calls.ProvisionNotifications, 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "should fail for notifications with no permission",
|
||||
expectedCode: http.StatusForbidden,
|
||||
url: "/api/admin/provisioning/notifications/reload",
|
||||
},
|
||||
{
|
||||
desc: "should work for datasources with specific scope",
|
||||
expectedCode: http.StatusOK,
|
||||
|
||||
-1035
File diff suppressed because it is too large
Load Diff
@@ -55,7 +55,6 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
reqNotSignedIn := middleware.ReqNotSignedIn
|
||||
reqSignedInNoAnonymous := middleware.ReqSignedInNoAnonymous
|
||||
reqGrafanaAdmin := middleware.ReqGrafanaAdmin
|
||||
reqEditorRole := middleware.ReqEditorRole
|
||||
reqOrgAdmin := middleware.ReqOrgAdmin
|
||||
reqRoleForAppRoute := middleware.RoleAppPluginAuth(hs.AccessControl, hs.pluginStore, hs.Features, hs.log)
|
||||
reqSnapshotPublicModeOrSignedIn := middleware.SnapshotPublicModeOrSignedIn(hs.Cfg)
|
||||
@@ -509,41 +508,11 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
// DataSource w/ expressions
|
||||
apiRoute.Post("/ds/query", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), authorize(ac.EvalPermission(datasources.ActionQuery)), hs.getDSQueryEndpoint())
|
||||
|
||||
apiRoute.Group("/alerts", func(alertsRoute routing.RouteRegister) {
|
||||
alertsRoute.Post("/test", routing.Wrap(hs.AlertTest))
|
||||
alertsRoute.Post("/:alertId/pause", reqEditorRole, routing.Wrap(hs.PauseAlert(hs.Cfg.AlertingEnabled)))
|
||||
alertsRoute.Get("/:alertId", hs.ValidateOrgAlert, routing.Wrap(hs.GetAlert))
|
||||
alertsRoute.Get("/", routing.Wrap(hs.GetAlerts))
|
||||
alertsRoute.Get("/states-for-dashboard", routing.Wrap(hs.GetAlertStatesForDashboard))
|
||||
}, requestmeta.SetOwner(requestmeta.TeamAlerting))
|
||||
|
||||
// Unified Alerting
|
||||
apiRoute.Get("/alert-notifiers", reqSignedIn, requestmeta.SetOwner(requestmeta.TeamAlerting), routing.Wrap(
|
||||
hs.GetAlertNotifiers()),
|
||||
)
|
||||
|
||||
// Legacy
|
||||
apiRoute.Get("/alert-notifiers-legacy", reqEditorRole, requestmeta.SetOwner(requestmeta.TeamAlerting), routing.Wrap(
|
||||
hs.GetLegacyAlertNotifiers()),
|
||||
)
|
||||
|
||||
apiRoute.Group("/alert-notifications", func(alertNotifications routing.RouteRegister) {
|
||||
alertNotifications.Get("/", routing.Wrap(hs.GetAlertNotifications))
|
||||
alertNotifications.Post("/test", routing.Wrap(hs.NotificationTest))
|
||||
alertNotifications.Post("/", routing.Wrap(hs.CreateAlertNotification))
|
||||
alertNotifications.Put("/:notificationId", routing.Wrap(hs.UpdateAlertNotification))
|
||||
alertNotifications.Get("/:notificationId", routing.Wrap(hs.GetAlertNotificationByID))
|
||||
alertNotifications.Delete("/:notificationId", routing.Wrap(hs.DeleteAlertNotification))
|
||||
alertNotifications.Get("/uid/:uid", routing.Wrap(hs.GetAlertNotificationByUID))
|
||||
alertNotifications.Put("/uid/:uid", routing.Wrap(hs.UpdateAlertNotificationByUID))
|
||||
alertNotifications.Delete("/uid/:uid", routing.Wrap(hs.DeleteAlertNotificationByUID))
|
||||
}, reqEditorRole, requestmeta.SetOwner(requestmeta.TeamAlerting))
|
||||
|
||||
// alert notifications without requirement of user to be org editor
|
||||
apiRoute.Group("/alert-notifications", func(orgRoute routing.RouteRegister) {
|
||||
orgRoute.Get("/lookup", routing.Wrap(hs.GetAlertNotificationLookup))
|
||||
}, requestmeta.SetOwner(requestmeta.TeamAlerting))
|
||||
|
||||
apiRoute.Get("/annotations", authorize(ac.EvalPermission(ac.ActionAnnotationsRead)), routing.Wrap(hs.GetAnnotations))
|
||||
apiRoute.Post("/annotations/mass-delete", authorize(ac.EvalPermission(ac.ActionAnnotationsDelete)), routing.Wrap(hs.MassDeleteAnnotations))
|
||||
|
||||
@@ -583,7 +552,6 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
adminRoute.Get("/settings", authorize(ac.EvalPermission(ac.ActionSettingsRead)), routing.Wrap(hs.AdminGetSettings))
|
||||
adminRoute.Get("/settings-verbose", authorize(ac.EvalPermission(ac.ActionSettingsRead)), routing.Wrap(hs.AdminGetVerboseSettings))
|
||||
adminRoute.Get("/stats", authorize(ac.EvalPermission(ac.ActionServerStatsRead)), routing.Wrap(hs.AdminGetStats))
|
||||
adminRoute.Post("/pause-all-alerts", reqGrafanaAdmin, routing.Wrap(hs.PauseAllAlerts(hs.Cfg.AlertingEnabled)))
|
||||
|
||||
adminRoute.Post("/encryption/rotate-data-keys", reqGrafanaAdmin, routing.Wrap(hs.AdminRotateDataEncryptionKeys))
|
||||
adminRoute.Post("/encryption/reencrypt-data-keys", reqGrafanaAdmin, routing.Wrap(hs.AdminReEncryptEncryptionKeys))
|
||||
@@ -596,7 +564,6 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
adminRoute.Post("/provisioning/dashboards/reload", authorize(ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersDashboards)), routing.Wrap(hs.AdminProvisioningReloadDashboards))
|
||||
adminRoute.Post("/provisioning/plugins/reload", authorize(ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersPlugins)), routing.Wrap(hs.AdminProvisioningReloadPlugins))
|
||||
adminRoute.Post("/provisioning/datasources/reload", authorize(ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersDatasources)), routing.Wrap(hs.AdminProvisioningReloadDatasources))
|
||||
adminRoute.Post("/provisioning/notifications/reload", authorize(ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersNotifications)), routing.Wrap(hs.AdminProvisioningReloadNotifications))
|
||||
adminRoute.Post("/provisioning/alerting/reload", authorize(ac.EvalPermission(ActionProvisioningReload, ScopeProvisionersAlertRules)), routing.Wrap(hs.AdminProvisioningReloadAlerting))
|
||||
}, reqSignedIn)
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
@@ -30,11 +29,6 @@ func ToDashboardErrorResponse(ctx context.Context, pluginStore pluginstore.Store
|
||||
return response.Error(http.StatusBadRequest, err.Error(), nil)
|
||||
}
|
||||
|
||||
var validationErr alerting.ValidationError
|
||||
if ok := errors.As(err, &validationErr); ok {
|
||||
return response.Error(http.StatusUnprocessableEntity, validationErr.Error(), err)
|
||||
}
|
||||
|
||||
var pluginErr dashboards.UpdatePluginDashboardError
|
||||
if ok := errors.As(err, &pluginErr); ok {
|
||||
message := fmt.Sprintf("The dashboard belongs to plugin %s.", pluginErr.PluginId)
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/metrics"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/auth/identity"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
@@ -441,7 +440,7 @@ func (hs *HTTPServer) postDashboard(c *contextmodel.ReqContext, cmd dashboards.S
|
||||
Overwrite: cmd.Overwrite,
|
||||
}
|
||||
|
||||
dashboard, err := hs.DashboardService.SaveDashboard(alerting.WithUAEnabled(ctx, hs.Cfg.UnifiedAlerting.IsEnabled()), dashItem, allowUiUpdate)
|
||||
dashboard, err := hs.DashboardService.SaveDashboard(ctx, dashItem, allowUiUpdate)
|
||||
|
||||
if hs.Live != nil {
|
||||
// Tell everyone listening that the dashboard changed
|
||||
|
||||
@@ -28,7 +28,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
|
||||
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/annotations/annotationstest"
|
||||
"github.com/grafana/grafana/pkg/services/auth/identity"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
@@ -479,7 +478,6 @@ func TestDashboardAPIEndpoint(t *testing.T) {
|
||||
{SaveError: dashboards.ErrDashboardVersionMismatch, ExpectedStatusCode: http.StatusPreconditionFailed},
|
||||
{SaveError: dashboards.ErrDashboardTitleEmpty, ExpectedStatusCode: http.StatusBadRequest},
|
||||
{SaveError: dashboards.ErrDashboardFolderCannotHaveParent, ExpectedStatusCode: http.StatusBadRequest},
|
||||
{SaveError: alerting.ValidationError{Reason: "Mu"}, ExpectedStatusCode: http.StatusUnprocessableEntity},
|
||||
{SaveError: dashboards.ErrDashboardTypeMismatch, ExpectedStatusCode: http.StatusBadRequest},
|
||||
{SaveError: dashboards.ErrDashboardFolderWithSameNameAsDashboard, ExpectedStatusCode: http.StatusBadRequest},
|
||||
{SaveError: dashboards.ErrDashboardWithSameNameAsFolder, ExpectedStatusCode: http.StatusBadRequest},
|
||||
@@ -834,14 +832,14 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr
|
||||
|
||||
if dashboardService == nil {
|
||||
dashboardService, err = service.ProvideDashboardServiceImpl(
|
||||
cfg, dashboardStore, folderStore, nil, features, folderPermissions, dashboardPermissions,
|
||||
cfg, dashboardStore, folderStore, features, folderPermissions, dashboardPermissions,
|
||||
ac, folderSvc, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
dashboardProvisioningService, err := service.ProvideDashboardServiceImpl(
|
||||
cfg, dashboardStore, folderStore, nil, features, folderPermissions, dashboardPermissions,
|
||||
cfg, dashboardStore, folderStore, features, folderPermissions, dashboardPermissions,
|
||||
ac, folderSvc, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
package dtos
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/null"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
)
|
||||
|
||||
func formatShort(interval time.Duration) string {
|
||||
var result string
|
||||
|
||||
hours := interval / time.Hour
|
||||
if hours > 0 {
|
||||
result += fmt.Sprintf("%dh", hours)
|
||||
}
|
||||
|
||||
remaining := interval - (hours * time.Hour)
|
||||
mins := remaining / time.Minute
|
||||
if mins > 0 {
|
||||
result += fmt.Sprintf("%dm", mins)
|
||||
}
|
||||
|
||||
remaining -= (mins * time.Minute)
|
||||
seconds := remaining / time.Second
|
||||
if seconds > 0 {
|
||||
result += fmt.Sprintf("%ds", seconds)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func NewAlertNotification(notification *models.AlertNotification) *AlertNotification {
|
||||
dto := &AlertNotification{
|
||||
Id: notification.ID,
|
||||
Uid: notification.UID,
|
||||
Name: notification.Name,
|
||||
Type: notification.Type,
|
||||
IsDefault: notification.IsDefault,
|
||||
Created: notification.Created,
|
||||
Updated: notification.Updated,
|
||||
Frequency: formatShort(notification.Frequency),
|
||||
SendReminder: notification.SendReminder,
|
||||
DisableResolveMessage: notification.DisableResolveMessage,
|
||||
Settings: notification.Settings,
|
||||
SecureFields: map[string]bool{},
|
||||
}
|
||||
|
||||
if notification.SecureSettings != nil {
|
||||
for k := range notification.SecureSettings {
|
||||
dto.SecureFields[k] = true
|
||||
}
|
||||
}
|
||||
|
||||
return dto
|
||||
}
|
||||
|
||||
type AlertNotification struct {
|
||||
Id int64 `json:"id"`
|
||||
Uid string `json:"uid"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
SendReminder bool `json:"sendReminder"`
|
||||
DisableResolveMessage bool `json:"disableResolveMessage"`
|
||||
Frequency string `json:"frequency"`
|
||||
Created time.Time `json:"created"`
|
||||
Updated time.Time `json:"updated"`
|
||||
Settings *simplejson.Json `json:"settings"`
|
||||
SecureFields map[string]bool `json:"secureFields"`
|
||||
}
|
||||
|
||||
func NewAlertNotificationLookup(notification *models.AlertNotification) *AlertNotificationLookup {
|
||||
return &AlertNotificationLookup{
|
||||
Id: notification.ID,
|
||||
Uid: notification.UID,
|
||||
Name: notification.Name,
|
||||
Type: notification.Type,
|
||||
IsDefault: notification.IsDefault,
|
||||
}
|
||||
}
|
||||
|
||||
type AlertNotificationLookup struct {
|
||||
Id int64 `json:"id"`
|
||||
Uid string `json:"uid"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
}
|
||||
|
||||
type AlertTestCommand struct {
|
||||
Dashboard *simplejson.Json `json:"dashboard" binding:"Required"`
|
||||
PanelId int64 `json:"panelId" binding:"Required"`
|
||||
}
|
||||
|
||||
type AlertTestResult struct {
|
||||
Firing bool `json:"firing"`
|
||||
State models.AlertStateType `json:"state"`
|
||||
ConditionEvals string `json:"conditionEvals"`
|
||||
TimeMs string `json:"timeMs"`
|
||||
Error string `json:"error,omitempty"`
|
||||
EvalMatches []*EvalMatch `json:"matches,omitempty"`
|
||||
Logs []*AlertTestResultLog `json:"logs,omitempty"`
|
||||
}
|
||||
|
||||
type AlertTestResultLog struct {
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
type EvalMatch struct {
|
||||
Tags map[string]string `json:"tags,omitempty"`
|
||||
Metric string `json:"metric"`
|
||||
Value null.Float `json:"value"`
|
||||
}
|
||||
|
||||
type NotificationTestCommand struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
SendReminder bool `json:"sendReminder"`
|
||||
DisableResolveMessage bool `json:"disableResolveMessage"`
|
||||
Frequency string `json:"frequency"`
|
||||
Settings *simplejson.Json `json:"settings"`
|
||||
SecureSettings map[string]string `json:"secureSettings"`
|
||||
}
|
||||
|
||||
type PauseAlertCommand struct {
|
||||
AlertId int64 `json:"alertId"`
|
||||
Paused bool `json:"paused"`
|
||||
}
|
||||
|
||||
type PauseAllAlertsCommand struct {
|
||||
Paused bool `json:"paused"`
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package dtos
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFormatShort(t *testing.T) {
|
||||
tcs := []struct {
|
||||
interval time.Duration
|
||||
expected string
|
||||
}{
|
||||
{interval: time.Hour, expected: "1h"},
|
||||
{interval: time.Hour + time.Minute, expected: "1h1m"},
|
||||
{interval: (time.Hour * 10) + time.Minute, expected: "10h1m"},
|
||||
{interval: (time.Hour * 10) + (time.Minute * 10) + time.Second, expected: "10h10m1s"},
|
||||
{interval: time.Minute * 10, expected: "10m"},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
got := formatShort(tc.interval)
|
||||
if got != tc.expected {
|
||||
t.Errorf("expected %s got %s interval: %v", tc.expected, got, tc.interval)
|
||||
}
|
||||
|
||||
parsed, err := time.ParseDuration(tc.expected)
|
||||
if err != nil {
|
||||
t.Fatalf("could not parse expected duration")
|
||||
}
|
||||
|
||||
if parsed != tc.interval {
|
||||
t.Errorf("expects the parsed duration to equal the interval. Got %v expected: %v", parsed, tc.interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,24 +141,20 @@ type FrontendSettingsSqlConnectionLimitsDTO struct {
|
||||
}
|
||||
|
||||
type FrontendSettingsDTO struct {
|
||||
DefaultDatasource string `json:"defaultDatasource"`
|
||||
Datasources map[string]plugins.DataSourceDTO `json:"datasources"`
|
||||
MinRefreshInterval string `json:"minRefreshInterval"`
|
||||
Panels map[string]plugins.PanelDTO `json:"panels"`
|
||||
Apps map[string]*plugins.AppDTO `json:"apps"`
|
||||
AppUrl string `json:"appUrl"`
|
||||
AppSubUrl string `json:"appSubUrl"`
|
||||
AllowOrgCreate bool `json:"allowOrgCreate"`
|
||||
AuthProxyEnabled bool `json:"authProxyEnabled"`
|
||||
LdapEnabled bool `json:"ldapEnabled"`
|
||||
JwtHeaderName string `json:"jwtHeaderName"`
|
||||
JwtUrlLogin bool `json:"jwtUrlLogin"`
|
||||
AlertingEnabled bool `json:"alertingEnabled"`
|
||||
AlertingErrorOrTimeout string `json:"alertingErrorOrTimeout"`
|
||||
AlertingNoDataOrNullValues string `json:"alertingNoDataOrNullValues"`
|
||||
AlertingMinInterval int64 `json:"alertingMinInterval"`
|
||||
LiveEnabled bool `json:"liveEnabled"`
|
||||
AutoAssignOrg bool `json:"autoAssignOrg"`
|
||||
DefaultDatasource string `json:"defaultDatasource"`
|
||||
Datasources map[string]plugins.DataSourceDTO `json:"datasources"`
|
||||
MinRefreshInterval string `json:"minRefreshInterval"`
|
||||
Panels map[string]plugins.PanelDTO `json:"panels"`
|
||||
Apps map[string]*plugins.AppDTO `json:"apps"`
|
||||
AppUrl string `json:"appUrl"`
|
||||
AppSubUrl string `json:"appSubUrl"`
|
||||
AllowOrgCreate bool `json:"allowOrgCreate"`
|
||||
AuthProxyEnabled bool `json:"authProxyEnabled"`
|
||||
LdapEnabled bool `json:"ldapEnabled"`
|
||||
JwtHeaderName string `json:"jwtHeaderName"`
|
||||
JwtUrlLogin bool `json:"jwtUrlLogin"`
|
||||
LiveEnabled bool `json:"liveEnabled"`
|
||||
AutoAssignOrg bool `json:"autoAssignOrg"`
|
||||
|
||||
VerifyEmailEnabled bool `json:"verifyEmailEnabled"`
|
||||
SigV4AuthEnabled bool `json:"sigV4AuthEnabled"`
|
||||
|
||||
@@ -465,7 +465,7 @@ func setupServer(b testing.TB, sc benchScenario, features featuremgmt.FeatureTog
|
||||
require.NoError(b, err)
|
||||
|
||||
dashboardSvc, err := dashboardservice.ProvideDashboardServiceImpl(
|
||||
sc.cfg, dashStore, folderStore, nil,
|
||||
sc.cfg, dashStore, folderStore,
|
||||
features, folderPermissions, dashboardPermissions, ac,
|
||||
folderServiceWithFlagOn, nil,
|
||||
)
|
||||
|
||||
@@ -178,9 +178,6 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
|
||||
LdapEnabled: hs.Cfg.LDAPAuthEnabled,
|
||||
JwtHeaderName: hs.Cfg.JWTAuth.HeaderName,
|
||||
JwtUrlLogin: hs.Cfg.JWTAuth.URLLogin,
|
||||
AlertingErrorOrTimeout: hs.Cfg.AlertingErrorOrTimeout,
|
||||
AlertingNoDataOrNullValues: hs.Cfg.AlertingNoDataOrNullValues,
|
||||
AlertingMinInterval: hs.Cfg.AlertingMinInterval,
|
||||
LiveEnabled: hs.Cfg.LiveMaxConnections != 0,
|
||||
AutoAssignOrg: hs.Cfg.AutoAssignOrg,
|
||||
VerifyEmailEnabled: hs.Cfg.VerifyEmailEnabled,
|
||||
@@ -312,10 +309,6 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
|
||||
frontendSettings.UnifiedAlertingEnabled = *hs.Cfg.UnifiedAlerting.Enabled
|
||||
}
|
||||
|
||||
if hs.Cfg.AlertingEnabled != nil {
|
||||
frontendSettings.AlertingEnabled = *(hs.Cfg.AlertingEnabled)
|
||||
}
|
||||
|
||||
// It returns false if the provider is not enabled or the skip org role sync is false.
|
||||
parseSkipOrgRoleSyncEnabled := func(info *social.OAuthInfo) bool {
|
||||
if info == nil {
|
||||
|
||||
@@ -47,7 +47,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/annotations"
|
||||
"github.com/grafana/grafana/pkg/services/apikey"
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
@@ -158,7 +157,6 @@ type HTTPServer struct {
|
||||
ContextHandler *contexthandler.ContextHandler
|
||||
LoggerMiddleware loggermw.Logger
|
||||
SQLStore db.DB
|
||||
AlertEngine *alerting.AlertEngine
|
||||
AlertNG *ngalert.AlertNG
|
||||
LibraryPanelService librarypanels.Service
|
||||
LibraryElementService libraryelements.Service
|
||||
@@ -184,7 +182,6 @@ type HTTPServer struct {
|
||||
dashboardProvisioningService dashboards.DashboardProvisioningService
|
||||
folderService folder.Service
|
||||
dsGuardian guardian.DatasourceGuardianProvider
|
||||
AlertNotificationService *alerting.AlertNotificationService
|
||||
dashboardsnapshotsService dashboardsnapshots.Service
|
||||
PluginSettings pluginSettings.Service
|
||||
AvatarCacheServer *avatar.AvatarCacheServer
|
||||
@@ -226,7 +223,7 @@ type ServerOptions struct {
|
||||
|
||||
func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routing.RouteRegister, bus bus.Bus,
|
||||
renderService rendering.Service, licensing licensing.Licensing, hooksService *hooks.HooksService,
|
||||
cacheService *localcache.CacheService, sqlStore *sqlstore.SQLStore, alertEngine *alerting.AlertEngine,
|
||||
cacheService *localcache.CacheService, sqlStore *sqlstore.SQLStore,
|
||||
pluginRequestValidator validations.PluginRequestValidator, pluginStaticRouteResolver plugins.StaticRouteResolver,
|
||||
pluginDashboardService plugindashboards.Service, pluginStore pluginstore.Store, pluginClient plugins.Client,
|
||||
pluginErrorResolver plugins.ErrorResolver, pluginInstaller plugins.Installer, settingsProvider setting.Provider,
|
||||
@@ -245,7 +242,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
|
||||
authInfoService login.AuthInfoService, storageService store.StorageService,
|
||||
notificationService notifications.Service, dashboardService dashboards.DashboardService,
|
||||
dashboardProvisioningService dashboards.DashboardProvisioningService, folderService folder.Service,
|
||||
dsGuardian guardian.DatasourceGuardianProvider, alertNotificationService *alerting.AlertNotificationService,
|
||||
dsGuardian guardian.DatasourceGuardianProvider,
|
||||
dashboardsnapshotsService dashboardsnapshots.Service, pluginSettings pluginSettings.Service,
|
||||
avatarCacheServer *avatar.AvatarCacheServer, preferenceService pref.Service,
|
||||
folderPermissionsService accesscontrol.FolderPermissionsService,
|
||||
@@ -274,7 +271,6 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
|
||||
HooksService: hooksService,
|
||||
CacheService: cacheService,
|
||||
SQLStore: sqlStore,
|
||||
AlertEngine: alertEngine,
|
||||
PluginRequestValidator: pluginRequestValidator,
|
||||
pluginInstaller: pluginInstaller,
|
||||
pluginClient: pluginClient,
|
||||
@@ -330,7 +326,6 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
|
||||
dashboardProvisioningService: dashboardProvisioningService,
|
||||
folderService: folderService,
|
||||
dsGuardian: dsGuardian,
|
||||
AlertNotificationService: alertNotificationService,
|
||||
dashboardsnapshotsService: dashboardsnapshotsService,
|
||||
PluginSettings: pluginSettings,
|
||||
AvatarCacheServer: avatarCacheServer,
|
||||
|
||||
@@ -350,6 +350,9 @@ func (hs *HTTPServer) applyUserInvite(ctx context.Context, usr *user.User, invit
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// swagger:response SMTPNotEnabledError
|
||||
type SMTPNotEnabledError PreconditionFailedError
|
||||
|
||||
// swagger:parameters addOrgInvite
|
||||
type AddInviteParams struct {
|
||||
// in:body
|
||||
|
||||
@@ -21,8 +21,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/metrics"
|
||||
"github.com/grafana/grafana/pkg/infra/process"
|
||||
"github.com/grafana/grafana/pkg/server"
|
||||
_ "github.com/grafana/grafana/pkg/services/alerting/conditions"
|
||||
_ "github.com/grafana/grafana/pkg/services/alerting/notifiers"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ func corePlugins(rt *thema.Runtime) []pfs.ParsedPlugin {
|
||||
parsePluginOrPanic("public/app/plugins/datasource/prometheus", "prometheus", rt),
|
||||
parsePluginOrPanic("public/app/plugins/datasource/tempo", "tempo", rt),
|
||||
parsePluginOrPanic("public/app/plugins/datasource/zipkin", "zipkin", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/alertGroups", "alertGroups", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/alertlist", "alertlist", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/annolist", "annolist", rt),
|
||||
parsePluginOrPanic("public/app/plugins/panel/barchart", "barchart", rt),
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/usagestats/statscollector"
|
||||
"github.com/grafana/grafana/pkg/registry"
|
||||
apiregistry "github.com/grafana/grafana/pkg/registry/apis"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/anonymous/anonimpl"
|
||||
grafanaapiserver "github.com/grafana/grafana/pkg/services/apiserver"
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
@@ -51,7 +50,7 @@ func ProvideBackgroundServiceRegistry(
|
||||
httpServer *api.HTTPServer, ng *ngalert.AlertNG, cleanup *cleanup.CleanUpService, live *live.GrafanaLive,
|
||||
pushGateway *pushhttp.Gateway, notifications *notifications.NotificationService, pluginStore *pluginStore.Service,
|
||||
rendering *rendering.RenderingService, tokenService auth.UserTokenBackgroundService, tracing *tracing.TracingService,
|
||||
provisioning *provisioning.ProvisioningServiceImpl, alerting *alerting.AlertEngine, usageStats *uss.UsageStats,
|
||||
provisioning *provisioning.ProvisioningServiceImpl, usageStats *uss.UsageStats,
|
||||
statsCollector *statscollector.Service, grafanaUpdateChecker *updatechecker.GrafanaService,
|
||||
pluginsUpdateChecker *updatechecker.PluginsService, metrics *metrics.InternalMetricsService,
|
||||
secretsService *secretsManager.SecretsService, remoteCache *remotecache.RemoteCache, StorageService store.StorageService, searchService searchV2.SearchService, entityEventsService store.EntityEventsService,
|
||||
@@ -64,7 +63,7 @@ func ProvideBackgroundServiceRegistry(
|
||||
ssoSettings *ssosettingsimpl.Service,
|
||||
pluginExternal *pluginexternal.Service,
|
||||
// Need to make sure these are initialized, is there a better place to put them?
|
||||
_ dashboardsnapshots.Service, _ *alerting.AlertNotificationService,
|
||||
_ dashboardsnapshots.Service,
|
||||
_ serviceaccounts.Service, _ *guardian.Provider,
|
||||
_ *plugindashboardsservice.DashboardUpdater, _ *sanitizer.Provider,
|
||||
_ *grpcserver.HealthService, _ entity.EntityStoreServer, _ *grpcserver.ReflectionService, _ *ldapapi.Service,
|
||||
@@ -81,7 +80,6 @@ func ProvideBackgroundServiceRegistry(
|
||||
rendering,
|
||||
tokenService,
|
||||
provisioning,
|
||||
alerting,
|
||||
grafanaUpdateChecker,
|
||||
pluginsUpdateChecker,
|
||||
metrics,
|
||||
|
||||
@@ -38,7 +38,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/annotations"
|
||||
"github.com/grafana/grafana/pkg/services/annotations/annotationsimpl"
|
||||
"github.com/grafana/grafana/pkg/services/anonymous/anonimpl/anonstore"
|
||||
@@ -90,8 +89,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/ngalert"
|
||||
ngimage "github.com/grafana/grafana/pkg/services/ngalert/image"
|
||||
ngmetrics "github.com/grafana/grafana/pkg/services/ngalert/metrics"
|
||||
ngmigration "github.com/grafana/grafana/pkg/services/ngalert/migration"
|
||||
migrationStore "github.com/grafana/grafana/pkg/services/ngalert/migration/store"
|
||||
ngstore "github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/grafana/grafana/pkg/services/oauthtoken"
|
||||
@@ -180,9 +177,6 @@ var wireBasicSet = wire.NewSet(
|
||||
wire.Bind(new(legacydata.RequestHandler), new(*legacydataservice.Service)),
|
||||
annotationsimpl.ProvideService,
|
||||
wire.Bind(new(annotations.Repository), new(*annotationsimpl.RepositoryImpl)),
|
||||
alerting.ProvideAlertStore,
|
||||
alerting.ProvideAlertEngine,
|
||||
wire.Bind(new(alerting.UsageStatsQuerier), new(*alerting.AlertEngine)),
|
||||
New,
|
||||
api.ProvideHTTPServer,
|
||||
query.ProvideService,
|
||||
@@ -246,8 +240,6 @@ var wireBasicSet = wire.NewSet(
|
||||
wire.Bind(new(jwt.JWTService), new(*jwt.AuthService)),
|
||||
ngstore.ProvideDBStore,
|
||||
ngimage.ProvideDeleteExpiredService,
|
||||
ngmigration.ProvideService,
|
||||
migrationStore.ProvideMigrationStore,
|
||||
ngalert.ProvideService,
|
||||
librarypanels.ProvideService,
|
||||
wire.Bind(new(librarypanels.Service), new(*librarypanels.LibraryPanelService)),
|
||||
@@ -286,7 +278,6 @@ var wireBasicSet = wire.NewSet(
|
||||
datasourceservice.ProvideService,
|
||||
wire.Bind(new(datasources.DataSourceService), new(*datasourceservice.Service)),
|
||||
datasourceservice.ProvideLegacyDataSourceLookup,
|
||||
alerting.ProvideService,
|
||||
serviceaccountsretriever.ProvideService,
|
||||
wire.Bind(new(serviceaccountsretriever.ServiceAccountRetriever), new(*serviceaccountsretriever.Service)),
|
||||
ossaccesscontrol.ProvideServiceAccountPermissions,
|
||||
@@ -310,8 +301,6 @@ var wireBasicSet = wire.NewSet(
|
||||
plugindashboardsservice.ProvideService,
|
||||
wire.Bind(new(plugindashboards.Service), new(*plugindashboardsservice.Service)),
|
||||
plugindashboardsservice.ProvideDashboardUpdater,
|
||||
alerting.ProvideDashAlertExtractorService,
|
||||
wire.Bind(new(alerting.DashAlertExtractor), new(*alerting.DashAlertExtractorService)),
|
||||
guardian.ProvideService,
|
||||
sanitizer.ProvideService,
|
||||
secretsStore.ProvideService,
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
package alerting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
)
|
||||
|
||||
// DatasourceAlertUsage is a hash where the key represents the
|
||||
// Datasource type and the value represents how many alerts
|
||||
// that use the datasources.
|
||||
type DatasourceAlertUsage map[string]int
|
||||
|
||||
// UsageStats contains stats about alert rules configured in
|
||||
// Grafana.
|
||||
type UsageStats struct {
|
||||
DatasourceUsage DatasourceAlertUsage
|
||||
}
|
||||
|
||||
// UsageStatsQuerier returns usage stats about alert rules
|
||||
// configured in Grafana.
|
||||
type UsageStatsQuerier interface {
|
||||
QueryUsageStats(context.Context) (*UsageStats, error)
|
||||
}
|
||||
|
||||
// QueryUsageStats returns usage stats about alert rules
|
||||
// configured in Grafana.
|
||||
func (e *AlertEngine) QueryUsageStats(ctx context.Context) (*UsageStats, error) {
|
||||
cmd := &models.GetAllAlertsQuery{}
|
||||
res, err := e.AlertStore.GetAllAlertQueryHandler(ctx, cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dsUsage, err := e.mapRulesToUsageStats(ctx, res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &UsageStats{
|
||||
DatasourceUsage: dsUsage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *AlertEngine) mapRulesToUsageStats(ctx context.Context, rules []*models.Alert) (DatasourceAlertUsage, error) {
|
||||
// map of datasourceId type and frequency
|
||||
typeCount := map[int64]int{}
|
||||
for _, a := range rules {
|
||||
dss, err := e.parseAlertRuleModel(a.Settings)
|
||||
if err != nil {
|
||||
e.log.Debug("Could not parse settings for alert rule", "id", a.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, d := range dss {
|
||||
// aggregated datasource usage based on datasource id
|
||||
typeCount[d]++
|
||||
}
|
||||
}
|
||||
|
||||
// map of datsource types and frequency
|
||||
result := map[string]int{}
|
||||
for k, v := range typeCount {
|
||||
query := &datasources.GetDataSourceQuery{ID: k}
|
||||
dataSource, err := e.datasourceService.GetDataSource(ctx, query)
|
||||
if err != nil {
|
||||
return map[string]int{}, nil
|
||||
}
|
||||
|
||||
// aggregate datasource usages based on datasource type
|
||||
result[dataSource.Type] += v
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *AlertEngine) parseAlertRuleModel(settings json.Marshaler) ([]int64, error) {
|
||||
datasourceIDs := []int64{}
|
||||
model := alertJSONModel{}
|
||||
|
||||
if settings == nil {
|
||||
return datasourceIDs, nil
|
||||
}
|
||||
|
||||
bytes, err := settings.MarshalJSON()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(bytes, &model)
|
||||
if err != nil {
|
||||
return datasourceIDs, err
|
||||
}
|
||||
|
||||
for _, condition := range model.Conditions {
|
||||
datasourceIDs = append(datasourceIDs, condition.Query.DatasourceID)
|
||||
}
|
||||
|
||||
return datasourceIDs, nil
|
||||
}
|
||||
|
||||
type alertCondition struct {
|
||||
Query *conditionQuery `json:"query"`
|
||||
}
|
||||
|
||||
type conditionQuery struct {
|
||||
DatasourceID int64 `json:"datasourceId"`
|
||||
}
|
||||
|
||||
type alertJSONModel struct {
|
||||
Conditions []*alertCondition `json:"conditions"`
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package alerting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
fd "github.com/grafana/grafana/pkg/services/datasources/fakes"
|
||||
)
|
||||
|
||||
func TestAlertingUsageStats(t *testing.T) {
|
||||
store := &AlertStoreMock{}
|
||||
dsMock := &fd.FakeDataSourceService{
|
||||
DataSources: []*datasources.DataSource{
|
||||
{ID: 1, Type: datasources.DS_INFLUXDB},
|
||||
{ID: 2, Type: datasources.DS_GRAPHITE},
|
||||
{ID: 3, Type: datasources.DS_PROMETHEUS},
|
||||
{ID: 4, Type: datasources.DS_PROMETHEUS},
|
||||
},
|
||||
}
|
||||
ae := &AlertEngine{
|
||||
AlertStore: store,
|
||||
datasourceService: dsMock,
|
||||
}
|
||||
|
||||
store.getAllAlerts = func(ctx context.Context, query *models.GetAllAlertsQuery) (res []*models.Alert, err error) {
|
||||
var createFake = func(file string) *simplejson.Json {
|
||||
// Ignore gosec warning G304 since it's a test
|
||||
// nolint:gosec
|
||||
content, err := os.ReadFile(file)
|
||||
require.NoError(t, err, "expected to be able to read file")
|
||||
|
||||
j, err := simplejson.NewJson(content)
|
||||
require.NoError(t, err)
|
||||
return j
|
||||
}
|
||||
|
||||
return []*models.Alert{
|
||||
{ID: 1, Settings: createFake("testdata/settings/one_condition.json")},
|
||||
{ID: 2, Settings: createFake("testdata/settings/two_conditions.json")},
|
||||
{ID: 2, Settings: createFake("testdata/settings/three_conditions.json")},
|
||||
{ID: 3, Settings: createFake("testdata/settings/empty.json")},
|
||||
}, nil
|
||||
}
|
||||
|
||||
result, err := ae.QueryUsageStats(context.Background())
|
||||
require.NoError(t, err, "getAlertingUsage should not return error")
|
||||
|
||||
expected := map[string]int{
|
||||
"prometheus": 4,
|
||||
"graphite": 2,
|
||||
}
|
||||
|
||||
for k := range expected {
|
||||
if expected[k] != result.DatasourceUsage[k] {
|
||||
t.Errorf("result mismatch for %s. got %v expected %v", k, result.DatasourceUsage[k], expected[k])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsingAlertRuleSettings(t *testing.T) {
|
||||
tcs := []struct {
|
||||
name string
|
||||
file string
|
||||
expected []int64
|
||||
shouldErr require.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "can parse single condition",
|
||||
file: "testdata/settings/one_condition.json",
|
||||
expected: []int64{3},
|
||||
shouldErr: require.NoError,
|
||||
},
|
||||
{
|
||||
name: "can parse multiple conditions",
|
||||
file: "testdata/settings/two_conditions.json",
|
||||
expected: []int64{3, 2},
|
||||
shouldErr: require.NoError,
|
||||
},
|
||||
{
|
||||
name: "can parse empty json",
|
||||
file: "testdata/settings/empty.json",
|
||||
expected: []int64{},
|
||||
shouldErr: require.NoError,
|
||||
},
|
||||
{
|
||||
name: "can handle nil content",
|
||||
expected: []int64{},
|
||||
shouldErr: require.NoError,
|
||||
},
|
||||
}
|
||||
|
||||
ae := &AlertEngine{}
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var settings json.Marshaler
|
||||
if tc.file != "" {
|
||||
content, err := os.ReadFile(tc.file)
|
||||
require.NoError(t, err, "expected to be able to read file")
|
||||
|
||||
settings, err = simplejson.NewJson(content)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
result, err := ae.parseAlertRuleModel(settings)
|
||||
|
||||
tc.shouldErr(t, err)
|
||||
diff := cmp.Diff(tc.expected, result)
|
||||
if diff != "" {
|
||||
t.Errorf("result mismatch (-want +got) %s\n", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package conditions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/null"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
)
|
||||
|
||||
var (
|
||||
defaultTypes = []string{"gt", "lt"}
|
||||
rangedTypes = []string{"within_range", "outside_range"}
|
||||
)
|
||||
|
||||
// AlertEvaluator evaluates the reduced value of a timeseries.
|
||||
// Returning true if a timeseries is violating the condition
|
||||
// ex: ThresholdEvaluator, NoValueEvaluator, RangeEvaluator
|
||||
type AlertEvaluator interface {
|
||||
Eval(reducedValue null.Float) bool
|
||||
}
|
||||
|
||||
type noValueEvaluator struct{}
|
||||
|
||||
func (e *noValueEvaluator) Eval(reducedValue null.Float) bool {
|
||||
return !reducedValue.Valid
|
||||
}
|
||||
|
||||
type thresholdEvaluator struct {
|
||||
Type string
|
||||
Threshold float64
|
||||
}
|
||||
|
||||
func newThresholdEvaluator(typ string, model *simplejson.Json) (*thresholdEvaluator, error) {
|
||||
params := model.Get("params").MustArray()
|
||||
if len(params) == 0 || params[0] == nil {
|
||||
return nil, fmt.Errorf("evaluator '%v' is missing the threshold parameter", HumanThresholdType(typ))
|
||||
}
|
||||
|
||||
firstParam, ok := params[0].(json.Number)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("evaluator has invalid parameter")
|
||||
}
|
||||
|
||||
defaultEval := &thresholdEvaluator{Type: typ}
|
||||
defaultEval.Threshold, _ = firstParam.Float64()
|
||||
return defaultEval, nil
|
||||
}
|
||||
|
||||
func (e *thresholdEvaluator) Eval(reducedValue null.Float) bool {
|
||||
if !reducedValue.Valid {
|
||||
return false
|
||||
}
|
||||
|
||||
switch e.Type {
|
||||
case "gt":
|
||||
return reducedValue.Float64 > e.Threshold
|
||||
case "lt":
|
||||
return reducedValue.Float64 < e.Threshold
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
type rangedEvaluator struct {
|
||||
Type string
|
||||
Lower float64
|
||||
Upper float64
|
||||
}
|
||||
|
||||
func newRangedEvaluator(typ string, model *simplejson.Json) (*rangedEvaluator, error) {
|
||||
params := model.Get("params").MustArray()
|
||||
if len(params) == 0 {
|
||||
return nil, alerting.ValidationError{Reason: "Evaluator missing threshold parameter"}
|
||||
}
|
||||
|
||||
firstParam, ok := params[0].(json.Number)
|
||||
if !ok {
|
||||
return nil, alerting.ValidationError{Reason: "Evaluator has invalid parameter"}
|
||||
}
|
||||
|
||||
secondParam, ok := params[1].(json.Number)
|
||||
if !ok {
|
||||
return nil, alerting.ValidationError{Reason: "Evaluator has invalid second parameter"}
|
||||
}
|
||||
|
||||
rangedEval := &rangedEvaluator{Type: typ}
|
||||
rangedEval.Lower, _ = firstParam.Float64()
|
||||
rangedEval.Upper, _ = secondParam.Float64()
|
||||
return rangedEval, nil
|
||||
}
|
||||
|
||||
func (e *rangedEvaluator) Eval(reducedValue null.Float) bool {
|
||||
if !reducedValue.Valid {
|
||||
return false
|
||||
}
|
||||
|
||||
floatValue := reducedValue.Float64
|
||||
|
||||
switch e.Type {
|
||||
case "within_range":
|
||||
return (e.Lower < floatValue && e.Upper > floatValue) || (e.Upper < floatValue && e.Lower > floatValue)
|
||||
case "outside_range":
|
||||
return (e.Upper < floatValue && e.Lower < floatValue) || (e.Upper > floatValue && e.Lower > floatValue)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// NewAlertEvaluator is a factory function for returning
|
||||
// an `AlertEvaluator` depending on the json model.
|
||||
func NewAlertEvaluator(model *simplejson.Json) (AlertEvaluator, error) {
|
||||
typ := model.Get("type").MustString()
|
||||
if typ == "" {
|
||||
return nil, fmt.Errorf("evaluator missing type property")
|
||||
}
|
||||
|
||||
if inSlice(typ, defaultTypes) {
|
||||
return newThresholdEvaluator(typ, model)
|
||||
}
|
||||
|
||||
if inSlice(typ, rangedTypes) {
|
||||
return newRangedEvaluator(typ, model)
|
||||
}
|
||||
|
||||
if typ == "no_value" {
|
||||
return &noValueEvaluator{}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("evaluator invalid evaluator type: %s", typ)
|
||||
}
|
||||
|
||||
func inSlice(a string, list []string) bool {
|
||||
for _, b := range list {
|
||||
if b == a {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HumanThresholdType converts a threshold "type" string to a string that matches the UI
|
||||
// so errors are less confusing.
|
||||
func HumanThresholdType(typ string) string {
|
||||
switch typ {
|
||||
case "gt":
|
||||
return "IS ABOVE"
|
||||
case "lt":
|
||||
return "IS BELOW"
|
||||
case "within_range":
|
||||
return "IS WITHIN RANGE"
|
||||
case "outside_range":
|
||||
return "IS OUTSIDE RANGE"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package conditions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/null"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
)
|
||||
|
||||
func evaluatorScenario(t *testing.T, json string, reducedValue float64, datapoints ...float64) bool {
|
||||
jsonModel, err := simplejson.NewJson([]byte(json))
|
||||
require.NoError(t, err)
|
||||
|
||||
evaluator, err := NewAlertEvaluator(jsonModel)
|
||||
require.NoError(t, err)
|
||||
|
||||
return evaluator.Eval(null.FloatFrom(reducedValue))
|
||||
}
|
||||
|
||||
func TestEvaluators(t *testing.T) {
|
||||
t.Run("greater then", func(t *testing.T) {
|
||||
require.True(t, evaluatorScenario(t, `{"type": "gt", "params": [1] }`, 3))
|
||||
require.False(t, evaluatorScenario(t, `{"type": "gt", "params": [3] }`, 1))
|
||||
})
|
||||
|
||||
t.Run("less then", func(t *testing.T) {
|
||||
require.False(t, evaluatorScenario(t, `{"type": "lt", "params": [1] }`, 3))
|
||||
require.True(t, evaluatorScenario(t, `{"type": "lt", "params": [3] }`, 1))
|
||||
})
|
||||
|
||||
t.Run("within_range", func(t *testing.T) {
|
||||
require.True(t, evaluatorScenario(t, `{"type": "within_range", "params": [1, 100] }`, 3))
|
||||
require.False(t, evaluatorScenario(t, `{"type": "within_range", "params": [1, 100] }`, 300))
|
||||
require.True(t, evaluatorScenario(t, `{"type": "within_range", "params": [100, 1] }`, 3))
|
||||
require.False(t, evaluatorScenario(t, `{"type": "within_range", "params": [100, 1] }`, 300))
|
||||
})
|
||||
|
||||
t.Run("outside_range", func(t *testing.T) {
|
||||
require.True(t, evaluatorScenario(t, `{"type": "outside_range", "params": [1, 100] }`, 1000))
|
||||
require.False(t, evaluatorScenario(t, `{"type": "outside_range", "params": [1, 100] }`, 50))
|
||||
require.True(t, evaluatorScenario(t, `{"type": "outside_range", "params": [100, 1] }`, 1000))
|
||||
require.False(t, evaluatorScenario(t, `{"type": "outside_range", "params": [100, 1] }`, 50))
|
||||
})
|
||||
|
||||
t.Run("no_value", func(t *testing.T) {
|
||||
t.Run("should be false if series have values", func(t *testing.T) {
|
||||
require.False(t, evaluatorScenario(t, `{"type": "no_value", "params": [] }`, 50))
|
||||
})
|
||||
|
||||
t.Run("should be true when the series have no value", func(t *testing.T) {
|
||||
jsonModel, err := simplejson.NewJson([]byte(`{"type": "no_value", "params": [] }`))
|
||||
require.NoError(t, err)
|
||||
|
||||
evaluator, err := NewAlertEvaluator(jsonModel)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, evaluator.Eval(null.FloatFromPtr(nil)))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
package conditions
|
||||
|
||||
import (
|
||||
gocontext "context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/null"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
prometheus "github.com/grafana/grafana/pkg/promlib"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
ngalertmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/tsdb/legacydata"
|
||||
"github.com/grafana/grafana/pkg/tsdb/legacydata/interval"
|
||||
)
|
||||
|
||||
func init() {
|
||||
alerting.RegisterCondition("query", func(model *simplejson.Json, index int) (alerting.Condition, error) {
|
||||
return newQueryCondition(model, index)
|
||||
})
|
||||
}
|
||||
|
||||
// QueryCondition is responsible for issue and query, reduce the
|
||||
// timeseries into single values and evaluate if they are firing or not.
|
||||
type QueryCondition struct {
|
||||
Index int
|
||||
Query AlertQuery
|
||||
Reducer *queryReducer
|
||||
Evaluator AlertEvaluator
|
||||
Operator string
|
||||
}
|
||||
|
||||
// AlertQuery contains information about what datasource a query
|
||||
// should be sent to and the query object.
|
||||
type AlertQuery struct {
|
||||
Model *simplejson.Json
|
||||
DatasourceID int64
|
||||
From string
|
||||
To string
|
||||
}
|
||||
|
||||
// Eval evaluates the `QueryCondition`.
|
||||
func (c *QueryCondition) Eval(context *alerting.EvalContext, requestHandler legacydata.RequestHandler) (*alerting.ConditionResult, error) {
|
||||
timeRange := legacydata.NewDataTimeRange(c.Query.From, c.Query.To)
|
||||
|
||||
seriesList, err := c.executeQuery(context, timeRange, requestHandler)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
emptySeriesCount := 0
|
||||
evalMatchCount := 0
|
||||
|
||||
// matches represents all the series that violate the alert condition
|
||||
var matches []*alerting.EvalMatch
|
||||
// allMatches capture all evaluation matches irregardless on whether the condition is met or not
|
||||
allMatches := make([]*alerting.EvalMatch, 0, len(seriesList))
|
||||
|
||||
for _, series := range seriesList {
|
||||
reducedValue := c.Reducer.Reduce(series)
|
||||
evalMatch := c.Evaluator.Eval(reducedValue)
|
||||
|
||||
if !reducedValue.Valid {
|
||||
emptySeriesCount++
|
||||
}
|
||||
|
||||
if context.IsTestRun {
|
||||
context.Logs = append(context.Logs, &alerting.ResultLogEntry{
|
||||
Message: fmt.Sprintf("Condition[%d]: Eval: %v, Metric: %s, Value: %s", c.Index, evalMatch, series.Name, reducedValue),
|
||||
})
|
||||
}
|
||||
|
||||
em := alerting.EvalMatch{
|
||||
Metric: series.Name,
|
||||
Value: reducedValue,
|
||||
Tags: series.Tags,
|
||||
}
|
||||
|
||||
allMatches = append(allMatches, &em)
|
||||
|
||||
if evalMatch {
|
||||
evalMatchCount++
|
||||
matches = append(matches, &em)
|
||||
}
|
||||
}
|
||||
|
||||
// handle no series special case
|
||||
if len(seriesList) == 0 {
|
||||
// eval condition for null value
|
||||
evalMatch := c.Evaluator.Eval(null.FloatFromPtr(nil))
|
||||
|
||||
if context.IsTestRun {
|
||||
context.Logs = append(context.Logs, &alerting.ResultLogEntry{
|
||||
Message: fmt.Sprintf("Condition: Eval: %v, Query Returned No Series (reduced to null/no value)", evalMatch),
|
||||
})
|
||||
}
|
||||
|
||||
if evalMatch {
|
||||
evalMatchCount++
|
||||
matches = append(matches, &alerting.EvalMatch{Metric: "NoData", Value: null.FloatFromPtr(nil)})
|
||||
}
|
||||
}
|
||||
|
||||
return &alerting.ConditionResult{
|
||||
Firing: evalMatchCount > 0,
|
||||
NoDataFound: emptySeriesCount == len(seriesList),
|
||||
Operator: c.Operator,
|
||||
EvalMatches: matches,
|
||||
AllMatches: allMatches,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func calculateInterval(timeRange legacydata.DataTimeRange, model *simplejson.Json, dsInfo *datasources.DataSource) (time.Duration, error) {
|
||||
// if there is no min-interval specified in the datasource or in the dashboard-panel,
|
||||
// the value of 1ms is used (this is how it is done in the dashboard-interval-calculation too,
|
||||
// see https://github.com/grafana/grafana/blob/9a0040c0aeaae8357c650cec2ee644a571dddf3d/packages/grafana-data/src/datetime/rangeutil.ts#L264)
|
||||
defaultMinInterval := time.Millisecond * 1
|
||||
|
||||
// interval.GetIntervalFrom has two problems (but they do not affect us here):
|
||||
// - it returns the min-interval, so it should be called interval.GetMinIntervalFrom
|
||||
// - it falls back to model.intervalMs. it should not, because that one is the real final
|
||||
// interval-value calculated by the browser. but, in this specific case (old-alert),
|
||||
// that value is not set, so the fallback never happens.
|
||||
minInterval, err := interval.GetIntervalFrom(dsInfo, model, defaultMinInterval)
|
||||
|
||||
if err != nil {
|
||||
return time.Duration(0), err
|
||||
}
|
||||
|
||||
calc := interval.NewCalculator()
|
||||
|
||||
interval := calc.Calculate(timeRange, minInterval)
|
||||
|
||||
return interval.Value, nil
|
||||
}
|
||||
|
||||
func (c *QueryCondition) executeQuery(context *alerting.EvalContext, timeRange legacydata.DataTimeRange,
|
||||
requestHandler legacydata.RequestHandler) (legacydata.DataTimeSeriesSlice, error) {
|
||||
getDsInfo := &datasources.GetDataSourceQuery{
|
||||
ID: c.Query.DatasourceID,
|
||||
OrgID: context.Rule.OrgID,
|
||||
}
|
||||
|
||||
dataSource, err := context.GetDataSource(context.Ctx, getDsInfo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not find datasource: %w", err)
|
||||
}
|
||||
|
||||
err = context.RequestValidator.Validate(dataSource.URL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("access denied: %w", err)
|
||||
}
|
||||
|
||||
req, err := c.getRequestForAlertRule(dataSource, timeRange, context.IsDebug)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("interval calculation failed: %w", err)
|
||||
}
|
||||
result := make(legacydata.DataTimeSeriesSlice, 0)
|
||||
|
||||
if context.IsDebug {
|
||||
data := simplejson.New()
|
||||
if req.TimeRange != nil {
|
||||
data.Set("from", req.TimeRange.GetFromAsMsEpoch())
|
||||
data.Set("to", req.TimeRange.GetToAsMsEpoch())
|
||||
}
|
||||
|
||||
type queryDto struct {
|
||||
RefID string `json:"refId"`
|
||||
Model *simplejson.Json `json:"model"`
|
||||
Datasource *simplejson.Json `json:"datasource"`
|
||||
MaxDataPoints int64 `json:"maxDataPoints"`
|
||||
IntervalMS int64 `json:"intervalMs"`
|
||||
}
|
||||
|
||||
queries := []*queryDto{}
|
||||
for _, q := range req.Queries {
|
||||
queries = append(queries, &queryDto{
|
||||
RefID: q.RefID,
|
||||
Model: q.Model,
|
||||
Datasource: simplejson.NewFromAny(map[string]any{
|
||||
"id": q.DataSource.ID,
|
||||
"name": q.DataSource.Name,
|
||||
}),
|
||||
MaxDataPoints: q.MaxDataPoints,
|
||||
IntervalMS: q.IntervalMS,
|
||||
})
|
||||
}
|
||||
|
||||
data.Set("queries", queries)
|
||||
|
||||
context.Logs = append(context.Logs, &alerting.ResultLogEntry{
|
||||
Message: fmt.Sprintf("Condition[%d]: Query", c.Index),
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
resp, err := requestHandler.HandleRequest(context.Ctx, dataSource, req)
|
||||
if err != nil {
|
||||
return nil, toCustomError(err)
|
||||
}
|
||||
|
||||
for _, v := range resp.Results {
|
||||
if v.Error != nil {
|
||||
return nil, fmt.Errorf("request handler response error %v", v)
|
||||
}
|
||||
|
||||
// If there are dataframes but no series on the result
|
||||
useDataframes := v.Dataframes != nil && (v.Series == nil || len(v.Series) == 0)
|
||||
|
||||
if useDataframes { // convert the dataframes to plugins.DataTimeSeries
|
||||
frames, err := v.Dataframes.Decoded()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%v: %w", "request handler failed to unmarshal arrow dataframes from bytes", err)
|
||||
}
|
||||
|
||||
for _, frame := range frames {
|
||||
ss, err := FrameToSeriesSlice(frame)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
`request handler failed to convert dataframe "%v" to plugins.DataTimeSeriesSlice: %w`, frame.Name, err)
|
||||
}
|
||||
result = append(result, ss...)
|
||||
}
|
||||
} else {
|
||||
result = append(result, v.Series...)
|
||||
}
|
||||
|
||||
queryResultData := map[string]any{}
|
||||
|
||||
if context.IsTestRun {
|
||||
queryResultData["series"] = result
|
||||
}
|
||||
|
||||
if context.IsDebug && v.Meta != nil {
|
||||
queryResultData["meta"] = v.Meta
|
||||
}
|
||||
|
||||
if context.IsTestRun || context.IsDebug {
|
||||
if useDataframes {
|
||||
queryResultData["fromDataframe"] = true
|
||||
}
|
||||
context.Logs = append(context.Logs, &alerting.ResultLogEntry{
|
||||
Message: fmt.Sprintf("Condition[%d]: Query Result", c.Index),
|
||||
Data: simplejson.NewFromAny(queryResultData),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *QueryCondition) getRequestForAlertRule(datasource *datasources.DataSource, timeRange legacydata.DataTimeRange,
|
||||
debug bool) (legacydata.DataQuery, error) {
|
||||
queryModel := c.Query.Model
|
||||
|
||||
calculatedInterval, err := calculateInterval(timeRange, queryModel, datasource)
|
||||
if err != nil {
|
||||
return legacydata.DataQuery{}, err
|
||||
}
|
||||
|
||||
req := legacydata.DataQuery{
|
||||
TimeRange: &timeRange,
|
||||
Queries: []legacydata.DataSubQuery{
|
||||
{
|
||||
RefID: "A",
|
||||
Model: queryModel,
|
||||
DataSource: datasource,
|
||||
QueryType: queryModel.Get("queryType").MustString(""),
|
||||
MaxDataPoints: interval.DefaultRes,
|
||||
IntervalMS: calculatedInterval.Milliseconds(),
|
||||
},
|
||||
},
|
||||
Headers: map[string]string{
|
||||
ngalertmodels.FromAlertHeaderName: "true",
|
||||
ngalertmodels.CacheSkipHeaderName: "true",
|
||||
},
|
||||
Debug: debug,
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func newQueryCondition(model *simplejson.Json, index int) (*QueryCondition, error) {
|
||||
condition := QueryCondition{}
|
||||
condition.Index = index
|
||||
|
||||
queryJSON := model.Get("query")
|
||||
|
||||
condition.Query.Model = queryJSON.Get("model")
|
||||
condition.Query.From = queryJSON.Get("params").MustArray()[1].(string)
|
||||
condition.Query.To = queryJSON.Get("params").MustArray()[2].(string)
|
||||
|
||||
if err := validateFromValue(condition.Query.From); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateToValue(condition.Query.To); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
condition.Query.DatasourceID = queryJSON.Get("datasourceId").MustInt64()
|
||||
|
||||
reducerJSON := model.Get("reducer")
|
||||
condition.Reducer = newSimpleReducer(reducerJSON.Get("type").MustString())
|
||||
|
||||
evaluatorJSON := model.Get("evaluator")
|
||||
evaluator, err := NewAlertEvaluator(evaluatorJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error in condition %v: %v", index, err)
|
||||
}
|
||||
condition.Evaluator = evaluator
|
||||
|
||||
operatorJSON := model.Get("operator")
|
||||
operator := operatorJSON.Get("type").MustString("and")
|
||||
condition.Operator = operator
|
||||
|
||||
return &condition, nil
|
||||
}
|
||||
|
||||
func validateFromValue(from string) error {
|
||||
fromRaw := strings.Replace(from, "now-", "", 1)
|
||||
|
||||
_, err := time.ParseDuration("-" + fromRaw)
|
||||
return err
|
||||
}
|
||||
|
||||
func validateToValue(to string) error {
|
||||
if to == "now" {
|
||||
return nil
|
||||
} else if strings.HasPrefix(to, "now-") {
|
||||
withoutNow := strings.Replace(to, "now-", "", 1)
|
||||
|
||||
_, err := time.ParseDuration("-" + withoutNow)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
_, err := time.ParseDuration(to)
|
||||
return err
|
||||
}
|
||||
|
||||
// FrameToSeriesSlice converts a frame that is a valid time series as per data.TimeSeriesSchema()
|
||||
// to a DataTimeSeriesSlice.
|
||||
func FrameToSeriesSlice(frame *data.Frame) (legacydata.DataTimeSeriesSlice, error) {
|
||||
tsSchema := frame.TimeSeriesSchema()
|
||||
if tsSchema.Type == data.TimeSeriesTypeNot {
|
||||
// If no fields, or only a time field, create an empty plugins.DataTimeSeriesSlice with a single
|
||||
// time series in order to trigger "no data" in alerting.
|
||||
if frame.Rows() == 0 || (len(frame.Fields) == 1 && frame.Fields[0].Type().Time()) {
|
||||
return legacydata.DataTimeSeriesSlice{{
|
||||
Name: frame.Name,
|
||||
Points: make(legacydata.DataTimeSeriesPoints, 0),
|
||||
}}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("input frame is not recognized as a time series")
|
||||
}
|
||||
seriesCount := len(tsSchema.ValueIndices)
|
||||
seriesSlice := make(legacydata.DataTimeSeriesSlice, 0, seriesCount)
|
||||
timeField := frame.Fields[tsSchema.TimeIndex]
|
||||
timeNullFloatSlice := make([]null.Float, timeField.Len())
|
||||
|
||||
for i := 0; i < timeField.Len(); i++ { // built slice of time as epoch ms in null floats
|
||||
tStamp, err := timeField.FloatAt(i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
timeNullFloatSlice[i] = null.FloatFrom(tStamp)
|
||||
}
|
||||
|
||||
for _, fieldIdx := range tsSchema.ValueIndices { // create a TimeSeries for each value Field
|
||||
field := frame.Fields[fieldIdx]
|
||||
ts := legacydata.DataTimeSeries{
|
||||
Points: make(legacydata.DataTimeSeriesPoints, field.Len()),
|
||||
}
|
||||
|
||||
if len(field.Labels) > 0 {
|
||||
ts.Tags = field.Labels.Copy()
|
||||
}
|
||||
|
||||
switch {
|
||||
case field.Config != nil && field.Config.DisplayName != "":
|
||||
ts.Name = field.Config.DisplayName
|
||||
case field.Config != nil && field.Config.DisplayNameFromDS != "":
|
||||
ts.Name = field.Config.DisplayNameFromDS
|
||||
case len(field.Labels) > 0:
|
||||
// Tags are appended to the name so they are eventually included in EvalMatch's Metric property
|
||||
// for display in notifications.
|
||||
ts.Name = fmt.Sprintf("%v {%v}", field.Name, field.Labels.String())
|
||||
default:
|
||||
ts.Name = field.Name
|
||||
}
|
||||
|
||||
for rowIdx := 0; rowIdx < field.Len(); rowIdx++ { // for each value in the field, make a TimePoint
|
||||
val, err := field.FloatAt(rowIdx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"failed to convert frame to DataTimeSeriesSlice, can not convert value %v to float: %w", field.At(rowIdx), err)
|
||||
}
|
||||
ts.Points[rowIdx] = legacydata.DataTimePoint{
|
||||
null.FloatFrom(val),
|
||||
timeNullFloatSlice[rowIdx],
|
||||
}
|
||||
}
|
||||
|
||||
seriesSlice = append(seriesSlice, ts)
|
||||
}
|
||||
|
||||
return seriesSlice, nil
|
||||
}
|
||||
|
||||
func toCustomError(err error) error {
|
||||
// is context timeout
|
||||
if errors.Is(err, gocontext.DeadlineExceeded) {
|
||||
return fmt.Errorf("alert execution exceeded the timeout")
|
||||
}
|
||||
|
||||
// is Prometheus error
|
||||
if prometheus.IsAPIError(err) {
|
||||
return prometheus.ConvertAPIError(err)
|
||||
}
|
||||
|
||||
// generic fallback
|
||||
return fmt.Errorf("request handler error: %w", err)
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
package conditions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/db/dbtest"
|
||||
"github.com/grafana/grafana/pkg/infra/localcache"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
fd "github.com/grafana/grafana/pkg/services/datasources/fakes"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/validations"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/tsdb/legacydata"
|
||||
)
|
||||
|
||||
const DefaultRes int64 = 1500
|
||||
|
||||
func TestQueryInterval(t *testing.T) {
|
||||
t.Run("When evaluating query condition, regarding the interval value", func(t *testing.T) {
|
||||
t.Run("Can handle interval-calculation with no panel-min-interval and no datasource-min-interval", func(t *testing.T) {
|
||||
// no panel-min-interval in the queryModel
|
||||
queryModel := `{"target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"}`
|
||||
|
||||
// no datasource-min-interval
|
||||
var dataSourceJson *simplejson.Json = nil
|
||||
|
||||
timeRange := "5m"
|
||||
|
||||
verifier := func(query legacydata.DataSubQuery) {
|
||||
// 5minutes timerange = 300000milliseconds; default-resolution is 1500pixels,
|
||||
// so we should have 300000/1500 = 200milliseconds here
|
||||
require.Equal(t, int64(200), query.IntervalMS)
|
||||
require.Equal(t, DefaultRes, query.MaxDataPoints)
|
||||
}
|
||||
|
||||
applyScenario(t, timeRange, dataSourceJson, queryModel, verifier)
|
||||
})
|
||||
t.Run("Can handle interval-calculation with panel-min-interval and no datasource-min-interval", func(t *testing.T) {
|
||||
// panel-min-interval in the queryModel
|
||||
queryModel := `{"interval":"123s", "target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"}`
|
||||
|
||||
// no datasource-min-interval
|
||||
var dataSourceJson *simplejson.Json = nil
|
||||
|
||||
timeRange := "5m"
|
||||
|
||||
verifier := func(query legacydata.DataSubQuery) {
|
||||
require.Equal(t, int64(123000), query.IntervalMS)
|
||||
require.Equal(t, DefaultRes, query.MaxDataPoints)
|
||||
}
|
||||
|
||||
applyScenario(t, timeRange, dataSourceJson, queryModel, verifier)
|
||||
})
|
||||
t.Run("Can handle interval-calculation with no panel-min-interval and datasource-min-interval", func(t *testing.T) {
|
||||
// no panel-min-interval in the queryModel
|
||||
queryModel := `{"target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"}`
|
||||
|
||||
// min-interval in datasource-json
|
||||
dataSourceJson, err := simplejson.NewJson([]byte(`{
|
||||
"timeInterval": "71s"
|
||||
}`))
|
||||
require.Nil(t, err)
|
||||
|
||||
timeRange := "5m"
|
||||
|
||||
verifier := func(query legacydata.DataSubQuery) {
|
||||
require.Equal(t, int64(71000), query.IntervalMS)
|
||||
require.Equal(t, DefaultRes, query.MaxDataPoints)
|
||||
}
|
||||
|
||||
applyScenario(t, timeRange, dataSourceJson, queryModel, verifier)
|
||||
})
|
||||
t.Run("Can handle interval-calculation with both panel-min-interval and datasource-min-interval", func(t *testing.T) {
|
||||
// panel-min-interval in the queryModel
|
||||
queryModel := `{"interval":"19s", "target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"}`
|
||||
|
||||
// min-interval in datasource-json
|
||||
dataSourceJson, err := simplejson.NewJson([]byte(`{
|
||||
"timeInterval": "71s"
|
||||
}`))
|
||||
require.Nil(t, err)
|
||||
|
||||
timeRange := "5m"
|
||||
|
||||
verifier := func(query legacydata.DataSubQuery) {
|
||||
// when both panel-min-interval and datasource-min-interval exists,
|
||||
// panel-min-interval is used
|
||||
require.Equal(t, int64(19000), query.IntervalMS)
|
||||
require.Equal(t, DefaultRes, query.MaxDataPoints)
|
||||
}
|
||||
|
||||
applyScenario(t, timeRange, dataSourceJson, queryModel, verifier)
|
||||
})
|
||||
|
||||
t.Run("Can handle no min-interval, and very small time-ranges, where the default-min-interval=1ms applies", func(t *testing.T) {
|
||||
// no panel-min-interval in the queryModel
|
||||
queryModel := `{"target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"}`
|
||||
|
||||
// no datasource-min-interval
|
||||
var dataSourceJson *simplejson.Json = nil
|
||||
|
||||
timeRange := "1s"
|
||||
|
||||
verifier := func(query legacydata.DataSubQuery) {
|
||||
// no min-interval exists, the default-min-interval will be used,
|
||||
// and for such a short time-range this will cause the value to be 1millisecond.
|
||||
require.Equal(t, int64(1), query.IntervalMS)
|
||||
require.Equal(t, DefaultRes, query.MaxDataPoints)
|
||||
}
|
||||
|
||||
applyScenario(t, timeRange, dataSourceJson, queryModel, verifier)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
type queryIntervalTestContext struct {
|
||||
result *alerting.EvalContext
|
||||
condition *QueryCondition
|
||||
}
|
||||
|
||||
type queryIntervalVerifier func(query legacydata.DataSubQuery)
|
||||
|
||||
type fakeIntervalTestReqHandler struct {
|
||||
//nolint: staticcheck // legacydata.DataResponse deprecated
|
||||
response legacydata.DataResponse
|
||||
verifier queryIntervalVerifier
|
||||
}
|
||||
|
||||
//nolint:staticcheck // legacydata.DataResponse deprecated
|
||||
func (rh fakeIntervalTestReqHandler) HandleRequest(ctx context.Context, dsInfo *datasources.DataSource, query legacydata.DataQuery) (
|
||||
legacydata.DataResponse, error) {
|
||||
q := query.Queries[0]
|
||||
rh.verifier(q)
|
||||
return rh.response, nil
|
||||
}
|
||||
|
||||
//nolint:staticcheck // legacydata.DataResponse deprecated
|
||||
func applyScenario(t *testing.T, timeRange string, dataSourceJsonData *simplejson.Json, queryModel string, verifier func(query legacydata.DataSubQuery)) {
|
||||
t.Run("desc", func(t *testing.T) {
|
||||
db := dbtest.NewFakeDB()
|
||||
store := alerting.ProvideAlertStore(db, localcache.ProvideService(), &setting.Cfg{}, nil, featuremgmt.WithFeatures())
|
||||
|
||||
ctx := &queryIntervalTestContext{}
|
||||
ctx.result = &alerting.EvalContext{
|
||||
Ctx: context.Background(),
|
||||
Rule: &alerting.Rule{},
|
||||
RequestValidator: &validations.OSSPluginRequestValidator{},
|
||||
Store: store,
|
||||
DatasourceService: &fd.FakeDataSourceService{
|
||||
DataSources: []*datasources.DataSource{
|
||||
{ID: 1, Type: datasources.DS_GRAPHITE, JsonData: dataSourceJsonData},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonModel, err := simplejson.NewJson([]byte(`{
|
||||
"type": "query",
|
||||
"query": {
|
||||
"params": ["A", "` + timeRange + `", "now"],
|
||||
"datasourceId": 1,
|
||||
"model": ` + queryModel + `
|
||||
},
|
||||
"reducer":{"type": "avg"},
|
||||
"evaluator":{"type": "gt", "params": [100]}
|
||||
}`))
|
||||
require.Nil(t, err)
|
||||
|
||||
condition, err := newQueryCondition(jsonModel, 0)
|
||||
require.Nil(t, err)
|
||||
|
||||
ctx.condition = condition
|
||||
|
||||
qr := legacydata.DataQueryResult{}
|
||||
|
||||
reqHandler := fakeIntervalTestReqHandler{
|
||||
response: legacydata.DataResponse{
|
||||
Results: map[string]legacydata.DataQueryResult{
|
||||
"A": qr,
|
||||
},
|
||||
},
|
||||
verifier: verifier,
|
||||
}
|
||||
_, err = condition.Eval(ctx.result, reqHandler)
|
||||
|
||||
require.Nil(t, err)
|
||||
})
|
||||
}
|
||||
@@ -1,386 +0,0 @@
|
||||
package conditions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/null"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/db/dbtest"
|
||||
"github.com/grafana/grafana/pkg/infra/localcache"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
fd "github.com/grafana/grafana/pkg/services/datasources/fakes"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/validations"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/tsdb/legacydata"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
func newTimeSeriesPointsFromArgs(values ...float64) legacydata.DataTimeSeriesPoints {
|
||||
points := make(legacydata.DataTimeSeriesPoints, 0)
|
||||
|
||||
for i := 0; i < len(values); i += 2 {
|
||||
points = append(points, legacydata.DataTimePoint{null.FloatFrom(values[i]), null.FloatFrom(values[i+1])})
|
||||
}
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
func TestQueryCondition(t *testing.T) {
|
||||
setup := func() *queryConditionTestContext {
|
||||
ctx := &queryConditionTestContext{}
|
||||
db := dbtest.NewFakeDB()
|
||||
store := alerting.ProvideAlertStore(db, localcache.ProvideService(), &setting.Cfg{}, nil, featuremgmt.WithFeatures())
|
||||
ctx.reducer = `{"type":"avg"}`
|
||||
ctx.evaluator = `{"type":"gt","params":[100]}`
|
||||
ctx.result = &alerting.EvalContext{
|
||||
Ctx: context.Background(),
|
||||
Rule: &alerting.Rule{},
|
||||
RequestValidator: &validations.OSSPluginRequestValidator{},
|
||||
Store: store,
|
||||
DatasourceService: &fd.FakeDataSourceService{
|
||||
DataSources: []*datasources.DataSource{
|
||||
{ID: 1, Type: datasources.DS_GRAPHITE},
|
||||
},
|
||||
},
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
t.Run("Can read query condition from json model", func(t *testing.T) {
|
||||
ctx := setup()
|
||||
_, err := ctx.exec(t)
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, "5m", ctx.condition.Query.From)
|
||||
require.Equal(t, "now", ctx.condition.Query.To)
|
||||
require.Equal(t, int64(1), ctx.condition.Query.DatasourceID)
|
||||
|
||||
t.Run("Can read query reducer", func(t *testing.T) {
|
||||
reducer := ctx.condition.Reducer
|
||||
require.Equal(t, "avg", reducer.Type)
|
||||
})
|
||||
|
||||
t.Run("Can read evaluator", func(t *testing.T) {
|
||||
evaluator, ok := ctx.condition.Evaluator.(*thresholdEvaluator)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "gt", evaluator.Type)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("should fire when avg is above 100", func(t *testing.T) {
|
||||
ctx := setup()
|
||||
points := newTimeSeriesPointsFromArgs(120, 0)
|
||||
ctx.series = legacydata.DataTimeSeriesSlice{legacydata.DataTimeSeries{Name: "test1", Points: points}}
|
||||
cr, err := ctx.exec(t)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.True(t, cr.Firing)
|
||||
})
|
||||
|
||||
t.Run("should fire when avg is above 100 on dataframe", func(t *testing.T) {
|
||||
ctx := setup()
|
||||
ctx.frame = data.NewFrame("",
|
||||
data.NewField("time", nil, []time.Time{time.Now(), time.Now()}),
|
||||
data.NewField("val", nil, []int64{120, 150}),
|
||||
)
|
||||
cr, err := ctx.exec(t)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.True(t, cr.Firing)
|
||||
})
|
||||
|
||||
t.Run("Should not fire when avg is below 100", func(t *testing.T) {
|
||||
ctx := setup()
|
||||
points := newTimeSeriesPointsFromArgs(90, 0)
|
||||
ctx.series = legacydata.DataTimeSeriesSlice{legacydata.DataTimeSeries{Name: "test1", Points: points}}
|
||||
cr, err := ctx.exec(t)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.False(t, cr.Firing)
|
||||
})
|
||||
|
||||
t.Run("Should not fire when avg is below 100 on dataframe", func(t *testing.T) {
|
||||
ctx := setup()
|
||||
ctx.frame = data.NewFrame("",
|
||||
data.NewField("time", nil, []time.Time{time.Now(), time.Now()}),
|
||||
data.NewField("val", nil, []int64{12, 47}),
|
||||
)
|
||||
cr, err := ctx.exec(t)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.False(t, cr.Firing)
|
||||
})
|
||||
|
||||
t.Run("Should fire if only first series matches", func(t *testing.T) {
|
||||
ctx := setup()
|
||||
ctx.series = legacydata.DataTimeSeriesSlice{
|
||||
legacydata.DataTimeSeries{Name: "test1", Points: newTimeSeriesPointsFromArgs(120, 0)},
|
||||
legacydata.DataTimeSeries{Name: "test2", Points: newTimeSeriesPointsFromArgs(0, 0)},
|
||||
}
|
||||
cr, err := ctx.exec(t)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.True(t, cr.Firing)
|
||||
})
|
||||
|
||||
t.Run("No series", func(t *testing.T) {
|
||||
ctx := setup()
|
||||
t.Run("Should set NoDataFound when condition is gt", func(t *testing.T) {
|
||||
ctx.series = legacydata.DataTimeSeriesSlice{}
|
||||
cr, err := ctx.exec(t)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.False(t, cr.Firing)
|
||||
require.True(t, cr.NoDataFound)
|
||||
})
|
||||
|
||||
t.Run("Should be firing when condition is no_value", func(t *testing.T) {
|
||||
ctx.evaluator = `{"type": "no_value", "params": []}`
|
||||
ctx.series = legacydata.DataTimeSeriesSlice{}
|
||||
cr, err := ctx.exec(t)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.True(t, cr.Firing)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Empty series", func(t *testing.T) {
|
||||
ctx := setup()
|
||||
t.Run("Should set Firing if eval match", func(t *testing.T) {
|
||||
ctx.evaluator = `{"type": "no_value", "params": []}`
|
||||
ctx.series = legacydata.DataTimeSeriesSlice{
|
||||
legacydata.DataTimeSeries{Name: "test1", Points: newTimeSeriesPointsFromArgs()},
|
||||
}
|
||||
cr, err := ctx.exec(t)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.True(t, cr.Firing)
|
||||
})
|
||||
|
||||
t.Run("Should set NoDataFound both series are empty", func(t *testing.T) {
|
||||
ctx.series = legacydata.DataTimeSeriesSlice{
|
||||
legacydata.DataTimeSeries{Name: "test1", Points: newTimeSeriesPointsFromArgs()},
|
||||
legacydata.DataTimeSeries{Name: "test2", Points: newTimeSeriesPointsFromArgs()},
|
||||
}
|
||||
cr, err := ctx.exec(t)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.True(t, cr.NoDataFound)
|
||||
})
|
||||
|
||||
t.Run("Should set NoDataFound both series contains null", func(t *testing.T) {
|
||||
ctx.series = legacydata.DataTimeSeriesSlice{
|
||||
legacydata.DataTimeSeries{Name: "test1", Points: legacydata.DataTimeSeriesPoints{legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(0)}}},
|
||||
legacydata.DataTimeSeries{Name: "test2", Points: legacydata.DataTimeSeriesPoints{legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(0)}}},
|
||||
}
|
||||
cr, err := ctx.exec(t)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.True(t, cr.NoDataFound)
|
||||
})
|
||||
|
||||
t.Run("Should not set NoDataFound if one series is empty", func(t *testing.T) {
|
||||
ctx.series = legacydata.DataTimeSeriesSlice{
|
||||
legacydata.DataTimeSeries{Name: "test1", Points: newTimeSeriesPointsFromArgs()},
|
||||
legacydata.DataTimeSeries{Name: "test2", Points: newTimeSeriesPointsFromArgs(120, 0)},
|
||||
}
|
||||
cr, err := ctx.exec(t)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.False(t, cr.NoDataFound)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
type queryConditionTestContext struct {
|
||||
reducer string
|
||||
evaluator string
|
||||
series legacydata.DataTimeSeriesSlice
|
||||
frame *data.Frame
|
||||
result *alerting.EvalContext
|
||||
condition *QueryCondition
|
||||
}
|
||||
|
||||
//nolint:staticcheck // legacydata.DataPlugin deprecated
|
||||
func (ctx *queryConditionTestContext) exec(t *testing.T) (*alerting.ConditionResult, error) {
|
||||
jsonModel, err := simplejson.NewJson([]byte(`{
|
||||
"type": "query",
|
||||
"query": {
|
||||
"params": ["A", "5m", "now"],
|
||||
"datasourceId": 1,
|
||||
"model": {"target": "aliasByNode(statsd.fakesite.counters.session_start.mobile.count, 4)"}
|
||||
},
|
||||
"reducer":` + ctx.reducer + `,
|
||||
"evaluator":` + ctx.evaluator + `
|
||||
}`))
|
||||
require.Nil(t, err)
|
||||
|
||||
condition, err := newQueryCondition(jsonModel, 0)
|
||||
require.Nil(t, err)
|
||||
|
||||
ctx.condition = condition
|
||||
|
||||
qr := legacydata.DataQueryResult{
|
||||
Series: ctx.series,
|
||||
}
|
||||
|
||||
if ctx.frame != nil {
|
||||
qr = legacydata.DataQueryResult{
|
||||
Dataframes: legacydata.NewDecodedDataFrames(data.Frames{ctx.frame}),
|
||||
}
|
||||
}
|
||||
reqHandler := fakeReqHandler{
|
||||
response: legacydata.DataResponse{
|
||||
Results: map[string]legacydata.DataQueryResult{
|
||||
"A": qr,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return condition.Eval(ctx.result, reqHandler)
|
||||
}
|
||||
|
||||
type fakeReqHandler struct {
|
||||
//nolint: staticcheck // legacydata.DataPlugin deprecated
|
||||
response legacydata.DataResponse
|
||||
}
|
||||
|
||||
//nolint:staticcheck // legacydata.DataPlugin deprecated
|
||||
func (rh fakeReqHandler) HandleRequest(context.Context, *datasources.DataSource, legacydata.DataQuery) (
|
||||
legacydata.DataResponse, error) {
|
||||
return rh.response, nil
|
||||
}
|
||||
|
||||
func TestFrameToSeriesSlice(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
frame *data.Frame
|
||||
seriesSlice legacydata.DataTimeSeriesSlice
|
||||
Err require.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "a wide series",
|
||||
frame: data.NewFrame("",
|
||||
data.NewField("Time", nil, []time.Time{
|
||||
time.Date(2020, 1, 2, 3, 4, 0, 0, time.UTC),
|
||||
time.Date(2020, 1, 2, 3, 4, 30, 0, time.UTC),
|
||||
}),
|
||||
data.NewField(`Values Int64s`, data.Labels{"Animal Factor": "cat"}, []*int64{
|
||||
nil,
|
||||
util.Pointer(int64(3)),
|
||||
}),
|
||||
data.NewField(`Values Floats`, data.Labels{"Animal Factor": "sloth"}, []float64{
|
||||
2.0,
|
||||
4.0,
|
||||
})),
|
||||
|
||||
seriesSlice: legacydata.DataTimeSeriesSlice{
|
||||
legacydata.DataTimeSeries{
|
||||
Name: "Values Int64s {Animal Factor=cat}",
|
||||
Tags: map[string]string{"Animal Factor": "cat"},
|
||||
Points: legacydata.DataTimeSeriesPoints{
|
||||
legacydata.DataTimePoint{null.FloatFrom(math.NaN()), null.FloatFrom(1577934240000)},
|
||||
legacydata.DataTimePoint{null.FloatFrom(3), null.FloatFrom(1577934270000)},
|
||||
},
|
||||
},
|
||||
legacydata.DataTimeSeries{
|
||||
Name: "Values Floats {Animal Factor=sloth}",
|
||||
Tags: map[string]string{"Animal Factor": "sloth"},
|
||||
Points: legacydata.DataTimeSeriesPoints{
|
||||
legacydata.DataTimePoint{null.FloatFrom(2), null.FloatFrom(1577934240000)},
|
||||
legacydata.DataTimePoint{null.FloatFrom(4), null.FloatFrom(1577934270000)},
|
||||
},
|
||||
},
|
||||
},
|
||||
Err: require.NoError,
|
||||
},
|
||||
{
|
||||
name: "empty wide series",
|
||||
frame: data.NewFrame("",
|
||||
data.NewField("Time", nil, []time.Time{}),
|
||||
data.NewField(`Values Int64s`, data.Labels{"Animal Factor": "cat"}, []*int64{}),
|
||||
data.NewField(`Values Floats`, data.Labels{"Animal Factor": "sloth"}, []float64{})),
|
||||
|
||||
seriesSlice: legacydata.DataTimeSeriesSlice{
|
||||
legacydata.DataTimeSeries{
|
||||
Name: "Values Int64s {Animal Factor=cat}",
|
||||
Tags: map[string]string{"Animal Factor": "cat"},
|
||||
Points: legacydata.DataTimeSeriesPoints{},
|
||||
},
|
||||
legacydata.DataTimeSeries{
|
||||
Name: "Values Floats {Animal Factor=sloth}",
|
||||
Tags: map[string]string{"Animal Factor": "sloth"},
|
||||
Points: legacydata.DataTimeSeriesPoints{},
|
||||
},
|
||||
},
|
||||
Err: require.NoError,
|
||||
},
|
||||
{
|
||||
name: "empty labels",
|
||||
frame: data.NewFrame("",
|
||||
data.NewField("Time", data.Labels{}, []time.Time{}),
|
||||
data.NewField(`Values`, data.Labels{}, []float64{})),
|
||||
|
||||
seriesSlice: legacydata.DataTimeSeriesSlice{
|
||||
legacydata.DataTimeSeries{
|
||||
Name: "Values",
|
||||
Points: legacydata.DataTimeSeriesPoints{},
|
||||
},
|
||||
},
|
||||
Err: require.NoError,
|
||||
},
|
||||
{
|
||||
name: "display name from data source",
|
||||
frame: data.NewFrame("",
|
||||
data.NewField("Time", data.Labels{}, []time.Time{}),
|
||||
data.NewField(`Values`, data.Labels{"Rating": "10"}, []*int64{}).SetConfig(&data.FieldConfig{
|
||||
DisplayNameFromDS: "sloth",
|
||||
})),
|
||||
|
||||
seriesSlice: legacydata.DataTimeSeriesSlice{
|
||||
legacydata.DataTimeSeries{
|
||||
Name: "sloth",
|
||||
Points: legacydata.DataTimeSeriesPoints{},
|
||||
Tags: map[string]string{"Rating": "10"},
|
||||
},
|
||||
},
|
||||
Err: require.NoError,
|
||||
},
|
||||
{
|
||||
name: "prefer display name over data source display name",
|
||||
frame: data.NewFrame("",
|
||||
data.NewField("Time", data.Labels{}, []time.Time{}),
|
||||
data.NewField(`Values`, data.Labels{}, []*int64{}).SetConfig(&data.FieldConfig{
|
||||
DisplayName: "sloth #1",
|
||||
DisplayNameFromDS: "sloth #2",
|
||||
})),
|
||||
|
||||
seriesSlice: legacydata.DataTimeSeriesSlice{
|
||||
legacydata.DataTimeSeries{
|
||||
Name: "sloth #1",
|
||||
Points: legacydata.DataTimeSeriesPoints{},
|
||||
},
|
||||
},
|
||||
Err: require.NoError,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
seriesSlice, err := FrameToSeriesSlice(tt.frame)
|
||||
tt.Err(t, err)
|
||||
if diff := cmp.Diff(tt.seriesSlice, seriesSlice, cmpopts.EquateNaNs()); diff != "" {
|
||||
t.Errorf("Result mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
package conditions
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/null"
|
||||
"github.com/grafana/grafana/pkg/tsdb/legacydata"
|
||||
)
|
||||
|
||||
// queryReducer reduces a timeseries to a nullable float
|
||||
type queryReducer struct {
|
||||
|
||||
// Type is how the timeseries should be reduced.
|
||||
// Ex avg, sum, max, min, count
|
||||
Type string
|
||||
}
|
||||
|
||||
//nolint:gocyclo
|
||||
func (s *queryReducer) Reduce(series legacydata.DataTimeSeries) null.Float {
|
||||
if len(series.Points) == 0 {
|
||||
return null.FloatFromPtr(nil)
|
||||
}
|
||||
|
||||
value := float64(0)
|
||||
allNull := true
|
||||
|
||||
switch s.Type {
|
||||
case "avg":
|
||||
validPointsCount := 0
|
||||
for _, point := range series.Points {
|
||||
if isValid(point[0]) {
|
||||
value += point[0].Float64
|
||||
validPointsCount++
|
||||
allNull = false
|
||||
}
|
||||
}
|
||||
if validPointsCount > 0 {
|
||||
value /= float64(validPointsCount)
|
||||
}
|
||||
case "sum":
|
||||
for _, point := range series.Points {
|
||||
if isValid(point[0]) {
|
||||
value += point[0].Float64
|
||||
allNull = false
|
||||
}
|
||||
}
|
||||
case "min":
|
||||
value = math.MaxFloat64
|
||||
for _, point := range series.Points {
|
||||
if isValid(point[0]) {
|
||||
allNull = false
|
||||
if value > point[0].Float64 {
|
||||
value = point[0].Float64
|
||||
}
|
||||
}
|
||||
}
|
||||
case "max":
|
||||
value = -math.MaxFloat64
|
||||
for _, point := range series.Points {
|
||||
if isValid(point[0]) {
|
||||
allNull = false
|
||||
if value < point[0].Float64 {
|
||||
value = point[0].Float64
|
||||
}
|
||||
}
|
||||
}
|
||||
case "count":
|
||||
value = float64(len(series.Points))
|
||||
allNull = false
|
||||
case "last":
|
||||
points := series.Points
|
||||
for i := len(points) - 1; i >= 0; i-- {
|
||||
if isValid(points[i][0]) {
|
||||
value = points[i][0].Float64
|
||||
allNull = false
|
||||
break
|
||||
}
|
||||
}
|
||||
case "median":
|
||||
var values []float64
|
||||
for _, v := range series.Points {
|
||||
if isValid(v[0]) {
|
||||
allNull = false
|
||||
values = append(values, v[0].Float64)
|
||||
}
|
||||
}
|
||||
if len(values) >= 1 {
|
||||
sort.Float64s(values)
|
||||
length := len(values)
|
||||
if length%2 == 1 {
|
||||
value = values[(length-1)/2]
|
||||
} else {
|
||||
value = (values[(length/2)-1] + values[length/2]) / 2
|
||||
}
|
||||
}
|
||||
case "diff":
|
||||
allNull, value = calculateDiff(series, allNull, value, diff)
|
||||
case "diff_abs":
|
||||
allNull, value = calculateDiff(series, allNull, value, diffAbs)
|
||||
case "percent_diff":
|
||||
allNull, value = calculateDiff(series, allNull, value, percentDiff)
|
||||
case "percent_diff_abs":
|
||||
allNull, value = calculateDiff(series, allNull, value, percentDiffAbs)
|
||||
case "count_non_null":
|
||||
for _, v := range series.Points {
|
||||
if isValid(v[0]) {
|
||||
value++
|
||||
}
|
||||
}
|
||||
|
||||
if value > 0 {
|
||||
allNull = false
|
||||
}
|
||||
}
|
||||
|
||||
if allNull {
|
||||
return null.FloatFromPtr(nil)
|
||||
}
|
||||
|
||||
return null.FloatFrom(value)
|
||||
}
|
||||
|
||||
func newSimpleReducer(t string) *queryReducer {
|
||||
return &queryReducer{Type: t}
|
||||
}
|
||||
|
||||
func calculateDiff(series legacydata.DataTimeSeries, allNull bool, value float64, fn func(float64, float64) float64) (bool, float64) {
|
||||
var (
|
||||
points = series.Points
|
||||
first float64
|
||||
i int
|
||||
)
|
||||
// get the newest point
|
||||
for i = len(points) - 1; i >= 0; i-- {
|
||||
if isValid(points[i][0]) {
|
||||
allNull = false
|
||||
first = points[i][0].Float64
|
||||
break
|
||||
}
|
||||
}
|
||||
if i >= 1 {
|
||||
// get the oldest point
|
||||
points = points[0:i]
|
||||
for i := 0; i < len(points); i++ {
|
||||
if isValid(points[i][0]) {
|
||||
allNull = false
|
||||
value = fn(first, points[i][0].Float64)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return allNull, value
|
||||
}
|
||||
|
||||
func isValid(f null.Float) bool {
|
||||
return f.Valid && !math.IsNaN(f.Float64)
|
||||
}
|
||||
|
||||
var diff = func(newest, oldest float64) float64 {
|
||||
return newest - oldest
|
||||
}
|
||||
|
||||
var diffAbs = func(newest, oldest float64) float64 {
|
||||
return math.Abs(newest - oldest)
|
||||
}
|
||||
|
||||
var percentDiff = func(newest, oldest float64) float64 {
|
||||
return (newest - oldest) / math.Abs(oldest) * 100
|
||||
}
|
||||
|
||||
var percentDiffAbs = func(newest, oldest float64) float64 {
|
||||
return math.Abs((newest - oldest) / oldest * 100)
|
||||
}
|
||||
@@ -1,409 +0,0 @@
|
||||
package conditions
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/null"
|
||||
"github.com/grafana/grafana/pkg/tsdb/legacydata"
|
||||
)
|
||||
|
||||
func TestSimpleReducer(t *testing.T) {
|
||||
t.Run("sum", func(t *testing.T) {
|
||||
result := testReducer("sum", 1, 2, 3)
|
||||
require.Equal(t, float64(6), result)
|
||||
})
|
||||
|
||||
t.Run("min", func(t *testing.T) {
|
||||
result := testReducer("min", 3, 2, 1)
|
||||
require.Equal(t, float64(1), result)
|
||||
})
|
||||
|
||||
t.Run("max", func(t *testing.T) {
|
||||
result := testReducer("max", 1, 2, 3)
|
||||
require.Equal(t, float64(3), result)
|
||||
})
|
||||
|
||||
t.Run("count", func(t *testing.T) {
|
||||
result := testReducer("count", 1, 2, 3000)
|
||||
require.Equal(t, float64(3), result)
|
||||
})
|
||||
|
||||
t.Run("last", func(t *testing.T) {
|
||||
result := testReducer("last", 1, 2, 3000)
|
||||
require.Equal(t, float64(3000), result)
|
||||
})
|
||||
|
||||
t.Run("median odd amount of numbers", func(t *testing.T) {
|
||||
result := testReducer("median", 1, 2, 3000)
|
||||
require.Equal(t, float64(2), result)
|
||||
})
|
||||
|
||||
t.Run("median even amount of numbers", func(t *testing.T) {
|
||||
result := testReducer("median", 1, 2, 4, 3000)
|
||||
require.Equal(t, float64(3), result)
|
||||
})
|
||||
|
||||
t.Run("median with one values", func(t *testing.T) {
|
||||
result := testReducer("median", 1)
|
||||
require.Equal(t, float64(1), result)
|
||||
})
|
||||
|
||||
t.Run("median should ignore null values", func(t *testing.T) {
|
||||
reducer := newSimpleReducer("median")
|
||||
series := legacydata.DataTimeSeries{
|
||||
Name: "test time series",
|
||||
}
|
||||
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)})
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)})
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(3)})
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFrom(float64(1)), null.FloatFrom(4)})
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFrom(float64(2)), null.FloatFrom(5)})
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFrom(float64(3)), null.FloatFrom(6)})
|
||||
|
||||
result := reducer.Reduce(series)
|
||||
require.Equal(t, true, result.Valid)
|
||||
require.Equal(t, float64(2), result.Float64)
|
||||
})
|
||||
|
||||
t.Run("avg", func(t *testing.T) {
|
||||
result := testReducer("avg", 1, 2, 3)
|
||||
require.Equal(t, float64(2), result)
|
||||
})
|
||||
|
||||
t.Run("avg with only nulls", func(t *testing.T) {
|
||||
reducer := newSimpleReducer("avg")
|
||||
series := legacydata.DataTimeSeries{
|
||||
Name: "test time series",
|
||||
}
|
||||
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)})
|
||||
require.Equal(t, false, reducer.Reduce(series).Valid)
|
||||
})
|
||||
|
||||
t.Run("count_non_null", func(t *testing.T) {
|
||||
t.Run("with null values and real values", func(t *testing.T) {
|
||||
reducer := newSimpleReducer("count_non_null")
|
||||
series := legacydata.DataTimeSeries{
|
||||
Name: "test time series",
|
||||
}
|
||||
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)})
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)})
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFrom(3), null.FloatFrom(3)})
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFrom(3), null.FloatFrom(4)})
|
||||
|
||||
require.Equal(t, true, reducer.Reduce(series).Valid)
|
||||
require.Equal(t, 2.0, reducer.Reduce(series).Float64)
|
||||
})
|
||||
|
||||
t.Run("with null values", func(t *testing.T) {
|
||||
reducer := newSimpleReducer("count_non_null")
|
||||
series := legacydata.DataTimeSeries{
|
||||
Name: "test time series",
|
||||
}
|
||||
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)})
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)})
|
||||
|
||||
require.Equal(t, false, reducer.Reduce(series).Valid)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("avg of number values and null values should ignore nulls", func(t *testing.T) {
|
||||
reducer := newSimpleReducer("avg")
|
||||
series := legacydata.DataTimeSeries{
|
||||
Name: "test time series",
|
||||
}
|
||||
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFrom(3), null.FloatFrom(1)})
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)})
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(3)})
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFrom(3), null.FloatFrom(4)})
|
||||
|
||||
require.Equal(t, float64(3), reducer.Reduce(series).Float64)
|
||||
})
|
||||
|
||||
// diff function Test Suite
|
||||
t.Run("diff of one positive point", func(t *testing.T) {
|
||||
result := testReducer("diff", 30)
|
||||
require.Equal(t, float64(0), result)
|
||||
})
|
||||
|
||||
t.Run("diff of one negative point", func(t *testing.T) {
|
||||
result := testReducer("diff", -30)
|
||||
require.Equal(t, float64(0), result)
|
||||
})
|
||||
|
||||
t.Run("diff of two positive points[1]", func(t *testing.T) {
|
||||
result := testReducer("diff", 30, 40)
|
||||
require.Equal(t, float64(10), result)
|
||||
})
|
||||
|
||||
t.Run("diff of two positive points[2]", func(t *testing.T) {
|
||||
result := testReducer("diff", 30, 20)
|
||||
require.Equal(t, float64(-10), result)
|
||||
})
|
||||
|
||||
t.Run("diff of two negative points[1]", func(t *testing.T) {
|
||||
result := testReducer("diff", -30, -40)
|
||||
require.Equal(t, float64(-10), result)
|
||||
})
|
||||
|
||||
t.Run("diff of two negative points[2]", func(t *testing.T) {
|
||||
result := testReducer("diff", -30, -10)
|
||||
require.Equal(t, float64(20), result)
|
||||
})
|
||||
|
||||
t.Run("diff of one positive and one negative point", func(t *testing.T) {
|
||||
result := testReducer("diff", 30, -40)
|
||||
require.Equal(t, float64(-70), result)
|
||||
})
|
||||
|
||||
t.Run("diff of one negative and one positive point", func(t *testing.T) {
|
||||
result := testReducer("diff", -30, 40)
|
||||
require.Equal(t, float64(70), result)
|
||||
})
|
||||
|
||||
t.Run("diff of three positive points", func(t *testing.T) {
|
||||
result := testReducer("diff", 30, 40, 50)
|
||||
require.Equal(t, float64(20), result)
|
||||
})
|
||||
|
||||
t.Run("diff of three negative points", func(t *testing.T) {
|
||||
result := testReducer("diff", -30, -40, -50)
|
||||
require.Equal(t, float64(-20), result)
|
||||
})
|
||||
|
||||
t.Run("diff with only nulls", func(t *testing.T) {
|
||||
reducer := newSimpleReducer("diff")
|
||||
series := legacydata.DataTimeSeries{
|
||||
Name: "test time series",
|
||||
}
|
||||
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)})
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)})
|
||||
|
||||
require.Equal(t, false, reducer.Reduce(series).Valid)
|
||||
})
|
||||
|
||||
// diff_abs function Test Suite
|
||||
t.Run("diff_abs of one positive point", func(t *testing.T) {
|
||||
result := testReducer("diff_abs", 30)
|
||||
require.Equal(t, float64(0), result)
|
||||
})
|
||||
|
||||
t.Run("diff_abs of one negative point", func(t *testing.T) {
|
||||
result := testReducer("diff_abs", -30)
|
||||
require.Equal(t, float64(0), result)
|
||||
})
|
||||
|
||||
t.Run("diff_abs of two positive points[1]", func(t *testing.T) {
|
||||
result := testReducer("diff_abs", 30, 40)
|
||||
require.Equal(t, float64(10), result)
|
||||
})
|
||||
|
||||
t.Run("diff_abs of two positive points[2]", func(t *testing.T) {
|
||||
result := testReducer("diff_abs", 30, 20)
|
||||
require.Equal(t, float64(10), result)
|
||||
})
|
||||
|
||||
t.Run("diff_abs of two negative points[1]", func(t *testing.T) {
|
||||
result := testReducer("diff_abs", -30, -40)
|
||||
require.Equal(t, float64(10), result)
|
||||
})
|
||||
|
||||
t.Run("diff_abs of two negative points[2]", func(t *testing.T) {
|
||||
result := testReducer("diff_abs", -30, -10)
|
||||
require.Equal(t, float64(20), result)
|
||||
})
|
||||
|
||||
t.Run("diff_abs of one positive and one negative point", func(t *testing.T) {
|
||||
result := testReducer("diff_abs", 30, -40)
|
||||
require.Equal(t, float64(70), result)
|
||||
})
|
||||
|
||||
t.Run("diff_abs of one negative and one positive point", func(t *testing.T) {
|
||||
result := testReducer("diff_abs", -30, 40)
|
||||
require.Equal(t, float64(70), result)
|
||||
})
|
||||
|
||||
t.Run("diff_abs of three positive points", func(t *testing.T) {
|
||||
result := testReducer("diff_abs", 30, 40, 50)
|
||||
require.Equal(t, float64(20), result)
|
||||
})
|
||||
|
||||
t.Run("diff_abs of three negative points", func(t *testing.T) {
|
||||
result := testReducer("diff_abs", -30, -40, -50)
|
||||
require.Equal(t, float64(20), result)
|
||||
})
|
||||
|
||||
t.Run("diff_abs with only nulls", func(t *testing.T) {
|
||||
reducer := newSimpleReducer("diff_abs")
|
||||
series := legacydata.DataTimeSeries{
|
||||
Name: "test time series",
|
||||
}
|
||||
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)})
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)})
|
||||
|
||||
require.Equal(t, false, reducer.Reduce(series).Valid)
|
||||
})
|
||||
|
||||
// percent_diff function Test Suite
|
||||
t.Run("percent_diff of one positive point", func(t *testing.T) {
|
||||
result := testReducer("percent_diff", 30)
|
||||
require.Equal(t, float64(0), result)
|
||||
})
|
||||
|
||||
t.Run("percent_diff of one negative point", func(t *testing.T) {
|
||||
result := testReducer("percent_diff", -30)
|
||||
require.Equal(t, float64(0), result)
|
||||
})
|
||||
|
||||
t.Run("percent_diff of two positive points[1]", func(t *testing.T) {
|
||||
result := testReducer("percent_diff", 30, 40)
|
||||
require.Equal(t, float64(33.33333333333333), result)
|
||||
})
|
||||
|
||||
t.Run("percent_diff of two positive points[2]", func(t *testing.T) {
|
||||
result := testReducer("percent_diff", 30, 20)
|
||||
require.Equal(t, float64(-33.33333333333333), result)
|
||||
})
|
||||
|
||||
t.Run("percent_diff of two negative points[1]", func(t *testing.T) {
|
||||
result := testReducer("percent_diff", -30, -40)
|
||||
require.Equal(t, float64(-33.33333333333333), result)
|
||||
})
|
||||
|
||||
t.Run("percent_diff of two negative points[2]", func(t *testing.T) {
|
||||
result := testReducer("percent_diff", -30, -10)
|
||||
require.Equal(t, float64(66.66666666666666), result)
|
||||
})
|
||||
|
||||
t.Run("percent_diff of one positive and one negative point", func(t *testing.T) {
|
||||
result := testReducer("percent_diff", 30, -40)
|
||||
require.Equal(t, float64(-233.33333333333334), result)
|
||||
})
|
||||
|
||||
t.Run("percent_diff of one negative and one positive point", func(t *testing.T) {
|
||||
result := testReducer("percent_diff", -30, 40)
|
||||
require.Equal(t, float64(233.33333333333334), result)
|
||||
})
|
||||
|
||||
t.Run("percent_diff of three positive points", func(t *testing.T) {
|
||||
result := testReducer("percent_diff", 30, 40, 50)
|
||||
require.Equal(t, float64(66.66666666666666), result)
|
||||
})
|
||||
|
||||
t.Run("percent_diff of three negative points", func(t *testing.T) {
|
||||
result := testReducer("percent_diff", -30, -40, -50)
|
||||
require.Equal(t, float64(-66.66666666666666), result)
|
||||
})
|
||||
|
||||
t.Run("percent_diff with only nulls", func(t *testing.T) {
|
||||
reducer := newSimpleReducer("percent_diff")
|
||||
series := legacydata.DataTimeSeries{
|
||||
Name: "test time series",
|
||||
}
|
||||
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)})
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)})
|
||||
|
||||
require.Equal(t, false, reducer.Reduce(series).Valid)
|
||||
})
|
||||
|
||||
// percent_diff_abs function Test Suite
|
||||
t.Run("percent_diff_abs_abs of one positive point", func(t *testing.T) {
|
||||
result := testReducer("percent_diff_abs", 30)
|
||||
require.Equal(t, float64(0), result)
|
||||
})
|
||||
|
||||
t.Run("percent_diff_abs of one negative point", func(t *testing.T) {
|
||||
result := testReducer("percent_diff_abs", -30)
|
||||
require.Equal(t, float64(0), result)
|
||||
})
|
||||
|
||||
t.Run("percent_diff_abs of two positive points[1]", func(t *testing.T) {
|
||||
result := testReducer("percent_diff_abs", 30, 40)
|
||||
require.Equal(t, float64(33.33333333333333), result)
|
||||
})
|
||||
|
||||
t.Run("percent_diff_abs of two positive points[2]", func(t *testing.T) {
|
||||
result := testReducer("percent_diff_abs", 30, 20)
|
||||
require.Equal(t, float64(33.33333333333333), result)
|
||||
})
|
||||
|
||||
t.Run("percent_diff_abs of two negative points[1]", func(t *testing.T) {
|
||||
result := testReducer("percent_diff_abs", -30, -40)
|
||||
require.Equal(t, float64(33.33333333333333), result)
|
||||
})
|
||||
|
||||
t.Run("percent_diff_abs of two negative points[2]", func(t *testing.T) {
|
||||
result := testReducer("percent_diff_abs", -30, -10)
|
||||
require.Equal(t, float64(66.66666666666666), result)
|
||||
})
|
||||
|
||||
t.Run("percent_diff_abs of one positive and one negative point", func(t *testing.T) {
|
||||
result := testReducer("percent_diff_abs", 30, -40)
|
||||
require.Equal(t, float64(233.33333333333334), result)
|
||||
})
|
||||
|
||||
t.Run("percent_diff_abs of one negative and one positive point", func(t *testing.T) {
|
||||
result := testReducer("percent_diff_abs", -30, 40)
|
||||
require.Equal(t, float64(233.33333333333334), result)
|
||||
})
|
||||
|
||||
t.Run("percent_diff_abs of three positive points", func(t *testing.T) {
|
||||
result := testReducer("percent_diff_abs", 30, 40, 50)
|
||||
require.Equal(t, float64(66.66666666666666), result)
|
||||
})
|
||||
|
||||
t.Run("percent_diff_abs of three negative points", func(t *testing.T) {
|
||||
result := testReducer("percent_diff_abs", -30, -40, -50)
|
||||
require.Equal(t, float64(66.66666666666666), result)
|
||||
})
|
||||
|
||||
t.Run("percent_diff_abs with only nulls", func(t *testing.T) {
|
||||
reducer := newSimpleReducer("percent_diff_abs")
|
||||
series := legacydata.DataTimeSeries{
|
||||
Name: "test time series",
|
||||
}
|
||||
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(1)})
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFromPtr(nil), null.FloatFrom(2)})
|
||||
|
||||
require.Equal(t, false, reducer.Reduce(series).Valid)
|
||||
})
|
||||
|
||||
t.Run("min should work with NaNs", func(t *testing.T) {
|
||||
result := testReducer("min", math.NaN(), math.NaN(), math.NaN())
|
||||
require.Equal(t, float64(0), result)
|
||||
})
|
||||
|
||||
t.Run("isValid should treat NaN as invalid", func(t *testing.T) {
|
||||
result := isValid(null.FloatFrom(math.NaN()))
|
||||
require.False(t, result)
|
||||
})
|
||||
|
||||
t.Run("isValid should treat invalid null.Float as invalid", func(t *testing.T) {
|
||||
result := isValid(null.FloatFromPtr(nil))
|
||||
require.False(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func testReducer(reducerType string, datapoints ...float64) float64 {
|
||||
reducer := newSimpleReducer(reducerType)
|
||||
series := legacydata.DataTimeSeries{
|
||||
Name: "test time series",
|
||||
}
|
||||
|
||||
for idx := range datapoints {
|
||||
series.Points = append(series.Points, legacydata.DataTimePoint{null.FloatFrom(datapoints[idx]), null.FloatFrom(1234134)})
|
||||
}
|
||||
|
||||
return reducer.Reduce(series).Float64
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
package alerting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/benbjohnson/clock"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/localcache"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/infra/usagestats"
|
||||
"github.com/grafana/grafana/pkg/infra/usagestats/validator"
|
||||
"github.com/grafana/grafana/pkg/services/annotations"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/encryption"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/grafana/grafana/pkg/services/rendering"
|
||||
"github.com/grafana/grafana/pkg/services/validations"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/tsdb/legacydata"
|
||||
"github.com/grafana/grafana/pkg/util/ticker"
|
||||
)
|
||||
|
||||
// AlertEngine is the background process that
|
||||
// schedules alert evaluations and makes sure notifications
|
||||
// are sent.
|
||||
type AlertEngine struct {
|
||||
RenderService rendering.Service
|
||||
RequestValidator validations.PluginRequestValidator
|
||||
DataService legacydata.RequestHandler
|
||||
Cfg *setting.Cfg
|
||||
|
||||
execQueue chan *Job
|
||||
ticker *ticker.T
|
||||
scheduler scheduler
|
||||
evalHandler evalHandler
|
||||
ruleReader ruleReader
|
||||
log log.Logger
|
||||
resultHandler resultHandler
|
||||
usageStatsService usagestats.Service
|
||||
validator validator.Service
|
||||
tracer tracing.Tracer
|
||||
AlertStore AlertStore
|
||||
dashAlertExtractor DashAlertExtractor
|
||||
dashboardService dashboards.DashboardService
|
||||
datasourceService datasources.DataSourceService
|
||||
annotationsRepo annotations.Repository
|
||||
}
|
||||
|
||||
// IsDisabled returns true if the alerting service is disabled for this instance.
|
||||
func (e *AlertEngine) IsDisabled() bool {
|
||||
return e.Cfg.AlertingEnabled == nil || !*(e.Cfg.AlertingEnabled) || !e.Cfg.ExecuteAlerts || e.Cfg.UnifiedAlerting.IsEnabled()
|
||||
}
|
||||
|
||||
// ProvideAlertEngine returns a new AlertEngine.
|
||||
func ProvideAlertEngine(renderer rendering.Service, requestValidator validations.PluginRequestValidator,
|
||||
dataService legacydata.RequestHandler, usageStatsService usagestats.Service, validator validator.Service, encryptionService encryption.Internal,
|
||||
notificationService *notifications.NotificationService, tracer tracing.Tracer, store AlertStore, cfg *setting.Cfg,
|
||||
dashAlertExtractor DashAlertExtractor, dashboardService dashboards.DashboardService, cacheService *localcache.CacheService, dsService datasources.DataSourceService, annotationsRepo annotations.Repository) *AlertEngine {
|
||||
e := &AlertEngine{
|
||||
Cfg: cfg,
|
||||
RenderService: renderer,
|
||||
RequestValidator: requestValidator,
|
||||
DataService: dataService,
|
||||
usageStatsService: usageStatsService,
|
||||
validator: validator,
|
||||
tracer: tracer,
|
||||
AlertStore: store,
|
||||
dashAlertExtractor: dashAlertExtractor,
|
||||
dashboardService: dashboardService,
|
||||
datasourceService: dsService,
|
||||
annotationsRepo: annotationsRepo,
|
||||
}
|
||||
e.execQueue = make(chan *Job, 1000)
|
||||
e.scheduler = newScheduler(cfg)
|
||||
e.evalHandler = NewEvalHandler(e.DataService)
|
||||
e.ruleReader = newRuleReader(store)
|
||||
e.log = log.New("alerting.engine")
|
||||
e.resultHandler = newResultHandler(cfg, e.RenderService, store, notificationService, encryptionService.GetDecryptedValue)
|
||||
|
||||
e.registerUsageMetrics()
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// Run starts the alerting service background process.
|
||||
func (e *AlertEngine) Run(ctx context.Context) error {
|
||||
reg := prometheus.WrapRegistererWithPrefix("legacy_", prometheus.DefaultRegisterer)
|
||||
e.ticker = ticker.New(clock.New(), 1*time.Second, ticker.NewMetrics(reg, "alerting"))
|
||||
defer e.ticker.Stop()
|
||||
alertGroup, ctx := errgroup.WithContext(ctx)
|
||||
alertGroup.Go(func() error { return e.alertingTicker(ctx) })
|
||||
alertGroup.Go(func() error { return e.runJobDispatcher(ctx) })
|
||||
|
||||
err := alertGroup.Wait()
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *AlertEngine) alertingTicker(grafanaCtx context.Context) error {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
e.log.Error("Scheduler Panic: stopping alertingTicker", "error", err, "stack", log.Stack(1))
|
||||
}
|
||||
}()
|
||||
|
||||
tickIndex := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-grafanaCtx.Done():
|
||||
return grafanaCtx.Err()
|
||||
case tick := <-e.ticker.C:
|
||||
// TEMP SOLUTION update rules ever tenth tick
|
||||
if tickIndex%10 == 0 {
|
||||
e.scheduler.Update(e.ruleReader.fetch(grafanaCtx))
|
||||
}
|
||||
|
||||
e.scheduler.Tick(tick, e.execQueue)
|
||||
tickIndex++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *AlertEngine) runJobDispatcher(grafanaCtx context.Context) error {
|
||||
dispatcherGroup, alertCtx := errgroup.WithContext(grafanaCtx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-grafanaCtx.Done():
|
||||
return dispatcherGroup.Wait()
|
||||
case job := <-e.execQueue:
|
||||
dispatcherGroup.Go(func() error { return e.processJobWithRetry(alertCtx, job) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
unfinishedWorkTimeout = time.Second * 5
|
||||
)
|
||||
|
||||
func (e *AlertEngine) processJobWithRetry(grafanaCtx context.Context, job *Job) error {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
e.log.Error("Alert Panic", "error", err, "stack", log.Stack(1))
|
||||
}
|
||||
}()
|
||||
|
||||
cancelChan := make(chan context.CancelFunc, e.Cfg.AlertingMaxAttempts*2)
|
||||
attemptChan := make(chan int, 1)
|
||||
|
||||
// Initialize with first attemptID=1
|
||||
attemptChan <- 1
|
||||
job.SetRunning(true)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-grafanaCtx.Done():
|
||||
// In case grafana server context is cancel, let a chance to job processing
|
||||
// to finish gracefully - by waiting a timeout duration - before forcing its end.
|
||||
unfinishedWorkTimer := time.NewTimer(unfinishedWorkTimeout)
|
||||
select {
|
||||
case <-unfinishedWorkTimer.C:
|
||||
return e.endJob(grafanaCtx.Err(), cancelChan, job)
|
||||
case <-attemptChan:
|
||||
return e.endJob(nil, cancelChan, job)
|
||||
}
|
||||
case attemptID, more := <-attemptChan:
|
||||
if !more {
|
||||
return e.endJob(nil, cancelChan, job)
|
||||
}
|
||||
go e.processJob(attemptID, attemptChan, cancelChan, job)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *AlertEngine) endJob(err error, cancelChan chan context.CancelFunc, job *Job) error {
|
||||
job.SetRunning(false)
|
||||
close(cancelChan)
|
||||
for cancelFn := range cancelChan {
|
||||
cancelFn()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *AlertEngine) processJob(attemptID int, attemptChan chan int, cancelChan chan context.CancelFunc, job *Job) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
e.log.Error("Alert Panic", "error", err, "stack", log.Stack(1))
|
||||
}
|
||||
}()
|
||||
|
||||
alertCtx, cancelFn := context.WithTimeout(context.Background(), e.Cfg.AlertingEvaluationTimeout)
|
||||
cancelChan <- cancelFn
|
||||
alertCtx, span := e.tracer.Start(alertCtx, "alert execution")
|
||||
evalContext := NewEvalContext(alertCtx, job.Rule, e.RequestValidator, e.AlertStore, e.dashboardService, e.datasourceService, e.annotationsRepo)
|
||||
evalContext.Ctx = alertCtx
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
e.log.Error("Alert Panic", "error", err, "stack", log.Stack(1))
|
||||
span.SetStatus(codes.Error, "failed to execute alert rule. panic was recovered.")
|
||||
span.RecordError(fmt.Errorf("%v", err))
|
||||
span.End()
|
||||
close(attemptChan)
|
||||
}
|
||||
}()
|
||||
|
||||
e.evalHandler.Eval(evalContext)
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.Int64("alertId", evalContext.Rule.ID),
|
||||
attribute.Int64("dashboardId", evalContext.Rule.DashboardID),
|
||||
attribute.Bool("firing", evalContext.Firing),
|
||||
attribute.Bool("nodatapoints", evalContext.NoDataFound),
|
||||
attribute.Int("attemptID", attemptID),
|
||||
)
|
||||
|
||||
if evalContext.Error != nil {
|
||||
span.SetStatus(codes.Error, "alerting execution attempt failed")
|
||||
span.RecordError(evalContext.Error)
|
||||
|
||||
if attemptID < e.Cfg.AlertingMaxAttempts {
|
||||
span.End()
|
||||
e.log.Debug("Job Execution attempt triggered retry", "timeMs", evalContext.GetDurationMs(), "alertId", evalContext.Rule.ID, "name", evalContext.Rule.Name, "firing", evalContext.Firing, "attemptID", attemptID)
|
||||
attemptChan <- (attemptID + 1)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// create new context with timeout for notifications
|
||||
resultHandleCtx, resultHandleCancelFn := context.WithTimeout(context.Background(), e.Cfg.AlertingNotificationTimeout)
|
||||
cancelChan <- resultHandleCancelFn
|
||||
|
||||
// override the context used for evaluation with a new context for notifications.
|
||||
// This makes it possible for notifiers to execute when datasources
|
||||
// don't respond within the timeout limit. We should rewrite this so notifications
|
||||
// don't reuse the evalContext and get its own context.
|
||||
evalContext.Ctx = resultHandleCtx
|
||||
evalContext.Rule.State = evalContext.GetNewState()
|
||||
if err := e.resultHandler.handle(evalContext); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled):
|
||||
e.log.Debug("Result handler returned context.Canceled")
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
e.log.Debug("Result handler returned context.DeadlineExceeded")
|
||||
default:
|
||||
e.log.Error("Failed to handle result", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
span.End()
|
||||
e.log.Debug("Job Execution completed", "timeMs", evalContext.GetDurationMs(), "alertId", evalContext.Rule.ID, "name", evalContext.Rule.Name, "firing", evalContext.Firing, "attemptID", attemptID)
|
||||
close(attemptChan)
|
||||
}()
|
||||
}
|
||||
|
||||
func (e *AlertEngine) registerUsageMetrics() {
|
||||
e.usageStatsService.RegisterMetricsFunc(func(ctx context.Context) (map[string]interface{}, error) {
|
||||
alertingUsageStats, err := e.QueryUsageStats(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
alertingOtherCount := 0
|
||||
metrics := map[string]interface{}{}
|
||||
|
||||
for dsType, usageCount := range alertingUsageStats.DatasourceUsage {
|
||||
if e.validator.ShouldBeReported(ctx, dsType) {
|
||||
metrics[fmt.Sprintf("stats.alerting.ds.%s.count", dsType)] = usageCount
|
||||
} else {
|
||||
alertingOtherCount += usageCount
|
||||
}
|
||||
}
|
||||
|
||||
metrics["stats.alerting.ds.other.count"] = alertingOtherCount
|
||||
|
||||
return metrics, nil
|
||||
})
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
package alerting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/localcache"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/infra/usagestats"
|
||||
"github.com/grafana/grafana/pkg/infra/usagestats/validator"
|
||||
"github.com/grafana/grafana/pkg/services/annotations/annotationstest"
|
||||
datasources "github.com/grafana/grafana/pkg/services/datasources/fakes"
|
||||
encryptionprovider "github.com/grafana/grafana/pkg/services/encryption/provider"
|
||||
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func TestIntegrationEngineTimeouts(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
usMock := &usagestats.UsageStatsMock{T: t}
|
||||
usValidatorMock := &validator.FakeUsageStatsValidator{}
|
||||
|
||||
encProvider := encryptionprovider.ProvideEncryptionProvider()
|
||||
encService, err := encryptionservice.ProvideEncryptionService(encProvider, usMock, setting.NewCfg())
|
||||
require.NoError(t, err)
|
||||
|
||||
tracer := tracing.InitializeTracerForTest()
|
||||
dsMock := &datasources.FakeDataSourceService{}
|
||||
annotationsRepo := annotationstest.NewFakeAnnotationsRepo()
|
||||
cfg := setting.NewCfg()
|
||||
engine := ProvideAlertEngine(nil, nil, nil, usMock, usValidatorMock, encService, nil, tracer, nil, cfg, nil, nil, localcache.New(time.Minute, time.Minute), dsMock, annotationsRepo)
|
||||
cfg.AlertingNotificationTimeout = 30 * time.Second
|
||||
cfg.AlertingMaxAttempts = 3
|
||||
engine.resultHandler = &FakeResultHandler{}
|
||||
job := &Job{running: true, Rule: &Rule{}}
|
||||
|
||||
t.Run("Should trigger as many retries as needed", func(t *testing.T) {
|
||||
t.Run("pended alert for datasource -> result handler should be worked", func(t *testing.T) {
|
||||
// reduce alert timeout to test quickly
|
||||
cfg.AlertingEvaluationTimeout = 30 * time.Second
|
||||
transportTimeoutInterval := 2 * time.Second
|
||||
serverBusySleepDuration := 1 * time.Second
|
||||
|
||||
evalHandler := NewFakeCommonTimeoutHandler(transportTimeoutInterval, serverBusySleepDuration)
|
||||
resultHandler := NewFakeCommonTimeoutHandler(transportTimeoutInterval, serverBusySleepDuration)
|
||||
engine.evalHandler = evalHandler
|
||||
engine.resultHandler = resultHandler
|
||||
|
||||
err := engine.processJobWithRetry(context.Background(), job)
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, true, evalHandler.EvalSucceed)
|
||||
require.Equal(t, true, resultHandler.ResultHandleSucceed)
|
||||
|
||||
// initialize for other tests.
|
||||
cfg.AlertingEvaluationTimeout = 2 * time.Second
|
||||
engine.resultHandler = &FakeResultHandler{}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
type FakeCommonTimeoutHandler struct {
|
||||
TransportTimeoutDuration time.Duration
|
||||
ServerBusySleepDuration time.Duration
|
||||
EvalSucceed bool
|
||||
ResultHandleSucceed bool
|
||||
}
|
||||
|
||||
func NewFakeCommonTimeoutHandler(transportTimeoutDuration time.Duration, serverBusySleepDuration time.Duration) *FakeCommonTimeoutHandler {
|
||||
return &FakeCommonTimeoutHandler{
|
||||
TransportTimeoutDuration: transportTimeoutDuration,
|
||||
ServerBusySleepDuration: serverBusySleepDuration,
|
||||
EvalSucceed: false,
|
||||
ResultHandleSucceed: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (handler *FakeCommonTimeoutHandler) Eval(evalContext *EvalContext) {
|
||||
// 1. prepare mock server
|
||||
path := "/evaltimeout"
|
||||
srv := runBusyServer(path, handler.ServerBusySleepDuration)
|
||||
defer srv.Close()
|
||||
|
||||
// 2. send requests
|
||||
url := srv.URL + path
|
||||
res, err := sendRequest(evalContext.Ctx, url, handler.TransportTimeoutDuration)
|
||||
if res != nil {
|
||||
defer func() {
|
||||
if err := res.Body.Close(); err != nil {
|
||||
logger.Warn("Error", "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
evalContext.Error = errors.New("Fake evaluation timeout test failure")
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode == 200 {
|
||||
handler.EvalSucceed = true
|
||||
}
|
||||
|
||||
evalContext.Error = errors.New("Fake evaluation timeout test failure; wrong response")
|
||||
}
|
||||
|
||||
func (handler *FakeCommonTimeoutHandler) handle(evalContext *EvalContext) error {
|
||||
// 1. prepare mock server
|
||||
path := "/resulthandle"
|
||||
srv := runBusyServer(path, handler.ServerBusySleepDuration)
|
||||
defer srv.Close()
|
||||
|
||||
// 2. send requests
|
||||
url := srv.URL + path
|
||||
res, err := sendRequest(evalContext.Ctx, url, handler.TransportTimeoutDuration)
|
||||
if res != nil {
|
||||
defer func() {
|
||||
if err := res.Body.Close(); err != nil {
|
||||
logger.Warn("Error", "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
evalContext.Error = errors.New("Fake result handle timeout test failure")
|
||||
return evalContext.Error
|
||||
}
|
||||
|
||||
if res.StatusCode == 200 {
|
||||
handler.ResultHandleSucceed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
evalContext.Error = errors.New("Fake result handle timeout test failure; wrong response")
|
||||
|
||||
return evalContext.Error
|
||||
}
|
||||
|
||||
func runBusyServer(path string, serverBusySleepDuration time.Duration) *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
server := httptest.NewServer(mux)
|
||||
|
||||
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(serverBusySleepDuration)
|
||||
})
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
func sendRequest(context context.Context, url string, transportTimeoutInterval time.Duration) (resp *http.Response, err error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req = req.WithContext(context)
|
||||
|
||||
transport := http.Transport{
|
||||
Dial: (&net.Dialer{
|
||||
Timeout: transportTimeoutInterval,
|
||||
KeepAlive: transportTimeoutInterval,
|
||||
}).Dial,
|
||||
}
|
||||
client := http.Client{
|
||||
Transport: &transport,
|
||||
}
|
||||
|
||||
return client.Do(req)
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
package alerting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/localcache"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/infra/usagestats"
|
||||
"github.com/grafana/grafana/pkg/infra/usagestats/validator"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/annotations/annotationstest"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
fd "github.com/grafana/grafana/pkg/services/datasources/fakes"
|
||||
encryptionprovider "github.com/grafana/grafana/pkg/services/encryption/provider"
|
||||
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
type FakeEvalHandler struct {
|
||||
SuccessCallID int // 0 means never success
|
||||
CallNb int
|
||||
}
|
||||
|
||||
func NewFakeEvalHandler(successCallID int) *FakeEvalHandler {
|
||||
return &FakeEvalHandler{
|
||||
SuccessCallID: successCallID,
|
||||
CallNb: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (handler *FakeEvalHandler) Eval(evalContext *EvalContext) {
|
||||
handler.CallNb++
|
||||
if handler.CallNb != handler.SuccessCallID {
|
||||
evalContext.Error = errors.New("Fake evaluation failure")
|
||||
}
|
||||
}
|
||||
|
||||
type FakeResultHandler struct{}
|
||||
|
||||
func (handler *FakeResultHandler) handle(evalContext *EvalContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// A mock implementation of the AlertStore interface, allowing to override certain methods individually
|
||||
type AlertStoreMock struct {
|
||||
getAllAlerts func(context.Context, *models.GetAllAlertsQuery) ([]*models.Alert, error)
|
||||
getAlertNotificationsWithUidToSend func(ctx context.Context, query *models.GetAlertNotificationsWithUidToSendQuery) ([]*models.AlertNotification, error)
|
||||
getOrCreateNotificationState func(ctx context.Context, query *models.GetOrCreateNotificationStateQuery) (*models.AlertNotificationState, error)
|
||||
}
|
||||
|
||||
func (a *AlertStoreMock) GetAlertById(c context.Context, cmd *models.GetAlertByIdQuery) (res *models.Alert, err error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (a *AlertStoreMock) GetAllAlertQueryHandler(c context.Context, cmd *models.GetAllAlertsQuery) (res []*models.Alert, err error) {
|
||||
if a.getAllAlerts != nil {
|
||||
return a.getAllAlerts(c, cmd)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (a *AlertStoreMock) GetAlertNotificationUidWithId(c context.Context, query *models.GetAlertNotificationUidQuery) (res string, err error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (a *AlertStoreMock) GetAlertNotificationsWithUidToSend(c context.Context, cmd *models.GetAlertNotificationsWithUidToSendQuery) (res []*models.AlertNotification, err error) {
|
||||
if a.getAlertNotificationsWithUidToSend != nil {
|
||||
return a.getAlertNotificationsWithUidToSend(c, cmd)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (a *AlertStoreMock) GetOrCreateAlertNotificationState(c context.Context, cmd *models.GetOrCreateNotificationStateQuery) (res *models.AlertNotificationState, err error) {
|
||||
if a.getOrCreateNotificationState != nil {
|
||||
return a.getOrCreateNotificationState(c, cmd)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (a *AlertStoreMock) GetDashboardUIDById(_ context.Context, _ *dashboards.GetDashboardRefByIDQuery) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AlertStoreMock) SetAlertNotificationStateToCompleteCommand(_ context.Context, _ *models.SetAlertNotificationStateToCompleteCommand) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AlertStoreMock) SetAlertNotificationStateToPendingCommand(_ context.Context, _ *models.SetAlertNotificationStateToPendingCommand) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AlertStoreMock) SetAlertState(_ context.Context, _ *models.SetAlertStateCommand) (res models.Alert, err error) {
|
||||
return models.Alert{}, nil
|
||||
}
|
||||
|
||||
func (a *AlertStoreMock) GetAlertStatesForDashboard(_ context.Context, _ *models.GetAlertStatesForDashboardQuery) (res []*models.AlertStateInfoDTO, err error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (a *AlertStoreMock) HandleAlertsQuery(context.Context, *models.GetAlertsQuery) (res []*models.AlertListItemDTO, err error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (a *AlertStoreMock) PauseAlert(context.Context, *models.PauseAlertCommand) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AlertStoreMock) PauseAllAlerts(context.Context, *models.PauseAllAlertCommand) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestEngineProcessJob(t *testing.T) {
|
||||
usMock := &usagestats.UsageStatsMock{T: t}
|
||||
usValidatorMock := &validator.FakeUsageStatsValidator{}
|
||||
|
||||
encProvider := encryptionprovider.ProvideEncryptionProvider()
|
||||
cfg := setting.NewCfg()
|
||||
encService, err := encryptionservice.ProvideEncryptionService(encProvider, usMock, cfg)
|
||||
require.NoError(t, err)
|
||||
tracer := tracing.InitializeTracerForTest()
|
||||
|
||||
store := &AlertStoreMock{}
|
||||
dsMock := &fd.FakeDataSourceService{
|
||||
DataSources: []*datasources.DataSource{{ID: 1, Type: datasources.DS_PROMETHEUS}},
|
||||
}
|
||||
engine := ProvideAlertEngine(nil, nil, nil, usMock, usValidatorMock, encService, nil, tracer, store, cfg, nil, nil, localcache.New(time.Minute, time.Minute), dsMock, annotationstest.NewFakeAnnotationsRepo())
|
||||
cfg.AlertingEvaluationTimeout = 30 * time.Second
|
||||
cfg.AlertingNotificationTimeout = 30 * time.Second
|
||||
cfg.AlertingMaxAttempts = 3
|
||||
engine.resultHandler = &FakeResultHandler{}
|
||||
job := &Job{running: true, Rule: &Rule{}}
|
||||
|
||||
t.Run("Should register usage metrics func", func(t *testing.T) {
|
||||
store.getAllAlerts = func(ctx context.Context, q *models.GetAllAlertsQuery) (res []*models.Alert, err error) {
|
||||
settings, err := simplejson.NewJson([]byte(`{"conditions": [{"query": { "datasourceId": 1}}]}`))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []*models.Alert{{Settings: settings}}, nil
|
||||
}
|
||||
|
||||
report, err := usMock.GetUsageReport(context.Background())
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Equal(t, 1, report.Metrics["stats.alerting.ds.prometheus.count"])
|
||||
require.Equal(t, 0, report.Metrics["stats.alerting.ds.other.count"])
|
||||
})
|
||||
|
||||
t.Run("Should trigger retry if needed", func(t *testing.T) {
|
||||
t.Run("error + not last attempt -> retry", func(t *testing.T) {
|
||||
engine.evalHandler = NewFakeEvalHandler(0)
|
||||
|
||||
for i := 1; i < cfg.AlertingMaxAttempts; i++ {
|
||||
attemptChan := make(chan int, 1)
|
||||
cancelChan := make(chan context.CancelFunc, cfg.AlertingMaxAttempts)
|
||||
|
||||
engine.processJob(i, attemptChan, cancelChan, job)
|
||||
nextAttemptID, more := <-attemptChan
|
||||
|
||||
require.Equal(t, i+1, nextAttemptID)
|
||||
require.Equal(t, true, more)
|
||||
require.NotNil(t, <-cancelChan)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error + last attempt -> no retry", func(t *testing.T) {
|
||||
engine.evalHandler = NewFakeEvalHandler(0)
|
||||
attemptChan := make(chan int, 1)
|
||||
cancelChan := make(chan context.CancelFunc, cfg.AlertingMaxAttempts)
|
||||
|
||||
engine.processJob(cfg.AlertingMaxAttempts, attemptChan, cancelChan, job)
|
||||
nextAttemptID, more := <-attemptChan
|
||||
|
||||
require.Equal(t, 0, nextAttemptID)
|
||||
require.Equal(t, false, more)
|
||||
require.NotNil(t, <-cancelChan)
|
||||
})
|
||||
|
||||
t.Run("no error -> no retry", func(t *testing.T) {
|
||||
engine.evalHandler = NewFakeEvalHandler(1)
|
||||
attemptChan := make(chan int, 1)
|
||||
cancelChan := make(chan context.CancelFunc, cfg.AlertingMaxAttempts)
|
||||
|
||||
engine.processJob(1, attemptChan, cancelChan, job)
|
||||
nextAttemptID, more := <-attemptChan
|
||||
|
||||
require.Equal(t, 0, nextAttemptID)
|
||||
require.Equal(t, false, more)
|
||||
require.NotNil(t, <-cancelChan)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Should trigger as many retries as needed", func(t *testing.T) {
|
||||
t.Run("never success -> max retries number", func(t *testing.T) {
|
||||
expectedAttempts := cfg.AlertingMaxAttempts
|
||||
evalHandler := NewFakeEvalHandler(0)
|
||||
engine.evalHandler = evalHandler
|
||||
|
||||
err := engine.processJobWithRetry(context.Background(), job)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, expectedAttempts, evalHandler.CallNb)
|
||||
})
|
||||
|
||||
t.Run("always success -> never retry", func(t *testing.T) {
|
||||
expectedAttempts := 1
|
||||
evalHandler := NewFakeEvalHandler(1)
|
||||
engine.evalHandler = evalHandler
|
||||
|
||||
err := engine.processJobWithRetry(context.Background(), job)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, expectedAttempts, evalHandler.CallNb)
|
||||
})
|
||||
|
||||
t.Run("some errors before success -> some retries", func(t *testing.T) {
|
||||
expectedAttempts := int(math.Ceil(float64(cfg.AlertingMaxAttempts) / 2))
|
||||
evalHandler := NewFakeEvalHandler(expectedAttempts)
|
||||
engine.evalHandler = evalHandler
|
||||
|
||||
err := engine.processJobWithRetry(context.Background(), job)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, expectedAttempts, evalHandler.CallNb)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
package alerting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
alertmodels "github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/annotations"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/validations"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
// EvalContext is the context object for an alert evaluation.
|
||||
type EvalContext struct {
|
||||
Firing bool
|
||||
IsTestRun bool
|
||||
IsDebug bool
|
||||
EvalMatches []*EvalMatch
|
||||
AllMatches []*EvalMatch
|
||||
Logs []*ResultLogEntry
|
||||
Error error
|
||||
ConditionEvals string
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
Rule *Rule
|
||||
Log log.Logger
|
||||
|
||||
dashboardRef *dashboards.DashboardRef
|
||||
|
||||
ImagePublicURL string
|
||||
ImageOnDiskPath string
|
||||
NoDataFound bool
|
||||
PrevAlertState alertmodels.AlertStateType
|
||||
|
||||
RequestValidator validations.PluginRequestValidator
|
||||
|
||||
Ctx context.Context
|
||||
|
||||
Store AlertStore
|
||||
dashboardService dashboards.DashboardService
|
||||
DatasourceService datasources.DataSourceService
|
||||
annotationRepo annotations.Repository
|
||||
}
|
||||
|
||||
// NewEvalContext is the EvalContext constructor.
|
||||
func NewEvalContext(alertCtx context.Context, rule *Rule, requestValidator validations.PluginRequestValidator,
|
||||
alertStore AlertStore, dashboardService dashboards.DashboardService, dsService datasources.DataSourceService, annotationRepo annotations.Repository) *EvalContext {
|
||||
return &EvalContext{
|
||||
Ctx: alertCtx,
|
||||
StartTime: time.Now(),
|
||||
Rule: rule,
|
||||
Logs: make([]*ResultLogEntry, 0),
|
||||
EvalMatches: make([]*EvalMatch, 0),
|
||||
AllMatches: make([]*EvalMatch, 0),
|
||||
Log: log.New("alerting.evalContext"),
|
||||
PrevAlertState: rule.State,
|
||||
RequestValidator: requestValidator,
|
||||
Store: alertStore,
|
||||
dashboardService: dashboardService,
|
||||
DatasourceService: dsService,
|
||||
annotationRepo: annotationRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// StateDescription contains visual information about the alert state.
|
||||
type StateDescription struct {
|
||||
Color string
|
||||
Text string
|
||||
Data string
|
||||
}
|
||||
|
||||
// GetStateModel returns the `StateDescription` based on current state.
|
||||
func (c *EvalContext) GetStateModel() *StateDescription {
|
||||
switch c.Rule.State {
|
||||
case alertmodels.AlertStateOK:
|
||||
return &StateDescription{
|
||||
Color: "#36a64f",
|
||||
Text: "OK",
|
||||
}
|
||||
case alertmodels.AlertStateNoData:
|
||||
return &StateDescription{
|
||||
Color: "#888888",
|
||||
Text: "No Data",
|
||||
}
|
||||
case alertmodels.AlertStateAlerting:
|
||||
return &StateDescription{
|
||||
Color: "#D63232",
|
||||
Text: "Alerting",
|
||||
}
|
||||
case alertmodels.AlertStateUnknown:
|
||||
return &StateDescription{
|
||||
Color: "#888888",
|
||||
Text: "Unknown",
|
||||
}
|
||||
default:
|
||||
panic("Unknown rule state for alert " + c.Rule.State)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *EvalContext) shouldUpdateAlertState() bool {
|
||||
return c.Rule.State != c.PrevAlertState
|
||||
}
|
||||
|
||||
// GetDurationMs returns the duration of the alert evaluation.
|
||||
func (c *EvalContext) GetDurationMs() float64 {
|
||||
return float64(c.EndTime.Sub(c.StartTime).Nanoseconds()) / float64(time.Millisecond)
|
||||
}
|
||||
|
||||
// GetNotificationTitle returns the title of the alert rule including alert state.
|
||||
func (c *EvalContext) GetNotificationTitle() string {
|
||||
return "[" + c.GetStateModel().Text + "] " + c.Rule.Name
|
||||
}
|
||||
|
||||
// GetDashboardUID returns the dashboard uid for the alert rule.
|
||||
func (c *EvalContext) GetDashboardUID() (*dashboards.DashboardRef, error) {
|
||||
if c.dashboardRef != nil {
|
||||
return c.dashboardRef, nil
|
||||
}
|
||||
|
||||
uidQuery := &dashboards.GetDashboardRefByIDQuery{ID: c.Rule.DashboardID}
|
||||
uidQueryResult, err := c.dashboardService.GetDashboardUIDByID(c.Ctx, uidQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.dashboardRef = uidQueryResult
|
||||
return c.dashboardRef, nil
|
||||
}
|
||||
|
||||
const urlFormat = "%s?tab=alert&viewPanel=%d&orgId=%d"
|
||||
|
||||
// GetRuleURL returns the url to the dashboard containing the alert.
|
||||
func (c *EvalContext) GetRuleURL() (string, error) {
|
||||
if c.IsTestRun {
|
||||
return setting.AppUrl, nil
|
||||
}
|
||||
|
||||
ref, err := c.GetDashboardUID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf(urlFormat, dashboards.GetFullDashboardURL(ref.UID, ref.Slug), c.Rule.PanelID, c.Rule.OrgID), nil
|
||||
}
|
||||
|
||||
// GetNewState returns the new state from the alert rule evaluation.
|
||||
func (c *EvalContext) GetNewState() alertmodels.AlertStateType {
|
||||
ns := getNewStateInternal(c)
|
||||
if ns != alertmodels.AlertStateAlerting || c.Rule.For == 0 {
|
||||
return ns
|
||||
}
|
||||
|
||||
since := time.Since(c.Rule.LastStateChange)
|
||||
if c.PrevAlertState == alertmodels.AlertStatePending && since > c.Rule.For {
|
||||
return alertmodels.AlertStateAlerting
|
||||
}
|
||||
|
||||
if c.PrevAlertState == alertmodels.AlertStateAlerting {
|
||||
return alertmodels.AlertStateAlerting
|
||||
}
|
||||
|
||||
return alertmodels.AlertStatePending
|
||||
}
|
||||
|
||||
func getNewStateInternal(c *EvalContext) alertmodels.AlertStateType {
|
||||
if c.Error != nil {
|
||||
c.Log.Error("Alert Rule Result Error",
|
||||
"ruleId", c.Rule.ID,
|
||||
"name", c.Rule.Name,
|
||||
"error", c.Error,
|
||||
"changing state to", c.Rule.ExecutionErrorState.ToAlertState())
|
||||
|
||||
if c.Rule.ExecutionErrorState == alertmodels.ExecutionErrorKeepState {
|
||||
return c.PrevAlertState
|
||||
}
|
||||
return c.Rule.ExecutionErrorState.ToAlertState()
|
||||
}
|
||||
|
||||
if c.Firing {
|
||||
return alertmodels.AlertStateAlerting
|
||||
}
|
||||
|
||||
if c.NoDataFound {
|
||||
c.Log.Info("Alert Rule returned no data",
|
||||
"ruleId", c.Rule.ID,
|
||||
"name", c.Rule.Name,
|
||||
"changing state to", c.Rule.NoDataState.ToAlertState())
|
||||
|
||||
if c.Rule.NoDataState == alertmodels.NoDataKeepState {
|
||||
return c.PrevAlertState
|
||||
}
|
||||
return c.Rule.NoDataState.ToAlertState()
|
||||
}
|
||||
|
||||
return alertmodels.AlertStateOK
|
||||
}
|
||||
|
||||
// evaluateNotificationTemplateFields will treat the alert evaluation rule's name and message fields as
|
||||
// templates, and evaluate the templates using data from the alert evaluation's tags
|
||||
func (c *EvalContext) evaluateNotificationTemplateFields() error {
|
||||
matches := c.getTemplateMatches()
|
||||
if len(matches) < 1 {
|
||||
// if there are no series to parse the templates with, return
|
||||
return nil
|
||||
}
|
||||
|
||||
templateDataMap, err := buildTemplateDataMap(matches)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ruleMsg, err := evaluateTemplate(c.Rule.Message, templateDataMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Rule.Message = ruleMsg
|
||||
|
||||
ruleName, err := evaluateTemplate(c.Rule.Name, templateDataMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Rule.Name = ruleName
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *EvalContext) GetDataSource(ctx context.Context, q *datasources.GetDataSourceQuery) (*datasources.DataSource, error) {
|
||||
return c.DatasourceService.GetDataSource(ctx, q)
|
||||
}
|
||||
|
||||
// getTemplateMatches returns the values we should use to parse the templates
|
||||
func (c *EvalContext) getTemplateMatches() []*EvalMatch {
|
||||
// EvalMatches represent series violating the rule threshold,
|
||||
// if we have any, this means the alert is firing and we should use this data to parse the templates.
|
||||
if len(c.EvalMatches) > 0 {
|
||||
return c.EvalMatches
|
||||
}
|
||||
|
||||
// If we don't have any alerting values, use all values to parse the templates.
|
||||
return c.AllMatches
|
||||
}
|
||||
|
||||
func evaluateTemplate(s string, m map[string]string) (string, error) {
|
||||
for k, v := range m {
|
||||
re, err := regexp.Compile(fmt.Sprintf(`\${%s}`, regexp.QuoteMeta(k)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s = re.ReplaceAllString(s, v)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// buildTemplateDataMap builds a map of alert evaluation tag names to a set of associated values (comma separated)
|
||||
func buildTemplateDataMap(evalMatches []*EvalMatch) (map[string]string, error) {
|
||||
var result = map[string]string{}
|
||||
for _, match := range evalMatches {
|
||||
for tagName, tagValue := range match.Tags {
|
||||
// skip duplicate values
|
||||
rVal, err := regexp.Compile(fmt.Sprintf(`\b%s\b`, regexp.QuoteMeta(tagValue)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rMatch := rVal.FindString(result[tagName])
|
||||
if len(rMatch) > 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := result[tagName]; exists {
|
||||
result[tagName] = fmt.Sprintf("%s, %s", result[tagName], tagValue)
|
||||
} else {
|
||||
result[tagName] = tagValue
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
package alerting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/annotations/annotationstest"
|
||||
"github.com/grafana/grafana/pkg/services/validations"
|
||||
)
|
||||
|
||||
func TestStateIsUpdatedWhenNeeded(t *testing.T) {
|
||||
ctx := NewEvalContext(context.Background(), &Rule{Conditions: []Condition{&conditionStub{firing: true}}}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
|
||||
t.Run("ok -> alerting", func(t *testing.T) {
|
||||
ctx.PrevAlertState = models.AlertStateOK
|
||||
ctx.Rule.State = models.AlertStateAlerting
|
||||
|
||||
if !ctx.shouldUpdateAlertState() {
|
||||
t.Fatalf("expected should updated to be true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ok -> ok", func(t *testing.T) {
|
||||
ctx.PrevAlertState = models.AlertStateOK
|
||||
ctx.Rule.State = models.AlertStateOK
|
||||
|
||||
if ctx.shouldUpdateAlertState() {
|
||||
t.Fatalf("expected should updated to be false")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetStateFromEvalContext(t *testing.T) {
|
||||
tcs := []struct {
|
||||
name string
|
||||
expected models.AlertStateType
|
||||
applyFn func(ec *EvalContext)
|
||||
}{
|
||||
{
|
||||
name: "ok -> alerting",
|
||||
expected: models.AlertStateAlerting,
|
||||
applyFn: func(ec *EvalContext) {
|
||||
ec.Firing = true
|
||||
ec.PrevAlertState = models.AlertStateOK
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ok -> error(alerting)",
|
||||
expected: models.AlertStateAlerting,
|
||||
applyFn: func(ec *EvalContext) {
|
||||
ec.PrevAlertState = models.AlertStateOK
|
||||
ec.Error = errors.New("test error")
|
||||
ec.Rule.ExecutionErrorState = models.ExecutionErrorSetAlerting
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ok -> pending. since its been firing for less than FOR",
|
||||
expected: models.AlertStatePending,
|
||||
applyFn: func(ec *EvalContext) {
|
||||
ec.PrevAlertState = models.AlertStateOK
|
||||
ec.Firing = true
|
||||
ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 2)
|
||||
ec.Rule.For = time.Minute * 5
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ok -> pending. since it has to be pending longer than FOR and prev state is ok",
|
||||
expected: models.AlertStatePending,
|
||||
applyFn: func(ec *EvalContext) {
|
||||
ec.PrevAlertState = models.AlertStateOK
|
||||
ec.Firing = true
|
||||
ec.Rule.LastStateChange = time.Now().Add(-(time.Hour * 5))
|
||||
ec.Rule.For = time.Minute * 2
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pending -> alerting. since its been firing for more than FOR and prev state is pending",
|
||||
expected: models.AlertStateAlerting,
|
||||
applyFn: func(ec *EvalContext) {
|
||||
ec.PrevAlertState = models.AlertStatePending
|
||||
ec.Firing = true
|
||||
ec.Rule.LastStateChange = time.Now().Add(-(time.Hour * 5))
|
||||
ec.Rule.For = time.Minute * 2
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "alerting -> alerting. should not update regardless of FOR",
|
||||
expected: models.AlertStateAlerting,
|
||||
applyFn: func(ec *EvalContext) {
|
||||
ec.PrevAlertState = models.AlertStatePending
|
||||
ec.Firing = true
|
||||
ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 5)
|
||||
ec.Rule.For = time.Minute * 2
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ok -> ok. should not update regardless of FOR",
|
||||
expected: models.AlertStateOK,
|
||||
applyFn: func(ec *EvalContext) {
|
||||
ec.PrevAlertState = models.AlertStateOK
|
||||
ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 5)
|
||||
ec.Rule.For = time.Minute * 2
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ok -> error(keep_last)",
|
||||
expected: models.AlertStateOK,
|
||||
applyFn: func(ec *EvalContext) {
|
||||
ec.PrevAlertState = models.AlertStateOK
|
||||
ec.Error = errors.New("test error")
|
||||
ec.Rule.ExecutionErrorState = models.ExecutionErrorKeepState
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pending -> error(keep_last)",
|
||||
expected: models.AlertStatePending,
|
||||
applyFn: func(ec *EvalContext) {
|
||||
ec.PrevAlertState = models.AlertStatePending
|
||||
ec.Error = errors.New("test error")
|
||||
ec.Rule.ExecutionErrorState = models.ExecutionErrorKeepState
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ok -> no_data(alerting)",
|
||||
expected: models.AlertStateAlerting,
|
||||
applyFn: func(ec *EvalContext) {
|
||||
ec.PrevAlertState = models.AlertStateOK
|
||||
ec.Rule.NoDataState = models.NoDataSetAlerting
|
||||
ec.NoDataFound = true
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ok -> no_data(keep_last)",
|
||||
expected: models.AlertStateOK,
|
||||
applyFn: func(ec *EvalContext) {
|
||||
ec.PrevAlertState = models.AlertStateOK
|
||||
ec.Rule.NoDataState = models.NoDataKeepState
|
||||
ec.NoDataFound = true
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pending -> no_data(keep_last)",
|
||||
expected: models.AlertStatePending,
|
||||
applyFn: func(ec *EvalContext) {
|
||||
ec.PrevAlertState = models.AlertStatePending
|
||||
ec.Rule.NoDataState = models.NoDataKeepState
|
||||
ec.NoDataFound = true
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pending -> no_data(alerting) with for duration have not passed",
|
||||
expected: models.AlertStatePending,
|
||||
applyFn: func(ec *EvalContext) {
|
||||
ec.PrevAlertState = models.AlertStatePending
|
||||
ec.Rule.NoDataState = models.NoDataSetAlerting
|
||||
ec.NoDataFound = true
|
||||
ec.Rule.For = time.Minute * 5
|
||||
ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 2)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pending -> no_data(alerting) should set alerting since time passed FOR",
|
||||
expected: models.AlertStateAlerting,
|
||||
applyFn: func(ec *EvalContext) {
|
||||
ec.PrevAlertState = models.AlertStatePending
|
||||
ec.Rule.NoDataState = models.NoDataSetAlerting
|
||||
ec.NoDataFound = true
|
||||
ec.Rule.For = time.Minute * 2
|
||||
ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 5)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pending -> error(alerting) with for duration have not passed ",
|
||||
expected: models.AlertStatePending,
|
||||
applyFn: func(ec *EvalContext) {
|
||||
ec.PrevAlertState = models.AlertStatePending
|
||||
ec.Rule.ExecutionErrorState = models.ExecutionErrorSetAlerting
|
||||
ec.Error = errors.New("test error")
|
||||
ec.Rule.For = time.Minute * 5
|
||||
ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 2)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pending -> error(alerting) should set alerting since time passed FOR",
|
||||
expected: models.AlertStateAlerting,
|
||||
applyFn: func(ec *EvalContext) {
|
||||
ec.PrevAlertState = models.AlertStatePending
|
||||
ec.Rule.ExecutionErrorState = models.ExecutionErrorSetAlerting
|
||||
ec.Error = errors.New("test error")
|
||||
ec.Rule.For = time.Minute * 2
|
||||
ec.Rule.LastStateChange = time.Now().Add(-time.Minute * 5)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
evalContext := NewEvalContext(context.Background(), &Rule{Conditions: []Condition{&conditionStub{firing: true}}}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
|
||||
tc.applyFn(evalContext)
|
||||
newState := evalContext.GetNewState()
|
||||
assert.Equal(t, tc.expected, newState, "failed: %s \n expected '%s' have '%s'\n", tc.name, tc.expected, string(newState))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTemplateDataMap(t *testing.T) {
|
||||
tcs := []struct {
|
||||
name string
|
||||
matches []*EvalMatch
|
||||
expected map[string]string
|
||||
}{
|
||||
{
|
||||
name: "single match",
|
||||
matches: []*EvalMatch{
|
||||
{
|
||||
Tags: map[string]string{
|
||||
"InstanceId": "i-123456789",
|
||||
"Percentile": "0.999",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]string{
|
||||
"InstanceId": "i-123456789",
|
||||
"Percentile": "0.999",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "matches with duplicate keys",
|
||||
matches: []*EvalMatch{
|
||||
{
|
||||
Tags: map[string]string{
|
||||
"InstanceId": "i-123456789",
|
||||
},
|
||||
},
|
||||
{
|
||||
Tags: map[string]string{
|
||||
"InstanceId": "i-987654321",
|
||||
"Percentile": "0.999",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]string{
|
||||
"InstanceId": "i-123456789, i-987654321",
|
||||
"Percentile": "0.999",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "matches with duplicate keys and values",
|
||||
matches: []*EvalMatch{
|
||||
{
|
||||
Tags: map[string]string{
|
||||
"InstanceId": "i-123456789",
|
||||
"Percentile": "0.999",
|
||||
},
|
||||
},
|
||||
{
|
||||
Tags: map[string]string{
|
||||
"InstanceId": "i-987654321",
|
||||
"Percentile": "0.995",
|
||||
},
|
||||
},
|
||||
{
|
||||
Tags: map[string]string{
|
||||
"InstanceId": "i-987654321",
|
||||
"Percentile": "0.999",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]string{
|
||||
"InstanceId": "i-123456789, i-987654321",
|
||||
"Percentile": "0.999, 0.995",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "a value and its substring for same key",
|
||||
matches: []*EvalMatch{
|
||||
{
|
||||
Tags: map[string]string{
|
||||
"Percentile": "0.9990",
|
||||
},
|
||||
},
|
||||
{
|
||||
Tags: map[string]string{
|
||||
"Percentile": "0.999",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: map[string]string{
|
||||
"Percentile": "0.9990, 0.999",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := buildTemplateDataMap(tc.matches)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expected, result, "failed: %s \n expected '%s' have '%s'\n", tc.name, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateTemplate(t *testing.T) {
|
||||
tcs := []struct {
|
||||
name string
|
||||
message string
|
||||
data map[string]string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "matching terms",
|
||||
message: "Degraded ${percentile} latency on ${instance}",
|
||||
data: map[string]string{
|
||||
"instance": "i-123456789",
|
||||
"percentile": "0.95",
|
||||
},
|
||||
expected: "Degraded 0.95 latency on i-123456789",
|
||||
},
|
||||
{
|
||||
name: "non-matching terms",
|
||||
message: "Degraded $percentile latency for endpoint ${ endpoint } on ${instance}",
|
||||
data: map[string]string{
|
||||
"INSTANCE": "i-123456789",
|
||||
"percentile": "0.95",
|
||||
"endpoint": "/api/dashboard/123",
|
||||
},
|
||||
expected: "Degraded $percentile latency for endpoint ${ endpoint } on ${instance}",
|
||||
},
|
||||
}
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := evaluateTemplate(tc.message, tc.data)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expected, result, "failed: %s \n expected '%s' have '%s'\n", tc.name, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateNotificationTemplateFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
evalMatches []*EvalMatch
|
||||
allMatches []*EvalMatch
|
||||
expectedName string
|
||||
expectedMessage string
|
||||
}{
|
||||
{
|
||||
"with evaluation matches",
|
||||
[]*EvalMatch{{
|
||||
Tags: map[string]string{"value1": "test1", "value2": "test2"},
|
||||
}},
|
||||
[]*EvalMatch{{
|
||||
Tags: map[string]string{"value1": "test1", "value2": "test2"},
|
||||
}},
|
||||
"Rule name: test1",
|
||||
"Rule message: test2",
|
||||
},
|
||||
{
|
||||
"missing key",
|
||||
[]*EvalMatch{{
|
||||
Tags: map[string]string{"value1": "test1", "value3": "test2"},
|
||||
}},
|
||||
[]*EvalMatch{{
|
||||
Tags: map[string]string{"value1": "test1", "value3": "test2"},
|
||||
}},
|
||||
"Rule name: test1",
|
||||
"Rule message: ${value2}",
|
||||
},
|
||||
{
|
||||
"no evaluation matches, with series",
|
||||
[]*EvalMatch{},
|
||||
[]*EvalMatch{{
|
||||
Tags: map[string]string{"value1": "test1", "value2": "test2"},
|
||||
}},
|
||||
"Rule name: test1",
|
||||
"Rule message: test2",
|
||||
},
|
||||
{
|
||||
"no evaluation matches, no series",
|
||||
[]*EvalMatch{},
|
||||
[]*EvalMatch{},
|
||||
"Rule name: ${value1}",
|
||||
"Rule message: ${value2}",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(tt *testing.T) {
|
||||
evalContext := NewEvalContext(context.Background(), &Rule{Name: "Rule name: ${value1}", Message: "Rule message: ${value2}",
|
||||
Conditions: []Condition{&conditionStub{firing: true}}}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
evalContext.EvalMatches = test.evalMatches
|
||||
evalContext.AllMatches = test.allMatches
|
||||
|
||||
err := evalContext.evaluateNotificationTemplateFields()
|
||||
|
||||
require.NoError(tt, err)
|
||||
require.Equal(tt, test.expectedName, evalContext.Rule.Name)
|
||||
require.Equal(tt, test.expectedMessage, evalContext.Rule.Message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDurationFromEvalContext(t *testing.T) {
|
||||
startTime, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", "2022-10-03 11:33:14.438803 +0200 CEST")
|
||||
require.NoError(t, err)
|
||||
|
||||
endTime, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", "2022-10-03 11:33:15.291075 +0200 CEST")
|
||||
require.NoError(t, err)
|
||||
|
||||
evalContext := EvalContext{
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
}
|
||||
|
||||
assert.Equal(t, float64(852.272), evalContext.GetDurationMs())
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package alerting
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/metrics"
|
||||
"github.com/grafana/grafana/pkg/tsdb/legacydata"
|
||||
)
|
||||
|
||||
// DefaultEvalHandler is responsible for evaluating the alert rule.
|
||||
type DefaultEvalHandler struct {
|
||||
log log.Logger
|
||||
alertJobTimeout time.Duration
|
||||
requestHandler legacydata.RequestHandler
|
||||
}
|
||||
|
||||
// NewEvalHandler is the `DefaultEvalHandler` constructor.
|
||||
func NewEvalHandler(requestHandler legacydata.RequestHandler) *DefaultEvalHandler {
|
||||
return &DefaultEvalHandler{
|
||||
log: log.New("alerting.evalHandler"),
|
||||
alertJobTimeout: time.Second * 5,
|
||||
requestHandler: requestHandler,
|
||||
}
|
||||
}
|
||||
|
||||
// Eval evaluated the alert rule.
|
||||
func (e *DefaultEvalHandler) Eval(context *EvalContext) {
|
||||
firing := true
|
||||
noDataFound := true
|
||||
conditionEvals := ""
|
||||
|
||||
for i := 0; i < len(context.Rule.Conditions); i++ {
|
||||
condition := context.Rule.Conditions[i]
|
||||
cr, err := condition.Eval(context, e.requestHandler)
|
||||
if err != nil {
|
||||
context.Error = err
|
||||
}
|
||||
|
||||
// break if condition could not be evaluated
|
||||
if context.Error != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
firing = cr.Firing
|
||||
noDataFound = cr.NoDataFound
|
||||
}
|
||||
|
||||
// calculating Firing based on operator
|
||||
if cr.Operator == "or" {
|
||||
firing = firing || cr.Firing
|
||||
} else {
|
||||
firing = firing && cr.Firing
|
||||
}
|
||||
|
||||
// We cannot evaluate the expression when one or more conditions are missing data
|
||||
// and so noDataFound should be true if at least one condition returns no data,
|
||||
// irrespective of the operator.
|
||||
noDataFound = noDataFound || cr.NoDataFound
|
||||
|
||||
if i > 0 {
|
||||
conditionEvals = "[" + conditionEvals + " " + strings.ToUpper(cr.Operator) + " " + strconv.FormatBool(cr.Firing) + "]"
|
||||
} else {
|
||||
conditionEvals = strconv.FormatBool(firing)
|
||||
}
|
||||
|
||||
context.EvalMatches = append(context.EvalMatches, cr.EvalMatches...)
|
||||
context.AllMatches = append(context.AllMatches, cr.AllMatches...)
|
||||
}
|
||||
|
||||
context.ConditionEvals = conditionEvals + " = " + strconv.FormatBool(firing)
|
||||
context.Firing = firing
|
||||
context.NoDataFound = noDataFound
|
||||
context.EndTime = time.Now()
|
||||
|
||||
elapsedTime := context.EndTime.Sub(context.StartTime).Nanoseconds() / int64(time.Millisecond)
|
||||
metrics.MAlertingExecutionTime.Observe(float64(elapsedTime))
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
package alerting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/annotations/annotationstest"
|
||||
"github.com/grafana/grafana/pkg/services/validations"
|
||||
"github.com/grafana/grafana/pkg/tsdb/legacydata"
|
||||
)
|
||||
|
||||
type conditionStub struct {
|
||||
firing bool
|
||||
operator string
|
||||
matches []*EvalMatch
|
||||
noData bool
|
||||
}
|
||||
|
||||
func (c *conditionStub) Eval(context *EvalContext, reqHandler legacydata.RequestHandler) (*ConditionResult, error) {
|
||||
return &ConditionResult{Firing: c.firing, EvalMatches: c.matches, Operator: c.operator, NoDataFound: c.noData}, nil
|
||||
}
|
||||
|
||||
func TestAlertingEvaluationHandler(t *testing.T) {
|
||||
handler := NewEvalHandler(nil)
|
||||
|
||||
t.Run("Show return triggered with single passing condition", func(t *testing.T) {
|
||||
context := NewEvalContext(context.Background(), &Rule{
|
||||
Conditions: []Condition{&conditionStub{
|
||||
firing: true,
|
||||
}},
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
|
||||
handler.Eval(context)
|
||||
require.Equal(t, true, context.Firing)
|
||||
require.Equal(t, "true = true", context.ConditionEvals)
|
||||
})
|
||||
|
||||
t.Run("Show return triggered with single passing condition2", func(t *testing.T) {
|
||||
context := NewEvalContext(context.Background(), &Rule{
|
||||
Conditions: []Condition{&conditionStub{firing: true, operator: "and"}},
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
|
||||
handler.Eval(context)
|
||||
require.Equal(t, true, context.Firing)
|
||||
require.Equal(t, "true = true", context.ConditionEvals)
|
||||
})
|
||||
|
||||
t.Run("Show return false with not passing asdf", func(t *testing.T) {
|
||||
context := NewEvalContext(context.Background(), &Rule{
|
||||
Conditions: []Condition{
|
||||
&conditionStub{firing: true, operator: "and", matches: []*EvalMatch{{}, {}}},
|
||||
&conditionStub{firing: false, operator: "and"},
|
||||
},
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
|
||||
handler.Eval(context)
|
||||
require.Equal(t, false, context.Firing)
|
||||
require.Equal(t, "[true AND false] = false", context.ConditionEvals)
|
||||
})
|
||||
|
||||
t.Run("Show return true if any of the condition is passing with OR operator", func(t *testing.T) {
|
||||
context := NewEvalContext(context.Background(), &Rule{
|
||||
Conditions: []Condition{
|
||||
&conditionStub{firing: true, operator: "and"},
|
||||
&conditionStub{firing: false, operator: "or"},
|
||||
},
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
|
||||
handler.Eval(context)
|
||||
require.Equal(t, true, context.Firing)
|
||||
require.Equal(t, "[true OR false] = true", context.ConditionEvals)
|
||||
})
|
||||
|
||||
t.Run("Show return false if any of the condition is failing with AND operator", func(t *testing.T) {
|
||||
context := NewEvalContext(context.Background(), &Rule{
|
||||
Conditions: []Condition{
|
||||
&conditionStub{firing: true, operator: "and"},
|
||||
&conditionStub{firing: false, operator: "and"},
|
||||
},
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
|
||||
handler.Eval(context)
|
||||
require.Equal(t, false, context.Firing)
|
||||
require.Equal(t, "[true AND false] = false", context.ConditionEvals)
|
||||
})
|
||||
|
||||
t.Run("Show return true if one condition is failing with nested OR operator", func(t *testing.T) {
|
||||
context := NewEvalContext(context.Background(), &Rule{
|
||||
Conditions: []Condition{
|
||||
&conditionStub{firing: true, operator: "and"},
|
||||
&conditionStub{firing: true, operator: "and"},
|
||||
&conditionStub{firing: false, operator: "or"},
|
||||
},
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
|
||||
handler.Eval(context)
|
||||
require.Equal(t, true, context.Firing)
|
||||
require.Equal(t, "[[true AND true] OR false] = true", context.ConditionEvals)
|
||||
})
|
||||
|
||||
t.Run("Show return false if one condition is passing with nested OR operator", func(t *testing.T) {
|
||||
context := NewEvalContext(context.Background(), &Rule{
|
||||
Conditions: []Condition{
|
||||
&conditionStub{firing: true, operator: "and"},
|
||||
&conditionStub{firing: false, operator: "and"},
|
||||
&conditionStub{firing: false, operator: "or"},
|
||||
},
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
|
||||
handler.Eval(context)
|
||||
require.Equal(t, false, context.Firing)
|
||||
require.Equal(t, "[[true AND false] OR false] = false", context.ConditionEvals)
|
||||
})
|
||||
|
||||
t.Run("Show return false if a condition is failing with nested AND operator", func(t *testing.T) {
|
||||
context := NewEvalContext(context.Background(), &Rule{
|
||||
Conditions: []Condition{
|
||||
&conditionStub{firing: true, operator: "and"},
|
||||
&conditionStub{firing: false, operator: "and"},
|
||||
&conditionStub{firing: true, operator: "and"},
|
||||
},
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
|
||||
handler.Eval(context)
|
||||
require.Equal(t, false, context.Firing)
|
||||
require.Equal(t, "[[true AND false] AND true] = false", context.ConditionEvals)
|
||||
})
|
||||
|
||||
t.Run("Show return true if a condition is passing with nested OR operator", func(t *testing.T) {
|
||||
context := NewEvalContext(context.Background(), &Rule{
|
||||
Conditions: []Condition{
|
||||
&conditionStub{firing: true, operator: "and"},
|
||||
&conditionStub{firing: false, operator: "or"},
|
||||
&conditionStub{firing: true, operator: "or"},
|
||||
},
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
|
||||
handler.Eval(context)
|
||||
require.Equal(t, true, context.Firing)
|
||||
require.Equal(t, "[[true OR false] OR true] = true", context.ConditionEvals)
|
||||
})
|
||||
|
||||
t.Run("Should return false if no condition is firing using OR operator", func(t *testing.T) {
|
||||
context := NewEvalContext(context.Background(), &Rule{
|
||||
Conditions: []Condition{
|
||||
&conditionStub{firing: false, operator: "or"},
|
||||
&conditionStub{firing: false, operator: "or"},
|
||||
&conditionStub{firing: false, operator: "or"},
|
||||
},
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
|
||||
handler.Eval(context)
|
||||
require.Equal(t, false, context.Firing)
|
||||
require.Equal(t, "[[false OR false] OR false] = false", context.ConditionEvals)
|
||||
})
|
||||
|
||||
// FIXME: What should the actual test case name be here?
|
||||
t.Run("Should not return NoDataFound if all conditions have data and using OR", func(t *testing.T) {
|
||||
context := NewEvalContext(context.Background(), &Rule{
|
||||
Conditions: []Condition{
|
||||
&conditionStub{operator: "or", noData: false},
|
||||
&conditionStub{operator: "or", noData: false},
|
||||
&conditionStub{operator: "or", noData: false},
|
||||
},
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
|
||||
handler.Eval(context)
|
||||
require.False(t, context.NoDataFound)
|
||||
})
|
||||
|
||||
t.Run("Should return NoDataFound if one condition has no data", func(t *testing.T) {
|
||||
context := NewEvalContext(context.Background(), &Rule{
|
||||
Conditions: []Condition{
|
||||
&conditionStub{operator: "and", noData: true},
|
||||
},
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
|
||||
handler.Eval(context)
|
||||
require.Equal(t, false, context.Firing)
|
||||
require.True(t, context.NoDataFound)
|
||||
})
|
||||
|
||||
t.Run("Should return no data if at least one condition has no data and using AND", func(t *testing.T) {
|
||||
context := NewEvalContext(context.Background(), &Rule{
|
||||
Conditions: []Condition{
|
||||
&conditionStub{operator: "and", noData: true},
|
||||
&conditionStub{operator: "and", noData: false},
|
||||
},
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
|
||||
handler.Eval(context)
|
||||
require.True(t, context.NoDataFound)
|
||||
})
|
||||
|
||||
t.Run("Should return no data if at least one condition has no data and using OR", func(t *testing.T) {
|
||||
context := NewEvalContext(context.Background(), &Rule{
|
||||
Conditions: []Condition{
|
||||
&conditionStub{operator: "or", noData: true},
|
||||
&conditionStub{operator: "or", noData: false},
|
||||
},
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
|
||||
handler.Eval(context)
|
||||
require.True(t, context.NoDataFound)
|
||||
})
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
package alerting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/datasources/guardian"
|
||||
)
|
||||
|
||||
type DashAlertExtractor interface {
|
||||
GetAlerts(ctx context.Context, dashAlertInfo DashAlertInfo) ([]*models.Alert, error)
|
||||
ValidateAlerts(ctx context.Context, dashAlertInfo DashAlertInfo) error
|
||||
}
|
||||
|
||||
// DashAlertExtractorService extracts alerts from the dashboard json.
|
||||
type DashAlertExtractorService struct {
|
||||
dsGuardian guardian.DatasourceGuardianProvider
|
||||
datasourceService datasources.DataSourceService
|
||||
alertStore AlertStore
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
func ProvideDashAlertExtractorService(dsGuardian guardian.DatasourceGuardianProvider, datasourceService datasources.DataSourceService, store AlertStore) *DashAlertExtractorService {
|
||||
return &DashAlertExtractorService{
|
||||
dsGuardian: dsGuardian,
|
||||
datasourceService: datasourceService,
|
||||
alertStore: store,
|
||||
log: log.New("alerting.extractor"),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *DashAlertExtractorService) lookupQueryDataSource(ctx context.Context, panel *simplejson.Json, panelQuery *simplejson.Json, orgID int64) (*datasources.DataSource, error) {
|
||||
dsName := ""
|
||||
dsUid := ""
|
||||
|
||||
ds, ok := panelQuery.CheckGet("datasource")
|
||||
|
||||
if !ok {
|
||||
ds = panel.Get("datasource")
|
||||
}
|
||||
|
||||
if name, err := ds.String(); err == nil {
|
||||
dsName = name
|
||||
} else if uid, ok := ds.CheckGet("uid"); ok {
|
||||
dsUid = uid.MustString()
|
||||
}
|
||||
|
||||
if dsName == "" && dsUid == "" {
|
||||
query := &datasources.GetDefaultDataSourceQuery{OrgID: orgID}
|
||||
dataSource, err := e.datasourceService.GetDefaultDataSource(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dataSource, nil
|
||||
}
|
||||
|
||||
query := &datasources.GetDataSourceQuery{Name: dsName, UID: dsUid, OrgID: orgID}
|
||||
dataSource, err := e.datasourceService.GetDataSource(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dataSource, nil
|
||||
}
|
||||
|
||||
func findPanelQueryByRefID(panel *simplejson.Json, refID string) *simplejson.Json {
|
||||
for _, targetsObj := range panel.Get("targets").MustArray() {
|
||||
target := simplejson.NewFromAny(targetsObj)
|
||||
|
||||
if target.Get("refId").MustString() == refID {
|
||||
return target
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyJSON(in json.Marshaler) (*simplejson.Json, error) {
|
||||
rawJSON, err := in.MarshalJSON()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("JSON marshaling failed: %w", err)
|
||||
}
|
||||
|
||||
return simplejson.NewJson(rawJSON)
|
||||
}
|
||||
|
||||
// UAEnabled takes a context and returns true if Unified Alerting is enabled
|
||||
// and false if it is disabled or the setting is not present in the context
|
||||
type uaEnabledKeyType string
|
||||
|
||||
const uaEnabledKey uaEnabledKeyType = "unified_alerting_enabled"
|
||||
|
||||
func WithUAEnabled(ctx context.Context, enabled bool) context.Context {
|
||||
retCtx := context.WithValue(ctx, uaEnabledKey, enabled)
|
||||
return retCtx
|
||||
}
|
||||
|
||||
func UAEnabled(ctx context.Context) bool {
|
||||
enabled, ok := ctx.Value(uaEnabledKey).(bool)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return enabled
|
||||
}
|
||||
|
||||
func (e *DashAlertExtractorService) getAlertFromPanels(ctx context.Context, jsonWithPanels *simplejson.Json, validateAlertFunc func(*models.Alert) error, logTranslationFailures bool, dashAlertInfo DashAlertInfo) ([]*models.Alert, error) {
|
||||
ret := make([]*models.Alert, 0)
|
||||
|
||||
for _, panelObj := range jsonWithPanels.Get("panels").MustArray() {
|
||||
panel := simplejson.NewFromAny(panelObj)
|
||||
|
||||
collapsedJSON, collapsed := panel.CheckGet("collapsed")
|
||||
// check if the panel is collapsed
|
||||
if collapsed && collapsedJSON.MustBool() {
|
||||
// extract alerts from sub panels for collapsed panels
|
||||
alertSlice, err := e.getAlertFromPanels(ctx, panel, validateAlertFunc, logTranslationFailures, dashAlertInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret = append(ret, alertSlice...)
|
||||
continue
|
||||
}
|
||||
|
||||
jsonAlert, hasAlert := panel.CheckGet("alert")
|
||||
|
||||
if !hasAlert {
|
||||
continue
|
||||
}
|
||||
|
||||
panelID, err := panel.Get("id").Int64()
|
||||
if err != nil {
|
||||
return nil, ValidationError{Reason: "A numeric panel id property is missing"}
|
||||
}
|
||||
|
||||
addIdentifiersToValidationError := func(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var validationErr ValidationError
|
||||
if ok := errors.As(err, &validationErr); ok {
|
||||
ve := ValidationError{
|
||||
Reason: validationErr.Reason,
|
||||
Err: validationErr.Err,
|
||||
PanelID: panelID,
|
||||
}
|
||||
if dashAlertInfo.Dash != nil {
|
||||
ve.DashboardID = dashAlertInfo.Dash.ID
|
||||
}
|
||||
return ve
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// backward compatibility check, can be removed later
|
||||
enabled, hasEnabled := jsonAlert.CheckGet("enabled")
|
||||
if hasEnabled && !enabled.MustBool() {
|
||||
continue
|
||||
}
|
||||
|
||||
frequency, err := getTimeDurationStringToSeconds(jsonAlert.Get("frequency").MustString())
|
||||
if err != nil {
|
||||
return nil, addIdentifiersToValidationError(ValidationError{Reason: err.Error()})
|
||||
}
|
||||
|
||||
rawFor := jsonAlert.Get("for").MustString()
|
||||
|
||||
forValue, err := getForValue(rawFor)
|
||||
if err != nil {
|
||||
return nil, addIdentifiersToValidationError(err)
|
||||
}
|
||||
|
||||
alert := &models.Alert{
|
||||
DashboardID: dashAlertInfo.Dash.ID,
|
||||
OrgID: dashAlertInfo.OrgID,
|
||||
PanelID: panelID,
|
||||
ID: jsonAlert.Get("id").MustInt64(),
|
||||
Name: jsonAlert.Get("name").MustString(),
|
||||
Handler: jsonAlert.Get("handler").MustInt64(),
|
||||
Message: jsonAlert.Get("message").MustString(),
|
||||
Frequency: frequency,
|
||||
For: forValue,
|
||||
}
|
||||
|
||||
for _, condition := range jsonAlert.Get("conditions").MustArray() {
|
||||
jsonCondition := simplejson.NewFromAny(condition)
|
||||
|
||||
jsonQuery := jsonCondition.Get("query")
|
||||
queryRefID := jsonQuery.Get("params").MustArray()[0].(string)
|
||||
panelQuery := findPanelQueryByRefID(panel, queryRefID)
|
||||
|
||||
if panelQuery == nil {
|
||||
var reason string
|
||||
if UAEnabled(ctx) {
|
||||
reason = fmt.Sprintf("Alert on PanelId: %v refers to query(%s) that cannot be found. Legacy alerting queries are not able to be removed at this time in order to preserve the ability to rollback to previous versions of Grafana", alert.PanelID, queryRefID)
|
||||
} else {
|
||||
reason = fmt.Sprintf("Alert on PanelId: %v refers to query(%s) that cannot be found", alert.PanelID, queryRefID)
|
||||
}
|
||||
return nil, ValidationError{Reason: reason}
|
||||
}
|
||||
|
||||
datasource, err := e.lookupQueryDataSource(ctx, panel, panelQuery, dashAlertInfo.OrgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
canQuery, err := e.dsGuardian.New(dashAlertInfo.OrgID, dashAlertInfo.User, *datasource).CanQuery(datasource.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !canQuery {
|
||||
return nil, datasources.ErrDataSourceAccessDenied
|
||||
}
|
||||
|
||||
jsonQuery.SetPath([]string{"datasourceId"}, datasource.ID)
|
||||
|
||||
if interval, err := panel.Get("interval").String(); err == nil {
|
||||
panelQuery.Set("interval", interval)
|
||||
}
|
||||
|
||||
jsonQuery.Set("model", panelQuery.Interface())
|
||||
}
|
||||
|
||||
alert.Settings = jsonAlert
|
||||
|
||||
// validate
|
||||
_, err = NewRuleFromDBAlert(ctx, e.alertStore, alert, logTranslationFailures)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateAlertFunc(alert); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret = append(ret, alert)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func validateAlertRule(alert *models.Alert) error {
|
||||
if !alert.ValidDashboardPanel() {
|
||||
return ValidationError{Reason: fmt.Sprintf("Panel id is not correct, alertName=%v, panelId=%v", alert.Name, alert.PanelID)}
|
||||
}
|
||||
if !alert.ValidTags() {
|
||||
return ValidationError{Reason: "Invalid tags, must be less than 100 characters"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAlerts extracts alerts from the dashboard json and does full validation on the alert json data.
|
||||
func (e *DashAlertExtractorService) GetAlerts(ctx context.Context, dashAlertInfo DashAlertInfo) ([]*models.Alert, error) {
|
||||
return e.extractAlerts(ctx, validateAlertRule, true, dashAlertInfo)
|
||||
}
|
||||
|
||||
func (e *DashAlertExtractorService) extractAlerts(ctx context.Context, validateFunc func(alert *models.Alert) error, logTranslationFailures bool, dashAlertInfo DashAlertInfo) ([]*models.Alert, error) {
|
||||
dashboardJSON, err := copyJSON(dashAlertInfo.Dash.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
alerts := make([]*models.Alert, 0)
|
||||
|
||||
// We extract alerts from rows to be backwards compatible
|
||||
// with the old dashboard json model.
|
||||
rows := dashboardJSON.Get("rows").MustArray()
|
||||
if len(rows) > 0 {
|
||||
for _, rowObj := range rows {
|
||||
row := simplejson.NewFromAny(rowObj)
|
||||
a, err := e.getAlertFromPanels(ctx, row, validateFunc, logTranslationFailures, dashAlertInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
alerts = append(alerts, a...)
|
||||
}
|
||||
} else {
|
||||
a, err := e.getAlertFromPanels(ctx, dashboardJSON, validateFunc, logTranslationFailures, dashAlertInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
alerts = append(alerts, a...)
|
||||
}
|
||||
|
||||
e.log.Debug("Extracted alerts from dashboard", "alertCount", len(alerts))
|
||||
return alerts, nil
|
||||
}
|
||||
|
||||
// ValidateAlerts validates alerts in the dashboard json but does not require a valid dashboard id
|
||||
// in the first validation pass.
|
||||
func (e *DashAlertExtractorService) ValidateAlerts(ctx context.Context, dashAlertInfo DashAlertInfo) error {
|
||||
_, err := e.extractAlerts(ctx, func(alert *models.Alert) error {
|
||||
if alert.OrgID == 0 || alert.PanelID == 0 {
|
||||
return errors.New("missing OrgId, PanelId or both")
|
||||
}
|
||||
return nil
|
||||
}, false, dashAlertInfo)
|
||||
return err
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
package alerting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/db/dbtest"
|
||||
"github.com/grafana/grafana/pkg/infra/localcache"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/datasources/guardian"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func TestAlertRuleExtraction(t *testing.T) {
|
||||
RegisterCondition("query", func(model *simplejson.Json, index int) (Condition, error) {
|
||||
return &FakeCondition{}, nil
|
||||
})
|
||||
|
||||
// mock data
|
||||
defaultDs := &datasources.DataSource{ID: 12, OrgID: 1, Name: "I am default", IsDefault: true, UID: "def-uid"}
|
||||
graphite2Ds := &datasources.DataSource{ID: 15, OrgID: 1, Name: "graphite2", UID: "graphite2-uid"}
|
||||
|
||||
json, err := os.ReadFile("./testdata/graphite-alert.json")
|
||||
require.Nil(t, err)
|
||||
|
||||
dsGuardian := guardian.ProvideGuardian()
|
||||
|
||||
dsService := &fakeDatasourceService{ExpectedDatasource: defaultDs}
|
||||
db := dbtest.NewFakeDB()
|
||||
cfg := &setting.Cfg{}
|
||||
store := ProvideAlertStore(db, localcache.ProvideService(), cfg, nil, featuremgmt.WithFeatures())
|
||||
extractor := ProvideDashAlertExtractorService(dsGuardian, dsService, store)
|
||||
|
||||
t.Run("Parsing alert rules from dashboard json", func(t *testing.T) {
|
||||
dashJSON, err := simplejson.NewJson(json)
|
||||
require.Nil(t, err)
|
||||
|
||||
getTarget := func(j *simplejson.Json) string {
|
||||
rowObj := j.Get("rows").MustArray()[0]
|
||||
row := simplejson.NewFromAny(rowObj)
|
||||
panelObj := row.Get("panels").MustArray()[0]
|
||||
panel := simplejson.NewFromAny(panelObj)
|
||||
conditionObj := panel.Get("alert").Get("conditions").MustArray()[0]
|
||||
condition := simplejson.NewFromAny(conditionObj)
|
||||
return condition.Get("query").Get("model").Get("target").MustString()
|
||||
}
|
||||
|
||||
require.Equal(t, getTarget(dashJSON), "")
|
||||
|
||||
_, _ = extractor.GetAlerts(context.Background(), DashAlertInfo{
|
||||
User: nil,
|
||||
Dash: dashboards.NewDashboardFromJson(dashJSON),
|
||||
OrgID: 1,
|
||||
})
|
||||
|
||||
require.Equal(t, getTarget(dashJSON), "")
|
||||
})
|
||||
|
||||
t.Run("Parsing and validating dashboard containing graphite alerts", func(t *testing.T) {
|
||||
dashJSON, err := simplejson.NewJson(json)
|
||||
require.Nil(t, err)
|
||||
|
||||
dsService.ExpectedDatasource = &datasources.DataSource{ID: 12}
|
||||
alerts, err := extractor.GetAlerts(context.Background(), DashAlertInfo{
|
||||
User: nil,
|
||||
Dash: dashboards.NewDashboardFromJson(dashJSON),
|
||||
OrgID: 1,
|
||||
})
|
||||
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Len(t, alerts, 2)
|
||||
|
||||
for _, v := range alerts {
|
||||
require.EqualValues(t, v.DashboardID, 57)
|
||||
require.NotEmpty(t, v.Name)
|
||||
require.NotEmpty(t, v.Message)
|
||||
|
||||
settings := simplejson.NewFromAny(v.Settings)
|
||||
require.Equal(t, settings.Get("interval").MustString(""), "")
|
||||
}
|
||||
|
||||
require.EqualValues(t, alerts[0].Handler, 1)
|
||||
require.EqualValues(t, alerts[1].Handler, 0)
|
||||
|
||||
require.EqualValues(t, alerts[0].Frequency, 60)
|
||||
require.EqualValues(t, alerts[1].Frequency, 60)
|
||||
|
||||
require.EqualValues(t, alerts[0].PanelID, 3)
|
||||
require.EqualValues(t, alerts[1].PanelID, 4)
|
||||
|
||||
require.Equal(t, alerts[0].For, time.Minute*2)
|
||||
require.Equal(t, alerts[1].For, time.Duration(0))
|
||||
|
||||
require.Equal(t, alerts[0].Name, "name1")
|
||||
require.Equal(t, alerts[0].Message, "desc1")
|
||||
require.Equal(t, alerts[1].Name, "name2")
|
||||
require.Equal(t, alerts[1].Message, "desc2")
|
||||
|
||||
condition := simplejson.NewFromAny(alerts[0].Settings.Get("conditions").MustArray()[0])
|
||||
query := condition.Get("query")
|
||||
require.EqualValues(t, query.Get("datasourceId").MustInt64(), 12)
|
||||
|
||||
condition = simplejson.NewFromAny(alerts[0].Settings.Get("conditions").MustArray()[0])
|
||||
model := condition.Get("query").Get("model")
|
||||
require.Equal(t, model.Get("target").MustString(), "aliasByNode(statsd.fakesite.counters.session_start.desktop.count, 4)")
|
||||
})
|
||||
|
||||
t.Run("Panels missing id should return error", func(t *testing.T) {
|
||||
panelWithoutID, err := os.ReadFile("./testdata/panels-missing-id.json")
|
||||
require.Nil(t, err)
|
||||
|
||||
dashJSON, err := simplejson.NewJson(panelWithoutID)
|
||||
require.Nil(t, err)
|
||||
|
||||
_, err = extractor.GetAlerts(context.Background(), DashAlertInfo{
|
||||
User: nil,
|
||||
Dash: dashboards.NewDashboardFromJson(dashJSON),
|
||||
OrgID: 1,
|
||||
})
|
||||
|
||||
require.NotNil(t, err)
|
||||
})
|
||||
|
||||
t.Run("Panels missing id should return error", func(t *testing.T) {
|
||||
panelWithIDZero, err := os.ReadFile("./testdata/panel-with-id-0.json")
|
||||
require.Nil(t, err)
|
||||
|
||||
dashJSON, err := simplejson.NewJson(panelWithIDZero)
|
||||
require.Nil(t, err)
|
||||
|
||||
_, err = extractor.GetAlerts(context.Background(), DashAlertInfo{
|
||||
User: nil,
|
||||
Dash: dashboards.NewDashboardFromJson(dashJSON),
|
||||
OrgID: 1,
|
||||
})
|
||||
|
||||
require.NotNil(t, err)
|
||||
})
|
||||
|
||||
t.Run("Cannot save panel with query that is referenced by legacy alerting", func(t *testing.T) {
|
||||
panelWithQuery, err := os.ReadFile("./testdata/panel-with-bad-query-id.json")
|
||||
require.Nil(t, err)
|
||||
dashJSON, err := simplejson.NewJson(panelWithQuery)
|
||||
require.Nil(t, err)
|
||||
|
||||
_, err = extractor.GetAlerts(WithUAEnabled(context.Background(), true), DashAlertInfo{
|
||||
User: nil,
|
||||
Dash: dashboards.NewDashboardFromJson(dashJSON),
|
||||
OrgID: 1,
|
||||
})
|
||||
require.Equal(t, "alert validation error: Alert on PanelId: 2 refers to query(B) that cannot be found. Legacy alerting queries are not able to be removed at this time in order to preserve the ability to rollback to previous versions of Grafana", err.Error())
|
||||
})
|
||||
|
||||
t.Run("Panel does not have datasource configured, use the default datasource", func(t *testing.T) {
|
||||
panelWithoutSpecifiedDatasource, err := os.ReadFile("./testdata/panel-without-specified-datasource.json")
|
||||
require.Nil(t, err)
|
||||
|
||||
dashJSON, err := simplejson.NewJson(panelWithoutSpecifiedDatasource)
|
||||
require.Nil(t, err)
|
||||
|
||||
dsService.ExpectedDatasource = &datasources.DataSource{ID: 12}
|
||||
alerts, err := extractor.GetAlerts(context.Background(), DashAlertInfo{
|
||||
User: nil,
|
||||
Dash: dashboards.NewDashboardFromJson(dashJSON),
|
||||
OrgID: 1,
|
||||
})
|
||||
require.Nil(t, err)
|
||||
|
||||
condition := simplejson.NewFromAny(alerts[0].Settings.Get("conditions").MustArray()[0])
|
||||
query := condition.Get("query")
|
||||
require.EqualValues(t, query.Get("datasourceId").MustInt64(), 12)
|
||||
})
|
||||
|
||||
t.Run("Parse alerts from dashboard without rows", func(t *testing.T) {
|
||||
json, err := os.ReadFile("./testdata/v5-dashboard.json")
|
||||
require.Nil(t, err)
|
||||
|
||||
dashJSON, err := simplejson.NewJson(json)
|
||||
require.Nil(t, err)
|
||||
|
||||
alerts, err := extractor.GetAlerts(context.Background(), DashAlertInfo{
|
||||
User: nil,
|
||||
Dash: dashboards.NewDashboardFromJson(dashJSON),
|
||||
OrgID: 1,
|
||||
})
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Len(t, alerts, 2)
|
||||
})
|
||||
|
||||
t.Run("Alert notifications are in DB", func(t *testing.T) {
|
||||
sqlStore := sqlStore{db: sqlstore.InitTestDB(t)}
|
||||
|
||||
firstNotification := models.CreateAlertNotificationCommand{UID: "notifier1", OrgID: 1, Name: "1"}
|
||||
_, err = sqlStore.CreateAlertNotificationCommand(context.Background(), &firstNotification)
|
||||
require.Nil(t, err)
|
||||
|
||||
secondNotification := models.CreateAlertNotificationCommand{UID: "notifier2", OrgID: 1, Name: "2"}
|
||||
_, err = sqlStore.CreateAlertNotificationCommand(context.Background(), &secondNotification)
|
||||
require.Nil(t, err)
|
||||
|
||||
json, err := os.ReadFile("./testdata/influxdb-alert.json")
|
||||
require.Nil(t, err)
|
||||
|
||||
dashJSON, err := simplejson.NewJson(json)
|
||||
require.Nil(t, err)
|
||||
|
||||
alerts, err := extractor.GetAlerts(context.Background(), DashAlertInfo{
|
||||
User: nil,
|
||||
Dash: dashboards.NewDashboardFromJson(dashJSON),
|
||||
OrgID: 1,
|
||||
})
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Len(t, alerts, 1)
|
||||
|
||||
for _, alert := range alerts {
|
||||
require.EqualValues(t, alert.DashboardID, 4)
|
||||
|
||||
conditions := alert.Settings.Get("conditions").MustArray()
|
||||
cond := simplejson.NewFromAny(conditions[0])
|
||||
|
||||
require.Equal(t, cond.Get("query").Get("model").Get("interval").MustString(), ">10s")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Should be able to extract collapsed panels", func(t *testing.T) {
|
||||
json, err := os.ReadFile("./testdata/collapsed-panels.json")
|
||||
require.Nil(t, err)
|
||||
|
||||
dashJSON, err := simplejson.NewJson(json)
|
||||
require.Nil(t, err)
|
||||
|
||||
dash := dashboards.NewDashboardFromJson(dashJSON)
|
||||
|
||||
alerts, err := extractor.GetAlerts(context.Background(), DashAlertInfo{
|
||||
User: nil,
|
||||
Dash: dash,
|
||||
OrgID: 1,
|
||||
})
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Len(t, alerts, 4)
|
||||
})
|
||||
|
||||
t.Run("Parse and validate dashboard without id and containing an alert", func(t *testing.T) {
|
||||
json, err := os.ReadFile("./testdata/dash-without-id.json")
|
||||
require.Nil(t, err)
|
||||
|
||||
dashJSON, err := simplejson.NewJson(json)
|
||||
require.Nil(t, err)
|
||||
|
||||
dashAlertInfo := DashAlertInfo{
|
||||
User: nil,
|
||||
Dash: dashboards.NewDashboardFromJson(dashJSON),
|
||||
OrgID: 1,
|
||||
}
|
||||
|
||||
err = extractor.ValidateAlerts(context.Background(), dashAlertInfo)
|
||||
require.Nil(t, err)
|
||||
|
||||
_, err = extractor.GetAlerts(context.Background(), dashAlertInfo)
|
||||
require.Equal(t, err.Error(), "alert validation error: Panel id is not correct, alertName=Influxdb, panelId=1")
|
||||
})
|
||||
|
||||
t.Run("Extract data source given new DataSourceRef object model", func(t *testing.T) {
|
||||
json, err := os.ReadFile("./testdata/panel-with-datasource-ref.json")
|
||||
require.Nil(t, err)
|
||||
|
||||
dashJSON, err := simplejson.NewJson(json)
|
||||
require.Nil(t, err)
|
||||
|
||||
dsService.ExpectedDatasource = graphite2Ds
|
||||
dashAlertInfo := DashAlertInfo{
|
||||
User: nil,
|
||||
Dash: dashboards.NewDashboardFromJson(dashJSON),
|
||||
OrgID: 1,
|
||||
}
|
||||
|
||||
err = extractor.ValidateAlerts(context.Background(), dashAlertInfo)
|
||||
require.Nil(t, err)
|
||||
|
||||
alerts, err := extractor.GetAlerts(context.Background(), dashAlertInfo)
|
||||
require.Nil(t, err)
|
||||
|
||||
condition := simplejson.NewFromAny(alerts[0].Settings.Get("conditions").MustArray()[0])
|
||||
query := condition.Get("query")
|
||||
require.EqualValues(t, 15, query.Get("datasourceId").MustInt64())
|
||||
})
|
||||
}
|
||||
|
||||
type fakeDatasourceService struct {
|
||||
ExpectedDatasource *datasources.DataSource
|
||||
datasources.DataSourceService
|
||||
}
|
||||
|
||||
func (f *fakeDatasourceService) GetDefaultDataSource(ctx context.Context, query *datasources.GetDefaultDataSourceQuery) (*datasources.DataSource, error) {
|
||||
return f.ExpectedDatasource, nil
|
||||
}
|
||||
|
||||
func (f *fakeDatasourceService) GetDataSource(ctx context.Context, query *datasources.GetDataSourceQuery) (*datasources.DataSource, error) {
|
||||
return f.ExpectedDatasource, nil
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package alerting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/tsdb/legacydata"
|
||||
)
|
||||
|
||||
type evalHandler interface {
|
||||
Eval(evalContext *EvalContext)
|
||||
}
|
||||
|
||||
type scheduler interface {
|
||||
Tick(time time.Time, execQueue chan *Job)
|
||||
Update(rules []*Rule)
|
||||
}
|
||||
|
||||
// Notifier is responsible for sending alert notifications.
|
||||
type Notifier interface {
|
||||
Notify(evalContext *EvalContext) error
|
||||
GetType() string
|
||||
NeedsImage() bool
|
||||
|
||||
// ShouldNotify checks this evaluation should send an alert notification
|
||||
ShouldNotify(ctx context.Context, evalContext *EvalContext, notificationState *models.AlertNotificationState) bool
|
||||
|
||||
GetNotifierUID() string
|
||||
GetIsDefault() bool
|
||||
GetSendReminder() bool
|
||||
GetDisableResolveMessage() bool
|
||||
GetFrequency() time.Duration
|
||||
}
|
||||
|
||||
type notifierState struct {
|
||||
notifier Notifier
|
||||
state *models.AlertNotificationState
|
||||
}
|
||||
|
||||
type notifierStateSlice []*notifierState
|
||||
|
||||
func (notifiers notifierStateSlice) ShouldUploadImage() bool {
|
||||
for _, ns := range notifiers {
|
||||
if ns.notifier.NeedsImage() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ConditionResult is the result of a condition evaluation.
|
||||
type ConditionResult struct {
|
||||
Firing bool
|
||||
NoDataFound bool
|
||||
Operator string
|
||||
EvalMatches []*EvalMatch
|
||||
AllMatches []*EvalMatch
|
||||
}
|
||||
|
||||
// Condition is responsible for evaluating an alert condition.
|
||||
type Condition interface {
|
||||
Eval(result *EvalContext, requestHandler legacydata.RequestHandler) (*ConditionResult, error)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package alerting
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/null"
|
||||
"github.com/grafana/grafana/pkg/services/auth/identity"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
)
|
||||
|
||||
// Job holds state about when the alert rule should be evaluated.
|
||||
type Job struct {
|
||||
Offset int64
|
||||
OffsetWait bool
|
||||
Delay bool
|
||||
running bool
|
||||
Rule *Rule
|
||||
runningLock sync.Mutex // Lock for running property which is used in the Scheduler and AlertEngine execution
|
||||
}
|
||||
|
||||
// GetRunning returns true if the job is running. A lock is taken and released on the Job to ensure atomicity.
|
||||
func (j *Job) GetRunning() bool {
|
||||
defer j.runningLock.Unlock()
|
||||
j.runningLock.Lock()
|
||||
return j.running
|
||||
}
|
||||
|
||||
// SetRunning sets the running property on the Job. A lock is taken and released on the Job to ensure atomicity.
|
||||
func (j *Job) SetRunning(b bool) {
|
||||
j.runningLock.Lock()
|
||||
j.running = b
|
||||
j.runningLock.Unlock()
|
||||
}
|
||||
|
||||
// ResultLogEntry represents log data for the alert evaluation.
|
||||
type ResultLogEntry struct {
|
||||
Message string
|
||||
Data any
|
||||
}
|
||||
|
||||
// EvalMatch represents the series violating the threshold.
|
||||
type EvalMatch struct {
|
||||
Value null.Float `json:"value"`
|
||||
Metric string `json:"metric"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
}
|
||||
|
||||
type DashAlertInfo struct {
|
||||
User identity.Requester
|
||||
Dash *dashboards.Dashboard
|
||||
OrgID int64
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/services/tag"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
)
|
||||
|
||||
type AlertStateType string
|
||||
type NoDataOption string
|
||||
type ExecutionErrorOption string
|
||||
|
||||
const (
|
||||
AlertStateNoData AlertStateType = "no_data"
|
||||
AlertStatePaused AlertStateType = "paused"
|
||||
AlertStateAlerting AlertStateType = "alerting"
|
||||
AlertStateOK AlertStateType = "ok"
|
||||
AlertStatePending AlertStateType = "pending"
|
||||
AlertStateUnknown AlertStateType = "unknown"
|
||||
)
|
||||
|
||||
const (
|
||||
NoDataSetOK NoDataOption = "ok"
|
||||
NoDataSetNoData NoDataOption = "no_data"
|
||||
NoDataKeepState NoDataOption = "keep_state"
|
||||
NoDataSetAlerting NoDataOption = "alerting"
|
||||
)
|
||||
|
||||
const (
|
||||
ExecutionErrorSetOk ExecutionErrorOption = "ok"
|
||||
ExecutionErrorSetAlerting ExecutionErrorOption = "alerting"
|
||||
ExecutionErrorKeepState ExecutionErrorOption = "keep_state"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCannotChangeStateOnPausedAlert = fmt.Errorf("cannot change state on pause alert")
|
||||
ErrRequiresNewState = fmt.Errorf("update alert state requires a new state")
|
||||
)
|
||||
|
||||
func (s AlertStateType) IsValid() bool {
|
||||
return s == AlertStateOK ||
|
||||
s == AlertStateNoData ||
|
||||
s == AlertStatePaused ||
|
||||
s == AlertStatePending ||
|
||||
s == AlertStateAlerting ||
|
||||
s == AlertStateUnknown
|
||||
}
|
||||
|
||||
func (s NoDataOption) IsValid() bool {
|
||||
return s == NoDataSetNoData || s == NoDataSetAlerting || s == NoDataKeepState || s == NoDataSetOK
|
||||
}
|
||||
|
||||
func (s NoDataOption) ToAlertState() AlertStateType {
|
||||
return AlertStateType(s)
|
||||
}
|
||||
|
||||
func (s ExecutionErrorOption) IsValid() bool {
|
||||
return s == ExecutionErrorSetAlerting || s == ExecutionErrorKeepState || s == ExecutionErrorSetOk
|
||||
}
|
||||
|
||||
func (s ExecutionErrorOption) ToAlertState() AlertStateType {
|
||||
return AlertStateType(s)
|
||||
}
|
||||
|
||||
// swagger:model LegacyAlert
|
||||
type Alert struct {
|
||||
ID int64 `xorm:"pk autoincr 'id'"`
|
||||
Version int64
|
||||
OrgID int64 `xorm:"org_id"`
|
||||
DashboardID int64 `xorm:"dashboard_id"`
|
||||
PanelID int64 `xorm:"panel_id"`
|
||||
Name string
|
||||
Message string
|
||||
Severity string // Unused
|
||||
State AlertStateType
|
||||
Handler int64 // Unused
|
||||
Silenced bool
|
||||
ExecutionError string
|
||||
Frequency int64
|
||||
For time.Duration
|
||||
|
||||
EvalData *simplejson.Json
|
||||
NewStateDate time.Time
|
||||
StateChanges int64
|
||||
|
||||
Created time.Time
|
||||
Updated time.Time
|
||||
|
||||
Settings *simplejson.Json
|
||||
}
|
||||
|
||||
func (a *Alert) ValidDashboardPanel() bool {
|
||||
return a.OrgID != 0 && a.DashboardID != 0 && a.PanelID != 0
|
||||
}
|
||||
|
||||
func (a *Alert) ValidTags() bool {
|
||||
for _, tag := range a.GetTagsFromSettings() {
|
||||
if len(tag.Key) > 100 || len(tag.Value) > 100 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *Alert) ContainsUpdates(other *Alert) bool {
|
||||
result := false
|
||||
result = result || a.Name != other.Name
|
||||
result = result || a.Message != other.Message
|
||||
|
||||
if a.Settings != nil && other.Settings != nil {
|
||||
json1, err1 := a.Settings.Encode()
|
||||
json2, err2 := other.Settings.Encode()
|
||||
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
result = result || string(json1) != string(json2)
|
||||
}
|
||||
|
||||
// don't compare .State! That would be insane.
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *Alert) GetTagsFromSettings() []*tag.Tag {
|
||||
tags := []*tag.Tag{}
|
||||
if a.Settings != nil {
|
||||
if data, ok := a.Settings.CheckGet("alertRuleTags"); ok {
|
||||
for tagNameString, tagValue := range data.MustMap() {
|
||||
// MustMap() already guarantees the return of a `map[string]any`.
|
||||
// Therefore we only need to verify that tagValue is a String.
|
||||
tagValueString := simplejson.NewFromAny(tagValue).MustString()
|
||||
tags = append(tags, &tag.Tag{Key: tagNameString, Value: tagValueString})
|
||||
}
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
type PauseAlertCommand struct {
|
||||
OrgID int64 `xorm:"org_id"`
|
||||
AlertIDs []int64 `xorm:"alert_ids"`
|
||||
ResultCount int64
|
||||
Paused bool
|
||||
}
|
||||
|
||||
type PauseAllAlertCommand struct {
|
||||
ResultCount int64
|
||||
Paused bool
|
||||
}
|
||||
|
||||
type SetAlertStateCommand struct {
|
||||
AlertID int64 `xorm:"alert_id"`
|
||||
OrgID int64 `xorm:"org_id"`
|
||||
State AlertStateType
|
||||
Error string
|
||||
EvalData *simplejson.Json
|
||||
}
|
||||
|
||||
// Queries
|
||||
type GetAlertsQuery struct {
|
||||
OrgID int64 `xorm:"org_id"`
|
||||
State []string
|
||||
DashboardIDs []int64 `xorm:"dashboard_ids"`
|
||||
PanelID int64 `xorm:"panel_id"`
|
||||
Limit int64
|
||||
Query string
|
||||
User *user.SignedInUser
|
||||
}
|
||||
|
||||
type GetAllAlertsQuery struct{}
|
||||
|
||||
type GetAlertByIdQuery struct {
|
||||
ID int64 `xorm:"id"`
|
||||
}
|
||||
|
||||
type GetAlertStatesForDashboardQuery struct {
|
||||
OrgID int64 `xorm:"org_id"`
|
||||
DashboardID int64 `xorm:"dashboard_id"`
|
||||
}
|
||||
|
||||
type AlertListItemDTO struct {
|
||||
ID int64 `json:"id" xorm:"id"`
|
||||
DashboardID int64 `json:"dashboardId" xorm:"dashboard_id"`
|
||||
DashboardUID string `json:"dashboardUid" xorm:"dashboard_uid"`
|
||||
DashboardSlug string `json:"dashboardSlug"`
|
||||
PanelID int64 `json:"panelId" xorm:"panel_id"`
|
||||
Name string `json:"name"`
|
||||
State AlertStateType `json:"state"`
|
||||
NewStateDate time.Time `json:"newStateDate"`
|
||||
EvalDate time.Time `json:"evalDate"`
|
||||
EvalData *simplejson.Json `json:"evalData"`
|
||||
ExecutionError string `json:"executionError"`
|
||||
URL string `json:"url" xorm:"url"`
|
||||
}
|
||||
|
||||
type AlertStateInfoDTO struct {
|
||||
ID int64 `json:"id" xorm:"id"`
|
||||
DashboardID int64 `json:"dashboardId" xorm:"dashboard_id"`
|
||||
PanelID int64 `json:"panelId" xorm:"panel_id"`
|
||||
State AlertStateType `json:"state"`
|
||||
NewStateDate time.Time `json:"newStateDate"`
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAlertNotificationNotFound = errors.New("alert notification not found")
|
||||
ErrNotificationFrequencyNotFound = errors.New("notification frequency not specified")
|
||||
ErrAlertNotificationStateVersionConflict = errors.New("alert notification state update version conflict")
|
||||
ErrAlertNotificationFailedGenerateUniqueUid = errors.New("failed to generate unique alert notification uid")
|
||||
ErrAlertNotificationFailedTranslateUniqueID = errors.New("failed to translate Notification Id to Uid")
|
||||
ErrAlertNotificationWithSameNameExists = errors.New("alert notification with same name already exists")
|
||||
ErrAlertNotificationWithSameUIDExists = errors.New("alert notification with same uid already exists")
|
||||
)
|
||||
|
||||
type AlertNotificationStateType string
|
||||
|
||||
var (
|
||||
AlertNotificationStatePending = AlertNotificationStateType("pending")
|
||||
AlertNotificationStateCompleted = AlertNotificationStateType("completed")
|
||||
AlertNotificationStateUnknown = AlertNotificationStateType("unknown")
|
||||
)
|
||||
|
||||
type AlertNotification struct {
|
||||
ID int64 `json:"id" xorm:"pk autoincr 'id'"`
|
||||
UID string `json:"-" xorm:"uid"`
|
||||
OrgID int64 `json:"-" xorm:"org_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
SendReminder bool `json:"sendReminder"`
|
||||
DisableResolveMessage bool `json:"disableResolveMessage"`
|
||||
Frequency time.Duration `json:"frequency"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
Settings *simplejson.Json `json:"settings"`
|
||||
SecureSettings map[string][]byte `json:"secureSettings"`
|
||||
Created time.Time `json:"created"`
|
||||
Updated time.Time `json:"updated"`
|
||||
}
|
||||
|
||||
type CreateAlertNotificationCommand struct {
|
||||
UID string `json:"uid"`
|
||||
Name string `json:"name" binding:"Required"`
|
||||
Type string `json:"type" binding:"Required"`
|
||||
SendReminder bool `json:"sendReminder"`
|
||||
DisableResolveMessage bool `json:"disableResolveMessage"`
|
||||
Frequency string `json:"frequency"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
Settings *simplejson.Json `json:"settings"`
|
||||
SecureSettings map[string]string `json:"secureSettings"`
|
||||
|
||||
OrgID int64 `json:"-"`
|
||||
EncryptedSecureSettings map[string][]byte `json:"-"`
|
||||
}
|
||||
|
||||
type UpdateAlertNotificationCommand struct {
|
||||
ID int64 `json:"id" binding:"Required"`
|
||||
UID string `json:"uid"`
|
||||
Name string `json:"name" binding:"Required"`
|
||||
Type string `json:"type" binding:"Required"`
|
||||
SendReminder bool `json:"sendReminder"`
|
||||
DisableResolveMessage bool `json:"disableResolveMessage"`
|
||||
Frequency string `json:"frequency"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
Settings *simplejson.Json `json:"settings" binding:"Required"`
|
||||
SecureSettings map[string]string `json:"secureSettings"`
|
||||
|
||||
OrgID int64 `json:"-"`
|
||||
EncryptedSecureSettings map[string][]byte `json:"-"`
|
||||
}
|
||||
|
||||
type UpdateAlertNotificationWithUidCommand struct {
|
||||
UID string `json:"-"`
|
||||
NewUID string `json:"uid"`
|
||||
Name string `json:"name" binding:"Required"`
|
||||
Type string `json:"type" binding:"Required"`
|
||||
SendReminder bool `json:"sendReminder"`
|
||||
DisableResolveMessage bool `json:"disableResolveMessage"`
|
||||
Frequency string `json:"frequency"`
|
||||
IsDefault bool `json:"isDefault"`
|
||||
Settings *simplejson.Json `json:"settings" binding:"Required"`
|
||||
SecureSettings map[string]string `json:"secureSettings"`
|
||||
|
||||
OrgID int64 `json:"-"`
|
||||
}
|
||||
|
||||
type DeleteAlertNotificationCommand struct {
|
||||
ID int64
|
||||
OrgID int64
|
||||
}
|
||||
type DeleteAlertNotificationWithUidCommand struct {
|
||||
UID string
|
||||
OrgID int64
|
||||
|
||||
DeletedAlertNotificationID int64
|
||||
}
|
||||
|
||||
type GetAlertNotificationUidQuery struct {
|
||||
ID int64
|
||||
OrgID int64
|
||||
}
|
||||
|
||||
type GetAlertNotificationsQuery struct {
|
||||
Name string
|
||||
ID int64
|
||||
OrgID int64
|
||||
}
|
||||
|
||||
type GetAlertNotificationsWithUidQuery struct {
|
||||
UID string
|
||||
OrgID int64
|
||||
}
|
||||
|
||||
type GetAlertNotificationsWithUidToSendQuery struct {
|
||||
UIDs []string
|
||||
OrgID int64
|
||||
}
|
||||
|
||||
type GetAllAlertNotificationsQuery struct {
|
||||
OrgID int64
|
||||
}
|
||||
|
||||
type AlertNotificationState struct {
|
||||
ID int64 `xorm:"pk autoincr 'id'"`
|
||||
OrgID int64 `xorm:"org_id"`
|
||||
AlertID int64 `xorm:"alert_id"`
|
||||
NotifierID int64 `xorm:"notifier_id"`
|
||||
State AlertNotificationStateType
|
||||
Version int64
|
||||
UpdatedAt int64
|
||||
AlertRuleStateUpdatedVersion int64
|
||||
}
|
||||
|
||||
type SetAlertNotificationStateToPendingCommand struct {
|
||||
ID int64
|
||||
AlertRuleStateUpdatedVersion int64
|
||||
Version int64
|
||||
|
||||
ResultVersion int64
|
||||
}
|
||||
|
||||
type SetAlertNotificationStateToCompleteCommand struct {
|
||||
ID int64
|
||||
Version int64
|
||||
}
|
||||
|
||||
type GetOrCreateNotificationStateQuery struct {
|
||||
OrgID int64
|
||||
AlertID int64
|
||||
NotifierID int64
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/services/tag"
|
||||
)
|
||||
|
||||
func TestAlert_ContainsUpdates(t *testing.T) {
|
||||
settings, err := simplejson.NewJson([]byte(`{ "field": "value" }`))
|
||||
require.NoError(t, err)
|
||||
|
||||
alert1 := &Alert{
|
||||
Settings: settings,
|
||||
Name: "Name",
|
||||
Message: "Message",
|
||||
}
|
||||
|
||||
alert2 := &Alert{
|
||||
Settings: settings,
|
||||
Name: "Name",
|
||||
Message: "Message",
|
||||
}
|
||||
|
||||
assert.False(t, alert1.ContainsUpdates(alert2))
|
||||
|
||||
settingsUpdated, err := simplejson.NewJson([]byte(`{ "field": "newValue" }`))
|
||||
require.NoError(t, err)
|
||||
|
||||
alert2.Settings = settingsUpdated
|
||||
|
||||
assert.True(t, alert1.ContainsUpdates(alert2))
|
||||
}
|
||||
|
||||
func TestAlert_GetTagsFromSettings(t *testing.T) {
|
||||
settings, err := simplejson.NewJson([]byte(`{
|
||||
"field": "value",
|
||||
"alertRuleTags": {
|
||||
"foo": "bar",
|
||||
"waldo": "fred",
|
||||
"tagMap": { "mapValue": "value" }
|
||||
}
|
||||
}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
alert := &Alert{
|
||||
Settings: settings,
|
||||
Name: "Name",
|
||||
Message: "Message",
|
||||
}
|
||||
|
||||
expectedTags := []*tag.Tag{
|
||||
{Id: 0, Key: "foo", Value: "bar"},
|
||||
{Id: 0, Key: "waldo", Value: "fred"},
|
||||
{Id: 0, Key: "tagMap", Value: ""},
|
||||
}
|
||||
actualTags := alert.GetTagsFromSettings()
|
||||
|
||||
assert.ElementsMatch(t, actualTags, expectedTags)
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
package alerting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/imguploader"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/metrics"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
alertmodels "github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/rendering"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
// for stubbing in tests
|
||||
//
|
||||
//nolint:gocritic
|
||||
var newImageUploaderProvider = func(cfg *setting.Cfg) (imguploader.ImageUploader, error) {
|
||||
return imguploader.NewImageUploader(cfg)
|
||||
}
|
||||
|
||||
// NotifierPlugin holds meta information about a notifier.
|
||||
type NotifierPlugin struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Heading string `json:"heading"`
|
||||
Description string `json:"description"`
|
||||
Info string `json:"info"`
|
||||
Factory NotifierFactory `json:"-"`
|
||||
Options []NotifierOption `json:"options"`
|
||||
}
|
||||
|
||||
// NotifierOption holds information about options specific for the NotifierPlugin.
|
||||
type NotifierOption struct {
|
||||
Element ElementType `json:"element"`
|
||||
InputType InputType `json:"inputType"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Placeholder string `json:"placeholder"`
|
||||
PropertyName string `json:"propertyName"`
|
||||
SelectOptions []SelectOption `json:"selectOptions"`
|
||||
ShowWhen ShowWhen `json:"showWhen"`
|
||||
Required bool `json:"required"`
|
||||
ValidationRule string `json:"validationRule"`
|
||||
Secure bool `json:"secure"`
|
||||
DependsOn string `json:"dependsOn"`
|
||||
}
|
||||
|
||||
// InputType is the type of input that can be rendered in the frontend.
|
||||
type InputType string
|
||||
|
||||
const (
|
||||
// InputTypeText will render a text field in the frontend
|
||||
InputTypeText = "text"
|
||||
// InputTypePassword will render a password field in the frontend
|
||||
InputTypePassword = "password"
|
||||
)
|
||||
|
||||
// ElementType is the type of element that can be rendered in the frontend.
|
||||
type ElementType string
|
||||
|
||||
const (
|
||||
// ElementTypeInput will render an input
|
||||
ElementTypeInput = "input"
|
||||
// ElementTypeSelect will render a select
|
||||
ElementTypeSelect = "select"
|
||||
// ElementTypeCheckbox will render a checkbox
|
||||
ElementTypeCheckbox = "checkbox"
|
||||
// ElementTypeTextArea will render a textarea
|
||||
ElementTypeTextArea = "textarea"
|
||||
)
|
||||
|
||||
// SelectOption is a simple type for Options that have dropdown options. Should be used when Element is ElementTypeSelect.
|
||||
type SelectOption struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// ShowWhen holds information about when options are dependant on other options.
|
||||
type ShowWhen struct {
|
||||
Field string `json:"field"`
|
||||
Is string `json:"is"`
|
||||
}
|
||||
|
||||
func newNotificationService(cfg *setting.Cfg, renderService rendering.Service, sqlStore AlertStore, notificationSvc *notifications.NotificationService, decryptFn GetDecryptedValueFn) *notificationService {
|
||||
return ¬ificationService{
|
||||
cfg: cfg,
|
||||
log: log.New("alerting.notifier"),
|
||||
renderService: renderService,
|
||||
sqlStore: sqlStore,
|
||||
notificationService: notificationSvc,
|
||||
decryptFn: decryptFn,
|
||||
}
|
||||
}
|
||||
|
||||
type notificationService struct {
|
||||
cfg *setting.Cfg
|
||||
log log.Logger
|
||||
renderService rendering.Service
|
||||
sqlStore AlertStore
|
||||
notificationService *notifications.NotificationService
|
||||
decryptFn GetDecryptedValueFn
|
||||
}
|
||||
|
||||
func (n *notificationService) SendIfNeeded(evalCtx *EvalContext) error {
|
||||
notifierStates, err := n.getNeededNotifiers(evalCtx.Rule.OrgID, evalCtx.Rule.Notifications, evalCtx)
|
||||
if err != nil {
|
||||
n.log.Error("Failed to get alert notifiers", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if len(notifierStates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if notifierStates.ShouldUploadImage() {
|
||||
// Create a copy of EvalContext and give it a new, shorter, timeout context to upload the image
|
||||
uploadEvalCtx := *evalCtx
|
||||
timeout := n.cfg.AlertingNotificationTimeout / 2
|
||||
var uploadCtxCancel func()
|
||||
uploadEvalCtx.Ctx, uploadCtxCancel = context.WithTimeout(evalCtx.Ctx, timeout)
|
||||
|
||||
// Try to upload the image without consuming all the time allocated for EvalContext
|
||||
if err = n.renderAndUploadImage(&uploadEvalCtx, timeout); err != nil {
|
||||
n.log.Error("Failed to render and upload alert panel image.", "ruleId", uploadEvalCtx.Rule.ID, "error", err)
|
||||
}
|
||||
uploadCtxCancel()
|
||||
evalCtx.ImageOnDiskPath = uploadEvalCtx.ImageOnDiskPath
|
||||
evalCtx.ImagePublicURL = uploadEvalCtx.ImagePublicURL
|
||||
}
|
||||
|
||||
return n.sendNotifications(evalCtx, notifierStates)
|
||||
}
|
||||
|
||||
func (n *notificationService) sendAndMarkAsComplete(evalContext *EvalContext, notifierState *notifierState) error {
|
||||
notifier := notifierState.notifier
|
||||
|
||||
n.log.Debug("Sending notification", "type", notifier.GetType(), "uid", notifier.GetNotifierUID(), "isDefault", notifier.GetIsDefault())
|
||||
metrics.MAlertingNotificationSent.WithLabelValues(notifier.GetType()).Inc()
|
||||
|
||||
if err := evalContext.evaluateNotificationTemplateFields(); err != nil {
|
||||
n.log.Error("Failed trying to evaluate notification template fields", "uid", notifier.GetNotifierUID(), "error", err)
|
||||
}
|
||||
|
||||
if err := notifier.Notify(evalContext); err != nil {
|
||||
n.log.Error("Failed to send notification", "uid", notifier.GetNotifierUID(), "error", err)
|
||||
metrics.MAlertingNotificationFailed.WithLabelValues(notifier.GetType()).Inc()
|
||||
return err
|
||||
}
|
||||
|
||||
if evalContext.IsTestRun {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := &alertmodels.SetAlertNotificationStateToCompleteCommand{
|
||||
ID: notifierState.state.ID,
|
||||
Version: notifierState.state.Version,
|
||||
}
|
||||
|
||||
return n.sqlStore.SetAlertNotificationStateToCompleteCommand(evalContext.Ctx, cmd)
|
||||
}
|
||||
|
||||
func (n *notificationService) sendNotification(evalContext *EvalContext, notifierState *notifierState) error {
|
||||
if !evalContext.IsTestRun {
|
||||
setPendingCmd := &alertmodels.SetAlertNotificationStateToPendingCommand{
|
||||
ID: notifierState.state.ID,
|
||||
Version: notifierState.state.Version,
|
||||
AlertRuleStateUpdatedVersion: evalContext.Rule.StateChanges,
|
||||
}
|
||||
|
||||
err := n.sqlStore.SetAlertNotificationStateToPendingCommand(evalContext.Ctx, setPendingCmd)
|
||||
if err != nil {
|
||||
if errors.Is(err, alertmodels.ErrAlertNotificationStateVersionConflict) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// We need to update state version to be able to log
|
||||
// unexpected version conflicts when marking notifications as ok
|
||||
notifierState.state.Version = setPendingCmd.ResultVersion
|
||||
}
|
||||
|
||||
return n.sendAndMarkAsComplete(evalContext, notifierState)
|
||||
}
|
||||
|
||||
func (n *notificationService) sendNotifications(evalContext *EvalContext, notifierStates notifierStateSlice) error {
|
||||
for _, notifierState := range notifierStates {
|
||||
err := n.sendNotification(evalContext, notifierState)
|
||||
if err != nil {
|
||||
n.log.Error("Failed to send notification", "uid", notifierState.notifier.GetNotifierUID(), "error", err)
|
||||
if evalContext.IsTestRun {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *notificationService) renderAndUploadImage(evalCtx *EvalContext, timeout time.Duration) (err error) {
|
||||
uploader, err := newImageUploaderProvider(n.cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
renderOpts := rendering.Opts{
|
||||
TimeoutOpts: rendering.TimeoutOpts{
|
||||
Timeout: timeout,
|
||||
},
|
||||
AuthOpts: rendering.AuthOpts{
|
||||
OrgID: evalCtx.Rule.OrgID,
|
||||
OrgRole: org.RoleAdmin,
|
||||
},
|
||||
Width: 1000,
|
||||
Height: 500,
|
||||
ConcurrentLimit: n.cfg.AlertingRenderLimit,
|
||||
Theme: models.ThemeDark,
|
||||
}
|
||||
|
||||
ref, err := evalCtx.GetDashboardUID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
renderOpts.Path = fmt.Sprintf("d-solo/%s/%s?orgId=%d&panelId=%d", ref.UID, ref.Slug, evalCtx.Rule.OrgID, evalCtx.Rule.PanelID)
|
||||
|
||||
n.log.Debug("Rendering alert panel image", "ruleId", evalCtx.Rule.ID, "urlPath", renderOpts.Path)
|
||||
start := time.Now()
|
||||
result, err := n.renderService.Render(evalCtx.Ctx, rendering.RenderPNG, renderOpts, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
took := time.Since(start)
|
||||
|
||||
n.log.Debug("Rendered alert panel image", "ruleId", evalCtx.Rule.ID, "path", result.FilePath, "took", took)
|
||||
|
||||
evalCtx.ImageOnDiskPath = result.FilePath
|
||||
|
||||
n.log.Debug("Uploading alert panel image to external image store", "ruleId", evalCtx.Rule.ID, "path", evalCtx.ImageOnDiskPath)
|
||||
|
||||
start = time.Now()
|
||||
evalCtx.ImagePublicURL, err = uploader.Upload(evalCtx.Ctx, evalCtx.ImageOnDiskPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
took = time.Since(start)
|
||||
|
||||
if evalCtx.ImagePublicURL != "" {
|
||||
n.log.Debug("Uploaded alert panel image to external image store", "ruleId", evalCtx.Rule.ID, "url", evalCtx.ImagePublicURL, "took", took)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *notificationService) getNeededNotifiers(orgID int64, notificationUids []string, evalContext *EvalContext) (notifierStateSlice, error) {
|
||||
query := &alertmodels.GetAlertNotificationsWithUidToSendQuery{OrgID: orgID, UIDs: notificationUids}
|
||||
|
||||
res, err := n.sqlStore.GetAlertNotificationsWithUidToSend(evalContext.Ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result notifierStateSlice
|
||||
for _, notification := range res {
|
||||
not, err := InitNotifier(n.cfg, notification, n.decryptFn, n.notificationService)
|
||||
if err != nil {
|
||||
n.log.Error("Could not create notifier", "notifier", notification.UID, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
query := &alertmodels.GetOrCreateNotificationStateQuery{
|
||||
NotifierID: notification.ID,
|
||||
AlertID: evalContext.Rule.ID,
|
||||
OrgID: evalContext.Rule.OrgID,
|
||||
}
|
||||
|
||||
state, err := n.sqlStore.GetOrCreateAlertNotificationState(evalContext.Ctx, query)
|
||||
if err != nil {
|
||||
n.log.Error("Could not get notification state.", "notifier", notification.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if not.ShouldNotify(evalContext.Ctx, evalContext, state) {
|
||||
result = append(result, ¬ifierState{
|
||||
notifier: not,
|
||||
state: state,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// InitNotifier instantiate a new notifier based on the model.
|
||||
func InitNotifier(cfg *setting.Cfg, model *alertmodels.AlertNotification, fn GetDecryptedValueFn, notificationService *notifications.NotificationService) (Notifier, error) {
|
||||
notifierPlugin, found := notifierFactories[model.Type]
|
||||
if !found {
|
||||
return nil, fmt.Errorf("unsupported notification type %q", model.Type)
|
||||
}
|
||||
|
||||
return notifierPlugin.Factory(cfg, model, fn, notificationService)
|
||||
}
|
||||
|
||||
// GetDecryptedValueFn is a function that returns the decrypted value of
|
||||
// the given key. If the key is not present, then it returns the fallback value.
|
||||
type GetDecryptedValueFn func(ctx context.Context, sjd map[string][]byte, key string, fallback string, secret string) string
|
||||
|
||||
// NotifierFactory is a signature for creating notifiers.
|
||||
type NotifierFactory func(*setting.Cfg, *alertmodels.AlertNotification, GetDecryptedValueFn, notifications.Service) (Notifier, error)
|
||||
|
||||
var notifierFactories = make(map[string]*NotifierPlugin)
|
||||
|
||||
// RegisterNotifier registers a notifier.
|
||||
func RegisterNotifier(plugin *NotifierPlugin) {
|
||||
notifierFactories[plugin.Type] = plugin
|
||||
}
|
||||
|
||||
// GetNotifiers returns a list of metadata about available notifiers.
|
||||
func GetNotifiers() []*NotifierPlugin {
|
||||
list := make([]*NotifierPlugin, 0)
|
||||
|
||||
for _, value := range notifierFactories {
|
||||
list = append(list, value)
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
package alerting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/imguploader"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
alertmodels "github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/annotations/annotationstest"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/grafana/grafana/pkg/services/rendering"
|
||||
"github.com/grafana/grafana/pkg/services/validations"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func TestNotificationService(t *testing.T) {
|
||||
testRule := &Rule{Name: "Test", Message: "Something is bad"}
|
||||
store := &AlertStoreMock{}
|
||||
evalCtx := NewEvalContext(context.Background(), testRule, &validations.OSSPluginRequestValidator{}, store, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
|
||||
testRuleTemplated := &Rule{Name: "Test latency ${quantile}", Message: "Something is bad on instance ${instance}"}
|
||||
|
||||
evalCtxWithMatch := NewEvalContext(context.Background(), testRuleTemplated, &validations.OSSPluginRequestValidator{}, store, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
evalCtxWithMatch.EvalMatches = []*EvalMatch{{
|
||||
Tags: map[string]string{
|
||||
"instance": "localhost:3000",
|
||||
"quantile": "0.99",
|
||||
},
|
||||
}}
|
||||
evalCtxWithoutMatch := NewEvalContext(context.Background(), testRuleTemplated, &validations.OSSPluginRequestValidator{}, store, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
|
||||
notificationServiceScenario(t, "Given alert rule with upload image enabled should render and upload image and send notification",
|
||||
evalCtx, true, func(sc *scenarioContext) {
|
||||
err := sc.notificationService.SendIfNeeded(evalCtx)
|
||||
require.NoError(sc.t, err)
|
||||
|
||||
require.Equalf(sc.t, 1, sc.renderCount, "expected render to be called, but wasn't")
|
||||
require.Equalf(sc.t, 1, sc.imageUploadCount, "expected image to be uploaded, but wasn't")
|
||||
require.Truef(sc.t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't")
|
||||
})
|
||||
|
||||
notificationServiceScenario(t,
|
||||
"Given alert rule with upload image enabled but no renderer available should render and upload unavailable image and send notification",
|
||||
evalCtx, true, func(sc *scenarioContext) {
|
||||
sc.rendererAvailable = false
|
||||
err := sc.notificationService.SendIfNeeded(evalCtx)
|
||||
require.NoError(sc.t, err)
|
||||
|
||||
require.Equalf(sc.t, 1, sc.renderCount, "expected render to be called, but it wasn't")
|
||||
require.Equalf(sc.t, 1, sc.imageUploadCount, "expected image to be uploaded, but it wasn't")
|
||||
require.Truef(sc.t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't")
|
||||
})
|
||||
|
||||
notificationServiceScenario(
|
||||
t, "Given alert rule with upload image disabled should not render and upload image, but send notification",
|
||||
evalCtx, false, func(sc *scenarioContext) {
|
||||
err := sc.notificationService.SendIfNeeded(evalCtx)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equalf(sc.t, 0, sc.renderCount, "expected render not to be called, but it was")
|
||||
require.Equalf(sc.t, 0, sc.imageUploadCount, "expected image not to be uploaded, but it was")
|
||||
require.Truef(sc.t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't")
|
||||
})
|
||||
|
||||
notificationServiceScenario(t, "Given alert rule with upload image enabled and render times out should send notification",
|
||||
evalCtx, true, func(sc *scenarioContext) {
|
||||
sc.notificationService.cfg.AlertingNotificationTimeout = 200 * time.Millisecond
|
||||
sc.renderProvider = func(ctx context.Context, opts rendering.Opts) (*rendering.RenderResult, error) {
|
||||
wait := make(chan bool)
|
||||
|
||||
go func() {
|
||||
time.Sleep(1 * time.Second)
|
||||
wait <- true
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
break
|
||||
case <-wait:
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
err := sc.notificationService.SendIfNeeded(evalCtx)
|
||||
require.NoError(sc.t, err)
|
||||
|
||||
require.Equalf(sc.t, 0, sc.renderCount, "expected render not to be called, but it was")
|
||||
require.Equalf(sc.t, 0, sc.imageUploadCount, "expected image not to be uploaded, but it was")
|
||||
require.Truef(sc.t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't")
|
||||
})
|
||||
|
||||
notificationServiceScenario(t, "Given alert rule with upload image enabled and upload times out should send notification",
|
||||
evalCtx, true, func(sc *scenarioContext) {
|
||||
sc.notificationService.cfg.AlertingNotificationTimeout = 200 * time.Millisecond
|
||||
sc.uploadProvider = func(ctx context.Context, path string) (string, error) {
|
||||
wait := make(chan bool)
|
||||
|
||||
go func() {
|
||||
time.Sleep(1 * time.Second)
|
||||
wait <- true
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
break
|
||||
case <-wait:
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
err := sc.notificationService.SendIfNeeded(evalCtx)
|
||||
require.NoError(sc.t, err)
|
||||
|
||||
require.Equalf(sc.t, 1, sc.renderCount, "expected render to be called, but wasn't")
|
||||
require.Equalf(sc.t, 0, sc.imageUploadCount, "expected image not to be uploaded, but it was")
|
||||
require.Truef(sc.t, evalCtx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't")
|
||||
})
|
||||
|
||||
notificationServiceScenario(t, "Given matched alert rule with templated notification fields",
|
||||
evalCtxWithMatch, true, func(sc *scenarioContext) {
|
||||
err := sc.notificationService.SendIfNeeded(evalCtxWithMatch)
|
||||
require.NoError(sc.t, err)
|
||||
|
||||
ctx := evalCtxWithMatch
|
||||
require.Equalf(sc.t, 1, sc.renderCount, "expected render to be called, but wasn't")
|
||||
require.Equalf(sc.t, 1, sc.imageUploadCount, "expected image to be uploaded, but wasn't")
|
||||
require.Truef(sc.t, ctx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't")
|
||||
assert.Equal(t, "Test latency 0.99", ctx.Rule.Name)
|
||||
assert.Equal(t, "Something is bad on instance localhost:3000", ctx.Rule.Message)
|
||||
})
|
||||
|
||||
notificationServiceScenario(t, "Given unmatched alert rule with templated notification fields",
|
||||
evalCtxWithoutMatch, true, func(sc *scenarioContext) {
|
||||
err := sc.notificationService.SendIfNeeded(evalCtxWithMatch)
|
||||
require.NoError(sc.t, err)
|
||||
|
||||
ctx := evalCtxWithMatch
|
||||
require.Equalf(sc.t, 1, sc.renderCount, "expected render to be called, but wasn't")
|
||||
require.Equalf(sc.t, 1, sc.imageUploadCount, "expected image to be uploaded, but wasn't")
|
||||
require.Truef(sc.t, ctx.Ctx.Value(notificationSent{}).(bool), "expected notification to be sent, but wasn't")
|
||||
assert.Equal(t, evalCtxWithoutMatch.Rule.Name, ctx.Rule.Name)
|
||||
assert.Equal(t, evalCtxWithoutMatch.Rule.Message, ctx.Rule.Message)
|
||||
})
|
||||
}
|
||||
|
||||
type scenarioContext struct {
|
||||
t *testing.T
|
||||
evalCtx *EvalContext
|
||||
notificationService *notificationService
|
||||
imageUploadCount int
|
||||
renderCount int
|
||||
uploadProvider func(ctx context.Context, path string) (string, error)
|
||||
renderProvider func(ctx context.Context, opts rendering.Opts) (*rendering.RenderResult, error)
|
||||
rendererAvailable bool
|
||||
}
|
||||
|
||||
type scenarioFunc func(c *scenarioContext)
|
||||
|
||||
func notificationServiceScenario(t *testing.T, name string, evalCtx *EvalContext, uploadImage bool, fn scenarioFunc) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
RegisterNotifier(&NotifierPlugin{
|
||||
Type: "test",
|
||||
Name: "Test",
|
||||
Description: "Test notifier",
|
||||
Factory: newTestNotifier,
|
||||
})
|
||||
|
||||
evalCtx.dashboardRef = &dashboards.DashboardRef{UID: "db-uid"}
|
||||
|
||||
store := evalCtx.Store.(*AlertStoreMock)
|
||||
|
||||
store.getAlertNotificationsWithUidToSend = func(ctx context.Context, query *alertmodels.GetAlertNotificationsWithUidToSendQuery) (res []*alertmodels.AlertNotification, err error) {
|
||||
return []*alertmodels.AlertNotification{
|
||||
{
|
||||
ID: 1,
|
||||
Type: "test",
|
||||
Settings: simplejson.NewFromAny(map[string]any{
|
||||
"uploadImage": uploadImage,
|
||||
}),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
store.getOrCreateNotificationState = func(ctx context.Context, query *alertmodels.GetOrCreateNotificationStateQuery) (res *alertmodels.AlertNotificationState, err error) {
|
||||
return &alertmodels.AlertNotificationState{
|
||||
AlertID: evalCtx.Rule.ID,
|
||||
AlertRuleStateUpdatedVersion: 1,
|
||||
ID: 1,
|
||||
OrgID: evalCtx.Rule.OrgID,
|
||||
State: alertmodels.AlertNotificationStateUnknown,
|
||||
}, nil
|
||||
}
|
||||
|
||||
scenarioCtx := &scenarioContext{
|
||||
t: t,
|
||||
evalCtx: evalCtx,
|
||||
}
|
||||
|
||||
uploadProvider := func(ctx context.Context, path string) (string, error) {
|
||||
scenarioCtx.imageUploadCount++
|
||||
return "", nil
|
||||
}
|
||||
|
||||
imageUploader := &testImageUploader{
|
||||
uploadProvider: func(ctx context.Context, path string) (string, error) {
|
||||
if scenarioCtx.uploadProvider != nil {
|
||||
if _, err := scenarioCtx.uploadProvider(ctx, path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return uploadProvider(ctx, path)
|
||||
},
|
||||
}
|
||||
|
||||
origNewImageUploaderProvider := newImageUploaderProvider
|
||||
newImageUploaderProvider = func(cfg *setting.Cfg) (imguploader.ImageUploader, error) {
|
||||
return imageUploader, nil
|
||||
}
|
||||
defer func() {
|
||||
newImageUploaderProvider = origNewImageUploaderProvider
|
||||
}()
|
||||
|
||||
renderProvider := func(ctx context.Context, opts rendering.Opts) (*rendering.RenderResult, error) {
|
||||
scenarioCtx.renderCount++
|
||||
return &rendering.RenderResult{FilePath: "image.png"}, nil
|
||||
}
|
||||
|
||||
scenarioCtx.rendererAvailable = true
|
||||
|
||||
renderService := &testRenderService{
|
||||
isAvailableProvider: func(ctx context.Context) bool {
|
||||
return scenarioCtx.rendererAvailable
|
||||
},
|
||||
renderProvider: func(ctx context.Context, opts rendering.Opts) (*rendering.RenderResult, error) {
|
||||
if scenarioCtx.renderProvider != nil {
|
||||
if _, err := scenarioCtx.renderProvider(ctx, opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return renderProvider(ctx, opts)
|
||||
},
|
||||
}
|
||||
|
||||
scenarioCtx.notificationService = newNotificationService(setting.NewCfg(), renderService, store, nil, nil)
|
||||
scenarioCtx.notificationService.cfg.AlertingNotificationTimeout = 30 * time.Second
|
||||
fn(scenarioCtx)
|
||||
})
|
||||
}
|
||||
|
||||
type testNotifier struct {
|
||||
Name string
|
||||
Type string
|
||||
UID string
|
||||
IsDefault bool
|
||||
UploadImage bool
|
||||
SendReminder bool
|
||||
DisableResolveMessage bool
|
||||
Frequency time.Duration
|
||||
}
|
||||
|
||||
func newTestNotifier(_ *setting.Cfg, model *alertmodels.AlertNotification, _ GetDecryptedValueFn, ns notifications.Service) (Notifier, error) {
|
||||
uploadImage := true
|
||||
value, exist := model.Settings.CheckGet("uploadImage")
|
||||
if exist {
|
||||
uploadImage = value.MustBool()
|
||||
}
|
||||
|
||||
return &testNotifier{
|
||||
UID: model.UID,
|
||||
Name: model.Name,
|
||||
IsDefault: model.IsDefault,
|
||||
Type: model.Type,
|
||||
UploadImage: uploadImage,
|
||||
SendReminder: model.SendReminder,
|
||||
DisableResolveMessage: model.DisableResolveMessage,
|
||||
Frequency: model.Frequency,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type notificationSent struct{}
|
||||
|
||||
func (n *testNotifier) Notify(evalCtx *EvalContext) error {
|
||||
evalCtx.Ctx = context.WithValue(evalCtx.Ctx, notificationSent{}, true)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *testNotifier) ShouldNotify(ctx context.Context, evalCtx *EvalContext, notifierState *alertmodels.AlertNotificationState) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (n *testNotifier) GetType() string {
|
||||
return n.Type
|
||||
}
|
||||
|
||||
func (n *testNotifier) NeedsImage() bool {
|
||||
return n.UploadImage
|
||||
}
|
||||
|
||||
func (n *testNotifier) GetNotifierUID() string {
|
||||
return n.UID
|
||||
}
|
||||
|
||||
func (n *testNotifier) GetIsDefault() bool {
|
||||
return n.IsDefault
|
||||
}
|
||||
|
||||
func (n *testNotifier) GetSendReminder() bool {
|
||||
return n.SendReminder
|
||||
}
|
||||
|
||||
func (n *testNotifier) GetDisableResolveMessage() bool {
|
||||
return n.DisableResolveMessage
|
||||
}
|
||||
|
||||
func (n *testNotifier) GetFrequency() time.Duration {
|
||||
return n.Frequency
|
||||
}
|
||||
|
||||
var _ Notifier = &testNotifier{}
|
||||
|
||||
type testRenderService struct {
|
||||
isAvailableProvider func(ctx context.Context) bool
|
||||
renderProvider func(ctx context.Context, opts rendering.Opts) (*rendering.RenderResult, error)
|
||||
renderErrorImageProvider func(error error) (*rendering.RenderResult, error)
|
||||
}
|
||||
|
||||
func (s *testRenderService) SanitizeSVG(ctx context.Context, req *rendering.SanitizeSVGRequest) (*rendering.SanitizeSVGResponse, error) {
|
||||
return &rendering.SanitizeSVGResponse{Sanitized: req.Content}, nil
|
||||
}
|
||||
|
||||
func (s *testRenderService) HasCapability(_ context.Context, feature rendering.CapabilityName) (rendering.CapabilitySupportRequestResult, error) {
|
||||
return rendering.CapabilitySupportRequestResult{}, nil
|
||||
}
|
||||
|
||||
func (s *testRenderService) IsAvailable(ctx context.Context) bool {
|
||||
if s.isAvailableProvider != nil {
|
||||
return s.isAvailableProvider(ctx)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *testRenderService) Render(ctx context.Context, _ rendering.RenderType, opts rendering.Opts, _ rendering.Session) (*rendering.RenderResult, error) {
|
||||
if s.renderProvider != nil {
|
||||
return s.renderProvider(ctx, opts)
|
||||
}
|
||||
|
||||
return &rendering.RenderResult{FilePath: "image.png"}, nil
|
||||
}
|
||||
|
||||
func (s *testRenderService) RenderCSV(ctx context.Context, opts rendering.CSVOpts, session rendering.Session) (*rendering.RenderCSVResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *testRenderService) RenderErrorImage(theme models.Theme, err error) (*rendering.RenderResult, error) {
|
||||
if s.renderErrorImageProvider != nil {
|
||||
return s.renderErrorImageProvider(err)
|
||||
}
|
||||
|
||||
return &rendering.RenderResult{FilePath: "image.png"}, nil
|
||||
}
|
||||
|
||||
func (s *testRenderService) GetRenderUser(ctx context.Context, key string) (*rendering.RenderUser, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (s *testRenderService) Version() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *testRenderService) CreateRenderingSession(ctx context.Context, authOpts rendering.AuthOpts, sessionOpts rendering.SessionOpts) (rendering.Session, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var _ rendering.Service = &testRenderService{}
|
||||
|
||||
type testImageUploader struct {
|
||||
uploadProvider func(ctx context.Context, path string) (string, error)
|
||||
}
|
||||
|
||||
func (u *testImageUploader) Upload(ctx context.Context, path string) (string, error) {
|
||||
if u.uploadProvider != nil {
|
||||
return u.uploadProvider(ctx, path)
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var _ imguploader.ImageUploader = &testImageUploader{}
|
||||
@@ -1,206 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func init() {
|
||||
alerting.RegisterNotifier(&alerting.NotifierPlugin{
|
||||
Type: "prometheus-alertmanager",
|
||||
Name: "Prometheus Alertmanager",
|
||||
Description: "Sends alert to Prometheus Alertmanager",
|
||||
Heading: "Alertmanager settings",
|
||||
Factory: NewAlertmanagerNotifier,
|
||||
Options: []alerting.NotifierOption{
|
||||
{
|
||||
Label: "Url",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Description: "As specified in Alertmanager documentation, do not specify a load balancer here. Enter all your Alertmanager URLs comma-separated.",
|
||||
Placeholder: "http://localhost:9093",
|
||||
PropertyName: "url",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Label: "Basic Auth User",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
PropertyName: "basicAuthUser",
|
||||
},
|
||||
{
|
||||
Label: "Basic Auth Password",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypePassword,
|
||||
PropertyName: "basicAuthPassword",
|
||||
Secure: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// NewAlertmanagerNotifier returns a new Alertmanager notifier
|
||||
func NewAlertmanagerNotifier(cfg *setting.Cfg, model *models.AlertNotification, fn alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) {
|
||||
urlString := model.Settings.Get("url").MustString()
|
||||
if urlString == "" {
|
||||
return nil, alerting.ValidationError{Reason: "Could not find url property in settings"}
|
||||
}
|
||||
|
||||
var url []string
|
||||
for _, u := range strings.Split(urlString, ",") {
|
||||
u = strings.TrimSpace(u)
|
||||
if u != "" {
|
||||
url = append(url, u)
|
||||
}
|
||||
}
|
||||
basicAuthUser := model.Settings.Get("basicAuthUser").MustString()
|
||||
basicAuthPassword := fn(context.Background(), model.SecureSettings, "basicAuthPassword", model.Settings.Get("basicAuthPassword").MustString(), cfg.SecretKey)
|
||||
|
||||
return &AlertmanagerNotifier{
|
||||
NotifierBase: NewNotifierBase(model, ns),
|
||||
URL: url,
|
||||
BasicAuthUser: basicAuthUser,
|
||||
BasicAuthPassword: basicAuthPassword,
|
||||
log: log.New("alerting.notifier.prometheus-alertmanager"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AlertmanagerNotifier sends alert notifications to the alert manager
|
||||
type AlertmanagerNotifier struct {
|
||||
NotifierBase
|
||||
URL []string
|
||||
BasicAuthUser string
|
||||
BasicAuthPassword string
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
// ShouldNotify returns true if the notifiers should be used depending on state
|
||||
func (am *AlertmanagerNotifier) ShouldNotify(ctx context.Context, evalContext *alerting.EvalContext, notificationState *models.AlertNotificationState) bool {
|
||||
am.log.Debug("Should notify", "ruleId", evalContext.Rule.ID, "state", evalContext.Rule.State, "previousState", evalContext.PrevAlertState)
|
||||
|
||||
// Do not notify when we become OK for the first time.
|
||||
if (evalContext.PrevAlertState == models.AlertStatePending) && (evalContext.Rule.State == models.AlertStateOK) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Notify on Alerting -> OK to resolve before alertmanager timeout.models.AlertStateOK
|
||||
if (evalContext.PrevAlertState == models.AlertStateAlerting) && (evalContext.Rule.State == models.AlertStateOK) {
|
||||
return true
|
||||
}
|
||||
|
||||
return evalContext.Rule.State == models.AlertStateAlerting
|
||||
}
|
||||
|
||||
func (am *AlertmanagerNotifier) createAlert(evalContext *alerting.EvalContext, match *alerting.EvalMatch, ruleURL string) *simplejson.Json {
|
||||
alertJSON := simplejson.New()
|
||||
alertJSON.Set("startsAt", evalContext.StartTime.UTC().Format(time.RFC3339))
|
||||
if evalContext.Rule.State == models.AlertStateOK {
|
||||
alertJSON.Set("endsAt", time.Now().UTC().Format(time.RFC3339))
|
||||
}
|
||||
alertJSON.Set("generatorURL", ruleURL)
|
||||
|
||||
// Annotations (summary and description are very commonly used).
|
||||
alertJSON.SetPath([]string{"annotations", "summary"}, evalContext.Rule.Name)
|
||||
description := ""
|
||||
if evalContext.Rule.Message != "" {
|
||||
description += evalContext.Rule.Message
|
||||
}
|
||||
if evalContext.Error != nil {
|
||||
if description != "" {
|
||||
description += "\n"
|
||||
}
|
||||
description += "Error: " + evalContext.Error.Error()
|
||||
}
|
||||
if description != "" {
|
||||
alertJSON.SetPath([]string{"annotations", "description"}, description)
|
||||
}
|
||||
if evalContext.ImagePublicURL != "" {
|
||||
alertJSON.SetPath([]string{"annotations", "image"}, evalContext.ImagePublicURL)
|
||||
}
|
||||
|
||||
// Labels (from metrics tags + AlertRuleTags + mandatory alertname).
|
||||
tags := make(map[string]string)
|
||||
if match != nil {
|
||||
if len(match.Tags) == 0 {
|
||||
tags["metric"] = match.Metric
|
||||
} else {
|
||||
for k, v := range match.Tags {
|
||||
tags[replaceIllegalCharsInLabelname(k)] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, tag := range evalContext.Rule.AlertRuleTags {
|
||||
tags[tag.Key] = tag.Value
|
||||
}
|
||||
tags["alertname"] = evalContext.Rule.Name
|
||||
alertJSON.Set("labels", tags)
|
||||
return alertJSON
|
||||
}
|
||||
|
||||
// Notify sends alert notifications to the alert manager
|
||||
func (am *AlertmanagerNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
am.log.Info("Sending Alertmanager alert", "ruleId", evalContext.Rule.ID, "notification", am.Name)
|
||||
|
||||
ruleURL, err := evalContext.GetRuleURL()
|
||||
if err != nil {
|
||||
am.log.Error("Failed get rule link", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Send one alert per matching series.
|
||||
alerts := make([]any, 0)
|
||||
for _, match := range evalContext.EvalMatches {
|
||||
alert := am.createAlert(evalContext, match, ruleURL)
|
||||
alerts = append(alerts, alert)
|
||||
}
|
||||
|
||||
// This happens on ExecutionError or NoData
|
||||
if len(alerts) == 0 {
|
||||
alert := am.createAlert(evalContext, nil, ruleURL)
|
||||
alerts = append(alerts, alert)
|
||||
}
|
||||
|
||||
bodyJSON := simplejson.NewFromAny(alerts)
|
||||
body, _ := bodyJSON.MarshalJSON()
|
||||
errCnt := 0
|
||||
|
||||
for _, url := range am.URL {
|
||||
cmd := ¬ifications.SendWebhookSync{
|
||||
Url: strings.TrimSuffix(url, "/") + "/api/v1/alerts",
|
||||
User: am.BasicAuthUser,
|
||||
Password: am.BasicAuthPassword,
|
||||
HttpMethod: "POST",
|
||||
Body: string(body),
|
||||
}
|
||||
|
||||
if err := am.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil {
|
||||
am.log.Error("Failed to send alertmanager", "error", err, "alertmanager", am.Name, "url", url)
|
||||
errCnt++
|
||||
}
|
||||
}
|
||||
|
||||
// This happens when every dispatch return error
|
||||
if errCnt == len(am.URL) {
|
||||
return fmt.Errorf("failed to send alert to alertmanager")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// regexp that matches all none valid label name characters
|
||||
// https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels
|
||||
var labelNamePattern = regexp.MustCompile(`[^a-zA-Z0-9_]`)
|
||||
|
||||
func replaceIllegalCharsInLabelname(input string) string {
|
||||
return labelNamePattern.ReplaceAllString(input, "_")
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/annotations/annotationstest"
|
||||
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
|
||||
"github.com/grafana/grafana/pkg/services/validations"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func TestReplaceIllegalCharswithUnderscore(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
input: "foobar",
|
||||
expected: "foobar",
|
||||
},
|
||||
{
|
||||
input: `foo.,\][!?#="~*^&+|<>\'bar09_09`,
|
||||
expected: "foo____________________bar09_09",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
assert.Equal(t, replaceIllegalCharsInLabelname(c.input), c.expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhenAlertManagerShouldNotify(t *testing.T) {
|
||||
tcs := []struct {
|
||||
prevState models.AlertStateType
|
||||
newState models.AlertStateType
|
||||
|
||||
expect bool
|
||||
}{
|
||||
{
|
||||
prevState: models.AlertStatePending,
|
||||
newState: models.AlertStateOK,
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
prevState: models.AlertStateAlerting,
|
||||
newState: models.AlertStateOK,
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
prevState: models.AlertStateOK,
|
||||
newState: models.AlertStatePending,
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
prevState: models.AlertStateUnknown,
|
||||
newState: models.AlertStatePending,
|
||||
expect: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
am := &AlertmanagerNotifier{log: log.New("test.logger")}
|
||||
evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{
|
||||
State: tc.prevState,
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
|
||||
evalContext.Rule.State = tc.newState
|
||||
|
||||
res := am.ShouldNotify(context.Background(), evalContext, &models.AlertNotificationState{})
|
||||
if res != tc.expect {
|
||||
t.Errorf("got %v expected %v", res, tc.expect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:goconst
|
||||
func TestAlertmanagerNotifier(t *testing.T) {
|
||||
encryptionService := encryptionservice.SetupTestService(t)
|
||||
|
||||
t.Run("Parsing alert notification from settings", func(t *testing.T) {
|
||||
t.Run("empty settings should return error", func(t *testing.T) {
|
||||
json := `{ }`
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "alertmanager",
|
||||
Type: "alertmanager",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
_, err := NewAlertmanagerNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("from settings", func(t *testing.T) {
|
||||
json := `{ "url": "http://127.0.0.1:9093/", "basicAuthUser": "user", "basicAuthPassword": "password" }`
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "alertmanager",
|
||||
Type: "alertmanager",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewAlertmanagerNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
alertmanagerNotifier := not.(*AlertmanagerNotifier)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, alertmanagerNotifier.BasicAuthUser, "user")
|
||||
require.Equal(t, alertmanagerNotifier.BasicAuthPassword, "password")
|
||||
require.Equal(t, alertmanagerNotifier.URL, []string{"http://127.0.0.1:9093/"})
|
||||
})
|
||||
|
||||
t.Run("from settings with multiple alertmanager", func(t *testing.T) {
|
||||
json := `{ "url": "http://alertmanager1:9093,http://alertmanager2:9093" }`
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "alertmanager",
|
||||
Type: "alertmanager",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewAlertmanagerNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
alertmanagerNotifier := not.(*AlertmanagerNotifier)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, alertmanagerNotifier.URL, []string{"http://alertmanager1:9093", "http://alertmanager2:9093"})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
)
|
||||
|
||||
const (
|
||||
triggMetrString = "Triggered metrics:\n\n"
|
||||
)
|
||||
|
||||
// NotifierBase is the base implementation of a notifier.
|
||||
type NotifierBase struct {
|
||||
Name string
|
||||
Type string
|
||||
UID string
|
||||
IsDefault bool
|
||||
UploadImage bool
|
||||
SendReminder bool
|
||||
DisableResolveMessage bool
|
||||
Frequency time.Duration
|
||||
|
||||
NotificationService notifications.Service
|
||||
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
// NewNotifierBase returns a new `NotifierBase`.
|
||||
func NewNotifierBase(model *models.AlertNotification, notificationService notifications.Service) NotifierBase {
|
||||
uploadImage := true
|
||||
if value, exists := model.Settings.CheckGet("uploadImage"); exists {
|
||||
uploadImage = value.MustBool()
|
||||
}
|
||||
|
||||
return NotifierBase{
|
||||
UID: model.UID,
|
||||
Name: model.Name,
|
||||
IsDefault: model.IsDefault,
|
||||
Type: model.Type,
|
||||
UploadImage: uploadImage,
|
||||
SendReminder: model.SendReminder,
|
||||
DisableResolveMessage: model.DisableResolveMessage,
|
||||
Frequency: model.Frequency,
|
||||
NotificationService: notificationService,
|
||||
log: log.New("alerting.notifier." + model.Name),
|
||||
}
|
||||
}
|
||||
|
||||
// ShouldNotify checks this evaluation should send an alert notification
|
||||
func (n *NotifierBase) ShouldNotify(ctx context.Context, context *alerting.EvalContext, notifierState *models.AlertNotificationState) bool {
|
||||
prevState := context.PrevAlertState
|
||||
newState := context.Rule.State
|
||||
|
||||
// Only notify on state change.
|
||||
if prevState == newState && !n.SendReminder {
|
||||
return false
|
||||
}
|
||||
|
||||
if prevState == newState && n.SendReminder {
|
||||
// Do not notify if interval has not elapsed
|
||||
lastNotify := time.Unix(notifierState.UpdatedAt, 0)
|
||||
if notifierState.UpdatedAt != 0 && lastNotify.Add(n.Frequency).After(time.Now()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Do not notify if alert state is OK or pending even on repeated notify
|
||||
if newState == models.AlertStateOK || newState == models.AlertStatePending {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
okOrPending := newState == models.AlertStatePending || newState == models.AlertStateOK
|
||||
|
||||
// Do not notify when new state is ok/pending when previous is unknown
|
||||
if prevState == models.AlertStateUnknown && okOrPending {
|
||||
return false
|
||||
}
|
||||
|
||||
// Do not notify when we become Pending for the first
|
||||
if prevState == models.AlertStateNoData && newState == models.AlertStatePending {
|
||||
return false
|
||||
}
|
||||
|
||||
// Do not notify when we become OK from pending
|
||||
if prevState == models.AlertStatePending && newState == models.AlertStateOK {
|
||||
return false
|
||||
}
|
||||
|
||||
// Do not notify when we OK -> Pending
|
||||
if prevState == models.AlertStateOK && newState == models.AlertStatePending {
|
||||
return false
|
||||
}
|
||||
|
||||
// Do not notify if state pending and it have been updated last minute
|
||||
if notifierState.State == models.AlertNotificationStatePending {
|
||||
lastUpdated := time.Unix(notifierState.UpdatedAt, 0)
|
||||
if lastUpdated.Add(1 * time.Minute).After(time.Now()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Do not notify when state is OK if DisableResolveMessage is set to true
|
||||
if newState == models.AlertStateOK && n.DisableResolveMessage {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// GetType returns the notifier type.
|
||||
func (n *NotifierBase) GetType() string {
|
||||
return n.Type
|
||||
}
|
||||
|
||||
// NeedsImage returns true if an image is expected in the notification.
|
||||
func (n *NotifierBase) NeedsImage() bool {
|
||||
return n.UploadImage
|
||||
}
|
||||
|
||||
// GetNotifierUID returns the notifier `uid`.
|
||||
func (n *NotifierBase) GetNotifierUID() string {
|
||||
return n.UID
|
||||
}
|
||||
|
||||
// GetIsDefault returns true if the notifiers should
|
||||
// be used for all alerts.
|
||||
func (n *NotifierBase) GetIsDefault() bool {
|
||||
return n.IsDefault
|
||||
}
|
||||
|
||||
// GetSendReminder returns true if reminders should be sent.
|
||||
func (n *NotifierBase) GetSendReminder() bool {
|
||||
return n.SendReminder
|
||||
}
|
||||
|
||||
// GetDisableResolveMessage returns true if ok alert notifications
|
||||
// should be skipped.
|
||||
func (n *NotifierBase) GetDisableResolveMessage() bool {
|
||||
return n.DisableResolveMessage
|
||||
}
|
||||
|
||||
// GetFrequency returns the frequency for how often
|
||||
// alerts should be evaluated.
|
||||
func (n *NotifierBase) GetFrequency() time.Duration {
|
||||
return n.Frequency
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/annotations/annotationstest"
|
||||
"github.com/grafana/grafana/pkg/services/validations"
|
||||
)
|
||||
|
||||
func TestShouldSendAlertNotification(t *testing.T) {
|
||||
tnow := time.Now()
|
||||
|
||||
tcs := []struct {
|
||||
name string
|
||||
prevState models.AlertStateType
|
||||
newState models.AlertStateType
|
||||
sendReminder bool
|
||||
frequency time.Duration
|
||||
state *models.AlertNotificationState
|
||||
|
||||
expect bool
|
||||
}{
|
||||
{
|
||||
name: "pending -> ok should not trigger an notification",
|
||||
newState: models.AlertStateOK,
|
||||
prevState: models.AlertStatePending,
|
||||
sendReminder: false,
|
||||
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "ok -> alerting should trigger an notification",
|
||||
newState: models.AlertStateAlerting,
|
||||
prevState: models.AlertStateOK,
|
||||
sendReminder: false,
|
||||
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "ok -> pending should not trigger an notification",
|
||||
newState: models.AlertStatePending,
|
||||
prevState: models.AlertStateOK,
|
||||
sendReminder: false,
|
||||
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "ok -> ok should not trigger an notification",
|
||||
newState: models.AlertStateOK,
|
||||
prevState: models.AlertStateOK,
|
||||
sendReminder: false,
|
||||
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "ok -> ok with reminder should not trigger an notification",
|
||||
newState: models.AlertStateOK,
|
||||
prevState: models.AlertStateOK,
|
||||
sendReminder: true,
|
||||
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "alerting -> ok should trigger an notification",
|
||||
newState: models.AlertStateOK,
|
||||
prevState: models.AlertStateAlerting,
|
||||
sendReminder: false,
|
||||
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "alerting -> ok should trigger an notification when reminders enabled",
|
||||
newState: models.AlertStateOK,
|
||||
prevState: models.AlertStateAlerting,
|
||||
frequency: time.Minute * 10,
|
||||
sendReminder: true,
|
||||
state: &models.AlertNotificationState{UpdatedAt: tnow.Add(-time.Minute).Unix()},
|
||||
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "alerting -> alerting with reminder and no state should trigger",
|
||||
newState: models.AlertStateAlerting,
|
||||
prevState: models.AlertStateAlerting,
|
||||
frequency: time.Minute * 10,
|
||||
sendReminder: true,
|
||||
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "alerting -> alerting with reminder and last notification sent 1 minute ago should not trigger",
|
||||
newState: models.AlertStateAlerting,
|
||||
prevState: models.AlertStateAlerting,
|
||||
frequency: time.Minute * 10,
|
||||
sendReminder: true,
|
||||
state: &models.AlertNotificationState{UpdatedAt: tnow.Add(-time.Minute).Unix()},
|
||||
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "alerting -> alerting with reminder and last notification sent 11 minutes ago should trigger",
|
||||
newState: models.AlertStateAlerting,
|
||||
prevState: models.AlertStateAlerting,
|
||||
frequency: time.Minute * 10,
|
||||
sendReminder: true,
|
||||
state: &models.AlertNotificationState{UpdatedAt: tnow.Add(-11 * time.Minute).Unix()},
|
||||
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "OK -> alerting with notification state pending and updated 30 seconds ago should not trigger",
|
||||
newState: models.AlertStateAlerting,
|
||||
prevState: models.AlertStateOK,
|
||||
state: &models.AlertNotificationState{State: models.AlertNotificationStatePending, UpdatedAt: tnow.Add(-30 * time.Second).Unix()},
|
||||
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "OK -> alerting with notification state pending and updated 2 minutes ago should trigger",
|
||||
newState: models.AlertStateAlerting,
|
||||
prevState: models.AlertStateOK,
|
||||
state: &models.AlertNotificationState{State: models.AlertNotificationStatePending, UpdatedAt: tnow.Add(-2 * time.Minute).Unix()},
|
||||
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "unknown -> ok",
|
||||
prevState: models.AlertStateUnknown,
|
||||
newState: models.AlertStateOK,
|
||||
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "unknown -> pending",
|
||||
prevState: models.AlertStateUnknown,
|
||||
newState: models.AlertStatePending,
|
||||
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "unknown -> alerting",
|
||||
prevState: models.AlertStateUnknown,
|
||||
newState: models.AlertStateAlerting,
|
||||
|
||||
expect: true,
|
||||
},
|
||||
{
|
||||
name: "no_data -> pending",
|
||||
prevState: models.AlertStateNoData,
|
||||
newState: models.AlertStatePending,
|
||||
|
||||
expect: false,
|
||||
},
|
||||
{
|
||||
name: "no_data -> ok",
|
||||
prevState: models.AlertStateNoData,
|
||||
newState: models.AlertStateOK,
|
||||
|
||||
expect: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{
|
||||
State: tc.prevState,
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
|
||||
if tc.state == nil {
|
||||
tc.state = &models.AlertNotificationState{}
|
||||
}
|
||||
|
||||
evalContext.Rule.State = tc.newState
|
||||
nb := &NotifierBase{SendReminder: tc.sendReminder, Frequency: tc.frequency}
|
||||
|
||||
r := nb.ShouldNotify(evalContext.Ctx, evalContext, tc.state)
|
||||
assert.Equal(t, r, tc.expect, "failed test %s. expected %+v to return: %v", tc.name, tc, tc.expect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseNotifier(t *testing.T) {
|
||||
bJSON := simplejson.New()
|
||||
|
||||
model := &models.AlertNotification{
|
||||
UID: "1",
|
||||
Name: "name",
|
||||
Type: "email",
|
||||
Settings: bJSON,
|
||||
}
|
||||
|
||||
t.Run("can parse false value", func(t *testing.T) {
|
||||
bJSON.Set("uploadImage", false)
|
||||
|
||||
base := NewNotifierBase(model, nil)
|
||||
require.False(t, base.UploadImage)
|
||||
})
|
||||
|
||||
t.Run("can parse true value", func(t *testing.T) {
|
||||
bJSON.Set("uploadImage", true)
|
||||
|
||||
base := NewNotifierBase(model, nil)
|
||||
require.True(t, base.UploadImage)
|
||||
})
|
||||
|
||||
t.Run("default value should be true for backwards compatibility", func(t *testing.T) {
|
||||
base := NewNotifierBase(model, nil)
|
||||
require.True(t, base.UploadImage)
|
||||
})
|
||||
|
||||
t.Run("default value should be false for backwards compatibility", func(t *testing.T) {
|
||||
base := NewNotifierBase(model, nil)
|
||||
require.False(t, base.DisableResolveMessage)
|
||||
})
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
const defaultDingdingMsgType = "link"
|
||||
|
||||
func init() {
|
||||
alerting.RegisterNotifier(&alerting.NotifierPlugin{
|
||||
Type: "dingding",
|
||||
Name: "DingDing",
|
||||
Description: "Sends HTTP POST request to DingDing",
|
||||
Heading: "DingDing settings",
|
||||
Factory: newDingDingNotifier,
|
||||
Options: []alerting.NotifierOption{
|
||||
{
|
||||
Label: "Url",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Placeholder: "https://oapi.dingtalk.com/robot/send?access_token=xxxxxxxxx",
|
||||
PropertyName: "url",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Label: "Message Type",
|
||||
Element: alerting.ElementTypeSelect,
|
||||
PropertyName: "msgType",
|
||||
SelectOptions: []alerting.SelectOption{
|
||||
{
|
||||
Value: "link",
|
||||
Label: "Link"},
|
||||
{
|
||||
Value: "actionCard",
|
||||
Label: "ActionCard",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func newDingDingNotifier(_ *setting.Cfg, model *models.AlertNotification, _ alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) {
|
||||
url := model.Settings.Get("url").MustString()
|
||||
if url == "" {
|
||||
return nil, alerting.ValidationError{Reason: "Could not find url property in settings"}
|
||||
}
|
||||
|
||||
msgType := model.Settings.Get("msgType").MustString(defaultDingdingMsgType)
|
||||
|
||||
return &DingDingNotifier{
|
||||
NotifierBase: NewNotifierBase(model, ns),
|
||||
MsgType: msgType,
|
||||
URL: url,
|
||||
log: log.New("alerting.notifier.dingding"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DingDingNotifier is responsible for sending alert notifications to ding ding.
|
||||
type DingDingNotifier struct {
|
||||
NotifierBase
|
||||
MsgType string
|
||||
URL string
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
// Notify sends the alert notification to dingding.
|
||||
func (dd *DingDingNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
dd.log.Info("Sending dingding")
|
||||
|
||||
messageURL, err := evalContext.GetRuleURL()
|
||||
if err != nil {
|
||||
dd.log.Error("Failed to get messageUrl", "error", err, "dingding", dd.Name)
|
||||
messageURL = ""
|
||||
}
|
||||
|
||||
body, err := dd.genBody(evalContext, messageURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := ¬ifications.SendWebhookSync{
|
||||
Url: dd.URL,
|
||||
Body: string(body),
|
||||
}
|
||||
|
||||
if err := dd.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil {
|
||||
dd.log.Error("Failed to send DingDing", "error", err, "dingding", dd.Name)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dd *DingDingNotifier) genBody(evalContext *alerting.EvalContext, messageURL string) ([]byte, error) {
|
||||
q := url.Values{
|
||||
"pc_slide": {"false"},
|
||||
"url": {messageURL},
|
||||
}
|
||||
|
||||
// Use special link to auto open the message url outside of Dingding
|
||||
// Refer: https://open-doc.dingtalk.com/docs/doc.htm?treeId=385&articleId=104972&docType=1#s9
|
||||
messageURL = "dingtalk://dingtalkclient/page/link?" + q.Encode()
|
||||
|
||||
dd.log.Info("MessageUrl:" + messageURL)
|
||||
|
||||
message := evalContext.Rule.Message
|
||||
picURL := evalContext.ImagePublicURL
|
||||
title := evalContext.GetNotificationTitle()
|
||||
if message == "" {
|
||||
message = title
|
||||
}
|
||||
|
||||
for i, match := range evalContext.EvalMatches {
|
||||
message += fmt.Sprintf("\n%2d. %s: %s", i+1, match.Metric, match.Value)
|
||||
}
|
||||
|
||||
var bodyMsg map[string]any
|
||||
if dd.MsgType == "actionCard" {
|
||||
// Embed the pic into the markdown directly because actionCard doesn't have a picUrl field
|
||||
if dd.NeedsImage() && picURL != "" {
|
||||
message = "\n\n" + message
|
||||
}
|
||||
|
||||
bodyMsg = map[string]any{
|
||||
"msgtype": "actionCard",
|
||||
"actionCard": map[string]string{
|
||||
"text": message,
|
||||
"title": title,
|
||||
"singleTitle": "More",
|
||||
"singleURL": messageURL,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
link := map[string]string{
|
||||
"text": message,
|
||||
"title": title,
|
||||
"messageUrl": messageURL,
|
||||
}
|
||||
|
||||
if dd.NeedsImage() {
|
||||
link["picUrl"] = picURL
|
||||
}
|
||||
|
||||
bodyMsg = map[string]any{
|
||||
"msgtype": "link",
|
||||
"link": link,
|
||||
}
|
||||
}
|
||||
return json.Marshal(bodyMsg)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/annotations/annotationstest"
|
||||
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
|
||||
"github.com/grafana/grafana/pkg/services/validations"
|
||||
)
|
||||
|
||||
func TestDingDingNotifier(t *testing.T) {
|
||||
encryptionService := encryptionservice.SetupTestService(t)
|
||||
|
||||
t.Run("empty settings should return error", func(t *testing.T) {
|
||||
json := `{ }`
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "dingding_testing",
|
||||
Type: "dingding",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
_, err := newDingDingNotifier(nil, model, encryptionService.GetDecryptedValue, nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
t.Run("settings should trigger incident", func(t *testing.T) {
|
||||
json := `{ "url": "https://www.google.com" }`
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "dingding_testing",
|
||||
Type: "dingding",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := newDingDingNotifier(nil, model, encryptionService.GetDecryptedValue, nil)
|
||||
notifier := not.(*DingDingNotifier)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "dingding_testing", notifier.Name)
|
||||
require.Equal(t, "dingding", notifier.Type)
|
||||
require.Equal(t, "https://www.google.com", notifier.URL)
|
||||
|
||||
t.Run("genBody should not panic", func(t *testing.T) {
|
||||
evalContext := alerting.NewEvalContext(context.Background(),
|
||||
&alerting.Rule{
|
||||
State: models.AlertStateAlerting,
|
||||
Message: `{host="localhost"}`,
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
_, err = notifier.genBody(evalContext, "")
|
||||
require.Nil(t, err)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func init() {
|
||||
alerting.RegisterNotifier(&alerting.NotifierPlugin{
|
||||
Type: "discord",
|
||||
Name: "Discord",
|
||||
Description: "Sends notifications to Discord",
|
||||
Factory: newDiscordNotifier,
|
||||
Heading: "Discord settings",
|
||||
Options: []alerting.NotifierOption{
|
||||
{
|
||||
Label: "Avatar URL",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Description: "Provide a URL to an image to use as the avatar for the bot's message",
|
||||
PropertyName: "avatar_url",
|
||||
},
|
||||
{
|
||||
Label: "Message Content",
|
||||
Description: "Mention a group using <@&ID> or a user using <@ID> when notifying in a channel",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
PropertyName: "content",
|
||||
},
|
||||
{
|
||||
Label: "Webhook URL",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Placeholder: "Discord webhook URL",
|
||||
PropertyName: "url",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Label: "Use Discord's Webhook Username",
|
||||
Description: "Use the username configured in Discord's webhook settings. Otherwise, the username will be 'Grafana'",
|
||||
Element: alerting.ElementTypeCheckbox,
|
||||
PropertyName: "use_discord_username",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func newDiscordNotifier(_ *setting.Cfg, model *models.AlertNotification, _ alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) {
|
||||
avatar := model.Settings.Get("avatar_url").MustString()
|
||||
content := model.Settings.Get("content").MustString()
|
||||
url := model.Settings.Get("url").MustString()
|
||||
if url == "" {
|
||||
return nil, alerting.ValidationError{Reason: "Could not find webhook url property in settings"}
|
||||
}
|
||||
useDiscordUsername := model.Settings.Get("use_discord_username").MustBool(false)
|
||||
|
||||
return &DiscordNotifier{
|
||||
NotifierBase: NewNotifierBase(model, ns),
|
||||
Content: content,
|
||||
AvatarURL: avatar,
|
||||
WebhookURL: url,
|
||||
log: log.New("alerting.notifier.discord"),
|
||||
UseDiscordUsername: useDiscordUsername,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DiscordNotifier is responsible for sending alert
|
||||
// notifications to discord.
|
||||
type DiscordNotifier struct {
|
||||
NotifierBase
|
||||
Content string
|
||||
AvatarURL string
|
||||
WebhookURL string
|
||||
log log.Logger
|
||||
UseDiscordUsername bool
|
||||
}
|
||||
|
||||
// Notify send an alert notification to Discord.
|
||||
func (dn *DiscordNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
dn.log.Info("Sending alert notification to", "webhook_url", dn.WebhookURL)
|
||||
|
||||
ruleURL, err := evalContext.GetRuleURL()
|
||||
if err != nil {
|
||||
dn.log.Error("Failed get rule link", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
bodyJSON := simplejson.New()
|
||||
if !dn.UseDiscordUsername {
|
||||
bodyJSON.Set("username", "Grafana")
|
||||
}
|
||||
|
||||
if dn.Content != "" {
|
||||
bodyJSON.Set("content", dn.Content)
|
||||
}
|
||||
|
||||
if dn.AvatarURL != "" {
|
||||
bodyJSON.Set("avatar_url", dn.AvatarURL)
|
||||
}
|
||||
|
||||
fields := make([]map[string]any, 0)
|
||||
|
||||
for _, evt := range evalContext.EvalMatches {
|
||||
fields = append(fields, map[string]any{
|
||||
// Discord uniquely does not send the alert if the metric field is empty,
|
||||
// which it can be in some cases
|
||||
"name": notEmpty(evt.Metric),
|
||||
"value": evt.Value.FullString(),
|
||||
"inline": true,
|
||||
})
|
||||
}
|
||||
|
||||
footer := map[string]any{
|
||||
"text": "Grafana v" + setting.BuildVersion,
|
||||
"icon_url": "https://grafana.com/static/assets/img/fav32.png",
|
||||
}
|
||||
|
||||
color, _ := strconv.ParseInt(strings.TrimLeft(evalContext.GetStateModel().Color, "#"), 16, 0)
|
||||
|
||||
embed := simplejson.New()
|
||||
embed.Set("title", evalContext.GetNotificationTitle())
|
||||
// Discord takes integer for color
|
||||
embed.Set("color", color)
|
||||
embed.Set("url", ruleURL)
|
||||
embed.Set("description", evalContext.Rule.Message)
|
||||
embed.Set("type", "rich")
|
||||
embed.Set("fields", fields)
|
||||
embed.Set("footer", footer)
|
||||
|
||||
var image map[string]any
|
||||
var embeddedImage = false
|
||||
|
||||
if dn.NeedsImage() {
|
||||
if evalContext.ImagePublicURL != "" {
|
||||
image = map[string]any{
|
||||
"url": evalContext.ImagePublicURL,
|
||||
}
|
||||
embed.Set("image", image)
|
||||
} else {
|
||||
image = map[string]any{
|
||||
"url": "attachment://graph.png",
|
||||
}
|
||||
embed.Set("image", image)
|
||||
embeddedImage = true
|
||||
}
|
||||
}
|
||||
|
||||
bodyJSON.Set("embeds", []any{embed})
|
||||
|
||||
json, _ := bodyJSON.MarshalJSON()
|
||||
|
||||
cmd := ¬ifications.SendWebhookSync{
|
||||
Url: dn.WebhookURL,
|
||||
HttpMethod: "POST",
|
||||
ContentType: "application/json",
|
||||
}
|
||||
|
||||
if !embeddedImage {
|
||||
cmd.Body = string(json)
|
||||
} else {
|
||||
err := dn.embedImage(cmd, evalContext.ImageOnDiskPath, json)
|
||||
if err != nil {
|
||||
dn.log.Error("Failed to embed image", "error", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := dn.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil {
|
||||
dn.log.Error("Failed to send notification to Discord", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dn *DiscordNotifier) embedImage(cmd *notifications.SendWebhookSync, imagePath string, existingJSONBody []byte) error {
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `imagePath` comes
|
||||
// from the alert `evalContext` that generates the images.
|
||||
f, err := os.Open(imagePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
cmd.Body = string(existingJSONBody)
|
||||
return nil
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
dn.log.Warn("Failed to close file", "path", imagePath, "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
var b bytes.Buffer
|
||||
w := multipart.NewWriter(&b)
|
||||
defer func() {
|
||||
if err := w.Close(); err != nil {
|
||||
// Should be OK since we already close it on non-error path
|
||||
dn.log.Warn("Failed to close multipart writer", "err", err)
|
||||
}
|
||||
}()
|
||||
fw, err := w.CreateFormField("payload_json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = fw.Write([]byte(string(existingJSONBody))); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fw, err = w.CreateFormFile("file", "graph.png")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = io.Copy(fw, f); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close multipart writer: %w", err)
|
||||
}
|
||||
|
||||
cmd.Body = b.String()
|
||||
cmd.ContentType = w.FormDataContentType()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func notEmpty(metric string) string {
|
||||
if metric == "" {
|
||||
return "<NO_METRIC_NAME>"
|
||||
}
|
||||
|
||||
return metric
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
|
||||
)
|
||||
|
||||
func TestDiscordNotifier(t *testing.T) {
|
||||
encryptionService := encryptionservice.SetupTestService(t)
|
||||
|
||||
t.Run("Parsing alert notification from settings", func(t *testing.T) {
|
||||
t.Run("empty settings should return error", func(t *testing.T) {
|
||||
json := `{ }`
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "discord_testing",
|
||||
Type: "discord",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
_, err := newDiscordNotifier(nil, model, encryptionService.GetDecryptedValue, nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("settings should trigger incident", func(t *testing.T) {
|
||||
json := `
|
||||
{
|
||||
"avatar_url": "https://grafana.com/img/fav32.png",
|
||||
"content": "@everyone Please check this notification",
|
||||
"url": "https://web.hook/"
|
||||
}`
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "discord_testing",
|
||||
Type: "discord",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := newDiscordNotifier(nil, model, encryptionService.GetDecryptedValue, nil)
|
||||
discordNotifier := not.(*DiscordNotifier)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "discord_testing", discordNotifier.Name)
|
||||
require.Equal(t, "discord", discordNotifier.Type)
|
||||
require.Equal(t, "https://grafana.com/img/fav32.png", discordNotifier.AvatarURL)
|
||||
require.Equal(t, "@everyone Please check this notification", discordNotifier.Content)
|
||||
require.Equal(t, "https://web.hook/", discordNotifier.WebhookURL)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
alerting.RegisterNotifier(&alerting.NotifierPlugin{
|
||||
Type: "email",
|
||||
Name: "Email",
|
||||
Description: "Sends notifications using Grafana server configured SMTP settings",
|
||||
Factory: NewEmailNotifier,
|
||||
Heading: "Email settings",
|
||||
Options: []alerting.NotifierOption{
|
||||
{
|
||||
Label: "Single email",
|
||||
Description: "Send a single email to all recipients",
|
||||
Element: alerting.ElementTypeCheckbox,
|
||||
PropertyName: "singleEmail",
|
||||
},
|
||||
{
|
||||
Label: "Addresses",
|
||||
Description: "You can enter multiple email addresses using a \";\" separator",
|
||||
Element: alerting.ElementTypeTextArea,
|
||||
PropertyName: "addresses",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// EmailNotifier is responsible for sending
|
||||
// alert notifications over email.
|
||||
type EmailNotifier struct {
|
||||
NotifierBase
|
||||
Addresses []string
|
||||
SingleEmail bool
|
||||
log log.Logger
|
||||
appURL string
|
||||
}
|
||||
|
||||
// NewEmailNotifier is the constructor function
|
||||
// for the EmailNotifier.
|
||||
func NewEmailNotifier(cfg *setting.Cfg, model *models.AlertNotification, _ alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) {
|
||||
addressesString := model.Settings.Get("addresses").MustString()
|
||||
singleEmail := model.Settings.Get("singleEmail").MustBool(false)
|
||||
|
||||
if addressesString == "" {
|
||||
return nil, alerting.ValidationError{Reason: "Could not find addresses in settings"}
|
||||
}
|
||||
|
||||
// split addresses with a few different ways
|
||||
addresses := util.SplitEmails(addressesString)
|
||||
|
||||
return &EmailNotifier{
|
||||
NotifierBase: NewNotifierBase(model, ns),
|
||||
Addresses: addresses,
|
||||
SingleEmail: singleEmail,
|
||||
log: log.New("alerting.notifier.email"),
|
||||
appURL: cfg.AppURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Notify sends the alert notification.
|
||||
func (en *EmailNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
en.log.Info("Sending alert notification to", "addresses", en.Addresses, "singleEmail", en.SingleEmail)
|
||||
|
||||
ruleURL, err := evalContext.GetRuleURL()
|
||||
if err != nil {
|
||||
en.log.Error("Failed get rule link", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
error := ""
|
||||
if evalContext.Error != nil {
|
||||
error = evalContext.Error.Error()
|
||||
}
|
||||
|
||||
cmd := ¬ifications.SendEmailCommandSync{
|
||||
SendEmailCommand: notifications.SendEmailCommand{
|
||||
Subject: evalContext.GetNotificationTitle(),
|
||||
Data: map[string]any{
|
||||
"Title": evalContext.GetNotificationTitle(),
|
||||
"State": evalContext.Rule.State,
|
||||
"Name": evalContext.Rule.Name,
|
||||
"StateModel": evalContext.GetStateModel(),
|
||||
"Message": evalContext.Rule.Message,
|
||||
"Error": error,
|
||||
"RuleUrl": ruleURL,
|
||||
"ImageLink": "",
|
||||
"EmbeddedImage": "",
|
||||
"AlertPageUrl": en.appURL + "alerting",
|
||||
"EvalMatches": evalContext.EvalMatches,
|
||||
},
|
||||
To: en.Addresses,
|
||||
SingleEmail: en.SingleEmail,
|
||||
Template: "alert_notification",
|
||||
EmbeddedFiles: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
if en.NeedsImage() {
|
||||
if evalContext.ImagePublicURL != "" {
|
||||
cmd.Data["ImageLink"] = evalContext.ImagePublicURL
|
||||
} else {
|
||||
file, err := os.Stat(evalContext.ImageOnDiskPath)
|
||||
if err == nil {
|
||||
cmd.EmbeddedFiles = []string{evalContext.ImageOnDiskPath}
|
||||
cmd.Data["EmbeddedImage"] = file.Name()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := en.NotificationService.SendEmailCommandHandlerSync(evalContext.Ctx, cmd); err != nil {
|
||||
en.log.Error("Failed to send alert notification email", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func TestEmailNotifier(t *testing.T) {
|
||||
encryptionService := encryptionservice.SetupTestService(t)
|
||||
|
||||
t.Run("Parsing alert notification from settings", func(t *testing.T) {
|
||||
t.Run("empty settings should return error", func(t *testing.T) {
|
||||
json := `{ }`
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "ops",
|
||||
Type: "email",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
_, err := NewEmailNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("from settings", func(t *testing.T) {
|
||||
json := `
|
||||
{
|
||||
"addresses": "ops@grafana.org"
|
||||
}`
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "ops",
|
||||
Type: "email",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewEmailNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
emailNotifier := not.(*EmailNotifier)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "ops", emailNotifier.Name)
|
||||
require.Equal(t, "email", emailNotifier.Type)
|
||||
require.Equal(t, "ops@grafana.org", emailNotifier.Addresses[0])
|
||||
})
|
||||
|
||||
t.Run("from settings with two emails", func(t *testing.T) {
|
||||
json := `
|
||||
{
|
||||
"addresses": "ops@grafana.org;dev@grafana.org"
|
||||
}`
|
||||
|
||||
settingsJSON, err := simplejson.NewJson([]byte(json))
|
||||
require.Nil(t, err)
|
||||
|
||||
model := &models.AlertNotification{
|
||||
Name: "ops",
|
||||
Type: "email",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewEmailNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
emailNotifier := not.(*EmailNotifier)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "ops", emailNotifier.Name)
|
||||
require.Equal(t, "email", emailNotifier.Type)
|
||||
require.Equal(t, 2, len(emailNotifier.Addresses))
|
||||
|
||||
require.Equal(t, "ops@grafana.org", emailNotifier.Addresses[0])
|
||||
require.Equal(t, "dev@grafana.org", emailNotifier.Addresses[1])
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func init() {
|
||||
alerting.RegisterNotifier(&alerting.NotifierPlugin{
|
||||
Type: "googlechat",
|
||||
Name: "Google Hangouts Chat",
|
||||
Description: "Sends notifications to Google Hangouts Chat via webhooks based on the official JSON message format",
|
||||
Factory: newGoogleChatNotifier,
|
||||
Heading: "Google Hangouts Chat settings",
|
||||
Options: []alerting.NotifierOption{
|
||||
{
|
||||
Label: "Url",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Placeholder: "Google Hangouts Chat incoming webhook url",
|
||||
PropertyName: "url",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func newGoogleChatNotifier(_ *setting.Cfg, model *models.AlertNotification, _ alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) {
|
||||
url := model.Settings.Get("url").MustString()
|
||||
if url == "" {
|
||||
return nil, alerting.ValidationError{Reason: "Could not find url property in settings"}
|
||||
}
|
||||
|
||||
return &GoogleChatNotifier{
|
||||
NotifierBase: NewNotifierBase(model, ns),
|
||||
URL: url,
|
||||
log: log.New("alerting.notifier.googlechat"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GoogleChatNotifier is responsible for sending
|
||||
// alert notifications to Google chat.
|
||||
type GoogleChatNotifier struct {
|
||||
NotifierBase
|
||||
URL string
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
/*
|
||||
*
|
||||
Structs used to build a custom Google Hangouts Chat message card.
|
||||
See: https://developers.google.com/hangouts/chat/reference/message-formats/cards
|
||||
*/
|
||||
type outerStruct struct {
|
||||
PreviewText string `json:"previewText"`
|
||||
FallbackText string `json:"fallbackText"`
|
||||
Cards []card `json:"cards"`
|
||||
}
|
||||
|
||||
type card struct {
|
||||
Header header `json:"header"`
|
||||
Sections []section `json:"sections"`
|
||||
}
|
||||
|
||||
type header struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type section struct {
|
||||
Widgets []widget `json:"widgets"`
|
||||
}
|
||||
|
||||
// "generic" widget used to add different types of widgets (buttonWidget, textParagraphWidget, imageWidget)
|
||||
type widget interface {
|
||||
}
|
||||
|
||||
type buttonWidget struct {
|
||||
Buttons []button `json:"buttons"`
|
||||
}
|
||||
|
||||
type textParagraphWidget struct {
|
||||
Text text `json:"textParagraph"`
|
||||
}
|
||||
|
||||
type text struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type imageWidget struct {
|
||||
Image image `json:"image"`
|
||||
}
|
||||
|
||||
type image struct {
|
||||
ImageURL string `json:"imageUrl"`
|
||||
}
|
||||
|
||||
type button struct {
|
||||
TextButton textButton `json:"textButton"`
|
||||
}
|
||||
|
||||
type textButton struct {
|
||||
Text string `json:"text"`
|
||||
OnClick onClick `json:"onClick"`
|
||||
}
|
||||
|
||||
type onClick struct {
|
||||
OpenLink openLink `json:"openLink"`
|
||||
}
|
||||
|
||||
type openLink struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// Notify send an alert notification to Google Chat.
|
||||
func (gcn *GoogleChatNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
gcn.log.Info("Executing Google Chat notification")
|
||||
|
||||
headers := map[string]string{
|
||||
"Content-Type": "application/json; charset=UTF-8",
|
||||
}
|
||||
|
||||
ruleURL, err := evalContext.GetRuleURL()
|
||||
if err != nil {
|
||||
gcn.log.Error("EvalContext returned an invalid rule URL")
|
||||
}
|
||||
|
||||
widgets := []widget{}
|
||||
if len(evalContext.Rule.Message) > 0 {
|
||||
// add a text paragraph widget for the message if there is a message
|
||||
// Google Chat API doesn't accept an empty text property
|
||||
widgets = append(widgets, textParagraphWidget{
|
||||
Text: text{
|
||||
Text: evalContext.Rule.Message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// add a text paragraph widget for the fields
|
||||
//nolint:prealloc // break block
|
||||
var fields []textParagraphWidget
|
||||
fieldLimitCount := 4
|
||||
for index, evt := range evalContext.EvalMatches {
|
||||
fields = append(fields,
|
||||
textParagraphWidget{
|
||||
Text: text{
|
||||
Text: "<i>" + evt.Metric + ": " + fmt.Sprint(evt.Value) + "</i>",
|
||||
},
|
||||
},
|
||||
)
|
||||
if index > fieldLimitCount {
|
||||
break
|
||||
}
|
||||
}
|
||||
widgets = append(widgets, fields)
|
||||
|
||||
if gcn.NeedsImage() {
|
||||
// if an image exists, add it as an image widget
|
||||
if evalContext.ImagePublicURL != "" {
|
||||
widgets = append(widgets, imageWidget{
|
||||
Image: image{
|
||||
ImageURL: evalContext.ImagePublicURL,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
gcn.log.Info("Could not retrieve a public image URL.")
|
||||
}
|
||||
}
|
||||
|
||||
if gcn.isUrlAbsolute(ruleURL) {
|
||||
// add a button widget (link to Grafana)
|
||||
widgets = append(widgets, buttonWidget{
|
||||
Buttons: []button{
|
||||
{
|
||||
TextButton: textButton{
|
||||
Text: "OPEN IN GRAFANA",
|
||||
OnClick: onClick{
|
||||
OpenLink: openLink{
|
||||
URL: ruleURL,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
gcn.log.Warn("Grafana External URL setting is missing or invalid. Skipping 'open in grafana' button to prevent google from displaying empty alerts.", "ruleURL", ruleURL)
|
||||
}
|
||||
|
||||
// add text paragraph widget for the build version and timestamp
|
||||
widgets = append(widgets, textParagraphWidget{
|
||||
Text: text{
|
||||
Text: "Grafana v" + setting.BuildVersion + " | " + (time.Now()).Format(time.RFC822),
|
||||
},
|
||||
})
|
||||
|
||||
// nest the required structs
|
||||
res1D := &outerStruct{
|
||||
PreviewText: evalContext.GetNotificationTitle(),
|
||||
FallbackText: evalContext.GetNotificationTitle(),
|
||||
Cards: []card{
|
||||
{
|
||||
Header: header{
|
||||
Title: evalContext.GetNotificationTitle(),
|
||||
},
|
||||
Sections: []section{
|
||||
{
|
||||
Widgets: widgets,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(res1D)
|
||||
|
||||
cmd := ¬ifications.SendWebhookSync{
|
||||
Url: gcn.URL,
|
||||
HttpMethod: "POST",
|
||||
HttpHeader: headers,
|
||||
Body: string(body),
|
||||
}
|
||||
|
||||
if err := gcn.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil {
|
||||
gcn.log.Error("Failed to send Google Hangouts Chat alert", "error", err, "webhook", gcn.Name)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gcn *GoogleChatNotifier) isUrlAbsolute(urlToCheck string) bool {
|
||||
parsed, err := url.Parse(urlToCheck)
|
||||
if err != nil {
|
||||
gcn.log.Warn("Could not parse URL", "urlToCheck", urlToCheck)
|
||||
return false
|
||||
}
|
||||
|
||||
return parsed.IsAbs()
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
|
||||
)
|
||||
|
||||
func TestGoogleChatNotifier(t *testing.T) {
|
||||
encryptionService := encryptionservice.SetupTestService(t)
|
||||
|
||||
t.Run("Parsing alert notification from settings", func(t *testing.T) {
|
||||
t.Run("empty settings should return error", func(t *testing.T) {
|
||||
json := `{ }`
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "ops",
|
||||
Type: "googlechat",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
_, err := newGoogleChatNotifier(nil, model, encryptionService.GetDecryptedValue, nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("from settings", func(t *testing.T) {
|
||||
json := `
|
||||
{
|
||||
"url": "http://google.com"
|
||||
}`
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "ops",
|
||||
Type: "googlechat",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := newGoogleChatNotifier(nil, model, encryptionService.GetDecryptedValue, nil)
|
||||
webhookNotifier := not.(*GoogleChatNotifier)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "ops", webhookNotifier.Name)
|
||||
require.Equal(t, "googlechat", webhookNotifier.Type)
|
||||
require.Equal(t, "http://google.com", webhookNotifier.URL)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func init() {
|
||||
alerting.RegisterNotifier(&alerting.NotifierPlugin{
|
||||
Type: "hipchat",
|
||||
Name: "HipChat",
|
||||
Description: "Sends notifications uto a HipChat Room",
|
||||
Heading: "HipChat settings",
|
||||
Factory: NewHipChatNotifier,
|
||||
Options: []alerting.NotifierOption{
|
||||
{
|
||||
Label: "Hip Chat Url",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Placeholder: "HipChat URL (ex https://grafana.hipchat.com)",
|
||||
PropertyName: "url",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Label: "API Key",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Placeholder: "HipChat API Key",
|
||||
PropertyName: "apiKey",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Label: "Room ID",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
PropertyName: "roomid",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
maxFieldCount int = 4
|
||||
)
|
||||
|
||||
// NewHipChatNotifier is the constructor functions
|
||||
// for the HipChatNotifier
|
||||
func NewHipChatNotifier(_ *setting.Cfg, model *models.AlertNotification, _ alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) {
|
||||
url := model.Settings.Get("url").MustString()
|
||||
url = strings.TrimSuffix(url, "/")
|
||||
if url == "" {
|
||||
return nil, alerting.ValidationError{Reason: "Could not find url property in settings"}
|
||||
}
|
||||
|
||||
apikey := model.Settings.Get("apikey").MustString()
|
||||
roomID := model.Settings.Get("roomid").MustString()
|
||||
|
||||
return &HipChatNotifier{
|
||||
NotifierBase: NewNotifierBase(model, ns),
|
||||
URL: url,
|
||||
APIKey: apikey,
|
||||
RoomID: roomID,
|
||||
log: log.New("alerting.notifier.hipchat"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HipChatNotifier is responsible for sending
|
||||
// alert notifications to Hipchat.
|
||||
type HipChatNotifier struct {
|
||||
NotifierBase
|
||||
URL string
|
||||
APIKey string
|
||||
RoomID string
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
// Notify sends an alert notification to HipChat
|
||||
func (hc *HipChatNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
hc.log.Info("Executing hipchat notification", "ruleId", evalContext.Rule.ID, "notification", hc.Name)
|
||||
|
||||
ruleURL, err := evalContext.GetRuleURL()
|
||||
if err != nil {
|
||||
hc.log.Error("Failed get rule link", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
attributes := make([]map[string]any, 0)
|
||||
for index, evt := range evalContext.EvalMatches {
|
||||
metricName := evt.Metric
|
||||
if len(metricName) > 50 {
|
||||
metricName = metricName[:50]
|
||||
}
|
||||
attributes = append(attributes, map[string]any{
|
||||
"label": metricName,
|
||||
"value": map[string]any{
|
||||
"label": strconv.FormatFloat(evt.Value.Float64, 'f', -1, 64),
|
||||
},
|
||||
})
|
||||
if index > maxFieldCount {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if evalContext.Error != nil {
|
||||
attributes = append(attributes, map[string]any{
|
||||
"label": "Error message",
|
||||
"value": map[string]any{
|
||||
"label": evalContext.Error.Error(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
message := ""
|
||||
if evalContext.Rule.State != models.AlertStateOK { // don't add message when going back to alert state ok.
|
||||
message += " " + evalContext.Rule.Message
|
||||
}
|
||||
|
||||
if message == "" {
|
||||
message = evalContext.GetNotificationTitle() + " in state " + evalContext.GetStateModel().Text
|
||||
}
|
||||
|
||||
// HipChat has a set list of colors
|
||||
var color string
|
||||
switch evalContext.Rule.State {
|
||||
case models.AlertStateOK:
|
||||
color = "green"
|
||||
case models.AlertStateNoData:
|
||||
color = "gray"
|
||||
case models.AlertStateAlerting:
|
||||
color = "red"
|
||||
default:
|
||||
// Handle other cases?
|
||||
}
|
||||
|
||||
// Add a card with link to the dashboard
|
||||
card := map[string]any{
|
||||
"style": "application",
|
||||
"url": ruleURL,
|
||||
"id": "1",
|
||||
"title": evalContext.GetNotificationTitle(),
|
||||
"description": message,
|
||||
"icon": map[string]any{
|
||||
"url": "https://grafana.com/static/assets/img/fav32.png",
|
||||
},
|
||||
"date": evalContext.EndTime.Unix(),
|
||||
"attributes": attributes,
|
||||
}
|
||||
if hc.NeedsImage() && evalContext.ImagePublicURL != "" {
|
||||
card["thumbnail"] = map[string]any{
|
||||
"url": evalContext.ImagePublicURL,
|
||||
"url@2x": evalContext.ImagePublicURL,
|
||||
"width": 1193,
|
||||
"height": 564,
|
||||
}
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"message": message,
|
||||
"notify": "true",
|
||||
"message_format": "html",
|
||||
"color": color,
|
||||
"card": card,
|
||||
}
|
||||
|
||||
hipURL := fmt.Sprintf("%s/v2/room/%s/notification?auth_token=%s", hc.URL, hc.RoomID, hc.APIKey)
|
||||
data, _ := json.Marshal(&body)
|
||||
hc.log.Info("Request payload", "json", string(data))
|
||||
cmd := ¬ifications.SendWebhookSync{Url: hipURL, Body: string(data)}
|
||||
|
||||
if err := hc.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil {
|
||||
hc.log.Error("Failed to send hipchat notification", "error", err, "webhook", hc.Name)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
|
||||
)
|
||||
|
||||
//nolint:goconst
|
||||
func TestHipChatNotifier(t *testing.T) {
|
||||
encryptionService := encryptionservice.SetupTestService(t)
|
||||
|
||||
t.Run("Parsing alert notification from settings", func(t *testing.T) {
|
||||
t.Run("empty settings should return error", func(t *testing.T) {
|
||||
json := `{ }`
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "ops",
|
||||
Type: "hipchat",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
_, err := NewHipChatNotifier(nil, model, encryptionService.GetDecryptedValue, nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("from settings", func(t *testing.T) {
|
||||
json := `
|
||||
{
|
||||
"url": "http://google.com"
|
||||
}`
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "ops",
|
||||
Type: "hipchat",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewHipChatNotifier(nil, model, encryptionService.GetDecryptedValue, nil)
|
||||
hipchatNotifier := not.(*HipChatNotifier)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "ops", hipchatNotifier.Name)
|
||||
require.Equal(t, "hipchat", hipchatNotifier.Type)
|
||||
require.Equal(t, "http://google.com", hipchatNotifier.URL)
|
||||
require.Equal(t, "", hipchatNotifier.APIKey)
|
||||
require.Equal(t, "", hipchatNotifier.RoomID)
|
||||
})
|
||||
|
||||
t.Run("from settings with Recipient and Mention", func(t *testing.T) {
|
||||
json := `
|
||||
{
|
||||
"url": "http://www.hipchat.com",
|
||||
"apikey": "1234",
|
||||
"roomid": "1234"
|
||||
}`
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "ops",
|
||||
Type: "hipchat",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewHipChatNotifier(nil, model, encryptionService.GetDecryptedValue, nil)
|
||||
hipchatNotifier := not.(*HipChatNotifier)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "ops", hipchatNotifier.Name)
|
||||
require.Equal(t, "hipchat", hipchatNotifier.Type)
|
||||
require.Equal(t, "http://www.hipchat.com", hipchatNotifier.URL)
|
||||
require.Equal(t, "1234", hipchatNotifier.APIKey)
|
||||
require.Equal(t, "1234", hipchatNotifier.RoomID)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func init() {
|
||||
alerting.RegisterNotifier(&alerting.NotifierPlugin{
|
||||
Type: "kafka",
|
||||
Name: "Kafka REST Proxy",
|
||||
Description: "Sends notifications to Kafka Rest Proxy",
|
||||
Heading: "Kafka settings",
|
||||
Factory: NewKafkaNotifier,
|
||||
Options: []alerting.NotifierOption{
|
||||
{
|
||||
Label: "Kafka REST Proxy",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Placeholder: "http://localhost:8082",
|
||||
PropertyName: "kafkaRestProxy",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Label: "Topic",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Placeholder: "topic1",
|
||||
PropertyName: "kafkaTopic",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// NewKafkaNotifier is the constructor function for the Kafka notifier.
|
||||
func NewKafkaNotifier(_ *setting.Cfg, model *models.AlertNotification, _ alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) {
|
||||
endpoint := model.Settings.Get("kafkaRestProxy").MustString()
|
||||
if endpoint == "" {
|
||||
return nil, alerting.ValidationError{Reason: "Could not find kafka rest proxy endpoint property in settings"}
|
||||
}
|
||||
topic := model.Settings.Get("kafkaTopic").MustString()
|
||||
if topic == "" {
|
||||
return nil, alerting.ValidationError{Reason: "Could not find kafka topic property in settings"}
|
||||
}
|
||||
|
||||
return &KafkaNotifier{
|
||||
NotifierBase: NewNotifierBase(model, ns),
|
||||
Endpoint: endpoint,
|
||||
Topic: topic,
|
||||
log: log.New("alerting.notifier.kafka"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// KafkaNotifier is responsible for sending
|
||||
// alert notifications to Kafka.
|
||||
type KafkaNotifier struct {
|
||||
NotifierBase
|
||||
Endpoint string
|
||||
Topic string
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
// Notify sends the alert notification.
|
||||
func (kn *KafkaNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
state := evalContext.Rule.State
|
||||
|
||||
customData := triggMetrString
|
||||
for _, evt := range evalContext.EvalMatches {
|
||||
customData += fmt.Sprintf("%s: %v\n", evt.Metric, evt.Value)
|
||||
}
|
||||
|
||||
kn.log.Info("Notifying Kafka", "alert_state", state)
|
||||
|
||||
recordJSON := simplejson.New()
|
||||
records := make([]any, 1)
|
||||
|
||||
bodyJSON := simplejson.New()
|
||||
// get alert state in the kafka output issue #11401
|
||||
bodyJSON.Set("alert_state", state)
|
||||
bodyJSON.Set("description", evalContext.Rule.Name+" - "+evalContext.Rule.Message)
|
||||
bodyJSON.Set("client", "Grafana")
|
||||
bodyJSON.Set("details", customData)
|
||||
bodyJSON.Set("incident_key", "alertId-"+strconv.FormatInt(evalContext.Rule.ID, 10))
|
||||
|
||||
ruleURL, err := evalContext.GetRuleURL()
|
||||
if err != nil {
|
||||
kn.log.Error("Failed get rule link", "error", err)
|
||||
return err
|
||||
}
|
||||
bodyJSON.Set("client_url", ruleURL)
|
||||
|
||||
if kn.NeedsImage() && evalContext.ImagePublicURL != "" {
|
||||
contexts := make([]any, 1)
|
||||
imageJSON := simplejson.New()
|
||||
imageJSON.Set("type", "image")
|
||||
imageJSON.Set("src", evalContext.ImagePublicURL)
|
||||
contexts[0] = imageJSON
|
||||
bodyJSON.Set("contexts", contexts)
|
||||
}
|
||||
|
||||
valueJSON := simplejson.New()
|
||||
valueJSON.Set("value", bodyJSON)
|
||||
records[0] = valueJSON
|
||||
recordJSON.Set("records", records)
|
||||
body, _ := recordJSON.MarshalJSON()
|
||||
|
||||
topicURL := kn.Endpoint + "/topics/" + kn.Topic
|
||||
|
||||
cmd := ¬ifications.SendWebhookSync{
|
||||
Url: topicURL,
|
||||
Body: string(body),
|
||||
HttpMethod: "POST",
|
||||
HttpHeader: map[string]string{
|
||||
"Content-Type": "application/vnd.kafka.json.v2+json",
|
||||
"Accept": "application/vnd.kafka.v2+json",
|
||||
},
|
||||
}
|
||||
|
||||
if err := kn.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil {
|
||||
kn.log.Error("Failed to send notification to Kafka", "error", err, "body", string(body))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
|
||||
)
|
||||
|
||||
func TestKafkaNotifier(t *testing.T) {
|
||||
encryptionService := encryptionservice.SetupTestService(t)
|
||||
|
||||
t.Run("Parsing alert notification from settings", func(t *testing.T) {
|
||||
t.Run("empty settings should return error", func(t *testing.T) {
|
||||
json := `{ }`
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "kafka_testing",
|
||||
Type: "kafka",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
_, err := NewKafkaNotifier(nil, model, encryptionService.GetDecryptedValue, nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("settings should send an event to kafka", func(t *testing.T) {
|
||||
json := `
|
||||
{
|
||||
"kafkaRestProxy": "http://localhost:8082",
|
||||
"kafkaTopic": "topic1"
|
||||
}`
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "kafka_testing",
|
||||
Type: "kafka",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewKafkaNotifier(nil, model, encryptionService.GetDecryptedValue, nil)
|
||||
kafkaNotifier := not.(*KafkaNotifier)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "kafka_testing", kafkaNotifier.Name)
|
||||
require.Equal(t, "kafka", kafkaNotifier.Type)
|
||||
require.Equal(t, "http://localhost:8082", kafkaNotifier.Endpoint)
|
||||
require.Equal(t, "topic1", kafkaNotifier.Topic)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func init() {
|
||||
alerting.RegisterNotifier(&alerting.NotifierPlugin{
|
||||
Type: "LINE",
|
||||
Name: "LINE",
|
||||
Description: "Send notifications to LINE notify",
|
||||
Heading: "LINE notify settings",
|
||||
Factory: NewLINENotifier,
|
||||
Options: []alerting.NotifierOption{
|
||||
{
|
||||
Label: "Token",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Placeholder: "LINE notify token key",
|
||||
PropertyName: "token",
|
||||
Required: true,
|
||||
Secure: true,
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
lineNotifyURL string = "https://notify-api.line.me/api/notify"
|
||||
)
|
||||
|
||||
// NewLINENotifier is the constructor for the LINE notifier
|
||||
func NewLINENotifier(cfg *setting.Cfg, model *models.AlertNotification, fn alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) {
|
||||
token := fn(context.Background(), model.SecureSettings, "token", model.Settings.Get("token").MustString(), cfg.SecretKey)
|
||||
if token == "" {
|
||||
return nil, alerting.ValidationError{Reason: "Could not find token in settings"}
|
||||
}
|
||||
|
||||
return &LineNotifier{
|
||||
NotifierBase: NewNotifierBase(model, ns),
|
||||
Token: token,
|
||||
log: log.New("alerting.notifier.line"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LineNotifier is responsible for sending
|
||||
// alert notifications to LINE.
|
||||
type LineNotifier struct {
|
||||
NotifierBase
|
||||
Token string
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
// Notify send an alert notification to LINE
|
||||
func (ln *LineNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
ln.log.Info("Executing line notification", "ruleId", evalContext.Rule.ID, "notification", ln.Name)
|
||||
|
||||
return ln.createAlert(evalContext)
|
||||
}
|
||||
|
||||
func (ln *LineNotifier) createAlert(evalContext *alerting.EvalContext) error {
|
||||
ln.log.Info("Creating Line notify", "ruleId", evalContext.Rule.ID, "notification", ln.Name)
|
||||
ruleURL, err := evalContext.GetRuleURL()
|
||||
if err != nil {
|
||||
ln.log.Error("Failed get rule link", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
body := fmt.Sprintf("%s - %s\n%s", evalContext.GetNotificationTitle(), ruleURL, evalContext.Rule.Message)
|
||||
form.Add("message", body)
|
||||
|
||||
if ln.NeedsImage() && evalContext.ImagePublicURL != "" {
|
||||
form.Add("imageThumbnail", evalContext.ImagePublicURL)
|
||||
form.Add("imageFullsize", evalContext.ImagePublicURL)
|
||||
}
|
||||
|
||||
cmd := ¬ifications.SendWebhookSync{
|
||||
Url: lineNotifyURL,
|
||||
HttpMethod: "POST",
|
||||
HttpHeader: map[string]string{
|
||||
"Authorization": fmt.Sprintf("Bearer %s", ln.Token),
|
||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||
},
|
||||
Body: form.Encode(),
|
||||
}
|
||||
|
||||
if err := ln.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil {
|
||||
ln.log.Error("Failed to send notification to LINE", "error", err, "body", body)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func TestLineNotifier(t *testing.T) {
|
||||
encryptionService := encryptionservice.SetupTestService(t)
|
||||
|
||||
t.Run("empty settings should return error", func(t *testing.T) {
|
||||
json := `{ }`
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "line_testing",
|
||||
Type: "line",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
_, err := NewLINENotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
t.Run("settings should trigger incident", func(t *testing.T) {
|
||||
json := `
|
||||
{
|
||||
"token": "abcdefgh0123456789"
|
||||
}`
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "line_testing",
|
||||
Type: "line",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewLINENotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
lineNotifier := not.(*LineNotifier)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "line_testing", lineNotifier.Name)
|
||||
require.Equal(t, "line", lineNotifier.Type)
|
||||
require.Equal(t, "abcdefgh0123456789", lineNotifier.Token)
|
||||
})
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
const (
|
||||
sendTags = "tags"
|
||||
sendDetails = "details"
|
||||
sendBoth = "both"
|
||||
)
|
||||
|
||||
func init() {
|
||||
alerting.RegisterNotifier(&alerting.NotifierPlugin{
|
||||
Type: "opsgenie",
|
||||
Name: "OpsGenie",
|
||||
Description: "Sends notifications to OpsGenie",
|
||||
Heading: "OpsGenie settings",
|
||||
Factory: NewOpsGenieNotifier,
|
||||
Options: []alerting.NotifierOption{
|
||||
{
|
||||
Label: "API Key",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Placeholder: "OpsGenie API Key",
|
||||
PropertyName: "apiKey",
|
||||
Required: true,
|
||||
Secure: true,
|
||||
},
|
||||
{
|
||||
Label: "Alert API Url",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Placeholder: "https://api.opsgenie.com/v2/alerts",
|
||||
PropertyName: "apiUrl",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Label: "Auto close incidents",
|
||||
Element: alerting.ElementTypeCheckbox,
|
||||
Description: "Automatically close alerts in OpsGenie once the alert goes back to ok.",
|
||||
PropertyName: "autoClose",
|
||||
}, {
|
||||
Label: "Override priority",
|
||||
Element: alerting.ElementTypeCheckbox,
|
||||
Description: "Allow the alert priority to be set using the og_priority tag",
|
||||
PropertyName: "overridePriority",
|
||||
},
|
||||
{
|
||||
Label: "Send notification tags as",
|
||||
Element: alerting.ElementTypeSelect,
|
||||
SelectOptions: []alerting.SelectOption{
|
||||
{
|
||||
Value: sendTags,
|
||||
Label: "Tags",
|
||||
},
|
||||
{
|
||||
Value: sendDetails,
|
||||
Label: "Extra Properties",
|
||||
},
|
||||
{
|
||||
Value: sendBoth,
|
||||
Label: "Tags & Extra Properties",
|
||||
},
|
||||
},
|
||||
Description: "Send the notification tags to Opsgenie as either Extra Properties, Tags or both",
|
||||
PropertyName: "sendTagsAs",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
opsgenieAlertURL = "https://api.opsgenie.com/v2/alerts"
|
||||
)
|
||||
|
||||
// NewOpsGenieNotifier is the constructor for OpsGenie.
|
||||
func NewOpsGenieNotifier(cfg *setting.Cfg, model *models.AlertNotification, fn alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) {
|
||||
autoClose := model.Settings.Get("autoClose").MustBool(true)
|
||||
overridePriority := model.Settings.Get("overridePriority").MustBool(true)
|
||||
apiKey := fn(context.Background(), model.SecureSettings, "apiKey", model.Settings.Get("apiKey").MustString(), cfg.SecretKey)
|
||||
apiURL := model.Settings.Get("apiUrl").MustString()
|
||||
if apiKey == "" {
|
||||
return nil, alerting.ValidationError{Reason: "Could not find api key property in settings"}
|
||||
}
|
||||
if apiURL == "" {
|
||||
apiURL = opsgenieAlertURL
|
||||
}
|
||||
|
||||
sendTagsAs := model.Settings.Get("sendTagsAs").MustString(sendTags)
|
||||
if sendTagsAs != sendTags && sendTagsAs != sendDetails && sendTagsAs != sendBoth {
|
||||
return nil, alerting.ValidationError{
|
||||
Reason: fmt.Sprintf("Invalid value for sendTagsAs: %q", sendTagsAs),
|
||||
}
|
||||
}
|
||||
|
||||
return &OpsGenieNotifier{
|
||||
NotifierBase: NewNotifierBase(model, ns),
|
||||
APIKey: apiKey,
|
||||
APIUrl: apiURL,
|
||||
AutoClose: autoClose,
|
||||
OverridePriority: overridePriority,
|
||||
SendTagsAs: sendTagsAs,
|
||||
log: log.New("alerting.notifier.opsgenie"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OpsGenieNotifier is responsible for sending
|
||||
// alert notifications to OpsGenie
|
||||
type OpsGenieNotifier struct {
|
||||
NotifierBase
|
||||
APIKey string
|
||||
APIUrl string
|
||||
AutoClose bool
|
||||
OverridePriority bool
|
||||
SendTagsAs string
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
// Notify sends an alert notification to OpsGenie.
|
||||
func (on *OpsGenieNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
var err error
|
||||
switch evalContext.Rule.State {
|
||||
case models.AlertStateOK:
|
||||
if on.AutoClose {
|
||||
err = on.closeAlert(evalContext)
|
||||
}
|
||||
case models.AlertStateAlerting:
|
||||
err = on.createAlert(evalContext)
|
||||
default:
|
||||
// Handle other cases?
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (on *OpsGenieNotifier) createAlert(evalContext *alerting.EvalContext) error {
|
||||
on.log.Info("Creating OpsGenie alert", "ruleId", evalContext.Rule.ID, "notification", on.Name)
|
||||
|
||||
ruleURL, err := evalContext.GetRuleURL()
|
||||
if err != nil {
|
||||
on.log.Error("Failed get rule link", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
customData := triggMetrString
|
||||
for _, evt := range evalContext.EvalMatches {
|
||||
customData += fmt.Sprintf("%s: %v\n", evt.Metric, evt.Value)
|
||||
}
|
||||
|
||||
bodyJSON := simplejson.New()
|
||||
bodyJSON.Set("message", evalContext.Rule.Name)
|
||||
bodyJSON.Set("source", "Grafana")
|
||||
bodyJSON.Set("alias", "alertId-"+strconv.FormatInt(evalContext.Rule.ID, 10))
|
||||
bodyJSON.Set("description", fmt.Sprintf("%s - %s\n%s\n%s", evalContext.Rule.Name, ruleURL, evalContext.Rule.Message, customData))
|
||||
|
||||
details := simplejson.New()
|
||||
details.Set("url", ruleURL)
|
||||
if on.NeedsImage() && evalContext.ImagePublicURL != "" {
|
||||
details.Set("image", evalContext.ImagePublicURL)
|
||||
}
|
||||
|
||||
tags := make([]string, 0)
|
||||
for _, tag := range evalContext.Rule.AlertRuleTags {
|
||||
if on.sendDetails() {
|
||||
details.Set(tag.Key, tag.Value)
|
||||
}
|
||||
|
||||
if on.sendTags() {
|
||||
if len(tag.Value) > 0 {
|
||||
tags = append(tags, fmt.Sprintf("%s:%s", tag.Key, tag.Value))
|
||||
} else {
|
||||
tags = append(tags, tag.Key)
|
||||
}
|
||||
}
|
||||
if tag.Key == "og_priority" {
|
||||
if on.OverridePriority {
|
||||
validPriorities := map[string]bool{"P1": true, "P2": true, "P3": true, "P4": true, "P5": true}
|
||||
if validPriorities[tag.Value] {
|
||||
bodyJSON.Set("priority", tag.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
bodyJSON.Set("tags", tags)
|
||||
bodyJSON.Set("details", details)
|
||||
|
||||
body, _ := bodyJSON.MarshalJSON()
|
||||
|
||||
cmd := ¬ifications.SendWebhookSync{
|
||||
Url: on.APIUrl,
|
||||
Body: string(body),
|
||||
HttpMethod: "POST",
|
||||
HttpHeader: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": fmt.Sprintf("GenieKey %s", on.APIKey),
|
||||
},
|
||||
}
|
||||
|
||||
if err := on.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil {
|
||||
on.log.Error("Failed to send notification to OpsGenie", "error", err, "body", string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (on *OpsGenieNotifier) closeAlert(evalContext *alerting.EvalContext) error {
|
||||
on.log.Info("Closing OpsGenie alert", "ruleId", evalContext.Rule.ID, "notification", on.Name)
|
||||
|
||||
bodyJSON := simplejson.New()
|
||||
bodyJSON.Set("source", "Grafana")
|
||||
body, _ := bodyJSON.MarshalJSON()
|
||||
|
||||
cmd := ¬ifications.SendWebhookSync{
|
||||
Url: fmt.Sprintf("%s/alertId-%d/close?identifierType=alias", on.APIUrl, evalContext.Rule.ID),
|
||||
Body: string(body),
|
||||
HttpMethod: "POST",
|
||||
HttpHeader: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": fmt.Sprintf("GenieKey %s", on.APIKey),
|
||||
},
|
||||
}
|
||||
|
||||
if err := on.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil {
|
||||
on.log.Error("Failed to send notification to OpsGenie", "error", err, "body", string(body))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (on *OpsGenieNotifier) sendDetails() bool {
|
||||
return on.SendTagsAs == sendDetails || on.SendTagsAs == sendBoth
|
||||
}
|
||||
|
||||
func (on *OpsGenieNotifier) sendTags() bool {
|
||||
return on.SendTagsAs == sendTags || on.SendTagsAs == sendBoth
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/annotations/annotationstest"
|
||||
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/grafana/grafana/pkg/services/tag"
|
||||
"github.com/grafana/grafana/pkg/services/validations"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func TestOpsGenieNotifier(t *testing.T) {
|
||||
encryptionService := encryptionservice.SetupTestService(t)
|
||||
|
||||
t.Run("Parsing alert notification from settings", func(t *testing.T) {
|
||||
t.Run("empty settings should return error", func(t *testing.T) {
|
||||
json := `{ }`
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "opsgenie_testing",
|
||||
Type: "opsgenie",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
_, err := NewOpsGenieNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("settings should trigger incident", func(t *testing.T) {
|
||||
json := `
|
||||
{
|
||||
"apiKey": "abcdefgh0123456789"
|
||||
}`
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "opsgenie_testing",
|
||||
Type: "opsgenie",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewOpsGenieNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
opsgenieNotifier := not.(*OpsGenieNotifier)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "opsgenie_testing", opsgenieNotifier.Name)
|
||||
require.Equal(t, "opsgenie", opsgenieNotifier.Type)
|
||||
require.Equal(t, "abcdefgh0123456789", opsgenieNotifier.APIKey)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Handling notification tags", func(t *testing.T) {
|
||||
t.Run("invalid sendTagsAs value should return error", func(t *testing.T) {
|
||||
json := `{
|
||||
"apiKey": "abcdefgh0123456789",
|
||||
"sendTagsAs": "not_a_valid_value"
|
||||
}`
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "opsgenie_testing",
|
||||
Type: "opsgenie",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
_, err := NewOpsGenieNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, reflect.TypeOf(err), reflect.TypeOf(alerting.ValidationError{}))
|
||||
require.True(t, strings.HasSuffix(err.Error(), "Invalid value for sendTagsAs: \"not_a_valid_value\""))
|
||||
})
|
||||
|
||||
t.Run("alert payload should include tag pairs only as an array in the tags key when sendAsTags is not set", func(t *testing.T) {
|
||||
json := `{
|
||||
"apiKey": "abcdefgh0123456789"
|
||||
}`
|
||||
|
||||
tagPairs := []*tag.Tag{
|
||||
{Key: "keyOnly"},
|
||||
{Key: "aKey", Value: "aValue"},
|
||||
}
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "opsgenie_testing",
|
||||
Type: "opsgenie",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
notificationService := notifications.MockNotificationService()
|
||||
notifier, notifierErr := NewOpsGenieNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, notificationService) // unhandled error
|
||||
|
||||
opsgenieNotifier := notifier.(*OpsGenieNotifier)
|
||||
|
||||
evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{
|
||||
ID: 0,
|
||||
Name: "someRule",
|
||||
Message: "someMessage",
|
||||
State: models.AlertStateAlerting,
|
||||
AlertRuleTags: tagPairs,
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
evalContext.IsTestRun = true
|
||||
|
||||
tags := make([]string, 0)
|
||||
details := make(map[string]any)
|
||||
|
||||
alertErr := opsgenieNotifier.createAlert(evalContext)
|
||||
|
||||
bodyJSON, err := simplejson.NewJson([]byte(notificationService.Webhook.Body))
|
||||
if err == nil {
|
||||
tags = bodyJSON.Get("tags").MustStringArray([]string{})
|
||||
details = bodyJSON.Get("details").MustMap(map[string]any{})
|
||||
}
|
||||
|
||||
require.Nil(t, notifierErr)
|
||||
require.Nil(t, alertErr)
|
||||
require.Equal(t, tags, []string{"keyOnly", "aKey:aValue"})
|
||||
require.Equal(t, details, map[string]any{"url": ""})
|
||||
})
|
||||
|
||||
t.Run("alert payload should include tag pairs only as a map in the details key when sendAsTags=details", func(t *testing.T) {
|
||||
json := `{
|
||||
"apiKey": "abcdefgh0123456789",
|
||||
"sendTagsAs": "details"
|
||||
}`
|
||||
|
||||
tagPairs := []*tag.Tag{
|
||||
{Key: "keyOnly"},
|
||||
{Key: "aKey", Value: "aValue"},
|
||||
}
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "opsgenie_testing",
|
||||
Type: "opsgenie",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
notificationService := notifications.MockNotificationService()
|
||||
notifier, notifierErr := NewOpsGenieNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, notificationService) // unhandled error
|
||||
|
||||
opsgenieNotifier := notifier.(*OpsGenieNotifier)
|
||||
|
||||
evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{
|
||||
ID: 0,
|
||||
Name: "someRule",
|
||||
Message: "someMessage",
|
||||
State: models.AlertStateAlerting,
|
||||
AlertRuleTags: tagPairs,
|
||||
}, nil, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
evalContext.IsTestRun = true
|
||||
|
||||
tags := make([]string, 0)
|
||||
details := make(map[string]any)
|
||||
|
||||
alertErr := opsgenieNotifier.createAlert(evalContext)
|
||||
|
||||
bodyJSON, err := simplejson.NewJson([]byte(notificationService.Webhook.Body))
|
||||
if err == nil {
|
||||
tags = bodyJSON.Get("tags").MustStringArray([]string{})
|
||||
details = bodyJSON.Get("details").MustMap(map[string]any{})
|
||||
}
|
||||
|
||||
require.Nil(t, notifierErr)
|
||||
require.Nil(t, alertErr)
|
||||
require.Equal(t, tags, []string{})
|
||||
require.Equal(t, details, map[string]any{"keyOnly": "", "aKey": "aValue", "url": ""})
|
||||
})
|
||||
|
||||
t.Run("alert payload should include tag pairs as both a map in the details key and an array in the tags key when sendAsTags=both", func(t *testing.T) {
|
||||
json := `{
|
||||
"apiKey": "abcdefgh0123456789",
|
||||
"sendTagsAs": "both"
|
||||
}`
|
||||
|
||||
tagPairs := []*tag.Tag{
|
||||
{Key: "keyOnly"},
|
||||
{Key: "aKey", Value: "aValue"},
|
||||
}
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "opsgenie_testing",
|
||||
Type: "opsgenie",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
notificationService := notifications.MockNotificationService()
|
||||
notifier, notifierErr := NewOpsGenieNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, notificationService) // unhandled error
|
||||
|
||||
opsgenieNotifier := notifier.(*OpsGenieNotifier)
|
||||
|
||||
evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{
|
||||
ID: 0,
|
||||
Name: "someRule",
|
||||
Message: "someMessage",
|
||||
State: models.AlertStateAlerting,
|
||||
AlertRuleTags: tagPairs,
|
||||
}, nil, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
evalContext.IsTestRun = true
|
||||
|
||||
tags := make([]string, 0)
|
||||
details := make(map[string]any)
|
||||
|
||||
alertErr := opsgenieNotifier.createAlert(evalContext)
|
||||
|
||||
bodyJSON, err := simplejson.NewJson([]byte(notificationService.Webhook.Body))
|
||||
if err == nil {
|
||||
tags = bodyJSON.Get("tags").MustStringArray([]string{})
|
||||
details = bodyJSON.Get("details").MustMap(map[string]any{})
|
||||
}
|
||||
|
||||
require.Nil(t, notifierErr)
|
||||
require.Nil(t, alertErr)
|
||||
require.Equal(t, tags, []string{"keyOnly", "aKey:aValue"})
|
||||
require.Equal(t, details, map[string]any{"keyOnly": "", "aKey": "aValue", "url": ""})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func init() {
|
||||
alerting.RegisterNotifier(&alerting.NotifierPlugin{
|
||||
Type: "pagerduty",
|
||||
Name: "PagerDuty",
|
||||
Description: "Sends notifications to PagerDuty",
|
||||
Heading: "PagerDuty settings",
|
||||
Factory: NewPagerdutyNotifier,
|
||||
Options: []alerting.NotifierOption{
|
||||
{
|
||||
Label: "Integration Key",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Placeholder: "Pagerduty Integration Key",
|
||||
PropertyName: "integrationKey",
|
||||
Required: true,
|
||||
Secure: true,
|
||||
},
|
||||
{
|
||||
Label: "Severity",
|
||||
Element: alerting.ElementTypeSelect,
|
||||
SelectOptions: []alerting.SelectOption{
|
||||
{
|
||||
Value: "critical",
|
||||
Label: "Critical",
|
||||
},
|
||||
{
|
||||
Value: "error",
|
||||
Label: "Error",
|
||||
},
|
||||
{
|
||||
Value: "warning",
|
||||
Label: "Warning",
|
||||
},
|
||||
{
|
||||
Value: "info",
|
||||
Label: "Info",
|
||||
},
|
||||
},
|
||||
PropertyName: "severity",
|
||||
},
|
||||
{
|
||||
Label: "Auto resolve incidents",
|
||||
Element: alerting.ElementTypeCheckbox,
|
||||
Description: "Resolve incidents in pagerduty once the alert goes back to ok.",
|
||||
PropertyName: "autoResolve",
|
||||
},
|
||||
{
|
||||
Label: "Include message in details",
|
||||
Element: alerting.ElementTypeCheckbox,
|
||||
Description: "Move the alert message from the PD summary into the custom details. This changes the custom details object and may break event rules you have configured",
|
||||
PropertyName: "messageInDetails",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
var (
|
||||
pagerdutyEventAPIURL = "https://events.pagerduty.com/v2/enqueue"
|
||||
)
|
||||
|
||||
// NewPagerdutyNotifier is the constructor for the PagerDuty notifier
|
||||
func NewPagerdutyNotifier(cfg *setting.Cfg, model *models.AlertNotification, fn alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) {
|
||||
severity := model.Settings.Get("severity").MustString("critical")
|
||||
autoResolve := model.Settings.Get("autoResolve").MustBool(false)
|
||||
key := fn(context.Background(), model.SecureSettings, "integrationKey", model.Settings.Get("integrationKey").MustString(), cfg.SecretKey)
|
||||
messageInDetails := model.Settings.Get("messageInDetails").MustBool(false)
|
||||
if key == "" {
|
||||
return nil, alerting.ValidationError{Reason: "Could not find integration key property in settings"}
|
||||
}
|
||||
|
||||
return &PagerdutyNotifier{
|
||||
NotifierBase: NewNotifierBase(model, ns),
|
||||
Key: key,
|
||||
Severity: severity,
|
||||
AutoResolve: autoResolve,
|
||||
MessageInDetails: messageInDetails,
|
||||
log: log.New("alerting.notifier.pagerduty"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PagerdutyNotifier is responsible for sending
|
||||
// alert notifications to pagerduty
|
||||
type PagerdutyNotifier struct {
|
||||
NotifierBase
|
||||
Key string
|
||||
Severity string
|
||||
AutoResolve bool
|
||||
MessageInDetails bool
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
// buildEventPayload is responsible for building the event payload body for sending to Pagerduty v2 API
|
||||
func (pn *PagerdutyNotifier) buildEventPayload(evalContext *alerting.EvalContext) ([]byte, error) {
|
||||
eventType := "trigger"
|
||||
if evalContext.Rule.State == models.AlertStateOK {
|
||||
eventType = "resolve"
|
||||
}
|
||||
customData := simplejson.New()
|
||||
customData.Set("state", evalContext.Rule.State)
|
||||
if pn.MessageInDetails {
|
||||
queries := make(map[string]interface{})
|
||||
for _, evt := range evalContext.EvalMatches {
|
||||
queries[evt.Metric] = evt.Value
|
||||
}
|
||||
customData.Set("queries", queries)
|
||||
customData.Set("message", evalContext.Rule.Message)
|
||||
} else {
|
||||
for _, evt := range evalContext.EvalMatches {
|
||||
customData.Set(evt.Metric, evt.Value)
|
||||
}
|
||||
}
|
||||
|
||||
pn.log.Info("Notifying Pagerduty", "event_type", eventType)
|
||||
|
||||
payloadJSON := simplejson.New()
|
||||
|
||||
// set default, override in following case switch if defined
|
||||
payloadJSON.Set("component", "Grafana")
|
||||
payloadJSON.Set("severity", pn.Severity)
|
||||
dedupKey := "alertId-" + strconv.FormatInt(evalContext.Rule.ID, 10)
|
||||
|
||||
for _, tag := range evalContext.Rule.AlertRuleTags {
|
||||
// Override tags appropriately if they are in the PagerDuty v2 API
|
||||
switch strings.ToLower(tag.Key) {
|
||||
case "group":
|
||||
payloadJSON.Set("group", tag.Value)
|
||||
case "class":
|
||||
payloadJSON.Set("class", tag.Value)
|
||||
case "component":
|
||||
payloadJSON.Set("component", tag.Value)
|
||||
case "dedup_key":
|
||||
if len(tag.Value) > 254 {
|
||||
tag.Value = tag.Value[0:254]
|
||||
}
|
||||
dedupKey = tag.Value
|
||||
case "severity":
|
||||
// Only set severity if it's one of the PD supported enum values
|
||||
// Info, Warning, Error, or Critical (case insensitive)
|
||||
switch sev := strings.ToLower(tag.Value); sev {
|
||||
case "info":
|
||||
fallthrough
|
||||
case "warning":
|
||||
fallthrough
|
||||
case "error":
|
||||
fallthrough
|
||||
case "critical":
|
||||
payloadJSON.Set("severity", sev)
|
||||
default:
|
||||
pn.log.Warn("Ignoring invalid severity tag", "severity", sev)
|
||||
}
|
||||
}
|
||||
customData.Set(tag.Key, tag.Value)
|
||||
}
|
||||
|
||||
var summary string
|
||||
if pn.MessageInDetails || evalContext.Rule.Message == "" {
|
||||
summary = evalContext.Rule.Name
|
||||
} else {
|
||||
summary = evalContext.Rule.Name + " - " + evalContext.Rule.Message
|
||||
}
|
||||
if len(summary) > 1024 {
|
||||
summary = summary[0:1024]
|
||||
}
|
||||
payloadJSON.Set("summary", summary)
|
||||
|
||||
if hostname, err := os.Hostname(); err == nil {
|
||||
payloadJSON.Set("source", hostname)
|
||||
}
|
||||
payloadJSON.Set("timestamp", time.Now())
|
||||
payloadJSON.Set("custom_details", customData)
|
||||
bodyJSON := simplejson.New()
|
||||
bodyJSON.Set("routing_key", pn.Key)
|
||||
bodyJSON.Set("event_action", eventType)
|
||||
bodyJSON.Set("dedup_key", dedupKey)
|
||||
bodyJSON.Set("payload", payloadJSON)
|
||||
|
||||
ruleURL, err := evalContext.GetRuleURL()
|
||||
if err != nil {
|
||||
pn.log.Error("Failed get rule link", "error", err)
|
||||
return []byte{}, err
|
||||
}
|
||||
links := make([]interface{}, 1)
|
||||
linkJSON := simplejson.New()
|
||||
linkJSON.Set("href", ruleURL)
|
||||
bodyJSON.Set("client_url", ruleURL)
|
||||
bodyJSON.Set("client", "Grafana")
|
||||
|
||||
links[0] = linkJSON
|
||||
bodyJSON.Set("links", links)
|
||||
|
||||
if pn.NeedsImage() && evalContext.ImagePublicURL != "" {
|
||||
contexts := make([]interface{}, 1)
|
||||
imageJSON := simplejson.New()
|
||||
imageJSON.Set("src", evalContext.ImagePublicURL)
|
||||
contexts[0] = imageJSON
|
||||
bodyJSON.Set("images", contexts)
|
||||
}
|
||||
|
||||
body, _ := bodyJSON.MarshalJSON()
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// Notify sends an alert notification to PagerDuty
|
||||
func (pn *PagerdutyNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
if evalContext.Rule.State == models.AlertStateOK && !pn.AutoResolve {
|
||||
pn.log.Info("Not sending a trigger to Pagerduty", "state", evalContext.Rule.State, "auto resolve", pn.AutoResolve)
|
||||
return nil
|
||||
}
|
||||
|
||||
body, err := pn.buildEventPayload(evalContext)
|
||||
if err != nil {
|
||||
pn.log.Error("Unable to build PagerDuty event payload", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := ¬ifications.SendWebhookSync{
|
||||
Url: pagerdutyEventAPIURL,
|
||||
Body: string(body),
|
||||
HttpMethod: "POST",
|
||||
HttpHeader: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
|
||||
if err := pn.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil {
|
||||
pn.log.Error("Failed to send notification to Pagerduty", "error", err, "body", string(body))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,543 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/null"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/annotations/annotationstest"
|
||||
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
|
||||
"github.com/grafana/grafana/pkg/services/tag"
|
||||
"github.com/grafana/grafana/pkg/services/validations"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func presenceComparer(a, b string) bool {
|
||||
if a == "<<PRESENCE>>" {
|
||||
return b != ""
|
||||
}
|
||||
if b == "<<PRESENCE>>" {
|
||||
return a != ""
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
|
||||
func TestPagerdutyNotifier(t *testing.T) {
|
||||
encryptionService := encryptionservice.SetupTestService(t)
|
||||
|
||||
t.Run("empty settings should return error", func(t *testing.T) {
|
||||
json := `{ }`
|
||||
|
||||
settingsJSON, err := simplejson.NewJson([]byte(json))
|
||||
require.Nil(t, err)
|
||||
|
||||
model := &models.AlertNotification{
|
||||
Name: "pageduty_testing",
|
||||
Type: "pagerduty",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
_, err = NewPagerdutyNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("severity should override default", func(t *testing.T) {
|
||||
json := `{ "integrationKey": "abcdefgh0123456789", "severity": "info", "tags": ["foo"]}`
|
||||
|
||||
settingsJSON, err := simplejson.NewJson([]byte(json))
|
||||
require.Nil(t, err)
|
||||
|
||||
model := &models.AlertNotification{
|
||||
Name: "pagerduty_testing",
|
||||
Type: "pagerduty",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewPagerdutyNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
pagerdutyNotifier := not.(*PagerdutyNotifier)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "pagerduty_testing", pagerdutyNotifier.Name)
|
||||
require.Equal(t, "pagerduty", pagerdutyNotifier.Type)
|
||||
require.Equal(t, "abcdefgh0123456789", pagerdutyNotifier.Key)
|
||||
require.Equal(t, "info", pagerdutyNotifier.Severity)
|
||||
require.False(t, pagerdutyNotifier.AutoResolve)
|
||||
})
|
||||
|
||||
t.Run("auto resolve and severity should have expected defaults", func(t *testing.T) {
|
||||
json := `{ "integrationKey": "abcdefgh0123456789" }`
|
||||
|
||||
settingsJSON, err := simplejson.NewJson([]byte(json))
|
||||
require.Nil(t, err)
|
||||
|
||||
model := &models.AlertNotification{
|
||||
Name: "pagerduty_testing",
|
||||
Type: "pagerduty",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewPagerdutyNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
pagerdutyNotifier := not.(*PagerdutyNotifier)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "pagerduty_testing", pagerdutyNotifier.Name)
|
||||
require.Equal(t, "pagerduty", pagerdutyNotifier.Type)
|
||||
require.Equal(t, "abcdefgh0123456789", pagerdutyNotifier.Key)
|
||||
require.Equal(t, "critical", pagerdutyNotifier.Severity)
|
||||
require.False(t, pagerdutyNotifier.AutoResolve)
|
||||
})
|
||||
|
||||
t.Run("settings should trigger incident", func(t *testing.T) {
|
||||
json := `
|
||||
{
|
||||
"integrationKey": "abcdefgh0123456789",
|
||||
"autoResolve": false
|
||||
}`
|
||||
|
||||
settingsJSON, err := simplejson.NewJson([]byte(json))
|
||||
require.Nil(t, err)
|
||||
|
||||
model := &models.AlertNotification{
|
||||
Name: "pagerduty_testing",
|
||||
Type: "pagerduty",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewPagerdutyNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
pagerdutyNotifier := not.(*PagerdutyNotifier)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "pagerduty_testing", pagerdutyNotifier.Name)
|
||||
require.Equal(t, "pagerduty", pagerdutyNotifier.Type)
|
||||
require.Equal(t, "abcdefgh0123456789", pagerdutyNotifier.Key)
|
||||
require.False(t, pagerdutyNotifier.AutoResolve)
|
||||
})
|
||||
|
||||
t.Run("should return properly formatted default v2 event payload", func(t *testing.T) {
|
||||
json := `{
|
||||
"integrationKey": "abcdefgh0123456789",
|
||||
"autoResolve": false
|
||||
}`
|
||||
|
||||
settingsJSON, err := simplejson.NewJson([]byte(json))
|
||||
require.Nil(t, err)
|
||||
|
||||
model := &models.AlertNotification{
|
||||
Name: "pagerduty_testing",
|
||||
Type: "pagerduty",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewPagerdutyNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
require.Nil(t, err)
|
||||
|
||||
pagerdutyNotifier := not.(*PagerdutyNotifier)
|
||||
evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{
|
||||
ID: 0,
|
||||
Name: "someRule",
|
||||
Message: "someMessage",
|
||||
State: models.AlertStateAlerting,
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
evalContext.IsTestRun = true
|
||||
|
||||
payloadJSON, err := pagerdutyNotifier.buildEventPayload(evalContext)
|
||||
require.Nil(t, err)
|
||||
payload, err := simplejson.NewJson(payloadJSON)
|
||||
require.Nil(t, err)
|
||||
|
||||
diff := cmp.Diff(map[string]any{
|
||||
"client": "Grafana",
|
||||
"client_url": "",
|
||||
"dedup_key": "alertId-0",
|
||||
"event_action": "trigger",
|
||||
"links": []any{
|
||||
map[string]any{
|
||||
"href": "",
|
||||
},
|
||||
},
|
||||
"payload": map[string]any{
|
||||
"component": "Grafana",
|
||||
"source": "<<PRESENCE>>",
|
||||
"custom_details": map[string]any{
|
||||
"state": "alerting",
|
||||
},
|
||||
"severity": "critical",
|
||||
"summary": "someRule - someMessage",
|
||||
"timestamp": "<<PRESENCE>>",
|
||||
},
|
||||
"routing_key": "abcdefgh0123456789",
|
||||
}, payload.Interface(), cmp.Comparer(presenceComparer))
|
||||
require.Empty(t, diff)
|
||||
})
|
||||
|
||||
t.Run("should return properly formatted default v2 event payload with empty message", func(t *testing.T) {
|
||||
json := `{
|
||||
"integrationKey": "abcdefgh0123456789",
|
||||
"autoResolve": false
|
||||
}`
|
||||
|
||||
settingsJSON, err := simplejson.NewJson([]byte(json))
|
||||
require.Nil(t, err)
|
||||
|
||||
model := &models.AlertNotification{
|
||||
Name: "pagerduty_testing",
|
||||
Type: "pagerduty",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewPagerdutyNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
require.Nil(t, err)
|
||||
|
||||
pagerdutyNotifier := not.(*PagerdutyNotifier)
|
||||
evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{
|
||||
ID: 0,
|
||||
Name: "someRule",
|
||||
State: models.AlertStateAlerting,
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
evalContext.IsTestRun = true
|
||||
|
||||
payloadJSON, err := pagerdutyNotifier.buildEventPayload(evalContext)
|
||||
require.Nil(t, err)
|
||||
payload, err := simplejson.NewJson(payloadJSON)
|
||||
require.Nil(t, err)
|
||||
|
||||
diff := cmp.Diff(map[string]any{
|
||||
"client": "Grafana",
|
||||
"client_url": "",
|
||||
"dedup_key": "alertId-0",
|
||||
"event_action": "trigger",
|
||||
"links": []any{
|
||||
map[string]any{
|
||||
"href": "",
|
||||
},
|
||||
},
|
||||
"payload": map[string]any{
|
||||
"component": "Grafana",
|
||||
"source": "<<PRESENCE>>",
|
||||
"custom_details": map[string]any{
|
||||
"state": "alerting",
|
||||
},
|
||||
"severity": "critical",
|
||||
"summary": "someRule",
|
||||
"timestamp": "<<PRESENCE>>",
|
||||
},
|
||||
"routing_key": "abcdefgh0123456789",
|
||||
}, payload.Interface(), cmp.Comparer(presenceComparer))
|
||||
require.Empty(t, diff)
|
||||
})
|
||||
|
||||
t.Run("should return properly formatted payload with message moved to details", func(t *testing.T) {
|
||||
json := `{
|
||||
"integrationKey": "abcdefgh0123456789",
|
||||
"autoResolve": false,
|
||||
"messageInDetails": true
|
||||
}`
|
||||
|
||||
settingsJSON, err := simplejson.NewJson([]byte(json))
|
||||
require.Nil(t, err)
|
||||
|
||||
model := &models.AlertNotification{
|
||||
Name: "pagerduty_testing",
|
||||
Type: "pagerduty",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewPagerdutyNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
require.Nil(t, err)
|
||||
|
||||
pagerdutyNotifier := not.(*PagerdutyNotifier)
|
||||
evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{
|
||||
ID: 0,
|
||||
Name: "someRule",
|
||||
Message: "someMessage",
|
||||
State: models.AlertStateAlerting,
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
evalContext.IsTestRun = true
|
||||
evalContext.EvalMatches = []*alerting.EvalMatch{
|
||||
{
|
||||
// nil is a terrible value to test with, but the cmp.Diff doesn't
|
||||
// like comparing actual floats. So this is roughly the equivalent
|
||||
// of <<PRESENCE>>
|
||||
Value: null.FloatFromPtr(nil),
|
||||
Metric: "someMetric",
|
||||
},
|
||||
}
|
||||
|
||||
payloadJSON, err := pagerdutyNotifier.buildEventPayload(evalContext)
|
||||
require.NoError(t, err)
|
||||
payload, err := simplejson.NewJson(payloadJSON)
|
||||
require.NoError(t, err)
|
||||
|
||||
diff := cmp.Diff(map[string]any{
|
||||
"client": "Grafana",
|
||||
"client_url": "",
|
||||
"dedup_key": "alertId-0",
|
||||
"event_action": "trigger",
|
||||
"links": []any{
|
||||
map[string]any{
|
||||
"href": "",
|
||||
},
|
||||
},
|
||||
"payload": map[string]any{
|
||||
"component": "Grafana",
|
||||
"source": "<<PRESENCE>>",
|
||||
"custom_details": map[string]any{
|
||||
"message": "someMessage",
|
||||
"queries": map[string]any{
|
||||
"someMetric": nil,
|
||||
},
|
||||
"state": "alerting",
|
||||
},
|
||||
"severity": "critical",
|
||||
"summary": "someRule",
|
||||
"timestamp": "<<PRESENCE>>",
|
||||
},
|
||||
"routing_key": "abcdefgh0123456789",
|
||||
}, payload.Interface(), cmp.Comparer(presenceComparer))
|
||||
require.Empty(t, diff)
|
||||
})
|
||||
|
||||
t.Run("should return properly formatted v2 event payload when using override tags", func(t *testing.T) {
|
||||
json := `{
|
||||
"integrationKey": "abcdefgh0123456789",
|
||||
"autoResolve": false
|
||||
}`
|
||||
|
||||
settingsJSON, err := simplejson.NewJson([]byte(json))
|
||||
require.NoError(t, err)
|
||||
|
||||
model := &models.AlertNotification{
|
||||
Name: "pagerduty_testing",
|
||||
Type: "pagerduty",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewPagerdutyNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
pagerdutyNotifier := not.(*PagerdutyNotifier)
|
||||
|
||||
evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{
|
||||
ID: 0,
|
||||
Name: "someRule",
|
||||
Message: "someMessage",
|
||||
State: models.AlertStateAlerting,
|
||||
AlertRuleTags: []*tag.Tag{
|
||||
{Key: "keyOnly"},
|
||||
{Key: "group", Value: "aGroup"},
|
||||
{Key: "class", Value: "aClass"},
|
||||
{Key: "component", Value: "aComponent"},
|
||||
{Key: "severity", Value: "warning"},
|
||||
{Key: "dedup_key", Value: "key-" + strings.Repeat("x", 260)},
|
||||
},
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
evalContext.ImagePublicURL = "http://somewhere.com/omg_dont_panic.png"
|
||||
evalContext.IsTestRun = true
|
||||
|
||||
payloadJSON, err := pagerdutyNotifier.buildEventPayload(evalContext)
|
||||
require.NoError(t, err)
|
||||
payload, err := simplejson.NewJson(payloadJSON)
|
||||
require.NoError(t, err)
|
||||
|
||||
diff := cmp.Diff(map[string]any{
|
||||
"client": "Grafana",
|
||||
"client_url": "",
|
||||
"dedup_key": "key-" + strings.Repeat("x", 250),
|
||||
"event_action": "trigger",
|
||||
"links": []any{
|
||||
map[string]any{
|
||||
"href": "",
|
||||
},
|
||||
},
|
||||
"payload": map[string]any{
|
||||
"source": "<<PRESENCE>>",
|
||||
"component": "aComponent",
|
||||
"custom_details": map[string]any{
|
||||
"group": "aGroup",
|
||||
"class": "aClass",
|
||||
"component": "aComponent",
|
||||
"severity": "warning",
|
||||
"dedup_key": "key-" + strings.Repeat("x", 250),
|
||||
"keyOnly": "",
|
||||
"state": "alerting",
|
||||
},
|
||||
"severity": "warning",
|
||||
"summary": "someRule - someMessage",
|
||||
"timestamp": "<<PRESENCE>>",
|
||||
"class": "aClass",
|
||||
"group": "aGroup",
|
||||
},
|
||||
"images": []any{
|
||||
map[string]any{
|
||||
"src": "http://somewhere.com/omg_dont_panic.png",
|
||||
},
|
||||
},
|
||||
"routing_key": "abcdefgh0123456789",
|
||||
}, payload.Interface(), cmp.Comparer(presenceComparer))
|
||||
require.Empty(t, diff)
|
||||
})
|
||||
|
||||
t.Run("should support multiple levels of severity", func(t *testing.T) {
|
||||
json := `{
|
||||
"integrationKey": "abcdefgh0123456789",
|
||||
"autoResolve": false
|
||||
}`
|
||||
|
||||
settingsJSON, err := simplejson.NewJson([]byte(json))
|
||||
require.NoError(t, err)
|
||||
|
||||
model := &models.AlertNotification{
|
||||
Name: "pagerduty_testing",
|
||||
Type: "pagerduty",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewPagerdutyNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
pagerdutyNotifier := not.(*PagerdutyNotifier)
|
||||
|
||||
evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{
|
||||
ID: 0,
|
||||
Name: "someRule",
|
||||
Message: "someMessage",
|
||||
State: models.AlertStateAlerting,
|
||||
AlertRuleTags: []*tag.Tag{
|
||||
{Key: "keyOnly"},
|
||||
{Key: "group", Value: "aGroup"},
|
||||
{Key: "class", Value: "aClass"},
|
||||
{Key: "component", Value: "aComponent"},
|
||||
{Key: "severity", Value: "info"},
|
||||
},
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
evalContext.ImagePublicURL = "http://somewhere.com/omg_dont_panic.png"
|
||||
evalContext.IsTestRun = true
|
||||
|
||||
payloadJSON, err := pagerdutyNotifier.buildEventPayload(evalContext)
|
||||
require.NoError(t, err)
|
||||
payload, err := simplejson.NewJson(payloadJSON)
|
||||
require.NoError(t, err)
|
||||
|
||||
diff := cmp.Diff(map[string]any{
|
||||
"client": "Grafana",
|
||||
"client_url": "",
|
||||
"dedup_key": "alertId-0",
|
||||
"event_action": "trigger",
|
||||
"links": []any{
|
||||
map[string]any{
|
||||
"href": "",
|
||||
},
|
||||
},
|
||||
"payload": map[string]any{
|
||||
"source": "<<PRESENCE>>",
|
||||
"component": "aComponent",
|
||||
"custom_details": map[string]any{
|
||||
"group": "aGroup",
|
||||
"class": "aClass",
|
||||
"component": "aComponent",
|
||||
"severity": "info",
|
||||
"keyOnly": "",
|
||||
"state": "alerting",
|
||||
},
|
||||
"severity": "info",
|
||||
"summary": "someRule - someMessage",
|
||||
"timestamp": "<<PRESENCE>>",
|
||||
"class": "aClass",
|
||||
"group": "aGroup",
|
||||
},
|
||||
"images": []any{
|
||||
map[string]any{
|
||||
"src": "http://somewhere.com/omg_dont_panic.png",
|
||||
},
|
||||
},
|
||||
"routing_key": "abcdefgh0123456789",
|
||||
}, payload.Interface(), cmp.Comparer(presenceComparer))
|
||||
require.Empty(t, diff)
|
||||
})
|
||||
|
||||
t.Run("should ignore invalid severity for PD but keep the tag", func(t *testing.T) {
|
||||
json := `{
|
||||
"integrationKey": "abcdefgh0123456789",
|
||||
"autoResolve": false,
|
||||
"severity": "critical"
|
||||
}`
|
||||
|
||||
settingsJSON, err := simplejson.NewJson([]byte(json))
|
||||
require.NoError(t, err)
|
||||
|
||||
model := &models.AlertNotification{
|
||||
Name: "pagerduty_testing",
|
||||
Type: "pagerduty",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewPagerdutyNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
pagerdutyNotifier := not.(*PagerdutyNotifier)
|
||||
|
||||
evalContext := alerting.NewEvalContext(context.Background(), &alerting.Rule{
|
||||
ID: 0,
|
||||
Name: "someRule",
|
||||
Message: "someMessage",
|
||||
State: models.AlertStateAlerting,
|
||||
AlertRuleTags: []*tag.Tag{
|
||||
{Key: "keyOnly"},
|
||||
{Key: "group", Value: "aGroup"},
|
||||
{Key: "class", Value: "aClass"},
|
||||
{Key: "component", Value: "aComponent"},
|
||||
{Key: "severity", Value: "llama"},
|
||||
},
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
evalContext.ImagePublicURL = "http://somewhere.com/omg_dont_panic.png"
|
||||
evalContext.IsTestRun = true
|
||||
|
||||
payloadJSON, err := pagerdutyNotifier.buildEventPayload(evalContext)
|
||||
require.NoError(t, err)
|
||||
payload, err := simplejson.NewJson(payloadJSON)
|
||||
require.NoError(t, err)
|
||||
|
||||
diff := cmp.Diff(map[string]any{
|
||||
"client": "Grafana",
|
||||
"client_url": "",
|
||||
"dedup_key": "alertId-0",
|
||||
"event_action": "trigger",
|
||||
"links": []any{
|
||||
map[string]any{
|
||||
"href": "",
|
||||
},
|
||||
},
|
||||
"payload": map[string]any{
|
||||
"source": "<<PRESENCE>>",
|
||||
"component": "aComponent",
|
||||
"custom_details": map[string]any{
|
||||
"group": "aGroup",
|
||||
"class": "aClass",
|
||||
"component": "aComponent",
|
||||
"severity": "llama",
|
||||
"keyOnly": "",
|
||||
"state": "alerting",
|
||||
},
|
||||
"severity": "critical",
|
||||
"summary": "someRule - someMessage",
|
||||
"timestamp": "<<PRESENCE>>",
|
||||
"class": "aClass",
|
||||
"group": "aGroup",
|
||||
},
|
||||
"images": []any{
|
||||
map[string]any{
|
||||
"src": "http://somewhere.com/omg_dont_panic.png",
|
||||
},
|
||||
},
|
||||
"routing_key": "abcdefgh0123456789",
|
||||
}, payload.Interface(), cmp.Comparer(presenceComparer))
|
||||
require.Empty(t, diff)
|
||||
})
|
||||
}
|
||||
@@ -1,415 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
const pushoverEndpoint = "https://api.pushover.net/1/messages.json"
|
||||
|
||||
func init() {
|
||||
soundOptions := []alerting.SelectOption{
|
||||
{
|
||||
Value: "default",
|
||||
Label: "Default",
|
||||
},
|
||||
{
|
||||
Value: "pushover",
|
||||
Label: "Pushover",
|
||||
}, {
|
||||
Value: "bike",
|
||||
Label: "Bike",
|
||||
}, {
|
||||
Value: "bugle",
|
||||
Label: "Bugle",
|
||||
}, {
|
||||
Value: "cashregister",
|
||||
Label: "Cashregister",
|
||||
}, {
|
||||
Value: "classical",
|
||||
Label: "Classical",
|
||||
}, {
|
||||
Value: "cosmic",
|
||||
Label: "Cosmic",
|
||||
}, {
|
||||
Value: "falling",
|
||||
Label: "Falling",
|
||||
}, {
|
||||
Value: "gamelan",
|
||||
Label: "Gamelan",
|
||||
}, {
|
||||
Value: "incoming",
|
||||
Label: "Incoming",
|
||||
}, {
|
||||
Value: "intermission",
|
||||
Label: "Intermission",
|
||||
}, {
|
||||
Value: "magic",
|
||||
Label: "Magic",
|
||||
}, {
|
||||
Value: "mechanical",
|
||||
Label: "Mechanical",
|
||||
}, {
|
||||
Value: "pianobar",
|
||||
Label: "Pianobar",
|
||||
}, {
|
||||
Value: "siren",
|
||||
Label: "Siren",
|
||||
}, {
|
||||
Value: "spacealarm",
|
||||
Label: "Spacealarm",
|
||||
}, {
|
||||
Value: "tugboat",
|
||||
Label: "Tugboat",
|
||||
}, {
|
||||
Value: "alien",
|
||||
Label: "Alien",
|
||||
}, {
|
||||
Value: "climb",
|
||||
Label: "Climb",
|
||||
}, {
|
||||
Value: "persistent",
|
||||
Label: "Persistent",
|
||||
}, {
|
||||
Value: "echo",
|
||||
Label: "Echo",
|
||||
}, {
|
||||
Value: "updown",
|
||||
Label: "Updown",
|
||||
}, {
|
||||
Value: "none",
|
||||
Label: "None",
|
||||
},
|
||||
}
|
||||
|
||||
priorityOptions := []alerting.SelectOption{
|
||||
{
|
||||
Value: "2",
|
||||
Label: "Emergency",
|
||||
},
|
||||
{
|
||||
Value: "1",
|
||||
Label: "High",
|
||||
},
|
||||
{
|
||||
Value: "0",
|
||||
Label: "Normal",
|
||||
},
|
||||
{
|
||||
Value: "-1",
|
||||
Label: "Low",
|
||||
},
|
||||
{
|
||||
Value: "-2",
|
||||
Label: "Lowest",
|
||||
},
|
||||
}
|
||||
|
||||
alerting.RegisterNotifier(&alerting.NotifierPlugin{
|
||||
Type: "pushover",
|
||||
Name: "Pushover",
|
||||
Description: "Sends HTTP POST request to the Pushover API",
|
||||
Heading: "Pushover settings",
|
||||
Factory: NewPushoverNotifier,
|
||||
Options: []alerting.NotifierOption{
|
||||
{
|
||||
Label: "API Token",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Placeholder: "Application token",
|
||||
PropertyName: "apiToken",
|
||||
Required: true,
|
||||
Secure: true,
|
||||
},
|
||||
{
|
||||
Label: "User key(s)",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Placeholder: "comma-separated list",
|
||||
PropertyName: "userKey",
|
||||
Required: true,
|
||||
Secure: true,
|
||||
},
|
||||
{
|
||||
Label: "Device(s) (optional)",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Placeholder: "comma-separated list; leave empty to send to all devices",
|
||||
PropertyName: "device",
|
||||
},
|
||||
{
|
||||
Label: "Alerting priority",
|
||||
Element: alerting.ElementTypeSelect,
|
||||
SelectOptions: priorityOptions,
|
||||
PropertyName: "priority",
|
||||
},
|
||||
{
|
||||
Label: "OK priority",
|
||||
Element: alerting.ElementTypeSelect,
|
||||
SelectOptions: priorityOptions,
|
||||
PropertyName: "okPriority",
|
||||
},
|
||||
{
|
||||
Description: "How often (in seconds) the Pushover servers will send the same alerting or OK notification to the user.",
|
||||
Label: "Retry (Only used for Emergency Priority)",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Placeholder: "minimum 30 seconds",
|
||||
PropertyName: "retry",
|
||||
},
|
||||
{
|
||||
Description: "How many seconds the alerting or OK notification will continue to be retried.",
|
||||
Label: "Expire (Only used for Emergency Priority)",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Placeholder: "maximum 86400 seconds",
|
||||
PropertyName: "expire",
|
||||
},
|
||||
{
|
||||
Label: "Alerting sound",
|
||||
Element: alerting.ElementTypeSelect,
|
||||
SelectOptions: soundOptions,
|
||||
PropertyName: "sound",
|
||||
},
|
||||
{
|
||||
Label: "OK sound",
|
||||
Element: alerting.ElementTypeSelect,
|
||||
SelectOptions: soundOptions,
|
||||
PropertyName: "okSound",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// NewPushoverNotifier is the constructor for the Pushover Notifier
|
||||
func NewPushoverNotifier(cfg *setting.Cfg, model *models.AlertNotification, fn alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) {
|
||||
userKey := fn(context.Background(), model.SecureSettings, "userKey", model.Settings.Get("userKey").MustString(), cfg.SecretKey)
|
||||
APIToken := fn(context.Background(), model.SecureSettings, "apiToken", model.Settings.Get("apiToken").MustString(), cfg.SecretKey)
|
||||
device := model.Settings.Get("device").MustString()
|
||||
alertingPriority, err := strconv.Atoi(model.Settings.Get("priority").MustString("0")) // default Normal
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert alerting priority to integer: %w", err)
|
||||
}
|
||||
okPriority, err := strconv.Atoi(model.Settings.Get("okPriority").MustString("0")) // default Normal
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert OK priority to integer: %w", err)
|
||||
}
|
||||
retry, _ := strconv.Atoi(model.Settings.Get("retry").MustString())
|
||||
expire, _ := strconv.Atoi(model.Settings.Get("expire").MustString())
|
||||
alertingSound := model.Settings.Get("sound").MustString()
|
||||
okSound := model.Settings.Get("okSound").MustString()
|
||||
uploadImage := model.Settings.Get("uploadImage").MustBool(true)
|
||||
|
||||
if userKey == "" {
|
||||
return nil, alerting.ValidationError{Reason: "User key not given"}
|
||||
}
|
||||
if APIToken == "" {
|
||||
return nil, alerting.ValidationError{Reason: "API token not given"}
|
||||
}
|
||||
return &PushoverNotifier{
|
||||
NotifierBase: NewNotifierBase(model, ns),
|
||||
UserKey: userKey,
|
||||
APIToken: APIToken,
|
||||
AlertingPriority: alertingPriority,
|
||||
OKPriority: okPriority,
|
||||
Retry: retry,
|
||||
Expire: expire,
|
||||
Device: device,
|
||||
AlertingSound: alertingSound,
|
||||
OKSound: okSound,
|
||||
Upload: uploadImage,
|
||||
log: log.New("alerting.notifier.pushover"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PushoverNotifier is responsible for sending
|
||||
// alert notifications to Pushover
|
||||
type PushoverNotifier struct {
|
||||
NotifierBase
|
||||
UserKey string
|
||||
APIToken string
|
||||
AlertingPriority int
|
||||
OKPriority int
|
||||
Retry int
|
||||
Expire int
|
||||
Device string
|
||||
AlertingSound string
|
||||
OKSound string
|
||||
Upload bool
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
// Notify sends a alert notification to Pushover
|
||||
func (pn *PushoverNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
ruleURL, err := evalContext.GetRuleURL()
|
||||
if err != nil {
|
||||
pn.log.Error("Failed get rule link", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
message := evalContext.Rule.Message
|
||||
for idx, evt := range evalContext.EvalMatches {
|
||||
message += fmt.Sprintf("\n<b>%s</b>: %v", evt.Metric, evt.Value)
|
||||
if idx > 4 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if evalContext.Error != nil {
|
||||
message += fmt.Sprintf("\n<b>Error message:</b> %s", evalContext.Error.Error())
|
||||
}
|
||||
|
||||
if message == "" {
|
||||
message = "Notification message missing (Set a notification message to replace this text.)"
|
||||
}
|
||||
|
||||
headers, uploadBody, err := pn.genPushoverBody(evalContext, message, ruleURL)
|
||||
if err != nil {
|
||||
pn.log.Error("Failed to generate body for pushover", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := ¬ifications.SendWebhookSync{
|
||||
Url: pushoverEndpoint,
|
||||
HttpMethod: "POST",
|
||||
HttpHeader: headers,
|
||||
Body: uploadBody.String(),
|
||||
}
|
||||
|
||||
if err := pn.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil {
|
||||
pn.log.Error("Failed to send pushover notification", "error", err, "webhook", pn.Name)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pn *PushoverNotifier) genPushoverBody(evalContext *alerting.EvalContext, message string, ruleURL string) (map[string]string, bytes.Buffer, error) {
|
||||
var b bytes.Buffer
|
||||
var err error
|
||||
w := multipart.NewWriter(&b)
|
||||
|
||||
// Add image only if requested and available
|
||||
if pn.Upload && evalContext.ImageOnDiskPath != "" {
|
||||
f, err := os.Open(evalContext.ImageOnDiskPath)
|
||||
if err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
pn.log.Warn("Failed to close file", "path", evalContext.ImageOnDiskPath, "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
fw, err := w.CreateFormFile("attachment", evalContext.ImageOnDiskPath)
|
||||
if err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
|
||||
_, err = io.Copy(fw, f)
|
||||
if err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
}
|
||||
|
||||
// Add the user token
|
||||
err = w.WriteField("user", pn.UserKey)
|
||||
if err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
|
||||
// Add the api token
|
||||
err = w.WriteField("token", pn.APIToken)
|
||||
if err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
|
||||
// Add priority
|
||||
priority := pn.AlertingPriority
|
||||
if evalContext.Rule.State == models.AlertStateOK {
|
||||
priority = pn.OKPriority
|
||||
}
|
||||
err = w.WriteField("priority", strconv.Itoa(priority))
|
||||
if err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
|
||||
if priority == 2 {
|
||||
err = w.WriteField("retry", strconv.Itoa(pn.Retry))
|
||||
if err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
|
||||
err = w.WriteField("expire", strconv.Itoa(pn.Expire))
|
||||
if err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
}
|
||||
|
||||
// Add device
|
||||
if pn.Device != "" {
|
||||
err = w.WriteField("device", pn.Device)
|
||||
if err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
}
|
||||
|
||||
// Add sound
|
||||
sound := pn.AlertingSound
|
||||
if evalContext.Rule.State == models.AlertStateOK {
|
||||
sound = pn.OKSound
|
||||
}
|
||||
if sound != "default" {
|
||||
err = w.WriteField("sound", sound)
|
||||
if err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
}
|
||||
|
||||
// Add title
|
||||
err = w.WriteField("title", evalContext.GetNotificationTitle())
|
||||
if err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
|
||||
// Add URL
|
||||
err = w.WriteField("url", ruleURL)
|
||||
if err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
// Add URL title
|
||||
err = w.WriteField("url_title", "Show dashboard with alert")
|
||||
if err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
|
||||
// Add message
|
||||
err = w.WriteField("message", message)
|
||||
if err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
|
||||
// Mark as html message
|
||||
err = w.WriteField("html", "1")
|
||||
if err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
|
||||
headers := map[string]string{
|
||||
"Content-Type": w.FormDataContentType(),
|
||||
}
|
||||
return headers, b, nil
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/annotations/annotationstest"
|
||||
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
|
||||
"github.com/grafana/grafana/pkg/services/validations"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func TestPushoverNotifier(t *testing.T) {
|
||||
encryptionService := encryptionservice.SetupTestService(t)
|
||||
|
||||
t.Run("Parsing alert notification from settings", func(t *testing.T) {
|
||||
t.Run("empty settings should return error", func(t *testing.T) {
|
||||
json := `{ }`
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "Pushover",
|
||||
Type: "pushover",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
_, err := NewPushoverNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("from settings", func(t *testing.T) {
|
||||
json := `
|
||||
{
|
||||
"apiToken": "4SrUFQL4A5V5TQ1z5Pg9nxHXPXSTve",
|
||||
"userKey": "tzNZYf36y0ohWwXo4XoUrB61rz1A4o",
|
||||
"priority": "1",
|
||||
"okPriority": "2",
|
||||
"sound": "pushover",
|
||||
"okSound": "magic"
|
||||
}`
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "Pushover",
|
||||
Type: "pushover",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewPushoverNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
pushoverNotifier := not.(*PushoverNotifier)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "Pushover", pushoverNotifier.Name)
|
||||
require.Equal(t, "pushover", pushoverNotifier.Type)
|
||||
require.Equal(t, "4SrUFQL4A5V5TQ1z5Pg9nxHXPXSTve", pushoverNotifier.APIToken)
|
||||
require.Equal(t, "tzNZYf36y0ohWwXo4XoUrB61rz1A4o", pushoverNotifier.UserKey)
|
||||
require.Equal(t, 1, pushoverNotifier.AlertingPriority)
|
||||
require.Equal(t, 2, pushoverNotifier.OKPriority)
|
||||
require.Equal(t, "pushover", pushoverNotifier.AlertingSound)
|
||||
require.Equal(t, "magic", pushoverNotifier.OKSound)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenPushoverBody(t *testing.T) {
|
||||
t.Run("Given common sounds", func(t *testing.T) {
|
||||
sirenSound := "siren_sound_tst"
|
||||
successSound := "success_sound_tst"
|
||||
notifier := &PushoverNotifier{AlertingSound: sirenSound, OKSound: successSound}
|
||||
|
||||
t.Run("When alert is firing - should use siren sound", func(t *testing.T) {
|
||||
evalContext := alerting.NewEvalContext(context.Background(),
|
||||
&alerting.Rule{
|
||||
State: models.AlertStateAlerting,
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
_, pushoverBody, err := notifier.genPushoverBody(evalContext, "", "")
|
||||
|
||||
require.Nil(t, err)
|
||||
require.True(t, strings.Contains(pushoverBody.String(), sirenSound))
|
||||
})
|
||||
|
||||
t.Run("When alert is ok - should use success sound", func(t *testing.T) {
|
||||
evalContext := alerting.NewEvalContext(context.Background(),
|
||||
&alerting.Rule{
|
||||
State: models.AlertStateOK,
|
||||
}, &validations.OSSPluginRequestValidator{}, nil, nil, nil, annotationstest.NewFakeAnnotationsRepo())
|
||||
_, pushoverBody, err := notifier.genPushoverBody(evalContext, "", "")
|
||||
|
||||
require.Nil(t, err)
|
||||
require.True(t, strings.Contains(pushoverBody.String(), successSound))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func init() {
|
||||
alerting.RegisterNotifier(&alerting.NotifierPlugin{
|
||||
Type: "sensu",
|
||||
Name: "Sensu",
|
||||
Description: "Sends HTTP POST request to a Sensu API",
|
||||
Heading: "Sensu settings",
|
||||
Factory: NewSensuNotifier,
|
||||
Options: []alerting.NotifierOption{
|
||||
{
|
||||
Label: "Url",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Placeholder: "http://sensu-api.local:4567/results",
|
||||
PropertyName: "url",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Label: "Source",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Description: "If empty rule id will be used",
|
||||
PropertyName: "source",
|
||||
},
|
||||
{
|
||||
Label: "Handler",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Placeholder: "default",
|
||||
PropertyName: "handler",
|
||||
},
|
||||
{
|
||||
Label: "Username",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
PropertyName: "username",
|
||||
},
|
||||
{
|
||||
Label: "Password",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypePassword,
|
||||
PropertyName: "passsword ",
|
||||
Secure: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// NewSensuNotifier is the constructor for the Sensu Notifier.
|
||||
func NewSensuNotifier(cfg *setting.Cfg, model *models.AlertNotification, fn alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) {
|
||||
url := model.Settings.Get("url").MustString()
|
||||
if url == "" {
|
||||
return nil, alerting.ValidationError{Reason: "Could not find url property in settings"}
|
||||
}
|
||||
|
||||
return &SensuNotifier{
|
||||
NotifierBase: NewNotifierBase(model, ns),
|
||||
URL: url,
|
||||
User: model.Settings.Get("username").MustString(),
|
||||
Source: model.Settings.Get("source").MustString(),
|
||||
Password: fn(context.Background(), model.SecureSettings, "password", model.Settings.Get("password").MustString(), cfg.SecretKey),
|
||||
Handler: model.Settings.Get("handler").MustString(),
|
||||
log: log.New("alerting.notifier.sensu"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SensuNotifier is responsible for sending
|
||||
// alert notifications to Sensu.
|
||||
type SensuNotifier struct {
|
||||
NotifierBase
|
||||
URL string
|
||||
Source string
|
||||
User string
|
||||
Password string
|
||||
Handler string
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
// Notify send alert notification to Sensu
|
||||
func (sn *SensuNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
sn.log.Info("Sending sensu result")
|
||||
|
||||
bodyJSON := simplejson.New()
|
||||
bodyJSON.Set("ruleId", evalContext.Rule.ID)
|
||||
// Sensu alerts cannot have spaces in them
|
||||
bodyJSON.Set("name", strings.ReplaceAll(evalContext.Rule.Name, " ", "_"))
|
||||
// Sensu alerts require a source. We set it to the user-specified value (optional),
|
||||
// else we fallback and use the grafana ruleID.
|
||||
if sn.Source != "" {
|
||||
bodyJSON.Set("source", sn.Source)
|
||||
} else {
|
||||
bodyJSON.Set("source", "grafana_rule_"+strconv.FormatInt(evalContext.Rule.ID, 10))
|
||||
}
|
||||
// Finally, sensu expects an output
|
||||
// We set it to a default output
|
||||
bodyJSON.Set("output", "Grafana Metric Condition Met")
|
||||
bodyJSON.Set("evalMatches", evalContext.EvalMatches)
|
||||
|
||||
switch evalContext.Rule.State {
|
||||
case "alerting":
|
||||
bodyJSON.Set("status", 2)
|
||||
case "no_data":
|
||||
bodyJSON.Set("status", 1)
|
||||
default:
|
||||
bodyJSON.Set("status", 0)
|
||||
}
|
||||
|
||||
if sn.Handler != "" {
|
||||
bodyJSON.Set("handler", sn.Handler)
|
||||
}
|
||||
|
||||
ruleURL, err := evalContext.GetRuleURL()
|
||||
if err == nil {
|
||||
bodyJSON.Set("ruleUrl", ruleURL)
|
||||
}
|
||||
|
||||
if sn.NeedsImage() && evalContext.ImagePublicURL != "" {
|
||||
bodyJSON.Set("imageUrl", evalContext.ImagePublicURL)
|
||||
}
|
||||
|
||||
if evalContext.Rule.Message != "" {
|
||||
bodyJSON.Set("output", evalContext.Rule.Message)
|
||||
}
|
||||
|
||||
body, _ := bodyJSON.MarshalJSON()
|
||||
|
||||
cmd := ¬ifications.SendWebhookSync{
|
||||
Url: sn.URL,
|
||||
User: sn.User,
|
||||
Password: sn.Password,
|
||||
Body: string(body),
|
||||
HttpMethod: "POST",
|
||||
}
|
||||
|
||||
if err := sn.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil {
|
||||
sn.log.Error("Failed to send sensu event", "error", err, "sensu", sn.Name)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func TestSensuNotifier(t *testing.T) {
|
||||
encryptionService := encryptionservice.SetupTestService(t)
|
||||
|
||||
t.Run("Parsing alert notification from settings", func(t *testing.T) {
|
||||
t.Run("empty settings should return error", func(t *testing.T) {
|
||||
json := `{ }`
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "sensu",
|
||||
Type: "sensu",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
_, err := NewSensuNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("from settings", func(t *testing.T) {
|
||||
json := `
|
||||
{
|
||||
"url": "http://sensu-api.example.com:4567/results",
|
||||
"source": "grafana_instance_01",
|
||||
"handler": "myhandler"
|
||||
}`
|
||||
|
||||
settingsJSON, _ := simplejson.NewJson([]byte(json))
|
||||
model := &models.AlertNotification{
|
||||
Name: "sensu",
|
||||
Type: "sensu",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewSensuNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
sensuNotifier := not.(*SensuNotifier)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "sensu", sensuNotifier.Name)
|
||||
require.Equal(t, "sensu", sensuNotifier.Type)
|
||||
require.Equal(t, "http://sensu-api.example.com:4567/results", sensuNotifier.URL)
|
||||
require.Equal(t, "grafana_instance_01", sensuNotifier.Source)
|
||||
require.Equal(t, "myhandler", sensuNotifier.Handler)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func init() {
|
||||
alerting.RegisterNotifier(&alerting.NotifierPlugin{
|
||||
Type: "sensugo",
|
||||
Name: "Sensu Go",
|
||||
Description: "Sends HTTP POST request to a Sensu Go API",
|
||||
Heading: "Sensu Go Settings",
|
||||
Factory: NewSensuGoNotifier,
|
||||
Options: []alerting.NotifierOption{
|
||||
{
|
||||
Label: "Backend URL",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Placeholder: "http://sensu-api.local:8080",
|
||||
PropertyName: "url",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Label: "API Key",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypePassword,
|
||||
Description: "API Key to auth to Sensu Go backend",
|
||||
PropertyName: "apikey",
|
||||
Required: true,
|
||||
Secure: true,
|
||||
},
|
||||
{
|
||||
Label: "Proxy entity name",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Description: "If empty, rule name will be used",
|
||||
PropertyName: "entity",
|
||||
},
|
||||
{
|
||||
Label: "Check name",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Description: "If empty, rule id will be used",
|
||||
PropertyName: "check",
|
||||
},
|
||||
{
|
||||
Label: "Handler",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
PropertyName: "handler",
|
||||
},
|
||||
{
|
||||
Label: "Namespace",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Placeholder: "default",
|
||||
PropertyName: "namespace",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// NewSensuGoNotifier is the constructor for the Sensu Go Notifier.
|
||||
func NewSensuGoNotifier(cfg *setting.Cfg, model *models.AlertNotification, fn alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) {
|
||||
url := model.Settings.Get("url").MustString()
|
||||
apikey := fn(context.Background(), model.SecureSettings, "apikey", model.Settings.Get("apikey").MustString(), cfg.SecretKey)
|
||||
|
||||
if url == "" {
|
||||
return nil, alerting.ValidationError{Reason: "Could not find URL property in settings"}
|
||||
}
|
||||
if apikey == "" {
|
||||
return nil, alerting.ValidationError{Reason: "Could not find the API Key property in settings"}
|
||||
}
|
||||
|
||||
return &SensuGoNotifier{
|
||||
NotifierBase: NewNotifierBase(model, ns),
|
||||
URL: url,
|
||||
Entity: model.Settings.Get("entity").MustString(),
|
||||
Check: model.Settings.Get("check").MustString(),
|
||||
Namespace: model.Settings.Get("namespace").MustString(),
|
||||
Handler: model.Settings.Get("handler").MustString(),
|
||||
APIKey: apikey,
|
||||
log: log.New("alerting.notifier.sensugo"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SensuGoNotifier is responsible for sending
|
||||
// alert notifications to Sensu Go.
|
||||
type SensuGoNotifier struct {
|
||||
NotifierBase
|
||||
URL string
|
||||
Entity string
|
||||
Check string
|
||||
Namespace string
|
||||
Handler string
|
||||
APIKey string
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
// Notify send alert notification to Sensu Go
|
||||
func (sn *SensuGoNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
sn.log.Info("Sending Sensu Go result")
|
||||
|
||||
var namespace string
|
||||
|
||||
customData := triggMetrString
|
||||
for _, evt := range evalContext.EvalMatches {
|
||||
customData += fmt.Sprintf("%s: %v\n", evt.Metric, evt.Value)
|
||||
}
|
||||
|
||||
bodyJSON := simplejson.New()
|
||||
// Sensu Go alerts require an entity and a check. We set it to the user-specified
|
||||
// value (optional), else we fallback and use the grafana rule anme and ruleID.
|
||||
if sn.Entity != "" {
|
||||
bodyJSON.SetPath([]string{"entity", "metadata", "name"}, sn.Entity)
|
||||
} else {
|
||||
// Sensu Go alerts cannot have spaces in them
|
||||
bodyJSON.SetPath([]string{"entity", "metadata", "name"}, strings.ReplaceAll(evalContext.Rule.Name, " ", "_"))
|
||||
}
|
||||
if sn.Check != "" {
|
||||
bodyJSON.SetPath([]string{"check", "metadata", "name"}, sn.Check)
|
||||
} else {
|
||||
bodyJSON.SetPath([]string{"check", "metadata", "name"}, "grafana_rule_"+strconv.FormatInt(evalContext.Rule.ID, 10))
|
||||
}
|
||||
// Sensu Go requires the entity in an event specify its namespace. We set it to
|
||||
// the user-specified value (optional), else we fallback and use default
|
||||
if sn.Namespace != "" {
|
||||
bodyJSON.SetPath([]string{"entity", "metadata", "namespace"}, sn.Namespace)
|
||||
namespace = sn.Namespace
|
||||
} else {
|
||||
bodyJSON.SetPath([]string{"entity", "metadata", "namespace"}, "default")
|
||||
namespace = "default"
|
||||
}
|
||||
// Sensu Go needs check output, triggered metrics as default value
|
||||
if evalContext.Rule.Message != "" {
|
||||
bodyJSON.SetPath([]string{"check", "output"}, evalContext.Rule.Message)
|
||||
} else {
|
||||
bodyJSON.SetPath([]string{"check", "output"}, customData)
|
||||
}
|
||||
// Add timestamp detail in event
|
||||
bodyJSON.SetPath([]string{"check", "issued"}, time.Now().Unix())
|
||||
// Sensu GO requires that the check portion of the event have an interval
|
||||
bodyJSON.SetPath([]string{"check", "interval"}, 86400)
|
||||
|
||||
switch evalContext.Rule.State {
|
||||
case "alerting":
|
||||
bodyJSON.SetPath([]string{"check", "status"}, 2)
|
||||
case "no_data":
|
||||
bodyJSON.SetPath([]string{"check", "status"}, 1)
|
||||
default:
|
||||
bodyJSON.SetPath([]string{"check", "status"}, 0)
|
||||
}
|
||||
|
||||
if sn.Handler != "" {
|
||||
bodyJSON.SetPath([]string{"check", "handlers"}, []string{sn.Handler})
|
||||
}
|
||||
|
||||
ruleURL, err := evalContext.GetRuleURL()
|
||||
if err == nil {
|
||||
bodyJSON.Set("ruleUrl", ruleURL)
|
||||
}
|
||||
|
||||
labels := map[string]string{
|
||||
"ruleName": evalContext.Rule.Name,
|
||||
"ruleId": strconv.FormatInt(evalContext.Rule.ID, 10),
|
||||
"ruleURL": ruleURL,
|
||||
}
|
||||
|
||||
if sn.NeedsImage() && evalContext.ImagePublicURL != "" {
|
||||
labels["imageUrl"] = evalContext.ImagePublicURL
|
||||
}
|
||||
|
||||
bodyJSON.SetPath([]string{"check", "metadata", "labels"}, labels)
|
||||
|
||||
body, err := bodyJSON.MarshalJSON()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := ¬ifications.SendWebhookSync{
|
||||
Url: fmt.Sprintf("%s/api/core/v2/namespaces/%s/events", strings.TrimSuffix(sn.URL, "/"), namespace),
|
||||
Body: string(body),
|
||||
HttpMethod: "POST",
|
||||
HttpHeader: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": fmt.Sprintf("Key %s", sn.APIKey),
|
||||
},
|
||||
}
|
||||
if err := sn.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil {
|
||||
sn.log.Error("Failed to send Sensu Go event", "error", err, "sensugo", sn.Name)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func TestSensuGoNotifier(t *testing.T) {
|
||||
json := `{ }`
|
||||
|
||||
settingsJSON, err := simplejson.NewJson([]byte(json))
|
||||
require.NoError(t, err)
|
||||
model := &models.AlertNotification{
|
||||
Name: "Sensu Go",
|
||||
Type: "sensugo",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
encryptionService := encryptionservice.SetupTestService(t)
|
||||
|
||||
_, err = NewSensuGoNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
require.Error(t, err)
|
||||
|
||||
json = `
|
||||
{
|
||||
"url": "http://sensu-api.example.com:8080",
|
||||
"entity": "grafana_instance_01",
|
||||
"check": "grafana_rule_0",
|
||||
"namespace": "default",
|
||||
"handler": "myhandler",
|
||||
"apikey": "abcdef0123456789abcdef"
|
||||
}`
|
||||
|
||||
settingsJSON, err = simplejson.NewJson([]byte(json))
|
||||
require.NoError(t, err)
|
||||
model = &models.AlertNotification{
|
||||
Name: "Sensu Go",
|
||||
Type: "sensugo",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewSensuGoNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
require.NoError(t, err)
|
||||
sensuGoNotifier := not.(*SensuGoNotifier)
|
||||
|
||||
assert.Equal(t, "Sensu Go", sensuGoNotifier.Name)
|
||||
assert.Equal(t, "sensugo", sensuGoNotifier.Type)
|
||||
assert.Equal(t, "http://sensu-api.example.com:8080", sensuGoNotifier.URL)
|
||||
assert.Equal(t, "grafana_instance_01", sensuGoNotifier.Entity)
|
||||
assert.Equal(t, "grafana_rule_0", sensuGoNotifier.Check)
|
||||
assert.Equal(t, "default", sensuGoNotifier.Namespace)
|
||||
assert.Equal(t, "myhandler", sensuGoNotifier.Handler)
|
||||
assert.Equal(t, "abcdef0123456789abcdef", sensuGoNotifier.APIKey)
|
||||
}
|
||||
@@ -1,480 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func init() {
|
||||
alerting.RegisterNotifier(&alerting.NotifierPlugin{
|
||||
Type: "slack",
|
||||
Name: "Slack",
|
||||
Description: "Sends notifications to Slack",
|
||||
Heading: "Slack settings",
|
||||
Factory: NewSlackNotifier,
|
||||
Options: []alerting.NotifierOption{
|
||||
{
|
||||
Label: "Recipient",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Description: "Specify channel, private group, or IM channel (can be an encoded ID or a name) - required unless you provide a webhook",
|
||||
PropertyName: "recipient",
|
||||
},
|
||||
// Logically, this field should be required when not using a webhook, since the Slack API needs a token.
|
||||
// However, since the UI doesn't allow to say that a field is required or not depending on another field,
|
||||
// we've gone with the compromise of making this field optional and instead return a validation error
|
||||
// if it's necessary and missing.
|
||||
{
|
||||
Label: "Token",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Description: "Provide a Slack API token (starts with \"xoxb\") - required unless you provide a webhook",
|
||||
PropertyName: "token",
|
||||
Secure: true,
|
||||
},
|
||||
{
|
||||
Label: "Username",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Description: "Set the username for the bot's message",
|
||||
PropertyName: "username",
|
||||
},
|
||||
{
|
||||
Label: "Icon emoji",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Description: "Provide an emoji to use as the icon for the bot's message. Overrides the icon URL.",
|
||||
PropertyName: "icon_emoji",
|
||||
},
|
||||
{
|
||||
Label: "Icon URL",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Description: "Provide a URL to an image to use as the icon for the bot's message",
|
||||
PropertyName: "icon_url",
|
||||
},
|
||||
{
|
||||
Label: "Mention Users",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Description: "Mention one or more users (comma separated) when notifying in a channel, by ID (you can copy this from the user's Slack profile)",
|
||||
PropertyName: "mentionUsers",
|
||||
},
|
||||
{
|
||||
Label: "Mention Groups",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Description: "Mention one or more groups (comma separated) when notifying in a channel (you can copy this from the group's Slack profile URL)",
|
||||
PropertyName: "mentionGroups",
|
||||
},
|
||||
{
|
||||
Label: "Mention Channel",
|
||||
Element: alerting.ElementTypeSelect,
|
||||
SelectOptions: []alerting.SelectOption{
|
||||
{
|
||||
Value: "",
|
||||
Label: "Disabled",
|
||||
},
|
||||
{
|
||||
Value: "here",
|
||||
Label: "Every active channel member",
|
||||
},
|
||||
{
|
||||
Value: "channel",
|
||||
Label: "Every channel member",
|
||||
},
|
||||
},
|
||||
Description: "Mention whole channel or just active members when notifying",
|
||||
PropertyName: "mentionChannel",
|
||||
},
|
||||
{
|
||||
Label: "Webhook URL",
|
||||
Element: alerting.ElementTypeInput,
|
||||
InputType: alerting.InputTypeText,
|
||||
Description: "Optionally provide a Slack incoming webhook URL for sending messages, in this case the token isn't necessary",
|
||||
Placeholder: "Slack incoming webhook URL",
|
||||
PropertyName: "url",
|
||||
Secure: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const slackAPIEndpoint = "https://slack.com/api/chat.postMessage"
|
||||
|
||||
// NewSlackNotifier is the constructor for the Slack notifier.
|
||||
func NewSlackNotifier(cfg *setting.Cfg, model *models.AlertNotification, fn alerting.GetDecryptedValueFn, ns notifications.Service) (alerting.Notifier, error) {
|
||||
urlStr := fn(context.Background(), model.SecureSettings, "url", model.Settings.Get("url").MustString(), cfg.SecretKey)
|
||||
if urlStr == "" {
|
||||
urlStr = slackAPIEndpoint
|
||||
}
|
||||
apiURL, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL %q: %w", urlStr, err)
|
||||
}
|
||||
|
||||
recipient := strings.TrimSpace(model.Settings.Get("recipient").MustString())
|
||||
if recipient == "" && apiURL.String() == slackAPIEndpoint {
|
||||
return nil, alerting.ValidationError{
|
||||
Reason: "recipient must be specified when using the Slack chat API",
|
||||
}
|
||||
}
|
||||
username := model.Settings.Get("username").MustString()
|
||||
iconEmoji := model.Settings.Get("icon_emoji").MustString()
|
||||
iconURL := model.Settings.Get("icon_url").MustString()
|
||||
mentionUsersStr := model.Settings.Get("mentionUsers").MustString()
|
||||
mentionGroupsStr := model.Settings.Get("mentionGroups").MustString()
|
||||
mentionChannel := model.Settings.Get("mentionChannel").MustString()
|
||||
token := fn(context.Background(), model.SecureSettings, "token", model.Settings.Get("token").MustString(), cfg.SecretKey)
|
||||
if token == "" && apiURL.String() == slackAPIEndpoint {
|
||||
return nil, alerting.ValidationError{
|
||||
Reason: "token must be specified when using the Slack chat API",
|
||||
}
|
||||
}
|
||||
|
||||
uploadImage := model.Settings.Get("uploadImage").MustBool(true)
|
||||
|
||||
if mentionChannel != "" && mentionChannel != "here" && mentionChannel != "channel" {
|
||||
return nil, alerting.ValidationError{
|
||||
Reason: fmt.Sprintf("Invalid value for mentionChannel: %q", mentionChannel),
|
||||
}
|
||||
}
|
||||
mentionUsers := []string{}
|
||||
for _, u := range strings.Split(mentionUsersStr, ",") {
|
||||
u = strings.TrimSpace(u)
|
||||
if u != "" {
|
||||
mentionUsers = append(mentionUsers, u)
|
||||
}
|
||||
}
|
||||
mentionGroups := []string{}
|
||||
for _, g := range strings.Split(mentionGroupsStr, ",") {
|
||||
g = strings.TrimSpace(g)
|
||||
if g != "" {
|
||||
mentionGroups = append(mentionGroups, g)
|
||||
}
|
||||
}
|
||||
|
||||
return &SlackNotifier{
|
||||
url: apiURL,
|
||||
NotifierBase: NewNotifierBase(model, ns),
|
||||
recipient: recipient,
|
||||
username: username,
|
||||
iconEmoji: iconEmoji,
|
||||
iconURL: iconURL,
|
||||
mentionUsers: mentionUsers,
|
||||
mentionGroups: mentionGroups,
|
||||
mentionChannel: mentionChannel,
|
||||
token: token,
|
||||
upload: uploadImage,
|
||||
log: log.New("alerting.notifier.slack"),
|
||||
homePath: cfg.HomePath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SlackNotifier is responsible for sending
|
||||
// alert notification to Slack.
|
||||
type SlackNotifier struct {
|
||||
NotifierBase
|
||||
url *url.URL
|
||||
recipient string
|
||||
username string
|
||||
iconEmoji string
|
||||
iconURL string
|
||||
mentionUsers []string
|
||||
mentionGroups []string
|
||||
mentionChannel string
|
||||
token string
|
||||
upload bool
|
||||
log log.Logger
|
||||
homePath string
|
||||
}
|
||||
|
||||
// Notify sends an alert notification to Slack.
|
||||
func (sn *SlackNotifier) Notify(evalContext *alerting.EvalContext) error {
|
||||
sn.log.Info("Executing slack notification", "ruleId", evalContext.Rule.ID, "notification", sn.Name)
|
||||
|
||||
ruleURL, err := evalContext.GetRuleURL()
|
||||
if err != nil {
|
||||
sn.log.Error("Failed to get rule link", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
fields := make([]map[string]interface{}, 0)
|
||||
for _, evt := range evalContext.EvalMatches {
|
||||
fields = append(fields, map[string]interface{}{
|
||||
"title": evt.Metric,
|
||||
"value": evt.Value,
|
||||
"short": true,
|
||||
})
|
||||
}
|
||||
|
||||
if evalContext.Error != nil {
|
||||
fields = append(fields, map[string]interface{}{
|
||||
"title": "Error message",
|
||||
"value": evalContext.Error.Error(),
|
||||
"short": false,
|
||||
})
|
||||
}
|
||||
|
||||
mentionsBuilder := strings.Builder{}
|
||||
appendSpace := func() {
|
||||
if mentionsBuilder.Len() > 0 {
|
||||
mentionsBuilder.WriteString(" ")
|
||||
}
|
||||
}
|
||||
mentionChannel := strings.TrimSpace(sn.mentionChannel)
|
||||
if mentionChannel != "" {
|
||||
mentionsBuilder.WriteString(fmt.Sprintf("<!%s|%s>", mentionChannel, mentionChannel))
|
||||
}
|
||||
if len(sn.mentionGroups) > 0 {
|
||||
appendSpace()
|
||||
for _, g := range sn.mentionGroups {
|
||||
mentionsBuilder.WriteString(fmt.Sprintf("<!subteam^%s>", g))
|
||||
}
|
||||
}
|
||||
if len(sn.mentionUsers) > 0 {
|
||||
appendSpace()
|
||||
for _, u := range sn.mentionUsers {
|
||||
mentionsBuilder.WriteString(fmt.Sprintf("<@%s>", u))
|
||||
}
|
||||
}
|
||||
msg := ""
|
||||
if evalContext.Rule.State != models.AlertStateOK { // don't add message when going back to alert state ok.
|
||||
msg = evalContext.Rule.Message
|
||||
}
|
||||
imageURL := ""
|
||||
// default to file.upload API method if a token is provided
|
||||
if sn.token == "" {
|
||||
imageURL = evalContext.ImagePublicURL
|
||||
}
|
||||
|
||||
var blocks []map[string]interface{}
|
||||
if mentionsBuilder.Len() > 0 {
|
||||
blocks = []map[string]interface{}{
|
||||
{
|
||||
"type": "section",
|
||||
"text": map[string]interface{}{
|
||||
"type": "mrkdwn",
|
||||
"text": mentionsBuilder.String(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
attachment := map[string]interface{}{
|
||||
"color": evalContext.GetStateModel().Color,
|
||||
"title": evalContext.GetNotificationTitle(),
|
||||
"title_link": ruleURL,
|
||||
"text": msg,
|
||||
"fallback": evalContext.GetNotificationTitle(),
|
||||
"fields": fields,
|
||||
"footer": "Grafana v" + setting.BuildVersion,
|
||||
"footer_icon": "https://grafana.com/static/assets/img/fav32.png",
|
||||
"ts": time.Now().Unix(),
|
||||
}
|
||||
if sn.NeedsImage() && imageURL != "" {
|
||||
attachment["image_url"] = imageURL
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"channel": sn.recipient,
|
||||
"attachments": []map[string]interface{}{
|
||||
attachment,
|
||||
},
|
||||
}
|
||||
if len(blocks) > 0 {
|
||||
body["blocks"] = blocks
|
||||
}
|
||||
|
||||
if sn.username != "" {
|
||||
body["username"] = sn.username
|
||||
}
|
||||
if sn.iconEmoji != "" {
|
||||
body["icon_emoji"] = sn.iconEmoji
|
||||
}
|
||||
if sn.iconURL != "" {
|
||||
body["icon_url"] = sn.iconURL
|
||||
}
|
||||
data, err := json.Marshal(&body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := sn.sendRequest(evalContext.Ctx, data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if sn.token != "" && sn.UploadImage {
|
||||
err := sn.slackFileUpload(evalContext, sn.log, sn.recipient, sn.token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sn *SlackNotifier) sendRequest(ctx context.Context, data []byte) error {
|
||||
sn.log.Debug("Sending Slack API request", "url", sn.url.String(), "data", string(data))
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, sn.url.String(), bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create HTTP request: %w", err)
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("User-Agent", "Grafana")
|
||||
if sn.token == "" {
|
||||
if sn.url.String() == slackAPIEndpoint {
|
||||
panic("Token should be set when using the Slack chat API")
|
||||
}
|
||||
} else {
|
||||
sn.log.Debug("Adding authorization header to HTTP request")
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", sn.token))
|
||||
}
|
||||
|
||||
netTransport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
Renegotiation: tls.RenegotiateFreelyAsClient,
|
||||
},
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
}).DialContext,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
}
|
||||
netClient := &http.Client{
|
||||
Timeout: time.Second * 30,
|
||||
Transport: netTransport,
|
||||
}
|
||||
resp, err := netClient.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
sn.log.Warn("Failed to close response body", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
// Slack responds to some requests with a JSON document, that might contain an error.
|
||||
rslt := struct {
|
||||
Ok bool `json:"ok"`
|
||||
Err string `json:"error"`
|
||||
}{}
|
||||
|
||||
// Marshaling can fail if Slack's response body is plain text (e.g. "ok").
|
||||
if err := json.Unmarshal(body, &rslt); err != nil && json.Valid(body) {
|
||||
sn.log.Error("Failed to unmarshal Slack API response", "url", sn.url.String(), "statusCode", resp.Status,
|
||||
"err", err)
|
||||
return fmt.Errorf("failed to unmarshal Slack API response with status code %d: %s", resp.StatusCode, err)
|
||||
}
|
||||
|
||||
if !rslt.Ok && rslt.Err != "" {
|
||||
sn.log.Error("Sending Slack API request failed", "url", sn.url.String(), "statusCode", resp.Status,
|
||||
"err", rslt.Err)
|
||||
return fmt.Errorf("failed to make Slack API request: %s", rslt.Err)
|
||||
}
|
||||
|
||||
sn.log.Debug("Sending Slack API request succeeded", "url", sn.url.String(), "statusCode", resp.Status)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
sn.log.Error("Slack API request failed", "url", sn.url.String(), "statusCode", resp.Status, "body", string(body))
|
||||
return fmt.Errorf("request to Slack API failed with status code %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
func (sn *SlackNotifier) slackFileUpload(evalContext *alerting.EvalContext, log log.Logger, recipient, token string) error {
|
||||
if evalContext.ImageOnDiskPath == "" {
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `setting.HomePath` comes from Grafana's configuration file.
|
||||
evalContext.ImageOnDiskPath = filepath.Join(sn.homePath, "public/img/mixed_styles.png")
|
||||
}
|
||||
log.Info("Uploading to slack via file.upload API")
|
||||
headers, uploadBody, err := sn.generateSlackBody(evalContext.ImageOnDiskPath, token, recipient)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := ¬ifications.SendWebhookSync{
|
||||
Url: "https://slack.com/api/files.upload", Body: uploadBody.String(), HttpHeader: headers, HttpMethod: "POST",
|
||||
}
|
||||
if err := sn.NotificationService.SendWebhookSync(evalContext.Ctx, cmd); err != nil {
|
||||
log.Error("Failed to upload slack image", "error", err, "webhook", "file.upload")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sn *SlackNotifier) generateSlackBody(path string, token string, recipient string) (map[string]string, bytes.Buffer, error) {
|
||||
// Slack requires all POSTs to files.upload to present
|
||||
// an "application/x-www-form-urlencoded" encoded querystring
|
||||
// See https://api.slack.com/methods/files.upload
|
||||
var b bytes.Buffer
|
||||
w := multipart.NewWriter(&b)
|
||||
defer func() {
|
||||
if err := w.Close(); err != nil {
|
||||
// Shouldn't matter since we already close w explicitly on the non-error path
|
||||
sn.log.Warn("Failed to close multipart writer", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Add the generated image file
|
||||
// We can ignore the gosec G304 warning on this one because `imagePath` comes
|
||||
// from the alert `evalContext` that generates the images. `evalContext` in turn derives the root of the file
|
||||
// path from configuration variables.
|
||||
// nolint:gosec
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
sn.log.Warn("Failed to close file", "path", path, "err", err)
|
||||
}
|
||||
}()
|
||||
fw, err := w.CreateFormFile("file", path)
|
||||
if err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
if _, err := io.Copy(fw, f); err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
// Add the authorization token
|
||||
if err := w.WriteField("token", token); err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
// Add the channel(s) to POST to
|
||||
if err := w.WriteField("channels", recipient); err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, b, fmt.Errorf("failed to close multipart writer: %w", err)
|
||||
}
|
||||
headers := map[string]string{
|
||||
"Content-Type": w.FormDataContentType(),
|
||||
"Authorization": "auth_token=\"" + token + "\"",
|
||||
}
|
||||
return headers, b, nil
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
package notifiers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/services/alerting/models"
|
||||
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func TestSlackNotifier(t *testing.T) {
|
||||
encryptionService := encryptionservice.SetupTestService(t)
|
||||
|
||||
t.Run("empty settings should return error", func(t *testing.T) {
|
||||
json := `{ }`
|
||||
|
||||
settingsJSON, err := simplejson.NewJson([]byte(json))
|
||||
require.NoError(t, err)
|
||||
model := &models.AlertNotification{
|
||||
Name: "ops",
|
||||
Type: "slack",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
_, err = NewSlackNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
assert.EqualError(t, err, "alert validation error: recipient must be specified when using the Slack chat API")
|
||||
})
|
||||
|
||||
t.Run("from settings", func(t *testing.T) {
|
||||
json := `
|
||||
{
|
||||
"url": "http://google.com"
|
||||
}`
|
||||
|
||||
settingsJSON, err := simplejson.NewJson([]byte(json))
|
||||
require.NoError(t, err)
|
||||
model := &models.AlertNotification{
|
||||
Name: "ops",
|
||||
Type: "slack",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewSlackNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
require.NoError(t, err)
|
||||
slackNotifier := not.(*SlackNotifier)
|
||||
assert.Equal(t, "ops", slackNotifier.Name)
|
||||
assert.Equal(t, "slack", slackNotifier.Type)
|
||||
assert.Equal(t, "http://google.com", slackNotifier.url.String())
|
||||
assert.Empty(t, slackNotifier.recipient)
|
||||
assert.Empty(t, slackNotifier.username)
|
||||
assert.Empty(t, slackNotifier.iconEmoji)
|
||||
assert.Empty(t, slackNotifier.iconURL)
|
||||
assert.Empty(t, slackNotifier.mentionUsers)
|
||||
assert.Empty(t, slackNotifier.mentionGroups)
|
||||
assert.Empty(t, slackNotifier.mentionChannel)
|
||||
assert.Empty(t, slackNotifier.token)
|
||||
})
|
||||
|
||||
t.Run("from settings with Recipient, Username, IconEmoji, IconUrl, MentionUsers, MentionGroups, MentionChannel, and Token", func(t *testing.T) {
|
||||
json := `
|
||||
{
|
||||
"url": "http://google.com",
|
||||
"recipient": "#ds-opentsdb",
|
||||
"username": "Grafana Alerts",
|
||||
"icon_emoji": ":smile:",
|
||||
"icon_url": "https://grafana.com/img/fav32.png",
|
||||
"mentionUsers": "user1, user2",
|
||||
"mentionGroups": "group1, group2",
|
||||
"mentionChannel": "here",
|
||||
"token": "xoxb-XXXXXXXX-XXXXXXXX-XXXXXXXXXX"
|
||||
}`
|
||||
|
||||
settingsJSON, err := simplejson.NewJson([]byte(json))
|
||||
require.NoError(t, err)
|
||||
model := &models.AlertNotification{
|
||||
Name: "ops",
|
||||
Type: "slack",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewSlackNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
require.NoError(t, err)
|
||||
slackNotifier := not.(*SlackNotifier)
|
||||
assert.Equal(t, "ops", slackNotifier.Name)
|
||||
assert.Equal(t, "slack", slackNotifier.Type)
|
||||
assert.Equal(t, "http://google.com", slackNotifier.url.String())
|
||||
assert.Equal(t, "#ds-opentsdb", slackNotifier.recipient)
|
||||
assert.Equal(t, "Grafana Alerts", slackNotifier.username)
|
||||
assert.Equal(t, ":smile:", slackNotifier.iconEmoji)
|
||||
assert.Equal(t, "https://grafana.com/img/fav32.png", slackNotifier.iconURL)
|
||||
assert.Equal(t, []string{"user1", "user2"}, slackNotifier.mentionUsers)
|
||||
assert.Equal(t, []string{"group1", "group2"}, slackNotifier.mentionGroups)
|
||||
assert.Equal(t, "here", slackNotifier.mentionChannel)
|
||||
assert.Equal(t, "xoxb-XXXXXXXX-XXXXXXXX-XXXXXXXXXX", slackNotifier.token)
|
||||
})
|
||||
|
||||
t.Run("from settings with Recipient, Username, IconEmoji, IconUrl, MentionUsers, MentionGroups, MentionChannel, and Secured Token", func(t *testing.T) {
|
||||
json := `
|
||||
{
|
||||
"url": "http://google.com",
|
||||
"recipient": "#ds-opentsdb",
|
||||
"username": "Grafana Alerts",
|
||||
"icon_emoji": ":smile:",
|
||||
"icon_url": "https://grafana.com/img/fav32.png",
|
||||
"mentionUsers": "user1, user2",
|
||||
"mentionGroups": "group1, group2",
|
||||
"mentionChannel": "here",
|
||||
"token": "uenc-XXXXXXXX-XXXXXXXX-XXXXXXXXXX"
|
||||
}`
|
||||
|
||||
settingsJSON, err := simplejson.NewJson([]byte(json))
|
||||
require.NoError(t, err)
|
||||
|
||||
encryptionService := encryptionService
|
||||
cfg := setting.NewCfg()
|
||||
securedSettingsJSON, err := encryptionService.EncryptJsonData(
|
||||
context.Background(),
|
||||
map[string]string{
|
||||
"token": "xenc-XXXXXXXX-XXXXXXXX-XXXXXXXXXX",
|
||||
}, cfg.SecretKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
model := &models.AlertNotification{
|
||||
Name: "ops",
|
||||
Type: "slack",
|
||||
Settings: settingsJSON,
|
||||
SecureSettings: securedSettingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewSlackNotifier(cfg, model, encryptionService.GetDecryptedValue, nil)
|
||||
require.NoError(t, err)
|
||||
slackNotifier := not.(*SlackNotifier)
|
||||
assert.Equal(t, "ops", slackNotifier.Name)
|
||||
assert.Equal(t, "slack", slackNotifier.Type)
|
||||
assert.Equal(t, "http://google.com", slackNotifier.url.String())
|
||||
assert.Equal(t, "#ds-opentsdb", slackNotifier.recipient)
|
||||
assert.Equal(t, "Grafana Alerts", slackNotifier.username)
|
||||
assert.Equal(t, ":smile:", slackNotifier.iconEmoji)
|
||||
assert.Equal(t, "https://grafana.com/img/fav32.png", slackNotifier.iconURL)
|
||||
assert.Equal(t, []string{"user1", "user2"}, slackNotifier.mentionUsers)
|
||||
assert.Equal(t, []string{"group1", "group2"}, slackNotifier.mentionGroups)
|
||||
assert.Equal(t, "here", slackNotifier.mentionChannel)
|
||||
assert.Equal(t, "xenc-XXXXXXXX-XXXXXXXX-XXXXXXXXXX", slackNotifier.token)
|
||||
})
|
||||
|
||||
t.Run("with Slack ID for recipient should work", func(t *testing.T) {
|
||||
json := `
|
||||
{
|
||||
"url": "http://google.com",
|
||||
"recipient": "1ABCDE"
|
||||
}`
|
||||
|
||||
settingsJSON, err := simplejson.NewJson([]byte(json))
|
||||
require.NoError(t, err)
|
||||
model := &models.AlertNotification{
|
||||
Name: "ops",
|
||||
Type: "slack",
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
not, err := NewSlackNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
require.NoError(t, err)
|
||||
slackNotifier := not.(*SlackNotifier)
|
||||
assert.Equal(t, "1ABCDE", slackNotifier.recipient)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSendSlackRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
slackResponse string
|
||||
statusCode int
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "Example error",
|
||||
slackResponse: `{
|
||||
"ok": false,
|
||||
"error": "too_many_attachments"
|
||||
}`,
|
||||
statusCode: http.StatusBadRequest,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Non 200 status code, no response body",
|
||||
statusCode: http.StatusMovedPermanently,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Success case, normal response body",
|
||||
slackResponse: `{
|
||||
"ok": true,
|
||||
"channel": "C1H9RESGL",
|
||||
"ts": "1503435956.000247",
|
||||
"message": {
|
||||
"text": "Here's a message for you",
|
||||
"username": "ecto1",
|
||||
"bot_id": "B19LU7CSY",
|
||||
"attachments": [
|
||||
{
|
||||
"text": "This is an attachment",
|
||||
"id": 1,
|
||||
"fallback": "This is an attachment's fallback"
|
||||
}
|
||||
],
|
||||
"type": "message",
|
||||
"subtype": "bot_message",
|
||||
"ts": "1503435956.000247"
|
||||
}
|
||||
}`,
|
||||
statusCode: http.StatusOK,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "No response body",
|
||||
statusCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "Success case, unexpected response body",
|
||||
statusCode: http.StatusOK,
|
||||
slackResponse: `{"test": true}`,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Success case, ok: true",
|
||||
statusCode: http.StatusOK,
|
||||
slackResponse: `{"ok": true}`,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "200 status code, error in body",
|
||||
statusCode: http.StatusOK,
|
||||
slackResponse: `{"ok": false, "error": "test error"}`,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(tt *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(test.statusCode)
|
||||
_, err := w.Write([]byte(test.slackResponse))
|
||||
require.NoError(tt, err)
|
||||
}))
|
||||
|
||||
settingsJSON, err := simplejson.NewJson([]byte(fmt.Sprintf(`{"url": %q}`, server.URL)))
|
||||
require.NoError(t, err)
|
||||
model := &models.AlertNotification{
|
||||
Settings: settingsJSON,
|
||||
}
|
||||
|
||||
encryptionService := encryptionservice.SetupTestService(t)
|
||||
|
||||
not, err := NewSlackNotifier(setting.NewCfg(), model, encryptionService.GetDecryptedValue, nil)
|
||||
require.NoError(t, err)
|
||||
slackNotifier := not.(*SlackNotifier)
|
||||
|
||||
err = slackNotifier.sendRequest(context.Background(), []byte("test"))
|
||||
if !test.expectError {
|
||||
require.NoError(tt, err)
|
||||
} else {
|
||||
require.Error(tt, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user