Skip to content

Commit e9d3bf6

Browse files
committed
feat(sanic): Support span streaming
The `_unsampled_statuses` option is not respected for span streaming since we currently don't allow tail-based sampling in the new system.
1 parent 0af4a8b commit e9d3bf6

3 files changed

Lines changed: 197 additions & 48 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: 83 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,28 @@ 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+
span._end()
238+
else:
239+
span.set_http_status(response_status)
240+
span.sampled &= should_sample
241+
span.__exit__(None, None, None)
207242

208243
request.ctx._sentry_scope.__exit__(None, None, None)
209244

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

317352

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

tests/integrations/sanic/test_sanic.py

Lines changed: 109 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ def __init__(
346346
expected_status: int,
347347
expected_transaction_name: "Optional[str]",
348348
expected_source: "Optional[str]" = None,
349+
streaming_compatible: bool = True,
349350
) -> None:
350351
"""
351352
expected_transaction_name of None indicates we expect to not receive a transaction
@@ -355,11 +356,13 @@ def __init__(
355356
self.expected_status = expected_status
356357
self.expected_transaction_name = expected_transaction_name
357358
self.expected_source = expected_source
359+
self.streaming_compatible = streaming_compatible
358360

359361

360362
@pytest.mark.skipif(
361363
not PERFORMANCE_SUPPORTED, reason="Performance not supported on this Sanic version"
362364
)
365+
@pytest.mark.parametrize("span_streaming", [True, False])
363366
@pytest.mark.parametrize(
364367
"test_config",
365368
[
@@ -371,6 +374,14 @@ def __init__(
371374
expected_transaction_name="hi",
372375
expected_source=TransactionSource.COMPONENT,
373376
),
377+
TransactionTestConfig(
378+
# Transaction for successful page load with query string
379+
integration_args=(),
380+
url="/message?foo=bar",
381+
expected_status=200,
382+
expected_transaction_name="hi",
383+
expected_source=TransactionSource.COMPONENT,
384+
),
374385
TransactionTestConfig(
375386
# Transaction still recorded when we have an internal server error
376387
integration_args=(),
@@ -385,6 +396,7 @@ def __init__(
385396
url="/404",
386397
expected_status=404,
387398
expected_transaction_name=None,
399+
streaming_compatible=False,
388400
),
389401
TransactionTestConfig(
390402
# With no ignored HTTP statuses, we should get transactions for 404 errors
@@ -400,6 +412,7 @@ def __init__(
400412
url="/message",
401413
expected_status=200,
402414
expected_transaction_name=None,
415+
streaming_compatible=False,
403416
),
404417
],
405418
)
@@ -408,57 +421,124 @@ def test_transactions(
408421
sentry_init: "Any",
409422
app: "Any",
410423
capture_events: "Any",
424+
capture_items: "Any",
425+
span_streaming: bool,
411426
) -> None:
427+
if span_streaming and not test_config.streaming_compatible:
428+
pytest.skip("unsampled_statuses is not supported in span streaming mode")
429+
412430
# Init the SanicIntegration with the desired arguments
413431
sentry_init(
414432
integrations=[SanicIntegration(*test_config.integration_args)],
415433
traces_sample_rate=1.0,
434+
_experiments={"trace_lifecycle": "stream" if span_streaming else "static"},
416435
)
417-
events = capture_events()
436+
437+
if span_streaming:
438+
items = capture_items("span")
439+
else:
440+
events = capture_events()
418441

419442
# Make request to the desired URL
420443
c = get_client(app)
421444
with c as client:
422445
_, response = client.get(test_config.url)
423446
assert response.status == test_config.expected_status
424447

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-
)
448+
sentry_sdk.flush()
449+
450+
if span_streaming:
451+
segments = [
452+
i.payload
453+
for i in items
454+
if i.type == "span"
455+
and i.payload["attributes"].get("sentry.origin") == "auto.http.sanic"
456+
]
457+
assert len(segments) <= 1
458+
(segment, *_) = [*segments, None]
459+
460+
assert (segment is None) == (test_config.expected_transaction_name is None)
461+
462+
if segment is not None:
463+
assert segment["name"] == test_config.expected_transaction_name
464+
assert (
465+
segment["attributes"]["sentry.span.source"]
466+
== test_config.expected_source
467+
)
438468

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

450511

451512
@pytest.mark.skipif(
452513
not PERFORMANCE_SUPPORTED, reason="Performance not supported on this Sanic version"
453514
)
454-
def test_span_origin(sentry_init, app, capture_events):
455-
sentry_init(integrations=[SanicIntegration()], traces_sample_rate=1.0)
456-
events = capture_events()
515+
@pytest.mark.parametrize("span_streaming", [True, False])
516+
def test_span_origin(sentry_init, app, capture_events, capture_items, span_streaming):
517+
sentry_init(
518+
integrations=[SanicIntegration()],
519+
traces_sample_rate=1.0,
520+
_experiments={"trace_lifecycle": "stream" if span_streaming else "static"},
521+
)
522+
523+
if span_streaming:
524+
items = capture_items("span")
525+
else:
526+
events = capture_events()
457527

458528
c = get_client(app)
459529
with c as client:
460530
client.get("/message?foo=bar")
461531

462-
(_, event) = events
532+
sentry_sdk.flush()
463533

464-
assert event["contexts"]["trace"]["origin"] == "auto.http.sanic"
534+
if span_streaming:
535+
(segment,) = [
536+
i.payload
537+
for i in items
538+
if i.type == "span"
539+
and i.payload["attributes"].get("sentry.origin") == "auto.http.sanic"
540+
]
541+
assert segment["attributes"]["sentry.origin"] == "auto.http.sanic"
542+
else:
543+
(_, event) = events
544+
assert event["contexts"]["trace"]["origin"] == "auto.http.sanic"

0 commit comments

Comments
 (0)