Skip to content

Commit 2a6a461

Browse files
feat: add sanic support for elastic-apm python agent
Signed-off-by: Harsha Narayana <harsha2k4@gmail.com>
1 parent a2442b3 commit 2a6a461

File tree

2 files changed

+304
-0
lines changed

2 files changed

+304
-0
lines changed

elasticapm/contrib/sanic/__init__.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
# BSD 3-Clause License
2+
#
3+
# Copyright (c) 2019, Elasticsearch BV
4+
# All rights reserved.
5+
#
6+
# Redistribution and use in source and binary forms, with or without
7+
# modification, are permitted provided that the following conditions are met:
8+
#
9+
# * Redistributions of source code must retain the above copyright notice, this
10+
# list of conditions and the following disclaimer.
11+
#
12+
# * Redistributions in binary form must reproduce the above copyright notice,
13+
# this list of conditions and the following disclaimer in the documentation
14+
# and/or other materials provided with the distribution.
15+
#
16+
# * Neither the name of the copyright holder nor the names of its
17+
# contributors may be used to endorse or promote products derived from
18+
# this software without specific prior written permission.
19+
#
20+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23+
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24+
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25+
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26+
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27+
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28+
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29+
30+
31+
from __future__ import absolute_import
32+
33+
import logging
34+
import sys
35+
36+
import elasticapm
37+
import elasticapm.instrumentation.control
38+
from elasticapm.base import Client
39+
from elasticapm.conf import constants, setup_logging
40+
from elasticapm.contrib.sanic.utils import get_data_from_request, get_data_from_response, make_client
41+
from elasticapm.handlers.logging import LoggingHandler
42+
from elasticapm.utils import build_name_with_http_method_prefix
43+
from elasticapm.utils.disttracing import TraceParent
44+
45+
logger = logging.getLogger("elasticapm.errors.client")
46+
47+
48+
class ElasticAPM(object):
49+
def __init__(self, app=None, client=None, client_cls=Client, logging=False, **defaults):
50+
self._app = app
51+
self._logging = logging
52+
self._client_cls = client_cls
53+
self._client = client
54+
self._skip_header = []
55+
if app:
56+
self.init_app(app, **defaults)
57+
58+
if defaults.get("skip_headers"):
59+
self._skip_header = defaults.pop("skip_headers")
60+
61+
@property
62+
def app(self):
63+
return self._app
64+
65+
@property
66+
def logging(self):
67+
return self._logging
68+
69+
@property
70+
def client_cls(self):
71+
return self._client_cls
72+
73+
@property
74+
def client(self):
75+
return self._client
76+
77+
@property
78+
def skip_headers(self):
79+
return self._skip_header
80+
81+
def _register_exception_handler(self):
82+
@self.app.exception(Exception)
83+
def _exception_handler(request, exception):
84+
if not self.client:
85+
return
86+
87+
if self.app.debug and not self.client.config.debug:
88+
return
89+
90+
self.client.capture_exception(
91+
exc_info=sys.exc_info(),
92+
context={
93+
"request": get_data_from_request(
94+
request,
95+
capture_body=self.client.config.capture_body in ("errors", "all"),
96+
capture_headers=self.client.config.capture_headers,
97+
skip_headers=self.skip_headers,
98+
)
99+
},
100+
custom={"app": self.app},
101+
handled=False,
102+
)
103+
104+
def _register_request_started(self):
105+
@self.app.middleware("request")
106+
def request_middleware(request):
107+
if not self.app.debug or self.client.config.debug:
108+
if constants.TRACEPARENT_HEADER_NAME in request.headers:
109+
trace_parent = TraceParent.from_string(request.headers[constants.TRACEPARENT_HEADER_NAME])
110+
else:
111+
trace_parent = None
112+
self.client.begin_transaction("request", trace_parent=trace_parent)
113+
114+
def _register_request_finished(self):
115+
@self.app.middleware("response")
116+
def response_middleware(request, response):
117+
if not self.app.debug or self.client.config.debug:
118+
rule = request.uri_template if request.uri_template is not None else ""
119+
rule = build_name_with_http_method_prefix(rule, request)
120+
elasticapm.set_context(
121+
lambda: get_data_from_request(
122+
request,
123+
capture_body=self.client.config.capture_body in ("transactions", "all"),
124+
capture_headers=self.client.config.capture_headers,
125+
skip_headers=self.skip_headers,
126+
),
127+
"request",
128+
)
129+
130+
elasticapm.set_context(
131+
lambda: get_data_from_response(
132+
response,
133+
capture_body=self.client.config.capture_body in ("transactions", "all"),
134+
capture_headers=self.client.config.capture_headers,
135+
skip_headers=self.skip_headers,
136+
),
137+
"response",
138+
)
139+
if response.status:
140+
result = "HTTP {}xx".format(response.status // 100)
141+
else:
142+
result = response.status
143+
elasticapm.set_transaction_name(rule, override=False)
144+
elasticapm.set_transaction_result(result, override=False)
145+
self.client.end_transaction(rule, result)
146+
147+
def init_app(self, app, **defaults):
148+
self._app = app
149+
if not self.client:
150+
self._client = make_client(self.client_cls, app, **defaults)
151+
152+
if self.logging or self.logging == 0:
153+
if self.logging is not True:
154+
kwargs = {"level": self.logging}
155+
else:
156+
kwargs = {}
157+
setup_logging(LoggingHandler(self.client, **kwargs))
158+
159+
self._register_exception_handler()
160+
161+
try:
162+
from elasticapm.contrib.celery import register_exception_tracking
163+
164+
register_exception_tracking(self.client)
165+
except ImportError:
166+
pass
167+
168+
if self.client.config.instrument:
169+
elasticapm.instrumentation.control.instrument()
170+
171+
self._register_request_started()
172+
self._register_request_finished()
173+
try:
174+
from elasticapm.contrib.celery import register_instrumentation
175+
176+
register_instrumentation(self.client)
177+
except ImportError:
178+
pass
179+
else:
180+
logger.debug("Skipping instrumentation. INSTRUMENT is set to False")
181+
182+
def capture_exception(self, *args, **kwargs):
183+
assert self.client, "capture_exception called before application configuration is initialized"
184+
return self.client.capture_exception(*args, **kwargs)
185+
186+
def capture_message(self, *args, **kwargs):
187+
assert self.client, "capture_message called before application configuration is initialized"
188+
return self.client.capture_message(*args, **kwargs)

elasticapm/contrib/sanic/utils.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# BSD 3-Clause License
2+
#
3+
# Copyright (c) 2019, Elasticsearch BV
4+
# All rights reserved.
5+
#
6+
# Redistribution and use in source and binary forms, with or without
7+
# modification, are permitted provided that the following conditions are met:
8+
#
9+
# * Redistributions of source code must retain the above copyright notice, this
10+
# list of conditions and the following disclaimer.
11+
#
12+
# * Redistributions in binary form must reproduce the above copyright notice,
13+
# this list of conditions and the following disclaimer in the documentation
14+
# and/or other materials provided with the distribution.
15+
#
16+
# * Neither the name of the copyright holder nor the names of its
17+
# contributors may be used to endorse or promote products derived from
18+
# this software without specific prior written permission.
19+
#
20+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23+
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24+
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25+
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26+
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27+
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28+
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29+
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30+
31+
import sanic
32+
from elasticapm.conf import constants
33+
from elasticapm.utils import compat, get_url_dict
34+
35+
36+
def get_environ(request):
37+
for attr in ("remote_addr", "server_nane", "server_port"):
38+
if hasattr(request, attr):
39+
yield attr, getattr(request, attr)
40+
41+
42+
def get_socket(request):
43+
if request.socket:
44+
return "{}:{}".format(request.socket[0], request.socket[1])
45+
46+
47+
def get_headers_from_request_or_response(entity, skip_headers=None):
48+
headers = dict(entity.headers)
49+
if skip_headers:
50+
for header in skip_headers:
51+
if headers.get(header):
52+
headers.pop(header)
53+
return headers
54+
55+
56+
def get_data_from_request(request, capture_body=False, capture_headers=True, skip_headers=None):
57+
result = {
58+
"env": dict(get_environ(request)),
59+
"method": request.method,
60+
"socket": {
61+
"remote_address": get_socket(request),
62+
"encrypted": True if request.scheme in ["https", "wss"] else False,
63+
},
64+
"cookies": request.cookies,
65+
}
66+
67+
if capture_headers:
68+
result["headers"] = get_headers_from_request_or_response(request, skip_headers)
69+
70+
if request.method in constants.HTTP_WITH_BODY:
71+
body = None
72+
if request.content_type == "appliation/x-www-form-urlencoded":
73+
body = compat.multidict_to_dict(request.form)
74+
elif request.content_type and request.content_type.startswith("multipart/form-data"):
75+
body = compat.multidict_to_dict(request.form)
76+
if request.files:
77+
body["_files"] = {
78+
field: val[0].filename if len(val) == 1 else [f.filename for f in val]
79+
for field, val in compat.iterlists(request.files)
80+
}
81+
else:
82+
try:
83+
body = request.body.decode("utf-8")
84+
except Exception:
85+
pass
86+
if body is not None:
87+
result["body"] = body if capture_body else "[REDACTED]"
88+
89+
result["url"] = get_url_dict(request.url)
90+
return result
91+
92+
93+
def get_data_from_response(response, capture_body=False, capture_headers=True, skip_headers=None):
94+
result = {"cookies": response.cookies}
95+
if isinstance(getattr(response, "status", None), compat.integer_types):
96+
result["status_code"] = response.status
97+
98+
if capture_headers and getattr(response, "heaaders", None):
99+
result["headers"] = get_headers_from_request_or_response(response, skip_headers)
100+
101+
if capture_body:
102+
try:
103+
result["body"] = response.body.decode("utf-8")
104+
except Exception:
105+
result["body"] = "[REDACTED]"
106+
return result
107+
108+
109+
def make_client(client_cls, app, **defaults):
110+
config = app.config.get("ELASTIC_APM", {})
111+
112+
if "framework_name" not in defaults:
113+
defaults["framework_name"] = "sanic"
114+
defaults["framework_version"] = sanic.__version__
115+
116+
return client_cls(config, **defaults)

0 commit comments

Comments
 (0)