From c19611efff2a9b7d5ba69b959a8ebc5c6d5b1a7e Mon Sep 17 00:00:00 2001 From: Moises Lopez - Vauxoo Date: Thu, 17 Sep 2026 21:23:40 +0000 Subject: [PATCH 1/2] [FIX] sentry: give each cron job its own scope A cron worker serves no WSGI request, so the middleware that scopes HTTP requests never covers it. Its jobs keep the scope built when the SDK was initialized, before the workers forked, so events from unrelated jobs report the same trace and carry each other's breadcrumbs. Fork the scope in ir.cron._callback and drop the trace and breadcrumbs that forking keeps. --- sentry/__init__.py | 1 + sentry/models/__init__.py | 3 ++ sentry/models/ir_cron.py | 26 ++++++++++++++++++ sentry/tests/__init__.py | 8 +++++- sentry/tests/test_ir_cron.py | 53 ++++++++++++++++++++++++++++++++++++ 5 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 sentry/models/__init__.py create mode 100644 sentry/models/ir_cron.py create mode 100644 sentry/tests/test_ir_cron.py diff --git a/sentry/__init__.py b/sentry/__init__.py index 7001103db4d..f1a5233b277 100644 --- a/sentry/__init__.py +++ b/sentry/__init__.py @@ -1 +1,2 @@ +from . import models from .hooks import post_load 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", + ) From 1f4363eeedb812c6bcc9f2cfcbd20e9883a7a369 Mon Sep 17 00:00:00 2001 From: Moises Lopez - Vauxoo Date: Fri, 18 Sep 2026 08:08:14 +0000 Subject: [PATCH 2/2] [IMP] sentry: name the transaction of a queue job A queue job runs inside an HTTP request, so it already gets a scope of its own, but it is reported under the route that ran it and every job groups under that one name. Name it after the job instead. Only when queue_job is available, the way the module already treats server_environment. --- sentry/__init__.py | 1 + sentry/controllers/__init__.py | 3 +++ sentry/controllers/queue_job.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 34 insertions(+) create mode 100644 sentry/controllers/__init__.py create mode 100644 sentry/controllers/queue_job.py diff --git a/sentry/__init__.py b/sentry/__init__.py index f1a5233b277..142dd3ed2d9 100644 --- a/sentry/__init__.py +++ b/sentry/__init__.py @@ -1,2 +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)