Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
f09e12f
GIT-521: add support for sanic framework
harshanarayana Mar 1, 2021
f8df92a
GIT-521: add base test infra configuration
harshanarayana Mar 1, 2021
4a3a96e
GIT-521: refactor and address review comments from @ahopkins
harshanarayana Mar 1, 2021
3122e03
GIT-521: fix transaction name generation workflow
harshanarayana Mar 1, 2021
4bc656d
GIT-521: enable documentation for sanic contrib
harshanarayana Mar 6, 2021
50374e0
GIT-521: add callback info to documentation
harshanarayana Mar 6, 2021
5e8ed0e
GIT-521: cleanup invalid html file
harshanarayana Mar 6, 2021
30c24c6
GIT-521: add monkey patch for exception handler
harshanarayana Mar 6, 2021
159a483
GIT-521: mark exception as handled
harshanarayana Mar 6, 2021
908b8e2
GIT-521: cleanup exception handler setup
harshanarayana Mar 6, 2021
c9d6f3b
GIT-521: add instance check before setting up apm exception handler
harshanarayana Mar 7, 2021
9cb8645
GIT-521: move types into reusable file
harshanarayana Mar 7, 2021
3350fb0
GIT-521: cleanup unsed types
harshanarayana Mar 7, 2021
b5c71f2
GIT-521: fix header sanitization and exception tracker
harshanarayana Apr 2, 2021
819e9a3
GIT-521: add additional tests and transaction name generation
harshanarayana Apr 2, 2021
d23661a
GIT-521: fix tests client compatibility mode
harshanarayana Apr 2, 2021
beb9f09
GIT-521: fix tests for new sanic router
harshanarayana Apr 18, 2021
3e79cf8
Merge branch 'master' into feature/GIT-521-sanic-apm-contrib
beniwohli Jun 8, 2021
4f535b4
exclude sanic tests in Python 3.6
beniwohli Jun 9, 2021
bb58841
Merge branch 'master' into feature/GIT-521-sanic-apm-contrib
basepi Oct 27, 2021
1ae5422
Merge branch 'master' into feature/GIT-521-sanic-apm-contrib
basepi Nov 1, 2021
56e4782
Pull in latest and merge conflicts
ahopkins Nov 9, 2021
4bf5d2c
Add arbitrary arg capture to ErrorHandler
ahopkins Nov 9, 2021
060ead3
Implement changes from PR review
ahopkins Nov 9, 2021
e3ab4f0
Merge branch 'master' into feature/GIT-521-sanic-apm-contrib
ahopkins Nov 10, 2021
ce69f9d
CHANGELOG
basepi Nov 11, 2021
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
GIT-521: add support for sanic framework
  • Loading branch information
harshanarayana committed Apr 18, 2021
commit f09e12f022b8ce38d0f0b1d24f76986b49b7eab3
168 changes: 168 additions & 0 deletions elasticapm/contrib/sanic/__init__.py
Original file line number Diff line number Diff line change
@@ -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
127 changes: 127 additions & 0 deletions elasticapm/contrib/sanic/utils.py
Original file line number Diff line number Diff line change
@@ -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)