Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions sentry_sdk/integrations/flask.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,9 @@ def setup_once() -> None:
def sentry_patched_wsgi_app(
self: "Any", environ: "Dict[str, str]", start_response: "Callable[..., Any]"
) -> "_ScopedResponse":
if sentry_sdk.get_client().get_integration(FlaskIntegration) is None:
return old_app(self, environ, start_response)

integration = sentry_sdk.get_client().get_integration(FlaskIntegration)
if integration is None:
return old_app(self, environ, start_response)

middleware = SentryWsgiMiddleware(
lambda *a, **kw: old_app(self, *a, **kw),
Expand Down
212 changes: 164 additions & 48 deletions tests/integrations/flask/test_flask.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
from flask.views import View
from flask_login import LoginManager, login_user

from sentry_sdk.traces import SpanStatus

try:
from werkzeug.wrappers.request import UnsupportedMediaType
except ImportError:
Expand Down Expand Up @@ -83,6 +85,7 @@ def test_has_context(sentry_init, app, capture_events):
assert event["request"]["url"] == "http://localhost/message"


@pytest.mark.parametrize("span_streaming", [True, False])
@pytest.mark.parametrize(
"url,transaction_style,expected_transaction,expected_source",
[
Expand All @@ -92,29 +95,45 @@ def test_has_context(sentry_init, app, capture_events):
("/message/123456", "url", "/message/<int:message_id>", "route"),
],
)
def test_transaction_style(
def test_transaction_or_segment_style(
sentry_init,
app,
capture_events,
capture_items,
url,
transaction_style,
expected_transaction,
expected_source,
span_streaming,
):
sentry_init(
integrations=[
flask_sentry.FlaskIntegration(transaction_style=transaction_style)
]
],
traces_sample_rate=1.0,
_experiments={"trace_lifecycle": "stream" if span_streaming else "static"},
)
events = capture_events()

if span_streaming:
items = capture_items("span")
else:
events = capture_events()

client = app.test_client()
response = client.get(url)
assert response.status_code == 200

(event,) = events
assert event["transaction"] == expected_transaction
assert event["transaction_info"] == {"source": expected_source}
if span_streaming:
sentry_sdk.flush()
spans = [i.payload for i in items if i.type == "span"]
assert len(spans) == 1
(segment,) = spans
assert segment["name"] == expected_transaction
assert segment["attributes"]["sentry.span.source"] == expected_source
else:
(_, event) = events
assert event["transaction"] == expected_transaction
assert event["transaction_info"] == {"source": expected_source}


@pytest.mark.parametrize("debug", (True, False))
Expand Down Expand Up @@ -750,8 +769,15 @@ def zerodivision(e):
assert not events


def test_tracing_success(sentry_init, capture_events, app):
sentry_init(traces_sample_rate=1.0, integrations=[flask_sentry.FlaskIntegration()])
@pytest.mark.parametrize("span_streaming", [True, False])
def test_tracing_success(
sentry_init, capture_events, capture_items, app, span_streaming
):
sentry_init(
traces_sample_rate=1.0,
integrations=[flask_sentry.FlaskIntegration()],
_experiments={"trace_lifecycle": "stream" if span_streaming else "static"},
)

@app.before_request
def _():
Expand All @@ -763,30 +789,61 @@ def hi_tx():
capture_message("hi")
return "ok"

events = capture_events()
if span_streaming:
items = capture_items("event", "span")
else:
events = capture_events()

with app.test_client() as client:
response = client.get("/message_tx")
assert response.status_code == 200

message_event, transaction_event = events
if span_streaming:
sentry_sdk.flush()
spans = [i.payload for i in items if i.type == "span"]
message_events = [i.payload for i in items if i.type == "event"]

assert transaction_event["type"] == "transaction"
assert transaction_event["transaction"] == "hi_tx"
assert transaction_event["contexts"]["trace"]["status"] == "ok"
assert transaction_event["tags"]["view"] == "yes"
assert transaction_event["tags"]["before_request"] == "yes"
assert len(spans) == 1
assert len(message_events) == 1

assert message_event["message"] == "hi"
assert message_event["transaction"] == "hi_tx"
assert message_event["tags"]["view"] == "yes"
assert message_event["tags"]["before_request"] == "yes"
(segment,) = spans
(message_event,) = message_events

assert segment["name"] == "hi_tx"
assert segment["status"] == SpanStatus.OK
assert segment["attributes"]["sentry.origin"] == "auto.http.flask"

def test_tracing_error(sentry_init, capture_events, app):
sentry_init(traces_sample_rate=1.0, integrations=[flask_sentry.FlaskIntegration()])
assert message_event["message"] == "hi"
assert message_event["transaction"] == "hi_tx"
assert message_event["tags"]["view"] == "yes"
assert message_event["tags"]["before_request"] == "yes"
else:
message_event, transaction_event = events

events = capture_events()
assert transaction_event["type"] == "transaction"
assert transaction_event["transaction"] == "hi_tx"
assert transaction_event["contexts"]["trace"]["status"] == "ok"
assert transaction_event["tags"]["view"] == "yes"
assert transaction_event["tags"]["before_request"] == "yes"

assert message_event["message"] == "hi"
assert message_event["transaction"] == "hi_tx"
assert message_event["tags"]["view"] == "yes"
assert message_event["tags"]["before_request"] == "yes"


@pytest.mark.parametrize("span_streaming", [True, False])
def test_tracing_error(sentry_init, capture_events, capture_items, app, span_streaming):
sentry_init(
traces_sample_rate=1.0,
integrations=[flask_sentry.FlaskIntegration()],
_experiments={"trace_lifecycle": "stream" if span_streaming else "static"},
)

if span_streaming:
items = capture_items("event", "span")
else:
events = capture_events()

@app.route("/error")
def error():
Expand All @@ -797,15 +854,33 @@ def error():
response = client.get("/error")
assert response.status_code == 500

error_event, transaction_event = events
if span_streaming:
sentry_sdk.flush()
spans = [i.payload for i in items if i.type == "span"]
error_events = [i.payload for i in items if i.type == "event"]

assert transaction_event["type"] == "transaction"
assert transaction_event["transaction"] == "error"
assert transaction_event["contexts"]["trace"]["status"] == "internal_error"
assert len(spans) == 1
assert len(error_events) == 1

assert error_event["transaction"] == "error"
(exception,) = error_event["exception"]["values"]
assert exception["type"] == "ZeroDivisionError"
(segment,) = spans
(error_event,) = error_events

assert segment["name"] == "error"
assert segment["status"] == SpanStatus.ERROR

assert error_event["transaction"] == "error"
(exception,) = error_event["exception"]["values"]
assert exception["type"] == "ZeroDivisionError"
else:
error_event, transaction_event = events

assert transaction_event["type"] == "transaction"
assert transaction_event["transaction"] == "error"
assert transaction_event["contexts"]["trace"]["status"] == "internal_error"

assert error_event["transaction"] == "error"
(exception,) = error_event["exception"]["values"]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assertion http.response.status_code == 500 will always fail when Flask TESTING propagates exceptions

With app.config["TESTING"] = True, Flask re-raises unhandled exceptions instead of returning a 500 response, so _sentry_start_response is never called and http.response.status_code is never set on the span; accessing segment["attributes"]["http.response.status_code"] will raise KeyError.

Verification

Traced the full execution path: (1) app.config["TESTING"] = True is set in the app fixture (test_flask.py:40). (2) With TESTING=True, Flask's handle_exception re-raises unhandled exceptions before return response(environ, start_response) is reached in Flask's wsgi_app. (3) The test wraps the request in with pytest.raises(ZeroDivisionError): (line 864), confirming no HTTP response is returned. (4) In SentryWsgiMiddleware.__call__ (wsgi.py), _sentry_start_response is passed as the start_response callback, but it is only called by Flask when a response is actually being sent. Because Flask propagates the exception without calling start_response, _sentry_start_response is never invoked and the span.set_attribute("http.response.status_code", status_int) line in _sentry_start_response is never reached. (5) The except BaseException: reraise(...) block and the finally block in wsgi.py do not set http.response.status_code either. (6) StreamedSpan.__exit__ only sets self.status = SpanStatus.ERROR.value, not any http.response.status_code attribute. (7) conftest.py:capture_items constructs segment["attributes"] from the span's attribute dict; a missing key causes KeyError when accessed. The assertion segment["status"] == SpanStatus.ERROR (line 882) would pass, but the http.response.status_code assertion will fail.

Suggested fix: Remove the assertion for http.response.status_code in the error-propagation case, since no response is sent when Flask testing mode propagates the exception.

Identified by Warden find-bugs · S66-Z95

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warden is correct on this one, and it's why the test is failing. I'm going to remove the assertion because, based on my understanding of this behaviour, Flask's error handlers are never called due to this snippet of code, and this only happens when Flask is set to "testing" or "debug" mode.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 855a9bb

assert exception["type"] == "ZeroDivisionError"


def test_error_has_trace_context_if_tracing_disabled(sentry_init, capture_events, app):
Expand Down Expand Up @@ -982,34 +1057,54 @@ def test_response_status_code_not_found_in_transaction_context(
assert transaction["contexts"]["response"]["status_code"] == 404


def test_span_origin(sentry_init, app, capture_events):
@pytest.mark.parametrize("span_streaming", [True, False])
def test_span_origin(sentry_init, app, capture_events, capture_items, span_streaming):
sentry_init(
integrations=[flask_sentry.FlaskIntegration()],
traces_sample_rate=1.0,
_experiments={"trace_lifecycle": "stream" if span_streaming else "static"},
)
events = capture_events()

if span_streaming:
items = capture_items("span")
else:
events = capture_events()

client = app.test_client()
client.get("/message")

(_, event) = events

assert event["contexts"]["trace"]["origin"] == "auto.http.flask"
if span_streaming:
sentry_sdk.flush()
spans = [i.payload for i in items if i.type == "span"]
assert len(spans) == 1
(segment,) = spans
assert segment["attributes"]["sentry.origin"] == "auto.http.flask"
else:
(_, event) = events
assert event["contexts"]["trace"]["origin"] == "auto.http.flask"


def test_transaction_http_method_default(
@pytest.mark.parametrize("span_streaming", [True, False])
def test_transaction_or_segment_http_method_default(
sentry_init,
app,
capture_events,
capture_items,
span_streaming,
):
"""
By default OPTIONS and HEAD requests do not create a transaction.
By default OPTIONS and HEAD requests do not create a transaction or segment.
"""
sentry_init(
traces_sample_rate=1.0,
integrations=[flask_sentry.FlaskIntegration()],
_experiments={"trace_lifecycle": "stream" if span_streaming else "static"},
)
events = capture_events()

if span_streaming:
items = capture_items("span")
else:
events = capture_events()

client = app.test_client()
response = client.get("/nomessage")
Expand All @@ -1021,16 +1116,25 @@ def test_transaction_http_method_default(
response = client.head("/nomessage")
assert response.status_code == 200

(event,) = events

assert len(events) == 1
assert event["request"]["method"] == "GET"
if span_streaming:
sentry_sdk.flush()
spans = [i.payload for i in items if i.type == "span"]
assert len(spans) == 1
(segment,) = spans
assert segment["attributes"]["http.request.method"] == "GET"
else:
(event,) = events
assert len(events) == 1
assert event["request"]["method"] == "GET"


def test_transaction_http_method_custom(
@pytest.mark.parametrize("span_streaming", [True, False])
def test_transaction_or_segment_http_method_custom(
sentry_init,
app,
capture_events,
capture_items,
span_streaming,
):
"""
Configure FlaskIntegration to ONLY capture OPTIONS and HEAD requests.
Expand All @@ -1045,8 +1149,13 @@ def test_transaction_http_method_custom(
) # capitalization does not matter
) # case does not matter
],
_experiments={"trace_lifecycle": "stream" if span_streaming else "static"},
)
events = capture_events()

if span_streaming:
items = capture_items("span")
else:
events = capture_events()

client = app.test_client()
response = client.get("/nomessage")
Expand All @@ -1058,8 +1167,15 @@ def test_transaction_http_method_custom(
response = client.head("/nomessage")
assert response.status_code == 200

assert len(events) == 2

(event1, event2) = events
assert event1["request"]["method"] == "OPTIONS"
assert event2["request"]["method"] == "HEAD"
if span_streaming:
sentry_sdk.flush()
spans = [i.payload for i in items if i.type == "span"]
assert len(spans) == 2
(options_segment, head_segment) = spans
assert options_segment["attributes"]["http.request.method"] == "OPTIONS"
assert head_segment["attributes"]["http.request.method"] == "HEAD"
else:
assert len(events) == 2
(event1, event2) = events
assert event1["request"]["method"] == "OPTIONS"
assert event2["request"]["method"] == "HEAD"
Loading