diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml
index 6cccb20c1d..ba98c37bb1 100644
--- a/.github/BOTMETA.yml
+++ b/.github/BOTMETA.yml
@@ -935,6 +935,9 @@ files:
lib/ansible/plugins/callback/unixy.py:
support: community
maintainers: akatch
+ lib/ansible/plugins/callback/grafana_annotations.py:
+ support: community
+ maintainers: rrey
lib/ansible/plugins/cliconf/:
maintainers: $team_networking
labels: networking
diff --git a/lib/ansible/plugins/callback/grafana_annotations.py b/lib/ansible/plugins/callback/grafana_annotations.py
new file mode 100644
index 0000000000..2101faf72c
--- /dev/null
+++ b/lib/ansible/plugins/callback/grafana_annotations.py
@@ -0,0 +1,261 @@
+# -*- coding: utf-8 -*-
+# This file is part of Ansible
+#
+# Ansible is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Ansible is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Ansible. If not, see .
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import os
+import json
+import socket
+import getpass
+from base64 import b64encode
+from datetime import datetime
+
+from ansible.module_utils.urls import open_url
+from ansible.plugins.callback import CallbackBase
+
+
+DOCUMENTATION = """
+ callback: grafana_annotations
+ callback_type: notification
+ short_description: send ansible events as annotations on charts to grafana over http api.
+ author: "RĂ©mi REY (@rrey)"
+ description:
+ - This callback will report start, failed and stats events to Grafana as annotations (https://grafana.com)
+ version_added: "2.6"
+ requirements:
+ - whitelisting in configuration
+ options:
+ grafana_url:
+ description: Grafana annotations api URL
+ required: True
+ env:
+ - name: GRAFANA_URL
+ ini:
+ - section: callback_grafana_annotations
+ key: grafana_url
+ validate_grafana_certs:
+ description: (bool) validate the SSL certificate of the Grafana server. (For HTTPS url)
+ env:
+ - name: GRAFANA_VALIDATE_CERT
+ ini:
+ - section: callback_grafana_annotations
+ key: validate_grafana_certs
+ default: True
+ http_agent:
+ description: The HTTP 'User-agent' value to set in HTTP requets.
+ env:
+ - name: HTTP_AGENT
+ ini:
+ - section: callback_grafana_annotations
+ key: http_agent
+ default: 'Ansible (grafana_annotations callback)'
+ grafana_api_key:
+ description: Grafana API key, allowing to authenticate when posting on the HTTP API.
+ If not provided, grafana_login and grafana_password will
+ be required.
+ env:
+ - name: GRAFANA_API_KEY
+ ini:
+ - section: callback_grafana_annotations
+ key: grafana_api_key
+ grafana_user:
+ description: Grafana user used for authentication. Ignored if grafana_api_key is provided.
+ env:
+ - name: GRAFANA_USER
+ ini:
+ - section: callback_grafana_annotations
+ key: grafana_user
+ default: ansible
+ grafana_password:
+ description: Grafana password used for authentication. Ignored if grafana_api_key is provided.
+ env:
+ - name: GRAFANA_PASSWORD
+ ini:
+ - section: callback_grafana_annotations
+ key: grafana_password
+ default: ansible
+ grafana_dashboard_id:
+ description: The grafana dashboard id where the annotation shall be created.
+ env:
+ - name: GRAFANA_DASHBOARD_ID
+ ini:
+ - section: callback_grafana_annotations
+ key: grafana_dashboard_id
+ grafana_panel_id:
+ description: The grafana panel id where the annotation shall be created.
+ env:
+ - name: GRAFANA_PANEL_ID
+ ini:
+ - section: callback_grafana_annotations
+ key: grafana_panel_id
+"""
+
+
+PLAYBOOK_START_TXT = """\
+Started playbook {playbook}
+
+From '{hostname}'
+By user '{username}'
+"""
+
+PLAYBOOK_ERROR_TXT = """\
+Playbook {playbook} Failure !
+
+From '{hostname}'
+By user '{username}'
+
+'{task}' failed on {host}
+
+debug: {result}
+"""
+
+PLAYBOOK_STATS_TXT = """\
+Playbook {playbook}
+Duration: {duration}
+Status: {status}
+
+From '{hostname}'
+By user '{username}'
+
+Result:
+{summary}
+"""
+
+
+def to_millis(dt):
+ return int(dt.strftime('%s')) * 1000
+
+
+class CallbackModule(CallbackBase):
+ """
+ ansible grafana callback plugin
+ ansible.cfg:
+ callback_plugins =
+ callback_whitelist = grafana_annotations
+ and put the plugin in
+ """
+
+ CALLBACK_VERSION = 1.0
+ CALLBACK_TYPE = 'aggregate'
+ CALLBACK_NAME = 'grafana_annotations'
+ CALLBACK_NEEDS_WHITELIST = True
+
+ def __init__(self, display=None):
+
+ super(CallbackModule, self).__init__(display=display)
+
+ self.headers = {'Content-Type': 'application/json'}
+ self.force_basic_auth = False
+ self.hostname = socket.gethostname()
+ self.username = getpass.getuser()
+ self.start_time = datetime.now()
+ self.errors = 0
+
+ def set_options(self, task_keys=None, var_options=None, direct=None):
+
+ super(CallbackModule, self).set_options(task_keys=task_keys, var_options=var_options, direct=direct)
+
+ self.grafana_api_key = self.get_option('grafana_api_key')
+ self.grafana_url = self.get_option('grafana_url')
+ self.validate_grafana_certs = self.get_option('validate_grafana_certs')
+ self.http_agent = self.get_option('http_agent')
+ self.grafana_user = self.get_option('grafana_user')
+ self.grafana_password = self.get_option('grafana_password')
+ self.dashboard_id = self.get_option('grafana_dashboard_id')
+ self.panel_id = self.get_option('grafana_panel_id')
+
+ if self.grafana_api_key:
+ self.headers['Authorization'] = "Bearer %s" % self.grafana_api_key
+ else:
+ self.force_basic_auth = True
+
+ if self.grafana_url is None:
+ self.disabled = True
+ self._display.warning('Grafana URL was not provided. The '
+ 'Grafana URL can be provided using '
+ 'the `GRAFANA_URL` environment variable.')
+ self._display.info('Grafana URL: %s' % self.grafana_url)
+
+ def v2_playbook_on_start(self, playbook):
+ self.playbook = playbook._file_name
+ text = PLAYBOOK_START_TXT.format(playbook=self.playbook, hostname=self.hostname,
+ username=self.username)
+ data = {
+ 'time': to_millis(self.start_time),
+ 'text': text,
+ 'tags': ['ansible', 'ansible_event_start', self.playbook]
+ }
+ if self.dashboard_id:
+ data["dashboardId"] = int(self.dashboard_id)
+ if self.panel_id:
+ data["panelId"] = int(self.panel_id)
+ self._send_annotation(json.dumps(data))
+
+ def v2_playbook_on_stats(self, stats):
+ end_time = datetime.now()
+ duration = end_time - self.start_time
+ summarize_stat = {}
+ for host in stats.processed.keys():
+ summarize_stat[host] = stats.summarize(host)
+
+ status = "FAILED"
+ if self.errors == 0:
+ status = "OK"
+
+ text = PLAYBOOK_STATS_TXT.format(playbook=self.playbook, hostname=self.hostname,
+ duration=duration.total_seconds(),
+ status=status, username=self.username,
+ summary=json.dumps(summarize_stat))
+
+ data = {
+ 'time': to_millis(self.start_time),
+ 'timeEnd': to_millis(end_time),
+ 'isRegion': True,
+ 'text': text,
+ 'tags': ['ansible', 'ansible_report', self.playbook]
+ }
+ if self.dashboard_id:
+ data["dashboardId"] = int(self.dashboard_id)
+ if self.panel_id:
+ data["panelId"] = int(self.panel_id)
+ self._send_annotation(json.dumps(data))
+
+ def v2_runner_on_failed(self, result, **kwargs):
+ text = PLAYBOOK_ERROR_TXT.format(playbook=self.playbook, hostname=self.hostname,
+ username=self.username, task=result._task,
+ host=result._host.name, result=self._dump_results(result._result))
+ data = {
+ 'time': to_millis(datetime.now()),
+ 'text': text,
+ 'tags': ['ansible', 'ansible_event_failure', self.playbook]
+ }
+ self.errors += 1
+ if self.dashboard_id:
+ data["dashboardId"] = int(self.dashboard_id)
+ if self.panel_id:
+ data["panelId"] = int(self.panel_id)
+ self._send_annotation(json.dumps(data))
+
+ def _send_annotation(self, annotation):
+ try:
+ response = open_url(self.grafana_url, data=annotation, headers=self.headers,
+ method="POST",
+ validate_certs=self.validate_grafana_certs,
+ url_username=self.grafana_user, url_password=self.grafana_password,
+ http_agent=self.http_agent, force_basic_auth=self.force_basic_auth)
+ except Exception as e:
+ self._display.error('Could not submit message to Grafana: %s' % str(e))