[REF] monitoring_prometheus: gather metrics on the fly in /metrics

The cron and the prometheus.metric table only existed to pass values from
the cron process to the HTTP workers. Querying the database directly when
/metrics is scraped removes the cron, the table and the staleness.
This commit is contained in:
David Gallay committed 2026-10-01 11:55:24 +02:00
1 parent 93d934d676
commit 95f6a48b9d
11 files changed
+76 -174

No files matched your search

+4 -13
View File
@@ -19,20 +19,11 @@ No additional configuration is needed, just ensure that the Prometheus server is
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.
Metrics that require a database query are gathered on the fly by the */metrics*
endpoint, with ``sudo()`` since the endpoint is public, and published as gauges.
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.metric``
model and return the result of ``super()`` extended with your own entries::
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",
+1 -1
View File
@@ -10,7 +10,7 @@
"category": "category",
"depends": ["base", "web", "server_environment"],
"website": "https://github.com/camptocamp/odoo-cloud-platform",
"data": ["security/ir.model.access.csv", "data/ir_cron.xml"],
"data": [],
"external_dependencies": {"python": ["prometheus_client"]},
"installable": True,
}
@@ -29,9 +29,8 @@ class PrometheusController(Controller):
# REGISTRY holds the in-process metrics (request latency, longpolling)
output = generate_latest(REGISTRY)
try:
records = request.env["prometheus.metric"].sudo().search([])
registry = records._build_prometheus_registry()
output += generate_latest(registry)
gatherer = request.env["prometheus.gatherer"].sudo()
output += generate_latest(gatherer._build_prometheus_registry())
except Exception:
_logger.exception("Could not publish the gathered Prometheus metrics")
return output
-14
View File
@@ -1,14 +0,0 @@
<?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_metric" />
<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
View File
@@ -1,2 +1,2 @@
from . import ir_http
from . import prometheus_metric
from . import prometheus_gatherer
@@ -0,0 +1,64 @@
# Copyright 2016-2021 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
from prometheus_client import CollectorRegistry, Gauge
from odoo import api, models
class PrometheusGatherer(models.AbstractModel):
"""Collect application metrics to be exposed on the /metrics endpoint."""
_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 _group_metrics(self, metrics):
# group the metrics by definition so each gauge is created once
groups = {}
for metric in metrics:
labels = metric.get("labels") or {}
label_names = tuple(sorted(labels))
key = (metric["name"], metric.get("documentation", ""), label_names)
groups.setdefault(key, []).append((labels, metric["value"]))
return groups
@api.model
def _build_prometheus_registry(self):
"""Return a new ``CollectorRegistry`` publishing ``_gather_metrics()``.
A fresh registry per call, instead of process-wide gauges, means
nothing is shared between concurrent requests nor between the
databases served by the same process.
"""
groups = self._group_metrics(self._gather_metrics())
registry = CollectorRegistry()
for (name, documentation, label_names), values in groups.items():
gauge = Gauge(name, documentation, label_names, registry=registry)
for labels, value in values:
if labels:
gauge.labels(**labels).set(value)
else:
gauge.set(value)
return registry
@@ -1,135 +0,0 @@
# Copyright 2026 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
import json
from prometheus_client import CollectorRegistry, Gauge
from odoo import api, fields, models
class PrometheusMetric(models.Model):
"""Application metrics gathered by a cron and exposed on ``/metrics``.
Metrics are gathered by ``_cron_gather_metrics`` and stored as records,
because crons and HTTP workers run in separate processes and cannot share
the Prometheus registry.
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)
_sql_constraints = [
(
"name_labels_uniq",
"unique(name, _labels)",
"A metric can only be stored once per name and labels.",
)
]
@staticmethod
def _serialize_labels(labels):
return json.dumps(labels or {}, sort_keys=True)
def labels(self):
return json.loads(self._labels or "{}")
def _check_upsert_context(self):
# forces every writer through ``_create_or_update_metric`` (the upsert)
assert self.env.context.get("prometheus_metric_upsert"), (
"prometheus.metric records must be written through "
"_create_or_update_metric()"
)
@api.model_create_multi
def create(self, vals_list):
self._check_upsert_context()
return super().create(vals_list)
def write(self, vals):
self._check_upsert_context()
return super().write(vals)
@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.sudo()
touched = Metric.browse()
for metric in Metric._gather_metrics():
touched |= Metric._create_or_update_metric(metric)
# drop the series that disappeared since the last collection
(Metric.search([]) - touched).unlink()
return True
@api.model
def _create_or_update_metric(self, metric):
"""Create or update the record matching ``metric``, return it."""
labels_key = self._serialize_labels(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.with_context(prometheus_metric_upsert=True).write(values)
else:
record = self.with_context(prometheus_metric_upsert=True).create(values)
return record
def _group_metrics(self):
# group the records by metric definition so each gauge is created once
groups = {}
for record in self:
labels = record.labels()
label_names = tuple(sorted(labels))
key = (record.name, record.documentation or "", label_names)
groups.setdefault(key, []).append((labels, record.value))
return groups
def _build_prometheus_registry(self):
groups = self._group_metrics()
registry = CollectorRegistry()
for (name, documentation, label_names), values in groups.items():
gauge = Gauge(name, documentation, label_names, registry=registry)
for labels, value in values:
if labels:
gauge.labels(**labels).set(value)
else:
gauge.set(value)
return registry
@@ -1,2 +0,0 @@
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
+1 -2
View File
@@ -13,8 +13,7 @@ The gauge ``odoo_queue_job_count`` is published with the labels *state* and
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.
Values are counted at each scrape of the */metrics* endpoint.
This module is automatically installed when both ``monitoring_prometheus`` and
``queue_job`` are installed.
@@ -1 +1 @@
from . import prometheus_metric
from . import prometheus_gatherer
@@ -8,8 +8,8 @@ GAUGE_NAME = "odoo_queue_job_count"
GAUGE_DOC = "Number of queue jobs per state and channel"
class PrometheusMetric(models.Model):
_inherit = "prometheus.metric"
class PrometheusGatherer(models.AbstractModel):
_inherit = "prometheus.gatherer"
@api.model
def _gather_metrics(self):
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.