Skip to content

WIP: feat: add sanic support for elastic-apm python agent #522

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions elasticapm/contrib/sanic/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# 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


from __future__ import absolute_import

import logging
import sys

import elasticapm
import elasticapm.instrumentation.control
from elasticapm.base import Client
from elasticapm.conf import constants, setup_logging
from elasticapm.contrib.sanic.utils import get_data_from_request, get_data_from_response, make_client
from elasticapm.handlers.logging import LoggingHandler
from elasticapm.utils import build_name_with_http_method_prefix
from elasticapm.utils.disttracing import TraceParent

logger = logging.getLogger("elasticapm.errors.client")


class ElasticAPM(object):
def __init__(self, app=None, client=None, client_cls=Client, logging=False, **defaults):
self._app = app
self._logging = logging
self._client_cls = client_cls
self._client = client
self._skip_header = []
if app:
self.init_app(app, **defaults)

if defaults.get("skip_headers"):
self._skip_header = defaults.pop("skip_headers")

@property
def app(self):
return self._app

@property
def logging(self):
return self._logging

@property
def client_cls(self):
return self._client_cls

@property
def client(self):
return self._client

@property
def skip_headers(self):
return self._skip_header

def _register_exception_handler(self):
@self.app.exception(Exception)
def _exception_handler(request, exception):
if not self.client:
return

if self.app.debug and not self.client.config.debug:
return

self.client.capture_exception(
exc_info=sys.exc_info(),
context={
"request": get_data_from_request(
request,
capture_body=self.client.config.capture_body in ("errors", "all"),
capture_headers=self.client.config.capture_headers,
skip_headers=self.skip_headers,
)
},
custom={"app": self.app},
handled=False,
)

def _register_request_started(self):
@self.app.middleware("request")
def request_middleware(request):
if not self.app.debug or self.client.config.debug:
if constants.TRACEPARENT_HEADER_NAME in request.headers:
trace_parent = TraceParent.from_string(request.headers[constants.TRACEPARENT_HEADER_NAME])
else:
trace_parent = None
self.client.begin_transaction("request", trace_parent=trace_parent)

def _register_request_finished(self):
@self.app.middleware("response")
def response_middleware(request, response):
if not self.app.debug or self.client.config.debug:
rule = request.uri_template if request.uri_template is not None else ""
rule = build_name_with_http_method_prefix(rule, request)
elasticapm.set_context(
lambda: get_data_from_request(
request,
capture_body=self.client.config.capture_body in ("transactions", "all"),
capture_headers=self.client.config.capture_headers,
skip_headers=self.skip_headers,
),
"request",
)

elasticapm.set_context(
lambda: get_data_from_response(
response,
capture_body=self.client.config.capture_body in ("transactions", "all"),
capture_headers=self.client.config.capture_headers,
skip_headers=self.skip_headers,
),
"response",
)
if response.status:
result = "HTTP {}xx".format(response.status // 100)
else:
result = response.status
elasticapm.set_transaction_name(rule, override=False)
elasticapm.set_transaction_result(result, override=False)
self.client.end_transaction(rule, result)

def init_app(self, app, **defaults):
self._app = app
if not self.client:
self._client = make_client(self.client_cls, app, **defaults)

if self.logging or self.logging == 0:
if self.logging is not True:
kwargs = {"level": self.logging}
else:
kwargs = {}
setup_logging(LoggingHandler(self.client, **kwargs))

self._register_exception_handler()

try:
from elasticapm.contrib.celery import register_exception_tracking

register_exception_tracking(self.client)
except ImportError:
pass

if self.client.config.instrument:
elasticapm.instrumentation.control.instrument()

self._register_request_started()
self._register_request_finished()
try:
from elasticapm.contrib.celery import register_instrumentation

register_instrumentation(self.client)
except ImportError:
pass
else:
logger.debug("Skipping instrumentation. INSTRUMENT is set to False")

def capture_exception(self, *args, **kwargs):
assert self.client, "capture_exception called before application configuration is initialized"
return self.client.capture_exception(*args, **kwargs)

def capture_message(self, *args, **kwargs):
assert self.client, "capture_message called before application configuration is initialized"
return self.client.capture_message(*args, **kwargs)
116 changes: 116 additions & 0 deletions elasticapm/contrib/sanic/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# 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 sanic
from elasticapm.conf import constants
from elasticapm.utils import compat, get_url_dict


def get_environ(request):
for attr in ("remote_addr", "server_nane", "server_port"):
if hasattr(request, attr):
yield attr, getattr(request, attr)


def get_socket(request):
if request.socket:
return "{}:{}".format(request.socket[0], request.socket[1])


def get_headers_from_request_or_response(entity, skip_headers=None):
headers = dict(entity.headers)
if skip_headers:
for header in skip_headers:
if headers.get(header):
headers.pop(header)
return headers


def get_data_from_request(request, capture_body=False, capture_headers=True, skip_headers=None):
result = {
"env": dict(get_environ(request)),
"method": request.method,
"socket": {
"remote_address": get_socket(request),
"encrypted": True if request.scheme in ["https", "wss"] else False,
},
"cookies": request.cookies,
}

if capture_headers:
result["headers"] = get_headers_from_request_or_response(request, skip_headers)

if request.method in constants.HTTP_WITH_BODY:
body = None
if request.content_type == "appliation/x-www-form-urlencoded":
body = compat.multidict_to_dict(request.form)
elif request.content_type and request.content_type.startswith("multipart/form-data"):
body = compat.multidict_to_dict(request.form)
if request.files:
body["_files"] = {
field: val[0].filename if len(val) == 1 else [f.filename for f in val]
for field, val in compat.iterlists(request.files)
}
else:
try:
body = request.body.decode("utf-8")
except Exception:
pass
if body is not None:
result["body"] = body if capture_body else "[REDACTED]"

result["url"] = get_url_dict(request.url)
return result


def get_data_from_response(response, capture_body=False, capture_headers=True, skip_headers=None):
result = {"cookies": response.cookies}
if isinstance(getattr(response, "status", None), compat.integer_types):
result["status_code"] = response.status

if capture_headers and getattr(response, "heaaders", None):
result["headers"] = get_headers_from_request_or_response(response, skip_headers)

if capture_body:
try:
result["body"] = response.body.decode("utf-8")
except Exception:
result["body"] = "[REDACTED]"
return result


def make_client(client_cls, app, **defaults):
config = app.config.get("ELASTIC_APM", {})

if "framework_name" not in defaults:
defaults["framework_name"] = "sanic"
defaults["framework_version"] = sanic.__version__

return client_cls(config, **defaults)