Skip to content

Commit 7b1e8b5

Browse files
authored
Merge branch 'master' into feat/span-first
2 parents 2531e8c + c11d1f2 commit 7b1e8b5

6 files changed

Lines changed: 123 additions & 4 deletions

File tree

codecov.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,17 @@ coverage:
22
status:
33
project:
44
default:
5-
target: auto # auto compares coverage to the previous base commit
6-
threshold: 10% # this allows a 10% drop from the previous base commit coverage
5+
target: auto # auto compares coverage to the previous base commit
6+
threshold: 10% # this allows a 10% drop from the previous base commit coverage
77
informational: true
88

99
ignore:
1010
- "tests"
1111
- "sentry_sdk/_types.py"
1212

1313
comment: true
14+
config:
15+
files: changed
1416

1517
github_checks:
1618
annotations: false

sentry_sdk/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
"last_event_id",
3838
"new_scope",
3939
"push_scope",
40+
"remove_attribute",
41+
"set_attribute",
4042
"set_context",
4143
"set_extra",
4244
"set_level",

sentry_sdk/api.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ def overload(x: "T") -> "T":
7171
"last_event_id",
7272
"new_scope",
7373
"push_scope",
74+
"remove_attribute",
75+
"set_attribute",
7476
"set_context",
7577
"set_extra",
7678
"set_level",
@@ -288,6 +290,28 @@ def push_scope( # noqa: F811
288290
return _ScopeManager()
289291

290292

293+
@scopemethod
294+
def set_attribute(attribute: str, value: "Any") -> None:
295+
"""
296+
Set an attribute.
297+
298+
Any attributes-based telemetry (logs, metrics) captured in this scope will
299+
include this attribute.
300+
"""
301+
return get_isolation_scope().set_attribute(attribute, value)
302+
303+
304+
@scopemethod
305+
def remove_attribute(attribute: str) -> None:
306+
"""
307+
Remove an attribute.
308+
309+
If the attribute doesn't exist, this function will not have any effect and
310+
it will also not raise an exception.
311+
"""
312+
return get_isolation_scope().remove_attribute(attribute)
313+
314+
291315
@scopemethod
292316
def set_tag(key: str, value: "Any") -> None:
293317
return get_isolation_scope().set_tag(key, value)

sentry_sdk/integrations/wsgi.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ def __init__(
8686

8787
def __call__(
8888
self, environ: "Dict[str, str]", start_response: "Callable[..., Any]"
89-
) -> "_ScopedResponse":
89+
) -> "Any":
9090
if _wsgi_middleware_applied.get(False):
9191
return self.app(environ, start_response)
9292

@@ -135,6 +135,29 @@ def __call__(
135135
finally:
136136
_wsgi_middleware_applied.set(False)
137137

138+
# Within the uWSGI subhandler, the use of the "offload" mechanism for file responses
139+
# is determined by a pointer equality check on the response object
140+
# (see https://github.com/unbit/uwsgi/blob/8d116f7ea2b098c11ce54d0b3a561c54dcd11929/plugins/python/wsgi_subhandler.c#L278).
141+
#
142+
# If we were to return a _ScopedResponse, this would cause the check to always fail
143+
# since it's checking the files are exactly the same.
144+
#
145+
# To avoid this and ensure that the offloading mechanism works as expected when it's
146+
# enabled, we check if the response is a file-like object (determined by the presence
147+
# of `fileno`), if the wsgi.file_wrapper is available in the environment (as if so,
148+
# it would've been used in handling the file in the response).
149+
#
150+
# Even if the offload mechanism is not enabled, there are optimizations that uWSGI does for file-like objects,
151+
# so we want to make sure we don't interfere with those either.
152+
#
153+
# If all conditions are met, we return the original response object directly,
154+
# allowing uWSGI to handle it as intended.
155+
if (
156+
environ.get("wsgi.file_wrapper")
157+
and getattr(response, "fileno", None) is not None
158+
):
159+
return response
160+
138161
return _ScopedResponse(scope, response)
139162

140163

tests/integrations/wsgi/test_wsgi.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import sentry_sdk
88
from sentry_sdk import capture_message
9-
from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware
9+
from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware, _ScopedResponse
1010

1111

1212
@pytest.fixture
@@ -500,3 +500,50 @@ def dogpark(environ, start_response):
500500
(event,) = events
501501

502502
assert event["contexts"]["trace"]["origin"] == "auto.dogpark.deluxe"
503+
504+
505+
@pytest.mark.parametrize(
506+
"has_file_wrapper, has_fileno, expect_wrapped",
507+
[
508+
(True, True, False), # both conditions met → unwrapped
509+
(False, True, True), # no file_wrapper → wrapped
510+
(True, False, True), # no fileno → wrapped
511+
(False, False, True), # neither condition → wrapped
512+
],
513+
)
514+
def test_file_response_wrapping(
515+
sentry_init, has_file_wrapper, has_fileno, expect_wrapped
516+
):
517+
sentry_init()
518+
519+
response_mock = mock.MagicMock()
520+
if not has_fileno:
521+
del response_mock.fileno
522+
523+
def app(environ, start_response):
524+
start_response("200 OK", [])
525+
return response_mock
526+
527+
environ_extra = {}
528+
if has_file_wrapper:
529+
environ_extra["wsgi.file_wrapper"] = mock.MagicMock()
530+
531+
middleware = SentryWsgiMiddleware(app)
532+
533+
result = middleware(
534+
{
535+
"REQUEST_METHOD": "GET",
536+
"PATH_INFO": "/",
537+
"SERVER_NAME": "localhost",
538+
"SERVER_PORT": "80",
539+
"wsgi.url_scheme": "http",
540+
"wsgi.input": mock.MagicMock(),
541+
**environ_extra,
542+
},
543+
lambda status, headers: None,
544+
)
545+
546+
if expect_wrapped:
547+
assert isinstance(result, _ScopedResponse)
548+
else:
549+
assert result is response_mock

tests/test_attributes.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,27 @@
33
from tests.test_metrics import envelopes_to_metrics
44

55

6+
def test_top_level_api(sentry_init, capture_envelopes):
7+
sentry_init()
8+
9+
envelopes = capture_envelopes()
10+
11+
sentry_sdk.set_attribute("set", "value")
12+
sentry_sdk.set_attribute("removed", "value")
13+
sentry_sdk.remove_attribute("removed")
14+
# Attempting to remove a nonexistent attribute should not raise
15+
sentry_sdk.remove_attribute("nonexistent")
16+
17+
sentry_sdk.metrics.count("test", 1)
18+
sentry_sdk.get_client().flush()
19+
20+
metrics = envelopes_to_metrics(envelopes)
21+
(metric,) = metrics
22+
23+
assert metric["attributes"]["set"] == "value"
24+
assert "removed" not in metric["attributes"]
25+
26+
627
def test_scope_precedence(sentry_init, capture_envelopes):
728
# Order of precedence, from most important to least:
829
# 1. telemetry attributes (directly supplying attributes on creation or using set_attribute)

0 commit comments

Comments
 (0)