diff --git a/.ci/.jenkins_exclude.yml b/.ci/.jenkins_exclude.yml index 0206beaa6..862de09ef 100644 --- a/.ci/.jenkins_exclude.yml +++ b/.ci/.jenkins_exclude.yml @@ -187,7 +187,7 @@ exclude: - PYTHON_VERSION: python-3.10-rc FRAMEWORK: zerorpc-0.4 # gevent - - PYTHON_VERSION: python-3.10-rc # causes issues with PosixPath.__enter__, unsure why + - PYTHON_VERSION: python-3.10-rc # causes issues with PosixPath.__enter__, unsure why FRAMEWORK: gevent-newest # aiohttp client, only supported in Python 3.7+ - PYTHON_VERSION: pypy-3 @@ -230,9 +230,19 @@ exclude: FRAMEWORK: asyncpg-newest - PYTHON_VERSION: python-3.6 FRAMEWORK: asyncpg-newest - - PYTHON_VERSION: python-3.10-rc # https://github.com/MagicStack/asyncpg/issues/699 + - PYTHON_VERSION: python-3.10-rc # https://github.com/MagicStack/asyncpg/issues/699 FRAMEWORK: asyncpg-newest + # sanic - PYTHON_VERSION: pypy-3 + FRAMEWORK: sanic-newest + - PYTHON_VERSION: pypy-3 + FRAMEWORK: sanic-20.12 + - PYTHON_VERSION: python-3.6 + FRAMEWORK: sanic-20.12 + - PYTHON_VERSION: python-3.6 + FRAMEWORK: sanic-newest + - PYTHON_VERSION: pypy-3 + # aioredis FRAMEWORK: aioredis-newest - PYTHON_VERSION: python-3.6 FRAMEWORK: aioredis-newest diff --git a/.ci/.jenkins_framework.yml b/.ci/.jenkins_framework.yml index 413541f63..ba18d6950 100644 --- a/.ci/.jenkins_framework.yml +++ b/.ci/.jenkins_framework.yml @@ -48,4 +48,5 @@ FRAMEWORK: - httpx-newest - httplib2-newest - prometheus_client-newest + - sanic-newest - aiomysql-newest diff --git a/.ci/.jenkins_framework_full.yml b/.ci/.jenkins_framework_full.yml index 70dc9bfee..619b1d908 100644 --- a/.ci/.jenkins_framework_full.yml +++ b/.ci/.jenkins_framework_full.yml @@ -79,3 +79,5 @@ FRAMEWORK: - httpx-0.14 - httpx-newest - httplib2-newest + - sanic-20.12 + - sanic-newest diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index c8331d7f8..b484f73b5 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -29,6 +29,18 @@ endif::[] //[float] //===== Bug fixes +=== Unreleased + +// Unreleased changes go here +// When the next release happens, nest these changes under the "Python Agent version 6.x" heading +[float] +===== Features + +* Add support for Sanic framework {pull}1390[#1390] + +//[float] +//===== Bug fixes + [[release-notes-6.x]] === Python Agent version 6.x diff --git a/conftest.py b/conftest.py index 7f8b94cbb..95ff11a66 100644 --- a/conftest.py +++ b/conftest.py @@ -63,6 +63,7 @@ "django": "tests.contrib.django.fixtures", "flask": "tests.contrib.flask.fixtures", "aiohttp": "aiohttp.pytest_plugin", + "sanic": "tests.contrib.sanic.fixtures", }.items(): try: importlib.import_module(module) diff --git a/docs/sanic.asciidoc b/docs/sanic.asciidoc new file mode 100644 index 000000000..83f8fd540 --- /dev/null +++ b/docs/sanic.asciidoc @@ -0,0 +1,179 @@ +[[sanic-support]] +=== Sanic Support + +Incorporating Elastic APM into your Sanic project only requires a few easy +steps. + +[float] +[[sanic-installation]] +==== Installation + +Install the Elastic APM agent using pip: + +[source,bash] +---- +$ pip install elastic-apm +---- + +or add `elastic-apm` to your project's `requirements.txt` file. + + +[float] +[[sanic-setup]] +==== Setup + +To set up the agent, you need to initialize it with appropriate settings. + +The settings are configured either via environment variables, or as +initialization arguments. + +You can find a list of all available settings in the +<> page. + +To initialize the agent for your application using environment variables: + +[source,python] +---- +from sanic import Sanic +from elasticapm.contrib.sanic import ElasticAPM + +app = Sanic(name="elastic-apm-sample") +apm = ElasticAPM(app=app) +---- + +To configure the agent using initialization arguments and Sanic's Configuration infrastructure: + +[source,python] +---- +# Create a file named external_config.py in your application +# If you want this module based configuration to be used for APM, prefix them with ELASTIC_APM_ +ELASTIC_APM_SERVER_URL = "https://serverurl.apm.com:443" +ELASTIC_APM_SECRET_TOKEN = "sometoken" +---- + +[source,python] +---- +from sanic import Sanic +from elasticapm.contrib.sanic import ElasticAPM + +app = Sanic(name="elastic-apm-sample") +app.config.update_config("path/to/external_config.py") +apm = ElasticAPM(app=app) +---- + +[float] +[[sanic-usage]] +==== Usage + +Once you have configured the agent, it will automatically track transactions +and capture uncaught exceptions within sanic. + +Capture an arbitrary exception by calling +<>: + +[source,python] +---- +from sanic import Sanic +from elasticapm.contrib.sanic import ElasticAPM + +app = Sanic(name="elastic-apm-sample") +apm = ElasticAPM(app=app) + +try: + 1 / 0 +except ZeroDivisionError: + apm.capture_exception() +---- + +Log a generic message with <>: + +[source,python] +---- +from sanic import Sanic +from elasticapm.contrib.sanic import ElasticAPM + +app = Sanic(name="elastic-apm-sample") +apm = ElasticAPM(app=app) + +apm.capture_message('hello, world!') +---- + +[float] +[[sanic-performance-metrics]] +==== Performance metrics + +If you've followed the instructions above, the agent has installed our +instrumentation middleware which will process all requests through your app. +This will measure response times, as well as detailed performance data for +all supported technologies. + +NOTE: Due to the fact that `asyncio` drivers are usually separate from their +synchronous counterparts, specific instrumentation is needed for all drivers. +The support for asynchronous drivers is currently quite limited. + +[float] +[[sanic-ignoring-specific-views]] +===== Ignoring specific routes + +You can use the +<> +configuration option to ignore specific routes. The list given should be a +list of regular expressions which are matched against the transaction name: + +[source,python] +---- +from sanic import Sanic +from elasticapm.contrib.sanic import ElasticAPM + +app = Sanic(name="elastic-apm-sample") +apm = ElasticAPM(app=app, config={ + 'TRANSACTIONS_IGNORE_PATTERNS': ['^GET /secret', '/extra_secret'], +}) +---- + +This would ignore any requests using the `GET /secret` route +and any requests containing `/extra_secret`. + +[float] +[[extended-sanic-usage]] +==== Extended Sanic APM Client Usage + +Sanic's contributed APM client also provides a few extendable way to configure selective behaviors to enhance the +information collected as part of the transactions being tracked by the APM. + +In order to enable this behavior, the APM Client middleware provides a few callback functions that you can leverage +in order to simplify the process of generating additional contexts into the traces being collected. +[cols="1,1,1,1"] +|=== +| Callback Name | Callback Invocation Format | Expected Return Format | Is Async + +| transaction_name_callback +| transaction_name_callback(request) +| string +| false + +| user_context_callback +| user_context_callback(request) +| (username_string, user_email_string, userid_string) +| true + +| custom_context_callback +| custom_context_callback(request) or custom_context_callback(response) +| dict(str=str) +| true + +| label_info_callback +| label_info_callback() +| dict(str=str) +| true +|=== + +[float] +[[supported-stanic-and-python-versions]] +==== Supported Sanic and Python versions + +A list of supported <> and +<> versions can be found on our +<> page. + +NOTE: Elastic APM only supports `asyncio` when using Python 3.7+ diff --git a/docs/set-up.asciidoc b/docs/set-up.asciidoc index 2dd223971..15269e0ff 100644 --- a/docs/set-up.asciidoc +++ b/docs/set-up.asciidoc @@ -8,6 +8,7 @@ To get you off the ground, we’ve prepared guides for setting up the Agent with * <> * <> * <> + * <> * <> For custom instrumentation, see <>. @@ -22,4 +23,6 @@ include::./tornado.asciidoc[] include::./starlette.asciidoc[] -include::./serverless.asciidoc[] \ No newline at end of file +include::./sanic.asciidoc[] + +include::./serverless.asciidoc[] diff --git a/docs/supported-technologies.asciidoc b/docs/supported-technologies.asciidoc index aea872994..b46d10f03 100644 --- a/docs/supported-technologies.asciidoc +++ b/docs/supported-technologies.asciidoc @@ -9,6 +9,7 @@ The Elastic APM Python Agent comes with support for the following frameworks: * <> * <> * <> + * <> For other frameworks and custom Python code, the agent exposes a set of <> for integration. @@ -71,6 +72,15 @@ We support these tornado versions: * 6.0+ +[float] +[[supported-sanic]] +=== Sanic + +We support these sanic versions: + + * 20.12.2+ + + [float] [[supported-starlette]] === Starlette/FastAPI diff --git a/elasticapm/contrib/sanic/__init__.py b/elasticapm/contrib/sanic/__init__.py new file mode 100644 index 000000000..991ce69d8 --- /dev/null +++ b/elasticapm/contrib/sanic/__init__.py @@ -0,0 +1,353 @@ +# BSD 3-Clause License +# +# Copyright (c) 2012, the Sentry Team, see AUTHORS for more details +# Copyright (c) 2019, Elasticsearch BV +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + + +from __future__ import absolute_import + +import sys +import typing as t + +from sanic import Sanic +from sanic.request import Request +from sanic.response import HTTPResponse + +from elasticapm import label +from elasticapm import set_context as elastic_context +from elasticapm import ( + set_custom_context, + set_transaction_name, + set_transaction_outcome, + set_transaction_result, + set_user_context, +) +from elasticapm.base import Client +from elasticapm.conf import constants +from elasticapm.contrib.asyncio.traces import set_context +from elasticapm.contrib.sanic.patch import ElasticAPMPatchedErrorHandler +from elasticapm.contrib.sanic.sanic_types import ( + AllMiddlewareGroup, + APMConfigType, + CustomContextCallbackType, + CustomInfoType, + ExtendableMiddlewareGroup, + LabelInfoCallbackType, + TransactionNameCallbackType, + UserInfoCallbackType, +) +from elasticapm.contrib.sanic.utils import SanicAPMConfig, get_request_info, get_response_info, make_client +from elasticapm.instrumentation.control import instrument +from elasticapm.utils.disttracing import TraceParent +from elasticapm.utils.logging import get_logger + + +class ElasticAPM: + """ + Sanic App Middleware for Elastic APM Capturing + + >>> app = Sanic(name="elastic-apm-sample") + + Pass the Sanic app and let the configuration be derived from it:: + + >>> apm = ElasticAPM(app=app) + + Configure the APM Client Using Custom Configurations:: + + >>> apm = ElasticAPM(app=app, config={ + "SERVICE_NAME": "elastic-apm-sample", + "SERVICE_VERSION": "v1.2.0", + "SERVER_URL": "http://eapm-server.somdomain.com:443", + "SECRET_TOKEN": "supersecrettokenstuff", + }) + + Pass a pre-build Client instance to the APM Middleware:: + + >>> apm = ElasticAPM(app=app, client=Client()) + + Pass arbitrary Server name and token to the client while initializing:: + + >>> apm = ElasticAPM(app=app, service_name="elastic-apm-sample", secret_token="supersecretthing") + + Capture an Exception:: + + >>> try: + >>> 1 / 0 + >>> except ZeroDivisionError: + >>> apm.capture_exception() + + Capture generic message:: + + >>> apm.capture_message("Some Nice message to be captured") + """ + + def __init__( + self, + app: Sanic, + client: t.Optional[Client] = None, + client_cls: t.Type[Client] = Client, + config: APMConfigType = None, + transaction_name_callback: TransactionNameCallbackType = None, + user_context_callback: UserInfoCallbackType = None, + custom_context_callback: CustomContextCallbackType = None, + label_info_callback: LabelInfoCallbackType = None, + **defaults, + ) -> None: + """ + Initialize an instance of the ElasticAPM client that will be used to configure the reset of the Application + middleware + + :param app: An instance of Sanic app server + :param client: An instance of Client if you want to leverage a custom APM client instance pre-created + :param client_cls: Base Instance of the Elastic Client to be used to setup the APM Middleware + :param config: Configuration values to be used for setting up the Elastic Client. This includes the APM server + :param transaction_name_callback: Callback method used to extract the transaction name. If nothing is provided + it will fallback to the default implementation provided by the middleware extension + :param user_context_callback: Callback method used to extract the user context information. Will be ignored + if one is not provided by the users while creating an instance of the ElasticAPM client + :param custom_context_callback: Callback method used to generate custom context information for the transaction + :param label_info_callback: Callback method used to generate custom labels/tags for the current transaction + :param defaults: Default configuration values to be used for settings up the APM client + """ + self._app = app # type: Sanic + self._client_cls = client_cls # type: type + self._client = client # type: t.Union[None, Client] + self._skip_init_middleware = defaults.pop("skip_init_middleware", False) # type: bool + self._skip_init_exception_handler = defaults.pop("skip_init_exception_handler", False) # type: bool + self._transaction_name_callback = transaction_name_callback # type: TransactionNameCallbackType + self._user_context_callback = user_context_callback # type: UserInfoCallbackType + self._custom_context_callback = custom_context_callback # type: CustomContextCallbackType + self._label_info_callback = label_info_callback # type: LabelInfoCallbackType + self._logger = get_logger("elasticapm.errors.client") + self._client_config = {} # type: t.Dict[str, str] + self._setup_client_config(config=config) + self._init_app() + + async def capture_exception(self, exc_info=None, handled=True, **kwargs): + """ + Capture a generic exception and traceback to be reported to the APM + :param exc_info: Exc info extracted from the traceback for the current exception + :param handled: Boolean indicator for if the exception is handled. + :param kwargs: additional context to be passed to the API client for capturing exception related information + :return: None + """ + assert self._client, "capture_exception called before application configuration is initialized" + return self._client.capture_exception(exc_info=exc_info, handled=handled, **kwargs) + + async def capture_message(self, message=None, param_message=None, **kwargs): + """ + Capture a generic message for the APM Client + :param message: Message information to be captured + :param param_message: + :param kwargs: additional context to be passed to the API client for capturing exception related information + :return: + """ + assert self._client, "capture_message called before application configuration is initialized" + return self._client.capture_message(message=message, param_message=param_message, **kwargs) + + def _setup_client_config(self, config: APMConfigType = None): + app_based_config = SanicAPMConfig(self._app) + if dict(app_based_config): + self._client_config = dict(app_based_config) + + if config: + self._client_config.update(config) + + # noinspection PyBroadException,PyUnresolvedReferences + def _init_app(self) -> None: + """ + Initialize all the required middleware and other application infrastructure that will perform the necessary + capture of the APM instrumentation artifacts + :return: None + """ + if not self._client: + self._client = make_client(config=self._client_config, client_cls=self._client_cls, **self._client_config) + + if not self._skip_init_exception_handler: + self._setup_exception_manager() + + if self._client.config.instrument and self._client.config.enabled: + instrument() + try: + from elasticapm.contrib.celery import register_instrumentation + + register_instrumentation(client=self._client) + except ImportError: + self._logger.debug( + "Failed to setup instrumentation. " + "Please install requirements for elasticapm.contrib.celery if instrumentation is required" + ) + pass + + if not self._skip_init_middleware: + self._setup_request_handler(entity=self._app) + + # noinspection PyMethodMayBeStatic,PyBroadException + def _default_transaction_name_generator(self, request: Request) -> str: + """ + Method used to extract the default transaction name. This is generated by joining the HTTP method and the + URL path used for invoking the API handler + :param request: Sanic HTTP Request object + :return: string containing the Transaction name + """ + url_template = request.path + # Sanic's new router puts this into the request itself so that it can be accessed easily + # On Exception with `NotFound` with new Sanic Router, the `route` object will be None + # This check is to enforce that limitation + if hasattr(request, "route") and request.route: + url_template = request.route.path + url_template = f"/{url_template}" if not url_template.startswith("/") else url_template + else: + # Let us fallback to using old router model to extract the info + try: + _, _, _, url_template, _, _ = self._app.router.get(request=request) + except Exception: + pass + + return f"{request.method} {url_template}" + + # noinspection PyMethodMayBeStatic + async def _setup_default_custom_context(self, request: Request) -> CustomInfoType: + return request.match_info + + def setup_middleware(self, entity: ExtendableMiddlewareGroup): + """ + Adhoc registration of the middlewares for Blueprint and BlueprintGroup if you don't want to instrument + your entire application. Only part of it can be done. + :param entity: Blueprint or BlueprintGroup Kind of resource + :return: None + """ + self._setup_request_handler(entity=entity) + + def _setup_request_handler(self, entity: AllMiddlewareGroup) -> None: + """ + This method is used to setup a series of Sanic Application level middleware so that they can be applied to all + the routes being registered under the app easily. + + :param entity: entity: Sanic APP or Blueprint or BlueprintGroup Kind of resource + :return: None + """ + + @entity.middleware("request") + async def _instrument_request(request: Request): + if not self._client.should_ignore_url(url=request.path): + trace_parent = TraceParent.from_headers(headers=request.headers) + self._client.begin_transaction("request", trace_parent=trace_parent) + await set_context( + lambda: get_request_info(config=self._client.config, request=request), + "request", + ) + self._setup_transaction_name(request=request) + if self._user_context_callback: + name, email, uid = await self._user_context_callback(request) + set_user_context(username=name, email=email, user_id=uid) + + await self._setup_custom_context(request=request) + + if self._label_info_callback: + labels = await self._label_info_callback(request) + label(**labels) + + # noinspection PyUnusedLocal + @entity.middleware("response") + async def _instrument_response(request: Request, response: HTTPResponse): + await set_context( + lambda: get_response_info( + config=self._client.config, + response=response, + ), + "response", + ) + self._setup_transaction_name(request=request) + result = f"HTTP {response.status // 100}xx" + set_transaction_result(result=result, override=False) + set_transaction_outcome(http_status_code=response.status, override=False) + elastic_context(data={"status_code": response.status}, key="response") + self._client.end_transaction() + + def _setup_transaction_name(self, request: Request) -> None: + """ + Method used to setup the transaction name using the provided callback or the default mode + :param request: Incoming HTTP Request entity + :return: None + """ + if self._transaction_name_callback: + name = self._transaction_name_callback(request) + else: + name = self._default_transaction_name_generator(request=request) + if name: + set_transaction_name(name, override=False) + + async def _setup_custom_context(self, request: Request): + if self._custom_context_callback: + set_custom_context(data=await self._custom_context_callback(request)) + else: + set_custom_context(data=await self._setup_default_custom_context(request=request)) + + # noinspection PyBroadException,PyProtectedMember + def _setup_exception_manager(self): + """ + Setup global exception handler where all unhandled exception can be caught and tracked to APM server + :return: + """ + + # noinspection PyUnusedLocal + async def _handler(request: Request, exception: BaseException): + if not self._client: + return + + self._client.capture_exception( + exc_info=sys.exc_info(), + context={ + "request": await get_request_info(config=self._client.config, request=request), + }, + handled=True, + ) + self._setup_transaction_name(request=request) + set_transaction_result(result="HTTP 5xx", override=False) + set_transaction_outcome(outcome=constants.OUTCOME.FAILURE, override=False) + elastic_context(data={"status_code": 500}, key="response") + self._client.end_transaction() + + if not isinstance(self._app.error_handler, ElasticAPMPatchedErrorHandler): + patched_client = ElasticAPMPatchedErrorHandler(current_handler=self._app.error_handler) + patched_client.setup_apm_handler(apm_handler=_handler) + self._app.error_handler = patched_client + else: + self._app.error_handler.setup_apm_handler(apm_handler=_handler) + + try: + from elasticapm.contrib.celery import register_exception_tracking + + register_exception_tracking(client=self._client) + except ImportError: + self._logger.debug( + "Failed to setup Exception tracking. " + "Please install requirements for elasticapm.contrib.celery if exception tracking is required" + ) + pass diff --git a/elasticapm/contrib/sanic/patch.py b/elasticapm/contrib/sanic/patch.py new file mode 100644 index 000000000..72986d590 --- /dev/null +++ b/elasticapm/contrib/sanic/patch.py @@ -0,0 +1,92 @@ +# BSD 3-Clause License +# +# Copyright (c) 2012, the Sentry Team, see AUTHORS for more details +# Copyright (c) 2019, Elasticsearch BV +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + +from inspect import isawaitable, iscoroutinefunction + +from sanic.handlers import ErrorHandler +from sanic.log import logger +from sanic.response import text + +from elasticapm.contrib.sanic.sanic_types import ApmHandlerType + + +class ElasticAPMPatchedErrorHandler(ErrorHandler): + """ + This is a monkey patchable instance of the Sanic's Error handler infra. Current implementation of Sanic doesn't + let you chain exception handlers by raising exception chains further down the line. In order to bypass this + limitation, we monkey patch the current implementation. We invoke the instrumentation function first and then + chain the exception down to the original handler so that we don't get in the way of standard exception handling. + """ + + def __init__(self, current_handler: ErrorHandler): + super(ElasticAPMPatchedErrorHandler, self).__init__() + self._current_handler = current_handler # type: ErrorHandler + self._apm_handler = None # type: ApmHandlerType + + def add(self, exception, handler, *args, **kwargs): + self._current_handler.add(exception, handler, *args, **kwargs) + + def lookup(self, exception, *args, **kwargs): + return self._current_handler.lookup(exception, *args, **kwargs) + + def setup_apm_handler(self, apm_handler: ApmHandlerType, force: bool = False): + if self._apm_handler is None or force: + self._apm_handler = apm_handler + + async def _patched_response(self, request, exception): + await self._apm_handler(request, exception) + handler = self._current_handler.lookup(exception) + response = None + try: + if handler: + response = handler(request, exception) + if response is None: + response = self._current_handler.default(request, exception) + except Exception: + try: + url = repr(request.url) + except AttributeError: + url = "unknown" + response_message = "Exception raised in exception handler " '"%s" for uri: %s' + logger.exception(response_message, handler.__name__, url) + + if self.debug: + return text(response_message % (handler.__name__, url), 500) + else: + return text("An error occurred while handling an error", 500) + if iscoroutinefunction(response) or isawaitable(response): + return await response + return response + + def response(self, request, exception): + return self._patched_response(request=request, exception=exception) + + def default(self, request, exception): + return self._patched_response(request=request, exception=exception) diff --git a/elasticapm/contrib/sanic/sanic_types.py b/elasticapm/contrib/sanic/sanic_types.py new file mode 100644 index 000000000..40c8bcc6a --- /dev/null +++ b/elasticapm/contrib/sanic/sanic_types.py @@ -0,0 +1,61 @@ +# BSD 3-Clause License +# +# Copyright (c) 2012, the Sentry Team, see AUTHORS for more details +# Copyright (c) 2019, Elasticsearch BV +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + +import typing as t + +from sanic import Sanic +from sanic.blueprint_group import BlueprintGroup +from sanic.blueprints import Blueprint +from sanic.request import Request +from sanic.response import HTTPResponse + +UserInfoType = t.Tuple[t.Optional[t.Any], t.Optional[t.Any], t.Optional[t.Any]] +LabelInfoType = t.Dict[str, t.Union[str, bool, int, float]] +CustomInfoType = t.Dict[str, t.Any] + +SanicRequestOrResponse = t.Union[Request, HTTPResponse] + +ApmHandlerType = t.Optional[t.Callable[[Request, BaseException], t.Coroutine[t.Any, t.Any, None]]] + +EnvInfoType = t.Iterable[t.Tuple[str, str]] + +TransactionNameCallbackType = t.Optional[t.Callable[[Request], str]] + +UserInfoCallbackType = t.Optional[t.Callable[[Request], t.Awaitable[UserInfoType]]] + +CustomContextCallbackType = t.Optional[t.Callable[[SanicRequestOrResponse], t.Awaitable[CustomInfoType]]] + +LabelInfoCallbackType = t.Optional[t.Callable[[SanicRequestOrResponse], t.Awaitable[LabelInfoType]]] + +APMConfigType = t.Optional[t.Union[t.Dict[str, t.Any], t.Dict[bytes, t.Any]]] + +ExtendableMiddlewareGroup = t.Union[Blueprint, BlueprintGroup] + +AllMiddlewareGroup = t.Union[Sanic, Blueprint, BlueprintGroup] diff --git a/elasticapm/contrib/sanic/utils.py b/elasticapm/contrib/sanic/utils.py new file mode 100644 index 000000000..e4700fc8a --- /dev/null +++ b/elasticapm/contrib/sanic/utils.py @@ -0,0 +1,147 @@ +# BSD 3-Clause License +# +# Copyright (c) 2012, the Sentry Team, see AUTHORS for more details +# Copyright (c) 2019, Elasticsearch BV +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + +from typing import Dict + +from sanic import Sanic +from sanic import __version__ as version +from sanic.cookies import CookieJar +from sanic.request import Request +from sanic.response import HTTPResponse + +from elasticapm.base import Client +from elasticapm.conf import Config, constants +from elasticapm.contrib.sanic.sanic_types import EnvInfoType +from elasticapm.utils import compat, get_url_dict + + +class SanicAPMConfig(dict): + def __init__(self, app: Sanic): + super(SanicAPMConfig, self).__init__() + for _key, _v in app.config.items(): + if _key.startswith("ELASTIC_APM_"): + self[_key.replace("ELASTIC_APM_", "")] = _v + + +def get_env(request: Request) -> EnvInfoType: + """ + Extract Server Environment Information from the current Request's context + :param request: Inbound HTTP Request + :return: A tuple containing the attribute and it's corresponding value for the current Application ENV + """ + for _attr in ("server_name", "server_port", "version"): + if hasattr(request, _attr): + yield _attr, getattr(request, _attr) + + +# noinspection PyBroadException +async def get_request_info(config: Config, request: Request) -> Dict[str, str]: + """ + Generate a traceable context information from the inbound HTTP request + + :param config: Application Configuration used to tune the way the data is captured + :param request: Inbound HTTP request + :return: A dictionary containing the context information of the ongoing transaction + """ + env = dict(get_env(request=request)) + env.update(dict(request.app.config)) + result = { + "env": env, + "method": request.method, + "socket": { + "remote_address": _get_client_ip(request=request), + "encrypted": request.scheme in ["https", "wss"], + }, + "cookies": request.cookies, + "http_version": request.version, + } + if config.capture_headers: + result["headers"] = dict(request.headers) + + if request.method in constants.HTTP_WITH_BODY and config.capture_body: + if request.content_type.startswith("multipart") or "octet-stream" in request.content_type: + result["body"] = "[DISCARDED]" + try: + result["body"] = request.body.decode("utf-8") + except Exception: + pass + + if "body" not in result: + result["body"] = "[REDACTED]" + result["url"] = get_url_dict(request.url) + return result + + +async def get_response_info(config: Config, response: HTTPResponse) -> Dict[str, str]: + """ + Generate a traceable context information from the inbound HTTP Response + + :param config: Application Configuration used to tune the way the data is captured + :param response: outbound HTTP Response + :return: A dictionary containing the context information of the ongoing transaction + """ + result = { + "cookies": _transform_response_cookie(cookies=response.cookies), + "finished": True, + "headers_sent": True, + } + if isinstance(response.status, compat.integer_types): + result["status_code"] = response.status + + if config.capture_headers: + result["headers"] = dict(response.headers) + + if config.capture_body and "octet-stream" not in response.content_type: + result["body"] = response.body.decode("utf-8") + else: + result["body"] = "[REDACTED]" + + return result + + +def _get_client_ip(request: Request) -> str: + """Extract Client IP Address Information""" + try: + return request.ip or request.socket[0] or request.remote_addr + except IndexError: + return request.remote_addr + + +def make_client(client_cls=Client, **defaults) -> Client: + if "framework_name" not in defaults: + defaults["framework_name"] = "sanic" + defaults["framework_version"] = version + + return client_cls(**defaults) + + +def _transform_response_cookie(cookies: CookieJar) -> Dict[str, str]: + """Transform the Sanic's CookieJar instance into a Normal dictionary to build the context""" + return {k: {"value": v.value, "path": v["path"]} for k, v in cookies.items()} diff --git a/elasticapm/traces.py b/elasticapm/traces.py index 83afecf7f..9fe760934 100644 --- a/elasticapm/traces.py +++ b/elasticapm/traces.py @@ -945,7 +945,7 @@ def set_transaction_result(result, override=True): Sets the result of the transaction. The result could be e.g. the HTTP status class (e.g "HTTP 5xx") for HTTP requests, or "success"/"failure" for background tasks. - :param name: the name of the transaction + :param result: Details of the transaction result that should be set :param override: if set to False, the name is only set if no name has been set before :return: None """ diff --git a/setup.cfg b/setup.cfg index 2610662b9..e6a9d938e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -96,7 +96,7 @@ tests_require = pytest-asyncio ; python_version >= '3.7' pytest-mock ; python_version >= '3.7' httpx ; python_version >= '3.6' - + sanic ; python_version >= '3.7' [options.extras_require] flask = @@ -109,6 +109,8 @@ starlette = starlette opentracing = opentracing>=2.0.0 +sanic = + sanic [options.packages.find] exclude = @@ -152,6 +154,7 @@ markers = graphene httpx prometheus_client + sanic [isort] line_length=120 diff --git a/tests/contrib/sanic/__init__.py b/tests/contrib/sanic/__init__.py new file mode 100644 index 000000000..7e2b340e6 --- /dev/null +++ b/tests/contrib/sanic/__init__.py @@ -0,0 +1,29 @@ +# BSD 3-Clause License +# +# Copyright (c) 2019, Elasticsearch BV +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/tests/contrib/sanic/fixtures.py b/tests/contrib/sanic/fixtures.py new file mode 100644 index 000000000..e88d7888e --- /dev/null +++ b/tests/contrib/sanic/fixtures.py @@ -0,0 +1,163 @@ +# BSD 3-Clause License +# +# Copyright (c) 2019, Elasticsearch BV +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import pytest # isort:skip + +sanic = pytest.importorskip("sanic") # isort:skip + +import logging +import time +import typing as t + +import pytest +from sanic import Sanic +from sanic.blueprints import Blueprint +from sanic.handlers import ErrorHandler +from sanic.request import Request +from sanic.response import HTTPResponse, json + +import elasticapm +from elasticapm import async_capture_span +from elasticapm.contrib.sanic import ElasticAPM + + +class CustomException(Exception): + pass + + +class CustomErrorHandler(ErrorHandler): + def __init__(self): + super(CustomErrorHandler, self).__init__() + + def default(self, request, exception): + return json({"source": "custom-handler-default"}, 500) + + +@pytest.fixture() +def custom_error_handler(): + return CustomErrorHandler() + + +@pytest.fixture() +def sanic_elastic_app(elasticapm_client): + def _generate( + error_handler=None, + elastic_client=None, + elastic_client_cls=None, + config=None, + transaction_name_callback=None, + user_context_callback=None, + custom_context_callback=None, + label_info_callback=None, + ): + Sanic.test_mode = True + args = {"name": "elastic-apm-test-app"} + if error_handler: + args["error_handler"] = error_handler + + app = Sanic(**args) + apm_args = {} + for key, value in { + "client": elastic_client, + "client_cls": elastic_client_cls, + "config": config, + "transaction_name_callback": transaction_name_callback, + "user_context_callback": user_context_callback, + "custom_context_callback": custom_context_callback, + "label_info_callback": label_info_callback, + }.items(): + if value is not None: + apm_args[key] = value + + apm = ElasticAPM(app=app, **apm_args) + try: + from sanic_testing import TestManager + except ImportError: + from sanic.testing import SanicTestClient as TestManager + + TestManager(app=app) + + bp = Blueprint(name="test", url_prefix="/apm", version="v1") + + @app.exception(ValueError) + async def handle_value_error(request, exception): + return json({"source": "value-error-custom"}, status=500) + + async def attribute_error_handler(request, expception): + return json({"source": "custom-handler"}, status=500) + + app.error_handler.add(AttributeError, attribute_error_handler) + + @bp.post("/unhandled-exception") + async def raise_something(request): + raise CustomException("Unhandled") + + @app.route("/", methods=["GET", "POST"]) + def default_route(request: Request): + with async_capture_span("test"): + pass + return json({"response": "ok"}) + + @app.get("/greet/") + async def greet_person(request: Request, name: str): + return json({"response": f"Hello {name}"}) + + @app.get("/capture-exception") + async def capture_exception(request): + try: + 1 / 0 + except ZeroDivisionError: + await apm.capture_exception() + return json({"response": "invalid"}, 500) + + app.blueprint(blueprint=bp) + + @app.get("/raise-exception") + async def raise_exception(request): + raise AttributeError + + @app.get("/fallback-default-error") + async def raise_default_error(request): + raise CustomException + + @app.get("/raise-value-error") + async def raise_value_error(request): + raise ValueError + + @app.get("/add-custom-headers") + async def custom_headers(request): + return json({"data": "message"}, headers={"sessionid": 1234555}) + + try: + yield app, apm + finally: + elasticapm.uninstrument() + + return _generate diff --git a/tests/contrib/sanic/sanic_tests.py b/tests/contrib/sanic/sanic_tests.py new file mode 100644 index 000000000..282c4e0cc --- /dev/null +++ b/tests/contrib/sanic/sanic_tests.py @@ -0,0 +1,198 @@ +# BSD 3-Clause License +# +# Copyright (c) 2019, Elasticsearch BV +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import pytest # isort:skip + +sanic = pytest.importorskip("sanic") # isort:skip + +from elasticapm.conf import constants + +pytestmark = [pytest.mark.sanic] # isort:skip + + +@pytest.mark.parametrize( + "url, transaction_name, span_count, custom_context", + [("/", "GET /", 1, {}), ("/greet/sanic", "GET /greet/", 0, {"name": "sanic"})], +) +def test_get(url, transaction_name, span_count, custom_context, sanic_elastic_app, elasticapm_client): + sanic_app, apm = next(sanic_elastic_app(elastic_client=elasticapm_client)) + source_request, response = sanic_app.test_client.get( + url, + headers={ + constants.TRACEPARENT_HEADER_NAME: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-03", + constants.TRACESTATE_HEADER_NAME: "foo=bar,bar=baz", + "REMOTE_ADDR": "127.0.0.1", + }, + ) # type: sanic.response.HttpResponse + + assert len(elasticapm_client.events[constants.TRANSACTION]) == 1 + transaction = elasticapm_client.events[constants.TRANSACTION][0] + spans = elasticapm_client.spans_for_transaction(transaction) + assert len(spans) == span_count + if span_count > 0: + span = spans[0] + assert span["name"] == "test" + assert transaction["span_count"]["started"] == span_count + + for field, value in { + "name": transaction_name, + "result": "HTTP 2xx", + "outcome": "success", + "type": "request", + }.items(): + assert transaction[field] == value + + request = transaction["context"]["request"] + assert request["method"] == "GET" + assert request["socket"] == {"remote_address": f"127.0.0.1", "encrypted": False} + context = transaction["context"]["custom"] + assert context == custom_context + + +def test_capture_exception(sanic_elastic_app, elasticapm_client): + sanic_app, apm = next(sanic_elastic_app(elastic_client=elasticapm_client)) + _, _ = sanic_app.test_client.get( + "/capture-exception", + headers={ + constants.TRACEPARENT_HEADER_NAME: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-03", + constants.TRACESTATE_HEADER_NAME: "foo=bar,bar=baz", + "REMOTE_ADDR": "127.0.0.1", + }, + ) + + assert len(elasticapm_client.events[constants.ERROR]) == 1 + transaction = elasticapm_client.events[constants.TRANSACTION][0] + + for field, value in { + "name": "GET /capture-exception", + "result": "HTTP 5xx", + "outcome": "failure", + "type": "request", + }.items(): + assert transaction[field] == value + + +def test_unhandled_exception_capture(sanic_elastic_app, elasticapm_client): + sanic_app, apm = next(sanic_elastic_app(elastic_client=elasticapm_client)) + _, resp = sanic_app.test_client.post( + "/v1/apm/unhandled-exception", + headers={ + constants.TRACEPARENT_HEADER_NAME: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-03", + constants.TRACESTATE_HEADER_NAME: "foo=bar,bar=baz", + "REMOTE_ADDR": "127.0.0.1", + }, + ) + assert len(elasticapm_client.events[constants.ERROR]) == 1 + transaction = elasticapm_client.events[constants.TRANSACTION][0] + for field, value in { + "name": "POST /v1/apm/unhandled-exception", + "result": "HTTP 5xx", + "outcome": "failure", + "type": "request", + }.items(): + assert transaction[field] == value + + +@pytest.mark.parametrize( + "url, expected_source", + [ + ("/raise-exception", "custom-handler"), + ("/raise-value-error", "value-error-custom"), + ("/fallback-value-error", "custom-handler-default"), + ], +) +def test_client_with_custom_error_handler( + url, expected_source, sanic_elastic_app, elasticapm_client, custom_error_handler +): + sanic_app, apm = next(sanic_elastic_app(elastic_client=elasticapm_client, error_handler=custom_error_handler)) + _, resp = sanic_app.test_client.get( + url, + headers={ + constants.TRACEPARENT_HEADER_NAME: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-03", + constants.TRACESTATE_HEADER_NAME: "foo=bar,bar=baz", + "REMOTE_ADDR": "127.0.0.1", + }, + ) + assert len(elasticapm_client.events[constants.ERROR]) == 1 + assert resp.json["source"] == expected_source + + +def test_header_field_sanitization(sanic_elastic_app, elasticapm_client): + sanic_app, apm = next(sanic_elastic_app(elastic_client=elasticapm_client)) + _, resp = sanic_app.test_client.get( + "/add-custom-headers", + headers={ + constants.TRACEPARENT_HEADER_NAME: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-03", + constants.TRACESTATE_HEADER_NAME: "foo=bar,bar=baz", + "REMOTE_ADDR": "127.0.0.1", + "API_KEY": "some-random-api-key", + }, + ) + assert len(apm._client.events[constants.TRANSACTION]) == 1 + transaction = apm._client.events[constants.TRANSACTION][0] + assert transaction["context"]["response"]["headers"]["sessionid"] == "[REDACTED]" + assert transaction["context"]["request"]["headers"]["api_key"] == "[REDACTED]" + + +def test_custom_callback_handlers(sanic_elastic_app, elasticapm_client): + def _custom_transaction_callback(request): + return "my-custom-name" + + async def _user_info_callback(request): + return "test", "test@test.com", 1234356 + + async def _label_callback(request): + return { + "label1": "value1", + "label2": 19, + } + + sanic_app, apm = next( + sanic_elastic_app( + elastic_client=elasticapm_client, + transaction_name_callback=_custom_transaction_callback, + user_context_callback=_user_info_callback, + label_info_callback=_label_callback, + ) + ) + _, resp = sanic_app.test_client.get( + "/add-custom-headers", + headers={ + constants.TRACEPARENT_HEADER_NAME: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-03", + constants.TRACESTATE_HEADER_NAME: "foo=bar,bar=baz", + "REMOTE_ADDR": "127.0.0.1", + "API_KEY": "some-random-api-key", + }, + ) + assert len(apm._client.events[constants.TRANSACTION]) == 1 + assert apm._client.events[constants.TRANSACTION][0]["name"] == "my-custom-name" + assert apm._client.events[constants.TRANSACTION][0]["context"]["user"]["username"] == "test" + assert apm._client.events[constants.TRANSACTION][0]["context"]["user"]["id"] == 1234356 + assert apm._client.events[constants.TRANSACTION][0]["context"]["tags"]["label2"] == 19 diff --git a/tests/fixtures.py b/tests/fixtures.py index a72b10678..7362727a3 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -361,6 +361,11 @@ def spans_for_transaction(self, transaction): return [span for span in self.events[SPAN] if span["transaction_id"] == transaction["id"]] +@pytest.fixture() +def temp_store_client(): + return TempStoreClient + + @pytest.fixture() def not_so_random(): old_state = random.getstate() diff --git a/tests/requirements/reqs-sanic-20.12.txt b/tests/requirements/reqs-sanic-20.12.txt new file mode 100644 index 000000000..14b917c08 --- /dev/null +++ b/tests/requirements/reqs-sanic-20.12.txt @@ -0,0 +1,2 @@ +sanic==20.12.2 +-r reqs-base.txt diff --git a/tests/requirements/reqs-sanic-newest.txt b/tests/requirements/reqs-sanic-newest.txt new file mode 100644 index 000000000..c30ea5e2b --- /dev/null +++ b/tests/requirements/reqs-sanic-newest.txt @@ -0,0 +1,3 @@ +sanic +sanic-testing +-r reqs-base.txt diff --git a/tests/scripts/envs/sanic.sh b/tests/scripts/envs/sanic.sh new file mode 100644 index 000000000..6a65b596b --- /dev/null +++ b/tests/scripts/envs/sanic.sh @@ -0,0 +1 @@ +export PYTEST_MARKER="-m sanic"