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:
Gilles De Mey 2024-03-14 15:36:35 +01:00 committed by GitHub
parent f26344e176
commit 8765c48389
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
298 changed files with 540 additions and 45125 deletions

View File

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

4
.github/CODEOWNERS vendored
View File

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

2
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +0,0 @@
apiVersion: 1
providers:
- name: 'Bulk alerting dashboards'
folder: 'Bulk alerting dashboards'
type: file
options:
path: devenv/bulk_alerting_dashboards

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -68,8 +68,6 @@ provisioning/
<yaml files>
dashboards/
<yaml files>
notifiers/
<yaml files>
```
Next, we'll look at how to provision a data source.

View File

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

2
go.mod
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 &notificationService{
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, &notifierState{
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
}

View File

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

View File

@ -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 := &notifications.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, "_")
}

View File

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

View File

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

View File

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

View File

@ -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 := &notifications.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 = "![](" + picURL + ")\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)
}

View File

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

View File

@ -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 := &notifications.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
}

View File

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

View File

@ -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 := &notifications.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
}

View File

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

View File

@ -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 := &notifications.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()
}

View File

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

View File

@ -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 := &notifications.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
}

View File

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

View File

@ -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 := &notifications.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
}

View File

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

View File

@ -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 := &notifications.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
}

View File

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

View File

@ -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 := &notifications.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 := &notifications.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
}

View File

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

View File

@ -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 := &notifications.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
}

View File

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

View File

@ -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 := &notifications.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
}

View File

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

View File

@ -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 := &notifications.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
}

View File

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

View File

@ -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 := &notifications.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
}

View File

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

View File

@ -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 := &notifications.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
}

View File

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