Skip to content

Commit 6485eee

Browse files
authored
feat(bottle): Add span streaming support to Bottle integration (#6486)
Add span streaming support to Bottle integration. Fixes PY-2310 Fixes #6008
1 parent 5ce5d1a commit 6485eee

2 files changed

Lines changed: 285 additions & 6 deletions

File tree

sentry_sdk/integrations/bottle.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
)
1111
from sentry_sdk.integrations._wsgi_common import RequestExtractor
1212
from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware
13-
from sentry_sdk.tracing import SOURCE_FOR_STYLE
13+
from sentry_sdk.traces import SOURCE_FOR_STYLE as SEGMENT_SOURCE_FOR_STYLE
14+
from sentry_sdk.tracing import SOURCE_FOR_STYLE as TRANSACTION_SOURCE_FOR_STYLE
15+
from sentry_sdk.tracing_utils import has_span_streaming_enabled
1416
from sentry_sdk.utils import (
1517
capture_internal_exceptions,
1618
ensure_integration_enabled,
@@ -102,6 +104,11 @@ def _patched_handle(self: "Bottle", environ: "Dict[str, Any]") -> "Any":
102104
)
103105
res = old_handle(self, environ)
104106

107+
if has_span_streaming_enabled(sentry_sdk.get_client().options):
108+
_set_segment_name_and_source(
109+
transaction_style=integration.transaction_style
110+
)
111+
105112
return res
106113

107114
Bottle._handle = _patched_handle
@@ -163,6 +170,25 @@ def size_of_file(self, file: "FileUpload") -> int:
163170
return file.content_length
164171

165172

173+
def _set_segment_name_and_source(transaction_style: str) -> None:
174+
try:
175+
if transaction_style == "url":
176+
name = bottle_request.route.rule or "bottle request"
177+
else:
178+
name = (
179+
bottle_request.route.name
180+
or transaction_from_function(bottle_request.route.callback)
181+
or "bottle request"
182+
)
183+
184+
sentry_sdk.get_current_scope().set_transaction_name(
185+
name,
186+
source=SEGMENT_SOURCE_FOR_STYLE[transaction_style],
187+
)
188+
except RuntimeError:
189+
pass
190+
191+
166192
def _set_transaction_name_and_source(
167193
event: "Event", transaction_style: str, request: "Any"
168194
) -> None:
@@ -185,7 +211,9 @@ def _set_transaction_name_and_source(
185211
pass
186212

187213
event["transaction"] = name
188-
event["transaction_info"] = {"source": SOURCE_FOR_STYLE[transaction_style]}
214+
event["transaction_info"] = {
215+
"source": TRANSACTION_SOURCE_FOR_STYLE[transaction_style]
216+
}
189217

190218

191219
def _make_request_event_processor(

tests/integrations/bottle/test_bottle.py

Lines changed: 255 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from werkzeug.test import Client
99
from werkzeug.wrappers import Response
1010

11+
import sentry_sdk
1112
from sentry_sdk import capture_message
1213
from sentry_sdk.integrations.bottle import BottleIntegration
1314
from sentry_sdk.integrations.logging import LoggingIntegration
@@ -462,23 +463,37 @@ def here():
462463
assert not events
463464

464465

466+
@pytest.mark.parametrize("span_streaming", [True, False])
465467
def test_span_origin(
466468
sentry_init,
467469
get_client,
468470
capture_events,
471+
capture_items,
472+
span_streaming,
469473
):
470474
sentry_init(
471475
integrations=[BottleIntegration()],
472476
traces_sample_rate=1.0,
477+
_experiments={"trace_lifecycle": "stream" if span_streaming else "static"},
473478
)
474-
events = capture_events()
479+
480+
if span_streaming:
481+
items = capture_items("span")
482+
else:
483+
events = capture_events()
475484

476485
client = get_client()
477486
client.get("/message")
478487

479-
(_, event) = events
480-
481-
assert event["contexts"]["trace"]["origin"] == "auto.http.bottle"
488+
if span_streaming:
489+
sentry_sdk.flush()
490+
spans = [item.payload for item in items]
491+
segment = spans[-1]
492+
assert segment["is_segment"] is True
493+
assert segment["attributes"]["sentry.origin"] == "auto.http.bottle"
494+
else:
495+
(_, event) = events
496+
assert event["contexts"]["trace"]["origin"] == "auto.http.bottle"
482497

483498

484499
@pytest.mark.parametrize("raise_error", [True, False])
@@ -556,3 +571,239 @@ def handle():
556571

557572
(event,) = events
558573
assert event["exception"]["values"][0]["type"] == "ZeroDivisionError"
574+
575+
576+
def test_span_streaming_basic(sentry_init, capture_items):
577+
sentry_init(
578+
integrations=[BottleIntegration()],
579+
traces_sample_rate=1.0,
580+
_experiments={"trace_lifecycle": "stream"},
581+
)
582+
items = capture_items("span")
583+
584+
app = Bottle()
585+
586+
@app.route("/message")
587+
def hi():
588+
return "ok"
589+
590+
client = Client(app)
591+
client.get("/message")
592+
593+
sentry_sdk.flush()
594+
595+
spans = [item.payload for item in items]
596+
assert len(spans) == 1
597+
598+
segment = spans[0]
599+
600+
# Segment span (root, created by WSGI middleware)
601+
assert segment["is_segment"] is True
602+
assert "parent_span_id" not in segment
603+
assert segment["status"] == "ok"
604+
assert segment["attributes"]["sentry.op"] == "http.server"
605+
assert segment["attributes"]["sentry.origin"] == "auto.http.bottle"
606+
assert segment["attributes"]["http.request.method"] == "GET"
607+
assert segment["attributes"]["http.response.status_code"] == 200
608+
assert segment["name"].endswith("hi")
609+
610+
611+
@pytest.mark.parametrize(
612+
"url,transaction_style,expected_name,expected_source",
613+
[
614+
("/message", "endpoint", "hi", "component"),
615+
("/message", "url", "/message", "route"),
616+
("/message/123456", "url", "/message/<message_id>", "route"),
617+
("/message-named-route", "endpoint", "hi", "component"),
618+
],
619+
)
620+
def test_span_streaming_transaction_style(
621+
sentry_init,
622+
capture_items,
623+
url,
624+
transaction_style,
625+
expected_name,
626+
expected_source,
627+
):
628+
sentry_init(
629+
integrations=[BottleIntegration(transaction_style=transaction_style)],
630+
traces_sample_rate=1.0,
631+
_experiments={"trace_lifecycle": "stream"},
632+
)
633+
items = capture_items("span")
634+
635+
app = Bottle()
636+
637+
@app.route("/message")
638+
def hi():
639+
return "ok"
640+
641+
@app.route("/message/<message_id>")
642+
def hi_with_id(message_id):
643+
return "ok"
644+
645+
@app.route("/message-named-route", name="hi")
646+
def named_hi():
647+
return "ok"
648+
649+
client = Client(app)
650+
client.get(url)
651+
652+
sentry_sdk.flush()
653+
654+
spans = [item.payload for item in items]
655+
assert len(spans) == 1
656+
657+
segment = spans[0]
658+
659+
assert segment["is_segment"] is True
660+
661+
assert segment["name"].endswith(expected_name)
662+
assert segment["attributes"]["sentry.span.source"] == expected_source
663+
664+
665+
def test_span_streaming_with_error(sentry_init, capture_items):
666+
sentry_init(
667+
integrations=[BottleIntegration()],
668+
traces_sample_rate=1.0,
669+
_experiments={"trace_lifecycle": "stream"},
670+
)
671+
items = capture_items("event", "span")
672+
673+
app = Bottle()
674+
675+
@app.route("/error")
676+
def error():
677+
1 / 0
678+
679+
client = Client(app)
680+
try:
681+
client.get("/error")
682+
except ZeroDivisionError:
683+
pass
684+
685+
sentry_sdk.flush()
686+
687+
events = [item.payload for item in items if item.type == "event"]
688+
spans = [item.payload for item in items if item.type == "span"]
689+
assert len(events) == 1
690+
assert len(spans) == 1
691+
692+
error_event = events[0]
693+
segment = spans[0]
694+
695+
# Confirm the same trace is shared
696+
assert segment["trace_id"] == error_event["contexts"]["trace"]["trace_id"]
697+
698+
# Span hierarchy
699+
assert segment["is_segment"] is True
700+
assert "parent_span_id" not in segment
701+
702+
# Error event span_id points to the segment span (where the exception was raised)
703+
assert error_event["contexts"]["trace"]["span_id"] == segment["span_id"]
704+
705+
# Span status
706+
assert segment["status"] == "error"
707+
708+
# Bottle mechanism on the error event
709+
assert error_event["exception"]["values"][0]["mechanism"]["type"] == "bottle"
710+
assert error_event["exception"]["values"][0]["mechanism"]["handled"] is False
711+
712+
713+
@pytest.mark.parametrize(
714+
"status_code,expected_span_status",
715+
[
716+
(200, "ok"),
717+
(404, "error"),
718+
(500, "error"),
719+
],
720+
)
721+
def test_span_streaming_http_error_status(
722+
sentry_init,
723+
capture_items,
724+
status_code,
725+
expected_span_status,
726+
):
727+
sentry_init(
728+
integrations=[BottleIntegration()],
729+
traces_sample_rate=1.0,
730+
_experiments={"trace_lifecycle": "stream"},
731+
)
732+
items = capture_items("span")
733+
734+
app = Bottle()
735+
736+
@app.route("/")
737+
def handle():
738+
return HTTPResponse(status=status_code, body="response")
739+
740+
client = Client(app)
741+
client.get("/")
742+
743+
sentry_sdk.flush()
744+
745+
spans = [item.payload for item in items]
746+
assert len(spans) == 1
747+
748+
segment = spans[0]
749+
750+
assert segment["is_segment"] is True
751+
752+
assert segment["status"] == expected_span_status
753+
assert segment["attributes"]["http.response.status_code"] == status_code
754+
755+
756+
@pytest.mark.parametrize("raise_error", [True, False])
757+
@pytest.mark.parametrize(
758+
("integration_kwargs", "status_code", "should_capture"),
759+
(
760+
({}, 500, True),
761+
({}, 400, False),
762+
({"failed_request_status_codes": set()}, 500, False),
763+
({"failed_request_status_codes": {404, *range(500, 600)}}, 404, True),
764+
({"failed_request_status_codes": {404, *range(500, 600)}}, 400, False),
765+
),
766+
)
767+
def test_span_streaming_failed_request_status_codes(
768+
sentry_init,
769+
capture_items,
770+
integration_kwargs,
771+
status_code,
772+
should_capture,
773+
raise_error,
774+
):
775+
sentry_init(
776+
integrations=[BottleIntegration(**integration_kwargs)],
777+
traces_sample_rate=1.0,
778+
_experiments={"trace_lifecycle": "stream"},
779+
)
780+
items = capture_items("event", "span")
781+
782+
app = Bottle()
783+
784+
@app.route("/")
785+
def handle():
786+
response = HTTPResponse(status=status_code)
787+
if raise_error:
788+
raise response
789+
return response
790+
791+
client = Client(app, Response)
792+
client.get("/")
793+
794+
sentry_sdk.flush()
795+
796+
events = [item.payload for item in items if item.type == "event"]
797+
spans = [item.payload for item in items if item.type == "span"]
798+
assert len(spans) == 1
799+
800+
segment = spans[0]
801+
802+
assert segment["is_segment"] is True
803+
804+
if should_capture:
805+
assert len(events) == 1
806+
assert events[0]["exception"]["values"][0]["type"] == "HTTPResponse"
807+
assert events[0]["exception"]["values"][0]["mechanism"]["handled"] is True
808+
else:
809+
assert len(events) == 0

0 commit comments

Comments
 (0)