diff --git a/monitoring_prometheus/README.rst b/monitoring_prometheus/README.rst index cf44e53..dbcc44e 100644 --- a/monitoring_prometheus/README.rst +++ b/monitoring_prometheus/README.rst @@ -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", diff --git a/monitoring_prometheus/__manifest__.py b/monitoring_prometheus/__manifest__.py index 0656e73..fc69346 100644 --- a/monitoring_prometheus/__manifest__.py +++ b/monitoring_prometheus/__manifest__.py @@ -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, } diff --git a/monitoring_prometheus/controllers/prometheus_metrics.py b/monitoring_prometheus/controllers/prometheus_metrics.py index a4cb8aa..92aaef8 100644 --- a/monitoring_prometheus/controllers/prometheus_metrics.py +++ b/monitoring_prometheus/controllers/prometheus_metrics.py @@ -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 diff --git a/monitoring_prometheus/data/ir_cron.xml b/monitoring_prometheus/data/ir_cron.xml deleted file mode 100644 index b094bae..0000000 --- a/monitoring_prometheus/data/ir_cron.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - Prometheus: gather metrics - - code - model._cron_gather_metrics() - 15 - minutes - -1 - - - - diff --git a/monitoring_prometheus/models/__init__.py b/monitoring_prometheus/models/__init__.py index d4d94ce..52ebd90 100644 --- a/monitoring_prometheus/models/__init__.py +++ b/monitoring_prometheus/models/__init__.py @@ -1,2 +1,2 @@ from . import ir_http -from . import prometheus_metric +from . import prometheus_gatherer diff --git a/monitoring_prometheus/models/prometheus_gatherer.py b/monitoring_prometheus/models/prometheus_gatherer.py new file mode 100644 index 0000000..421ff61 --- /dev/null +++ b/monitoring_prometheus/models/prometheus_gatherer.py @@ -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 diff --git a/monitoring_prometheus/models/prometheus_metric.py b/monitoring_prometheus/models/prometheus_metric.py deleted file mode 100644 index 7e8e05e..0000000 --- a/monitoring_prometheus/models/prometheus_metric.py +++ /dev/null @@ -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 diff --git a/monitoring_prometheus/security/ir.model.access.csv b/monitoring_prometheus/security/ir.model.access.csv deleted file mode 100644 index 3da8765..0000000 --- a/monitoring_prometheus/security/ir.model.access.csv +++ /dev/null @@ -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 diff --git a/monitoring_prometheus_queue_job/README.rst b/monitoring_prometheus_queue_job/README.rst index 8cac167..e4e1f63 100644 --- a/monitoring_prometheus_queue_job/README.rst +++ b/monitoring_prometheus_queue_job/README.rst @@ -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. diff --git a/monitoring_prometheus_queue_job/models/__init__.py b/monitoring_prometheus_queue_job/models/__init__.py index 726183e..18bb3bc 100644 --- a/monitoring_prometheus_queue_job/models/__init__.py +++ b/monitoring_prometheus_queue_job/models/__init__.py @@ -1 +1 @@ -from . import prometheus_metric +from . import prometheus_gatherer diff --git a/monitoring_prometheus_queue_job/models/prometheus_metric.py b/monitoring_prometheus_queue_job/models/prometheus_gatherer.py similarity index 94% rename from monitoring_prometheus_queue_job/models/prometheus_metric.py rename to monitoring_prometheus_queue_job/models/prometheus_gatherer.py index 3353b9b..be493f7 100644 --- a/monitoring_prometheus_queue_job/models/prometheus_metric.py +++ b/monitoring_prometheus_queue_job/models/prometheus_gatherer.py @@ -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):