Skip to content

Commit

Permalink
Instrument sanic (#313)
Browse files Browse the repository at this point in the history
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]>
  • Loading branch information
pdimitra and manojpandey authored May 28, 2021
1 parent 9872d14 commit 2e79688
Show file tree
Hide file tree
Showing 11 changed files with 618 additions and 3 deletions.
2 changes: 2 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ jobs:
INSTANA_TEST: "true"
command: |
. venv/bin/activate
# We uninstall uvloop as it interferes with asyncio changing the event loop policy
pip uninstall -y uvloop
pytest -v
python38:
Expand Down
9 changes: 8 additions & 1 deletion instana/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
do_not_load_list = ["pip", "pip2", "pip3", "pipenv", "docker-compose", "easy_install", "easy_install-2.7",
"smtpd.py", "twine", "ufw", "unattended-upgrade"]


def load(_):
"""
Method used to activate the Instana sensor via AUTOWRAPT_BOOTSTRAP
Expand All @@ -57,6 +58,7 @@ def load(_):
sys.argv = ['']
return None


def get_lambda_handler_or_default():
"""
For instrumenting AWS Lambda, users specify their original lambda handler in the LAMBDA_HANDLER environment
Expand Down Expand Up @@ -108,7 +110,7 @@ def lambda_handler(event, context):
def boot_agent_later():
""" Executes <boot_agent> in the future! """
if 'gevent' in sys.modules:
import gevent # pylint: disable=import-outside-toplevel
import gevent # pylint: disable=import-outside-toplevel
gevent.spawn_later(2.0, boot_agent)
else:
Timer(2.0, boot_agent).start()
Expand All @@ -127,6 +129,9 @@ def boot_agent():
# Import & initialize instrumentation
from .instrumentation.aws import lambda_inst

if sys.version_info >= (3, 7, 0):
from .instrumentation import sanic_inst

if sys.version_info >= (3, 6, 0):
from .instrumentation import fastapi_inst
from .instrumentation import starlette_inst
Expand Down Expand Up @@ -173,6 +178,7 @@ def boot_agent():
# Hooks
from .hooks import hook_uwsgi


if 'INSTANA_DISABLE' not in os.environ:
# There are cases when sys.argv may not be defined at load time. Seems to happen in embedded Python,
# and some Pipenv installs. If this is the case, it's best effort.
Expand All @@ -184,6 +190,7 @@ def boot_agent():
# AutoProfile
if "INSTANA_AUTOPROFILE" in os.environ:
from .singletons import get_profiler

profiler = get_profiler()
if profiler:
profiler.start()
Expand Down
138 changes: 138 additions & 0 deletions instana/instrumentation/sanic_inst.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# (c) Copyright IBM Corp. 2021
# (c) Copyright Instana Inc. 2021

"""
Instrumentation for Sanic
https://sanicframework.org/en/
"""
try:
import sanic
import wrapt
import opentracing
from ..log import logger
from ..singletons import async_tracer, agent
from ..util.secrets import strip_secrets_from_query
from ..util.traceutils import extract_custom_headers


@wrapt.patch_function_wrapper('sanic.exceptions', 'SanicException.__init__')
def exception_with_instana(wrapped, instance, args, kwargs):
message = kwargs.get("message", args[0])
status_code = kwargs.get("status_code")
span = async_tracer.active_span

if all([span, status_code, message]) and (500 <= status_code <= 599):
span.set_tag("http.error", message)
try:
wrapped(*args, **kwargs)
except Exception as exc:
span.log_exception(exc)
else:
wrapped(*args, **kwargs)


def response_details(span, response):
try:
status_code = response.status
if status_code is not None:
if 500 <= int(status_code) <= 511:
span.mark_as_errored()
span.set_tag('http.status_code', status_code)

if response.headers is not None:
async_tracer.inject(span.context, opentracing.Format.HTTP_HEADERS, response.headers)
response.headers['Server-Timing'] = "intid;desc=%s" % span.context.trace_id
except Exception:
logger.debug("send_wrapper: ", exc_info=True)


if hasattr(sanic.response.BaseHTTPResponse, "send"):
@wrapt.patch_function_wrapper('sanic.response', 'BaseHTTPResponse.send')
async def send_with_instana(wrapped, instance, args, kwargs):
span = async_tracer.active_span
if span is None:
await wrapped(*args, **kwargs)
else:
response_details(span=span, response=instance)
try:
await wrapped(*args, **kwargs)
except Exception as exc:
span.log_exception(exc)
raise
else:
@wrapt.patch_function_wrapper('sanic.server', 'HttpProtocol.write_response')
def write_with_instana(wrapped, instance, args, kwargs):
response = args[0]
span = async_tracer.active_span
if span is None:
wrapped(*args, **kwargs)
else:
response_details(span=span, response=response)
try:
wrapped(*args, **kwargs)
except Exception as exc:
span.log_exception(exc)
raise


@wrapt.patch_function_wrapper('sanic.server', 'HttpProtocol.stream_response')
async def stream_with_instana(wrapped, instance, args, kwargs):
response = args[0]
span = async_tracer.active_span
if span is None:
await wrapped(*args, **kwargs)
else:
response_details(span=span, response=response)
try:
await wrapped(*args, **kwargs)
except Exception as exc:
span.log_exception(exc)
raise


@wrapt.patch_function_wrapper('sanic.app', 'Sanic.handle_request')
async def handle_request_with_instana(wrapped, instance, args, kwargs):

try:
request = args[0]
try: # scheme attribute is calculated in the sanic handle_request method for v19, not yet present
if "http" not in request.scheme:
return await wrapped(*args, **kwargs)
except AttributeError:
pass
headers = request.headers.copy()
ctx = async_tracer.extract(opentracing.Format.HTTP_HEADERS, headers)
with async_tracer.start_active_span("asgi", child_of=ctx) as scope:
scope.span.set_tag('span.kind', 'entry')
scope.span.set_tag('http.path', request.path)
scope.span.set_tag('http.method', request.method)
scope.span.set_tag('http.host', request.host)
scope.span.set_tag("http.url", request.url)

query = request.query_string

if isinstance(query, (str, bytes)) and len(query):
if isinstance(query, bytes):
query = query.decode('utf-8')
scrubbed_params = strip_secrets_from_query(query, agent.options.secrets_matcher,
agent.options.secrets_list)
scope.span.set_tag("http.params", scrubbed_params)

if agent.options.extra_http_headers is not None:
extract_custom_headers(scope, headers)
await wrapped(*args, **kwargs)
if hasattr(request, "uri_template"):
scope.span.set_tag("http.path_tpl", request.uri_template)
if hasattr(request, "ctx"): # ctx attribute added in the latest v19 versions
request.ctx.iscope = scope
except Exception as e:
logger.debug("Sanic framework @ handle_request", exc_info=True)
return await wrapped(*args, **kwargs)


logger.debug("Instrumenting Sanic")

except ImportError:
pass
except AttributeError:
logger.debug("Not supported Sanic version")
16 changes: 16 additions & 0 deletions instana/util/traceutils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# (c) Copyright IBM Corp. 2021
# (c) Copyright Instana Inc. 2021

from ..singletons import agent
from ..log import logger


def extract_custom_headers(tracing_scope, headers):
try:
for custom_header in agent.options.extra_http_headers:
# Headers are in the following format: b'x-header-1'
for header_key, value in headers.items():
if header_key.lower() == custom_header.lower():
tracing_scope.span.set_tag("http.header.%s" % custom_header, value)
except Exception as e:
logger.debug("extract_custom_headers: ", exc_info=True)
20 changes: 20 additions & 0 deletions tests/apps/sanic_app/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# (c) Copyright IBM Corp. 2021
# (c) Copyright Instana Inc. 2021


import uvicorn
from ...helpers import testenv
from instana.log import logger

testenv["sanic_port"] = 1337
testenv["sanic_server"] = ("http://127.0.0.1:" + str(testenv["sanic_port"]))


def launch_sanic():
from .server import app
from instana.singletons import agent

# Hack together a manual custom headers list; We'll use this in tests
agent.options.extra_http_headers = [u'X-Capture-This', u'X-Capture-That']

uvicorn.run(app, host='127.0.0.1', port=testenv['sanic_port'], log_level="critical")
14 changes: 14 additions & 0 deletions tests/apps/sanic_app/name.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# (c) Copyright IBM Corp. 2021
# (c) Copyright Instana Inc. 2021


from sanic.views import HTTPMethodView
from sanic.response import text


class NameView(HTTPMethodView):

def get(self, request, name):
return text("Hello {}".format(name))


36 changes: 36 additions & 0 deletions tests/apps/sanic_app/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# (c) Copyright IBM Corp. 2021
# (c) Copyright Instana Inc. 2021

from sanic import Sanic
from sanic.exceptions import SanicException
from .simpleview import SimpleView
from .name import NameView
from sanic.response import text
import instana

app = Sanic('test')

@app.get("/foo/<foo_id:int>")
async def uuid_handler(request, foo_id: int):
return text("INT - {}".format(foo_id))


@app.route("/test_request_args")
async def test_request_args(request):
raise SanicException("Something went wrong.", status_code=500)


@app.get("/tag/<tag>")
async def tag_handler(request, tag):
return text("Tag - {}".format(tag))


app.add_route(SimpleView.as_view(), "/")
app.add_route(NameView.as_view(), "/<name>")


if __name__ == '__main__':
app.run(host="0.0.0.0", port=8000, debug=True, access_log=True)



24 changes: 24 additions & 0 deletions tests/apps/sanic_app/simpleview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# (c) Copyright IBM Corp. 2021
# (c) Copyright Instana Inc. 2021


from sanic.views import HTTPMethodView
from sanic.response import text

class SimpleView(HTTPMethodView):

def get(self, request):
return text("I am get method")

# You can also use async syntax
async def post(self, request):
return text("I am post method")

def put(self, request):
return text("I am put method")

def patch(self, request):
return text("I am patch method")

def delete(self, request):
return text("I am delete method")
3 changes: 1 addition & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import pytest
from distutils.version import LooseVersion


collect_ignore_glob = []

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


@pytest.fixture(scope='session')
def celery_config():
return {
Expand All @@ -62,4 +62,3 @@ def celery_includes():
return {
'tests.frameworks.test_celery'
}

Loading

0 comments on commit 2e79688

Please sign in to comment.