diff --git a/.craft.yml b/.craft.yml index 5237c9debe..e351462f72 100644 --- a/.craft.yml +++ b/.craft.yml @@ -1,18 +1,12 @@ ---- -minVersion: "0.14.0" -github: - owner: getsentry - repo: sentry-python - +minVersion: 0.23.1 targets: - name: pypi includeNames: /^sentry[_\-]sdk.*$/ - name: github - name: gh-pages - name: registry - type: sdk - config: - canonical: pypi:sentry-sdk + sdks: + pypi:sentry-sdk: - name: aws-lambda-layer includeNames: /^sentry-python-serverless-\d+(\.\d+)*\.zip$/ layerName: SentryPythonServerlessSDK @@ -29,11 +23,5 @@ targets: - python3.7 - python3.8 license: MIT - changelog: CHANGELOG.md changelogPolicy: simple - -statusProvider: - name: github -artifactProvider: - name: github diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..9c69247970 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,43 @@ +version: 2 +updates: +- package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 + allow: + - dependency-type: direct + - dependency-type: indirect + ignore: + - dependency-name: pytest + versions: + - "> 3.7.3" + - dependency-name: pytest-cov + versions: + - "> 2.8.1" + - dependency-name: pytest-forked + versions: + - "> 1.1.3" + - dependency-name: sphinx + versions: + - ">= 2.4.a, < 2.5" + - dependency-name: tox + versions: + - "> 3.7.0" + - dependency-name: werkzeug + versions: + - "> 0.15.5, < 1" + - dependency-name: werkzeug + versions: + - ">= 1.0.a, < 1.1" + - dependency-name: mypy + versions: + - "0.800" + - dependency-name: sphinx + versions: + - 3.4.3 +- package-ecosystem: gitsubmodule + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 diff --git a/CHANGELOG.md b/CHANGELOG.md index b7a5003fb4..672c2ef016 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,23 @@ sentry-sdk==0.10.1 A major release `N` implies the previous release `N-1` will no longer receive updates. We generally do not backport bugfixes to older versions unless they are security relevant. However, feel free to ask for backports of specific commits on the bugtracker. +## 1.3.1 + +- Fix detection of contextvars compatibility with Gevent versions >=20.9.0 #1157 + +## 1.3.0 + +- Add support for Sanic versions 20 and 21 #1146 + +## 1.2.0 + +- Fix for `AWSLambda` Integration to handle other path formats for function initial handler #1139 +- Fix for worker to set deamon attribute instead of deprecated setDaemon method #1093 +- Fix for `bottle` Integration that discards `-dev` for version extraction #1085 +- Fix for transport that adds a unified hook for capturing metrics about dropped events #1100 +- Add `Httpx` Integration #1119 +- Add support for china domains in `AWSLambda` Integration #1051 + ## 1.1.0 - Fix for `AWSLambda` integration returns value of original handler #1106 diff --git a/checkouts/data-schemas b/checkouts/data-schemas index f97137ddd1..f8615dff7f 160000 --- a/checkouts/data-schemas +++ b/checkouts/data-schemas @@ -1 +1 @@ -Subproject commit f97137ddd16853269519de3c9ec00503a99b5da3 +Subproject commit f8615dff7f4640ff8a1810b264589b9fc6a4684a diff --git a/docs-requirements.txt b/docs-requirements.txt index 8273d572e7..e66af3de2c 100644 --- a/docs-requirements.txt +++ b/docs-requirements.txt @@ -1,4 +1,4 @@ -sphinx==3.5.3 +sphinx==4.1.1 sphinx-rtd-theme sphinx-autodoc-typehints[type_comments]>=1.8.0 typing-extensions diff --git a/docs/conf.py b/docs/conf.py index 64084a3970..67a32f39ae 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -5,6 +5,13 @@ import typing +# prevent circular imports +import sphinx.builders.html +import sphinx.builders.latex +import sphinx.builders.texinfo +import sphinx.builders.text +import sphinx.ext.autodoc + typing.TYPE_CHECKING = True # @@ -22,7 +29,7 @@ copyright = u"2019, Sentry Team and Contributors" author = u"Sentry Team and Contributors" -release = "1.1.0" +release = "1.3.1" version = ".".join(release.split(".")[:2]) # The short X.Y version. diff --git a/linter-requirements.txt b/linter-requirements.txt index 08b4795849..812b929c97 100644 --- a/linter-requirements.txt +++ b/linter-requirements.txt @@ -1,6 +1,6 @@ -black==20.8b1 -flake8==3.9.0 +black==21.7b0 +flake8==3.9.2 flake8-import-order==0.18.1 mypy==0.782 -flake8-bugbear==21.3.2 +flake8-bugbear==21.4.3 pep8-naming==0.11.1 diff --git a/scripts/init_serverless_sdk.py b/scripts/init_serverless_sdk.py index 0d3545039b..878ff6029e 100644 --- a/scripts/init_serverless_sdk.py +++ b/scripts/init_serverless_sdk.py @@ -6,6 +6,8 @@ 'sentry_sdk.integrations.init_serverless_sdk.sentry_lambda_handler' """ import os +import sys +import re import sentry_sdk from sentry_sdk._types import MYPY @@ -23,16 +25,53 @@ ) +class AWSLambdaModuleLoader: + DIR_PATH_REGEX = r"^(.+)\/([^\/]+)$" + + def __init__(self, sentry_initial_handler): + try: + module_path, self.handler_name = sentry_initial_handler.rsplit(".", 1) + except ValueError: + raise ValueError("Incorrect AWS Handler path (Not a path)") + + self.extract_and_load_lambda_function_module(module_path) + + def extract_and_load_lambda_function_module(self, module_path): + """ + Method that extracts and loads lambda function module from module_path + """ + py_version = sys.version_info + + if re.match(self.DIR_PATH_REGEX, module_path): + # With a path like -> `scheduler/scheduler/event` + # `module_name` is `event`, and `module_file_path` is `scheduler/scheduler/event.py` + module_name = module_path.split(os.path.sep)[-1] + module_file_path = module_path + ".py" + + # Supported python versions are 2.7, 3.6, 3.7, 3.8 + if py_version >= (3, 5): + import importlib.util + spec = importlib.util.spec_from_file_location(module_name, module_file_path) + self.lambda_function_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self.lambda_function_module) + elif py_version[0] < 3: + import imp + self.lambda_function_module = imp.load_source(module_name, module_file_path) + else: + raise ValueError("Python version %s is not supported." % py_version) + else: + import importlib + self.lambda_function_module = importlib.import_module(module_path) + + def get_lambda_handler(self): + return getattr(self.lambda_function_module, self.handler_name) + + def sentry_lambda_handler(event, context): # type: (Any, Any) -> None """ Handler function that invokes a lambda handler which path is defined in - environment vairables as "SENTRY_INITIAL_HANDLER" + environment variables as "SENTRY_INITIAL_HANDLER" """ - try: - module_name, handler_name = os.environ["SENTRY_INITIAL_HANDLER"].rsplit(".", 1) - except ValueError: - raise ValueError("Incorrect AWS Handler path (Not a path)") - lambda_function = __import__(module_name) - lambda_handler = getattr(lambda_function, handler_name) - return lambda_handler(event, context) + module_loader = AWSLambdaModuleLoader(os.environ["SENTRY_INITIAL_HANDLER"]) + return module_loader.get_lambda_handler()(event, context) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 824e874bbd..a9822e8223 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -99,7 +99,7 @@ def _get_default_options(): del _get_default_options -VERSION = "1.1.0" +VERSION = "1.3.1" SDK_INFO = { "name": "sentry.python", "version": VERSION, diff --git a/sentry_sdk/integrations/aws_lambda.py b/sentry_sdk/integrations/aws_lambda.py index 7f823dc04e..533250efaa 100644 --- a/sentry_sdk/integrations/aws_lambda.py +++ b/sentry_sdk/integrations/aws_lambda.py @@ -400,13 +400,15 @@ def _get_cloudwatch_logs_url(aws_context, start_time): str -- AWS Console URL to logs. """ formatstring = "%Y-%m-%dT%H:%M:%SZ" + region = environ.get("AWS_REGION", "") url = ( - "https://console.aws.amazon.com/cloudwatch/home?region={region}" + "https://console.{domain}/cloudwatch/home?region={region}" "#logEventViewer:group={log_group};stream={log_stream}" ";start={start_time};end={end_time}" ).format( - region=environ.get("AWS_REGION"), + domain="amazonaws.cn" if region.startswith("cn-") else "aws.amazon.com", + region=region, log_group=aws_context.log_group_name, log_stream=aws_context.log_stream_name, start_time=(start_time - timedelta(seconds=1)).strftime(formatstring), diff --git a/sentry_sdk/integrations/bottle.py b/sentry_sdk/integrations/bottle.py index 8bdabda4f7..4fa077e8f6 100644 --- a/sentry_sdk/integrations/bottle.py +++ b/sentry_sdk/integrations/bottle.py @@ -57,7 +57,7 @@ def setup_once(): # type: () -> None try: - version = tuple(map(int, BOTTLE_VERSION.split("."))) + version = tuple(map(int, BOTTLE_VERSION.replace("-dev", "").split("."))) except (TypeError, ValueError): raise DidNotEnable("Unparsable Bottle version: {}".format(version)) diff --git a/sentry_sdk/integrations/httpx.py b/sentry_sdk/integrations/httpx.py new file mode 100644 index 0000000000..af67315338 --- /dev/null +++ b/sentry_sdk/integrations/httpx.py @@ -0,0 +1,83 @@ +from sentry_sdk import Hub +from sentry_sdk.integrations import Integration, DidNotEnable + +from sentry_sdk._types import MYPY + +if MYPY: + from typing import Any + + +try: + from httpx import AsyncClient, Client, Request, Response # type: ignore +except ImportError: + raise DidNotEnable("httpx is not installed") + +__all__ = ["HttpxIntegration"] + + +class HttpxIntegration(Integration): + identifier = "httpx" + + @staticmethod + def setup_once(): + # type: () -> None + """ + httpx has its own transport layer and can be customized when needed, + so patch Client.send and AsyncClient.send to support both synchronous and async interfaces. + """ + _install_httpx_client() + _install_httpx_async_client() + + +def _install_httpx_client(): + # type: () -> None + real_send = Client.send + + def send(self, request, **kwargs): + # type: (Client, Request, **Any) -> Response + hub = Hub.current + if hub.get_integration(HttpxIntegration) is None: + return real_send(self, request, **kwargs) + + with hub.start_span( + op="http", description="%s %s" % (request.method, request.url) + ) as span: + span.set_data("method", request.method) + span.set_data("url", str(request.url)) + for key, value in hub.iter_trace_propagation_headers(): + request.headers[key] = value + rv = real_send(self, request, **kwargs) + + span.set_data("status_code", rv.status_code) + span.set_http_status(rv.status_code) + span.set_data("reason", rv.reason_phrase) + return rv + + Client.send = send + + +def _install_httpx_async_client(): + # type: () -> None + real_send = AsyncClient.send + + async def send(self, request, **kwargs): + # type: (AsyncClient, Request, **Any) -> Response + hub = Hub.current + if hub.get_integration(HttpxIntegration) is None: + return await real_send(self, request, **kwargs) + + with hub.start_span( + op="http", description="%s %s" % (request.method, request.url) + ) as span: + span.set_data("method", request.method) + span.set_data("url", str(request.url)) + for key, value in hub.iter_trace_propagation_headers(): + request.headers[key] = value + rv = await real_send(self, request, **kwargs) + + span.set_data("status_code", rv.status_code) + span.set_http_status(rv.status_code) + span.set_data("reason", rv.reason_phrase) + return rv + + AsyncClient.send = send diff --git a/sentry_sdk/integrations/redis.py b/sentry_sdk/integrations/redis.py index 0df6121a54..6475d15bf6 100644 --- a/sentry_sdk/integrations/redis.py +++ b/sentry_sdk/integrations/redis.py @@ -56,7 +56,7 @@ def setup_once(): try: _patch_rediscluster() except Exception: - logger.exception("Error occured while patching `rediscluster` library") + logger.exception("Error occurred while patching `rediscluster` library") def patch_redis_client(cls): diff --git a/sentry_sdk/integrations/sanic.py b/sentry_sdk/integrations/sanic.py index d5eb7fae87..890bb2f3e2 100644 --- a/sentry_sdk/integrations/sanic.py +++ b/sentry_sdk/integrations/sanic.py @@ -96,14 +96,29 @@ async def sentry_handle_request(self, request, *args, **kwargs): old_router_get = Router.get - def sentry_router_get(self, request): - # type: (Any, Request) -> Any - rv = old_router_get(self, request) + def sentry_router_get(self, *args): + # type: (Any, Union[Any, Request]) -> Any + rv = old_router_get(self, *args) hub = Hub.current if hub.get_integration(SanicIntegration) is not None: with capture_internal_exceptions(): with hub.configure_scope() as scope: - scope.transaction = rv[0].__name__ + if version >= (21, 3): + # Sanic versions above and including 21.3 append the app name to the + # route name, and so we need to remove it from Route name so the + # transaction name is consistent across all versions + sanic_app_name = self.ctx.app.name + sanic_route = rv[0].name + + if sanic_route.startswith("%s." % sanic_app_name): + # We add a 1 to the len of the sanic_app_name because there is a dot + # that joins app name and the route name + # Format: app_name.route_name + sanic_route = sanic_route[len(sanic_app_name) + 1 :] + + scope.transaction = sanic_route + else: + scope.transaction = rv[0].__name__ return rv Router.get = sentry_router_get diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index 21269d68df..4ce25f27c2 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -666,7 +666,7 @@ def has_tracing_enabled(options): # type: (Dict[str, Any]) -> bool """ Returns True if either traces_sample_rate or traces_sampler is - non-zero/defined, False otherwise. + defined, False otherwise. """ return bool( diff --git a/sentry_sdk/transport.py b/sentry_sdk/transport.py index 5fdfdfbdc1..a254b4f6ee 100644 --- a/sentry_sdk/transport.py +++ b/sentry_sdk/transport.py @@ -150,12 +150,14 @@ def _update_rate_limits(self, response): # no matter of the status code to update our internal rate limits. header = response.headers.get("x-sentry-rate-limits") if header: + logger.warning("Rate-limited via x-sentry-rate-limits") self._disabled_until.update(_parse_rate_limits(header)) # old sentries only communicate global rate limit hits via the # retry-after header on 429. This header can also be emitted on new # sentries if a proxy in front wants to globally slow things down. elif response.status == 429: + logger.warning("Rate-limited via 429") self._disabled_until[None] = datetime.utcnow() + timedelta( seconds=self._retry.get_retry_after(response) or 60 ) @@ -173,12 +175,16 @@ def _send_request( "X-Sentry-Auth": str(self._auth.to_header()), } ) - response = self._pool.request( - "POST", - str(self._auth.get_api_url(endpoint_type)), - body=body, - headers=headers, - ) + try: + response = self._pool.request( + "POST", + str(self._auth.get_api_url(endpoint_type)), + body=body, + headers=headers, + ) + except Exception: + self.on_dropped_event("network") + raise try: self._update_rate_limits(response) @@ -186,6 +192,7 @@ def _send_request( if response.status == 429: # if we hit a 429. Something was rate limited but we already # acted on this in `self._update_rate_limits`. + self.on_dropped_event("status_429") pass elif response.status >= 300 or response.status < 200: @@ -194,9 +201,14 @@ def _send_request( response.status, response.data, ) + self.on_dropped_event("status_{}".format(response.status)) finally: response.close() + def on_dropped_event(self, reason): + # type: (str) -> None + pass + def _check_disabled(self, category): # type: (str) -> bool def _disabled(bucket): @@ -212,6 +224,7 @@ def _send_event( # type: (...) -> None if self._check_disabled("error"): + self.on_dropped_event("self_rate_limits") return None body = io.BytesIO() @@ -325,7 +338,8 @@ def send_event_wrapper(): with capture_internal_exceptions(): self._send_event(event) - self._worker.submit(send_event_wrapper) + if not self._worker.submit(send_event_wrapper): + self.on_dropped_event("full_queue") def capture_envelope( self, envelope # type: Envelope @@ -339,7 +353,8 @@ def send_envelope_wrapper(): with capture_internal_exceptions(): self._send_envelope(envelope) - self._worker.submit(send_envelope_wrapper) + if not self._worker.submit(send_envelope_wrapper): + self.on_dropped_event("full_queue") def flush( self, diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 323e4ceffa..43b63b41ac 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -785,12 +785,24 @@ def _is_contextvars_broken(): Returns whether gevent/eventlet have patched the stdlib in a way where thread locals are now more "correct" than contextvars. """ try: + import gevent # type: ignore from gevent.monkey import is_object_patched # type: ignore + # Get the MAJOR and MINOR version numbers of Gevent + version_tuple = tuple([int(part) for part in gevent.__version__.split(".")[:2]]) if is_object_patched("threading", "local"): - # Gevent 20.5 is able to patch both thread locals and contextvars, - # in that case all is good. - if is_object_patched("contextvars", "ContextVar"): + # Gevent 20.9.0 depends on Greenlet 0.4.17 which natively handles switching + # context vars when greenlets are switched, so, Gevent 20.9.0+ is all fine. + # Ref: https://github.com/gevent/gevent/blob/83c9e2ae5b0834b8f84233760aabe82c3ba065b4/src/gevent/monkey.py#L604-L609 + # Gevent 20.5, that doesn't depend on Greenlet 0.4.17 with native support + # for contextvars, is able to patch both thread locals and contextvars, in + # that case, check if contextvars are effectively patched. + if ( + # Gevent 20.9.0+ + (sys.version_info >= (3, 7) and version_tuple >= (20, 9)) + # Gevent 20.5.0+ or Python < 3.7 + or (is_object_patched("contextvars", "ContextVar")) + ): return False return True diff --git a/sentry_sdk/worker.py b/sentry_sdk/worker.py index a8e2fe1ce6..a06fb8f0d1 100644 --- a/sentry_sdk/worker.py +++ b/sentry_sdk/worker.py @@ -66,7 +66,7 @@ def start(self): self._thread = threading.Thread( target=self._target, name="raven-sentry.BackgroundWorker" ) - self._thread.setDaemon(True) + self._thread.daemon = True self._thread.start() self._thread_for_pid = os.getpid() @@ -109,16 +109,13 @@ def _wait_flush(self, timeout, callback): logger.error("flush timed out, dropped %s events", pending) def submit(self, callback): - # type: (Callable[[], None]) -> None + # type: (Callable[[], None]) -> bool self._ensure_thread() try: self._queue.put_nowait(callback) + return True except Full: - self.on_full_queue(callback) - - def on_full_queue(self, callback): - # type: (Optional[Any]) -> None - logger.error("background worker queue full, dropping event") + return False def _target(self): # type: () -> None diff --git a/setup.py b/setup.py index eaced8dbd9..bec94832c6 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ def get_file_text(file_name): setup( name="sentry-sdk", - version="1.1.0", + version="1.3.1", author="Sentry Team and Contributors", author_email="hello@sentry.io", url="https://github.com/getsentry/sentry-python", @@ -53,6 +53,7 @@ def get_file_text(file_name): "pyspark": ["pyspark>=2.4.4"], "pure_eval": ["pure_eval", "executing", "asttokens"], "chalice": ["chalice>=1.16.0"], + "httpx": ["httpx>=0.16.0"], }, classifiers=[ "Development Status :: 5 - Production/Stable", diff --git a/tests/integrations/aws_lambda/client.py b/tests/integrations/aws_lambda/client.py index 8273b281c3..784a4a9006 100644 --- a/tests/integrations/aws_lambda/client.py +++ b/tests/integrations/aws_lambda/client.py @@ -18,7 +18,7 @@ def get_boto_client(): def build_no_code_serverless_function_and_layer( - client, tmpdir, fn_name, runtime, timeout + client, tmpdir, fn_name, runtime, timeout, initial_handler ): """ Util function that auto instruments the no code implementation of the python @@ -45,7 +45,7 @@ def build_no_code_serverless_function_and_layer( Timeout=timeout, Environment={ "Variables": { - "SENTRY_INITIAL_HANDLER": "test_lambda.test_handler", + "SENTRY_INITIAL_HANDLER": initial_handler, "SENTRY_DSN": "https://123abc@example.com/123", "SENTRY_TRACES_SAMPLE_RATE": "1.0", } @@ -67,12 +67,27 @@ def run_lambda_function( syntax_check=True, timeout=30, layer=None, + initial_handler=None, subprocess_kwargs=(), ): subprocess_kwargs = dict(subprocess_kwargs) with tempfile.TemporaryDirectory() as tmpdir: - test_lambda_py = os.path.join(tmpdir, "test_lambda.py") + if initial_handler: + # If Initial handler value is provided i.e. it is not the default + # `test_lambda.test_handler`, then create another dir level so that our path is + # test_dir.test_lambda.test_handler + test_dir_path = os.path.join(tmpdir, "test_dir") + python_init_file = os.path.join(test_dir_path, "__init__.py") + os.makedirs(test_dir_path) + with open(python_init_file, "w"): + # Create __init__ file to make it a python package + pass + + test_lambda_py = os.path.join(tmpdir, "test_dir", "test_lambda.py") + else: + test_lambda_py = os.path.join(tmpdir, "test_lambda.py") + with open(test_lambda_py, "w") as f: f.write(code) @@ -127,8 +142,13 @@ def run_lambda_function( cwd=tmpdir, check=True, ) + + # Default initial handler + if not initial_handler: + initial_handler = "test_lambda.test_handler" + build_no_code_serverless_function_and_layer( - client, tmpdir, fn_name, runtime, timeout + client, tmpdir, fn_name, runtime, timeout, initial_handler ) @add_finalizer diff --git a/tests/integrations/aws_lambda/test_aws.py b/tests/integrations/aws_lambda/test_aws.py index 36c212c08f..0f50753be7 100644 --- a/tests/integrations/aws_lambda/test_aws.py +++ b/tests/integrations/aws_lambda/test_aws.py @@ -112,7 +112,9 @@ def lambda_runtime(request): @pytest.fixture def run_lambda_function(request, lambda_client, lambda_runtime): - def inner(code, payload, timeout=30, syntax_check=True, layer=None): + def inner( + code, payload, timeout=30, syntax_check=True, layer=None, initial_handler=None + ): from tests.integrations.aws_lambda.client import run_lambda_function response = run_lambda_function( @@ -124,6 +126,7 @@ def inner(code, payload, timeout=30, syntax_check=True, layer=None): timeout=timeout, syntax_check=syntax_check, layer=layer, + initial_handler=initial_handler, ) # for better debugging @@ -621,32 +624,39 @@ def test_serverless_no_code_instrumentation(run_lambda_function): python sdk, with no code changes sentry is able to capture errors """ - _, _, response = run_lambda_function( - dedent( - """ - import sentry_sdk + for initial_handler in [ + None, + "test_dir/test_lambda.test_handler", + "test_dir.test_lambda.test_handler", + ]: + print("Testing Initial Handler ", initial_handler) + _, _, response = run_lambda_function( + dedent( + """ + import sentry_sdk - def test_handler(event, context): - current_client = sentry_sdk.Hub.current.client + def test_handler(event, context): + current_client = sentry_sdk.Hub.current.client - assert current_client is not None + assert current_client is not None - assert len(current_client.options['integrations']) == 1 - assert isinstance(current_client.options['integrations'][0], - sentry_sdk.integrations.aws_lambda.AwsLambdaIntegration) + assert len(current_client.options['integrations']) == 1 + assert isinstance(current_client.options['integrations'][0], + sentry_sdk.integrations.aws_lambda.AwsLambdaIntegration) - raise Exception("something went wrong") - """ - ), - b'{"foo": "bar"}', - layer=True, - ) - assert response["FunctionError"] == "Unhandled" - assert response["StatusCode"] == 200 + raise Exception("something went wrong") + """ + ), + b'{"foo": "bar"}', + layer=True, + initial_handler=initial_handler, + ) + assert response["FunctionError"] == "Unhandled" + assert response["StatusCode"] == 200 - assert response["Payload"]["errorType"] != "AssertionError" + assert response["Payload"]["errorType"] != "AssertionError" - assert response["Payload"]["errorType"] == "Exception" - assert response["Payload"]["errorMessage"] == "something went wrong" + assert response["Payload"]["errorType"] == "Exception" + assert response["Payload"]["errorMessage"] == "something went wrong" - assert "sentry_handler" in response["LogResult"][3].decode("utf-8") + assert "sentry_handler" in response["LogResult"][3].decode("utf-8") diff --git a/tests/integrations/django/myapp/settings.py b/tests/integrations/django/myapp/settings.py index bea1c35bf4..cc4d249082 100644 --- a/tests/integrations/django/myapp/settings.py +++ b/tests/integrations/django/myapp/settings.py @@ -157,7 +157,7 @@ def middleware(request): USE_L10N = True -USE_TZ = True +USE_TZ = False TEMPLATE_DEBUG = True diff --git a/tests/integrations/django/test_basic.py b/tests/integrations/django/test_basic.py index 9341dc238d..09fefe6a4c 100644 --- a/tests/integrations/django/test_basic.py +++ b/tests/integrations/django/test_basic.py @@ -1,6 +1,7 @@ from __future__ import absolute_import import pytest +import pytest_django import json from werkzeug.test import Client @@ -21,6 +22,19 @@ from tests.integrations.django.myapp.wsgi import application +# Hack to prevent from experimental feature introduced in version `4.3.0` in `pytest-django` that +# requires explicit database allow from failing the test +pytest_mark_django_db_decorator = pytest.mark.django_db +try: + pytest_version = tuple(map(int, pytest_django.__version__.split("."))) + if pytest_version > (4, 2, 0): + pytest_mark_django_db_decorator = pytest.mark.django_db(databases="__all__") +except ValueError: + if "dev" in pytest_django.__version__: + pytest_mark_django_db_decorator = pytest.mark.django_db(databases="__all__") +except AttributeError: + pass + @pytest.fixture def client(): @@ -245,7 +259,7 @@ def test_sql_queries(sentry_init, capture_events, with_integration): @pytest.mark.forked -@pytest.mark.django_db +@pytest_mark_django_db_decorator def test_sql_dict_query_params(sentry_init, capture_events): sentry_init( integrations=[DjangoIntegration()], @@ -290,7 +304,7 @@ def test_sql_dict_query_params(sentry_init, capture_events): ], ) @pytest.mark.forked -@pytest.mark.django_db +@pytest_mark_django_db_decorator def test_sql_psycopg2_string_composition(sentry_init, capture_events, query): sentry_init( integrations=[DjangoIntegration()], @@ -323,7 +337,7 @@ def test_sql_psycopg2_string_composition(sentry_init, capture_events, query): @pytest.mark.forked -@pytest.mark.django_db +@pytest_mark_django_db_decorator def test_sql_psycopg2_placeholders(sentry_init, capture_events): sentry_init( integrations=[DjangoIntegration()], diff --git a/tests/integrations/httpx/__init__.py b/tests/integrations/httpx/__init__.py new file mode 100644 index 0000000000..1afd90ea3a --- /dev/null +++ b/tests/integrations/httpx/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("httpx") diff --git a/tests/integrations/httpx/test_httpx.py b/tests/integrations/httpx/test_httpx.py new file mode 100644 index 0000000000..4623f13348 --- /dev/null +++ b/tests/integrations/httpx/test_httpx.py @@ -0,0 +1,66 @@ +import asyncio + +import httpx + +from sentry_sdk import capture_message, start_transaction +from sentry_sdk.integrations.httpx import HttpxIntegration + + +def test_crumb_capture_and_hint(sentry_init, capture_events): + def before_breadcrumb(crumb, hint): + crumb["data"]["extra"] = "foo" + return crumb + + sentry_init(integrations=[HttpxIntegration()], before_breadcrumb=before_breadcrumb) + clients = (httpx.Client(), httpx.AsyncClient()) + for i, c in enumerate(clients): + with start_transaction(): + events = capture_events() + + url = "https://httpbin.org/status/200" + if not asyncio.iscoroutinefunction(c.get): + response = c.get(url) + else: + response = asyncio.get_event_loop().run_until_complete(c.get(url)) + + assert response.status_code == 200 + capture_message("Testing!") + + (event,) = events + # send request twice so we need get breadcrumb by index + crumb = event["breadcrumbs"]["values"][i] + assert crumb["type"] == "http" + assert crumb["category"] == "httplib" + assert crumb["data"] == { + "url": url, + "method": "GET", + "status_code": 200, + "reason": "OK", + "extra": "foo", + } + + +def test_outgoing_trace_headers(sentry_init): + sentry_init(traces_sample_rate=1.0, integrations=[HttpxIntegration()]) + clients = (httpx.Client(), httpx.AsyncClient()) + for i, c in enumerate(clients): + with start_transaction( + name="/interactions/other-dogs/new-dog", + op="greeting.sniff", + # make trace_id difference between transactions + trace_id=f"012345678901234567890123456789{i}", + ) as transaction: + url = "https://httpbin.org/status/200" + if not asyncio.iscoroutinefunction(c.get): + response = c.get(url) + else: + response = asyncio.get_event_loop().run_until_complete(c.get(url)) + + request_span = transaction._span_recorder.spans[-1] + assert response.request.headers[ + "sentry-trace" + ] == "{trace_id}-{parent_span_id}-{sampled}".format( + trace_id=transaction.trace_id, + parent_span_id=request_span.span_id, + sampled=1, + ) diff --git a/tests/integrations/sanic/test_sanic.py b/tests/integrations/sanic/test_sanic.py index 72425abbcb..8ee19844c5 100644 --- a/tests/integrations/sanic/test_sanic.py +++ b/tests/integrations/sanic/test_sanic.py @@ -9,6 +9,7 @@ from sentry_sdk.integrations.sanic import SanicIntegration from sanic import Sanic, request, response, __version__ as SANIC_VERSION_RAW +from sanic.response import HTTPResponse from sanic.exceptions import abort SANIC_VERSION = tuple(map(int, SANIC_VERSION_RAW.split("."))) @@ -16,7 +17,12 @@ @pytest.fixture def app(): - app = Sanic(__name__) + if SANIC_VERSION >= (20, 12): + # Build (20.12.0) adds a feature where the instance is stored in an internal class + # registry for later retrieval, and so add register=False to disable that + app = Sanic(__name__, register=False) + else: + app = Sanic(__name__) @app.route("/message") def hi(request): @@ -166,11 +172,46 @@ async def task(i): if SANIC_VERSION >= (19,): kwargs["app"] = app - await app.handle_request( - request.Request(**kwargs), - write_callback=responses.append, - stream_callback=responses.append, - ) + if SANIC_VERSION >= (21, 3): + try: + app.router.reset() + app.router.finalize() + except AttributeError: + ... + + class MockAsyncStreamer: + def __init__(self, request_body): + self.request_body = request_body + self.iter = iter(self.request_body) + self.response = b"success" + + def respond(self, response): + responses.append(response) + patched_response = HTTPResponse() + patched_response.send = lambda end_stream: asyncio.sleep(0.001) + return patched_response + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self.iter) + except StopIteration: + raise StopAsyncIteration + + patched_request = request.Request(**kwargs) + patched_request.stream = MockAsyncStreamer([b"hello", b"foo"]) + + await app.handle_request( + patched_request, + ) + else: + await app.handle_request( + request.Request(**kwargs), + write_callback=responses.append, + stream_callback=responses.append, + ) (r,) = responses assert r.status == 200 diff --git a/tox.ini b/tox.ini index 40e322650c..68cee8e587 100644 --- a/tox.ini +++ b/tox.ini @@ -39,6 +39,8 @@ envlist = {py3.5,py3.6,py3.7}-sanic-{0.8,18} {py3.6,py3.7}-sanic-19 + {py3.6,py3.7,py3.8}-sanic-20 + {py3.7,py3.8,py3.9}-sanic-21 # TODO: Add py3.9 {pypy,py2.7}-celery-3 @@ -83,6 +85,8 @@ envlist = {py2.7,py3.6,py3.7,py3.8}-boto3-{1.9,1.10,1.11,1.12,1.13,1.14,1.15,1.16} + {py3.6,py3.7,py3.8,py3.9}-httpx-{0.16,0.17} + [testenv] deps = # if you change test-requirements.txt and your change is not being reflected @@ -102,6 +106,7 @@ deps = django-{1.6,1.7}: pytest-django<3.0 django-{1.8,1.9,1.10,1.11,2.0,2.1}: pytest-django<4.0 django-{2.2,3.0,3.1}: pytest-django>=4.0 + django-{2.2,3.0,3.1}: Werkzeug<2.0 django-dev: git+https://github.com/pytest-dev/pytest-django#egg=pytest-django django-1.6: Django>=1.6,<1.7 @@ -136,6 +141,9 @@ deps = sanic-0.8: sanic>=0.8,<0.9 sanic-18: sanic>=18.0,<19.0 sanic-19: sanic>=19.0,<20.0 + sanic-20: sanic>=20.0,<21.0 + sanic-21: sanic>=21.0,<22.0 + {py3.7,py3.8,py3.9}-sanic-21: sanic_testing {py3.5,py3.6}-sanic: aiocontextvars==0.2.1 sanic: aiohttp py3.5-sanic: ujson<4 @@ -201,7 +209,7 @@ deps = trytond-5.0: trytond>=5.0,<5.1 trytond-4.6: trytond>=4.6,<4.7 - trytond-4.8: werkzeug<1.0 + trytond-{4.6,4.8,5.0,5.2,5.4}: werkzeug<2.0 redis: fakeredis @@ -235,6 +243,9 @@ deps = boto3-1.15: boto3>=1.15,<1.16 boto3-1.16: boto3>=1.16,<1.17 + httpx-0.16: httpx>=0.16,<0.17 + httpx-0.17: httpx>=0.17,<0.18 + setenv = PYTHONDONTWRITEBYTECODE=1 TESTPATH=tests @@ -260,6 +271,7 @@ setenv = pure_eval: TESTPATH=tests/integrations/pure_eval chalice: TESTPATH=tests/integrations/chalice boto3: TESTPATH=tests/integrations/boto3 + httpx: TESTPATH=tests/integrations/httpx COVERAGE_FILE=.coverage-{envname} passenv = @@ -297,9 +309,7 @@ commands = ; https://github.com/pytest-dev/pytest/issues/5532 {py3.5,py3.6,py3.7,py3.8,py3.9}-flask-{0.10,0.11,0.12}: pip install pytest<5 - - ; trytond tries to import werkzeug.contrib - trytond-5.0: pip install werkzeug<1.0 + {py3.6,py3.7,py3.8,py3.9}-flask-{0.11}: pip install Werkzeug<2 py.test {env:TESTPATH} {posargs}