Skip to content

Commit d266281

Browse files
committed
feat(sanic): Support span streaming
1 parent 0af4a8b commit d266281

2 files changed

Lines changed: 184 additions & 47 deletions

File tree

sentry_sdk/integrations/sanic.py

Lines changed: 90 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,31 @@ 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+
# If the status is in unsampled_statuses, skip ending the span so
237+
# it is never queued for sending. The orphaned scope is discarded
238+
# when the isolation scope exits below.
239+
if should_sample:
240+
span.__exit__(None, None, None)
241+
else:
242+
span.set_http_status(response_status)
243+
span.sampled &= should_sample
244+
span.__exit__(None, None, None)
207245

208246
request.ctx._sentry_scope.__exit__(None, None, None)
209247

@@ -315,6 +353,40 @@ def _capture_exception(exception: "Union[ExcInfo, BaseException]") -> None:
315353
sentry_sdk.capture_event(event, hint=hint)
316354

317355

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

tests/integrations/sanic/test_sanic.py

Lines changed: 94 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
[
@@ -408,57 +409,121 @@ def test_transactions(
408409
sentry_init: "Any",
409410
app: "Any",
410411
capture_events: "Any",
412+
capture_items: "Any",
413+
span_streaming: bool,
411414
) -> None:
412415
# Init the SanicIntegration with the desired arguments
413416
sentry_init(
414417
integrations=[SanicIntegration(*test_config.integration_args)],
415418
traces_sample_rate=1.0,
419+
_experiments={"trace_lifecycle": "stream" if span_streaming else "static"},
416420
)
417-
events = capture_events()
421+
422+
if span_streaming:
423+
items = capture_items("span")
424+
else:
425+
events = capture_events()
418426

419427
# Make request to the desired URL
420428
c = get_client(app)
421429
with c as client:
422430
_, response = client.get(test_config.url)
423431
assert response.status == test_config.expected_status
424432

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-
)
433+
sentry_sdk.flush()
434+
435+
if span_streaming:
436+
segments = [
437+
i.payload
438+
for i in items
439+
if i.type == "span"
440+
and i.payload["attributes"].get("sentry.origin") == "auto.http.sanic"
441+
]
442+
assert len(segments) <= 1
443+
(segment, *_) = [*segments, None]
444+
445+
assert (segment is None) == (test_config.expected_transaction_name is None)
446+
447+
if segment is not None:
448+
assert segment["name"] == test_config.expected_transaction_name
449+
assert (
450+
segment["attributes"]["sentry.span.source"]
451+
== test_config.expected_source
452+
)
438453

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-
)
454+
attrs = segment["attributes"]
455+
assert attrs["http.request.method"] == "GET"
456+
assert attrs["url.full"].endswith(test_config.url)
457+
assert attrs["network.protocol.name"] == "http"
458+
header_keys = {
459+
key[len("http.request.header.") :]
460+
for key in attrs
461+
if key.startswith("http.request.header.")
462+
}
463+
assert header_keys >= {"accept", "accept-encoding", "host", "user-agent"}
464+
assert (
465+
attrs["http.response.status_code"] == test_config.expected_status
466+
)
467+
assert segment["status"] == (
468+
"error" if test_config.expected_status >= 400 else "ok"
469+
)
470+
else:
471+
# Extract the transaction events by inspecting the event types. We should at most have 1 transaction event.
472+
transaction_events = [
473+
e for e in events if "type" in e and e["type"] == "transaction"
474+
]
475+
assert len(transaction_events) <= 1
476+
477+
# Get the only transaction event, or set to None if there are no transaction events.
478+
(transaction_event, *_) = [*transaction_events, None]
479+
480+
# We should have no transaction event if and only if we expect no transactions
481+
assert (transaction_event is None) == (
482+
test_config.expected_transaction_name is None
483+
)
484+
485+
# If a transaction was expected, ensure it is correct
486+
assert (
487+
transaction_event is None
488+
or transaction_event["transaction"] == test_config.expected_transaction_name
489+
)
490+
assert (
491+
transaction_event is None
492+
or transaction_event["transaction_info"]["source"]
493+
== test_config.expected_source
494+
)
449495

450496

451497
@pytest.mark.skipif(
452498
not PERFORMANCE_SUPPORTED, reason="Performance not supported on this Sanic version"
453499
)
454-
def test_span_origin(sentry_init, app, capture_events):
455-
sentry_init(integrations=[SanicIntegration()], traces_sample_rate=1.0)
456-
events = capture_events()
500+
@pytest.mark.parametrize("span_streaming", [True, False])
501+
def test_span_origin(sentry_init, app, capture_events, capture_items, span_streaming):
502+
sentry_init(
503+
integrations=[SanicIntegration()],
504+
traces_sample_rate=1.0,
505+
_experiments={"trace_lifecycle": "stream" if span_streaming else "static"},
506+
)
507+
508+
if span_streaming:
509+
items = capture_items("span")
510+
else:
511+
events = capture_events()
457512

458513
c = get_client(app)
459514
with c as client:
460515
client.get("/message?foo=bar")
461516

462-
(_, event) = events
517+
sentry_sdk.flush()
463518

464-
assert event["contexts"]["trace"]["origin"] == "auto.http.sanic"
519+
if span_streaming:
520+
(segment,) = [
521+
i.payload
522+
for i in items
523+
if i.type == "span"
524+
and i.payload["attributes"].get("sentry.origin") == "auto.http.sanic"
525+
]
526+
assert segment["attributes"]["sentry.origin"] == "auto.http.sanic"
527+
else:
528+
(_, event) = events
529+
assert event["contexts"]["trace"]["origin"] == "auto.http.sanic"

0 commit comments

Comments
 (0)