diff --git a/docs-requirements.txt b/docs-requirements.txt index 8e52786424..78b98c5047 100644 --- a/docs-requirements.txt +++ b/docs-requirements.txt @@ -1,3 +1,4 @@ sphinx==2.3.1 sphinx-rtd-theme sphinx-autodoc-typehints[type_comments]>=1.8.0 +typing-extensions diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index e83c8a02a0..200274fc1b 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -106,6 +106,9 @@ def _init_impl(self): self.integrations = setup_integrations( self.options["integrations"], with_defaults=self.options["default_integrations"], + with_auto_enabling_integrations=self.options["_experiments"].get( + "auto_enabling_integrations", False + ), ) finally: _client_init_debug.set(old_debug) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 06591004a4..30d140ffb1 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -9,12 +9,27 @@ from typing import Dict from typing import Any from typing import Sequence + from typing_extensions import TypedDict from sentry_sdk.transport import Transport from sentry_sdk.integrations import Integration from sentry_sdk._types import Event, EventProcessor, BreadcrumbProcessor + # Experiments are feature flags to enable and disable certain unstable SDK + # functionality. Changing them from the defaults (`None`) in production + # code is highly discouraged. They are not subject to any stability + # guarantees such as the ones from semantic versioning. + Experiments = TypedDict( + "Experiments", + { + "max_spans": Optional[int], + "record_sql_params": Optional[bool], + "auto_enabling_integrations": Optional[bool], + }, + total=False, + ) + # This type exists to trick mypy and PyCharm into thinking `init` and `Client` # take these arguments (even though they take opaque **kwargs) @@ -49,7 +64,7 @@ def __init__( # DO NOT ENABLE THIS RIGHT NOW UNLESS YOU WANT TO EXCEED YOUR EVENT QUOTA IMMEDIATELY traces_sample_rate=0.0, # type: float traceparent_v2=False, # type: bool - _experiments={}, # type: Dict[str, Any] # noqa: B006 + _experiments={}, # type: Experiments # noqa: B006 ): # type: (...) -> None pass diff --git a/sentry_sdk/integrations/__init__.py b/sentry_sdk/integrations/__init__.py index 18c8069e2f..f264bc4855 100644 --- a/sentry_sdk/integrations/__init__.py +++ b/sentry_sdk/integrations/__init__.py @@ -9,53 +9,85 @@ from sentry_sdk._types import MYPY if MYPY: - from typing import Iterator + from typing import Callable from typing import Dict + from typing import Iterator from typing import List from typing import Set + from typing import Tuple from typing import Type - from typing import Callable _installer_lock = Lock() _installed_integrations = set() # type: Set[str] -def _generate_default_integrations_iterator(*import_strings): - # type: (*str) -> Callable[[], Iterator[Type[Integration]]] - def iter_default_integrations(): - # type: () -> Iterator[Type[Integration]] +def _generate_default_integrations_iterator(integrations, auto_enabling_integrations): + # type: (Tuple[str, ...], Tuple[str, ...]) -> Callable[[bool], Iterator[Type[Integration]]] + + def iter_default_integrations(with_auto_enabling_integrations): + # type: (bool) -> Iterator[Type[Integration]] """Returns an iterator of the default integration classes: """ from importlib import import_module - for import_string in import_strings: - module, cls = import_string.rsplit(".", 1) - yield getattr(import_module(module), cls) + if with_auto_enabling_integrations: + all_import_strings = integrations + auto_enabling_integrations + else: + all_import_strings = integrations + + for import_string in all_import_strings: + try: + module, cls = import_string.rsplit(".", 1) + yield getattr(import_module(module), cls) + except (DidNotEnable, SyntaxError) as e: + logger.debug( + "Did not import default integration %s: %s", import_string, e + ) if isinstance(iter_default_integrations.__doc__, str): - for import_string in import_strings: + for import_string in integrations: iter_default_integrations.__doc__ += "\n- `{}`".format(import_string) return iter_default_integrations +_AUTO_ENABLING_INTEGRATIONS = ( + "sentry_sdk.integrations.django.DjangoIntegration", + "sentry_sdk.integrations.flask.FlaskIntegration", + "sentry_sdk.integrations.bottle.BottleIntegration", + "sentry_sdk.integrations.falcon.FalconIntegration", + "sentry_sdk.integrations.sanic.SanicIntegration", + "sentry_sdk.integrations.celery.CeleryIntegration", + "sentry_sdk.integrations.rq.RqIntegration", + "sentry_sdk.integrations.aiohttp.AioHttpIntegration", + "sentry_sdk.integrations.tornado.TornadoIntegration", + "sentry_sdk.integrations.sqlalchemy.SqlalchemyIntegration", +) + + iter_default_integrations = _generate_default_integrations_iterator( - "sentry_sdk.integrations.logging.LoggingIntegration", - "sentry_sdk.integrations.stdlib.StdlibIntegration", - "sentry_sdk.integrations.excepthook.ExcepthookIntegration", - "sentry_sdk.integrations.dedupe.DedupeIntegration", - "sentry_sdk.integrations.atexit.AtexitIntegration", - "sentry_sdk.integrations.modules.ModulesIntegration", - "sentry_sdk.integrations.argv.ArgvIntegration", - "sentry_sdk.integrations.threading.ThreadingIntegration", + integrations=( + # stdlib/base runtime integrations + "sentry_sdk.integrations.logging.LoggingIntegration", + "sentry_sdk.integrations.stdlib.StdlibIntegration", + "sentry_sdk.integrations.excepthook.ExcepthookIntegration", + "sentry_sdk.integrations.dedupe.DedupeIntegration", + "sentry_sdk.integrations.atexit.AtexitIntegration", + "sentry_sdk.integrations.modules.ModulesIntegration", + "sentry_sdk.integrations.argv.ArgvIntegration", + "sentry_sdk.integrations.threading.ThreadingIntegration", + ), + auto_enabling_integrations=_AUTO_ENABLING_INTEGRATIONS, ) del _generate_default_integrations_iterator -def setup_integrations(integrations, with_defaults=True): - # type: (List[Integration], bool) -> Dict[str, Integration] +def setup_integrations( + integrations, with_defaults=True, with_auto_enabling_integrations=False +): + # type: (List[Integration], bool, bool) -> Dict[str, Integration] """Given a list of integration instances this installs them all. When `with_defaults` is set to `True` then all default integrations are added unless they were already provided before. @@ -66,11 +98,17 @@ def setup_integrations(integrations, with_defaults=True): logger.debug("Setting up integrations (with default = %s)", with_defaults) + # Integrations that are not explicitly set up by the user. + used_as_default_integration = set() + if with_defaults: - for integration_cls in iter_default_integrations(): + for integration_cls in iter_default_integrations( + with_auto_enabling_integrations + ): if integration_cls.identifier not in integrations: instance = integration_cls() integrations[instance.identifier] = instance + used_as_default_integration.add(instance.identifier) for identifier, integration in iteritems(integrations): with _installer_lock: @@ -90,6 +128,14 @@ def setup_integrations(integrations, with_defaults=True): integration.install() else: raise + except DidNotEnable as e: + if identifier not in used_as_default_integration: + raise + + logger.debug( + "Did not enable default integration %s: %s", identifier, e + ) + _installed_integrations.add(identifier) for identifier in integrations: @@ -98,6 +144,16 @@ def setup_integrations(integrations, with_defaults=True): return integrations +class DidNotEnable(Exception): + """ + The integration could not be enabled due to a trivial user error like + `flask` not being installed for the `FlaskIntegration`. + + This exception is silently swallowed for default integrations, but reraised + for explicitly enabled integrations. + """ + + class Integration(object): """Baseclass for all integrations. diff --git a/sentry_sdk/integrations/aiohttp.py b/sentry_sdk/integrations/aiohttp.py index 20b1a7145c..02c76df7ef 100644 --- a/sentry_sdk/integrations/aiohttp.py +++ b/sentry_sdk/integrations/aiohttp.py @@ -3,7 +3,7 @@ from sentry_sdk._compat import reraise from sentry_sdk.hub import Hub -from sentry_sdk.integrations import Integration +from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.integrations._wsgi_common import ( _filter_headers, @@ -18,8 +18,13 @@ AnnotatedValue, ) -import asyncio -from aiohttp.web import Application, HTTPException, UrlDispatcher +try: + import asyncio + + from aiohttp import __version__ as AIOHTTP_VERSION + from aiohttp.web import Application, HTTPException, UrlDispatcher +except ImportError: + raise DidNotEnable("AIOHTTP not installed") from sentry_sdk._types import MYPY @@ -43,6 +48,15 @@ class AioHttpIntegration(Integration): @staticmethod def setup_once(): # type: () -> None + + try: + version = tuple(map(int, AIOHTTP_VERSION.split("."))) + except (TypeError, ValueError): + raise DidNotEnable("AIOHTTP version unparseable: {}".format(version)) + + if version < (3, 4): + raise DidNotEnable("AIOHTTP 3.4 or newer required.") + if not HAS_REAL_CONTEXTVARS: # We better have contextvars or we're going to leak state between # requests. diff --git a/sentry_sdk/integrations/bottle.py b/sentry_sdk/integrations/bottle.py index 93ca96ea34..8dab3757ea 100644 --- a/sentry_sdk/integrations/bottle.py +++ b/sentry_sdk/integrations/bottle.py @@ -6,7 +6,7 @@ event_from_exception, transaction_from_function, ) -from sentry_sdk.integrations import Integration +from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware from sentry_sdk.integrations._wsgi_common import RequestExtractor @@ -22,7 +22,16 @@ from sentry_sdk._types import EventProcessor -from bottle import Bottle, Route, request as bottle_request, HTTPResponse +try: + from bottle import ( + Bottle, + Route, + request as bottle_request, + HTTPResponse, + __version__ as BOTTLE_VERSION, + ) +except ImportError: + raise DidNotEnable("Bottle not installed") class BottleIntegration(Integration): @@ -32,6 +41,7 @@ class BottleIntegration(Integration): def __init__(self, transaction_style="endpoint"): # type: (str) -> None + TRANSACTION_STYLE_VALUES = ("endpoint", "url") if transaction_style not in TRANSACTION_STYLE_VALUES: raise ValueError( @@ -44,6 +54,14 @@ def __init__(self, transaction_style="endpoint"): def setup_once(): # type: () -> None + try: + version = tuple(map(int, BOTTLE_VERSION.split("."))) + except (TypeError, ValueError): + raise DidNotEnable("Unparseable Bottle version: {}".format(version)) + + if version < (0, 12): + raise DidNotEnable("Bottle 0.12 or newer required.") + # monkey patch method Bottle.__call__ old_app = Bottle.__call__ diff --git a/sentry_sdk/integrations/celery.py b/sentry_sdk/integrations/celery.py index 38c2452618..9b58796173 100644 --- a/sentry_sdk/integrations/celery.py +++ b/sentry_sdk/integrations/celery.py @@ -3,18 +3,11 @@ import functools import sys -from celery.exceptions import ( # type: ignore - SoftTimeLimitExceeded, - Retry, - Ignore, - Reject, -) - from sentry_sdk.hub import Hub from sentry_sdk.utils import capture_internal_exceptions, event_from_exception from sentry_sdk.tracing import Span from sentry_sdk._compat import reraise -from sentry_sdk.integrations import Integration +from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk._types import MYPY @@ -29,6 +22,18 @@ F = TypeVar("F", bound=Callable[..., Any]) +try: + from celery import VERSION as CELERY_VERSION # type: ignore + from celery.exceptions import ( # type: ignore + SoftTimeLimitExceeded, + Retry, + Ignore, + Reject, + ) +except ImportError: + raise DidNotEnable("Celery not installed") + + CELERY_CONTROL_FLOW_EXCEPTIONS = (Retry, Ignore, Reject) @@ -42,6 +47,9 @@ def __init__(self, propagate_traces=True): @staticmethod def setup_once(): # type: () -> None + if CELERY_VERSION < (3,): + raise DidNotEnable("Celery 3 or newer required.") + import celery.app.trace as trace # type: ignore old_build_tracer = trace.build_tracer diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py index 698516e6b3..ab252cb680 100644 --- a/sentry_sdk/integrations/django/__init__.py +++ b/sentry_sdk/integrations/django/__init__.py @@ -5,11 +5,40 @@ import threading import weakref -from django import VERSION as DJANGO_VERSION -from django.core import signals - from sentry_sdk._types import MYPY -from sentry_sdk.utils import HAS_REAL_CONTEXTVARS, logger +from sentry_sdk.hub import Hub, _should_send_default_pii +from sentry_sdk.scope import add_global_event_processor +from sentry_sdk.serializer import add_global_repr_processor +from sentry_sdk.tracing import record_sql_queries +from sentry_sdk.utils import ( + HAS_REAL_CONTEXTVARS, + logger, + capture_internal_exceptions, + event_from_exception, + transaction_from_function, + walk_exception_chain, +) +from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations.logging import ignore_logger +from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware +from sentry_sdk.integrations._wsgi_common import RequestExtractor + +try: + from django import VERSION as DJANGO_VERSION + from django.core import signals + + try: + from django.urls import resolve + except ImportError: + from django.core.urlresolvers import resolve +except ImportError: + raise DidNotEnable("Django not installed") + + +from sentry_sdk.integrations.django.transactions import LEGACY_RESOLVER +from sentry_sdk.integrations.django.templates import get_template_frame_from_exception +from sentry_sdk.integrations.django.middleware import patch_django_middlewares + if MYPY: from typing import Any @@ -28,31 +57,6 @@ from sentry_sdk._types import Event, Hint, EventProcessor, NotImplementedType -try: - from django.urls import resolve -except ImportError: - from django.core.urlresolvers import resolve - -from sentry_sdk import Hub -from sentry_sdk.hub import _should_send_default_pii -from sentry_sdk.scope import add_global_event_processor -from sentry_sdk.serializer import add_global_repr_processor -from sentry_sdk.tracing import record_sql_queries -from sentry_sdk.utils import ( - capture_internal_exceptions, - event_from_exception, - transaction_from_function, - walk_exception_chain, -) -from sentry_sdk.integrations import Integration -from sentry_sdk.integrations.logging import ignore_logger -from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware -from sentry_sdk.integrations._wsgi_common import RequestExtractor -from sentry_sdk.integrations.django.transactions import LEGACY_RESOLVER -from sentry_sdk.integrations.django.templates import get_template_frame_from_exception -from sentry_sdk.integrations.django.middleware import patch_django_middlewares - - if DJANGO_VERSION < (1, 10): def is_authenticated(request_user): @@ -87,6 +91,10 @@ def __init__(self, transaction_style="url", middleware_spans=True): @staticmethod def setup_once(): # type: () -> None + + if DJANGO_VERSION < (1, 6): + raise DidNotEnable("Django 1.6 or newer is required.") + install_sql_hook() # Patch in our custom middleware. diff --git a/sentry_sdk/integrations/falcon.py b/sentry_sdk/integrations/falcon.py index bf644b99c4..07f4098ef6 100644 --- a/sentry_sdk/integrations/falcon.py +++ b/sentry_sdk/integrations/falcon.py @@ -1,9 +1,7 @@ from __future__ import absolute_import -import falcon # type: ignore -import falcon.api_helpers # type: ignore from sentry_sdk.hub import Hub -from sentry_sdk.integrations import Integration +from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.integrations._wsgi_common import RequestExtractor from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware from sentry_sdk.utils import capture_internal_exceptions, event_from_exception @@ -17,6 +15,14 @@ from sentry_sdk._types import EventProcessor +try: + import falcon # type: ignore + import falcon.api_helpers # type: ignore + + from falcon import __version__ as FALCON_VERSION +except ImportError: + raise DidNotEnable("Falcon not installed") + class FalconRequestExtractor(RequestExtractor): def env(self): @@ -93,6 +99,14 @@ def __init__(self, transaction_style="uri_template"): @staticmethod def setup_once(): # type: () -> None + try: + version = tuple(map(int, FALCON_VERSION.split("."))) + except (ValueError, TypeError): + raise DidNotEnable("Unparseable Falcon version: {}".format(FALCON_VERSION)) + + if version < (1, 4): + raise DidNotEnable("Falcon 1.4 or newer required.") + _patch_wsgi_app() _patch_handle_exception() _patch_prepare_middleware() diff --git a/sentry_sdk/integrations/flask.py b/sentry_sdk/integrations/flask.py index 8f2612eba2..6031c1b621 100644 --- a/sentry_sdk/integrations/flask.py +++ b/sentry_sdk/integrations/flask.py @@ -4,7 +4,7 @@ from sentry_sdk.hub import Hub, _should_send_default_pii from sentry_sdk.utils import capture_internal_exceptions, event_from_exception -from sentry_sdk.integrations import Integration +from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware from sentry_sdk.integrations._wsgi_common import RequestExtractor @@ -22,18 +22,28 @@ from sentry_sdk._types import EventProcessor + try: import flask_login # type: ignore except ImportError: flask_login = None -from flask import Request, Flask, _request_ctx_stack, _app_ctx_stack # type: ignore -from flask.signals import ( - appcontext_pushed, - appcontext_tearing_down, - got_request_exception, - request_started, -) +try: + from flask import ( # type: ignore + Request, + Flask, + _request_ctx_stack, + _app_ctx_stack, + __version__ as FLASK_VERSION, + ) + from flask.signals import ( + appcontext_pushed, + appcontext_tearing_down, + got_request_exception, + request_started, + ) +except ImportError: + raise DidNotEnable("Flask is not installed") class FlaskIntegration(Integration): @@ -54,6 +64,14 @@ def __init__(self, transaction_style="endpoint"): @staticmethod def setup_once(): # type: () -> None + try: + version = tuple(map(int, FLASK_VERSION.split(".")[:3])) + except (ValueError, TypeError): + raise DidNotEnable("Unparseable Flask version: {}".format(FLASK_VERSION)) + + if version < (0, 11): + raise DidNotEnable("Flask 0.11 or newer is required.") + appcontext_pushed.connect(_push_appctx) appcontext_tearing_down.connect(_pop_appctx) request_started.connect(_request_started) diff --git a/sentry_sdk/integrations/rq.py b/sentry_sdk/integrations/rq.py index f34afeb93e..fbe8cdda3d 100644 --- a/sentry_sdk/integrations/rq.py +++ b/sentry_sdk/integrations/rq.py @@ -3,13 +3,18 @@ import weakref from sentry_sdk.hub import Hub -from sentry_sdk.integrations import Integration +from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.tracing import Span from sentry_sdk.utils import capture_internal_exceptions, event_from_exception -from rq.timeouts import JobTimeoutException -from rq.worker import Worker -from rq.queue import Queue + +try: + from rq.version import VERSION as RQ_VERSION + from rq.timeouts import JobTimeoutException + from rq.worker import Worker + from rq.queue import Queue +except ImportError: + raise DidNotEnable("RQ not installed") from sentry_sdk._types import MYPY @@ -31,6 +36,14 @@ class RqIntegration(Integration): def setup_once(): # type: () -> None + try: + version = tuple(map(int, RQ_VERSION.split(".")[:3])) + except (ValueError, TypeError): + raise DidNotEnable("Unparseable RQ version: {}".format(RQ_VERSION)) + + if version < (0, 6): + raise DidNotEnable("RQ 0.6 or newer is required.") + old_perform_job = Worker.perform_job def sentry_patched_perform_job(self, job, *args, **kwargs): diff --git a/sentry_sdk/integrations/sanic.py b/sentry_sdk/integrations/sanic.py index 301685443e..e8fdca422a 100644 --- a/sentry_sdk/integrations/sanic.py +++ b/sentry_sdk/integrations/sanic.py @@ -9,15 +9,10 @@ event_from_exception, HAS_REAL_CONTEXTVARS, ) -from sentry_sdk.integrations import Integration +from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.integrations._wsgi_common import RequestExtractor, _filter_headers from sentry_sdk.integrations.logging import ignore_logger -from sanic import Sanic, __version__ as VERSION -from sanic.exceptions import SanicException -from sanic.router import Router -from sanic.handlers import ErrorHandler - from sentry_sdk._types import MYPY if MYPY: @@ -32,6 +27,14 @@ from sentry_sdk._types import Event, EventProcessor, Hint +try: + from sanic import Sanic, __version__ as SANIC_VERSION + from sanic.exceptions import SanicException + from sanic.router import Router + from sanic.handlers import ErrorHandler +except ImportError: + raise DidNotEnable("Sanic not installed") + class SanicIntegration(Integration): identifier = "sanic" @@ -39,15 +42,23 @@ class SanicIntegration(Integration): @staticmethod def setup_once(): # type: () -> None + try: + version = tuple(map(int, SANIC_VERSION.split("."))) + except (TypeError, ValueError): + raise DidNotEnable("Unparseable Sanic version: {}".format(SANIC_VERSION)) + + if version < (0, 8): + raise DidNotEnable("Sanic 0.8 or newer required.") + if not HAS_REAL_CONTEXTVARS: # We better have contextvars or we're going to leak state between # requests. - raise RuntimeError( + raise DidNotEnable( "The sanic integration for Sentry requires Python 3.7+ " " or aiocontextvars package" ) - if VERSION.startswith("0.8."): + if SANIC_VERSION.startswith("0.8."): # Sanic 0.8 and older creates a logger named "root" and puts a # stringified version of every exception in there (without exc_info), # which our error deduplication can't detect. diff --git a/sentry_sdk/integrations/sqlalchemy.py b/sentry_sdk/integrations/sqlalchemy.py index 5ce2a02c10..f24d2f20bf 100644 --- a/sentry_sdk/integrations/sqlalchemy.py +++ b/sentry_sdk/integrations/sqlalchemy.py @@ -2,11 +2,15 @@ from sentry_sdk._types import MYPY from sentry_sdk.hub import Hub -from sentry_sdk.integrations import Integration +from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.tracing import record_sql_queries -from sqlalchemy.engine import Engine # type: ignore -from sqlalchemy.event import listen # type: ignore +try: + from sqlalchemy.engine import Engine # type: ignore + from sqlalchemy.event import listen # type: ignore + from sqlalchemy import __version__ as SQLALCHEMY_VERSION # type: ignore +except ImportError: + raise DidNotEnable("SQLAlchemy not installed.") if MYPY: from typing import Any @@ -23,6 +27,16 @@ class SqlalchemyIntegration(Integration): def setup_once(): # type: () -> None + try: + version = tuple(map(int, SQLALCHEMY_VERSION.split("b")[0].split("."))) + except (TypeError, ValueError): + raise DidNotEnable( + "Unparseable SQLAlchemy version: {}".format(SQLALCHEMY_VERSION) + ) + + if version < (1, 2): + raise DidNotEnable("SQLAlchemy 1.2 or newer required.") + listen(Engine, "before_cursor_execute", _before_cursor_execute) listen(Engine, "after_cursor_execute", _after_cursor_execute) listen(Engine, "handle_error", _handle_error) diff --git a/sentry_sdk/integrations/tornado.py b/sentry_sdk/integrations/tornado.py index 3c43e0180c..abd540b611 100644 --- a/sentry_sdk/integrations/tornado.py +++ b/sentry_sdk/integrations/tornado.py @@ -8,7 +8,7 @@ capture_internal_exceptions, transaction_from_function, ) -from sentry_sdk.integrations import Integration +from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.integrations._wsgi_common import ( RequestExtractor, _filter_headers, @@ -17,8 +17,12 @@ from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk._compat import iteritems -from tornado.web import RequestHandler, HTTPError -from tornado.gen import coroutine +try: + from tornado import version_info as TORNADO_VERSION + from tornado.web import RequestHandler, HTTPError + from tornado.gen import coroutine +except ImportError: + raise DidNotEnable("Tornado not installed") from sentry_sdk._types import MYPY @@ -37,16 +41,13 @@ class TornadoIntegration(Integration): @staticmethod def setup_once(): # type: () -> None - import tornado - - tornado_version = getattr(tornado, "version_info", None) - if tornado_version is None or tornado_version < (5, 0): - raise RuntimeError("Tornado 5+ required") + if TORNADO_VERSION < (5, 0): + raise DidNotEnable("Tornado 5+ required") if not HAS_REAL_CONTEXTVARS: # Tornado is async. We better have contextvars or we're going to leak # state between requests. - raise RuntimeError( + raise DidNotEnable( "The tornado integration for Sentry requires Python 3.6+ or the aiocontextvars package" ) diff --git a/tests/integrations/flask/test_flask.py b/tests/integrations/flask/test_flask.py index 78002f569d..3347c4d886 100644 --- a/tests/integrations/flask/test_flask.py +++ b/tests/integrations/flask/test_flask.py @@ -39,6 +39,16 @@ def hi(): return app +@pytest.fixture(params=("auto", "manual")) +def integration_enabled_params(request): + if request.param == "auto": + return {"_experiments": {"auto_enabling_integrations": True}} + elif request.param == "manual": + return {"integrations": [flask_sentry.FlaskIntegration()]} + else: + raise ValueError(request.param) + + def test_has_context(sentry_init, app, capture_events): sentry_init(integrations=[flask_sentry.FlaskIntegration()]) events = capture_events() @@ -76,8 +86,16 @@ def test_transaction_style( @pytest.mark.parametrize("debug", (True, False)) @pytest.mark.parametrize("testing", (True, False)) -def test_errors(sentry_init, capture_exceptions, capture_events, app, debug, testing): - sentry_init(integrations=[flask_sentry.FlaskIntegration()], debug=True) +def test_errors( + sentry_init, + capture_exceptions, + capture_events, + app, + debug, + testing, + integration_enabled_params, +): + sentry_init(debug=True, **integration_enabled_params) app.debug = debug app.testing = testing @@ -102,8 +120,10 @@ def index(): assert event["exception"]["values"][0]["mechanism"]["type"] == "flask" -def test_flask_login_not_installed(sentry_init, app, capture_events, monkeypatch): - sentry_init(integrations=[flask_sentry.FlaskIntegration()]) +def test_flask_login_not_installed( + sentry_init, app, capture_events, monkeypatch, integration_enabled_params +): + sentry_init(**integration_enabled_params) monkeypatch.setattr(flask_sentry, "flask_login", None) @@ -116,8 +136,10 @@ def test_flask_login_not_installed(sentry_init, app, capture_events, monkeypatch assert event.get("user", {}).get("id") is None -def test_flask_login_not_configured(sentry_init, app, capture_events, monkeypatch): - sentry_init(integrations=[flask_sentry.FlaskIntegration()]) +def test_flask_login_not_configured( + sentry_init, app, capture_events, monkeypatch, integration_enabled_params +): + sentry_init(**integration_enabled_params) assert flask_sentry.flask_login @@ -130,9 +152,9 @@ def test_flask_login_not_configured(sentry_init, app, capture_events, monkeypatc def test_flask_login_partially_configured( - sentry_init, app, capture_events, monkeypatch + sentry_init, app, capture_events, monkeypatch, integration_enabled_params ): - sentry_init(integrations=[flask_sentry.FlaskIntegration()]) + sentry_init(**integration_enabled_params) events = capture_events() @@ -149,12 +171,15 @@ def test_flask_login_partially_configured( @pytest.mark.parametrize("send_default_pii", [True, False]) @pytest.mark.parametrize("user_id", [None, "42", 3]) def test_flask_login_configured( - send_default_pii, sentry_init, app, user_id, capture_events, monkeypatch + send_default_pii, + sentry_init, + app, + user_id, + capture_events, + monkeypatch, + integration_enabled_params, ): - sentry_init( - send_default_pii=send_default_pii, - integrations=[flask_sentry.FlaskIntegration()], - ) + sentry_init(send_default_pii=send_default_pii, **integration_enabled_params) class User(object): is_authenticated = is_active = True diff --git a/tests/test_basics.py b/tests/test_basics.py index 78d4f2b7c3..8953dc8803 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -12,6 +12,8 @@ last_event_id, Hub, ) + +from sentry_sdk.integrations import _AUTO_ENABLING_INTEGRATIONS from sentry_sdk.integrations.logging import LoggingIntegration @@ -37,6 +39,20 @@ def error_processor(event, exc_info): assert event["exception"]["values"][0]["value"] == "aha! whatever" +def test_auto_enabling_integrations_catches_import_error(sentry_init, caplog): + caplog.set_level(logging.DEBUG) + + sentry_init(_experiments={"auto_enabling_integrations": True}, debug=True) + + for import_string in _AUTO_ENABLING_INTEGRATIONS: + assert any( + record.message.startswith( + "Did not import default integration {}:".format(import_string) + ) + for record in caplog.records + ) + + def test_event_id(sentry_init, capture_events): sentry_init() events = capture_events()