diff --git a/profiler/README.rst b/profiler/README.rst new file mode 100644 index 00000000000..f128d6a0ab2 --- /dev/null +++ b/profiler/README.rst @@ -0,0 +1,117 @@ +===================================== +Queue Job and Thread Profiler (Yappi) +===================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:f7f74561a87f9017647be70515bfbb630fb4e6c0cd85fbaaad907046e76d3bcc + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github + :target: https://github.com/OCA/server-tools/tree/18.0/profiler + :alt: OCA/server-tools +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-tools-18-0/server-tools-18-0-profiler + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/server-tools&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module aims to help profle code executed in Odoo, and to analyze +the results of the profiling. It is based on yappi profiler, which is a +Python profiler that supports multi-threading and multi-processing. It's +main use case is to profile function executed in queue jobs or executed +a large amount of times in a short time, which is not possible with the +default cProfile profiler used in Odoo. It also allows to store the +results of the profiling in the database, and to analyze them in Odoo or +with external tools like snakeviz or flameprof. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +To use this module, you need to: + +- got to General Settings > Proflier > profiled functions and create a + new record with the name of the function you want to profile, and the + model if it's a method of a model. For example, if you want to profile + the method ``my_method`` of the model ``my.model``, you need to create + a record with the name ``my_method`` and the model + ``my.model.my_method``. EG: +- name: Stock Rule +- Python Path: + odoo.addons.stock.models.stock_rule.ProcurementGroup.run_scheduler +- Sample rate (from 0 to 1): 0.1 (to profile 10% of the calls to this + method and avoid too much overhead) +- Active if you want it to be active. + +Known issues / Roadmap +====================== + + + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Camptocamp + +Contributors +------------ + +- Hadrien Huvelle + +Other credits +------------- + +The implementation of this module was financially supported by +Camptocamp. + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/server-tools `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/profiler/__init__.py b/profiler/__init__.py new file mode 100644 index 00000000000..ed59ba5542b --- /dev/null +++ b/profiler/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from . import models +from . import tools diff --git a/profiler/__manifest__.py b/profiler/__manifest__.py new file mode 100644 index 00000000000..8177b43ace6 --- /dev/null +++ b/profiler/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +{ + "name": "Queue Job and Thread Profiler (Yappi)", + "version": "18.0.1.0.0", + "category": "Tools", + "summary": "yappi profiler decorator with database storage", + "author": "Camptocamp, Odoo Community Association (OCA)", + "license": "AGPL-3", + "website": "https://github.com/OCA/server-tools", + "depends": ["base"], + "external_dependencies": { + "python": ["yappi", "cairosvg", "flameprof"], + }, + "data": [ + "security/ir.model.access.csv", + "views/profiler_function_views.xml", + "views/profiler_report_views.xml", + "views/profiler_result_views.xml", + ], + "installable": True, + "development_status": "Alpha", +} diff --git a/profiler/models/__init__.py b/profiler/models/__init__.py new file mode 100644 index 00000000000..ec76f21b8d7 --- /dev/null +++ b/profiler/models/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from . import profiler_result +from . import profiler_report +from . import profiler_function diff --git a/profiler/models/profiler_function.py b/profiler/models/profiler_function.py new file mode 100644 index 00000000000..8e203231d59 --- /dev/null +++ b/profiler/models/profiler_function.py @@ -0,0 +1,85 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + +from ..tools import dynamic_profile + +_logger = logging.getLogger(__name__) + + +class ProfilerFunction(models.Model): + _name = "profiler.function" + _description = "Profiled Function" + _order = "name" + + name = fields.Char(required=True) + python_path = fields.Char( + required=True, + index=True, + help="Use module.function or module.Class.method", + ) + sample_rate = fields.Float( + default=0.05, + help="Percentage of calls to profile (0.0 - 1.0)", + ) + active = fields.Boolean(default=True) + + @api.constrains("python_path") + def _check_python_path(self): + for record in self: + if not record.python_path: + continue + try: + dynamic_profile.validate_path(record.python_path) + except Exception as exc: + raise ValidationError( + f"Invalid python path {record.python_path}: {exc}" + ) from exc + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + records._update_registry() + return records + + def write(self, vals): + res = super().write(vals) + if {"python_path", "sample_rate", "active"} & set(vals): + self._update_registry() + return res + + def unlink(self): + res = super().unlink() + self._update_registry() + return res + + def _update_registry(self): + if self.env.registry.ready: + self._unregister_hook() + self._register_hook() + self.env.registry.registry_invalidated = True + else: + _logger.info("Registry not ready, skipping profiler patch update") + + def _register_hook(self): + res = super()._register_hook() + active_records = self.search([("active", "=", True)]) + dynamic_profile.patch_active_records(active_records) + return res + + def _unregister_hook(self): + res = super()._unregister_hook() + for record in self.with_context(active_test=False).search([]): + try: + dynamic_profile.unpatch_path(record.python_path) + except Exception as exc: + _logger.warning( + "Unable to remove profile patch for %s: %s", + record.python_path, + exc, + ) + return res diff --git a/profiler/models/profiler_report.py b/profiler/models/profiler_report.py new file mode 100644 index 00000000000..4f131bbb777 --- /dev/null +++ b/profiler/models/profiler_report.py @@ -0,0 +1,82 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import api, fields, models + + +class ProfilerReport(models.TransientModel): + _name = "profiler.report" + _description = "Profiler Statistics Report" + + name = fields.Char(string="Function Name") + p95 = fields.Float(string="P95 Duration (s)", digits=(12, 6)) + p99 = fields.Float(string="P99 Duration (s)", digits=(12, 6)) + count = fields.Integer(string="Call Count") + + def _parse_time_delta(self, time_delta_str): + """Parse time delta string in format HH:MM:SS and return total seconds.""" + try: + parts = time_delta_str.split(":") + if len(parts) != 3: + raise ValueError("Invalid format") + hours, minutes, seconds = map(int, parts) + return hours * 3600 + minutes * 60 + seconds + except Exception: + # Default to 24 hours if parsing fails + return 86400 + + @api.model + def generate_report(self): + """Generate the profiler statistics report.""" + self.search([]).unlink() + + # Get time_delta from context (format: "HH:MM:SS") + time_delta_str = self.env.context.get("time_delta", "24:00:00") + total_seconds = self._parse_time_delta(time_delta_str) + + query = """ + SELECT name, + percentile_disc(0.95) WITHIN GROUP (ORDER BY duration) AS p95, + percentile_disc(0.99) WITHIN GROUP (ORDER BY duration) AS p99, + count(*) AS count + FROM profiler_result + WHERE create_date > now() - interval '%s second' + GROUP BY name + ORDER BY p99 DESC + LIMIT 20 + """ + + self.env.cr.execute(query, (total_seconds,)) + results = self.env.cr.fetchall() + + report_records = [] + for row in results: + report_records.append( + { + "name": row[0], + "p95": row[1], + "p99": row[2], + "count": row[3], + } + ) + + if report_records: + self.create(report_records) + + return { + "type": "ir.actions.act_window", + "name": "Profiler Statistics Report", + "res_model": "profiler.report", + "view_mode": "list", + "target": "current", + "context": { + "create": False, + "edit": False, + "delete": False, + "time_delta": time_delta_str, + }, + } + + def action_refresh(self): + """Refresh the report with current data.""" + return self.generate_report() diff --git a/profiler/models/profiler_result.py b/profiler/models/profiler_result.py new file mode 100644 index 00000000000..c2764fc284b --- /dev/null +++ b/profiler/models/profiler_result.py @@ -0,0 +1,189 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + + +import subprocess + +from odoo import api, fields, models + + +class ProfilerResult(models.Model): + _name = "profiler.result" + _description = "Profiler Result" + _order = "create_date desc" + + name = fields.Char(string="Function Name", required=True, index=True) + stats_text = fields.Text(string="Profile Statistics", required=True) + stats_json = fields.Text(string="Profile Statistics JSON") + stats_binary = fields.Binary(string="Profile Binary Data", attachment=True) + stats_callgrind = fields.Binary(string="Profile Callgrind Data", attachment=True) + duration = fields.Float(string="Duration (seconds)") + create_date = fields.Datetime(string="Execution Date", readonly=True, index=True) + user_id = fields.Many2one( + "res.users", string="User", default=lambda self: self.env.user, readonly=True + ) + flamegraph_html = fields.Html( + string="Flamegraph", compute="_compute_flamegraph_html", store=True + ) + + @api.depends("stats_binary") + def _compute_flamegraph_html(self): + for record in self: + record.flamegraph_html = record._generate_flamegraph_html() + + def _generate_flamegraph_html(self): + """Generate flamegraph from binary pstats data.""" + if not self.stats_binary: + return ( + "

No binary profiling data available. " + "Flamegraph requires stats_binary.

" + ) + + try: + import base64 + import logging + import os + import tempfile + + _logger = logging.getLogger(__name__) + + # When attachment=True, data is stored in ir.attachment + # We need to retrieve the attachment manually with bin_size=False + attachment = ( + self.env["ir.attachment"] + .sudo() + .search( + [ + ("res_model", "=", self._name), + ("res_id", "=", self.id), + ("res_field", "=", "stats_binary"), + ], + limit=1, + ) + ) + + if not attachment: + return "

No attachment found for stats_binary

" + + # Use with_context to ensure we get the full data, not just the size + attachment = attachment.with_context(bin_size=False) + + # Get raw data from attachment - datas field contains base64 encoded data + if not attachment.datas: + return "

No data in attachment

" + + try: + # Decode base64 to get raw binary pstats data + stats_data = base64.b64decode(attachment.datas) + _logger.info( + f"Stats data decoded successfully, length: {len(stats_data)} bytes" + ) + except Exception as e: + _logger.error( + f"Failed to decode base64: {e}, datas:" + f"{attachment.datas[:100] if attachment.datas else 'None'}" + ) + return f"

Cannot decode attachment data: {str(e)}

" + + # Create temp files + fd, pstats_path = tempfile.mkstemp(suffix=".pstats") + + try: + # Write pstats data to temp file + with os.fdopen(fd, "wb") as f: + f.write(stats_data) + + # Generate SVG flamegraph using flameprof + try: + result = subprocess.run( + ["flameprof", pstats_path], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + error_msg = result.stderr if result.stderr else "Unknown error" + _logger.warning(f"flameprof failed: {error_msg}") + return self.env["ir.ui.view"]._render_template( + "profiler.flamegraph_error", + {"error_message": error_msg}, + ) + + svg_content = result.stdout + except FileNotFoundError: + return self.env["ir.ui.view"]._render_template( + "profiler.flameprof_not_installed" + ) + except subprocess.TimeoutExpired: + return "

Flamegraph generation timed out (>30s)

" + except Exception as e: + _logger.error(f"Unexpected error in flameprof: {e}") + return f"

Unexpected error generating flamegraph: {str(e)}

" + + # Convert SVG to PNG using cairosvg to avoid SVG rendering issues + try: + from importlib import import_module + + cairosvg = import_module("cairosvg") + + # Convert SVG to PNG + png_bytes = cairosvg.svg2png( + bytestring=svg_content.encode("utf-8"), + output_width=1600, + ) # Set a reasonable width + + # Read PNG and encode as base64 for embedding + png_data = base64.b64encode(png_bytes).decode("utf-8") + + # Embed PNG as data URL + return self.env["ir.ui.view"]._render_template( + "profiler.flamegraph_png", {"png_data": png_data} + ) + + except ImportError: + _logger.warning("cairosvg not available, falling back to SVG") + # Fallback to SVG if cairosvg is not available + return self.env["ir.ui.view"]._render_template( + "profiler.flamegraph_svg", + {"svg_content": svg_content}, + ) + + finally: + # Cleanup temp files + os.unlink(pstats_path) + + except ImportError: + return ( + "

flameprof is not installed. " + "Please install it: pip install flameprof

" + ) + except subprocess.TimeoutExpired: + return "

Flamegraph generation timed out (>30s)

" + except Exception as e: + return f"

Error generating flamegraph: {str(e)}

" + + def action_download_pstats(self): + """Download pstats file for external analysis with gprof2dot.""" + self.ensure_one() + if not self.stats_binary: + raise ValueError("No binary stats data available for this profile") + + return { + "type": "ir.actions.act_url", + "url": f"/web/content/profiler.result/{self.id}/" + f"stats_binary/{self.name}.pstats?download=true", + "target": "new", + } + + def action_download_callgrind(self): + """Download callgrind file for external analysis with qcachegrind.""" + self.ensure_one() + if not self.stats_callgrind: + raise ValueError("No callgrind data available for this profile") + + return { + "type": "ir.actions.act_url", + "url": f"/web/content/profiler.result/{self.id}/" + f"stats_callgrind/{self.name}.callgrind?download=true", + "target": "new", + } diff --git a/profiler/pyproject.toml b/profiler/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/profiler/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/profiler/readme/CONTRIBUTORS.md b/profiler/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..9d9e2b572ab --- /dev/null +++ b/profiler/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Hadrien Huvelle \<\> + diff --git a/profiler/readme/CREDITS.md b/profiler/readme/CREDITS.md new file mode 100644 index 00000000000..41574341fc2 --- /dev/null +++ b/profiler/readme/CREDITS.md @@ -0,0 +1 @@ +The implementation of this module was financially supported by Camptocamp. diff --git a/profiler/readme/DESCRIPTION.md b/profiler/readme/DESCRIPTION.md new file mode 100644 index 00000000000..3ae9261c82a --- /dev/null +++ b/profiler/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +This module aims to help profle code executed in Odoo, and to analyze the results of the profiling. It is based on yappi profiler, which is a Python profiler that supports multi-threading and multi-processing. +It's main use case is to profile function executed in queue jobs or executed a large amount of times in a short time, which is not possible with the default cProfile profiler used in Odoo. It also allows to store the results of the profiling in the database, and to analyze them in Odoo or with external tools like snakeviz or flameprof. diff --git a/profiler/readme/ROADMAP.md b/profiler/readme/ROADMAP.md new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/profiler/readme/ROADMAP.md @@ -0,0 +1 @@ + diff --git a/profiler/readme/USAGE.md b/profiler/readme/USAGE.md new file mode 100644 index 00000000000..1e1b3e987e9 --- /dev/null +++ b/profiler/readme/USAGE.md @@ -0,0 +1,9 @@ +To use this module, you need to: + +- got to General Settings > Proflier > profiled functions and create a new record with the name of the function you want to profile, and the model if it's a method of a model. For example, if you want to profile the method `my_method` of the model `my.model`, you need to create a record with the name `my_method` and the model `my.model.my_method`. +EG: +- name: Stock Rule +- Python Path: odoo.addons.stock.models.stock_rule.ProcurementGroup.run_scheduler +- Sample rate (from 0 to 1): 0.1 (to profile 10% of the calls to this method and avoid too much overhead) +- Active if you want it to be active. + diff --git a/profiler/security/ir.model.access.csv b/profiler/security/ir.model.access.csv new file mode 100644 index 00000000000..82d0b6e2639 --- /dev/null +++ b/profiler/security/ir.model.access.csv @@ -0,0 +1,7 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_profiler_result_user,access_profiler_result_user,model_profiler_result,base.group_user,1,0,0,0 +access_profiler_result_system,access_profiler_result_system,model_profiler_result,base.group_system,1,1,1,1 +access_profiler_report_user,access_profiler_report_user,model_profiler_report,base.group_user,1,0,0,0 +access_profiler_report_system,access_profiler_report_system,model_profiler_report,base.group_system,1,1,1,1 +access_profiler_function_user,access_profiler_function_user,model_profiler_function,base.group_user,1,0,0,0 +access_profiler_function_system,access_profiler_function_system,model_profiler_function,base.group_system,1,1,1,1 diff --git a/profiler/static/description/index.html b/profiler/static/description/index.html new file mode 100644 index 00000000000..6a763c22edd --- /dev/null +++ b/profiler/static/description/index.html @@ -0,0 +1,58 @@ + + + + + Profiler + + +
+
+

Profiler

+

Performance profiling for Odoo

+
+

+ This module provides a decorator to profile Python functions using yappi + (Yet Another Python Profiler) and store the results in the database for later analysis. + Yappi is a tracing profiler that is thread-aware and provides more accurate timing + information than cProfile, especially for multi-threaded applications like Odoo. +

+
+
+
+ +
+
+

Features

+
+
    +
  • Simple decorator to profile any method
  • +
  • Configurable sampling rate
  • +
  • Interactive call graph visualization
  • +
  • Statistical reports with P95/P99 metrics
  • +
  • Export to .pstats for external analysis
  • +
+
+
+
+ +
+
+

Usage

+
+

Simply add the decorator to your methods:

+
+from odoo.addons.profiler.tools import profiled
+
+class MyModel(models.Model):
+    _name = "my.model"
+
+    @profiled()
+    def my_method(self):
+        # Your code here
+        pass
+                
+
+
+
+ + diff --git a/profiler/tests/__init__.py b/profiler/tests/__init__.py new file mode 100644 index 00000000000..b74da3ac326 --- /dev/null +++ b/profiler/tests/__init__.py @@ -0,0 +1 @@ +from . import test_profiler_result diff --git a/profiler/tests/test_profiler_result.py b/profiler/tests/test_profiler_result.py new file mode 100644 index 00000000000..529764b5da7 --- /dev/null +++ b/profiler/tests/test_profiler_result.py @@ -0,0 +1,109 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import base64 +from unittest.mock import Mock, patch + +from odoo.tests.common import TransactionCase + + +class TestProfilerResult(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.profiler_result_model = cls.env["profiler.result"] + + def _create_result(self, **vals): + defaults = { + "name": "test.function", + "stats_text": "sample stats", + "stats_binary": False, + } + defaults.update(vals) + with self.env.norecompute(): # type: ignore[attr-defined] + return self.profiler_result_model.create(defaults) + + def test_action_download_pstats_raises_without_binary(self): + record = self._create_result(stats_binary=False) + with self.assertRaises(ValueError): + record.action_download_pstats() + + def test_action_download_callgrind_raises_without_data(self): + record = self._create_result(stats_callgrind=False) + with self.assertRaises(ValueError): + record.action_download_callgrind() + + def test_action_download_urls(self): + record = self._create_result( + name="my.func", + stats_binary=base64.b64encode(b"pstats").decode(), + stats_callgrind=base64.b64encode(b"callgrind").decode(), + ) + pstats_action = record.action_download_pstats() + callgrind_action = record.action_download_callgrind() + + self.assertIn( + f"/profiler.result/{record.id}/stats_binary/my.func.pstats", + pstats_action["url"], + ) + self.assertIn( + f"/profiler.result/{record.id}/stats_callgrind/my.func.callgrind", + callgrind_action["url"], + ) + + def test_generate_flamegraph_html_without_binary(self): + record = self._create_result(stats_binary=False) + html = record._generate_flamegraph_html() + self.assertIn("No binary profiling data available", html) + + @patch("subprocess.run") + def test_generate_flamegraph_flameprof_error_template(self, mock_run): + mock_run.return_value = Mock(returncode=1, stderr="boom", stdout="") + record = self._create_result( + stats_binary=base64.b64encode(b"pstats").decode(), + ) + + html = record._generate_flamegraph_html() + + self.assertIn("Flamegraph Generation Failed", html) + self.assertIn("boom", html) + + @patch("subprocess.run") + def test_generate_flamegraph_flameprof_missing(self, mock_run): + mock_run.side_effect = FileNotFoundError() + record = self._create_result( + stats_binary=base64.b64encode(b"pstats").decode(), + ) + + html = record._generate_flamegraph_html() + + self.assertIn("Flameprof Not Installed", html) + + @patch("subprocess.run") + @patch("importlib.import_module") + def test_generate_flamegraph_svg_fallback(self, mock_import_module, mock_run): + mock_run.return_value = Mock(returncode=0, stderr="", stdout="") + mock_import_module.side_effect = ImportError() + record = self._create_result( + stats_binary=base64.b64encode(b"pstats").decode(), + ) + + html = record._generate_flamegraph_html() + + self.assertIn('
', html) + self.assertIn("", html) + + @patch("subprocess.run") + @patch("importlib.import_module") + def test_generate_flamegraph_png_render(self, mock_import_module, mock_run): + mock_run.return_value = Mock(returncode=0, stderr="", stdout="") + fake_cairosvg = Mock() + fake_cairosvg.svg2png.return_value = b"png-bytes" + mock_import_module.return_value = fake_cairosvg + record = self._create_result( + stats_binary=base64.b64encode(b"pstats").decode(), + ) + + html = record._generate_flamegraph_html() + + self.assertIn("data:image/png;base64,", html) diff --git a/profiler/tools/__init__.py b/profiler/tools/__init__.py new file mode 100644 index 00000000000..c6bdeaec539 --- /dev/null +++ b/profiler/tools/__init__.py @@ -0,0 +1,7 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .decorator import profiled +from . import dynamic_profile + +__all__ = ["profiled", "dynamic_profile"] diff --git a/profiler/tools/decorator.py b/profiler/tools/decorator.py new file mode 100644 index 00000000000..3b94801f2af --- /dev/null +++ b/profiler/tools/decorator.py @@ -0,0 +1,163 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import base64 +import io +import logging +import os +import random +import tempfile +import time +from functools import wraps + +try: + import yappi +except ImportError: + yappi = None + +_logger = logging.getLogger(__name__) + + +def profiled(sample_rate=0.05): + """ + Decorator to profile a function with yappi and store results in database. + + Args: + sample_rate (float): Percentage of calls to profile (default: 0.05 = 5%) + + Usage: + @profiled() + def my_function(self): + # Your code here + pass + + @profiled(sample_rate=0.01) # Profile 1% of calls + def another_function(self): + # Your code here + pass + """ + + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + # Skip profiling based on sample rate + if random.random() > sample_rate: + return func(*args, **kwargs) + + if yappi is None: + _logger.warning("yappi not installed, skipping profiling") + return func(*args, **kwargs) + + start_time = time.time() + previous_clock_type = yappi.get_clock_type() + use_wall_clock = previous_clock_type != "wall" + if use_wall_clock: + yappi.set_clock_type("wall") + yappi.start() + try: + result = func(*args, **kwargs) + return result + finally: + yappi.stop() + duration = time.time() - start_time + + # Get function stats + func_stats = yappi.get_func_stats() + + # Generate statistics text + s = io.StringIO() + func_stats.print_all(out=s) + stats_text = s.getvalue() + + # Save to pstats format (compatible with gprof2dot, snakeviz, etc.) + stats_binary = None + stats_callgrind = None + try: + # Create a temporary file to save stats + fd, temp_path = tempfile.mkstemp(suffix=".pstats") + try: + func_stats.save(temp_path, type="pstat") + # Read the binary data + with open(temp_path, "rb") as f: + stats_binary = f.read() + finally: + os.close(fd) + os.unlink(temp_path) + except Exception as e: + _logger.warning("Failed to serialize pstats: %s", e) + + try: + fd, temp_path = tempfile.mkstemp(suffix=".callgrind") + try: + func_stats.save(temp_path, type="callgrind") + with open(temp_path, "rb") as f: + stats_callgrind = f.read() + finally: + os.close(fd) + os.unlink(temp_path) + except Exception as e: + _logger.warning("Failed to serialize callgrind: %s", e) + + # Clear yappi stats for next profiling session + yappi.clear_stats() + + if use_wall_clock: + try: + yappi.set_clock_type(previous_clock_type) + except Exception as e: + _logger.warning("Failed to restore yappi clock type: %s", e) + + # Store in database + try: + _store_profile_in_db( + func.__name__, + stats_text, + duration, + args, + stats_binary, + stats_callgrind, + ) + except Exception as e: + _logger.error( + "Failed to store profile for %s: %s", func.__name__, e + ) + + return wrapper + + return decorator + + +def _store_profile_in_db( + func_name, stats_text, duration, args, stats_binary=None, stats_callgrind=None +): + """Store profile data in database.""" + # Try to get the environment from function arguments + env = None + if args and hasattr(args[0], "env"): + env = args[0].env + elif args and hasattr(args[0], "_name"): + # For model methods + env = args[0].env + + if not env: + _logger.warning("Cannot store profile for %s: no environment found", func_name) + return + + # Create a new cursor to avoid transaction rollback issues + with env.registry.cursor() as new_cr: + new_env = env(cr=new_cr) + try: + values = { + "name": func_name, + "stats_text": stats_text, + "duration": duration, + } + if stats_binary: + # Encode to base64 for Binary field storage + values["stats_binary"] = base64.b64encode(stats_binary) + if stats_callgrind: + values["stats_callgrind"] = base64.b64encode(stats_callgrind) + new_env["profiler.result"].create(values) + except Exception as e: + _logger.error("Error storing profile: %s", e) + new_cr.rollback() diff --git a/profiler/tools/dynamic_profile.py b/profiler/tools/dynamic_profile.py new file mode 100644 index 00000000000..ef86e3abee5 --- /dev/null +++ b/profiler/tools/dynamic_profile.py @@ -0,0 +1,162 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import importlib +import inspect +import logging + +from .decorator import profiled + +_logger = logging.getLogger(__name__) + +_PATCHED = {} + + +def _safe_import(module_path): + try: + return importlib.import_module(module_path) + except Exception as exc: + _logger.warning("Unable to import %s: %s", module_path, exc) + return None + + +def _resolve_target(path): + if not path or "." not in path: + raise ValueError("Invalid python path") + + parts = path.split(".") + if len(parts) < 2: + raise ValueError("Invalid python path") + + module_path = ".".join(parts[:-1]) + module = _safe_import(module_path) + if module and hasattr(module, parts[-1]): + return { + "owner": module, + "attr": parts[-1], + "descriptor": getattr(module, parts[-1]), + "restore_action": "set", + } + + if len(parts) < 3: + raise ValueError("Invalid python path") + + module_path = ".".join(parts[:-2]) + class_name = parts[-2] + attr = parts[-1] + module = _safe_import(module_path) + if not module: + raise ValueError("Module could not be imported") + + owner = getattr(module, class_name, None) + if owner is None or not inspect.isclass(owner): + raise ValueError("Class not found on module") + + if not hasattr(owner, attr): + raise ValueError("Attribute not found on class") + + raw = owner.__dict__.get(attr) + restore_action = "set" if raw is not None else "delete" + descriptor = raw if raw is not None else getattr(owner, attr) + + return { + "owner": owner, + "attr": attr, + "descriptor": descriptor, + "restore_action": restore_action, + } + + +def validate_path(path): + target = _resolve_target(path) + wrapped = _wrap_descriptor(target["descriptor"], 0.0) + if wrapped is None: + raise ValueError("Target is not callable") + return True + + +def _wrap_descriptor(descriptor, sample_rate): + if isinstance(descriptor, staticmethod): + wrapped = _wrap_callable(descriptor.__func__, sample_rate) + return staticmethod(wrapped) + if isinstance(descriptor, classmethod): + wrapped = _wrap_callable(descriptor.__func__, sample_rate) + return classmethod(wrapped) + if inspect.isfunction(descriptor): + return _wrap_callable(descriptor, sample_rate) + if inspect.ismethod(descriptor): + return _wrap_callable(descriptor.__func__, sample_rate) + if callable(descriptor): + return _wrap_callable(descriptor, sample_rate) + return None + + +def _wrap_callable(func, sample_rate): + if getattr(func, "_profiled_wrapped", False): + return func + + wrapped = profiled(sample_rate)(func) + wrapped._profiled_wrapped = True + wrapped._profiled_origin = func + return wrapped + + +def patch_path(path, sample_rate): + if path in _PATCHED: + if _PATCHED[path]["sample_rate"] == sample_rate: + return False + unpatch_path(path) + + target = _resolve_target(path) + wrapped = _wrap_descriptor(target["descriptor"], sample_rate) + if wrapped is None: + _logger.warning("Target %s is not callable", path) + return False + + setattr(target["owner"], target["attr"], wrapped) + _PATCHED[path] = { + "owner": target["owner"], + "attr": target["attr"], + "original": target["descriptor"], + "restore_action": target["restore_action"], + "sample_rate": sample_rate, + } + _logger.info("Profiled decorator applied on %s", path) + return True + + +def unpatch_path(path): + info = _PATCHED.pop(path, None) + if not info: + return False + + if info["restore_action"] == "delete": + try: + delattr(info["owner"], info["attr"]) + except AttributeError as exc: + _logger.error( + "Unable to remove profile patch for %s: attribute not found", + path, + exc, + _info=True, + ) + pass + else: + setattr(info["owner"], info["attr"], info["original"]) + + _logger.info("Profiled decorator removed from %s", path) + return True + + +def patch_active_records(records): + desired = {rec.python_path: rec for rec in records} + + for path in list(_PATCHED): + if path not in desired: + unpatch_path(path) + + for path, rec in desired.items(): + try: + patch_path(path, rec.sample_rate) + except Exception as exc: + _logger.warning("Unable to patch %s: %s", path, exc) diff --git a/profiler/views/profiler_function_views.xml b/profiler/views/profiler_function_views.xml new file mode 100644 index 00000000000..1554736021c --- /dev/null +++ b/profiler/views/profiler_function_views.xml @@ -0,0 +1,78 @@ + + + + + profiler.function.list + profiler.function + + + + + + + + + + + + profiler.function.form + profiler.function + +
+ + + + + + + + +
+
+
+ + + profiler.function.search + profiler.function + + + + + + + + + + + + + + Profiled Functions + profiler.function + list,form + + + + + +
diff --git a/profiler/views/profiler_report_views.xml b/profiler/views/profiler_report_views.xml new file mode 100644 index 00000000000..6a64f5fe90f --- /dev/null +++ b/profiler/views/profiler_report_views.xml @@ -0,0 +1,109 @@ + + + + + + profiler.report.list + profiler.report + + + + + + + + + + + + + profiler.report.graph + profiler.report + + + + + + + + + + + + profiler.report.pivot + profiler.report + + + + + + + + + + + + + profiler.report.search + profiler.report + + + + + + + + + + + + + + Profiler Statistics + profiler.report + graph,pivot,list + {'create': False, 'edit': False, 'delete': False, 'time_delta': '24:00:00', 'search_default_time_delta_24h': 1} + + + + + diff --git a/profiler/views/profiler_result_views.xml b/profiler/views/profiler_result_views.xml new file mode 100644 index 00000000000..1f5257c9d47 --- /dev/null +++ b/profiler/views/profiler_result_views.xml @@ -0,0 +1,227 @@ + + + + + + profiler.result.list + profiler.result + + + + + + + + + + + + + profiler.result.form + profiler.result + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + profiler.result.search + profiler.result + + + + + + + + + + + + + + + + + + Profiler Results + profiler.result + list,form + {'search_default_last_7_days': 1} + + + + + + + + + + + + + + +
diff --git a/requirements.txt b/requirements.txt index 5d1fefa6f23..947d3446403 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,8 @@ # generated from manifests external_dependencies +cairosvg cryptography dataclasses +flameprof odoo_test_helper odoorpc openpyxl @@ -10,3 +12,4 @@ pygount pysftp sentry_sdk>=2.0.0,<=2.22.0 unittest-xml-reporting +yappi