Skip to content

Commit ca346f8

Browse files
committed
feat(sanic): Support span streaming
* Moved span end state cleanup logic to a new `_end_cleanup` sub-method * we need this because sanic retroactively decides not to queue a span and in span-first there is no way to unsample a span like in the older system
1 parent 0af4a8b commit ca346f8

4 files changed

Lines changed: 212 additions & 59 deletions

File tree

.claude/settings.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,15 @@
3030
"Bash(mv:*)",
3131
"Bash(source .venv/bin/activate)",
3232
"Bash(source tox.venv/bin/activate:*)",
33+
"Bash(source .tox/*/bin/activate:*)",
3334
"Bash(tox:*)",
3435
"Bash(tox.venv/bin/tox:*)",
3536
"Bash(.tox/*/bin/python:*)",
3637
"Bash(.tox/*/bin/pytest:*)",
37-
"Bash(.tox/*/bin/ruff:*)"
38+
"Bash(.tox/*/bin/ruff:*)",
39+
"Bash(ruff format:*)",
40+
"Bash(ruff check:*)",
41+
"Bash(mypy:*)"
3842
],
3943
"deny": []
4044
}

sentry_sdk/integrations/sanic.py

Lines changed: 89 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66

77
import sentry_sdk
88
from sentry_sdk import continue_trace
9-
from sentry_sdk.consts import OP
9+
from sentry_sdk.consts import OP, SPANDATA
1010
from sentry_sdk.integrations import DidNotEnable, Integration, _check_minimum_version
1111
from sentry_sdk.integrations._wsgi_common import RequestExtractor, _filter_headers
1212
from sentry_sdk.integrations.logging import ignore_logger
13+
from sentry_sdk.scope import should_send_default_pii
14+
from sentry_sdk.traces import SegmentSource, StreamedSpan
1315
from sentry_sdk.tracing import TransactionSource
16+
from sentry_sdk.tracing_utils import has_span_streaming_enabled
1417
from sentry_sdk.utils import (
1518
CONTEXTVARS_ERROR_MESSAGE,
1619
HAS_REAL_CONTEXTVARS,
@@ -165,23 +168,43 @@ async def _context_enter(request: "Request") -> None:
165168
if not request.ctx._sentry_do_integration:
166169
return
167170

171+
client = sentry_sdk.get_client()
172+
is_span_streaming_enabled = has_span_streaming_enabled(client.options)
173+
168174
weak_request = weakref.ref(request)
169175
request.ctx._sentry_scope = sentry_sdk.isolation_scope()
170176
scope = request.ctx._sentry_scope.__enter__()
171177
scope.clear_breadcrumbs()
172178
scope.add_event_processor(_make_request_processor(weak_request))
173179

174-
transaction = continue_trace(
175-
dict(request.headers),
176-
op=OP.HTTP_SERVER,
177-
# Unless the request results in a 404 error, the name and source will get overwritten in _set_transaction
178-
name=request.path,
179-
source=TransactionSource.URL,
180-
origin=SanicIntegration.origin,
181-
)
182-
request.ctx._sentry_transaction = sentry_sdk.start_transaction(
183-
transaction
184-
).__enter__()
180+
if is_span_streaming_enabled:
181+
sentry_sdk.traces.continue_trace(dict(request.headers))
182+
scope.set_custom_sampling_context({"sanic_request": request})
183+
184+
span = sentry_sdk.traces.start_span(
185+
# Unless the request results in a 404 error, the name and source
186+
# will get overwritten in _set_transaction
187+
name=request.path,
188+
attributes={
189+
"sentry.op": OP.HTTP_SERVER,
190+
"sentry.origin": SanicIntegration.origin,
191+
"sentry.span.source": SegmentSource.URL.value,
192+
},
193+
parent_span=None,
194+
)
195+
request.ctx._sentry_root_span = span
196+
else:
197+
transaction = continue_trace(
198+
dict(request.headers),
199+
op=OP.HTTP_SERVER,
200+
# Unless the request results in a 404 error, the name and source will get overwritten in _set_transaction
201+
name=request.path,
202+
source=TransactionSource.URL,
203+
origin=SanicIntegration.origin,
204+
)
205+
request.ctx._sentry_root_span = sentry_sdk.start_transaction(
206+
transaction
207+
).__enter__()
185208

186209

187210
async def _context_exit(
@@ -194,16 +217,34 @@ async def _context_exit(
194217
integration = sentry_sdk.get_client().get_integration(SanicIntegration)
195218

196219
response_status = None if response is None else response.status
220+
should_sample = (
221+
isinstance(integration, SanicIntegration)
222+
and response_status not in integration._unsampled_statuses
223+
)
197224

198225
# This capture_internal_exceptions block has been intentionally nested here, so that in case an exception
199226
# happens while trying to end the transaction, we still attempt to exit the hub.
200227
with capture_internal_exceptions():
201-
request.ctx._sentry_transaction.set_http_status(response_status)
202-
request.ctx._sentry_transaction.sampled &= (
203-
isinstance(integration, SanicIntegration)
204-
and response_status not in integration._unsampled_statuses
205-
)
206-
request.ctx._sentry_transaction.__exit__(None, None, None)
228+
span = request.ctx._sentry_root_span
229+
if isinstance(span, StreamedSpan):
230+
with capture_internal_exceptions():
231+
for attr, value in _get_request_attributes(request).items():
232+
span.set_attribute(attr, value)
233+
if response_status is not None:
234+
span.set_attribute(SPANDATA.HTTP_STATUS_CODE, response_status)
235+
span.status = "error" if response_status >= 400 else "ok"
236+
237+
# If the status is in unsampled_statuses, skip ending the span so
238+
# it is never queued for sending but just cleanup the state.
239+
# The orphaned scope is discarded when the isolation scope exits below.
240+
if should_sample:
241+
span._end()
242+
else:
243+
span._end_cleanup()
244+
else:
245+
span.set_http_status(response_status)
246+
span.sampled &= should_sample
247+
span.__exit__(None, None, None)
207248

208249
request.ctx._sentry_scope.__exit__(None, None, None)
209250

@@ -315,6 +356,36 @@ def _capture_exception(exception: "Union[ExcInfo, BaseException]") -> None:
315356
sentry_sdk.capture_event(event, hint=hint)
316357

317358

359+
def _get_request_attributes(request: "Request") -> "Dict[str, Any]":
360+
"""
361+
Return span attributes related to the HTTP request from a Sanic request.
362+
"""
363+
attributes = {} # type: Dict[str, Any]
364+
365+
if request.method:
366+
attributes[SPANDATA.HTTP_REQUEST_METHOD] = request.method.upper()
367+
368+
headers = _filter_headers(dict(request.headers), use_annotated_value=False)
369+
for header, value in headers.items():
370+
attributes[f"{SPANDATA.HTTP_REQUEST_HEADER}.{header.lower()}"] = value
371+
372+
urlparts = urlsplit(request.url)
373+
374+
if urlparts.query:
375+
attributes[SPANDATA.HTTP_QUERY] = urlparts.query
376+
377+
attributes[SPANDATA.URL_FULL] = request.url
378+
379+
if urlparts.scheme:
380+
attributes[SPANDATA.NETWORK_PROTOCOL_NAME] = urlparts.scheme
381+
382+
if should_send_default_pii() and request.remote_addr:
383+
attributes[SPANDATA.CLIENT_ADDRESS] = request.remote_addr
384+
attributes[SPANDATA.USER_IP_ADDRESS] = request.remote_addr
385+
386+
return attributes
387+
388+
318389
def _make_request_processor(weak_request: "Callable[[], Request]") -> "EventProcessor":
319390
def sanic_processor(event: "Event", hint: "Optional[Hint]") -> "Optional[Event]":
320391
try:

sentry_sdk/traces.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -366,17 +366,7 @@ def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None
366366
# This span is already finished, ignore.
367367
return
368368

369-
# Stop the profiler
370-
if self._is_segment() and self._continuous_profile is not None:
371-
with capture_internal_exceptions():
372-
self._continuous_profile.stop()
373-
374-
# Detach from scope
375-
if self._active:
376-
with capture_internal_exceptions():
377-
old_span = self._previous_span_on_scope
378-
del self._previous_span_on_scope
379-
self._scope.streamed_span = old_span
369+
self._end_cleanup()
380370

381371
# Set attributes from the segment. These are set on span end on purpose
382372
# so that we have the best chance to capture the segment's final name
@@ -412,6 +402,21 @@ def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None
412402
# Finally, queue the span for sending to Sentry
413403
self._scope._capture_span(self)
414404

405+
406+
def _end_cleanup(self):
407+
# Stop the profiler
408+
if self._is_segment() and self._continuous_profile is not None:
409+
with capture_internal_exceptions():
410+
self._continuous_profile.stop()
411+
412+
# Detach from scope
413+
if self._active:
414+
with capture_internal_exceptions():
415+
old_span = self._previous_span_on_scope
416+
del self._previous_span_on_scope
417+
self._scope.streamed_span = old_span
418+
419+
415420
def get_attributes(self) -> "Attributes":
416421
return self._attributes
417422

tests/integrations/sanic/test_sanic.py

Lines changed: 102 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,7 @@ def __init__(
360360
@pytest.mark.skipif(
361361
not PERFORMANCE_SUPPORTED, reason="Performance not supported on this Sanic version"
362362
)
363+
@pytest.mark.parametrize("span_streaming", [True, False])
363364
@pytest.mark.parametrize(
364365
"test_config",
365366
[
@@ -371,6 +372,14 @@ def __init__(
371372
expected_transaction_name="hi",
372373
expected_source=TransactionSource.COMPONENT,
373374
),
375+
TransactionTestConfig(
376+
# Transaction for successful page load with query string
377+
integration_args=(),
378+
url="/message?foo=bar",
379+
expected_status=200,
380+
expected_transaction_name="hi",
381+
expected_source=TransactionSource.COMPONENT,
382+
),
374383
TransactionTestConfig(
375384
# Transaction still recorded when we have an internal server error
376385
integration_args=(),
@@ -408,57 +417,121 @@ def test_transactions(
408417
sentry_init: "Any",
409418
app: "Any",
410419
capture_events: "Any",
420+
capture_items: "Any",
421+
span_streaming: bool,
411422
) -> None:
412423
# Init the SanicIntegration with the desired arguments
413424
sentry_init(
414425
integrations=[SanicIntegration(*test_config.integration_args)],
415426
traces_sample_rate=1.0,
427+
_experiments={"trace_lifecycle": "stream" if span_streaming else "static"},
416428
)
417-
events = capture_events()
429+
430+
if span_streaming:
431+
items = capture_items("span")
432+
else:
433+
events = capture_events()
418434

419435
# Make request to the desired URL
420436
c = get_client(app)
421437
with c as client:
422438
_, response = client.get(test_config.url)
423439
assert response.status == test_config.expected_status
424440

425-
# Extract the transaction events by inspecting the event types. We should at most have 1 transaction event.
426-
transaction_events = [
427-
e for e in events if "type" in e and e["type"] == "transaction"
428-
]
429-
assert len(transaction_events) <= 1
430-
431-
# Get the only transaction event, or set to None if there are no transaction events.
432-
(transaction_event, *_) = [*transaction_events, None]
433-
434-
# We should have no transaction event if and only if we expect no transactions
435-
assert (transaction_event is None) == (
436-
test_config.expected_transaction_name is None
437-
)
441+
sentry_sdk.flush()
442+
443+
if span_streaming:
444+
segments = [
445+
i.payload
446+
for i in items
447+
if i.type == "span"
448+
and i.payload["attributes"].get("sentry.origin") == "auto.http.sanic"
449+
]
450+
assert len(segments) <= 1
451+
(segment, *_) = [*segments, None]
452+
453+
assert (segment is None) == (test_config.expected_transaction_name is None)
454+
455+
if segment is not None:
456+
assert segment["name"] == test_config.expected_transaction_name
457+
assert (
458+
segment["attributes"]["sentry.span.source"]
459+
== test_config.expected_source
460+
)
438461

439-
# If a transaction was expected, ensure it is correct
440-
assert (
441-
transaction_event is None
442-
or transaction_event["transaction"] == test_config.expected_transaction_name
443-
)
444-
assert (
445-
transaction_event is None
446-
or transaction_event["transaction_info"]["source"]
447-
== test_config.expected_source
448-
)
462+
attrs = segment["attributes"]
463+
assert attrs["http.request.method"] == "GET"
464+
assert attrs["url.full"].endswith(test_config.url)
465+
if "?" in test_config.url:
466+
assert attrs["http.query"] == test_config.url.split("?", 1)[1]
467+
assert attrs["network.protocol.name"] == "http"
468+
header_keys = {
469+
key[len("http.request.header.") :]
470+
for key in attrs
471+
if key.startswith("http.request.header.")
472+
}
473+
assert header_keys >= {"accept", "accept-encoding", "host", "user-agent"}
474+
assert attrs["http.response.status_code"] == test_config.expected_status
475+
assert segment["status"] == (
476+
"error" if test_config.expected_status >= 400 else "ok"
477+
)
478+
else:
479+
# Extract the transaction events by inspecting the event types. We should at most have 1 transaction event.
480+
transaction_events = [
481+
e for e in events if "type" in e and e["type"] == "transaction"
482+
]
483+
assert len(transaction_events) <= 1
484+
485+
# Get the only transaction event, or set to None if there are no transaction events.
486+
(transaction_event, *_) = [*transaction_events, None]
487+
488+
# We should have no transaction event if and only if we expect no transactions
489+
assert (transaction_event is None) == (
490+
test_config.expected_transaction_name is None
491+
)
492+
493+
# If a transaction was expected, ensure it is correct
494+
assert (
495+
transaction_event is None
496+
or transaction_event["transaction"] == test_config.expected_transaction_name
497+
)
498+
assert (
499+
transaction_event is None
500+
or transaction_event["transaction_info"]["source"]
501+
== test_config.expected_source
502+
)
449503

450504

451505
@pytest.mark.skipif(
452506
not PERFORMANCE_SUPPORTED, reason="Performance not supported on this Sanic version"
453507
)
454-
def test_span_origin(sentry_init, app, capture_events):
455-
sentry_init(integrations=[SanicIntegration()], traces_sample_rate=1.0)
456-
events = capture_events()
508+
@pytest.mark.parametrize("span_streaming", [True, False])
509+
def test_span_origin(sentry_init, app, capture_events, capture_items, span_streaming):
510+
sentry_init(
511+
integrations=[SanicIntegration()],
512+
traces_sample_rate=1.0,
513+
_experiments={"trace_lifecycle": "stream" if span_streaming else "static"},
514+
)
515+
516+
if span_streaming:
517+
items = capture_items("span")
518+
else:
519+
events = capture_events()
457520

458521
c = get_client(app)
459522
with c as client:
460523
client.get("/message?foo=bar")
461524

462-
(_, event) = events
525+
sentry_sdk.flush()
463526

464-
assert event["contexts"]["trace"]["origin"] == "auto.http.sanic"
527+
if span_streaming:
528+
(segment,) = [
529+
i.payload
530+
for i in items
531+
if i.type == "span"
532+
and i.payload["attributes"].get("sentry.origin") == "auto.http.sanic"
533+
]
534+
assert segment["attributes"]["sentry.origin"] == "auto.http.sanic"
535+
else:
536+
(_, event) = events
537+
assert event["contexts"]["trace"]["origin"] == "auto.http.sanic"

0 commit comments

Comments
 (0)