diff --git a/Makefile b/Makefile index b5ab2445d4..bad7622804 100644 --- a/Makefile +++ b/Makefile @@ -10,9 +10,13 @@ dist: test: .venv @pip install -r test-requirements.txt @pip install --editable . - @pytest tests + @pytest tests --tb=short .PHONY: test +format: + @black sentry_sdk tests +.PHONY: format + tox-test: @sh ./scripts/runtox.sh .PHONY: tox-test diff --git a/sentry_sdk/_compat.py b/sentry_sdk/_compat.py index c89bcf9f21..b796adf608 100644 --- a/sentry_sdk/_compat.py +++ b/sentry_sdk/_compat.py @@ -9,6 +9,7 @@ text_type = unicode # noqa import Queue as queue # noqa + string_types = (str, text_type) number_types = (int, long, float) # noqa def implements_str(cls): @@ -29,6 +30,7 @@ def implements_iterator(cls): import queue # noqa text_type = str + string_types = (text_type,) number_types = (int, float) def _identity(x): diff --git a/sentry_sdk/api.py b/sentry_sdk/api.py index c7be722707..2caf7845c5 100644 --- a/sentry_sdk/api.py +++ b/sentry_sdk/api.py @@ -1,5 +1,6 @@ from .hub import Hub -from .client import Client +from .client import Client, get_options +from .integrations import setup_integrations class _InitGuard(object): @@ -15,12 +16,27 @@ def __exit__(self, exc_type, exc_value, tb): c.close() -def init(*args, **kwargs): - client = Client(*args, **kwargs) - Hub.main.bind_client(client) +def _init_on_hub(hub, args, kwargs): + options = get_options(*args, **kwargs) + install = setup_integrations(options) + client = Client(options) + hub.bind_client(client) + install() return _InitGuard(client) +def init(*args, **kwargs): + """Initializes the SDK and optionally integrations.""" + return _init_on_hub(Hub.main, args, kwargs) + + +def _init_on_current(*args, **kwargs): + # This function only exists to support unittests. Do not call it as + # initializing integrations on anything but the main hub is not going + # to yield the results you expect. + return _init_on_hub(Hub.current, args, kwargs) + + from . import minimal as sentry_minimal __all__ = ["Hub", "Scope", "Client", "init"] + sentry_minimal.__all__ diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 4eb095468e..a11e22bcb5 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -2,40 +2,57 @@ import uuid import random import atexit - -from .utils import Dsn, SkipEvent, ContextVar +import weakref +from datetime import datetime + +from ._compat import string_types +from .utils import ( + strip_event, + flatten_metadata, + convert_types, + handle_in_app, + get_type_name, + pop_hidden_keys, + Dsn, +) from .transport import Transport from .consts import DEFAULT_OPTIONS, SDK_INFO -from .event import strip_event, flatten_metadata, convert_types, Event NO_DSN = object() -_most_recent_exception = ContextVar("sentry_most_recent_exception") +def get_options(*args, **kwargs): + if args and (isinstance(args[0], string_types) or args[0] is None): + dsn = args[0] + args = args[1:] + else: + dsn = None + + rv = dict(DEFAULT_OPTIONS) + options = dict(*args, **kwargs) + if dsn is not None and options.get("dsn") is None: + options["dsn"] = dsn + + for key, value in options.items(): + if key not in rv: + raise TypeError("Unknown option %r" % (key,)) + rv[key] = value -def _get_default_integrations(): - from .integrations.logging import LoggingIntegration - from .integrations.excepthook import ExcepthookIntegration + if rv["dsn"] is None: + rv["dsn"] = os.environ.get("SENTRY_DSN") - yield LoggingIntegration - yield ExcepthookIntegration + return rv class Client(object): - def __init__(self, dsn=None, *args, **kwargs): - passed_dsn = dsn - if dsn is NO_DSN: - dsn = None - else: - if dsn is None: - dsn = os.environ.get("SENTRY_DSN") - if not dsn: - dsn = None - else: - dsn = Dsn(dsn) - options = dict(DEFAULT_OPTIONS) - options.update(*args, **kwargs) + def __init__(self, *args, **kwargs): + options = get_options(*args, **kwargs) + + dsn = options["dsn"] + if dsn is not None: + dsn = Dsn(dsn) + self.options = options self._transport = self.options.pop("transport") if self._transport is None and dsn is not None: @@ -45,18 +62,6 @@ def __init__(self, dsn=None, *args, **kwargs): https_proxy=self.options.pop("https_proxy"), ) self._transport.start() - elif passed_dsn is not None and self._transport is not None: - raise ValueError("Cannot pass DSN and a custom transport.") - - integrations = list(options.pop("integrations") or ()) - - if options["default_integrations"]: - for cls in _get_default_integrations(): - if not any(isinstance(x, cls) for x in integrations): - integrations.append(cls()) - - for integration in integrations: - integration(self) request_bodies = ("always", "never", "small", "medium") if options["request_bodies"] not in request_bodies: @@ -66,6 +71,8 @@ def __init__(self, dsn=None, *args, **kwargs): ) ) + self._exceptions_seen = weakref.WeakKeyDictionary() + atexit.register(self.close) @property @@ -80,11 +87,13 @@ def disabled(cls): return cls(NO_DSN) def _prepare_event(self, event, scope): - if event.get("event_id") is None: - event["event_id"] = uuid.uuid4().hex + if event.get("timestamp") is None: + event["timestamp"] = datetime.utcnow() if scope is not None: - scope.apply_to_event(event) + event = scope.apply_to_event(event) + if event is None: + return for key in "release", "environment", "server_name", "repos", "dist": if event.get(key) is None: @@ -95,41 +104,67 @@ def _prepare_event(self, event, scope): if event.get("platform") is None: event["platform"] = "python" + event = handle_in_app( + event, self.options["in_app_exclude"], self.options["in_app_include"] + ) event = strip_event(event) - event = flatten_metadata(event) - event = convert_types(event) + + before_send = self.options["before_send"] + if before_send is not None: + event = before_send(event) + + if event is not None: + pop_hidden_keys(event) + event = flatten_metadata(event) + event = convert_types(event) + return event - def _check_should_capture(self, event): + def _is_ignored_error(self, event): + exc_info = event.get("__sentry_exc_info") + + if not exc_info or exc_info[0] is None: + return False + + type_name = get_type_name(exc_info[0]) + full_name = "%s.%s" % (exc_info[0].__module__, type_name) + + for errcls in self.options["ignore_errors"]: + # String types are matched against the type name in the + # exception only + if isinstance(errcls, string_types): + if errcls == full_name or errcls == type_name: + return True + else: + if issubclass(exc_info[0], errcls): + return True + + return False + + def _should_capture(self, event, scope=None): if ( self.options["sample_rate"] < 1.0 and random.random() >= self.options["sample_rate"] ): - raise SkipEvent() + return False - if event._exc_value is not None: - exclusions = self.options["ignore_errors"] - exc_type = type(event._exc_value) + if self._is_ignored_error(event): + return False - if any(issubclass(exc_type, e) for e in exclusions): - raise SkipEvent() - - if _most_recent_exception.get(None) is event._exc_value: - raise SkipEvent() - _most_recent_exception.set(event._exc_value) + return True def capture_event(self, event, scope=None): """Captures an event.""" if self._transport is None: return - if not isinstance(event, Event): - event = Event(event) - try: - self._check_should_capture(event) + rv = event.get("event_id") + if rv is None: + event["event_id"] = rv = uuid.uuid4().hex + if self._should_capture(event, scope): event = self._prepare_event(event, scope) - except SkipEvent: - return - self._transport.capture_event(event) + if event is not None: + self._transport.capture_event(event) + return True def drain_events(self, timeout=None): if timeout is None: diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index e885267c4d..475cd47876 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -3,6 +3,7 @@ VERSION = "0.1" DEFAULT_SERVER_NAME = socket.gethostname() if hasattr(socket, "gethostname") else None DEFAULT_OPTIONS = { + "dsn": None, "with_locals": True, "max_breadcrumbs": 100, "release": None, @@ -10,6 +11,8 @@ "server_name": DEFAULT_SERVER_NAME, "shutdown_timeout": 2.0, "integrations": [], + "in_app_include": [], + "in_app_exclude": [], "default_integrations": True, "repos": {}, "dist": None, @@ -18,8 +21,9 @@ "send_default_pii": False, "http_proxy": None, "https_proxy": None, - "ignore_errors": (), + "ignore_errors": [], "request_bodies": "medium", + "before_send": None, } SDK_INFO = {"name": "sentry-python", "version": VERSION} diff --git a/sentry_sdk/event.py b/sentry_sdk/event.py deleted file mode 100644 index f2f1a0d503..0000000000 --- a/sentry_sdk/event.py +++ /dev/null @@ -1,165 +0,0 @@ -import uuid -import datetime - -from collections import Mapping, Sequence - -from .utils import exceptions_from_error_tuple -from ._compat import text_type - - -def _datetime_to_json(dt): - return dt.strftime("%Y-%m-%dT%H:%M:%SZ") - - -class Event(Mapping): - __slots__ = ("_data", "_exc_value") - - def __init__(self, data={}): - self._data = { - "event_id": uuid.uuid4().hex, - "timestamp": datetime.datetime.utcnow(), - "level": "error", - } - - self._data.update(data) - - self._exc_value = None - - def set_exception(self, exc_type, exc_value, tb, with_locals): - self["exception"] = { - "values": exceptions_from_error_tuple(exc_type, exc_value, tb, with_locals) - } - self._exc_value = exc_value - - def __getitem__(self, key): - return self._data[key] - - def __contains__(self, key): - return key in self._data - - def get(self, *a, **kw): - return self._data.get(*a, **kw) - - def setdefault(self, *a, **kw): - return self._data.setdefault(*a, **kw) - - def __setitem__(self, key, value): - self._data[key] = value - - def __iter__(self): - return iter(self._data) - - def __len__(self): - return len(self._data) - - def iter_frames(self): - stacktraces = [] - if "stacktrace" in self: - stacktraces.append(self["stacktrace"]) - if "exception" in self: - for exception in self["exception"].get("values") or (): - if "stacktrace" in exception: - stacktraces.append(exception["stacktrace"]) - for stacktrace in stacktraces: - for frame in stacktrace.get("frames") or (): - yield frame - - -class AnnotatedValue(object): - def __init__(self, value, metadata): - self.value = value - self.metadata = metadata - - -def flatten_metadata(obj): - def inner(obj): - if isinstance(obj, Mapping): - rv = {} - meta = {} - for k, v in obj.items(): - # if we actually have "" keys in our data, throw them away. It's - # unclear how we would tell them apart from metadata - if k == "": - continue - - rv[k], meta[k] = inner(v) - if meta[k] is None: - del meta[k] - if rv[k] is None: - del rv[k] - return rv, (meta or None) - if isinstance(obj, Sequence) and not isinstance(obj, (text_type, bytes)): - rv = [] - meta = {} - for i, v in enumerate(obj): - new_v, meta[i] = inner(v) - rv.append(new_v) - if meta[i] is None: - del meta[i] - return rv, (meta or None) - if isinstance(obj, AnnotatedValue): - return obj.value, {"": obj.metadata} - return obj, None - - obj, meta = inner(obj) - if meta is not None: - obj[""] = meta - return obj - - -def strip_event(event): - old_frames = event.get("stacktrace", {}).get("frames", None) - if old_frames: - event["stacktrace"]["frames"] = [strip_frame(frame) for frame in old_frames] - - old_request_data = event.get("request", {}).get("data", None) - if old_request_data: - event["request"]["data"] = strip_databag(old_request_data) - - return event - - -def strip_frame(frame): - frame["vars"], meta = strip_databag(frame.get("vars")) - return frame, ({"vars": meta} if meta is not None else None) - - -def convert_types(obj): - if isinstance(obj, (datetime.datetime, datetime.date)): - return _datetime_to_json(obj) - if isinstance(obj, Mapping): - return {k: convert_types(v) for k, v in obj.items()} - if isinstance(obj, Sequence) and not isinstance(obj, (text_type, bytes)): - return [convert_types(v) for v in obj] - return obj - - -def strip_databag(obj, remaining_depth=20): - assert not isinstance(obj, bytes), "bytes should have been normalized before" - if remaining_depth <= 0: - return AnnotatedValue(None, {"rem": [["!dep", "x"]]}) - if isinstance(obj, text_type): - return strip_string(obj) - if isinstance(obj, Mapping): - return {k: strip_databag(v, remaining_depth - 1) for k, v in obj.items()} - if isinstance(obj, Sequence): - return [strip_databag(v, remaining_depth - 1) for v in obj] - return obj - - -def strip_string(value, assume_length=None, max_length=512): - # TODO: read max_length from config - if not value: - return value - if assume_length is None: - assume_length = len(value) - - if assume_length > max_length: - return AnnotatedValue( - value=value[: max_length - 3] + u"...", - metadata={ - "len": assume_length, - "rem": [["!len", "x", max_length - 3, max_length]], - }, - ) - return value[:max_length] diff --git a/sentry_sdk/hub.py b/sentry_sdk/hub.py index 99e5023a4a..cb762e2057 100644 --- a/sentry_sdk/hub.py +++ b/sentry_sdk/hub.py @@ -4,8 +4,7 @@ from ._compat import with_metaclass from .scope import Scope -from .utils import skip_internal_frames, ContextVar -from .event import Event +from .utils import exc_info_from_error, event_from_exception, ContextVar _local = ContextVar("sentry_current_hub") @@ -19,6 +18,12 @@ def _internal_exceptions(): Hub.current.capture_internal_exception() +def _get_client_options(): + hub = Hub.current + if hub and hub.client: + return hub.client.options + + def _should_send_default_pii(): client = Hub.current.client if not client: @@ -67,14 +72,12 @@ def __init__(self, client_or_hub=None, scope=None): hub = client_or_hub client, other_scope = hub._stack[-1] if scope is None: - hub._flush_event_processors() scope = copy.copy(other_scope) else: client = client_or_hub if scope is None: scope = Scope() self._stack = [(client, scope)] - self._pending_processors = [] def __enter__(self): return _HubManager(self) @@ -98,11 +101,9 @@ def bind_client(self, new): def capture_event(self, event): """Captures an event.""" - self._flush_event_processors() client, scope = self._stack[-1] if client is not None: - client.capture_event(event, scope) - return event.get("event_id") + return client.capture_event(event, scope) def capture_message(self, message, level=None): """Captures a message.""" @@ -110,11 +111,7 @@ def capture_message(self, message, level=None): return if level is None: level = "info" - event = Event() - event["message"] = message - if level is not None: - event["level"] = level - return self.capture_event(event) + return self.capture_event({"message": message, "level": level}) def capture_exception(self, error=None): """Captures an exception.""" @@ -122,27 +119,14 @@ def capture_exception(self, error=None): if client is None: return if error is None: - exc_type, exc_value, tb = sys.exc_info() - elif isinstance(error, tuple) and len(error) == 3: - exc_type, exc_value, tb = error + exc_info = sys.exc_info() else: - tb = getattr(error, "__traceback__", None) - if tb is not None: - exc_type = type(error) - exc_value = error - else: - exc_type, exc_value, tb = sys.exc_info() - if exc_value is not error: - tb = None - exc_value = error - exc_type = type(error) + exc_info = exc_info_from_error(error) - if tb is not None: - tb = skip_internal_frames(tb) - - event = Event() + event = event_from_exception( + exc_info, with_locals=client.options["with_locals"] + ) try: - event.set_exception(exc_type, exc_value, tb, client.options["with_locals"]) return self.capture_event(event) except Exception: self.capture_internal_exception() @@ -168,14 +152,9 @@ def add_breadcrumb(self, *args, **kwargs): while len(scope._breadcrumbs) >= client.options["max_breadcrumbs"]: scope._breadcrumbs.popleft() - def add_event_processor(self, factory): - """Registers a new event processor with the top scope.""" - self._pending_processors.append(factory) - def push_scope(self): """Pushes a new layer on the scope stack. Returns a context manager that should be used to pop the scope again.""" - self._flush_event_processors() client, scope = self._stack[-1] new_layer = (client, copy.copy(scope)) self._stack.append(new_layer) @@ -184,7 +163,6 @@ def push_scope(self): def pop_scope_unsafe(self): """Pops a scope layer from the stack. Try to use the context manager `push_scope()` instead.""" - self._pending_processors = [] rv = self._stack.pop() assert self._stack return rv @@ -195,23 +173,16 @@ def configure_scope(self, callback=None): if callback is not None: if client is not None and scope is not None: callback(scope) - else: + return + + @contextmanager + def inner(): + if client is not None and scope is not None: + yield scope + else: + yield Scope() - @contextmanager - def inner(): - if client is not None and scope is not None: - yield scope - else: - yield Scope() - - return inner() - - def _flush_event_processors(self): - rv = self._pending_processors - self._pending_processors = [] - top = self._stack[-1][1] - for factory in rv: - top._event_processors.append(factory()) + return inner() GLOBAL_HUB = Hub() diff --git a/sentry_sdk/integrations/__init__.py b/sentry_sdk/integrations/__init__.py index ef38c10907..457657ef39 100644 --- a/sentry_sdk/integrations/__init__.py +++ b/sentry_sdk/integrations/__init__.py @@ -8,17 +8,39 @@ _installed_integrations = {} +def _get_default_integrations(): + from .logging import LoggingIntegration + from .excepthook import ExcepthookIntegration + from .dedupe import DedupeIntegration + + yield LoggingIntegration + yield ExcepthookIntegration + yield DedupeIntegration + + +def setup_integrations(options): + integrations = list(options.pop("integrations", None) or ()) + default_integrations = options.pop("default_integrations") or False + + def install(): + if default_integrations: + for cls in _get_default_integrations(): + if not any(isinstance(x, cls) for x in integrations): + integrations.append(cls()) + + for integration in integrations: + integration() + + return install + + class Integration(object): identifier = None - def __init__(self, **kwargs): - """Initialize an integration.""" - raise NotImplementedError() - - def install(self, client): + def install(self): raise NotImplementedError() - def __call__(self, client): + def __call__(self): assert self.identifier with _installer_lock: if self.identifier in _installed_integrations: @@ -29,5 +51,5 @@ def __call__(self, client): ) return - self.install(client) + self.install() _installed_integrations[self.identifier] = self diff --git a/sentry_sdk/integrations/_wsgi.py b/sentry_sdk/integrations/_wsgi.py index 9523818201..803cf9308d 100644 --- a/sentry_sdk/integrations/_wsgi.py +++ b/sentry_sdk/integrations/_wsgi.py @@ -2,8 +2,12 @@ import sys import sentry_sdk -from sentry_sdk.hub import _internal_exceptions, _should_send_default_pii -from sentry_sdk.event import AnnotatedValue +from sentry_sdk.hub import ( + _internal_exceptions, + _should_send_default_pii, + _get_client_options, +) +from sentry_sdk.utils import AnnotatedValue from sentry_sdk._compat import reraise, implements_iterator @@ -40,7 +44,11 @@ class RequestExtractor(object): def __init__(self, request): self.request = request - def extract_into_event(self, event, client_options): + def extract_into_event(self, event): + client_options = _get_client_options() + if client_options is None: + return + content_length = self.content_length() request_info = event.setdefault("request", {}) request_info["url"] = self.url() @@ -48,7 +56,7 @@ def extract_into_event(self, event, client_options): if _should_send_default_pii(): request_info["cookies"] = dict(self.cookies()) - bodies = client_options.get("request_bodies") + bodies = client_options["request_bodies"] if ( bodies == "never" or (bodies == "small" and content_length > 10 ** 3) @@ -148,10 +156,8 @@ def run_wsgi_app(app, environ, start_response): hub = sentry_sdk.get_current_hub() hub.push_scope() with _internal_exceptions(): - client_options = sentry_sdk.get_current_hub().client.options - sentry_sdk.get_current_hub().add_event_processor( - lambda: _make_wsgi_event_processor(environ, client_options) - ) + with hub.configure_scope() as scope: + scope.add_event_processor(_make_wsgi_event_processor(environ)) try: rv = app(environ, start_response) @@ -202,7 +208,7 @@ def close(self): reraise(*einfo) -def _make_wsgi_event_processor(environ, client_options): +def _make_wsgi_event_processor(environ): def event_processor(event): with _internal_exceptions(): # if the code below fails halfway through we at least have some data @@ -231,4 +237,6 @@ def event_processor(event): not in ("set-cookie", "cookie", "authorization") } + return event + return event_processor diff --git a/sentry_sdk/integrations/celery.py b/sentry_sdk/integrations/celery.py index cca340daaa..d5706e6c04 100644 --- a/sentry_sdk/integrations/celery.py +++ b/sentry_sdk/integrations/celery.py @@ -3,7 +3,7 @@ from celery.signals import task_failure, task_prerun, task_postrun from celery.exceptions import SoftTimeLimitExceeded -from sentry_sdk import get_current_hub, configure_scope, capture_exception +from sentry_sdk import get_current_hub from sentry_sdk.hub import _internal_exceptions from . import Integration @@ -15,7 +15,7 @@ class CeleryIntegration(Integration): def __init__(self): pass - def install(self, client): + def install(self): task_prerun.connect(self._handle_task_prerun, weak=False) task_postrun.connect(self._handle_task_postrun, weak=False) task_failure.connect(self._process_failure_signal, weak=False) @@ -24,24 +24,25 @@ def _process_failure_signal(self, sender, task_id, einfo, **kw): if hasattr(sender, "throws") and isinstance(einfo.exception, sender.throws): return + hub = get_current_hub() if isinstance(einfo.exception, SoftTimeLimitExceeded): - with get_current_hub().push_scope(): - with configure_scope() as scope: + with hub.push_scope(): + with hub.configure_scope() as scope: scope.fingerprint = [ "celery", "SoftTimeLimitExceeded", getattr(sender, "name", sender), ] - capture_exception(einfo.exc_info) + hub.capture_exception(einfo.exc_info) else: - capture_exception(einfo.exc_info) + hub.capture_exception(einfo.exc_info) def _handle_task_prerun(self, sender, task, **kw): with _internal_exceptions(): - get_current_hub().push_scope() - - with configure_scope() as scope: + hub = get_current_hub() + hub.push_scope() + with hub.configure_scope() as scope: scope.transaction = task.name def _handle_task_postrun(self, sender, task_id, task, **kw): diff --git a/sentry_sdk/integrations/dedupe.py b/sentry_sdk/integrations/dedupe.py new file mode 100644 index 0000000000..406777e994 --- /dev/null +++ b/sentry_sdk/integrations/dedupe.py @@ -0,0 +1,33 @@ +import weakref + +from sentry_sdk.api import configure_scope +from sentry_sdk.utils import ContextVar + +from . import Integration + + +last_seen = ContextVar("last-seen") + + +class DedupeIntegration(Integration): + identifier = "dedupe" + + def __init__(self): + self._exceptions_seen = weakref.WeakKeyDictionary() + + def install(self): + with configure_scope() as scope: + + @scope.add_error_processor + def processor(event, error): + exc_info = event.get("__sentry_exc_info") + if exc_info and exc_info[1] is not None: + exc = exc_info[1] + if last_seen.get(None) is exc: + seen = True + else: + seen = False + last_seen.set(exc) + if seen: + return None + return event diff --git a/sentry_sdk/integrations/django.py b/sentry_sdk/integrations/django.py index 6030a23eac..2f53ab0e21 100644 --- a/sentry_sdk/integrations/django.py +++ b/sentry_sdk/integrations/django.py @@ -10,7 +10,7 @@ except ImportError: from django.core.urlresolvers import resolve -from sentry_sdk import get_current_hub, capture_exception +from sentry_sdk import capture_exception, configure_scope from sentry_sdk.hub import _internal_exceptions, _should_send_default_pii from ._wsgi import RequestExtractor, run_wsgi_app from . import Integration @@ -31,10 +31,7 @@ def is_authenticated(request_user): class DjangoIntegration(Integration): identifier = "django" - def __init__(self): - pass - - def install(self, client): + def install(self): # Patch in our custom middleware. from django.core.handlers.wsgi import WSGIHandler @@ -50,65 +47,45 @@ def sentry_patched_wsgi_handler(self, environ, start_response): # patch get_response, because at that point we have the Django request # object - from django.core.handlers.base import BaseHandler old_get_response = BaseHandler.get_response - make_event_processor = self._make_event_processor def sentry_patched_get_response(self, request): - weak_request = weakref.ref(request) - get_current_hub().add_event_processor( - lambda: make_event_processor(weak_request) - ) + with configure_scope() as scope: + scope.add_event_processor(_make_event_processor(weakref.ref(request))) return old_get_response(self, request) BaseHandler.get_response = sentry_patched_get_response signals.got_request_exception.connect(_got_request_exception) - def _make_event_processor(self, weak_request): - client_options = get_current_hub().client.options - def processor(event): - # if the request is gone we are fine not logging the data from - # it. This might happen if the processor is pushed away to - # another thread. - request = weak_request() - if request is None: - return +def _make_event_processor(weak_request): + def event_processor(event): + # if the request is gone we are fine not logging the data from + # it. This might happen if the processor is pushed away to + # another thread. + request = weak_request() + if request is None: + return event - if "transaction" not in event: - try: - event["transaction"] = resolve(request.path).func.__name__ - except Exception: - pass + if "transaction" not in event: + try: + event["transaction"] = resolve(request.path).func.__name__ + except Exception: + pass - with _internal_exceptions(): - DjangoRequestExtractor(request).extract_into_event( - event, client_options - ) - - if _should_send_default_pii(): - with _internal_exceptions(): - _set_user_info(request, event) + with _internal_exceptions(): + DjangoRequestExtractor(request).extract_into_event(event) + if _should_send_default_pii(): with _internal_exceptions(): - _process_frames(event) - - return processor - + _set_user_info(request, event) -def _process_frames(event): - for frame in event.iter_frames(): - if "in_app" in frame: - continue + return event - module = frame.get("module") - if not module: - continue - if module == "django" or module.startswith("django."): - frame["in_app"] = False + return event_processor def _got_request_exception(request=None, **kwargs): diff --git a/sentry_sdk/integrations/excepthook.py b/sentry_sdk/integrations/excepthook.py index e320a3b05b..49b90f09ae 100644 --- a/sentry_sdk/integrations/excepthook.py +++ b/sentry_sdk/integrations/excepthook.py @@ -9,10 +9,7 @@ class ExcepthookIntegration(Integration): identifier = "excepthook" - def __init__(self): - pass - - def install(self, client): + def install(self): if hasattr(sys, "ps1"): # Disable the excepthook for interactive Python shells, otherwise # every typo gets sent to Sentry. diff --git a/sentry_sdk/integrations/flask.py b/sentry_sdk/integrations/flask.py index 7a63e6835d..a3d578e09e 100644 --- a/sentry_sdk/integrations/flask.py +++ b/sentry_sdk/integrations/flask.py @@ -2,7 +2,7 @@ import weakref -from sentry_sdk import capture_exception, get_current_hub +from sentry_sdk import capture_exception, get_current_hub, configure_scope from sentry_sdk.hub import _internal_exceptions, _should_send_default_pii from ._wsgi import RequestExtractor, run_wsgi_app from . import Integration @@ -24,10 +24,7 @@ class FlaskIntegration(Integration): identifier = "flask" - def __init__(self): - pass - - def install(self, client): + def install(self): appcontext_pushed.connect(_push_appctx) appcontext_tearing_down.connect(_pop_appctx) request_started.connect(_request_started) @@ -46,8 +43,10 @@ def sentry_patched_wsgi_app(self, environ, start_response): def _push_appctx(*args, **kwargs): # always want to push scope regardless of whether WSGI app might already # have (not the case for CLI for example) - get_current_hub().push_scope() - get_current_hub().add_event_processor(_make_user_event_processor) + hub = get_current_hub() + hub.push_scope() + with hub.configure_scope() as scope: + scope.add_event_processor(event_processor) def _pop_appctx(*args, **kwargs): @@ -57,29 +56,28 @@ def _pop_appctx(*args, **kwargs): def _request_started(sender, **kwargs): weak_request = weakref.ref(_request_ctx_stack.top.request) app = _app_ctx_stack.top.app - get_current_hub().add_event_processor( - lambda: _make_request_event_processor(app, weak_request) - ) + with configure_scope() as scope: + scope.add_event_processor(_make_request_event_processor(app, weak_request)) -def _capture_exception(sender, exception, **kwargs): - capture_exception(exception) +def event_processor(event): + request = getattr(_request_ctx_stack.top, "request", None) + + if request: + if "transaction" not in event: + try: + event["transaction"] = request.url_rule.endpoint + except Exception: + pass + with _internal_exceptions(): + FlaskRequestExtractor(request).extract_into_event(event) -def _process_frames(app, event): - for frame in event.iter_frames(): - if "in_app" in frame: - continue - module = frame.get("module") - if not module: - continue + if _should_send_default_pii(): + with _internal_exceptions(): + _add_user_to_event(event) - if module == "flask" or module.startswith("flask."): - frame["in_app"] = False - elif app and ( - module.startswith("%s." % app.import_name) or module == app.import_name - ): - frame["in_app"] = True + return event class FlaskRequestExtractor(RequestExtractor): @@ -105,13 +103,12 @@ def size_of_file(self, file): return file.content_length -def _make_request_event_processor(app, weak_request): - client_options = get_current_hub().client.options +def _capture_exception(sender, exception, **kwargs): + capture_exception(exception) - def inner(event): - with _internal_exceptions(): - _process_frames(app, event) +def _make_request_event_processor(app, weak_request): + def inner(event): request = weak_request() # if the request is gone we are fine not logging the data from @@ -127,35 +124,34 @@ def inner(event): pass with _internal_exceptions(): - FlaskRequestExtractor(request).extract_into_event(event, client_options) + FlaskRequestExtractor(request).extract_into_event(event) + + return event return inner -def _make_user_event_processor(): - def inner(event): - if flask_login is None or not _should_send_default_pii(): - return +def _add_user_to_event(event): + if flask_login is None: + return - user = flask_login.current_user - if user is None: - return + user = flask_login.current_user + if user is None: + return - with _internal_exceptions(): - # Access this object as late as possible as accessing the user - # is relatively costly - - user_info = event.setdefault("user", {}) - - if user_info.get("id", None) is None: - try: - user_info["id"] = user.get_id() - # TODO: more configurable user attrs here - except AttributeError: - # might happen if: - # - flask_login could not be imported - # - flask_login is not configured - # - no user is logged in - pass + with _internal_exceptions(): + # Access this object as late as possible as accessing the user + # is relatively costly - return inner + user_info = event.setdefault("user", {}) + + if user_info.get("id", None) is None: + try: + user_info["id"] = user.get_id() + # TODO: more configurable user attrs here + except AttributeError: + # might happen if: + # - flask_login could not be imported + # - flask_login is not configured + # - no user is logged in + pass diff --git a/sentry_sdk/integrations/logging.py b/sentry_sdk/integrations/logging.py index 28660a6b19..db48689cb8 100644 --- a/sentry_sdk/integrations/logging.py +++ b/sentry_sdk/integrations/logging.py @@ -5,8 +5,7 @@ import datetime from sentry_sdk import get_current_hub, capture_event, add_breadcrumb -from sentry_sdk.utils import to_string, skip_internal_frames -from sentry_sdk.event import Event +from sentry_sdk.utils import to_string, event_from_exception from sentry_sdk.hub import _internal_exceptions from . import Integration @@ -18,7 +17,7 @@ class LoggingIntegration(Integration): def __init__(self, level=logging.INFO, event_level=None): self._handler = SentryHandler(level=level, event_level=event_level) - def install(self, client): + def install(self): handler = self._handler old_callhandlers = logging.Logger.callHandlers @@ -60,18 +59,18 @@ def _emit(self, record): return if self._should_create_event(record): - with _internal_exceptions(): - event = Event() + hub = get_current_hub() + if hub.client is None: + return + with _internal_exceptions(): # exc_info might be None or (None, None, None) if record.exc_info and all(record.exc_info): - exc_type, exc_value, tb = record.exc_info - event.set_exception( - exc_type, - exc_value, - skip_internal_frames(tb), - get_current_hub().client.options["with_locals"], + event = event_from_exception( + record.exc_info, with_locals=hub.client.options["with_locals"] ) + else: + event = {} event["level"] = self._logging_to_event_level(record.levelname) event["logger"] = record.name diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index d9656c6037..50de0fc698 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1,10 +1,11 @@ class Scope(object): - __slots__ = ["_data", "_breadcrumbs", "_event_processors"] + __slots__ = ["_data", "_breadcrumbs", "_event_processors", "_error_processors"] def __init__(self): self._data = {} self._breadcrumbs = [] self._event_processors = [] + self._error_processors = [] def _set_fingerprint(self, value): self._data["fingerprint"] = value @@ -53,6 +54,12 @@ def clear(self): del self._breadcrumbs[:] del self._event_processors[:] + def add_event_processor(self, func): + self._event_processors.append(func) + + def add_error_processor(self, func): + self._error_processors.append(func) + def apply_to_event(self, event): event.setdefault("breadcrumbs", []).extend(self._breadcrumbs) if event.get("user") is None and "user" in self._data: @@ -77,12 +84,24 @@ def apply_to_event(self, event): if contexts: event.setdefault("contexts", {}).update(contexts) + exc_info = event.get("__sentry_exc_info", None) + if exc_info is not None: + for processor in self._error_processors: + event = processor(event, exc_info) + if event is None: + return + for processor in self._event_processors: - processor(event) + event = processor(event) + if event is None: + return None + + return event def __copy__(self): rv = object.__new__(self.__class__) rv._data = dict(self._data) rv._breadcrumbs = list(self._breadcrumbs) rv._event_processors = list(self._event_processors) + rv._error_processors = list(self._error_processors) return rv diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index d0b6202d8a..35bc34840d 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -1,4 +1,5 @@ import os +import sys import linecache from datetime import datetime @@ -297,7 +298,8 @@ def single_exception_from_error_tuple(exc_type, exc_value, tb, with_locals=True) } -def exceptions_from_error_tuple(exc_type, exc_value, tb, with_locals=True): +def exceptions_from_error_tuple(exc_info, with_locals=True): + exc_type, exc_value, tb = exc_info rv = [] while exc_type is not None: rv.append( @@ -312,11 +314,6 @@ def exceptions_from_error_tuple(exc_type, exc_value, tb, with_locals=True): return rv -class SkipEvent(Exception): - """Risen from an event processor to indicate that the event should be - ignored and not be reported.""" - - def to_string(value): try: return text_type(value) @@ -324,6 +321,188 @@ def to_string(value): return repr(value)[1:-1] +def iter_event_frames(event): + stacktraces = [] + if "stacktrace" in event: + stacktraces.append(event["stacktrace"]) + if "exception" in event: + for exception in event["exception"].get("values") or (): + if "stacktrace" in exception: + stacktraces.append(exception["stacktrace"]) + for stacktrace in stacktraces: + for frame in stacktrace.get("frames") or (): + yield frame + + +def handle_in_app(event, in_app_exclude=None, in_app_include=None): + any_in_app = False + for frame in iter_event_frames(event): + in_app = frame.get("in_app") + if in_app is not None: + if in_app: + any_in_app = True + continue + + module = frame.get("module") + if not module: + continue + + if _module_in_set(module, in_app_exclude): + frame["in_app"] = False + if _module_in_set(module, in_app_include): + frame["in_app"] = True + any_in_app = True + + if not any_in_app: + for frame in iter_event_frames(event): + frame["in_app"] = True + + return event + + +def exc_info_from_error(error): + if isinstance(error, tuple) and len(error) == 3: + exc_type, exc_value, tb = error + else: + tb = getattr(error, "__traceback__", None) + if tb is not None: + exc_type = type(error) + exc_value = error + else: + exc_type, exc_value, tb = sys.exc_info() + if exc_value is not error: + tb = None + exc_value = error + exc_type = type(error) + + if tb is not None: + tb = skip_internal_frames(tb) + + return exc_type, exc_value, tb + + +def event_from_exception(exc_info, with_locals=False, processors=None): + exc_info = exc_info_from_error(exc_info) + return { + "level": "error", + "exception": {"values": exceptions_from_error_tuple(exc_info, with_locals)}, + "__sentry_exc_info": exc_info, + } + + +def _module_in_set(name, set): + if not set: + return False + for item in set or (): + if item == name or name.startswith(item + "."): + return True + return False + + +class AnnotatedValue(object): + def __init__(self, value, metadata): + self.value = value + self.metadata = metadata + + +def flatten_metadata(obj): + def inner(obj): + if isinstance(obj, Mapping): + rv = {} + meta = {} + for k, v in obj.items(): + # if we actually have "" keys in our data, throw them away. It's + # unclear how we would tell them apart from metadata + if k == "": + continue + + rv[k], meta[k] = inner(v) + if meta[k] is None: + del meta[k] + if rv[k] is None: + del rv[k] + return rv, (meta or None) + if isinstance(obj, Sequence) and not isinstance(obj, (text_type, bytes)): + rv = [] + meta = {} + for i, v in enumerate(obj): + new_v, meta[i] = inner(v) + rv.append(new_v) + if meta[i] is None: + del meta[i] + return rv, (meta or None) + if isinstance(obj, AnnotatedValue): + return obj.value, {"": obj.metadata} + return obj, None + + obj, meta = inner(obj) + if meta is not None: + obj[""] = meta + return obj + + +def strip_event(event): + old_frames = event.get("stacktrace", {}).get("frames", None) + if old_frames: + event["stacktrace"]["frames"] = [strip_frame(frame) for frame in old_frames] + + old_request_data = event.get("request", {}).get("data", None) + if old_request_data: + event["request"]["data"] = strip_databag(old_request_data) + + return event + + +def strip_frame(frame): + frame["vars"], meta = strip_databag(frame.get("vars")) + return frame, ({"vars": meta} if meta is not None else None) + + +def pop_hidden_keys(event): + event.pop("__sentry_exc_info", None) + + +def convert_types(obj): + if isinstance(obj, datetime): + return obj.strftime("%Y-%m-%dT%H:%M:%SZ") + if isinstance(obj, Mapping): + return {k: convert_types(v) for k, v in obj.items()} + if isinstance(obj, Sequence) and not isinstance(obj, (text_type, bytes)): + return [convert_types(v) for v in obj] + return obj + + +def strip_databag(obj, remaining_depth=20): + assert not isinstance(obj, bytes), "bytes should have been normalized before" + if remaining_depth <= 0: + return AnnotatedValue(None, {"rem": [["!dep", "x"]]}) + if isinstance(obj, text_type): + return strip_string(obj) + if isinstance(obj, Mapping): + return {k: strip_databag(v, remaining_depth - 1) for k, v in obj.items()} + if isinstance(obj, Sequence): + return [strip_databag(v, remaining_depth - 1) for v in obj] + return obj + + +def strip_string(value, assume_length=None, max_length=512): + # TODO: read max_length from config + if not value: + return value + if assume_length is None: + assume_length = len(value) + + if assume_length > max_length: + return AnnotatedValue( + value=value[: max_length - 3] + u"...", + metadata={ + "len": assume_length, + "rem": [["!len", "x", max_length - 3, max_length]], + }, + ) + return value[:max_length] + + try: from contextvars import ContextVar except ImportError: diff --git a/tests/conftest.py b/tests/conftest.py index 800b971f88..c49ffc01ac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -79,9 +79,8 @@ def inner(event): @pytest.fixture def sentry_init(monkeypatch_test_transport, assert_semaphore_acceptance): def inner(*a, **kw): - client = sentry_sdk.Client(*a, **kw) - monkeypatch_test_transport(client) - sentry_sdk.Hub.current.bind_client(client) + sentry_sdk.api._init_on_current(*a, **kw) + monkeypatch_test_transport(sentry_sdk.Hub.current.client) return inner diff --git a/tests/integrations/django/myapp/settings.py b/tests/integrations/django/myapp/settings.py index 7925a489df..632fd27d41 100644 --- a/tests/integrations/django/myapp/settings.py +++ b/tests/integrations/django/myapp/settings.py @@ -130,6 +130,6 @@ def process_response(self, request, response): STATIC_URL = "/static/" -sentry_sdk.get_current_hub().bind_client( - sentry_sdk.Client(integrations=[DjangoIntegration()], send_default_pii=True) +sentry_sdk.api._init_on_current( + integrations=[DjangoIntegration()], send_default_pii=True ) diff --git a/tests/test_client.py b/tests/test_client.py index 35a065bad0..b797cabbda 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,8 +8,7 @@ from sentry_sdk import Hub, Client, configure_scope, capture_message, add_breadcrumb from sentry_sdk.hub import HubMeta from sentry_sdk.transport import Transport -from sentry_sdk.utils import Dsn -from sentry_sdk.event import Event +from sentry_sdk.utils import Dsn, event_from_exception class EventCaptured(Exception): @@ -32,10 +31,6 @@ def test_transport_option(monkeypatch): dsn2 = "https://bar@sentry.io/124" assert str(Client(dsn=dsn).dsn) == dsn assert Client().dsn is None - with pytest.raises(ValueError): - Client(dsn, transport=Transport(Dsn(dsn2))) - with pytest.raises(ValueError): - Client(dsn, transport=Transport(Dsn(dsn))) assert str(Client(transport=Transport(Dsn(dsn2))).dsn) == dsn2 monkeypatch.setenv("SENTRY_DSN", dsn) @@ -44,20 +39,21 @@ def test_transport_option(monkeypatch): def test_ignore_errors(): def e(exc_type): - rv = Event() - rv._exc_value = exc_type() - return rv + return event_from_exception(exc_type()) - c = Client(ignore_errors=[Exception], transport=_TestTransport()) - c.capture_event(e(Exception)) - c.capture_event(e(ValueError)) - pytest.raises(EventCaptured, lambda: c.capture_event(e(BaseException))) + class MyDivisionError(ZeroDivisionError): + pass + + c = Client(ignore_errors=[ZeroDivisionError], transport=_TestTransport()) + c.capture_event(e(ZeroDivisionError)) + c.capture_event(e(MyDivisionError)) + pytest.raises(EventCaptured, lambda: c.capture_event(e(ValueError))) def test_capture_event_works(): c = Client(transport=_TestTransport()) pytest.raises(EventCaptured, lambda: c.capture_event({})) - pytest.raises(EventCaptured, lambda: c.capture_event(Event())) + pytest.raises(EventCaptured, lambda: c.capture_event({})) @pytest.mark.parametrize("num_messages", [10, 20]) diff --git a/tests/test_event.py b/tests/test_event.py index 8116bd160a..7ca4b0e959 100644 --- a/tests/test_event.py +++ b/tests/test_event.py @@ -1,4 +1,4 @@ -from sentry_sdk.event import AnnotatedValue, flatten_metadata, strip_databag +from sentry_sdk.utils import AnnotatedValue, flatten_metadata, strip_databag def test_flatten_metadata(): diff --git a/tox.ini b/tox.ini index 7b36c12110..25b68398e1 100644 --- a/tox.ini +++ b/tox.ini @@ -67,7 +67,7 @@ basepython = pypy: pypy commands = - py.test {env:TESTPATH} {posargs} + py.test --tb=short {env:TESTPATH} {posargs} [testenv:linters] commands =