Skip to content

Allow push_scope to act as a decorator #523

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 10 commits into from
Closed
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
29 changes: 29 additions & 0 deletions sentry_sdk/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,35 @@

PY2 = sys.version_info[0] == 2

if not PY2 and sys.version_info >= (3, 2):
from contextlib import ContextDecorator
else:
from functools import wraps

class ContextDecorator(object):
"A base class or mixin that enables context managers to work as decorators."

def _recreate_cm(self):
"""Return a recreated instance of self.

Allows an otherwise one-shot context manager like
_GeneratorContextManager to support use as
a decorator via implicit recreation.

This is a private interface just for _GeneratorContextManager.
See issue #11647 for details.
"""
return self

def __call__(self, func):
@wraps(func)
def inner(*args, **kwds):
with self._recreate_cm():
return func(*args, **kwds)

return inner


if PY2:
import urlparse # noqa

Expand Down
52 changes: 49 additions & 3 deletions sentry_sdk/api.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import inspect
from contextlib import contextmanager

from sentry_sdk.hub import Hub
from sentry_sdk.hub import Hub, ScopeManager
from sentry_sdk.scope import Scope

from sentry_sdk._types import MYPY

if MYPY:
from typing import Any
from typing import Dict
from typing import Optional
from typing import overload
from typing import Callable
Expand Down Expand Up @@ -36,6 +37,10 @@ def overload(x):
"flush",
"last_event_id",
"start_span",
"set_tag",
"set_extra",
"set_user",
"set_level",
]


Expand All @@ -48,6 +53,15 @@ def hubmethod(f):
return f


def scopemethod(f):
# type: (F) -> F
f.__doc__ = "%s\n\n%s" % (
"Alias for :py:meth:`sentry_sdk.Scope.%s`" % f.__name__,
inspect.getdoc(getattr(Scope, f.__name__)),
)
return f


@hubmethod
def capture_event(
event, # type: Event
Expand Down Expand Up @@ -131,7 +145,7 @@ def inner():

@overload # noqa
def push_scope():
# type: () -> ContextManager[Scope]
# type: () -> ScopeManager
pass


Expand All @@ -147,7 +161,7 @@ def push_scope(
def push_scope(
callback=None # type: Optional[Callable[[Scope], None]]
):
# type: (...) -> Optional[ContextManager[Scope]]
# type: (...) -> Optional[ScopeManager]
hub = Hub.current
if hub is not None:
return hub.push_scope(callback)
Expand All @@ -163,6 +177,38 @@ def inner():
return None


@scopemethod # noqa
def set_tag(key, value):
# type: (str, Any) -> None
hub = Hub.current
if hub is not None:
hub.current_scope.set_tag(key, value)


@scopemethod # noqa
def set_extra(key, value):
# type: (str, Any) -> None
hub = Hub.current
if hub is not None:
hub.current_scope.set_extra(key, value)


@scopemethod # noqa
def set_user(value):
# type: (Dict[str, Any]) -> None
hub = Hub.current
if hub is not None:
hub.current_scope.set_user(value)


@scopemethod # noqa
def set_level(value):
# type: (str) -> None
hub = Hub.current
if hub is not None:
hub.current_scope.set_level(value)


@hubmethod
def flush(
timeout=None, # type: Optional[float]
Expand Down
31 changes: 19 additions & 12 deletions sentry_sdk/hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from datetime import datetime
from contextlib import contextmanager

from sentry_sdk._compat import with_metaclass
from sentry_sdk._compat import with_metaclass, ContextDecorator
from sentry_sdk.scope import Scope
from sentry_sdk.client import Client
from sentry_sdk.tracing import Span
Expand Down Expand Up @@ -123,15 +123,20 @@ def main(cls):
return GLOBAL_HUB


class _ScopeManager(object):
class ScopeManager(ContextDecorator):
def __init__(self, hub):
# type: (Hub) -> None
self._hub = hub
self._original_len = len(hub._stack)
self._layer = hub._stack[-1]

def __enter__(self):
# type: () -> Scope
client, scope = self._hub._stack[-1]
new_layer = (client, copy.copy(scope))
self._hub._stack.append(new_layer)

self._original_len = len(self._hub._stack)
self._layer = self._hub._stack[-1]

scope = self._layer[1]
assert scope is not None
return scope
Expand Down Expand Up @@ -263,6 +268,12 @@ def client(self):
"""Returns the current client on the hub."""
return self._stack[-1][0]

@property
def current_scope(self):
Copy link
Member

Choose a reason for hiding this comment

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

If you want this in I would like to call it scope to be consistent with JS

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Makes sense.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually, I don't think this works because of a line below.

# type: () -> Scope
"""Returns the current scope on the hub."""
return self._stack[-1][1]

def last_event_id(self):
# type: () -> Optional[str]
"""Returns the last event ID."""
Expand Down Expand Up @@ -448,7 +459,7 @@ def start_span(
def push_scope(
self, callback=None # type: Optional[None]
):
# type: (...) -> ContextManager[Scope]
# type: (...) -> ScopeManager
pass

@overload # noqa
Expand All @@ -461,14 +472,14 @@ def push_scope(
def push_scope( # noqa
self, callback=None # type: Optional[Callable[[Scope], None]]
):
# type: (...) -> Optional[ContextManager[Scope]]
# type: (...) -> Optional[ScopeManager]
"""
Pushes a new layer on the scope stack.

:param callback: If provided, this method pushes a scope, calls
`callback`, and pops the scope again.

:returns: If no `callback` is provided, a context manager that should
:returns: If no `callback` is provided, a ContextDecorator that should
be used to pop the scope again.
"""

Expand All @@ -477,11 +488,7 @@ def push_scope( # noqa
callback(scope)
return None

client, scope = self._stack[-1]
new_layer = (client, copy.copy(scope))
self._stack.append(new_layer)

return _ScopeManager(self)
return ScopeManager(self)

scope = push_scope
Copy link
Contributor Author

@a-d-collins a-d-collins Oct 23, 2019

Choose a reason for hiding this comment

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

@untitaker This property here seems to take the namespace of Hub.scope?


Expand Down
10 changes: 10 additions & 0 deletions sentry_sdk/scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ def level(self, value):
"""When set this overrides the level."""
self._level = value

def set_level(self, value):
# type: (Optional[str]) -> None
"""When set this overrides the level."""
Copy link
Member

Choose a reason for hiding this comment

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

Since this is no longer a property this docstring should be rephrased. Also please change the level property docs to say "deprecated in favor of set_level"

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I can change the docstring and docs.

self._level = value

@_attr_setter
def fingerprint(self, value):
# type: (Optional[List[str]]) -> None
Expand All @@ -144,6 +149,11 @@ def user(self, value):
"""When set a specific user is bound to the scope."""
self._user = value

def set_user(self, value):
# type: (Dict[str, Any]) -> None
"""When set a specific user is bound to the scope."""
self._user = value

@property
def span(self):
# type: () -> Optional[Span]
Expand Down
42 changes: 25 additions & 17 deletions tests/test_basics.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
add_breadcrumb,
last_event_id,
Hub,
set_level,
)
from sentry_sdk.integrations.logging import LoggingIntegration

Expand Down Expand Up @@ -174,6 +175,30 @@ def _(scope):
assert Hub.current._stack[-1][1] is outer_scope


def test_push_scope_as_decorator(sentry_init, capture_events):
sentry_init()
events = capture_events()

Hub.current.bind_client(None)

outer_scope = Hub.current._stack[-1][1]

@push_scope()
def foo():
set_level("warning")
try:
1 / 0
except Exception as e:
capture_exception(e)

foo()

event, = events
assert outer_scope._level != "warning"
assert event["level"] == "warning"
assert "exception" in event


def test_breadcrumbs(sentry_init, capture_events):
sentry_init(max_breadcrumbs=10)
events = capture_events()
Expand Down Expand Up @@ -240,23 +265,6 @@ def test_client_initialized_within_scope(sentry_init, caplog):
assert record.msg.startswith("init() called inside of pushed scope.")


def test_scope_leaks_cleaned_up(sentry_init, caplog):
caplog.set_level(logging.WARNING)

sentry_init(debug=True)

old_stack = list(Hub.current._stack)

with push_scope():
push_scope()
Copy link
Member

@untitaker untitaker Oct 6, 2019

Choose a reason for hiding this comment

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

Damn, I didn't consider that this usecase no longer works. I'll have to think about this more. Right now I believe this is the less expected behavior, after all e.g. open() also does its work "eagerly".

For context, this is used in third-party integrations in situations where a use of context manager is not possible (search on github for pop_scope_unsafe). They could still manually call __enter__ and ``exit`, but it's a breaking API change in any case.

The scope cleanup described here has to continue working in any case because of concurrency issues in async envs that we need to mitigate against. For example, some async environment may share the hub across threads, which incurs data races left and right but push_scope and pop_scope_unsafe should crash as little as possible.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I definitely understand that scope cleanup needs to happen in async envs. However, if it is as critical as you say that push_scope() continues working the "eager" way it does now AND have it work as a context manager AND like this

@sentry_sdk.push_scope()
def foo():
    sentry_sdk.set_tag("key", "value")

Then I haven't thought of how to make it work, it's not possible, or something else may have to give. From my outsider's perspective the easiest solution is to deprecate push_scope(callback) functionality + alter the Hub and ScopeManager classes so that the following examples would all work:

@sentry_sdk.push_scope
def foo():
    sentry_sdk.set_tag("key", "value")


with sentry_sdk.push_scope():
    print("pushed scope!")


scope = sentry_sdk.push_scope()

but... that feels like a very major change to the api.


assert Hub.current._stack == old_stack

record, = (x for x in caplog.records if x.levelname == "WARNING")

assert record.message.startswith("Leaked 1 scopes:")


def test_scope_popped_too_soon(sentry_init, caplog):
caplog.set_level(logging.ERROR)

Expand Down