diff --git a/monitoring/README.rst b/monitoring/README.rst index 73f35bcc74a..a131b808b1b 100644 --- a/monitoring/README.rst +++ b/monitoring/README.rst @@ -7,7 +7,7 @@ Monitoring !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:089d22b288537e81dc88039bb1d3118b4c1bd61f3e2b7aa4550d3643d93a1e1a + !! source digest: sha256:dc7fb543f5709c41b41ecba13da4e08a0bfa74b3f3d45b5ae06d3fff0eef3ff1 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png diff --git a/monitoring/controllers/main.py b/monitoring/controllers/main.py index e5826193f22..233638c9884 100644 --- a/monitoring/controllers/main.py +++ b/monitoring/controllers/main.py @@ -1,8 +1,6 @@ # © 2021 initOS GmbH # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -import json - from odoo import http from odoo.http import Response, request @@ -14,18 +12,16 @@ class MonitoringHome(Home): def monitoring(self, token): monitoring = request.env["monitoring"].sudo().search([("token", "=", token)]) if monitoring: - result = monitoring.validate() return Response( - json.dumps({"status": monitoring.state, "checks": result}), - headers={"Content-Type": "application/json"}, + monitoring.validate_and_format(), + headers=monitoring.response_headers(), ) script = request.env["monitoring.script"].sudo().search([("token", "=", token)]) if script: - result = script.validate(verbose=True) return Response( - json.dumps(result[0] if result else {"status": "unknown"}), - headers={"Content-Type": "application/json"}, + script.validate_and_format(verbose=True), + headers=script.response_headers(), ) return request.not_found() diff --git a/monitoring/models/__init__.py b/monitoring/models/__init__.py index e7127693d33..e6070d92c4e 100644 --- a/monitoring/models/__init__.py +++ b/monitoring/models/__init__.py @@ -1,4 +1,6 @@ # © 2023 initOS GmbH # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from . import monitoring_output_mixin # isort:skip + from . import monitoring, monitoring_script diff --git a/monitoring/models/monitoring.py b/monitoring/models/monitoring.py index 8433e0a3ed0..8e340e65d91 100644 --- a/monitoring/models/monitoring.py +++ b/monitoring/models/monitoring.py @@ -1,14 +1,18 @@ # © 2023 initOS GmbH # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging import uuid from odoo import _, api, fields, models +_logger = logging.getLogger(__name__) + class Monitoring(models.Model): _name = "monitoring" _description = _("Monitoring definition") + _inherit = ["monitoring.output.mixin"] def _get_states(self): return [ @@ -65,7 +69,7 @@ def cron_validate(self, send_mail=True): def validate(self, send_mail=False): self.ensure_one() - result = self.script_ids.validate(self.verbose) + result = self.script_ids.validate(self._use_verbose()) if ( send_mail @@ -77,3 +81,12 @@ def validate(self, send_mail=False): self.mail_sent = True return result + + def validate_and_format(self, send_mail=False): + self.ensure_one() + + result = self.validate(send_mail=send_mail) + return self.format_output(result) + + def _use_verbose(self): + return self.output_format == "prometheus" or self.verbose diff --git a/monitoring/models/monitoring_output_mixin.py b/monitoring/models/monitoring_output_mixin.py new file mode 100644 index 00000000000..858eecc741b --- /dev/null +++ b/monitoring/models/monitoring_output_mixin.py @@ -0,0 +1,111 @@ +# © 2024 initOS GmbH +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import json +import re + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class MonitoringOutputMixin(models.AbstractModel): + _name = "monitoring.output.mixin" + _description = _("Mixin for the Monitoring Output") + + def _get_output_format(self): + return [("json", "JSON"), ("prometheus", "Prometheus")] + + output_format = fields.Selection( + "_get_output_format", default="json", required=True + ) + prometheus_metric = fields.Char(default="odoo_metric") + prometheus_label = fields.Char(default="check") + + @api.constrains("output_format", "prometheus_metric", "prometheus_label") + def _check_prometheus_metric(self): + regex = re.compile(r"^[a-zA-Z0-9:_]+$") + for rec in self: + if rec.output_format != "prometheus": + continue + + if not regex.match(rec.prometheus_metric): + raise ValidationError(_("Invalid metric naming")) + + if not regex.match(rec.prometheus_label): + raise ValidationError(_("Invalid label naming")) + + def format_output(self, result): + formatter = getattr(self, f"format_{self.output_format}", None) + if callable(formatter): + return formatter(self.state, result) + + raise NotImplementedError() + + def response_headers(self): + self.ensure_one() + if self.output_format == "json": + return {"Content-Type": "application/json"} + if self.output_format == "prometheus": + return {"Content-Type": "text/plain"} + raise NotImplementedError() + + def format_json(self, state, result): + return json.dumps({"status": state, "checks": result}) + + def format_prometheus_line(self, name, value, labels=None): + def escape(v): + return v.replace("\\", "\\\\").replace("\n", "\\n").replace('"', '\\"') + + if labels: + # flake8: noqa: B907 + labels = ( + "{" + ",".join(f'{k}="{escape(v)}"' for k, v in labels.items()) + "}" + ) + else: + labels = "" + + return f"{name}{labels} {value}" + + def format_prometheus(self, state, result): + metric = self.prometheus_metric + keys = ("name", "value", "state") + + lines = [ + tuple(map(check.get, keys)) + for check in result + if all(k in check for k in keys) + ] + + output = [ + f"# HELP {metric}_value Monitored value of the metric", + f"# TYPE {metric}_value gauge", + *[ + self.format_prometheus_line( + f"{metric}_value", + int(value) if isinstance(value, bool) else value, + {self.prometheus_label: name}, + ) + for name, value, state in lines + ], + f"# HELP {metric}_warning Metric is in a warning state", + f"# TYPE {metric}_warning gauge", + *[ + self.format_prometheus_line( + f"{metric}_warning", + int(state == "warning"), + {self.prometheus_label: name}, + ) + for name, value, state in lines + ], + f"# HELP {metric}_critical Metric is in a critical state", + f"# TYPE {metric}_critical gauge", + *[ + self.format_prometheus_line( + f"{metric}_critical", + int(state == "critical"), + {self.prometheus_label: name}, + ) + for name, value, state in lines + ], + ] + return "\n".join(output) + "\n" diff --git a/monitoring/models/monitoring_script.py b/monitoring/models/monitoring_script.py index 0febd363fc3..a29bba4def3 100644 --- a/monitoring/models/monitoring_script.py +++ b/monitoring/models/monitoring_script.py @@ -12,6 +12,7 @@ class MonitoringScript(models.Model): _name = "monitoring.script" _description = _("Monitoring Check") + _inherit = ["monitoring.output.mixin"] def _get_types(self): return [ @@ -83,33 +84,37 @@ def _render(self, value=None): result.update({"warning": self.warning, "critical": self.critical}) return result - def validate(self, verbose=False): - def expect_bool(value, expect): - if isinstance(value, bool) and value == expect: - return "ok" - return "critical" - - def expect_threshold(value, warning, critical): - if value >= critical: - return "critical" - if value >= warning: - return "warning" + def _state_expect_bool(self, value, expect): + if isinstance(value, bool) and value == expect: return "ok" + return "critical" + + def _state_expect_threshold(self, value, warning, critical): + if value >= critical: + return "critical" + if value >= warning: + return "warning" + return "ok" + def validate(self, verbose=False): result = [] - for rec in self: + for rec in self.with_context(active_test=False): value = rec._evaluate() if not isinstance(value, (int, float, bool)): rec.state = "critical" elif rec.check_type == "false": - rec.state = expect_bool(value, False) + rec.state = rec._state_expect_bool(value, False) elif rec.check_type == "true": - rec.state = expect_bool(value, True) + rec.state = rec._state_expect_bool(value, True) elif rec.check_type == "lower": - rec.state = expect_threshold(value, rec.warning, rec.critical) + rec.state = rec._state_expect_threshold( + value, rec.warning, rec.critical + ) elif rec.check_type == "higher": - rec.state = expect_threshold(-value, -rec.warning, -rec.critical) + rec.state = rec._state_expect_threshold( + -value, -rec.warning, -rec.critical + ) else: raise NotImplementedError() @@ -120,3 +125,9 @@ def expect_threshold(value, warning, critical): result.append(rec._render(value)) return result + + def validate_and_format(self, verbose=False): + self.ensure_one() + + result = self.validate(verbose=verbose) + return self.format_output(result) diff --git a/monitoring/static/description/index.html b/monitoring/static/description/index.html index f6d1d08d612..b71763c0672 100644 --- a/monitoring/static/description/index.html +++ b/monitoring/static/description/index.html @@ -1,4 +1,3 @@ - @@ -367,7 +366,7 @@

Monitoring

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:089d22b288537e81dc88039bb1d3118b4c1bd61f3e2b7aa4550d3643d93a1e1a +!! source digest: sha256:dc7fb543f5709c41b41ecba13da4e08a0bfa74b3f3d45b5ae06d3fff0eef3ff1 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

Beta License: AGPL-3 OCA/server-tools Translate me on Weblate Try me on Runboat

This module allows monitoring software to extract additional information about an Odoo installation. An administrator can configure monitoring snippets and group them together which allows external software to fetch the results using the token in the URL /monitoring/<token>.

diff --git a/monitoring/tests/test_monitoring.py b/monitoring/tests/test_monitoring.py index 73b779cb01e..c1c9c6eba46 100644 --- a/monitoring/tests/test_monitoring.py +++ b/monitoring/tests/test_monitoring.py @@ -5,6 +5,7 @@ from werkzeug.exceptions import NotFound +from odoo.exceptions import ValidationError from odoo.tests import TransactionCase from odoo.addons.monitoring.controllers.main import MonitoringHome @@ -111,11 +112,63 @@ def test_monitoring_validation(self): def test_controller(self): with MockRequest(self.env) as request_mock: request_mock.not_found = NotFound - response = self.controller.monitoring("invalid") - self.assertEqual(response.status_code, 404) + with self.assertRaises(NotFound): + self.controller.monitoring("invalid") response = self.controller.monitoring(self.script.token) self.assertEqual(response.status_code, 200) response = self.controller.monitoring(self.monitoring.token) self.assertEqual(response.status_code, 200) + + def test_formatting_headers(self): + self.monitoring.output_format = "json" + self.assertEqual( + self.monitoring.response_headers().get("Content-Type"), + "application/json", + ) + + self.monitoring.output_format = "prometheus" + self.assertEqual( + self.monitoring.response_headers().get("Content-Type"), + "text/plain", + ) + + with self.assertRaises(NotImplementedError): + self.monitoring.output_format = "" + self.monitoring.response_headers() + + def test_prometheus_configuration(self): + self.monitoring.output_format = "prometheus" + self.monitoring._check_prometheus_metric() + + with self.assertRaises(ValidationError): + self.monitoring.prometheus_label = "invalid!" + + self.monitoring.prometheus_label = "valid" + with self.assertRaises(ValidationError): + self.monitoring.prometheus_metric = "invalid!" + + def test_prometheus_formatting(self): + self.monitoring.output_format = "prometheus" + result = self.monitoring.validate_and_format() + + metric = self.monitoring.prometheus_metric + self.assertIn(f"HELP {metric}_value", result) + self.assertIn(f"TYPE {metric}_value", result) + self.assertIn(f"HELP {metric}_warning", result) + self.assertIn(f"TYPE {metric}_warning", result) + self.assertIn(f"HELP {metric}_critical", result) + self.assertIn(f"TYPE {metric}_critical", result) + + self.assertEqual( + self.monitoring.format_prometheus_line(f"{metric}_value", value=1), + f"{metric}_value 1", + ) + + self.assertEqual( + self.monitoring.format_prometheus_line( + f"{metric}_value", value=1, labels={"check": "abc", "test": "def"} + ), + '%s_value{check="abc",test="def"} 1' % metric, + ) diff --git a/monitoring/views/monitoring_script_views.xml b/monitoring/views/monitoring_script_views.xml index 256150af6ce..6981adff408 100644 --- a/monitoring/views/monitoring_script_views.xml +++ b/monitoring/views/monitoring_script_views.xml @@ -12,6 +12,7 @@ + + + + + + @@ -40,6 +49,7 @@ + diff --git a/monitoring/views/monitoring_views.xml b/monitoring/views/monitoring_views.xml index c43ea2a7f2d..f7c0f0efb53 100644 --- a/monitoring/views/monitoring_views.xml +++ b/monitoring/views/monitoring_views.xml @@ -11,6 +11,7 @@ + @@ -34,10 +35,19 @@ + + + + + +