Skip to content

Commit efddaab

Browse files
sentrivanaclaudeericapisani
authored
fix(tracing): Skip child span creation in streaming path when no current span (task queues) (#6814)
## Summary - When span streaming is enabled and there is no current span, task queue integrations should not create new root segments for child-span operations - Adds a `sentry_sdk.traces.get_current_span() is None` guard before creating spans in the streaming path - Affected integrations: celery, huey, arq, ray ## Test plan - [ ] Existing tests pass - [ ] Verify that in streaming mode, no orphan root segments are created for task queue operations when there's no active span 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Erica Pisani <hey@ericapisani.dev>
1 parent b199a9e commit efddaab

6 files changed

Lines changed: 94 additions & 46 deletions

File tree

sentry_sdk/integrations/arq.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ async def _sentry_enqueue_job(
7979
return await old_enqueue_job(self, function, *args, **kwargs)
8080

8181
if has_span_streaming_enabled(client.options):
82+
if sentry_sdk.traces.get_current_span() is None:
83+
return await old_enqueue_job(self, function, *args, **kwargs)
84+
8285
with sentry_sdk.traces.start_span(
8386
name=function,
8487
attributes={

sentry_sdk/integrations/celery/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,9 @@ def _inner(*args: "Any", **kwargs: "Any") -> "Any":
419419
span_streaming = has_span_streaming_enabled(client.options)
420420

421421
try:
422+
if span_streaming and get_current_span() is None:
423+
return f(*args, **kwargs)
424+
422425
span: "Union[Span, StreamedSpan]"
423426
if span_streaming:
424427
span = sentry_sdk.traces.start_span(
@@ -462,7 +465,8 @@ def _inner(*args: "Any", **kwargs: "Any") -> "Any":
462465

463466
with capture_internal_exceptions():
464467
set_on_span(
465-
SPANDATA.MESSAGING_MESSAGE_RETRY_COUNT, task.request.retries
468+
SPANDATA.MESSAGING_MESSAGE_RETRY_COUNT,
469+
task.request.retries,
466470
)
467471

468472
with capture_internal_exceptions():

sentry_sdk/integrations/huey.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,18 @@ def _sentry_enqueue(
7979
sentry_sdk.get_client().options
8080
)
8181

82-
span_ctx = None
82+
no_headers_types = (PeriodicTask,) + tuple(
83+
t for t in [HueyGroup, HueyChord] if t is not None
84+
)
85+
86+
if is_span_streaming_enabled and sentry_sdk.traces.get_current_span() is None:
87+
if not isinstance(item, no_headers_types):
88+
item.kwargs["sentry_headers"] = {
89+
BAGGAGE_HEADER_NAME: get_baggage(),
90+
SENTRY_TRACE_HEADER_NAME: get_traceparent(),
91+
}
92+
return old_enqueue(self, item)
93+
8394
if is_span_streaming_enabled:
8495
span_ctx = sentry_sdk.traces.start_span(
8596
name=span_name,
@@ -97,9 +108,6 @@ def _sentry_enqueue(
97108
)
98109
span_ctx.set_data(SPANDATA.MESSAGING_DESTINATION_NAME, self.name)
99110

100-
no_headers_types = (PeriodicTask,) + tuple(
101-
t for t in [HueyGroup, HueyChord] if t is not None
102-
)
103111
with span_ctx:
104112
if not isinstance(item, no_headers_types):
105113
# Attach trace propagation data to task kwargs. We do

sentry_sdk/integrations/ray.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,23 @@ def _remote_method_with_header_propagation(
156156
)
157157
if span_streaming:
158158
function_name = qualname_from_function(user_f)
159+
160+
if sentry_sdk.traces.get_current_span() is None:
161+
tracing = {
162+
k: v
163+
for k, v in sentry_sdk.get_current_scope().iter_trace_propagation_headers()
164+
}
165+
try:
166+
result = old_remote_method(
167+
*args, **kwargs, _sentry_tracing=tracing
168+
)
169+
except Exception:
170+
exc_info = sys.exc_info()
171+
_capture_exception(exc_info)
172+
reraise(*exc_info)
173+
174+
return result
175+
159176
with sentry_sdk.traces.start_span(
160177
name="unknown Ray task"
161178
if function_name is None

tests/integrations/arq/test_arq.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -245,18 +245,22 @@ async def retry_job(ctx):
245245
sentry_sdk.flush()
246246
spans = [item.payload for item in items]
247247

248-
assert spans[4]["attributes"]["sentry.op"] == "queue.task.arq"
249-
assert spans[4]["status"] == "ok"
250-
assert spans[4]["name"] == "retry_job"
248+
# The retry re-enqueue happens without an active span, so no producer
249+
# (queue.submit.arq) span is created for it; only the consumer segment
250+
# is emitted. The consumer segment is preceded by the redis spans for
251+
# the re-enqueue, so it lands at index 3.
252+
assert spans[3]["attributes"]["sentry.op"] == "queue.task.arq"
253+
assert spans[3]["status"] == "ok"
254+
assert spans[3]["name"] == "retry_job"
251255

252256
await worker.run_job(job.job_id, timestamp_ms())
253257

254258
sentry_sdk.flush()
255259
spans = [item.payload for item in items]
256260

257-
assert spans[7]["attributes"]["sentry.op"] == "queue.task.arq"
258-
assert spans[7]["status"] == "ok"
259-
assert spans[7]["name"] == "retry_job"
261+
assert spans[6]["attributes"]["sentry.op"] == "queue.task.arq"
262+
assert spans[6]["status"] == "ok"
263+
assert spans[6]["name"] == "retry_job"
260264
else:
261265
events = capture_events()
262266

@@ -594,10 +598,13 @@ async def job(ctx):
594598
sentry_sdk.flush()
595599
spans = [item.payload for item in items]
596600

597-
assert spans[4]["attributes"]["sentry.op"] == "queue.task.arq"
598-
assert spans[4]["attributes"]["sentry.origin"] == "auto.queue.arq"
599-
assert spans[3]["attributes"]["sentry.origin"] == "auto.db.redis"
601+
# No producer (queue.submit.arq) span is created for the re-enqueue
602+
# triggered by the retry, since it happens without an active span, so
603+
# the consumer segment lands at index 3.
604+
assert spans[3]["attributes"]["sentry.op"] == "queue.task.arq"
605+
assert spans[3]["attributes"]["sentry.origin"] == "auto.queue.arq"
600606
assert spans[2]["attributes"]["sentry.origin"] == "auto.db.redis"
607+
assert spans[1]["attributes"]["sentry.origin"] == "auto.db.redis"
601608
else:
602609
job = await pool.enqueue_job("retry_job")
603610

tests/integrations/huey/test_huey.py

Lines changed: 41 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -93,13 +93,12 @@ def division(a, b):
9393
sentry_sdk.get_client().flush()
9494

9595
payloads = [i.payload for i in items]
96-
assert len(payloads) == 2
97-
enqueue_span, execute_span = payloads
96+
# The task is enqueued without a wrapping span, so in streaming mode no
97+
# producer (queue.submit.huey) span is created (see the early return in
98+
# patch_enqueue). Only the consumer segment is emitted.
99+
assert len(payloads) == 1
100+
(execute_span,) = payloads
98101

99-
assert enqueue_span["attributes"]["sentry.op"] == OP.QUEUE_SUBMIT_HUEY
100-
assert (
101-
enqueue_span["attributes"][SPANDATA.MESSAGING_DESTINATION_NAME] == huey.name
102-
)
103102
assert execute_span["is_segment"]
104103
assert execute_span["attributes"]["sentry.op"] == OP.QUEUE_TASK_HUEY
105104
assert (
@@ -157,12 +156,12 @@ def retry_task(context):
157156
sentry_sdk.get_client().flush()
158157

159158
payloads = [i.payload for i in items]
160-
assert len(payloads) == 3
161-
162-
enqueue_span, re_enqueue_span, execute_span = payloads
159+
# The initial enqueue happens without a wrapping span, so no producer
160+
# span is created for it. The re-enqueue triggered by RetryTask happens
161+
# inside the running consumer segment, so it does get a child span.
162+
assert len(payloads) == 2
163163

164-
assert enqueue_span["attributes"]["sentry.op"] == OP.QUEUE_SUBMIT_HUEY
165-
assert enqueue_span["is_segment"]
164+
re_enqueue_span, execute_span = payloads
166165

167166
assert re_enqueue_span["attributes"]["sentry.op"] == OP.QUEUE_SUBMIT_HUEY
168167
assert not re_enqueue_span["is_segment"]
@@ -181,8 +180,8 @@ def retry_task(context):
181180

182181
all_payloads = [i.payload for i in items]
183182

184-
assert len(all_payloads) == 4
185-
retry_span = all_payloads[3]
183+
assert len(all_payloads) == 3
184+
retry_span = all_payloads[2]
186185

187186
assert retry_span["is_segment"]
188187
assert retry_span["name"] == "retry_task"
@@ -224,10 +223,10 @@ def cancel_task():
224223
sentry_sdk.get_client().flush()
225224

226225
payloads = [i.payload for i in items]
227-
assert len(payloads) == 2
228-
enqueue_span, execute_span = payloads
226+
# Enqueued without a wrapping span -> no producer span in streaming mode.
227+
assert len(payloads) == 1
228+
(execute_span,) = payloads
229229

230-
assert enqueue_span["attributes"]["sentry.op"] == OP.QUEUE_SUBMIT_HUEY
231230
assert execute_span["attributes"]["sentry.op"] == OP.QUEUE_TASK_HUEY
232231
assert execute_span["is_segment"]
233232
assert execute_span["name"] == "cancel_task"
@@ -270,10 +269,10 @@ def maybe_locked_task():
270269
sentry_sdk.get_client().flush()
271270

272271
payloads = [i.payload for i in items]
273-
assert len(payloads) == 2
274-
enqueue_span, execute_span = payloads
272+
# Enqueued without a wrapping span -> no producer span in streaming mode.
273+
assert len(payloads) == 1
274+
(execute_span,) = payloads
275275

276-
assert enqueue_span["attributes"]["sentry.op"] == OP.QUEUE_SUBMIT_HUEY
277276
assert execute_span["attributes"]["sentry.op"] == OP.QUEUE_TASK_HUEY
278277

279278
assert execute_span["is_segment"]
@@ -391,29 +390,36 @@ def task2():
391390
if has_span_streaming:
392391
items = capture_items("span")
393392

394-
huey.enqueue(group([task1.s(), task2.s()]))
393+
with sentry_sdk.traces.start_span(name="submission"):
394+
huey.enqueue(group([task1.s(), task2.s()]))
395395

396396
for _ in range(2):
397397
task = huey.dequeue()
398398
huey.execute(task)
399399

400400
sentry_sdk.get_client().flush()
401-
assert len(items) == 5
401+
assert len(items) == 6
402402

403403
(
404404
task1_enqueue_span,
405405
task2_enqueue_span,
406406
group_span,
407+
submission_span,
407408
task1_execute_span,
408409
task2_execute_span,
409410
) = [i.payload for i in items]
410411

411-
assert group_span["is_segment"]
412+
# The enqueue happens inside a wrapping span, so the group producer
413+
# tree is created and parented under that segment.
414+
assert submission_span["is_segment"]
415+
assert submission_span["name"] == "submission"
416+
assert not group_span["is_segment"]
412417
assert not task1_enqueue_span["is_segment"]
413418
assert not task2_enqueue_span["is_segment"]
414419
assert task1_execute_span["is_segment"]
415420
assert task2_execute_span["is_segment"]
416421

422+
assert group_span["parent_span_id"] == submission_span["span_id"]
417423
assert group_span["name"] == "Huey Task Group"
418424
assert group_span["status"] == "ok"
419425
assert group_span["attributes"]["sentry.op"] == "queue.submit.huey"
@@ -422,18 +428,14 @@ def task2():
422428
assert task1_enqueue_span["name"] == "task1"
423429
assert task1_enqueue_span["status"] == "ok"
424430
assert task1_enqueue_span["parent_span_id"] == group_span["span_id"]
425-
assert (
426-
task1_enqueue_span["attributes"]["sentry.segment.name"] == "Huey Task Group"
427-
)
431+
assert task1_enqueue_span["attributes"]["sentry.segment.name"] == "submission"
428432
assert task1_enqueue_span["attributes"]["sentry.op"] == "queue.submit.huey"
429433
assert task1_enqueue_span["attributes"]["sentry.origin"] == "auto.queue.huey"
430434

431435
assert task2_enqueue_span["name"] == "task2"
432436
assert task2_enqueue_span["status"] == "ok"
433437
assert task2_enqueue_span["parent_span_id"] == group_span["span_id"]
434-
assert (
435-
task2_enqueue_span["attributes"]["sentry.segment.name"] == "Huey Task Group"
436-
)
438+
assert task2_enqueue_span["attributes"]["sentry.segment.name"] == "submission"
437439
assert task2_enqueue_span["attributes"]["sentry.op"] == "queue.submit.huey"
438440
assert task2_enqueue_span["attributes"]["sentry.origin"] == "auto.queue.huey"
439441

@@ -518,29 +520,36 @@ def task2(results):
518520

519521
if has_span_streaming:
520522
items = capture_items("span")
521-
huey.enqueue(chord([task1.s()], task2.s()))
523+
with sentry_sdk.traces.start_span(name="submission"):
524+
huey.enqueue(chord([task1.s()], task2.s()))
522525

523526
for _ in range(2):
524527
task = huey.dequeue()
525528
huey.execute(task)
526529

527530
sentry_sdk.get_client().flush()
528-
assert len(items) == 5
531+
assert len(items) == 6
529532

530533
(
531534
task1_enqueue_span,
532535
chord_span,
536+
submission_span,
533537
task2_enqueue_span,
534538
task1_execute_span,
535539
task2_execute_span,
536540
) = [i.payload for i in items]
537541

538-
assert chord_span["is_segment"]
542+
# The enqueue happens inside a wrapping span, so the chord producer
543+
# tree is created and parented under that segment.
544+
assert submission_span["is_segment"]
545+
assert submission_span["name"] == "submission"
546+
assert not chord_span["is_segment"]
539547
assert not task1_enqueue_span["is_segment"]
540548
assert not task2_enqueue_span["is_segment"]
541549
assert task1_execute_span["is_segment"]
542550
assert task2_execute_span["is_segment"]
543551

552+
assert chord_span["parent_span_id"] == submission_span["span_id"]
544553
assert chord_span["name"] == "Huey Chord"
545554
assert chord_span["status"] == "ok"
546555
assert chord_span["attributes"]["sentry.op"] == "queue.submit.huey"
@@ -549,7 +558,7 @@ def task2(results):
549558
assert task1_enqueue_span["name"] == "task1"
550559
assert task1_enqueue_span["status"] == "ok"
551560
assert task1_enqueue_span["parent_span_id"] == chord_span["span_id"]
552-
assert task1_enqueue_span["attributes"]["sentry.segment.name"] == "Huey Chord"
561+
assert task1_enqueue_span["attributes"]["sentry.segment.name"] == "submission"
553562
assert task1_enqueue_span["attributes"]["sentry.op"] == "queue.submit.huey"
554563
assert task1_enqueue_span["attributes"]["sentry.origin"] == "auto.queue.huey"
555564

0 commit comments

Comments
 (0)