mirror of
https://github.com/camptocamp/odoo-cloud-platform.git
synced 2026-10-21 13:31:19 +00:00
[ADD] monitoring_prometheus_queue_job
This commit features a refactoring of monitoring_prometheus to get a cron gathering some metrics from the database, and then publishing the result on the /metrics endpoint The new module monitoring_prometheus_queue_job extends the cron to get information about the queue job states.
This commit is contained in:
1 parent
64dc2fc8fd
commit
8a660b6081
18 files changed
+274
-7
No files matched your search
@@ -15,3 +15,29 @@ Current available metrics are:
|
|||||||
* Longpolling request count
|
* Longpolling request count
|
||||||
|
|
||||||
No additional configuration is needed, just ensure that the Prometheus server is allowed to communicate with Odoo
|
No additional configuration is needed, just ensure that the Prometheus server is allowed to communicate with Odoo
|
||||||
|
|
||||||
|
Gathering metrics from the database
|
||||||
|
-----------------------------------
|
||||||
|
|
||||||
|
Metrics that require a database query are collected by the *Prometheus: gather metrics*
|
||||||
|
scheduled action, which runs every 15 minutes and stores its result as
|
||||||
|
``prometheus.metric`` records (one per metric name/labels combination). The */metrics*
|
||||||
|
endpoint reads those records and publishes them as gauges. This indirection is required
|
||||||
|
because the cron and the HTTP workers run in separate processes and cannot share the
|
||||||
|
Prometheus registry.
|
||||||
|
|
||||||
|
By default, only users in the *Administration / Settings* group (``base.group_system``)
|
||||||
|
can read ``prometheus.metric`` records through the ORM/UI. The */metrics* endpoint
|
||||||
|
remains public and reads the records with ``sudo()`` so Prometheus can keep scraping it
|
||||||
|
without authentication.
|
||||||
|
|
||||||
|
To publish new metrics, extend ``_gather_metrics`` on the ``prometheus.gatherer``
|
||||||
|
abstract model and return the result of ``super()`` extended with your own entries::
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "odoo_some_metric",
|
||||||
|
"documentation": "What this metric measures",
|
||||||
|
"labels": {"label_name": "label_value"},
|
||||||
|
"value": 42,
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
from . import controllers
|
|
||||||
from . import models
|
from . import models
|
||||||
|
from . import controllers
|
||||||
@@ -4,13 +4,13 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
"name": "Monitoring: Prometheus Metrics",
|
"name": "Monitoring: Prometheus Metrics",
|
||||||
"version": "14.0.1.0.0",
|
"version": "14.0.1.1.0",
|
||||||
"author": "Camptocamp,Odoo Community Association (OCA)",
|
"author": "Camptocamp,Odoo Community Association (OCA)",
|
||||||
"license": "AGPL-3",
|
"license": "AGPL-3",
|
||||||
"category": "category",
|
"category": "category",
|
||||||
"depends": ["base", "web", "server_environment"],
|
"depends": ["base", "web", "server_environment"],
|
||||||
"website": "https://github.com/camptocamp/odoo-cloud-platform",
|
"website": "https://github.com/camptocamp/odoo-cloud-platform",
|
||||||
"data": [],
|
"data": ["security/ir.model.access.csv", "data/ir_cron.xml"],
|
||||||
"external_dependencies": {"python": ["prometheus_client"]},
|
"external_dependencies": {"python": ["prometheus_client"]},
|
||||||
"installable": True,
|
"installable": True,
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,52 @@
|
|||||||
# Copyright 2016-2021 Camptocamp SA
|
# Copyright 2016 Camptocamp SA
|
||||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
|
||||||
|
|
||||||
from prometheus_client import generate_latest
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
from odoo.http import Controller, route
|
from prometheus_client import Gauge, generate_latest
|
||||||
|
|
||||||
|
from odoo.http import Controller, request, route
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Gauges must be instantiated only once per process, registering the same
|
||||||
|
# metric name twice raises a "Duplicated timeseries" error.
|
||||||
|
_GAUGES = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_gauge(name, documentation, label_names):
|
||||||
|
gauge = _GAUGES.get(name)
|
||||||
|
if gauge is None:
|
||||||
|
gauge = Gauge(name, documentation, label_names)
|
||||||
|
_GAUGES[name] = gauge
|
||||||
|
return gauge
|
||||||
|
|
||||||
|
|
||||||
class PrometheusController(Controller):
|
class PrometheusController(Controller):
|
||||||
|
def _publish_gathered_metrics(self):
|
||||||
|
records = request.env["prometheus.metric"].sudo().search([])
|
||||||
|
if not records:
|
||||||
|
return
|
||||||
|
|
||||||
|
cleared = set()
|
||||||
|
for record in records:
|
||||||
|
name = record.name
|
||||||
|
labels = json.loads(record.labels or "{}")
|
||||||
|
gauge = _get_gauge(name, record.documentation or "", sorted(labels))
|
||||||
|
if name not in cleared:
|
||||||
|
# drop the series that disappeared since the last collection
|
||||||
|
gauge.clear()
|
||||||
|
cleared.add(name)
|
||||||
|
if labels:
|
||||||
|
gauge.labels(**labels).set(record.value)
|
||||||
|
else:
|
||||||
|
gauge.set(record.value)
|
||||||
|
|
||||||
@route("/metrics", auth="public")
|
@route("/metrics", auth="public")
|
||||||
def metrics(self):
|
def metrics(self):
|
||||||
|
try:
|
||||||
|
self._publish_gathered_metrics()
|
||||||
|
except Exception:
|
||||||
|
_logger.exception("Could not publish the gathered Prometheus metrics")
|
||||||
return generate_latest()
|
return generate_latest()
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<odoo noupdate="1">
|
||||||
|
<record id="cron_prometheus_gather_metrics" model="ir.cron">
|
||||||
|
<field name="name">Prometheus: gather metrics</field>
|
||||||
|
<field name="model_id" ref="model_prometheus_gatherer" />
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_gather_metrics()</field>
|
||||||
|
<field name="interval_number">15</field>
|
||||||
|
<field name="interval_type">minutes</field>
|
||||||
|
<field name="numbercall">-1</field>
|
||||||
|
<field name="doall" eval="False" />
|
||||||
|
<field name="active" eval="True" />
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@@ -1 +1,3 @@
|
|||||||
from . import ir_http
|
from . import ir_http
|
||||||
|
from . import prometheus_gatherer
|
||||||
|
from . import prometheus_metric
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Copyright 2016-2021 Camptocamp SA
|
# Copyright 2016 Camptocamp SA
|
||||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
|
||||||
|
|
||||||
from prometheus_client import Counter, Summary
|
from prometheus_client import Counter, Summary
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Copyright 2016-2021 Camptocamp SA
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
|
||||||
|
|
||||||
|
from odoo import api, models
|
||||||
|
|
||||||
|
|
||||||
|
class PrometheusGatherer(models.AbstractModel):
|
||||||
|
"""Collect application metrics to be exposed on the /metrics endpoint.
|
||||||
|
|
||||||
|
Metrics are gathered by a cron and stored in ``prometheus.metric``
|
||||||
|
records, because crons and HTTP workers run in separate processes and
|
||||||
|
cannot share the Prometheus registry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_name = "prometheus.gatherer"
|
||||||
|
_description = "Prometheus Metrics Gatherer"
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _gather_metrics(self):
|
||||||
|
"""Return the metrics as a list of dicts.
|
||||||
|
|
||||||
|
Each entry is of the form::
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "odoo_some_metric",
|
||||||
|
"documentation": "What this metric measures",
|
||||||
|
"labels": {"label_name": "label_value"},
|
||||||
|
"value": 42,
|
||||||
|
}
|
||||||
|
|
||||||
|
``labels`` may be omitted for an unlabelled metric. All the entries
|
||||||
|
sharing a ``name`` must declare the same label names.
|
||||||
|
|
||||||
|
Modules extending this method must return the result of ``super()``
|
||||||
|
extended with their own entries.
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _cron_gather_metrics(self):
|
||||||
|
Metric = self.env["prometheus.metric"].sudo()
|
||||||
|
touched = self.env["prometheus.metric"]
|
||||||
|
for metric in self._gather_metrics():
|
||||||
|
touched |= Metric._update_metric(metric)
|
||||||
|
# drop the series that disappeared since the last collection
|
||||||
|
(Metric.search([]) - touched).unlink()
|
||||||
|
return True
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Copyright 2026 Camptocamp SA
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class PrometheusMetric(models.Model):
|
||||||
|
"""Store the metrics gathered by ``prometheus.gatherer``.
|
||||||
|
|
||||||
|
A record is stored per metric name/labels combination, because a single
|
||||||
|
metric name can be reported several times with different labels (e.g.
|
||||||
|
one gauge value per queue job state and channel).
|
||||||
|
"""
|
||||||
|
|
||||||
|
_name = "prometheus.metric"
|
||||||
|
_description = "Prometheus Metric"
|
||||||
|
|
||||||
|
name = fields.Char(required=True, index=True)
|
||||||
|
documentation = fields.Char()
|
||||||
|
# JSON-serialized dict, used both to publish the metric and as part of
|
||||||
|
# the uniqueness key alongside ``name``.
|
||||||
|
labels = fields.Char(default="{}")
|
||||||
|
value = fields.Float(required=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _labels_key(labels):
|
||||||
|
return json.dumps(labels or {}, sort_keys=True)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _update_metric(self, metric):
|
||||||
|
"""Create or update the record matching ``metric``, return it."""
|
||||||
|
labels_key = self._labels_key(metric.get("labels"))
|
||||||
|
record = self.search(
|
||||||
|
[("name", "=", metric["name"]), ("labels", "=", labels_key)],
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
values = {
|
||||||
|
"name": metric["name"],
|
||||||
|
"documentation": metric.get("documentation", ""),
|
||||||
|
"labels": labels_key,
|
||||||
|
"value": metric["value"],
|
||||||
|
}
|
||||||
|
if record:
|
||||||
|
record.write(values)
|
||||||
|
else:
|
||||||
|
record = self.create(values)
|
||||||
|
return record
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_prometheus_metric_system,prometheus.metric system,model_prometheus_metric,base.group_system,1,1,1,1
|
||||||
|
@@ -0,0 +1,20 @@
|
|||||||
|
.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg
|
||||||
|
:alt: License
|
||||||
|
|
||||||
|
=========================================
|
||||||
|
Monitoring: Prometheus metrics queue jobs
|
||||||
|
=========================================
|
||||||
|
|
||||||
|
Expose the number of queue jobs to Prometheus through the */metrics* endpoint
|
||||||
|
of ``monitoring_prometheus``.
|
||||||
|
|
||||||
|
The gauge ``odoo_queue_job_count`` is published with the labels *state* and
|
||||||
|
*channel*, for the states ``pending``, ``enqueued``, ``started``, ``failed``
|
||||||
|
and ``wait_dependencies``. The terminal states ``done`` and ``cancelled`` are
|
||||||
|
not reported.
|
||||||
|
|
||||||
|
Values are refreshed by the *Prometheus: gather metrics* scheduled action, so
|
||||||
|
they are a snapshot and not a real time count.
|
||||||
|
|
||||||
|
This module is automatically installed when both ``monitoring_prometheus`` and
|
||||||
|
``queue_job`` are installed.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import models
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Copyright 2026 Camptocamp SA
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
|
||||||
|
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "Monitoring: Prometheus Metrics for Queue Jobs",
|
||||||
|
"version": "14.0.1.0.0",
|
||||||
|
"author": "Camptocamp,Odoo Community Association (OCA)",
|
||||||
|
"license": "AGPL-3",
|
||||||
|
"category": "category",
|
||||||
|
"depends": ["monitoring_prometheus", "queue_job"],
|
||||||
|
"website": "https://github.com/camptocamp/odoo-cloud-platform",
|
||||||
|
"data": [],
|
||||||
|
"external_dependencies": {"python": ["prometheus_client"]},
|
||||||
|
"auto_install": True,
|
||||||
|
"installable": True,
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import prometheus_gatherer
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Copyright 2026 Camptocamp SA
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
|
||||||
|
|
||||||
|
from odoo import api, models
|
||||||
|
|
||||||
|
MONITORED_STATES = ("pending", "enqueued", "started", "failed", "wait_dependencies")
|
||||||
|
GAUGE_NAME = "odoo_queue_job_count"
|
||||||
|
GAUGE_DOC = "Number of queue jobs per state and channel"
|
||||||
|
|
||||||
|
|
||||||
|
class PrometheusGatherer(models.AbstractModel):
|
||||||
|
_inherit = "prometheus.gatherer"
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _gather_metrics(self):
|
||||||
|
metrics = super()._gather_metrics()
|
||||||
|
groups = self.env["queue.job"].read_group(
|
||||||
|
[("state", "in", list(MONITORED_STATES))],
|
||||||
|
["state", "channel"],
|
||||||
|
["state", "channel"],
|
||||||
|
lazy=False,
|
||||||
|
)
|
||||||
|
channels = self.env["queue.job.channel"].search([]).mapped("complete_name")
|
||||||
|
# seed every combination so a series never disappears when its jobs are gone
|
||||||
|
counts = {
|
||||||
|
(state, channel): 0 for state in MONITORED_STATES for channel in channels
|
||||||
|
}
|
||||||
|
for group in groups:
|
||||||
|
key = (group["state"], group["channel"])
|
||||||
|
counts[key] = counts.get(key, 0) + group["__count"]
|
||||||
|
metrics.extend(
|
||||||
|
{
|
||||||
|
"name": GAUGE_NAME,
|
||||||
|
"documentation": GAUGE_DOC,
|
||||||
|
"labels": {"state": state, "channel": channel},
|
||||||
|
"value": count,
|
||||||
|
}
|
||||||
|
for (state, channel), count in counts.items()
|
||||||
|
)
|
||||||
|
return metrics
|
||||||
@@ -1 +1,2 @@
|
|||||||
server-env
|
server-env
|
||||||
|
queue
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
../../../../monitoring_prometheus_queue_job
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import setuptools
|
||||||
|
|
||||||
|
setuptools.setup(
|
||||||
|
setup_requires=['setuptools-odoo'],
|
||||||
|
odoo_addon=True,
|
||||||
|
)
|
||||||