Skip to content

Commit 6191458

Browse files
author
Hadrien Huvelle
committed
Init profiler
1 parent 7473ce1 commit 6191458

18 files changed

+1229
-0
lines changed

profiler/README.rst

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
========
2+
Profiler
3+
========
4+
5+
.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
6+
!! This file is generated by oca-gen-addon-readme !!
7+
!! changes will be overwritten. !!
8+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
9+
10+
This module provides a decorator to profile Python functions using yappi
11+
and store the results in the database for later analysis.
12+
13+
Yappi (Yet Another Python Profiler) is a tracing profiler that is thread-aware
14+
and provides more accurate timing information than cProfile, especially for
15+
multi-threaded applications like Odoo.
16+
17+
**Table of contents**
18+
19+
.. contents::
20+
:local:
21+
22+
Usage
23+
=====
24+
25+
To profile a function, simply use the ``@profiled()`` decorator::
26+
27+
from odoo.addons.profiler.tools import profiled
28+
29+
class MyModel(models.Model):
30+
_name = "my.model"
31+
32+
@profiled()
33+
def my_method(self):
34+
# Your code here
35+
pass
36+
37+
You can customize the sample rate (percentage of calls to profile)::
38+
39+
@profiled(sample_rate=0.01) # Profile 1% of calls
40+
def another_method(self):
41+
# Your code here
42+
pass
43+
44+
The profiling results are stored in the database and can be viewed in:
45+
Settings → Technical → Profiler → Profiler Results
46+
47+
Each profiling result includes:
48+
49+
* Interactive Call Graph visualization showing top functions by cumulative time
50+
* Raw statistics with detailed timing information
51+
* Download button to export .pstats files for external analysis
52+
53+
You can download the .pstats file from any profiling result and use external tools
54+
like gprof2dot to generate detailed call graphs::
55+
56+
gprof2dot -f pstats my_function.pstats | dot -Tpng -o output.png
57+
58+
Or use snakeviz for interactive visualization::
59+
60+
snakeviz my_function.pstats
61+
62+
Bug Tracker
63+
===========
64+
65+
Bugs are tracked on `GitHub Issues <https://github.com/OCA/server-tools/issues>`_.
66+
In case of trouble, please check there if your issue has already been reported.
67+
68+
Credits
69+
=======
70+
71+
Authors
72+
~~~~~~~
73+
74+
* Camptocamp
75+
76+
Contributors
77+
~~~~~~~~~~~~
78+
79+
* Camptocamp
80+
81+
Maintainers
82+
~~~~~~~~~~~
83+
84+
This module is maintained by the OCA.
85+
86+
.. image:: https://odoo-community.org/logo.png
87+
:alt: Odoo Community Association
88+
:target: https://odoo-community.org
89+
90+
OCA, or the Odoo Community Association, is a nonprofit organization whose
91+
mission is to support the collaborative development of Odoo features and
92+
promote its widespread use.
93+
94+
This module is part of the `OCA/server-tools <https://github.com/OCA/server-tools/tree/18.0/profiler>`_ project on GitHub.
95+
96+
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

profiler/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Copyright 2026 Camptocamp SA
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
3+
4+
from . import models
5+
from . import tools

profiler/__manifest__.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Copyright 2026 Camptocamp SA
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
3+
4+
{
5+
"name": "Profiler",
6+
"version": "18.0.1.0.0",
7+
"category": "Tools",
8+
"summary": "yappi profiler decorator with database storage",
9+
"author": "Camptocamp, Odoo Community Association (OCA)",
10+
"license": "AGPL-3",
11+
"website": "https://github.com/OCA/server-tools",
12+
"depends": ["base", "edi_exchange_template_oca"],
13+
"external_dependencies": {
14+
"python": ["yappi", "cairosvg"],
15+
},
16+
"data": [
17+
"security/ir.model.access.csv",
18+
"views/profiler_function_views.xml",
19+
"views/profiler_result_views.xml",
20+
"views/profiler_report_views.xml",
21+
],
22+
"installable": True,
23+
}

profiler/models/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Copyright 2026 Camptocamp SA
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
3+
4+
from . import profiler_result
5+
from . import profiler_report
6+
from . import edi_exchange_template_output
7+
from . import profiler_function
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Copyright 2026 Camptocamp SA
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
3+
4+
from odoo import models
5+
6+
from ..tools import profiled
7+
8+
9+
class EDIExchangeOutputTemplate(models.Model):
10+
_inherit = "edi.exchange.template.output"
11+
12+
@profiled(sample_rate=1.0)
13+
def exchange_generate(self, exchange_record, **kw):
14+
"""Generate output for given record using related QWeb template."""
15+
return super().exchange_generate(exchange_record, **kw)
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Copyright 2026 Camptocamp SA
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
3+
4+
import logging
5+
6+
from odoo import api, fields, models
7+
from odoo.exceptions import ValidationError
8+
9+
from ..tools import dynamic_profile
10+
11+
_logger = logging.getLogger(__name__)
12+
13+
14+
class ProfilerFunction(models.Model):
15+
_name = "profiler.function"
16+
_description = "Profiled Function"
17+
_order = "name"
18+
19+
name = fields.Char(required=True)
20+
python_path = fields.Char(
21+
required=True,
22+
index=True,
23+
help="Use module.function or module.Class.method",
24+
)
25+
sample_rate = fields.Float(
26+
default=0.05,
27+
help="Percentage of calls to profile (0.0 - 1.0)",
28+
)
29+
active = fields.Boolean(default=True)
30+
31+
@api.constrains("python_path")
32+
def _check_python_path(self):
33+
for record in self:
34+
if not record.python_path:
35+
continue
36+
try:
37+
dynamic_profile.validate_path(record.python_path)
38+
except Exception as exc:
39+
raise ValidationError(
40+
f"Invalid python path {record.python_path}: {exc}"
41+
) from exc
42+
43+
@api.model_create_multi
44+
def create(self, vals_list):
45+
records = super().create(vals_list)
46+
records._update_registry()
47+
return records
48+
49+
def write(self, vals):
50+
res = super().write(vals)
51+
if {"python_path", "sample_rate", "active"} & set(vals):
52+
self._update_registry()
53+
return res
54+
55+
def unlink(self):
56+
res = super().unlink()
57+
self._update_registry()
58+
return res
59+
60+
def _update_registry(self):
61+
if self.env.registry.ready:
62+
self._unregister_hook()
63+
self._register_hook()
64+
self.env.registry.registry_invalidated = True
65+
else:
66+
_logger.info("Registry not ready, skipping profiler patch update")
67+
68+
def _register_hook(self):
69+
res = super()._register_hook()
70+
active_records = self.search([("active", "=", True)])
71+
dynamic_profile.patch_active_records(active_records)
72+
return res
73+
74+
def _unregister_hook(self):
75+
res = super()._unregister_hook()
76+
for record in self.with_context(active_test=False).search([]):
77+
try:
78+
dynamic_profile.unpatch_path(record.python_path)
79+
except Exception as exc:
80+
_logger.warning(
81+
"Unable to remove profile patch for %s: %s",
82+
record.python_path,
83+
exc,
84+
)
85+
return res

profiler/models/profiler_report.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Copyright 2026 Camptocamp SA
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
3+
4+
from odoo import api, fields, models
5+
6+
7+
class ProfilerReport(models.TransientModel):
8+
_name = "profiler.report"
9+
_description = "Profiler Statistics Report"
10+
11+
name = fields.Char(string="Function Name")
12+
p95 = fields.Float(string="P95 Duration (s)", digits=(12, 6))
13+
p99 = fields.Float(string="P99 Duration (s)", digits=(12, 6))
14+
count = fields.Integer(string="Call Count")
15+
days_back = fields.Integer(string="Number of days", default=1)
16+
17+
@api.model
18+
def generate_report(self, days_back=1):
19+
"""Generate the profiler statistics report."""
20+
self.search([]).unlink()
21+
22+
query = """
23+
SELECT name,
24+
percentile_disc(0.95) WITHIN GROUP (ORDER BY duration) AS p95,
25+
percentile_disc(0.99) WITHIN GROUP (ORDER BY duration) AS p99,
26+
count(*) AS count
27+
FROM profiler_result
28+
WHERE create_date > now() - interval '%s day'
29+
GROUP BY name
30+
ORDER BY p99 DESC
31+
LIMIT 20
32+
"""
33+
34+
self.env.cr.execute(query, (days_back,))
35+
results = self.env.cr.fetchall()
36+
37+
report_records = []
38+
for row in results:
39+
report_records.append(
40+
{
41+
"name": row[0],
42+
"p95": row[1],
43+
"p99": row[2],
44+
"count": row[3],
45+
"days_back": days_back,
46+
}
47+
)
48+
49+
if report_records:
50+
self.create(report_records)
51+
52+
return {
53+
"type": "ir.actions.act_window",
54+
"name": "Profiler Statistics Report",
55+
"res_model": "profiler.report",
56+
"view_mode": "list",
57+
"target": "current",
58+
"context": {"create": False, "edit": False, "delete": False},
59+
}
60+
61+
def action_refresh(self):
62+
"""Refresh the report with current data."""
63+
days_back = self[0].days_back if self else 1
64+
return self.generate_report(days_back=days_back)

0 commit comments

Comments
 (0)