Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion monitoring/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 4 additions & 8 deletions monitoring/controllers/main.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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()
2 changes: 2 additions & 0 deletions monitoring/models/__init__.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 14 additions & 1 deletion monitoring/models/monitoring.py
Original file line number Diff line number Diff line change
@@ -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 [
Expand Down Expand Up @@ -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
Expand All @@ -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
111 changes: 111 additions & 0 deletions monitoring/models/monitoring_output_mixin.py
Original file line number Diff line number Diff line change
@@ -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"
43 changes: 27 additions & 16 deletions monitoring/models/monitoring_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
class MonitoringScript(models.Model):
_name = "monitoring.script"
_description = _("Monitoring Check")
_inherit = ["monitoring.output.mixin"]

def _get_types(self):
return [
Expand Down Expand Up @@ -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()

Expand All @@ -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)
3 changes: 1 addition & 2 deletions monitoring/static/description/index.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
Expand Down Expand Up @@ -367,7 +366,7 @@ <h1 class="title">Monitoring</h1>
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:089d22b288537e81dc88039bb1d3118b4c1bd61f3e2b7aa4550d3643d93a1e1a
!! source digest: sha256:dc7fb543f5709c41b41ecba13da4e08a0bfa74b3f3d45b5ae06d3fff0eef3ff1
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/server-tools/tree/15.0/monitoring"><img alt="OCA/server-tools" src="https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/server-tools-15-0/server-tools-15-0-monitoring"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/server-tools&amp;target_branch=15.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
<p>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 <cite>/monitoring/&lt;token&gt;</cite>.</p>
Expand Down
57 changes: 55 additions & 2 deletions monitoring/tests/test_monitoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
10 changes: 10 additions & 0 deletions monitoring/views/monitoring_script_views.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<field name="name" />
<field name="active" widget="boolean_toggle" />
<field name="token" />
<field name="output_format" />
<field name="check_type" />
<field
name="warning"
Expand All @@ -23,6 +24,14 @@
/>
<field name="snippet" />
</group>

<group
string="Prometheus"
attrs="{'invisible': [('output_format', '!=', 'prometheus')]}"
>
<field name="prometheus_metric" />
<field name="prometheus_label" />
</group>
</sheet>
</form>
</field>
Expand All @@ -40,6 +49,7 @@
<field name="token" />
<field name="active" widget="boolean_toggle" />
<field name="state" />
<field name="output_format" />
</tree>
</field>
</record>
Expand Down
Loading