Skip to content

Commit 2e79688

Browse files
Instrument sanic (#313)
Instrumentation of Sanic Framework * initial instrumentation for sanic v21 * supporting sanic v20 * supporting v19 * uninstall uvloop, sanic optional dependency as it interferes with asynqp * Update .circleci/config.yml Co-authored-by: Manoj Pandey <[email protected]> * Update tests/conftest.py Co-authored-by: Manoj Pandey <[email protected]> * requested review changes and refactoring tests to class based Co-authored-by: Manoj Pandey <[email protected]>
1 parent 9872d14 commit 2e79688

File tree

11 files changed

+618
-3
lines changed

11 files changed

+618
-3
lines changed

.circleci/config.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ jobs:
9898
INSTANA_TEST: "true"
9999
command: |
100100
. venv/bin/activate
101+
# We uninstall uvloop as it interferes with asyncio changing the event loop policy
102+
pip uninstall -y uvloop
101103
pytest -v
102104
103105
python38:

instana/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
do_not_load_list = ["pip", "pip2", "pip3", "pipenv", "docker-compose", "easy_install", "easy_install-2.7",
4848
"smtpd.py", "twine", "ufw", "unattended-upgrade"]
4949

50+
5051
def load(_):
5152
"""
5253
Method used to activate the Instana sensor via AUTOWRAPT_BOOTSTRAP
@@ -57,6 +58,7 @@ def load(_):
5758
sys.argv = ['']
5859
return None
5960

61+
6062
def get_lambda_handler_or_default():
6163
"""
6264
For instrumenting AWS Lambda, users specify their original lambda handler in the LAMBDA_HANDLER environment
@@ -108,7 +110,7 @@ def lambda_handler(event, context):
108110
def boot_agent_later():
109111
""" Executes <boot_agent> in the future! """
110112
if 'gevent' in sys.modules:
111-
import gevent # pylint: disable=import-outside-toplevel
113+
import gevent # pylint: disable=import-outside-toplevel
112114
gevent.spawn_later(2.0, boot_agent)
113115
else:
114116
Timer(2.0, boot_agent).start()
@@ -127,6 +129,9 @@ def boot_agent():
127129
# Import & initialize instrumentation
128130
from .instrumentation.aws import lambda_inst
129131

132+
if sys.version_info >= (3, 7, 0):
133+
from .instrumentation import sanic_inst
134+
130135
if sys.version_info >= (3, 6, 0):
131136
from .instrumentation import fastapi_inst
132137
from .instrumentation import starlette_inst
@@ -173,6 +178,7 @@ def boot_agent():
173178
# Hooks
174179
from .hooks import hook_uwsgi
175180

181+
176182
if 'INSTANA_DISABLE' not in os.environ:
177183
# There are cases when sys.argv may not be defined at load time. Seems to happen in embedded Python,
178184
# and some Pipenv installs. If this is the case, it's best effort.
@@ -184,6 +190,7 @@ def boot_agent():
184190
# AutoProfile
185191
if "INSTANA_AUTOPROFILE" in os.environ:
186192
from .singletons import get_profiler
193+
187194
profiler = get_profiler()
188195
if profiler:
189196
profiler.start()

instana/instrumentation/sanic_inst.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# (c) Copyright IBM Corp. 2021
2+
# (c) Copyright Instana Inc. 2021
3+
4+
"""
5+
Instrumentation for Sanic
6+
https://sanicframework.org/en/
7+
"""
8+
try:
9+
import sanic
10+
import wrapt
11+
import opentracing
12+
from ..log import logger
13+
from ..singletons import async_tracer, agent
14+
from ..util.secrets import strip_secrets_from_query
15+
from ..util.traceutils import extract_custom_headers
16+
17+
18+
@wrapt.patch_function_wrapper('sanic.exceptions', 'SanicException.__init__')
19+
def exception_with_instana(wrapped, instance, args, kwargs):
20+
message = kwargs.get("message", args[0])
21+
status_code = kwargs.get("status_code")
22+
span = async_tracer.active_span
23+
24+
if all([span, status_code, message]) and (500 <= status_code <= 599):
25+
span.set_tag("http.error", message)
26+
try:
27+
wrapped(*args, **kwargs)
28+
except Exception as exc:
29+
span.log_exception(exc)
30+
else:
31+
wrapped(*args, **kwargs)
32+
33+
34+
def response_details(span, response):
35+
try:
36+
status_code = response.status
37+
if status_code is not None:
38+
if 500 <= int(status_code) <= 511:
39+
span.mark_as_errored()
40+
span.set_tag('http.status_code', status_code)
41+
42+
if response.headers is not None:
43+
async_tracer.inject(span.context, opentracing.Format.HTTP_HEADERS, response.headers)
44+
response.headers['Server-Timing'] = "intid;desc=%s" % span.context.trace_id
45+
except Exception:
46+
logger.debug("send_wrapper: ", exc_info=True)
47+
48+
49+
if hasattr(sanic.response.BaseHTTPResponse, "send"):
50+
@wrapt.patch_function_wrapper('sanic.response', 'BaseHTTPResponse.send')
51+
async def send_with_instana(wrapped, instance, args, kwargs):
52+
span = async_tracer.active_span
53+
if span is None:
54+
await wrapped(*args, **kwargs)
55+
else:
56+
response_details(span=span, response=instance)
57+
try:
58+
await wrapped(*args, **kwargs)
59+
except Exception as exc:
60+
span.log_exception(exc)
61+
raise
62+
else:
63+
@wrapt.patch_function_wrapper('sanic.server', 'HttpProtocol.write_response')
64+
def write_with_instana(wrapped, instance, args, kwargs):
65+
response = args[0]
66+
span = async_tracer.active_span
67+
if span is None:
68+
wrapped(*args, **kwargs)
69+
else:
70+
response_details(span=span, response=response)
71+
try:
72+
wrapped(*args, **kwargs)
73+
except Exception as exc:
74+
span.log_exception(exc)
75+
raise
76+
77+
78+
@wrapt.patch_function_wrapper('sanic.server', 'HttpProtocol.stream_response')
79+
async def stream_with_instana(wrapped, instance, args, kwargs):
80+
response = args[0]
81+
span = async_tracer.active_span
82+
if span is None:
83+
await wrapped(*args, **kwargs)
84+
else:
85+
response_details(span=span, response=response)
86+
try:
87+
await wrapped(*args, **kwargs)
88+
except Exception as exc:
89+
span.log_exception(exc)
90+
raise
91+
92+
93+
@wrapt.patch_function_wrapper('sanic.app', 'Sanic.handle_request')
94+
async def handle_request_with_instana(wrapped, instance, args, kwargs):
95+
96+
try:
97+
request = args[0]
98+
try: # scheme attribute is calculated in the sanic handle_request method for v19, not yet present
99+
if "http" not in request.scheme:
100+
return await wrapped(*args, **kwargs)
101+
except AttributeError:
102+
pass
103+
headers = request.headers.copy()
104+
ctx = async_tracer.extract(opentracing.Format.HTTP_HEADERS, headers)
105+
with async_tracer.start_active_span("asgi", child_of=ctx) as scope:
106+
scope.span.set_tag('span.kind', 'entry')
107+
scope.span.set_tag('http.path', request.path)
108+
scope.span.set_tag('http.method', request.method)
109+
scope.span.set_tag('http.host', request.host)
110+
scope.span.set_tag("http.url", request.url)
111+
112+
query = request.query_string
113+
114+
if isinstance(query, (str, bytes)) and len(query):
115+
if isinstance(query, bytes):
116+
query = query.decode('utf-8')
117+
scrubbed_params = strip_secrets_from_query(query, agent.options.secrets_matcher,
118+
agent.options.secrets_list)
119+
scope.span.set_tag("http.params", scrubbed_params)
120+
121+
if agent.options.extra_http_headers is not None:
122+
extract_custom_headers(scope, headers)
123+
await wrapped(*args, **kwargs)
124+
if hasattr(request, "uri_template"):
125+
scope.span.set_tag("http.path_tpl", request.uri_template)
126+
if hasattr(request, "ctx"): # ctx attribute added in the latest v19 versions
127+
request.ctx.iscope = scope
128+
except Exception as e:
129+
logger.debug("Sanic framework @ handle_request", exc_info=True)
130+
return await wrapped(*args, **kwargs)
131+
132+
133+
logger.debug("Instrumenting Sanic")
134+
135+
except ImportError:
136+
pass
137+
except AttributeError:
138+
logger.debug("Not supported Sanic version")

instana/util/traceutils.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# (c) Copyright IBM Corp. 2021
2+
# (c) Copyright Instana Inc. 2021
3+
4+
from ..singletons import agent
5+
from ..log import logger
6+
7+
8+
def extract_custom_headers(tracing_scope, headers):
9+
try:
10+
for custom_header in agent.options.extra_http_headers:
11+
# Headers are in the following format: b'x-header-1'
12+
for header_key, value in headers.items():
13+
if header_key.lower() == custom_header.lower():
14+
tracing_scope.span.set_tag("http.header.%s" % custom_header, value)
15+
except Exception as e:
16+
logger.debug("extract_custom_headers: ", exc_info=True)

tests/apps/sanic_app/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# (c) Copyright IBM Corp. 2021
2+
# (c) Copyright Instana Inc. 2021
3+
4+
5+
import uvicorn
6+
from ...helpers import testenv
7+
from instana.log import logger
8+
9+
testenv["sanic_port"] = 1337
10+
testenv["sanic_server"] = ("http://127.0.0.1:" + str(testenv["sanic_port"]))
11+
12+
13+
def launch_sanic():
14+
from .server import app
15+
from instana.singletons import agent
16+
17+
# Hack together a manual custom headers list; We'll use this in tests
18+
agent.options.extra_http_headers = [u'X-Capture-This', u'X-Capture-That']
19+
20+
uvicorn.run(app, host='127.0.0.1', port=testenv['sanic_port'], log_level="critical")

tests/apps/sanic_app/name.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# (c) Copyright IBM Corp. 2021
2+
# (c) Copyright Instana Inc. 2021
3+
4+
5+
from sanic.views import HTTPMethodView
6+
from sanic.response import text
7+
8+
9+
class NameView(HTTPMethodView):
10+
11+
def get(self, request, name):
12+
return text("Hello {}".format(name))
13+
14+

tests/apps/sanic_app/server.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# (c) Copyright IBM Corp. 2021
2+
# (c) Copyright Instana Inc. 2021
3+
4+
from sanic import Sanic
5+
from sanic.exceptions import SanicException
6+
from .simpleview import SimpleView
7+
from .name import NameView
8+
from sanic.response import text
9+
import instana
10+
11+
app = Sanic('test')
12+
13+
@app.get("/foo/<foo_id:int>")
14+
async def uuid_handler(request, foo_id: int):
15+
return text("INT - {}".format(foo_id))
16+
17+
18+
@app.route("/test_request_args")
19+
async def test_request_args(request):
20+
raise SanicException("Something went wrong.", status_code=500)
21+
22+
23+
@app.get("/tag/<tag>")
24+
async def tag_handler(request, tag):
25+
return text("Tag - {}".format(tag))
26+
27+
28+
app.add_route(SimpleView.as_view(), "/")
29+
app.add_route(NameView.as_view(), "/<name>")
30+
31+
32+
if __name__ == '__main__':
33+
app.run(host="0.0.0.0", port=8000, debug=True, access_log=True)
34+
35+
36+

tests/apps/sanic_app/simpleview.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# (c) Copyright IBM Corp. 2021
2+
# (c) Copyright Instana Inc. 2021
3+
4+
5+
from sanic.views import HTTPMethodView
6+
from sanic.response import text
7+
8+
class SimpleView(HTTPMethodView):
9+
10+
def get(self, request):
11+
return text("I am get method")
12+
13+
# You can also use async syntax
14+
async def post(self, request):
15+
return text("I am post method")
16+
17+
def put(self, request):
18+
return text("I am put method")
19+
20+
def patch(self, request):
21+
return text("I am patch method")
22+
23+
def delete(self, request):
24+
return text("I am delete method")

tests/conftest.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import pytest
77
from distutils.version import LooseVersion
88

9-
109
collect_ignore_glob = []
1110

1211
# Cassandra and gevent tests are run in dedicated jobs on CircleCI and will
@@ -44,6 +43,7 @@
4443
# Make sure the instana package is fully loaded
4544
import instana
4645

46+
4747
@pytest.fixture(scope='session')
4848
def celery_config():
4949
return {
@@ -62,4 +62,3 @@ def celery_includes():
6262
return {
6363
'tests.frameworks.test_celery'
6464
}
65-

0 commit comments

Comments
 (0)