mirror of
https://github.com/camptocamp/odoo-cloud-platform.git
synced 2026-10-21 21:31:52 +00:00
[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:
11 files changed
+76
-174
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
|
@@ -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
|
||||
+2
-2
@@ -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):
|
||||