Skip to content

Commit f8f49f6

Browse files
authored
feat(django): Add span around Task.enqueue (#5209)
### Description Add a `queue.submit.django` span when a `Task` in enqueued via Django. <img width="1352" height="640" alt="Screenshot 2025-12-10 at 13 26 04" src="https://github.com/user-attachments/assets/5ba32d19-1b73-4a0c-ba59-d55dd7b22f4b" /> #### Issues Closes #5201 Closes PY-2006 #### Reminders - Please add tests to validate your changes, and lint your code using `tox -e linters`. - Add GH Issue ID _&_ Linear ID (if applicable) - PR title should use [conventional commit](https://develop.sentry.dev/engineering-practices/commit-messages/#type) style (`feat:`, `fix:`, `ref:`, `meta:`) - For external contributors: [CONTRIBUTING.md](https://github.com/getsentry/sentry-python/blob/master/CONTRIBUTING.md), [Sentry SDK development docs](https://develop.sentry.dev/sdk/), [Discord community](https://discord.gg/Ww9hbqr)
1 parent 2ce4379 commit f8f49f6

File tree

4 files changed

+233
-0
lines changed

4 files changed

+233
-0
lines changed

sentry_sdk/consts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -936,6 +936,7 @@ class OP:
936936
QUEUE_SUBMIT_RAY = "queue.submit.ray"
937937
QUEUE_TASK_RAY = "queue.task.ray"
938938
QUEUE_TASK_DRAMATIQ = "queue.task.dramatiq"
939+
QUEUE_SUBMIT_DJANGO = "queue.submit.django"
939940
SUBPROCESS = "subprocess"
940941
SUBPROCESS_WAIT = "subprocess.wait"
941942
SUBPROCESS_COMMUNICATE = "subprocess.communicate"

sentry_sdk/integrations/django/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
)
6363
from sentry_sdk.integrations.django.middleware import patch_django_middlewares
6464
from sentry_sdk.integrations.django.signals_handlers import patch_signals
65+
from sentry_sdk.integrations.django.tasks import patch_tasks
6566
from sentry_sdk.integrations.django.views import patch_views
6667

6768
if DJANGO_VERSION[:2] > (1, 8):
@@ -271,6 +272,7 @@ def _django_queryset_repr(value, hint):
271272
patch_views()
272273
patch_templates()
273274
patch_signals()
275+
patch_tasks()
274276
add_template_context_repr_sequence()
275277

276278
if patch_caching is not None:
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from functools import wraps
2+
3+
import sentry_sdk
4+
from sentry_sdk.consts import OP
5+
from sentry_sdk.tracing import SPANSTATUS
6+
from sentry_sdk.utils import qualname_from_function
7+
8+
try:
9+
# django.tasks were added in Django 6.0
10+
from django.tasks.base import Task, TaskResultStatus
11+
except ImportError:
12+
Task = None
13+
14+
from typing import TYPE_CHECKING
15+
16+
if TYPE_CHECKING:
17+
from typing import Any
18+
19+
20+
def patch_tasks():
21+
# type: () -> None
22+
if Task is None:
23+
return
24+
25+
old_task_enqueue = Task.enqueue
26+
27+
@wraps(old_task_enqueue)
28+
def _sentry_enqueue(self, *args, **kwargs):
29+
# type: (Any, Any, Any) -> Any
30+
from sentry_sdk.integrations.django import DjangoIntegration
31+
32+
integration = sentry_sdk.get_client().get_integration(DjangoIntegration)
33+
if integration is None:
34+
return old_task_enqueue(self, *args, **kwargs)
35+
36+
name = qualname_from_function(self.func) or "<unknown Django task>"
37+
38+
with sentry_sdk.start_span(
39+
op=OP.QUEUE_SUBMIT_DJANGO, name=name, origin=DjangoIntegration.origin
40+
):
41+
return old_task_enqueue(self, *args, **kwargs)
42+
43+
Task.enqueue = _sentry_enqueue
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import pytest
2+
3+
import sentry_sdk
4+
from sentry_sdk import start_span
5+
from sentry_sdk.integrations.django import DjangoIntegration
6+
from sentry_sdk.consts import OP
7+
8+
9+
try:
10+
from django.tasks import task
11+
12+
HAS_DJANGO_TASKS = True
13+
except ImportError:
14+
HAS_DJANGO_TASKS = False
15+
16+
17+
@pytest.fixture
18+
def immediate_backend(settings):
19+
"""Configure Django to use the immediate task backend for synchronous testing."""
20+
settings.TASKS = {
21+
"default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBackend"}
22+
}
23+
24+
25+
if HAS_DJANGO_TASKS:
26+
27+
@task
28+
def simple_task():
29+
return "result"
30+
31+
@task
32+
def add_numbers(a, b):
33+
return a + b
34+
35+
@task
36+
def greet(name, greeting="Hello"):
37+
return f"{greeting}, {name}!"
38+
39+
@task
40+
def failing_task():
41+
raise ValueError("Task failed!")
42+
43+
@task
44+
def task_one():
45+
return 1
46+
47+
@task
48+
def task_two():
49+
return 2
50+
51+
52+
@pytest.mark.skipif(
53+
not HAS_DJANGO_TASKS,
54+
reason="Django tasks are only available in Django 6.0+",
55+
)
56+
def test_task_span_is_created(sentry_init, capture_events, immediate_backend):
57+
"""Test that the queue.submit.django span is created when a task is enqueued."""
58+
sentry_init(
59+
integrations=[DjangoIntegration()],
60+
traces_sample_rate=1.0,
61+
)
62+
events = capture_events()
63+
64+
with sentry_sdk.start_transaction(name="test_transaction"):
65+
simple_task.enqueue()
66+
67+
(event,) = events
68+
assert event["type"] == "transaction"
69+
70+
queue_submit_spans = [
71+
span for span in event["spans"] if span["op"] == OP.QUEUE_SUBMIT_DJANGO
72+
]
73+
assert len(queue_submit_spans) == 1
74+
assert (
75+
queue_submit_spans[0]["description"]
76+
== "tests.integrations.django.test_tasks.simple_task"
77+
)
78+
assert queue_submit_spans[0]["origin"] == "auto.http.django"
79+
80+
81+
@pytest.mark.skipif(
82+
not HAS_DJANGO_TASKS,
83+
reason="Django tasks are only available in Django 6.0+",
84+
)
85+
def test_task_enqueue_returns_result(sentry_init, immediate_backend):
86+
"""Test that the task enqueuing behavior is unchanged from the user perspective."""
87+
sentry_init(
88+
integrations=[DjangoIntegration()],
89+
traces_sample_rate=1.0,
90+
)
91+
92+
result = add_numbers.enqueue(3, 5)
93+
94+
assert result is not None
95+
assert result.return_value == 8
96+
97+
98+
@pytest.mark.skipif(
99+
not HAS_DJANGO_TASKS,
100+
reason="Django tasks are only available in Django 6.0+",
101+
)
102+
def test_task_enqueue_with_kwargs(sentry_init, immediate_backend, capture_events):
103+
"""Test that task enqueuing works correctly with keyword arguments."""
104+
sentry_init(
105+
integrations=[DjangoIntegration()],
106+
traces_sample_rate=1.0,
107+
)
108+
events = capture_events()
109+
110+
with sentry_sdk.start_transaction(name="test_transaction"):
111+
result = greet.enqueue(name="World", greeting="Hi")
112+
113+
assert result.return_value == "Hi, World!"
114+
115+
(event,) = events
116+
queue_submit_spans = [
117+
span for span in event["spans"] if span["op"] == OP.QUEUE_SUBMIT_DJANGO
118+
]
119+
assert len(queue_submit_spans) == 1
120+
assert (
121+
queue_submit_spans[0]["description"]
122+
== "tests.integrations.django.test_tasks.greet"
123+
)
124+
125+
126+
@pytest.mark.skipif(
127+
not HAS_DJANGO_TASKS,
128+
reason="Django tasks are only available in Django 6.0+",
129+
)
130+
def test_task_error_reporting(sentry_init, immediate_backend, capture_events):
131+
"""Test that errors in tasks are correctly reported and don't break the span."""
132+
sentry_init(
133+
integrations=[DjangoIntegration()],
134+
traces_sample_rate=1.0,
135+
)
136+
events = capture_events()
137+
138+
with sentry_sdk.start_transaction(name="test_transaction"):
139+
result = failing_task.enqueue()
140+
141+
with pytest.raises(ValueError, match="Task failed"):
142+
_ = result.return_value
143+
144+
assert len(events) == 2
145+
transaction_event = events[-1]
146+
assert transaction_event["type"] == "transaction"
147+
148+
queue_submit_spans = [
149+
span
150+
for span in transaction_event["spans"]
151+
if span["op"] == OP.QUEUE_SUBMIT_DJANGO
152+
]
153+
assert len(queue_submit_spans) == 1
154+
assert (
155+
queue_submit_spans[0]["description"]
156+
== "tests.integrations.django.test_tasks.failing_task"
157+
)
158+
159+
160+
@pytest.mark.skipif(
161+
not HAS_DJANGO_TASKS,
162+
reason="Django tasks are only available in Django 6.0+",
163+
)
164+
def test_multiple_task_enqueues_create_multiple_spans(
165+
sentry_init, capture_events, immediate_backend
166+
):
167+
"""Test that enqueueing multiple tasks creates multiple spans."""
168+
sentry_init(
169+
integrations=[DjangoIntegration()],
170+
traces_sample_rate=1.0,
171+
)
172+
events = capture_events()
173+
174+
with sentry_sdk.start_transaction(name="test_transaction"):
175+
task_one.enqueue()
176+
task_two.enqueue()
177+
task_one.enqueue()
178+
179+
(event,) = events
180+
queue_submit_spans = [
181+
span for span in event["spans"] if span["op"] == OP.QUEUE_SUBMIT_DJANGO
182+
]
183+
assert len(queue_submit_spans) == 3
184+
185+
span_names = [span["description"] for span in queue_submit_spans]
186+
assert span_names.count("tests.integrations.django.test_tasks.task_one") == 2
187+
assert span_names.count("tests.integrations.django.test_tasks.task_two") == 1

0 commit comments

Comments
 (0)