From d3f416f1f0d991f48ed06f99c6b1c3c0ec5d096e Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 9 Dec 2025 16:13:01 +0100 Subject: [PATCH 1/7] feat: Add queue.submit span when a Django task is enqueued --- sentry_sdk/consts.py | 1 + sentry_sdk/integrations/django/__init__.py | 2 ++ sentry_sdk/integrations/django/tasks.py | 38 ++++++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 sentry_sdk/integrations/django/tasks.py diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index ae6bc10f99..11e4c2b760 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -936,6 +936,7 @@ class OP: QUEUE_SUBMIT_RAY = "queue.submit.ray" QUEUE_TASK_RAY = "queue.task.ray" QUEUE_TASK_DRAMATIQ = "queue.task.dramatiq" + QUEUE_SUBMIT_DJANGO = "queue.submit.django" SUBPROCESS = "subprocess" SUBPROCESS_WAIT = "subprocess.wait" SUBPROCESS_COMMUNICATE = "subprocess.communicate" diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py index c18a03a38c..5a808a53cb 100644 --- a/sentry_sdk/integrations/django/__init__.py +++ b/sentry_sdk/integrations/django/__init__.py @@ -62,6 +62,7 @@ ) from sentry_sdk.integrations.django.middleware import patch_django_middlewares from sentry_sdk.integrations.django.signals_handlers import patch_signals +from sentry_sdk.integrations.django.tasks import patch_tasks from sentry_sdk.integrations.django.views import patch_views if DJANGO_VERSION[:2] > (1, 8): @@ -271,6 +272,7 @@ def _django_queryset_repr(value, hint): patch_views() patch_templates() patch_signals() + patch_tasks() add_template_context_repr_sequence() if patch_caching is not None: diff --git a/sentry_sdk/integrations/django/tasks.py b/sentry_sdk/integrations/django/tasks.py new file mode 100644 index 0000000000..8a14a353cb --- /dev/null +++ b/sentry_sdk/integrations/django/tasks.py @@ -0,0 +1,38 @@ +from functools import wraps + +import sentry_sdk +from sentry_sdk.consts import OP + +try: + # django.tasks were added in Django 6.0 + from django.tasks.base import Task +except ImportError: + Task = None + + +def patch_tasks(): + # type: () -> None + if Task is None: + return + + old_task_enqueue = Task.enqueue + + @wraps(old_task_enqueue) + def _sentry_enqueue(self, *args, **kwargs): + # type: (Any, Any) -> Any + from sentry_sdk.integrations.django import DjangoIntegration + + integration = sentry_sdk.get_client().get_integration(DjangoIntegration) + if integration is None: + return old_task_enqueue(*args, **kwargs) + + name = ( + getattr(self.func, "__name__", repr(self.func)) or "" + ) + + with sentry_sdk.start_span( + op=OP.QUEUE_SUBMIT_DJANGO, name=name, origin=DjangoIntegration.origin + ): + return old_task_enqueue(*args, **kwargs) + + Task.enqueue = _sentry_enqueue From aefbc18ff748587477db7a27e802c3be1509379c Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 9 Dec 2025 16:19:03 +0100 Subject: [PATCH 2/7] fix --- sentry_sdk/integrations/django/tasks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/django/tasks.py b/sentry_sdk/integrations/django/tasks.py index 8a14a353cb..71ca048bab 100644 --- a/sentry_sdk/integrations/django/tasks.py +++ b/sentry_sdk/integrations/django/tasks.py @@ -24,7 +24,7 @@ def _sentry_enqueue(self, *args, **kwargs): integration = sentry_sdk.get_client().get_integration(DjangoIntegration) if integration is None: - return old_task_enqueue(*args, **kwargs) + return old_task_enqueue(self, *args, **kwargs) name = ( getattr(self.func, "__name__", repr(self.func)) or "" @@ -33,6 +33,6 @@ def _sentry_enqueue(self, *args, **kwargs): with sentry_sdk.start_span( op=OP.QUEUE_SUBMIT_DJANGO, name=name, origin=DjangoIntegration.origin ): - return old_task_enqueue(*args, **kwargs) + return old_task_enqueue(self, *args, **kwargs) Task.enqueue = _sentry_enqueue From 11180e695861ff9c11fe5c7fa970641c7d625666 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 10 Dec 2025 11:08:13 +0100 Subject: [PATCH 3/7] tests --- tests/integrations/django/test_tasks.py | 228 ++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 tests/integrations/django/test_tasks.py diff --git a/tests/integrations/django/test_tasks.py b/tests/integrations/django/test_tasks.py new file mode 100644 index 0000000000..9d2d6bbaaf --- /dev/null +++ b/tests/integrations/django/test_tasks.py @@ -0,0 +1,228 @@ +import pytest + +import sentry_sdk +from sentry_sdk import start_span +from sentry_sdk.integrations.django import DjangoIntegration +from sentry_sdk.consts import OP + + +try: + from django.tasks import task + + HAS_DJANGO_TASKS = True +except ImportError: + HAS_DJANGO_TASKS = False + + +@pytest.fixture +def immediate_backend(settings): + """Configure Django to use the immediate task backend for synchronous testing.""" + settings.TASKS = { + "default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBackend"} + } + + +if HAS_DJANGO_TASKS: + + @task + def simple_task(): + return "result" + + @task + def add_numbers(a, b): + return a + b + + @task + def greet(name, greeting="Hello"): + return f"{greeting}, {name}!" + + @task + def failing_task(): + raise ValueError("Task failed!") + + @task + def task_one(): + return 1 + + @task + def task_two(): + return 2 + + +@pytest.mark.skipif( + not HAS_DJANGO_TASKS, + reason="Django tasks are only available in Django 6.0+", +) +def test_task_span_is_created(sentry_init, capture_events, immediate_backend): + """Test that the queue.submit.django span is created when a task is enqueued.""" + sentry_init( + integrations=[DjangoIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + with sentry_sdk.start_transaction(name="test_transaction"): + simple_task.enqueue() + + (event,) = events + assert event["type"] == "transaction" + + queue_submit_spans = [ + span for span in event["spans"] if span["op"] == OP.QUEUE_SUBMIT_DJANGO + ] + assert len(queue_submit_spans) == 1 + assert queue_submit_spans[0]["description"] == "simple_task" + assert queue_submit_spans[0]["origin"] == "auto.http.django" + + +@pytest.mark.skipif( + not HAS_DJANGO_TASKS, + reason="Django tasks are only available in Django 6.0+", +) +def test_task_enqueue_returns_result(sentry_init, immediate_backend): + """Test that the task enqueuing behavior is unchanged from the user perspective.""" + sentry_init( + integrations=[DjangoIntegration()], + traces_sample_rate=1.0, + ) + + result = add_numbers.enqueue(3, 5) + + assert result is not None + assert result.return_value == 8 + + +@pytest.mark.skipif( + not HAS_DJANGO_TASKS, + reason="Django tasks are only available in Django 6.0+", +) +def test_task_enqueue_with_kwargs(sentry_init, immediate_backend, capture_events): + """Test that task enqueuing works correctly with keyword arguments.""" + sentry_init( + integrations=[DjangoIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + with sentry_sdk.start_transaction(name="test_transaction"): + result = greet.enqueue(name="World", greeting="Hi") + + assert result.return_value == "Hi, World!" + + (event,) = events + queue_submit_spans = [ + span for span in event["spans"] if span["op"] == OP.QUEUE_SUBMIT_DJANGO + ] + assert len(queue_submit_spans) == 1 + assert queue_submit_spans[0]["description"] == "greet" + + +@pytest.mark.skipif( + not HAS_DJANGO_TASKS, + reason="Django tasks are only available in Django 6.0+", +) +def test_task_error_reporting(sentry_init, immediate_backend, capture_events): + """Test that errors in tasks are correctly reported and don't break the span.""" + sentry_init( + integrations=[DjangoIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + with sentry_sdk.start_transaction(name="test_transaction"): + result = failing_task.enqueue() + + with pytest.raises(ValueError, match="Task failed"): + _ = result.return_value + + assert len(events) >= 1 + transaction_event = events[-1] + assert transaction_event["type"] == "transaction" + + queue_submit_spans = [ + span + for span in transaction_event["spans"] + if span["op"] == OP.QUEUE_SUBMIT_DJANGO + ] + assert len(queue_submit_spans) == 1 + assert queue_submit_spans[0]["description"] == "failing_task" + + +@pytest.mark.skipif( + not HAS_DJANGO_TASKS, + reason="Django tasks are only available in Django 6.0+", +) +def test_task_span_not_created_without_integration( + sentry_init, capture_events, immediate_backend +): + """Test that no span is created when DjangoIntegration is not enabled.""" + sentry_init(traces_sample_rate=1.0, default_integrations=False) + events = capture_events() + + with sentry_sdk.start_transaction(name="test_transaction"): + simple_task.enqueue() + + (event,) = events + queue_submit_spans = [ + span for span in event["spans"] if span["op"] == OP.QUEUE_SUBMIT_DJANGO + ] + assert len(queue_submit_spans) == 0 + + +@pytest.mark.skipif( + not HAS_DJANGO_TASKS, + reason="Django tasks are only available in Django 6.0+", +) +def test_multiple_task_enqueues_create_multiple_spans( + sentry_init, capture_events, immediate_backend +): + """Test that enqueueing multiple tasks creates multiple spans.""" + sentry_init( + integrations=[DjangoIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + with sentry_sdk.start_transaction(name="test_transaction"): + task_one.enqueue() + task_two.enqueue() + task_one.enqueue() + + (event,) = events + queue_submit_spans = [ + span for span in event["spans"] if span["op"] == OP.QUEUE_SUBMIT_DJANGO + ] + assert len(queue_submit_spans) == 3 + + span_names = [span["description"] for span in queue_submit_spans] + assert span_names.count("task_one") == 2 + assert span_names.count("task_two") == 1 + + +@pytest.mark.skipif( + not HAS_DJANGO_TASKS, + reason="Django tasks are only available in Django 6.0+", +) +def test_nested_task_enqueue_spans(sentry_init, capture_events, immediate_backend): + """Test that task spans are properly nested under parent spans.""" + sentry_init( + integrations=[DjangoIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + with sentry_sdk.start_transaction(name="test_transaction"): + with start_span(op="custom.operation", name="parent_span"): + simple_task.enqueue() + + (event,) = events + + custom_spans = [span for span in event["spans"] if span["op"] == "custom.operation"] + queue_submit_spans = [ + span for span in event["spans"] if span["op"] == OP.QUEUE_SUBMIT_DJANGO + ] + + assert len(custom_spans) == 1 + assert len(queue_submit_spans) == 1 + + assert queue_submit_spans[0]["parent_span_id"] == custom_spans[0]["span_id"] From 8a6cc399ce6f945224c4444b5d4681b366820512 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 10 Dec 2025 13:23:04 +0100 Subject: [PATCH 4/7] . --- sentry_sdk/integrations/django/tasks.py | 3 +- tests/integrations/django/test_tasks.py | 52 +------------------------ 2 files changed, 3 insertions(+), 52 deletions(-) diff --git a/sentry_sdk/integrations/django/tasks.py b/sentry_sdk/integrations/django/tasks.py index 71ca048bab..99b08dd3df 100644 --- a/sentry_sdk/integrations/django/tasks.py +++ b/sentry_sdk/integrations/django/tasks.py @@ -2,10 +2,11 @@ import sentry_sdk from sentry_sdk.consts import OP +from sentry_sdk.tracing import SPANSTATUS try: # django.tasks were added in Django 6.0 - from django.tasks.base import Task + from django.tasks.base import Task, TaskResultStatus except ImportError: Task = None diff --git a/tests/integrations/django/test_tasks.py b/tests/integrations/django/test_tasks.py index 9d2d6bbaaf..400f42e865 100644 --- a/tests/integrations/django/test_tasks.py +++ b/tests/integrations/django/test_tasks.py @@ -135,7 +135,7 @@ def test_task_error_reporting(sentry_init, immediate_backend, capture_events): with pytest.raises(ValueError, match="Task failed"): _ = result.return_value - assert len(events) >= 1 + assert len(events) == 2 transaction_event = events[-1] assert transaction_event["type"] == "transaction" @@ -148,27 +148,6 @@ def test_task_error_reporting(sentry_init, immediate_backend, capture_events): assert queue_submit_spans[0]["description"] == "failing_task" -@pytest.mark.skipif( - not HAS_DJANGO_TASKS, - reason="Django tasks are only available in Django 6.0+", -) -def test_task_span_not_created_without_integration( - sentry_init, capture_events, immediate_backend -): - """Test that no span is created when DjangoIntegration is not enabled.""" - sentry_init(traces_sample_rate=1.0, default_integrations=False) - events = capture_events() - - with sentry_sdk.start_transaction(name="test_transaction"): - simple_task.enqueue() - - (event,) = events - queue_submit_spans = [ - span for span in event["spans"] if span["op"] == OP.QUEUE_SUBMIT_DJANGO - ] - assert len(queue_submit_spans) == 0 - - @pytest.mark.skipif( not HAS_DJANGO_TASKS, reason="Django tasks are only available in Django 6.0+", @@ -197,32 +176,3 @@ def test_multiple_task_enqueues_create_multiple_spans( span_names = [span["description"] for span in queue_submit_spans] assert span_names.count("task_one") == 2 assert span_names.count("task_two") == 1 - - -@pytest.mark.skipif( - not HAS_DJANGO_TASKS, - reason="Django tasks are only available in Django 6.0+", -) -def test_nested_task_enqueue_spans(sentry_init, capture_events, immediate_backend): - """Test that task spans are properly nested under parent spans.""" - sentry_init( - integrations=[DjangoIntegration()], - traces_sample_rate=1.0, - ) - events = capture_events() - - with sentry_sdk.start_transaction(name="test_transaction"): - with start_span(op="custom.operation", name="parent_span"): - simple_task.enqueue() - - (event,) = events - - custom_spans = [span for span in event["spans"] if span["op"] == "custom.operation"] - queue_submit_spans = [ - span for span in event["spans"] if span["op"] == OP.QUEUE_SUBMIT_DJANGO - ] - - assert len(custom_spans) == 1 - assert len(queue_submit_spans) == 1 - - assert queue_submit_spans[0]["parent_span_id"] == custom_spans[0]["span_id"] From 176a660b6ec7b71a41c85a48b476dcc9e75b2bac Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 10 Dec 2025 13:30:49 +0100 Subject: [PATCH 5/7] typing --- sentry_sdk/integrations/django/tasks.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/django/tasks.py b/sentry_sdk/integrations/django/tasks.py index 99b08dd3df..10892bf974 100644 --- a/sentry_sdk/integrations/django/tasks.py +++ b/sentry_sdk/integrations/django/tasks.py @@ -10,6 +10,11 @@ except ImportError: Task = None +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any + def patch_tasks(): # type: () -> None @@ -20,7 +25,7 @@ def patch_tasks(): @wraps(old_task_enqueue) def _sentry_enqueue(self, *args, **kwargs): - # type: (Any, Any) -> Any + # type: (Any, Any, Any) -> Any from sentry_sdk.integrations.django import DjangoIntegration integration = sentry_sdk.get_client().get_integration(DjangoIntegration) From 1589371519af6776cdbffb11eb81052cd8a9f36b Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 11 Dec 2025 11:09:46 +0100 Subject: [PATCH 6/7] use qualname_from_function --- sentry_sdk/integrations/django/tasks.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/django/tasks.py b/sentry_sdk/integrations/django/tasks.py index 10892bf974..f98d5bb43e 100644 --- a/sentry_sdk/integrations/django/tasks.py +++ b/sentry_sdk/integrations/django/tasks.py @@ -3,6 +3,7 @@ import sentry_sdk from sentry_sdk.consts import OP from sentry_sdk.tracing import SPANSTATUS +from sentry_sdk.utils import qualname_from_function try: # django.tasks were added in Django 6.0 @@ -32,9 +33,7 @@ def _sentry_enqueue(self, *args, **kwargs): if integration is None: return old_task_enqueue(self, *args, **kwargs) - name = ( - getattr(self.func, "__name__", repr(self.func)) or "" - ) + name = qualname_from_function(self.func) or "" with sentry_sdk.start_span( op=OP.QUEUE_SUBMIT_DJANGO, name=name, origin=DjangoIntegration.origin From 7c5ed8b38470e9297f7064b1ccf85445ccc80837 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 11 Dec 2025 11:16:02 +0100 Subject: [PATCH 7/7] update tests --- tests/integrations/django/test_tasks.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/integrations/django/test_tasks.py b/tests/integrations/django/test_tasks.py index 400f42e865..220d64b111 100644 --- a/tests/integrations/django/test_tasks.py +++ b/tests/integrations/django/test_tasks.py @@ -71,7 +71,10 @@ def test_task_span_is_created(sentry_init, capture_events, immediate_backend): span for span in event["spans"] if span["op"] == OP.QUEUE_SUBMIT_DJANGO ] assert len(queue_submit_spans) == 1 - assert queue_submit_spans[0]["description"] == "simple_task" + assert ( + queue_submit_spans[0]["description"] + == "tests.integrations.django.test_tasks.simple_task" + ) assert queue_submit_spans[0]["origin"] == "auto.http.django" @@ -114,7 +117,10 @@ def test_task_enqueue_with_kwargs(sentry_init, immediate_backend, capture_events span for span in event["spans"] if span["op"] == OP.QUEUE_SUBMIT_DJANGO ] assert len(queue_submit_spans) == 1 - assert queue_submit_spans[0]["description"] == "greet" + assert ( + queue_submit_spans[0]["description"] + == "tests.integrations.django.test_tasks.greet" + ) @pytest.mark.skipif( @@ -145,7 +151,10 @@ def test_task_error_reporting(sentry_init, immediate_backend, capture_events): if span["op"] == OP.QUEUE_SUBMIT_DJANGO ] assert len(queue_submit_spans) == 1 - assert queue_submit_spans[0]["description"] == "failing_task" + assert ( + queue_submit_spans[0]["description"] + == "tests.integrations.django.test_tasks.failing_task" + ) @pytest.mark.skipif( @@ -174,5 +183,5 @@ def test_multiple_task_enqueues_create_multiple_spans( assert len(queue_submit_spans) == 3 span_names = [span["description"] for span in queue_submit_spans] - assert span_names.count("task_one") == 2 - assert span_names.count("task_two") == 1 + assert span_names.count("tests.integrations.django.test_tasks.task_one") == 2 + assert span_names.count("tests.integrations.django.test_tasks.task_two") == 1