Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions sentry_sdk/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions sentry_sdk/integrations/django/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
43 changes: 43 additions & 0 deletions sentry_sdk/integrations/django/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from functools import wraps

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
from django.tasks.base import Task, TaskResultStatus
except ImportError:
Task = None

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from typing import Any


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) -> Any
from sentry_sdk.integrations.django import DjangoIntegration

integration = sentry_sdk.get_client().get_integration(DjangoIntegration)
if integration is None:
return old_task_enqueue(self, *args, **kwargs)

name = qualname_from_function(self.func) or "<unknown Django task>"

This comment was marked as outdated.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not public, but it's def there and pretty integral to the whole tasks feature, so I don't expect it to disappear.


with sentry_sdk.start_span(
op=OP.QUEUE_SUBMIT_DJANGO, name=name, origin=DjangoIntegration.origin
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Queue span uses HTTP origin instead of queue origin

The queue.submit.django span uses DjangoIntegration.origin which is "auto.http.django", but queue operations in other integrations (arq, celery, huey) consistently use "auto.queue.{name}" as the origin. This causes the span to be incorrectly categorized as an HTTP operation rather than a queue operation, which is inconsistent with other queue integrations and may affect how Sentry analyzes and displays queue-related telemetry data.

Fix in Cursor Fix in Web

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a valid point @sentrivana.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IDK if origin is actually used anywhere on the server, but the OG idea behind the field is just to be able to determine which integration the span is coming from, nothing more.

When someone actually uses Celery/some other queuing library as a backend for django.tasks, those will have their own origin.

):
return old_task_enqueue(self, *args, **kwargs)

Task.enqueue = _sentry_enqueue
187 changes: 187 additions & 0 deletions tests/integrations/django/test_tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
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"]
== "tests.integrations.django.test_tasks.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"]
== "tests.integrations.django.test_tasks.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) == 2
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"]
== "tests.integrations.django.test_tasks.failing_task"
)


@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("tests.integrations.django.test_tasks.task_one") == 2
assert span_names.count("tests.integrations.django.test_tasks.task_two") == 1