From f09e12f022b8ce38d0f0b1d24f76986b49b7eab3 Mon Sep 17 00:00:00 2001 From: Harsha Narayana Date: Mon, 1 Mar 2021 13:53:00 +0530 Subject: [PATCH 01/21] GIT-521: add support for sanic framework --- elasticapm/contrib/sanic/__init__.py | 168 +++++++++++++++++++++++++++ elasticapm/contrib/sanic/utils.py | 127 ++++++++++++++++++++ 2 files changed, 295 insertions(+) create mode 100644 elasticapm/contrib/sanic/__init__.py create mode 100644 elasticapm/contrib/sanic/utils.py diff --git a/elasticapm/contrib/sanic/__init__.py b/elasticapm/contrib/sanic/__init__.py new file mode 100644 index 000000000..a6764fb9b --- /dev/null +++ b/elasticapm/contrib/sanic/__init__.py @@ -0,0 +1,168 @@ +# 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 set_context as elastic_context +from elasticapm import set_transaction_name, set_transaction_outcome, set_transaction_result +from elasticapm.base import Client +from elasticapm.conf import constants, setup_logging +from elasticapm.contrib.asyncio.traces import set_context +from elasticapm.contrib.sanic.utils import get_request_info, get_response_info, make_client +from elasticapm.handlers.logging import LoggingHandler +from elasticapm.instrumentation.control import instrument +from elasticapm.utils.disttracing import TraceParent + + +class ElasticAPM: + def __init__( + self, + app: Sanic, + client: t.Union[None, Client] = None, + client_cls: t.Type[Client] = Client, + log_level: int = 0, + config: t.Union[None, t.Dict[str, t.Any]] = None, + transaction_name_callback: t.Union[None, t.Callable[[Request], str]] = None, + **defaults, + ) -> None: + self._app = app # type: Sanic + self._logging = log_level # type: int + self._client_cls = client_cls # type: type + self._client = client # type: t.Union[None, Client] + self._logger = None + self._skip_headers = defaults.pop("skip_headers", []) # type: t.List[str] + self._transaction_name_callback = transaction_name_callback # type: t.Union[None, t.Callable[[Request], str]] + self._init_app(config=config, **defaults) + + async def capture_exception(self, *args, **kwargs): + assert self._client, "capture_exception called before application configuration is initialized" + return self._client.capture_exception(*args, **kwargs) + + async def capture_message(self, *args, **kwargs): + assert self._client, "capture_message called before application configuration is initialized" + return self._client.capture_message(*args, **kwargs) + + # noinspection PyBroadException + def _init_app(self, config: t.Union[None, t.Dict[str, t.Any]], **defaults) -> None: + if not self._client: + cfg = config or self._app.config.get("ELASTIC_APM") + self._client = make_client(config=cfg, client_cls=self._client_cls, **defaults) + + setup_logging(LoggingHandler(client=self._client, level=10)) + 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: + pass + + self._setup_request_handler() + + def _default_transaction_name_generator(self, request: Request) -> str: + name = self._app.router.get(request=request)[-1] + return f"{request.method}_{name}" + + def _setup_request_handler(self): + @self._app.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, skip_headers=self._skip_headers + ), + "request", + ) + if self._transaction_name_callback: + name = self._transaction_name_callback(request) + else: + name = self._default_transaction_name_generator(request=request) + + set_transaction_name(name, override=False) + + # noinspection PyUnusedLocal + @self._app.middleware("response") + async def _instrument_response(request: Request, response: HTTPResponse): + await set_context( + lambda: get_response_info( + config=self._client.config, + response=response, + skip_headers=self._skip_headers, + ), + "response", + ) + result = f"HTTP {response.status // 100}xx" + set_transaction_result(result=result, override=False) + set_transaction_outcome(outcome=constants.OUTCOME.SUCCESS, override=False) + elastic_context(data={"status_code": response.status}, key="response") + self._client.end_transaction() + + # noinspection PyBroadException + def _setup_exception_manager(self): + # noinspection PyUnusedLocal + @self._app.exception(Exception) + async def _handler(request: Request, exception: Exception): + 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, skip_headers=self._skip_headers + ), + }, + custom={"app": self._app}, + handled=False, + ) + 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() + + try: + from elasticapm.contrib.celery import register_exception_tracking + + register_exception_tracking(client=self._client) + except ImportError: + pass diff --git a/elasticapm/contrib/sanic/utils.py b/elasticapm/contrib/sanic/utils.py new file mode 100644 index 000000000..09c6d6880 --- /dev/null +++ b/elasticapm/contrib/sanic/utils.py @@ -0,0 +1,127 @@ +# 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, Iterable, List, Tuple, Union + +from sanic import __version__ as version +from sanic.request import Request +from sanic.response import HTTPResponse + +from elasticapm.base import Client +from elasticapm.conf import Config, constants +from elasticapm.utils import compat, get_url_dict + + +def get_env(request: Request) -> Iterable[Tuple[str, str]]: + for _attr in ("server_name", "server_port", "version"): + if hasattr(request, _attr): + yield _attr, getattr(request, _attr) + + +def extract_header(entity: Union[Request, HTTPResponse], skip_headers: Union[None, List[str]]) -> Dict[str, str]: + header = dict(entity.headers) + if skip_headers: + for _header in skip_headers: + _ = header.pop(_header, None) + return header + + +# noinspection PyBroadException +async def get_request_info( + config: Config, request: Request, skip_headers: Union[None, List[str]] = None +) -> Dict[str, str]: + 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, + } + if config.capture_headers: + result["headers"] = extract_header(entity=request, skip_headers=skip_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, + skip_headers: Union[None, List[str]] = None, +) -> Dict[str, str]: + result = { + "cookies": response.cookies, + } + if isinstance(response.status, compat.integer_types): + result["status_code"] = response.status + + if config.capture_headers: + result["headers"] = extract_header(entity=response, skip_headers=skip_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: + x_forwarded_for = request.forwarded + if x_forwarded_for: + return x_forwarded_for.split(",")[0] + else: + if request.socket != (None, None): + return f"{request.socket[0]}:{request.socket[1]}" + elif request.ip and request.port: + return f"{request.ip}:{request.port}" + return request.remote_addr + + +def make_client(config: dict, client_cls=Client, **defaults) -> Client: + if "framework_name" not in defaults: + defaults["framework_name"] = "sanic" + defaults["framework_version"] = version + + return client_cls(config, **defaults) From f8df92aaa02a4b76781994998eaef09d94d9566e Mon Sep 17 00:00:00 2001 From: Harsha Narayana Date: Mon, 1 Mar 2021 16:10:11 +0530 Subject: [PATCH 02/21] GIT-521: add base test infra configuration --- .ci/.jenkins_exclude.yml | 9 +++- .ci/.jenkins_framework.yml | 1 + .ci/.jenkins_framework_full.yml | 2 + conftest.py | 1 + setup.cfg | 8 ++- tests/contrib/sanic/__init__.py | 29 ++++++++++ tests/contrib/sanic/fixtures.py | 68 +++++++++++++++++++++++ tests/contrib/sanic/sanic_tests.py | 69 ++++++++++++++++++++++++ tests/requirements/reqs-sanic-20.12.txt | 3 ++ tests/requirements/reqs-sanic-newest.txt | 3 ++ tests/scripts/envs/sanic.sh | 1 + 11 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 tests/contrib/sanic/__init__.py create mode 100644 tests/contrib/sanic/fixtures.py create mode 100644 tests/contrib/sanic/sanic_tests.py create mode 100644 tests/requirements/reqs-sanic-20.12.txt create mode 100644 tests/requirements/reqs-sanic-newest.txt create mode 100644 tests/scripts/envs/sanic.sh diff --git a/.ci/.jenkins_exclude.yml b/.ci/.jenkins_exclude.yml index fee4968db..15a4f89b4 100644 --- a/.ci/.jenkins_exclude.yml +++ b/.ci/.jenkins_exclude.yml @@ -177,7 +177,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 @@ -216,5 +216,10 @@ 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 diff --git a/.ci/.jenkins_framework.yml b/.ci/.jenkins_framework.yml index 0937af7a8..35d6c140b 100644 --- a/.ci/.jenkins_framework.yml +++ b/.ci/.jenkins_framework.yml @@ -44,3 +44,4 @@ FRAMEWORK: - httpx-newest - httplib2-newest - prometheus_client-newest + - sanic-newest diff --git a/.ci/.jenkins_framework_full.yml b/.ci/.jenkins_framework_full.yml index 22da4d611..2635a061c 100644 --- a/.ci/.jenkins_framework_full.yml +++ b/.ci/.jenkins_framework_full.yml @@ -78,3 +78,5 @@ FRAMEWORK: - httpx-0.14 - httpx-newest - httplib2-newest + - sanic-20.12 + - sanic-newest diff --git a/conftest.py b/conftest.py index cd8588df0..3caf31c99 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/setup.cfg b/setup.cfg index 8b12c72b5..8d06fc73b 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.6' [options.extras_require] flask = @@ -109,6 +109,8 @@ starlette = starlette opentracing = opentracing>=2.0.0 +sanic = + sanic [options.packages.find] exclude = @@ -148,7 +150,11 @@ markers = starlette graphene httpx +<<<<<<< HEAD prometheus_client +======= + sanic +>>>>>>> GIT-521: add base test infra configuration [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..52be5a4bd --- /dev/null +++ b/tests/contrib/sanic/fixtures.py @@ -0,0 +1,68 @@ +# 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 pytest +from sanic import Sanic +from sanic.request import Request +from sanic.response import HTTPResponse, json +from sanic_testing import TestManager + +import elasticapm +from elasticapm import async_capture_span +from elasticapm.contrib.sanic import ElasticAPM + + +@pytest.fixture() +def sanic_app(elasticapm_client): + app = Sanic(name="elastic-apm") + ElasticAPM(app=app, client=elasticapm_client) + TestManager(app=app) + + @app.route("/", methods=["GET", "POST"]) + def default_route(request: Request): + with async_capture_span("test"): + pass + return json({"response": "ok"}) + + @app.route("/greet/") + async def greet_person(request: Request, name: str): + return json({"response": f"Hello {name}"}) + + try: + yield app + finally: + elasticapm.uninstrument() diff --git a/tests/contrib/sanic/sanic_tests.py b/tests/contrib/sanic/sanic_tests.py new file mode 100644 index 000000000..4af956fa8 --- /dev/null +++ b/tests/contrib/sanic/sanic_tests.py @@ -0,0 +1,69 @@ +# 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 + + +def test_get(sanic_app, elasticapm_client): + source_request, response = sanic_app.test_client.get( + "/", + 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) == 1 + span = spans[0] + + for field, value in { + "name": "GET_elastic-apm.default_route", + "result": "HTTP 2xx", + "outcome": "success", + "type": "request", + }.items(): + assert transaction[field] == value + + assert transaction["span_count"]["started"] == 1 + request = transaction["context"]["request"] + assert request["method"] == "GET" + assert request["socket"] == {"remote_address": f"127.0.0.1:{source_request.port}", "encrypted": False} + + assert span["name"] == "test" diff --git a/tests/requirements/reqs-sanic-20.12.txt b/tests/requirements/reqs-sanic-20.12.txt new file mode 100644 index 000000000..f3f559f0d --- /dev/null +++ b/tests/requirements/reqs-sanic-20.12.txt @@ -0,0 +1,3 @@ +sanic==20.12.2 +sanic-testing==0.3.0 +-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" From 4a3a96ec5733cf21720fe564b6d0bd77e4a7f1e7 Mon Sep 17 00:00:00 2001 From: Harsha Narayana Date: Mon, 1 Mar 2021 19:13:16 +0530 Subject: [PATCH 03/21] GIT-521: refactor and address review comments from @ahopkins --- elasticapm/contrib/sanic/__init__.py | 58 ++++++++++++++++++++++++---- elasticapm/contrib/sanic/utils.py | 21 +++++----- elasticapm/traces.py | 2 +- tests/contrib/sanic/sanic_tests.py | 2 +- 4 files changed, 64 insertions(+), 19 deletions(-) diff --git a/elasticapm/contrib/sanic/__init__.py b/elasticapm/contrib/sanic/__init__.py index a6764fb9b..45b17f0c4 100644 --- a/elasticapm/contrib/sanic/__init__.py +++ b/elasticapm/contrib/sanic/__init__.py @@ -38,15 +38,27 @@ 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_transaction_name, set_transaction_outcome, set_transaction_result +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, setup_logging +from elasticapm.conf import constants from elasticapm.contrib.asyncio.traces import set_context from elasticapm.contrib.sanic.utils import get_request_info, get_response_info, make_client -from elasticapm.handlers.logging import LoggingHandler from elasticapm.instrumentation.control import instrument from elasticapm.utils.disttracing import TraceParent +from elasticapm.utils.logging import get_logger + +user_info_type = t.Tuple[t.Union[None, str], t.Union[None, str], t.Union[None, str]] +label_info_type = t.Dict[str, t.Union[str, bool, int, float]] +custom_info_type = t.Dict[str, t.Any] +req_or_response_type = t.Union[Request, HTTPResponse] class ElasticAPM: @@ -55,18 +67,30 @@ def __init__( app: Sanic, client: t.Union[None, Client] = None, client_cls: t.Type[Client] = Client, - log_level: int = 0, config: t.Union[None, t.Dict[str, t.Any]] = None, transaction_name_callback: t.Union[None, t.Callable[[Request], str]] = None, + user_context_callback: t.Union[None, t.Callable[[Request], t.Awaitable[user_info_type]]] = None, + custom_context_callback: t.Union[ + None, t.Callable[[req_or_response_type], t.Awaitable[custom_info_type]] + ] = None, + label_info_callback: t.Union[None, t.Callable[[req_or_response_type], t.Awaitable[label_info_type]]] = None, **defaults, ) -> None: self._app = app # type: Sanic - self._logging = log_level # type: int self._client_cls = client_cls # type: type self._client = client # type: t.Union[None, Client] - self._logger = None self._skip_headers = defaults.pop("skip_headers", []) # type: t.List[str] self._transaction_name_callback = transaction_name_callback # type: t.Union[None, t.Callable[[Request], str]] + self._user_context_callback = ( + user_context_callback + ) # type: t.Union[None, t.Callable[[Request], t.Awaitable[user_info_type]]] + self._custom_context_callback = ( + custom_context_callback + ) # type: t.Union[None, t.Callable[[req_or_response_type], t.Awaitable[custom_info_type]]] + self._label_info_callback = ( + label_info_callback + ) # type: t.Union[None, t.Callable[[req_or_response_type], t.Awaitable[label_info_type]]] + self._logger = get_logger("elasticapm.errors.client") self._init_app(config=config, **defaults) async def capture_exception(self, *args, **kwargs): @@ -83,7 +107,6 @@ def _init_app(self, config: t.Union[None, t.Dict[str, t.Any]], **defaults) -> No cfg = config or self._app.config.get("ELASTIC_APM") self._client = make_client(config=cfg, client_cls=self._client_cls, **defaults) - setup_logging(LoggingHandler(client=self._client, level=10)) self._setup_exception_manager() if self._client.config.instrument and self._client.config.enabled: @@ -93,6 +116,10 @@ def _init_app(self, config: t.Union[None, t.Dict[str, t.Any]], **defaults) -> No 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 self._setup_request_handler() @@ -120,6 +147,17 @@ async def _instrument_request(request: Request): set_transaction_name(name, override=False) + if self._user_context_callback: + name, email, uid = await self._user_context_callback(request) + set_user_context(username=name, email=email, user_id=uid) + + if self._custom_context_callback: + set_custom_context(data=await self._custom_context_callback(request)) + + if self._label_info_callback: + labels = await self._label_info_callback(request) + label(**labels) + # noinspection PyUnusedLocal @self._app.middleware("response") async def _instrument_response(request: Request, response: HTTPResponse): @@ -133,7 +171,7 @@ async def _instrument_response(request: Request, response: HTTPResponse): ) result = f"HTTP {response.status // 100}xx" set_transaction_result(result=result, override=False) - set_transaction_outcome(outcome=constants.OUTCOME.SUCCESS, 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() @@ -165,4 +203,8 @@ async def _handler(request: Request, exception: Exception): 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/utils.py b/elasticapm/contrib/sanic/utils.py index 09c6d6880..669d2b8fa 100644 --- a/elasticapm/contrib/sanic/utils.py +++ b/elasticapm/contrib/sanic/utils.py @@ -31,6 +31,7 @@ from typing import Dict, Iterable, List, Tuple, Union from sanic import __version__ as version +from sanic.cookies import CookieJar from sanic.request import Request from sanic.response import HTTPResponse @@ -67,6 +68,7 @@ async def get_request_info( "encrypted": request.scheme in ["https", "wss"], }, "cookies": request.cookies, + "http_version": request.version, } if config.capture_headers: result["headers"] = extract_header(entity=request, skip_headers=skip_headers) @@ -91,7 +93,9 @@ async def get_response_info( skip_headers: Union[None, List[str]] = None, ) -> Dict[str, str]: result = { - "cookies": response.cookies, + "cookies": _transform_response_cookie(cookies=response.cookies), + "finished": True, + "headers_sent": True, } if isinstance(response.status, compat.integer_types): result["status_code"] = response.status @@ -108,14 +112,9 @@ async def get_response_info( def _get_client_ip(request: Request) -> str: - x_forwarded_for = request.forwarded - if x_forwarded_for: - return x_forwarded_for.split(",")[0] - else: - if request.socket != (None, None): - return f"{request.socket[0]}:{request.socket[1]}" - elif request.ip and request.port: - return f"{request.ip}:{request.port}" + try: + return request.ip or request.socket[0] or request.remote_addr + except IndexError: return request.remote_addr @@ -125,3 +124,7 @@ def make_client(config: dict, client_cls=Client, **defaults) -> Client: defaults["framework_version"] = version return client_cls(config, **defaults) + + +def _transform_response_cookie(cookies: CookieJar) -> Dict[str, str]: + 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 f78dae996..ed6856c0d 100644 --- a/elasticapm/traces.py +++ b/elasticapm/traces.py @@ -747,7 +747,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"/"fail" 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/tests/contrib/sanic/sanic_tests.py b/tests/contrib/sanic/sanic_tests.py index 4af956fa8..b8505d18b 100644 --- a/tests/contrib/sanic/sanic_tests.py +++ b/tests/contrib/sanic/sanic_tests.py @@ -64,6 +64,6 @@ def test_get(sanic_app, elasticapm_client): assert transaction["span_count"]["started"] == 1 request = transaction["context"]["request"] assert request["method"] == "GET" - assert request["socket"] == {"remote_address": f"127.0.0.1:{source_request.port}", "encrypted": False} + assert request["socket"] == {"remote_address": f"127.0.0.1", "encrypted": False} assert span["name"] == "test" From 3122e0325e1167f51a09f399c3721c048f120822 Mon Sep 17 00:00:00 2001 From: Harsha Narayana Date: Mon, 1 Mar 2021 19:41:17 +0530 Subject: [PATCH 04/21] GIT-521: fix transaction name generation workflow --- elasticapm/contrib/sanic/__init__.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/elasticapm/contrib/sanic/__init__.py b/elasticapm/contrib/sanic/__init__.py index 45b17f0c4..45963ba20 100644 --- a/elasticapm/contrib/sanic/__init__.py +++ b/elasticapm/contrib/sanic/__init__.py @@ -124,8 +124,9 @@ def _init_app(self, config: t.Union[None, t.Dict[str, t.Any]], **defaults) -> No self._setup_request_handler() + # noinspection PyMethodMayBeStatic def _default_transaction_name_generator(self, request: Request) -> str: - name = self._app.router.get(request=request)[-1] + name = request.endpoint return f"{request.method}_{name}" def _setup_request_handler(self): @@ -140,13 +141,7 @@ async def _instrument_request(request: Request): ), "request", ) - if self._transaction_name_callback: - name = self._transaction_name_callback(request) - else: - name = self._default_transaction_name_generator(request=request) - - set_transaction_name(name, override=False) - + 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) @@ -169,12 +164,21 @@ async def _instrument_response(request: Request, response: HTTPResponse): ), "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: + 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) + # noinspection PyBroadException def _setup_exception_manager(self): # noinspection PyUnusedLocal @@ -193,6 +197,7 @@ async def _handler(request: Request, exception: Exception): custom={"app": self._app}, handled=False, ) + 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") From 4bc656d807656e0278ace66023cef1b0fc9bcfe1 Mon Sep 17 00:00:00 2001 From: Harsha Narayana Date: Sat, 6 Mar 2021 20:52:11 +0530 Subject: [PATCH 05/21] GIT-521: enable documentation for sanic contrib --- docs/sanic.asciidoc | 146 +++++++++++++++++++++++ docs/set-up.asciidoc | 3 +- docs/supported-technologies.asciidoc | 10 ++ elasticapm/contrib/sanic/__init__.py | 170 +++++++++++++++++++++++---- elasticapm/contrib/sanic/utils.py | 40 +++++++ 5 files changed, 347 insertions(+), 22 deletions(-) create mode 100644 docs/sanic.asciidoc diff --git a/docs/sanic.asciidoc b/docs/sanic.asciidoc new file mode 100644 index 000000000..9a134688f --- /dev/null +++ b/docs/sanic.asciidoc @@ -0,0 +1,146 @@ +[[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] +[[supported-starlette-and-python-versions]] +==== Supported Starlette 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 ee7fe788c..c2dba5d31 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 <>. @@ -19,4 +20,4 @@ include::./aiohttp-server.asciidoc[] include::./tornado.asciidoc[] -include::./starlette.asciidoc[] \ No newline at end of file +include::./starlette.asciidoc[] diff --git a/docs/supported-technologies.asciidoc b/docs/supported-technologies.asciidoc index 468709aff..6595c2e2f 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. @@ -69,6 +70,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 index 45963ba20..7f73ab187 100644 --- a/elasticapm/contrib/sanic/__init__.py +++ b/elasticapm/contrib/sanic/__init__.py @@ -35,6 +35,8 @@ 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 @@ -50,7 +52,7 @@ from elasticapm.base import Client from elasticapm.conf import constants from elasticapm.contrib.asyncio.traces import set_context -from elasticapm.contrib.sanic.utils import get_request_info, get_response_info, make_client +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 @@ -62,12 +64,50 @@ 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 Clinet 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.Union[None, Client] = None, client_cls: t.Type[Client] = Client, - config: t.Union[None, t.Dict[str, t.Any]] = None, + config: t.Union[None, t.Dict[str, t.Any], t.Dict[bytes, t.Any]] = None, transaction_name_callback: t.Union[None, t.Callable[[Request], str]] = None, user_context_callback: t.Union[None, t.Callable[[Request], t.Awaitable[user_info_type]]] = None, custom_context_callback: t.Union[ @@ -76,10 +116,28 @@ def __init__( label_info_callback: t.Union[None, t.Callable[[req_or_response_type], t.Awaitable[label_info_type]]] = 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_headers = defaults.pop("skip_headers", []) # type: t.List[str] + 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: t.Union[None, t.Callable[[Request], str]] self._user_context_callback = ( user_context_callback @@ -91,23 +149,52 @@ def __init__( label_info_callback ) # type: t.Union[None, t.Callable[[req_or_response_type], t.Awaitable[label_info_type]]] self._logger = get_logger("elasticapm.errors.client") - self._init_app(config=config, **defaults) + self._client_config = {} # type: t.Dict[str, str] + self._setup_client_config(config=config) + self._init_app() - async def capture_exception(self, *args, **kwargs): + 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(*args, **kwargs) + return self._client.capture_exception(exc_info=exc_info, handled=handled, **kwargs) - async def capture_message(self, *args, **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(*args, **kwargs) + return self._client.capture_message(message=message, param_message=param_message, **kwargs) - # noinspection PyBroadException - def _init_app(self, config: t.Union[None, t.Dict[str, t.Any]], **defaults) -> None: + def _setup_client_config(self, config: t.Union[None, t.Dict[str, t.Any], t.Dict[bytes, t.Any]] = 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: - cfg = config or self._app.config.get("ELASTIC_APM") - self._client = make_client(config=cfg, client_cls=self._client_cls, **defaults) + self._client = make_client(config=self._client_config, client_cls=self._client_cls, **self._client_config) - self._setup_exception_manager() + if not self._skip_init_exception_handler: + self._setup_exception_manager(entity=self._app) if self._client.config.instrument and self._client.config.enabled: instrument() @@ -122,15 +209,47 @@ def _init_app(self, config: t.Union[None, t.Dict[str, t.Any]], **defaults) -> No ) pass - self._setup_request_handler() + if not self._skip_init_middleware: + self._setup_request_handler(entity=self._app) # noinspection PyMethodMayBeStatic def _default_transaction_name_generator(self, request: Request) -> str: - name = request.endpoint - return f"{request.method}_{name}" + """ + 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 + """ + return f"{request.method} {request.path}" + + def setup_middleware(self, entity: t.Union[Blueprint, BlueprintGroup]): + """ + 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_exception_handler(self, entity: t.Union[Blueprint, BlueprintGroup]): + """ + 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_exception_manager(entity=entity) + + def _setup_request_handler(self, entity: t.Union[Sanic, Blueprint, BlueprintGroup]) -> 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 + """ - def _setup_request_handler(self): - @self._app.middleware("request") + @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) @@ -154,7 +273,7 @@ async def _instrument_request(request: Request): label(**labels) # noinspection PyUnusedLocal - @self._app.middleware("response") + @entity.middleware("response") async def _instrument_response(request: Request, response: HTTPResponse): await set_context( lambda: get_response_info( @@ -172,6 +291,11 @@ async def _instrument_response(request: Request, response: HTTPResponse): 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: @@ -180,9 +304,14 @@ def _setup_transaction_name(self, request: Request) -> None: set_transaction_name(name, override=False) # noinspection PyBroadException - def _setup_exception_manager(self): + def _setup_exception_manager(self, entity: t.Union[Sanic, Blueprint, BlueprintGroup]): + """ + Setup global exception handler where all unhandled exception can be caught and tracked to APM server + :param entity: entity: Sanic APP or Blueprint or BlueprintGroup Kind of resource + :return: + """ # noinspection PyUnusedLocal - @self._app.exception(Exception) + @entity.exception(Exception) async def _handler(request: Request, exception: Exception): if not self._client: return @@ -194,7 +323,6 @@ async def _handler(request: Request, exception: Exception): config=self._client.config, request=request, skip_headers=self._skip_headers ), }, - custom={"app": self._app}, handled=False, ) self._setup_transaction_name(request=request) diff --git a/elasticapm/contrib/sanic/utils.py b/elasticapm/contrib/sanic/utils.py index 669d2b8fa..7477e5504 100644 --- a/elasticapm/contrib/sanic/utils.py +++ b/elasticapm/contrib/sanic/utils.py @@ -30,6 +30,7 @@ from typing import Dict, Iterable, List, Tuple, Union +from sanic import Sanic from sanic import __version__ as version from sanic.cookies import CookieJar from sanic.request import Request @@ -40,13 +41,34 @@ 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) -> Iterable[Tuple[str, str]]: + """ + 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) def extract_header(entity: Union[Request, HTTPResponse], skip_headers: Union[None, List[str]]) -> Dict[str, str]: + """ + Extract the necessary headers from the Incoming request. This method also provides a way to skip + certain headers from being included in the trace and they might contain sensitive information such + as the JWT Token and they might not always need to be tracked. + :param entity: HTTP Request or the HTTP Response Entity + :param skip_headers: A list of String indicating which headers to be skipped while extracting the headers + :return: + """ header = dict(entity.headers) if skip_headers: for _header in skip_headers: @@ -58,6 +80,14 @@ def extract_header(entity: Union[Request, HTTPResponse], skip_headers: Union[Non async def get_request_info( config: Config, request: Request, skip_headers: Union[None, List[str]] = None ) -> 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 + :param skip_headers: A list of String indicating which headers to be skipped while extracting the headers + :return: A dictionary containing the context information of the ongoing transaction + """ env = dict(get_env(request=request)) env.update(dict(request.app.config)) result = { @@ -92,6 +122,14 @@ async def get_response_info( response: HTTPResponse, skip_headers: Union[None, List[str]] = None, ) -> 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 + :param skip_headers: A list of String indicating which headers to be skipped while extracting the headers + :return: A dictionary containing the context information of the ongoing transaction + """ result = { "cookies": _transform_response_cookie(cookies=response.cookies), "finished": True, @@ -112,6 +150,7 @@ async def get_response_info( 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: @@ -127,4 +166,5 @@ def make_client(config: dict, client_cls=Client, **defaults) -> Client: 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()} From 50374e02cd43baa1c43ccc3ba8fd865af8e899bb Mon Sep 17 00:00:00 2001 From: Harsha Narayana Date: Sat, 6 Mar 2021 21:03:38 +0530 Subject: [PATCH 06/21] GIT-521: add callback info to documentation --- docs/sanic.asciidoc | 37 ++- docs/sanic.html | 663 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 698 insertions(+), 2 deletions(-) create mode 100644 docs/sanic.html diff --git a/docs/sanic.asciidoc b/docs/sanic.asciidoc index 9a134688f..4328ab834 100644 --- a/docs/sanic.asciidoc +++ b/docs/sanic.asciidoc @@ -134,10 +134,43 @@ apm = ElasticAPM(app=app, config={ 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, useremain_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-starlette-and-python-versions]] -==== Supported Starlette and Python versions +[[supported-stanic-and-python-versions]] +==== Supported Sanic and Python versions A list of supported <> and <> versions can be found on our diff --git a/docs/sanic.html b/docs/sanic.html new file mode 100644 index 000000000..817caa602 --- /dev/null +++ b/docs/sanic.html @@ -0,0 +1,663 @@ + + + + + + + +Sanic Support + + + + + +
+
+

Sanic Support

+
+

Incorporating Elastic APM into your Sanic project only requires a few easy +steps.

+
+

Installation

+
+

Install the Elastic APM agent using pip:

+
+
+
+
$ pip install elastic-apm
+
+
+
+

or add elastic-apm to your project’s requirements.txt file.

+
+

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 +Configuration page.

+
+
+

To initialize the agent for your application using environment variables:

+
+
+
+
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:

+
+
+
+
# 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"
+
+
+
+
+
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)
+
+
+

Usage

+
+

Once you have configured the agent, it will automatically track transactions +and capture uncaught exceptions within sanic.

+
+
+

Capture an arbitrary exception by calling +capture_exception:

+
+
+
+
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 capture_message:

+
+
+
+
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!')
+
+
+

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. +
+
+
Ignoring specific routes
+
+

You can use the +TRANSACTIONS_IGNORE_PATTERNS +configuration option to ignore specific routes. The list given should be a +list of regular expressions which are matched against the transaction name:

+
+
+
+
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.

+
+

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.

+
+ ++++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

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, useremain_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

+

Supported Starlette and Python versions

+
+

A list of supported Sanic and +Python versions can be found on our +Supported Technologies page.

+
+
+ + + + + +
+
Note
+
+Elastic APM only supports asyncio when using Python 3.7+ +
+
+
+
+ + + \ No newline at end of file From 5e8ed0eba186f0b2c1f08c6b0c0f8357351ad26f Mon Sep 17 00:00:00 2001 From: Harsha Narayana Date: Sat, 6 Mar 2021 21:16:05 +0530 Subject: [PATCH 07/21] GIT-521: cleanup invalid html file --- docs/sanic.asciidoc | 2 +- docs/sanic.html | 663 -------------------------------------------- 2 files changed, 1 insertion(+), 664 deletions(-) delete mode 100644 docs/sanic.html diff --git a/docs/sanic.asciidoc b/docs/sanic.asciidoc index 4328ab834..83f8fd540 100644 --- a/docs/sanic.asciidoc +++ b/docs/sanic.asciidoc @@ -154,7 +154,7 @@ in order to simplify the process of generating additional contexts into the trac | user_context_callback | user_context_callback(request) -| (username_string, useremain_string, userid_string) +| (username_string, user_email_string, userid_string) | true | custom_context_callback diff --git a/docs/sanic.html b/docs/sanic.html deleted file mode 100644 index 817caa602..000000000 --- a/docs/sanic.html +++ /dev/null @@ -1,663 +0,0 @@ - - - - - - - -Sanic Support - - - - - -
-
-

Sanic Support

-
-

Incorporating Elastic APM into your Sanic project only requires a few easy -steps.

-
-

Installation

-
-

Install the Elastic APM agent using pip:

-
-
-
-
$ pip install elastic-apm
-
-
-
-

or add elastic-apm to your project’s requirements.txt file.

-
-

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 -Configuration page.

-
-
-

To initialize the agent for your application using environment variables:

-
-
-
-
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:

-
-
-
-
# 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"
-
-
-
-
-
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)
-
-
-

Usage

-
-

Once you have configured the agent, it will automatically track transactions -and capture uncaught exceptions within sanic.

-
-
-

Capture an arbitrary exception by calling -capture_exception:

-
-
-
-
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 capture_message:

-
-
-
-
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!')
-
-
-

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. -
-
-
Ignoring specific routes
-
-

You can use the -TRANSACTIONS_IGNORE_PATTERNS -configuration option to ignore specific routes. The list given should be a -list of regular expressions which are matched against the transaction name:

-
-
-
-
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.

-
-

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.

-
- ------ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

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, useremain_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

-

Supported Starlette and Python versions

-
-

A list of supported Sanic and -Python versions can be found on our -Supported Technologies page.

-
-
- - - - - -
-
Note
-
-Elastic APM only supports asyncio when using Python 3.7+ -
-
-
-
- - - \ No newline at end of file From 30c24c64cbc657a1f03af414e10696ed1532ba7d Mon Sep 17 00:00:00 2001 From: Harsha Narayana Date: Sat, 6 Mar 2021 23:26:29 +0530 Subject: [PATCH 08/21] GIT-521: add monkey patch for exception handler --- elasticapm/contrib/sanic/__init__.py | 12 +++++- elasticapm/contrib/sanic/patch.py | 64 ++++++++++++++++++++++++++++ tests/contrib/sanic/fixtures.py | 29 ++++++++++++- tests/contrib/sanic/sanic_tests.py | 44 ++++++++++++++++++- 4 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 elasticapm/contrib/sanic/patch.py diff --git a/elasticapm/contrib/sanic/__init__.py b/elasticapm/contrib/sanic/__init__.py index 7f73ab187..f8722fb01 100644 --- a/elasticapm/contrib/sanic/__init__.py +++ b/elasticapm/contrib/sanic/__init__.py @@ -52,6 +52,7 @@ 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.utils import SanicAPMConfig, get_request_info, get_response_info, make_client from elasticapm.instrumentation.control import instrument from elasticapm.utils.disttracing import TraceParent @@ -303,15 +304,15 @@ def _setup_transaction_name(self, request: Request) -> None: if name: set_transaction_name(name, override=False) - # noinspection PyBroadException + # noinspection PyBroadException,PyProtectedMember def _setup_exception_manager(self, entity: t.Union[Sanic, Blueprint, BlueprintGroup]): """ Setup global exception handler where all unhandled exception can be caught and tracked to APM server :param entity: entity: Sanic APP or Blueprint or BlueprintGroup Kind of resource :return: """ + # noinspection PyUnusedLocal - @entity.exception(Exception) async def _handler(request: Request, exception: Exception): if not self._client: return @@ -331,6 +332,13 @@ async def _handler(request: Request, exception: Exception): elastic_context(data={"status_code": 500}, key="response") self._client.end_transaction() + patched_client = ElasticAPMPatchedErrorHandler(apm_handler=_handler) + patched_client.handlers = self._app.error_handler.handlers + patched_client.cached_handlers = self._app.error_handler.cached_handlers + patched_client._missing = self._app.error_handler._missing + + self._app.error_handler = patched_client + try: from elasticapm.contrib.celery import register_exception_tracking diff --git a/elasticapm/contrib/sanic/patch.py b/elasticapm/contrib/sanic/patch.py new file mode 100644 index 000000000..15a3047f2 --- /dev/null +++ b/elasticapm/contrib/sanic/patch.py @@ -0,0 +1,64 @@ +# 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 inspect import isawaitable, iscoroutinefunction + +from sanic.handlers import ErrorHandler +from sanic.request import Request + + +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, apm_handler: t.Callable[[Request, Exception], t.Awaitable[None]]): + super(ElasticAPMPatchedErrorHandler, self).__init__() + self._apm_handler = apm_handler + + async def _patched_response(self, request, exception, default=False): + await self._apm_handler(request, exception) + if not default: + resp = super(ElasticAPMPatchedErrorHandler, self).response(request=request, exception=exception) + else: + resp = super(ElasticAPMPatchedErrorHandler, self).default(request=request, exception=exception) + if iscoroutinefunction(resp) or isawaitable(resp): + return await resp + return resp + + 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, default=True) diff --git a/tests/contrib/sanic/fixtures.py b/tests/contrib/sanic/fixtures.py index 52be5a4bd..80cb08b41 100644 --- a/tests/contrib/sanic/fixtures.py +++ b/tests/contrib/sanic/fixtures.py @@ -37,6 +37,7 @@ import pytest from sanic import Sanic +from sanic.blueprints import Blueprint from sanic.request import Request from sanic.response import HTTPResponse, json from sanic_testing import TestManager @@ -46,22 +47,46 @@ from elasticapm.contrib.sanic import ElasticAPM +class CustomException(Exception): + pass + + @pytest.fixture() def sanic_app(elasticapm_client): app = Sanic(name="elastic-apm") - ElasticAPM(app=app, client=elasticapm_client) + apm = ElasticAPM(app=app, client=elasticapm_client) TestManager(app=app) + bp = Blueprint(name="test", url_prefix="/apm", version="v1") + + @app.exception(Exception) + def handler(request, exception): + return json({"response": str(exception)}, 500) + + @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.route("/greet/") + @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) + try: yield app finally: diff --git a/tests/contrib/sanic/sanic_tests.py b/tests/contrib/sanic/sanic_tests.py index b8505d18b..08401c723 100644 --- a/tests/contrib/sanic/sanic_tests.py +++ b/tests/contrib/sanic/sanic_tests.py @@ -54,7 +54,7 @@ def test_get(sanic_app, elasticapm_client): span = spans[0] for field, value in { - "name": "GET_elastic-apm.default_route", + "name": "GET /", "result": "HTTP 2xx", "outcome": "success", "type": "request", @@ -67,3 +67,45 @@ def test_get(sanic_app, elasticapm_client): assert request["socket"] == {"remote_address": f"127.0.0.1", "encrypted": False} assert span["name"] == "test" + + +def test_capture_exception(sanic_app, 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_app, 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 From 159a483978d761ccbf7ddc19ff6422c9ba4d9e4d Mon Sep 17 00:00:00 2001 From: Harsha Narayana Date: Sat, 6 Mar 2021 23:35:07 +0530 Subject: [PATCH 09/21] GIT-521: mark exception as handled --- elasticapm/contrib/sanic/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/elasticapm/contrib/sanic/__init__.py b/elasticapm/contrib/sanic/__init__.py index f8722fb01..742582d89 100644 --- a/elasticapm/contrib/sanic/__init__.py +++ b/elasticapm/contrib/sanic/__init__.py @@ -324,7 +324,7 @@ async def _handler(request: Request, exception: Exception): config=self._client.config, request=request, skip_headers=self._skip_headers ), }, - handled=False, + handled=True, ) self._setup_transaction_name(request=request) set_transaction_result(result="HTTP 5xx", override=False) From 908b8e2775c46a845768e910be3c85eef8d60b00 Mon Sep 17 00:00:00 2001 From: Harsha Narayana Date: Sat, 6 Mar 2021 23:36:52 +0530 Subject: [PATCH 10/21] GIT-521: cleanup exception handler setup --- elasticapm/contrib/sanic/__init__.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/elasticapm/contrib/sanic/__init__.py b/elasticapm/contrib/sanic/__init__.py index 742582d89..07f7d436e 100644 --- a/elasticapm/contrib/sanic/__init__.py +++ b/elasticapm/contrib/sanic/__init__.py @@ -195,7 +195,7 @@ def _init_app(self) -> None: 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(entity=self._app) + self._setup_exception_manager() if self._client.config.instrument and self._client.config.enabled: instrument() @@ -232,15 +232,6 @@ def setup_middleware(self, entity: t.Union[Blueprint, BlueprintGroup]): """ self._setup_request_handler(entity=entity) - def setup_exception_handler(self, entity: t.Union[Blueprint, BlueprintGroup]): - """ - 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_exception_manager(entity=entity) - def _setup_request_handler(self, entity: t.Union[Sanic, Blueprint, BlueprintGroup]) -> None: """ This method is used to setup a series of Sanic Application level middleware so that they can be applied to all @@ -305,10 +296,9 @@ def _setup_transaction_name(self, request: Request) -> None: set_transaction_name(name, override=False) # noinspection PyBroadException,PyProtectedMember - def _setup_exception_manager(self, entity: t.Union[Sanic, Blueprint, BlueprintGroup]): + def _setup_exception_manager(self): """ Setup global exception handler where all unhandled exception can be caught and tracked to APM server - :param entity: entity: Sanic APP or Blueprint or BlueprintGroup Kind of resource :return: """ From c9d6f3b2a25b9b95077998138c07e489e44e0e57 Mon Sep 17 00:00:00 2001 From: Harsha Narayana Date: Sun, 7 Mar 2021 11:57:33 +0530 Subject: [PATCH 11/21] GIT-521: add instance check before setting up apm exception handler --- elasticapm/contrib/sanic/__init__.py | 15 +++++++++------ elasticapm/contrib/sanic/patch.py | 10 ++++++++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/elasticapm/contrib/sanic/__init__.py b/elasticapm/contrib/sanic/__init__.py index 07f7d436e..3dc69d195 100644 --- a/elasticapm/contrib/sanic/__init__.py +++ b/elasticapm/contrib/sanic/__init__.py @@ -322,12 +322,15 @@ async def _handler(request: Request, exception: Exception): elastic_context(data={"status_code": 500}, key="response") self._client.end_transaction() - patched_client = ElasticAPMPatchedErrorHandler(apm_handler=_handler) - patched_client.handlers = self._app.error_handler.handlers - patched_client.cached_handlers = self._app.error_handler.cached_handlers - patched_client._missing = self._app.error_handler._missing - - self._app.error_handler = patched_client + if not isinstance(self._app.error_handler, ElasticAPMPatchedErrorHandler): + patched_client = ElasticAPMPatchedErrorHandler() + patched_client.setup_apm_handler(apm_handler=_handler) + patched_client.handlers = self._app.error_handler.handlers + patched_client.cached_handlers = self._app.error_handler.cached_handlers + patched_client._missing = self._app.error_handler._missing + 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 diff --git a/elasticapm/contrib/sanic/patch.py b/elasticapm/contrib/sanic/patch.py index 15a3047f2..70a7aa6ee 100644 --- a/elasticapm/contrib/sanic/patch.py +++ b/elasticapm/contrib/sanic/patch.py @@ -43,9 +43,15 @@ class ElasticAPMPatchedErrorHandler(ErrorHandler): chain the exception down to the original handler so that we don't get in the way of standard exception handling. """ - def __init__(self, apm_handler: t.Callable[[Request, Exception], t.Awaitable[None]]): + def __init__(self): super(ElasticAPMPatchedErrorHandler, self).__init__() - self._apm_handler = apm_handler + self._apm_handler = None # type: t.Union[None, t.Callable[[Request, Exception], t.Awaitable[None]]] + + def setup_apm_handler( + self, apm_handler: t.Union[None, t.Callable[[Request, Exception], t.Awaitable[None]]], force: bool = False + ): + if self._apm_handler is None or force: + self._apm_handler = apm_handler async def _patched_response(self, request, exception, default=False): await self._apm_handler(request, exception) From 9cb8645779d9dbc04d94519c6db63dfdbffdb84c Mon Sep 17 00:00:00 2001 From: Harsha Narayana Date: Sun, 7 Mar 2021 19:28:16 +0530 Subject: [PATCH 12/21] GIT-521: move types into reusable file --- elasticapm/contrib/sanic/__init__.py | 47 ++++++++++--------- elasticapm/contrib/sanic/patch.py | 10 ++-- elasticapm/contrib/sanic/sanic_types.py | 61 +++++++++++++++++++++++++ elasticapm/contrib/sanic/utils.py | 11 +++-- 4 files changed, 94 insertions(+), 35 deletions(-) create mode 100644 elasticapm/contrib/sanic/sanic_types.py diff --git a/elasticapm/contrib/sanic/__init__.py b/elasticapm/contrib/sanic/__init__.py index 3dc69d195..f1f7043f2 100644 --- a/elasticapm/contrib/sanic/__init__.py +++ b/elasticapm/contrib/sanic/__init__.py @@ -35,8 +35,6 @@ 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 @@ -53,6 +51,15 @@ 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, + 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 @@ -106,15 +113,13 @@ class ElasticAPM: def __init__( self, app: Sanic, - client: t.Union[None, Client] = None, + client: t.Optional[Client] = None, client_cls: t.Type[Client] = Client, - config: t.Union[None, t.Dict[str, t.Any], t.Dict[bytes, t.Any]] = None, - transaction_name_callback: t.Union[None, t.Callable[[Request], str]] = None, - user_context_callback: t.Union[None, t.Callable[[Request], t.Awaitable[user_info_type]]] = None, - custom_context_callback: t.Union[ - None, t.Callable[[req_or_response_type], t.Awaitable[custom_info_type]] - ] = None, - label_info_callback: t.Union[None, t.Callable[[req_or_response_type], t.Awaitable[label_info_type]]] = None, + 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: """ @@ -139,16 +144,10 @@ def __init__( self._skip_headers = defaults.pop("skip_headers", []) # type: t.List[str] 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: t.Union[None, t.Callable[[Request], str]] - self._user_context_callback = ( - user_context_callback - ) # type: t.Union[None, t.Callable[[Request], t.Awaitable[user_info_type]]] - self._custom_context_callback = ( - custom_context_callback - ) # type: t.Union[None, t.Callable[[req_or_response_type], t.Awaitable[custom_info_type]]] - self._label_info_callback = ( - label_info_callback - ) # type: t.Union[None, t.Callable[[req_or_response_type], t.Awaitable[label_info_type]]] + 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) @@ -176,7 +175,7 @@ async def capture_message(self, message=None, param_message=None, **kwargs): 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: t.Union[None, t.Dict[str, t.Any], t.Dict[bytes, t.Any]] = None): + 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) @@ -223,7 +222,7 @@ def _default_transaction_name_generator(self, request: Request) -> str: """ return f"{request.method} {request.path}" - def setup_middleware(self, entity: t.Union[Blueprint, BlueprintGroup]): + 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. @@ -232,7 +231,7 @@ def setup_middleware(self, entity: t.Union[Blueprint, BlueprintGroup]): """ self._setup_request_handler(entity=entity) - def _setup_request_handler(self, entity: t.Union[Sanic, Blueprint, BlueprintGroup]) -> None: + 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. @@ -303,7 +302,7 @@ def _setup_exception_manager(self): """ # noinspection PyUnusedLocal - async def _handler(request: Request, exception: Exception): + async def _handler(request: Request, exception: BaseException): if not self._client: return diff --git a/elasticapm/contrib/sanic/patch.py b/elasticapm/contrib/sanic/patch.py index 70a7aa6ee..414aac216 100644 --- a/elasticapm/contrib/sanic/patch.py +++ b/elasticapm/contrib/sanic/patch.py @@ -28,11 +28,11 @@ # 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 inspect import isawaitable, iscoroutinefunction from sanic.handlers import ErrorHandler -from sanic.request import Request + +from elasticapm.contrib.sanic.sanic_types import ApmHandlerType class ElasticAPMPatchedErrorHandler(ErrorHandler): @@ -45,11 +45,9 @@ class ElasticAPMPatchedErrorHandler(ErrorHandler): def __init__(self): super(ElasticAPMPatchedErrorHandler, self).__init__() - self._apm_handler = None # type: t.Union[None, t.Callable[[Request, Exception], t.Awaitable[None]]] + self._apm_handler = None # type: ApmHandlerType - def setup_apm_handler( - self, apm_handler: t.Union[None, t.Callable[[Request, Exception], t.Awaitable[None]]], force: bool = False - ): + def setup_apm_handler(self, apm_handler: ApmHandlerType, force: bool = False): if self._apm_handler is None or force: self._apm_handler = apm_handler diff --git a/elasticapm/contrib/sanic/sanic_types.py b/elasticapm/contrib/sanic/sanic_types.py new file mode 100644 index 000000000..75ac9aec3 --- /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[str], t.Optional[str], t.Optional[str]] +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.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 index 7477e5504..38f14b10e 100644 --- a/elasticapm/contrib/sanic/utils.py +++ b/elasticapm/contrib/sanic/utils.py @@ -28,7 +28,7 @@ # 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, Iterable, List, Tuple, Union +from typing import Dict, List, Optional from sanic import Sanic from sanic import __version__ as version @@ -38,6 +38,7 @@ from elasticapm.base import Client from elasticapm.conf import Config, constants +from elasticapm.contrib.sanic.sanic_types import EnvInfoType, SanicRequestOrResponse from elasticapm.utils import compat, get_url_dict @@ -49,7 +50,7 @@ def __init__(self, app: Sanic): self[_key.replace("ELASTIC_APM_", "")] = _v -def get_env(request: Request) -> Iterable[Tuple[str, str]]: +def get_env(request: Request) -> EnvInfoType: """ Extract Server Environment Information from the current Request's context :param request: Inbound HTTP Request @@ -60,7 +61,7 @@ def get_env(request: Request) -> Iterable[Tuple[str, str]]: yield _attr, getattr(request, _attr) -def extract_header(entity: Union[Request, HTTPResponse], skip_headers: Union[None, List[str]]) -> Dict[str, str]: +def extract_header(entity: SanicRequestOrResponse, skip_headers: Optional[List[str]]) -> Dict[str, str]: """ Extract the necessary headers from the Incoming request. This method also provides a way to skip certain headers from being included in the trace and they might contain sensitive information such @@ -78,7 +79,7 @@ def extract_header(entity: Union[Request, HTTPResponse], skip_headers: Union[Non # noinspection PyBroadException async def get_request_info( - config: Config, request: Request, skip_headers: Union[None, List[str]] = None + config: Config, request: Request, skip_headers: Optional[List[str]] = None ) -> Dict[str, str]: """ Generate a traceable context information from the inbound HTTP request @@ -120,7 +121,7 @@ async def get_request_info( async def get_response_info( config: Config, response: HTTPResponse, - skip_headers: Union[None, List[str]] = None, + skip_headers: Optional[List[str]] = None, ) -> Dict[str, str]: """ Generate a traceable context information from the inbound HTTP Response From 3350fb06019a973f7036c0d2ecb0e0b091627c1b Mon Sep 17 00:00:00 2001 From: Harsha Narayana Date: Sun, 7 Mar 2021 19:30:10 +0530 Subject: [PATCH 13/21] GIT-521: cleanup unsed types --- elasticapm/contrib/sanic/__init__.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/elasticapm/contrib/sanic/__init__.py b/elasticapm/contrib/sanic/__init__.py index f1f7043f2..29ea551fe 100644 --- a/elasticapm/contrib/sanic/__init__.py +++ b/elasticapm/contrib/sanic/__init__.py @@ -65,11 +65,6 @@ from elasticapm.utils.disttracing import TraceParent from elasticapm.utils.logging import get_logger -user_info_type = t.Tuple[t.Union[None, str], t.Union[None, str], t.Union[None, str]] -label_info_type = t.Dict[str, t.Union[str, bool, int, float]] -custom_info_type = t.Dict[str, t.Any] -req_or_response_type = t.Union[Request, HTTPResponse] - class ElasticAPM: """ From b5c71f21b40a243617b8d8a16e8607df8f3fde24 Mon Sep 17 00:00:00 2001 From: Harsha Narayana Date: Fri, 2 Apr 2021 16:37:44 +0530 Subject: [PATCH 14/21] GIT-521: fix header sanitization and exception tracker --- elasticapm/contrib/sanic/__init__.py | 17 ++---- elasticapm/contrib/sanic/patch.py | 44 ++++++++++---- elasticapm/contrib/sanic/sanic_types.py | 2 +- elasticapm/contrib/sanic/utils.py | 40 +++---------- setup.cfg | 3 - tests/contrib/sanic/fixtures.py | 80 ++++++++++++++++++++++++- tests/contrib/sanic/sanic_tests.py | 38 ++++++++++++ tests/fixtures.py | 5 ++ tests/requirements/reqs-sanic-20.12.txt | 1 - 9 files changed, 169 insertions(+), 61 deletions(-) diff --git a/elasticapm/contrib/sanic/__init__.py b/elasticapm/contrib/sanic/__init__.py index 29ea551fe..9e01cde3c 100644 --- a/elasticapm/contrib/sanic/__init__.py +++ b/elasticapm/contrib/sanic/__init__.py @@ -136,7 +136,6 @@ def __init__( self._app = app # type: Sanic self._client_cls = client_cls # type: type self._client = client # type: t.Union[None, Client] - self._skip_headers = defaults.pop("skip_headers", []) # type: t.List[str] 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 @@ -215,7 +214,7 @@ def _default_transaction_name_generator(self, request: Request) -> str: :param request: Sanic HTTP Request object :return: string containing the Transaction name """ - return f"{request.method} {request.path}" + return f"{request.uri_template}" def setup_middleware(self, entity: ExtendableMiddlewareGroup): """ @@ -241,9 +240,7 @@ async def _instrument_request(request: Request): 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, skip_headers=self._skip_headers - ), + lambda: get_request_info(config=self._client.config, request=request), "request", ) self._setup_transaction_name(request=request) @@ -265,7 +262,6 @@ async def _instrument_response(request: Request, response: HTTPResponse): lambda: get_response_info( config=self._client.config, response=response, - skip_headers=self._skip_headers, ), "response", ) @@ -304,9 +300,7 @@ async def _handler(request: Request, exception: BaseException): self._client.capture_exception( exc_info=sys.exc_info(), context={ - "request": await get_request_info( - config=self._client.config, request=request, skip_headers=self._skip_headers - ), + "request": await get_request_info(config=self._client.config, request=request), }, handled=True, ) @@ -317,11 +311,8 @@ async def _handler(request: Request, exception: BaseException): self._client.end_transaction() if not isinstance(self._app.error_handler, ElasticAPMPatchedErrorHandler): - patched_client = ElasticAPMPatchedErrorHandler() + patched_client = ElasticAPMPatchedErrorHandler(current_handler=self._app.error_handler) patched_client.setup_apm_handler(apm_handler=_handler) - patched_client.handlers = self._app.error_handler.handlers - patched_client.cached_handlers = self._app.error_handler.cached_handlers - patched_client._missing = self._app.error_handler._missing self._app.error_handler = patched_client else: self._app.error_handler.setup_apm_handler(apm_handler=_handler) diff --git a/elasticapm/contrib/sanic/patch.py b/elasticapm/contrib/sanic/patch.py index 414aac216..78c1bb2fd 100644 --- a/elasticapm/contrib/sanic/patch.py +++ b/elasticapm/contrib/sanic/patch.py @@ -31,6 +31,8 @@ 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 @@ -43,26 +45,48 @@ class ElasticAPMPatchedErrorHandler(ErrorHandler): chain the exception down to the original handler so that we don't get in the way of standard exception handling. """ - def __init__(self): + 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): + self._current_handler.add(exception, handler) + + def lookup(self, exception): + return self._current_handler.lookup(exception) + 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, default=False): + async def _patched_response(self, request, exception): await self._apm_handler(request, exception) - if not default: - resp = super(ElasticAPMPatchedErrorHandler, self).response(request=request, exception=exception) - else: - resp = super(ElasticAPMPatchedErrorHandler, self).default(request=request, exception=exception) - if iscoroutinefunction(resp) or isawaitable(resp): - return await resp - return resp + 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, default=True) + return self._patched_response(request=request, exception=exception) diff --git a/elasticapm/contrib/sanic/sanic_types.py b/elasticapm/contrib/sanic/sanic_types.py index 75ac9aec3..a101711aa 100644 --- a/elasticapm/contrib/sanic/sanic_types.py +++ b/elasticapm/contrib/sanic/sanic_types.py @@ -54,7 +54,7 @@ LabelInfoCallbackType = t.Optional[t.Callable[[SanicRequestOrResponse], t.Awaitable[LabelInfoType]]] -APMConfigType = t.Optional[t.Dict[str, t.Any], t.Dict[bytes, t.Any]] +APMConfigType = t.Optional[t.Union[t.Dict[str, t.Any], t.Dict[bytes, t.Any]]] ExtendableMiddlewareGroup = t.Union[Blueprint, BlueprintGroup] diff --git a/elasticapm/contrib/sanic/utils.py b/elasticapm/contrib/sanic/utils.py index 38f14b10e..e4700fc8a 100644 --- a/elasticapm/contrib/sanic/utils.py +++ b/elasticapm/contrib/sanic/utils.py @@ -28,7 +28,7 @@ # 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, List, Optional +from typing import Dict from sanic import Sanic from sanic import __version__ as version @@ -38,7 +38,7 @@ from elasticapm.base import Client from elasticapm.conf import Config, constants -from elasticapm.contrib.sanic.sanic_types import EnvInfoType, SanicRequestOrResponse +from elasticapm.contrib.sanic.sanic_types import EnvInfoType from elasticapm.utils import compat, get_url_dict @@ -61,32 +61,13 @@ def get_env(request: Request) -> EnvInfoType: yield _attr, getattr(request, _attr) -def extract_header(entity: SanicRequestOrResponse, skip_headers: Optional[List[str]]) -> Dict[str, str]: - """ - Extract the necessary headers from the Incoming request. This method also provides a way to skip - certain headers from being included in the trace and they might contain sensitive information such - as the JWT Token and they might not always need to be tracked. - :param entity: HTTP Request or the HTTP Response Entity - :param skip_headers: A list of String indicating which headers to be skipped while extracting the headers - :return: - """ - header = dict(entity.headers) - if skip_headers: - for _header in skip_headers: - _ = header.pop(_header, None) - return header - - # noinspection PyBroadException -async def get_request_info( - config: Config, request: Request, skip_headers: Optional[List[str]] = None -) -> Dict[str, str]: +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 - :param skip_headers: A list of String indicating which headers to be skipped while extracting the headers :return: A dictionary containing the context information of the ongoing transaction """ env = dict(get_env(request=request)) @@ -102,7 +83,7 @@ async def get_request_info( "http_version": request.version, } if config.capture_headers: - result["headers"] = extract_header(entity=request, skip_headers=skip_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: @@ -118,17 +99,12 @@ async def get_request_info( return result -async def get_response_info( - config: Config, - response: HTTPResponse, - skip_headers: Optional[List[str]] = None, -) -> Dict[str, str]: +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 - :param skip_headers: A list of String indicating which headers to be skipped while extracting the headers :return: A dictionary containing the context information of the ongoing transaction """ result = { @@ -140,7 +116,7 @@ async def get_response_info( result["status_code"] = response.status if config.capture_headers: - result["headers"] = extract_header(entity=response, skip_headers=skip_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") @@ -158,12 +134,12 @@ def _get_client_ip(request: Request) -> str: return request.remote_addr -def make_client(config: dict, client_cls=Client, **defaults) -> Client: +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(config, **defaults) + return client_cls(**defaults) def _transform_response_cookie(cookies: CookieJar) -> Dict[str, str]: diff --git a/setup.cfg b/setup.cfg index 8d06fc73b..dda63d254 100644 --- a/setup.cfg +++ b/setup.cfg @@ -150,11 +150,8 @@ markers = starlette graphene httpx -<<<<<<< HEAD prometheus_client -======= sanic ->>>>>>> GIT-521: add base test infra configuration [isort] line_length=120 diff --git a/tests/contrib/sanic/fixtures.py b/tests/contrib/sanic/fixtures.py index 80cb08b41..f5eccb424 100644 --- a/tests/contrib/sanic/fixtures.py +++ b/tests/contrib/sanic/fixtures.py @@ -34,13 +34,14 @@ 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 -from sanic_testing import TestManager import elasticapm from elasticapm import async_capture_span @@ -51,10 +52,87 @@ 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 sanic_app_with_error_handler(elasticapm_client): + app = Sanic(name="elastic-apm-custom-error", error_handler=CustomErrorHandler()) + _ = ElasticAPM(app=app, client=elasticapm_client) + try: + from sanic_testing import TestManager + + TestManager(app=app) + except ImportError: + from sanic.testing import SanicTestClient as TestManager + + @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) + + @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 + + try: + yield app + finally: + elasticapm.uninstrument() + + +@pytest.fixture() +def sanic_app_with_custom_config(request, temp_store_client): + client_config = getattr(request, "param", {}) + client_config.setdefault("service_name", "myapp") + client_config.setdefault("secret_token", "test_key") + client_config.setdefault("central_config", "false") + client_config.setdefault("include_paths", ("*/tests/*",)) + client_config.setdefault("span_frames_min_duration", -1) + client_config.setdefault("metrics_interval", "0ms") + client_config.setdefault("cloud_provider", False) + client_config.setdefault("processors", ("elasticapm.processors.sanitize_http_headers")) + app = Sanic(name="elastic-apm-custom-cfg") + apm = ElasticAPM(app=app, client_cls=temp_store_client, config=client_config) + + @app.get("/add-custom-headers") + async def custom_headers(request): + return json({"data": "message"}, headers={"sessionid": 1234555}) + + try: + yield app, apm + finally: + elasticapm.uninstrument() + + @pytest.fixture() def sanic_app(elasticapm_client): app = Sanic(name="elastic-apm") apm = ElasticAPM(app=app, client=elasticapm_client) + try: + from sanic_testing import TestManager + + TestManager(app=app) + except ImportError: + from sanic.testing import SanicTestClient as TestManager + TestManager(app=app) bp = Blueprint(name="test", url_prefix="/apm", version="v1") diff --git a/tests/contrib/sanic/sanic_tests.py b/tests/contrib/sanic/sanic_tests.py index 08401c723..b16c20910 100644 --- a/tests/contrib/sanic/sanic_tests.py +++ b/tests/contrib/sanic/sanic_tests.py @@ -109,3 +109,41 @@ def test_unhandled_exception_capture(sanic_app, elasticapm_client): "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_app_with_error_handler, elasticapm_client): + _, resp = sanic_app_with_error_handler.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_app_with_custom_config, elasticapm_client): + app, apm = sanic_app_with_custom_config + _, resp = 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]" diff --git a/tests/fixtures.py b/tests/fixtures.py index 82456a5da..630066473 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -344,6 +344,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 index f3f559f0d..14b917c08 100644 --- a/tests/requirements/reqs-sanic-20.12.txt +++ b/tests/requirements/reqs-sanic-20.12.txt @@ -1,3 +1,2 @@ sanic==20.12.2 -sanic-testing==0.3.0 -r reqs-base.txt From 819e9a3ce8032c1650e5639a5b595a0f3c227026 Mon Sep 17 00:00:00 2001 From: Harsha Narayana Date: Fri, 2 Apr 2021 20:03:23 +0530 Subject: [PATCH 15/21] GIT-521: add additional tests and transaction name generation --- elasticapm/contrib/sanic/__init__.py | 29 +++- elasticapm/contrib/sanic/sanic_types.py | 2 +- tests/contrib/sanic/fixtures.py | 169 +++++++++++------------- tests/contrib/sanic/sanic_tests.py | 76 +++++++++-- 4 files changed, 168 insertions(+), 108 deletions(-) diff --git a/elasticapm/contrib/sanic/__init__.py b/elasticapm/contrib/sanic/__init__.py index 9e01cde3c..5801638cb 100644 --- a/elasticapm/contrib/sanic/__init__.py +++ b/elasticapm/contrib/sanic/__init__.py @@ -55,6 +55,7 @@ AllMiddlewareGroup, APMConfigType, CustomContextCallbackType, + CustomInfoType, ExtendableMiddlewareGroup, LabelInfoCallbackType, TransactionNameCallbackType, @@ -206,7 +207,7 @@ def _init_app(self) -> None: if not self._skip_init_middleware: self._setup_request_handler(entity=self._app) - # noinspection PyMethodMayBeStatic + # 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 @@ -214,7 +215,22 @@ def _default_transaction_name_generator(self, request: Request) -> str: :param request: Sanic HTTP Request object :return: string containing the Transaction name """ - return f"{request.uri_template}" + url_template = request.path + # Sanic's new router puts this into the request itself so that it can be accessed easily + if hasattr(request, "route"): + url_template = request.route.path + 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): """ @@ -248,8 +264,7 @@ async def _instrument_request(request: Request): name, email, uid = await self._user_context_callback(request) set_user_context(username=name, email=email, user_id=uid) - if self._custom_context_callback: - set_custom_context(data=await self._custom_context_callback(request)) + await self._setup_custom_context(request=request) if self._label_info_callback: labels = await self._label_info_callback(request) @@ -285,6 +300,12 @@ def _setup_transaction_name(self, request: Request) -> None: 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): """ diff --git a/elasticapm/contrib/sanic/sanic_types.py b/elasticapm/contrib/sanic/sanic_types.py index a101711aa..40c8bcc6a 100644 --- a/elasticapm/contrib/sanic/sanic_types.py +++ b/elasticapm/contrib/sanic/sanic_types.py @@ -36,7 +36,7 @@ from sanic.request import Request from sanic.response import HTTPResponse -UserInfoType = t.Tuple[t.Optional[str], t.Optional[str], t.Optional[str]] +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] diff --git a/tests/contrib/sanic/fixtures.py b/tests/contrib/sanic/fixtures.py index f5eccb424..47ec03b87 100644 --- a/tests/contrib/sanic/fixtures.py +++ b/tests/contrib/sanic/fixtures.py @@ -61,111 +61,102 @@ def default(self, request, exception): @pytest.fixture() -def sanic_app_with_error_handler(elasticapm_client): - app = Sanic(name="elastic-apm-custom-error", error_handler=CustomErrorHandler()) - _ = ElasticAPM(app=app, client=elasticapm_client) - try: - from sanic_testing import TestManager +def custom_error_handler(): + return CustomErrorHandler() - TestManager(app=app) - except ImportError: - from sanic.testing import SanicTestClient as TestManager - - @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) - - @app.get("/raise-exception") - async def raise_exception(request): - raise AttributeError +@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, + ): + 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 - @app.get("/fallback-default-error") - async def raise_default_error(request): - raise CustomException + TestManager(app=app) - @app.get("/raise-value-error") - async def raise_value_error(request): - raise ValueError + bp = Blueprint(name="test", url_prefix="/apm", version="v1") - try: - yield app - finally: - elasticapm.uninstrument() + @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) -@pytest.fixture() -def sanic_app_with_custom_config(request, temp_store_client): - client_config = getattr(request, "param", {}) - client_config.setdefault("service_name", "myapp") - client_config.setdefault("secret_token", "test_key") - client_config.setdefault("central_config", "false") - client_config.setdefault("include_paths", ("*/tests/*",)) - client_config.setdefault("span_frames_min_duration", -1) - client_config.setdefault("metrics_interval", "0ms") - client_config.setdefault("cloud_provider", False) - client_config.setdefault("processors", ("elasticapm.processors.sanitize_http_headers")) - app = Sanic(name="elastic-apm-custom-cfg") - apm = ElasticAPM(app=app, client_cls=temp_store_client, config=client_config) - - @app.get("/add-custom-headers") - async def custom_headers(request): - return json({"data": "message"}, headers={"sessionid": 1234555}) - - try: - yield app, apm - finally: - elasticapm.uninstrument() + app.error_handler.add(AttributeError, attribute_error_handler) + @bp.post("/unhandled-exception") + async def raise_something(request): + raise CustomException("Unhandled") -@pytest.fixture() -def sanic_app(elasticapm_client): - app = Sanic(name="elastic-apm") - apm = ElasticAPM(app=app, client=elasticapm_client) - try: - from sanic_testing import TestManager + @app.route("/", methods=["GET", "POST"]) + def default_route(request: Request): + with async_capture_span("test"): + pass + return json({"response": "ok"}) - TestManager(app=app) - except ImportError: - from sanic.testing import SanicTestClient as TestManager + @app.get("/greet/") + async def greet_person(request: Request, name: str): + return json({"response": f"Hello {name}"}) - TestManager(app=app) + @app.get("/capture-exception") + async def capture_exception(request): + try: + 1 / 0 + except ZeroDivisionError: + await apm.capture_exception() + return json({"response": "invalid"}, 500) - bp = Blueprint(name="test", url_prefix="/apm", version="v1") + app.blueprint(blueprint=bp) - @app.exception(Exception) - def handler(request, exception): - return json({"response": str(exception)}, 500) + @app.get("/raise-exception") + async def raise_exception(request): + raise AttributeError - @bp.post("/unhandled-exception") - async def raise_something(request): - raise CustomException("Unhandled") + @app.get("/fallback-default-error") + async def raise_default_error(request): + raise CustomException - @app.route("/", methods=["GET", "POST"]) - def default_route(request: Request): - with async_capture_span("test"): - pass - return json({"response": "ok"}) + @app.get("/raise-value-error") + async def raise_value_error(request): + raise ValueError - @app.get("/greet/") - async def greet_person(request: Request, name: str): - return json({"response": f"Hello {name}"}) + @app.get("/add-custom-headers") + async def custom_headers(request): + return json({"data": "message"}, headers={"sessionid": 1234555}) - @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) + yield app, apm + finally: + elasticapm.uninstrument() - try: - yield app - finally: - elasticapm.uninstrument() + return _generate diff --git a/tests/contrib/sanic/sanic_tests.py b/tests/contrib/sanic/sanic_tests.py index b16c20910..31a0560a8 100644 --- a/tests/contrib/sanic/sanic_tests.py +++ b/tests/contrib/sanic/sanic_tests.py @@ -37,9 +37,14 @@ pytestmark = [pytest.mark.sanic] # isort:skip -def test_get(sanic_app, elasticapm_client): +@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", @@ -50,23 +55,25 @@ def test_get(sanic_app, elasticapm_client): 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) == 1 - span = spans[0] + 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": "GET /", + "name": transaction_name, "result": "HTTP 2xx", "outcome": "success", "type": "request", }.items(): assert transaction[field] == value - assert transaction["span_count"]["started"] == 1 request = transaction["context"]["request"] assert request["method"] == "GET" assert request["socket"] == {"remote_address": f"127.0.0.1", "encrypted": False} - - assert span["name"] == "test" + context = transaction["context"]["custom"] + assert context == custom_context def test_capture_exception(sanic_app, elasticapm_client): @@ -91,7 +98,8 @@ def test_capture_exception(sanic_app, elasticapm_client): assert transaction[field] == value -def test_unhandled_exception_capture(sanic_app, elasticapm_client): +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={ @@ -119,8 +127,11 @@ def test_unhandled_exception_capture(sanic_app, elasticapm_client): ("/fallback-value-error", "custom-handler-default"), ], ) -def test_client_with_custom_error_handler(url, expected_source, sanic_app_with_error_handler, elasticapm_client): - _, resp = sanic_app_with_error_handler.test_client.get( +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", @@ -132,9 +143,9 @@ def test_client_with_custom_error_handler(url, expected_source, sanic_app_with_e assert resp.json["source"] == expected_source -def test_header_field_sanitization(sanic_app_with_custom_config, elasticapm_client): - app, apm = sanic_app_with_custom_config - _, resp = app.test_client.get( +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", @@ -147,3 +158,40 @@ def test_header_field_sanitization(sanic_app_with_custom_config, elasticapm_clie 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 From d23661a397e93c53e2dfbd08c3fafa64f499d27c Mon Sep 17 00:00:00 2001 From: Harsha Narayana Date: Fri, 2 Apr 2021 20:16:14 +0530 Subject: [PATCH 16/21] GIT-521: fix tests client compatibility mode --- tests/contrib/sanic/fixtures.py | 1 + tests/contrib/sanic/sanic_tests.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/contrib/sanic/fixtures.py b/tests/contrib/sanic/fixtures.py index 47ec03b87..e88d7888e 100644 --- a/tests/contrib/sanic/fixtures.py +++ b/tests/contrib/sanic/fixtures.py @@ -77,6 +77,7 @@ def _generate( 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 diff --git a/tests/contrib/sanic/sanic_tests.py b/tests/contrib/sanic/sanic_tests.py index 31a0560a8..282c4e0cc 100644 --- a/tests/contrib/sanic/sanic_tests.py +++ b/tests/contrib/sanic/sanic_tests.py @@ -76,7 +76,8 @@ def test_get(url, transaction_name, span_count, custom_context, sanic_elastic_ap assert context == custom_context -def test_capture_exception(sanic_app, elasticapm_client): +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={ From beb9f09060866d9c6bec5456b2bfebe991c656bc Mon Sep 17 00:00:00 2001 From: Harsha Narayana Date: Sun, 18 Apr 2021 09:54:41 +0530 Subject: [PATCH 17/21] GIT-521: fix tests for new sanic router --- elasticapm/contrib/sanic/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/elasticapm/contrib/sanic/__init__.py b/elasticapm/contrib/sanic/__init__.py index 5801638cb..0d8b631b6 100644 --- a/elasticapm/contrib/sanic/__init__.py +++ b/elasticapm/contrib/sanic/__init__.py @@ -217,8 +217,11 @@ def _default_transaction_name_generator(self, request: Request) -> str: """ url_template = request.path # Sanic's new router puts this into the request itself so that it can be accessed easily - if hasattr(request, "route"): + # 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: From 4f535b4e6643cdbf55a875ebfeb222c20fbd2e91 Mon Sep 17 00:00:00 2001 From: Benjamin Wohlwend Date: Wed, 9 Jun 2021 10:23:34 +0200 Subject: [PATCH 18/21] exclude sanic tests in Python 3.6 We only support async tracing in Python 3.7+ --- .ci/.jenkins_exclude.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.ci/.jenkins_exclude.yml b/.ci/.jenkins_exclude.yml index 6f5839cf9..fe1ba18bc 100644 --- a/.ci/.jenkins_exclude.yml +++ b/.ci/.jenkins_exclude.yml @@ -225,6 +225,10 @@ exclude: 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 From 4bf5d2c52bf84fef91a73ddebe0a7c683cec22c4 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Tue, 9 Nov 2021 21:54:39 +0200 Subject: [PATCH 19/21] Add arbitrary arg capture to ErrorHandler --- docs/set-up.asciidoc | 2 ++ elasticapm/contrib/sanic/patch.py | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/set-up.asciidoc b/docs/set-up.asciidoc index 4df452e80..15269e0ff 100644 --- a/docs/set-up.asciidoc +++ b/docs/set-up.asciidoc @@ -23,4 +23,6 @@ include::./tornado.asciidoc[] include::./starlette.asciidoc[] +include::./sanic.asciidoc[] + include::./serverless.asciidoc[] diff --git a/elasticapm/contrib/sanic/patch.py b/elasticapm/contrib/sanic/patch.py index 78c1bb2fd..72986d590 100644 --- a/elasticapm/contrib/sanic/patch.py +++ b/elasticapm/contrib/sanic/patch.py @@ -50,11 +50,11 @@ def __init__(self, current_handler: ErrorHandler): self._current_handler = current_handler # type: ErrorHandler self._apm_handler = None # type: ApmHandlerType - def add(self, exception, handler): - self._current_handler.add(exception, handler) + def add(self, exception, handler, *args, **kwargs): + self._current_handler.add(exception, handler, *args, **kwargs) - def lookup(self, exception): - return self._current_handler.lookup(exception) + 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: From 060ead38dd1bbb073e00cd8dc7b03e9d438a1077 Mon Sep 17 00:00:00 2001 From: Adam Hopkins Date: Tue, 9 Nov 2021 21:56:43 +0200 Subject: [PATCH 20/21] Implement changes from PR review --- elasticapm/contrib/sanic/__init__.py | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/elasticapm/contrib/sanic/__init__.py b/elasticapm/contrib/sanic/__init__.py index 0d8b631b6..991ce69d8 100644 --- a/elasticapm/contrib/sanic/__init__.py +++ b/elasticapm/contrib/sanic/__init__.py @@ -86,7 +86,7 @@ class ElasticAPM: "SECRET_TOKEN": "supersecrettokenstuff", }) - Pass a pre-build Clinet instance to the APM Middleware:: + Pass a pre-build Client instance to the APM Middleware:: >>> apm = ElasticAPM(app=app, client=Client()) diff --git a/setup.cfg b/setup.cfg index 1f2c48e1e..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.6' + sanic ; python_version >= '3.7' [options.extras_require] flask = From ce69f9d2d63b8fcf3ce3a2a107f777c3fb7f6427 Mon Sep 17 00:00:00 2001 From: Colton Myers Date: Thu, 11 Nov 2021 10:13:54 -0700 Subject: [PATCH 21/21] CHANGELOG --- CHANGELOG.asciidoc | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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