[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:
Alexandre Fayolle authored and Maksym Yankin committed 2026-09-30 19:33:16 +03:00
1 parent 64dc2fc8fd
commit 8a660b6081
18 files changed
+274 -7

No files matched your search

+26
View File
@@ -15,3 +15,29 @@ Current available metrics are:
* Longpolling request count
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 -1
View File
@@ -1,2 +1,2 @@
from . import controllers
from . import models
from . import controllers
+2 -2
View File
@@ -4,13 +4,13 @@
{
"name": "Monitoring: Prometheus Metrics",
"version": "14.0.1.0.0",
"version": "14.0.1.1.0",
"author": "Camptocamp,Odoo Community Association (OCA)",
"license": "AGPL-3",
"category": "category",
"depends": ["base", "web", "server_environment"],
"website": "https://github.com/camptocamp/odoo-cloud-platform",
"data": [],
"data": ["security/ir.model.access.csv", "data/ir_cron.xml"],
"external_dependencies": {"python": ["prometheus_client"]},
"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)
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):
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")
def metrics(self):
try:
self._publish_gathered_metrics()
except Exception:
_logger.exception("Could not publish the gathered Prometheus metrics")
return generate_latest()
+14
View File
@@ -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>
+2
View File
@@ -1 +1,3 @@
from . import ir_http
from . import prometheus_gatherer
from . import prometheus_metric
+1 -1
View File
@@ -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)
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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 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
View File
@@ -1 +1,2 @@
server-env
queue
@@ -0,0 +1 @@
../../../../monitoring_prometheus_queue_job
@@ -0,0 +1,6 @@
import setuptools
setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.