diff --git a/sentry/__init__.py b/sentry/__init__.py index 7001103db4d..142dd3ed2d9 100644 --- a/sentry/__init__.py +++ b/sentry/__init__.py @@ -1 +1,3 @@ +from . import controllers +from . import models from .hooks import post_load diff --git a/sentry/controllers/__init__.py b/sentry/controllers/__init__.py new file mode 100644 index 00000000000..b2b96022d97 --- /dev/null +++ b/sentry/controllers/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import queue_job diff --git a/sentry/controllers/queue_job.py b/sentry/controllers/queue_job.py new file mode 100644 index 00000000000..129517d106f --- /dev/null +++ b/sentry/controllers/queue_job.py @@ -0,0 +1,30 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +import sentry_sdk + +_logger = logging.getLogger(__name__) + +try: + from odoo.addons.queue_job.controllers.main import RunJobController +except ImportError: + _logger.debug("'queue_job' is not available, its jobs keep the default name.") +else: + + class SentryRunJobController(RunJobController): + @classmethod + def _runjob(cls, env, job): + """Report a queue job under a name of its own. + + A job already runs inside an HTTP request, so the WSGI middleware + gives it a scope of its own. What it does not give it is a name: + every job is reported as the route that ran it, which groups all + of them together whatever they were doing. Name it after the job + instead, keeping to the model and the method so that jobs of the + same kind still group together. + """ + sentry_sdk.get_current_scope().set_transaction_name( + f"{job.model_name}.{job.method_name}", source="task" + ) + return super()._runjob(env, job) diff --git a/sentry/models/__init__.py b/sentry/models/__init__.py new file mode 100644 index 00000000000..b365c0e973b --- /dev/null +++ b/sentry/models/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import ir_cron diff --git a/sentry/models/ir_cron.py b/sentry/models/ir_cron.py new file mode 100644 index 00000000000..137f99355cc --- /dev/null +++ b/sentry/models/ir_cron.py @@ -0,0 +1,26 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import sentry_sdk + +from odoo import models + + +class IrCron(models.Model): + _inherit = "ir.cron" + + def _callback(self, cron_name, server_action_id): + """Run every job under a scope of its own. + + The WSGI middleware gives each HTTP request a fresh scope, but a cron + worker serves no request, so its jobs otherwise keep the scope that + existed when ``sentry_sdk.init()`` ran: in the master process, before + the workers forked. Events coming from unrelated jobs then report the + same trace and carry each other's breadcrumbs. + """ + with sentry_sdk.isolation_scope() as scope: + # Forking a scope copies what is already on it, so the inherited + # trace and breadcrumbs have to be dropped explicitly. + scope.clear_breadcrumbs() + scope.set_new_propagation_context() + scope.set_transaction_name(cron_name, source="task") + return super()._callback(cron_name, server_action_id) diff --git a/sentry/tests/__init__.py b/sentry/tests/__init__.py index 184488f04bf..18669645c2f 100644 --- a/sentry/tests/__init__.py +++ b/sentry/tests/__init__.py @@ -1,4 +1,10 @@ # Copyright 2016-2017 Versada # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from . import test_client, test_logutils, test_processor, test_generalutils +from . import ( + test_client, + test_generalutils, + test_ir_cron, + test_logutils, + test_processor, +) diff --git a/sentry/tests/test_ir_cron.py b/sentry/tests/test_ir_cron.py new file mode 100644 index 00000000000..da8ff1627ba --- /dev/null +++ b/sentry/tests/test_ir_cron.py @@ -0,0 +1,53 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +import sentry_sdk + +from odoo.tests import TransactionCase + +from odoo.addons.base.models.ir_cron import IrCron + + +class TestIrCronScope(TransactionCase): + def _run_two_jobs(self): + """Run two jobs, reporting the scope each one ran under.""" + seen = [] + + def spy(self, cron_name, server_action_id): + sentry_sdk.add_breadcrumb(message=cron_name) + scope = sentry_sdk.get_isolation_scope() + seen.append((scope.get_traceparent(), len(scope._breadcrumbs))) + return True + + with patch.object(IrCron, "_callback", spy): + self.env["ir.cron"]._callback("job-1", 1) + self.env["ir.cron"]._callback("job-2", 2) + return seen + + def test_each_job_runs_under_its_own_trace(self): + outside = sentry_sdk.get_isolation_scope().get_traceparent() + seen = self._run_two_jobs() + self.assertEqual(len(seen), 2) + self.assertTrue( + all(traceparent for traceparent, _ in seen), + "a job ran without any trace at all", + ) + self.assertNotEqual( + seen[0][0], + seen[1][0], + "both jobs reported the same trace, so they shared a scope", + ) + self.assertEqual( + sentry_sdk.get_isolation_scope().get_traceparent(), + outside, + "the scope of the worker itself must be left as it was", + ) + + def test_breadcrumbs_do_not_leak_between_jobs(self): + seen = self._run_two_jobs() + self.assertEqual( + [count for _, count in seen], + [1, 1], + "a job saw breadcrumbs left by another one", + )