From 0fbcb0253877bddb0c961605c6a0d7684e349f36 Mon Sep 17 00:00:00 2001 From: Vasilii Novikov Date: Sat, 8 May 2021 18:44:24 +0300 Subject: [PATCH] Remove upper bound of Tornado Signed-off-by: Vasilii Novikov --- .travis.yml | 3 +- docker-compose.yml | 2 +- .../client_hooks/boto3.py | 2 +- opentracing_instrumentation/local_span.py | 22 +- .../request_context.py | 74 +----- .../tornado_context.py | 119 +++++++++ setup.py | 8 +- tests/conftest.py | 63 ++++- .../test_asyncio_request_context.py | 139 ++++++++++ .../test_tornado_asyncio_http.py | 114 ++++++++ .../test_traced_function_decorator.py | 185 +------------ ...d_function_decorator_tornado_coroutines.py | 249 ++++++++++++++++++ tox.ini | 9 +- 13 files changed, 715 insertions(+), 274 deletions(-) create mode 100644 opentracing_instrumentation/tornado_context.py create mode 100644 tests/opentracing_instrumentation/test_asyncio_request_context.py create mode 100644 tests/opentracing_instrumentation/test_tornado_asyncio_http.py create mode 100644 tests/opentracing_instrumentation/test_traced_function_decorator_tornado_coroutines.py diff --git a/.travis.yml b/.travis.yml index a379d50..6c7f0ec 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,4 @@ -dist: xenial +dist: bionic addons: apt: packages: @@ -11,6 +11,7 @@ python: - '3.5' - '3.6' - '3.7' + - '3.8' install: - pip install tox-travis coveralls diff --git a/docker-compose.yml b/docker-compose.yml index 674d589..0c8c341 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,7 @@ services: - "3306:3306" localstack: - image: localstack/localstack + image: localstack/localstack:0.10.9 environment: - SERVICES=dynamodb,s3 ports: diff --git a/opentracing_instrumentation/client_hooks/boto3.py b/opentracing_instrumentation/client_hooks/boto3.py index 2ce6b92..ba553d1 100644 --- a/opentracing_instrumentation/client_hooks/boto3.py +++ b/opentracing_instrumentation/client_hooks/boto3.py @@ -2,7 +2,6 @@ import logging from opentracing.ext import tags -from tornado.stack_context import wrap as keep_stack_context from opentracing_instrumentation import utils from ..request_context import get_current_span, span_in_stack_context @@ -10,6 +9,7 @@ try: + from tornado.stack_context import wrap as keep_stack_context from boto3.resources.action import ServiceAction from boto3.s3 import inject as s3_functions from botocore import xform_name diff --git a/opentracing_instrumentation/local_span.py b/opentracing_instrumentation/local_span.py index 0214bb6..69f92f8 100644 --- a/opentracing_instrumentation/local_span.py +++ b/opentracing_instrumentation/local_span.py @@ -21,11 +21,13 @@ from builtins import str import functools import contextlib2 -import tornado.concurrent import opentracing -from opentracing.scope_managers.tornado import TornadoScopeManager +from . import tornado_context from . import get_current_span, span_in_stack_context, span_in_context, utils +if tornado_context.is_tornado_supported(): + import tornado.concurrent + def func_span(func, tags=None, require_active_trace=False): """ @@ -82,10 +84,17 @@ def __exit__(self, exc_type, exc_val, exc_tb): def _span_in_stack_context(span): - if isinstance(opentracing.tracer.scope_manager, TornadoScopeManager): - return span_in_stack_context(span) - else: + if not tornado_context.is_tornado_supported(): + return _DummyStackContext(span_in_context(span)) + if not tornado_context.is_stack_context_supported(): return _DummyStackContext(span_in_context(span)) + if not isinstance( + opentracing.tracer.scope_manager, + tornado_context.TornadoScopeManager + ): + return _DummyStackContext(span_in_context(span)) + + return span_in_stack_context(span) def traced_function(func=None, name=None, on_start=None, @@ -158,7 +167,8 @@ def decorator(*args, **kwargs): # Tornado co-routines usually return futures, so we must wait # until the future is completed, in order to accurately # capture the function's execution time. - if tornado.concurrent.is_future(res): + if tornado_context.is_tornado_supported() and \ + tornado.concurrent.is_future(res): def done_callback(future): deactivate_cb() exception = future.exception() diff --git a/opentracing_instrumentation/request_context.py b/opentracing_instrumentation/request_context.py index d378753..1ca8ef9 100644 --- a/opentracing_instrumentation/request_context.py +++ b/opentracing_instrumentation/request_context.py @@ -23,9 +23,7 @@ import threading import opentracing -from opentracing.scope_managers.tornado import TornadoScopeManager -from opentracing.scope_managers.tornado import tracer_stack_context -from opentracing.scope_managers.tornado import ThreadSafeStackContext # noqa +from .tornado_context import span_in_stack_context # noqa class RequestContext(object): @@ -94,26 +92,6 @@ def __exit__(self, *_): return False -class _TracerEnteredStackContext(object): - """ - An entered tracer_stack_context() object. - - Intended to have a ready-to-use context where - Span objects can be activated before the context - itself is returned to the user. - """ - - def __init__(self, context): - self._context = context - self._deactivation_cb = context.__enter__() - - def __enter__(self): - return self._deactivation_cb - - def __exit__(self, type, value, traceback): - return self._context.__exit__(type, value, traceback) - - def get_current_span(): """ Access current request context and extract current Span from it. @@ -138,6 +116,8 @@ def span_in_context(span): request context. This function should only be used in single-threaded applications like Flask / uWSGI. + This function also compatible with asyncio. + ## Usage example in WSGI middleware: .. code-block:: python @@ -176,51 +156,3 @@ def start_response_wrapper(status, response_headers, return opentracing.Scope(None, None) return opentracing.tracer.scope_manager.activate(span, False) - - -def span_in_stack_context(span): - """ - Create Tornado's StackContext that stores the given span in the - thread-local request context. This function is intended for use - in Tornado applications based on IOLoop, although will work fine - in single-threaded apps like Flask, albeit with more overhead. - - ## Usage example in Tornado application - - Suppose you have a method `handle_request(request)` in the http server. - Instead of calling it directly, use a wrapper: - - .. code-block:: python - - from opentracing_instrumentation import request_context - - @tornado.gen.coroutine - def handle_request_wrapper(request, actual_handler, *args, **kwargs) - - request_wrapper = TornadoRequestWrapper(request=request) - span = http_server.before_request(request=request_wrapper) - - with request_context.span_in_stack_context(span): - return actual_handler(*args, **kwargs) - - :param span: - :return: - Return StackContext that wraps the request context. - """ - - if not isinstance(opentracing.tracer.scope_manager, TornadoScopeManager): - raise RuntimeError('scope_manager is not TornadoScopeManager') - - # Enter the newly created stack context so we have - # storage available for Span activation. - context = tracer_stack_context() - entered_context = _TracerEnteredStackContext(context) - - if span is None: - return entered_context - - opentracing.tracer.scope_manager.activate(span, False) - assert opentracing.tracer.active_span is not None - assert opentracing.tracer.active_span is span - - return entered_context diff --git a/opentracing_instrumentation/tornado_context.py b/opentracing_instrumentation/tornado_context.py new file mode 100644 index 0000000..08a79c2 --- /dev/null +++ b/opentracing_instrumentation/tornado_context.py @@ -0,0 +1,119 @@ +# Copyright (c) 2015 Uber Technologies, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +_tornado_supported = False +_stack_context_supported = False + +import opentracing +try: + import tornado # noqa + _tornado_supported = True + from opentracing.scope_managers.tornado import TornadoScopeManager + from opentracing.scope_managers.tornado import tracer_stack_context + _stack_context_supported = True +except ImportError: + pass + + +def is_tornado_supported(): + return _tornado_supported + + +def is_stack_context_supported(): + return _stack_context_supported + + +class _TracerEnteredStackContext(object): + """ + An entered tracer_stack_context() object. + + Intended to have a ready-to-use context where + Span objects can be activated before the context + itself is returned to the user. + """ + + def __init__(self, context): + self._context = context + self._deactivation_cb = context.__enter__() + + def __enter__(self): + return self._deactivation_cb + + def __exit__(self, type, value, traceback): + return self._context.__exit__(type, value, traceback) + + +def span_in_stack_context(span): + """ + Create Tornado's (4.x, 5.x) StackContext that stores the given span in the + thread-local request context. This function is intended for use + in Tornado applications based on IOLoop, although will work fine + in single-threaded apps like Flask, albeit with more overhead. + + StackContext has been deprecated in Tornado 6 and higher. + Because of asyncio nature of Tornado 6.x, consider using + `span_in_context` with opentracing scope manager `ContextVarScopeManager` + + ## Usage example in Tornado application + + Suppose you have a method `handle_request(request)` in the http server. + Instead of calling it directly, use a wrapper: + + .. code-block:: python + + from opentracing_instrumentation import request_context + + @tornado.gen.coroutine + def handle_request_wrapper(request, actual_handler, *args, **kwargs) + + request_wrapper = TornadoRequestWrapper(request=request) + span = http_server.before_request(request=request_wrapper) + + with request_context.span_in_stack_context(span): + return actual_handler(*args, **kwargs) + + :param span: + :return: + Return StackContext that wraps the request context. + """ + if not _tornado_supported: + raise RuntimeError('span_in_stack_context requires Tornado') + + if not is_stack_context_supported(): + raise RuntimeError('tornado.stack_context is not supported in ' + 'Tornado >= 6.x') + if not isinstance( + opentracing.tracer.scope_manager, TornadoScopeManager + ): + raise RuntimeError('scope_manager is not TornadoScopeManager') + + # Enter the newly created stack context so we have + # storage available for Span activation. + context = tracer_stack_context() + entered_context = _TracerEnteredStackContext(context) + + if span is None: + return entered_context + + opentracing.tracer.scope_manager.activate(span, False) + assert opentracing.tracer.active_span is not None + assert opentracing.tracer.active_span is span + + return entered_context diff --git a/setup.py b/setup.py index 1faebf6..2d9c35a 100644 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ install_requires=[ 'future', 'wrapt', - 'tornado>=4.1,<6', + 'tornado>=4.1', 'contextlib2', 'opentracing>=2,<3', 'six', @@ -47,7 +47,10 @@ 'flake8', 'flake8-quotes', 'mock', - 'moto', + # next major version is incompatible with python 3.5 because of + # cryptography library + 'moto==1.3.16; python_version=="3.5"', + 'moto; python_version!="3.5"', 'MySQL-python; python_version=="2.7"', 'psycopg2-binary', 'sqlalchemy>=1.3.7', @@ -61,6 +64,7 @@ 'Sphinx', 'sphinx_rtd_theme', 'testfixtures', + 'pytest-asyncio; python_version>="3.4"', ] }, ) diff --git a/tests/conftest.py b/tests/conftest.py index b664971..96e8812 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,10 +17,35 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. - +import six +import sys import opentracing import pytest -from opentracing.scope_managers.tornado import TornadoScopeManager +try: + import tornado +except ImportError: + stack_context_support = False +else: + stack_context_support = tornado.version_info < (6, 0, 0, 0) + +collect_ignore = [] + + +if not stack_context_support: + collect_ignore.extend([ + 'opentracing_instrumentation/test_tornado_http.py', + # XXX: boto3 instrumentation relies on stack_context now + 'opentracing_instrumentation/test_boto3.py', + 'opentracing_instrumentation/test_thread_safe_request_context.py', + 'opentracing_instrumentation/test_tornado_request_context.py', + 'opentracing_instrumentation/test_traced_function_decorator_tornado_coroutines.py', + ]) + +if six.PY2: + collect_ignore.extend([ + 'opentracing_instrumentation/test_asyncio_request_context.py', + 'opentracing_instrumentation/test_tornado_asyncio_http.py', + ]) def _get_tracers(scope_manager=None): @@ -45,10 +70,30 @@ def tracer(): opentracing.tracer = old_tracer -@pytest.fixture -def thread_safe_tracer(): - old_tracer, dummy_tracer = _get_tracers(TornadoScopeManager()) - try: - yield dummy_tracer - finally: - opentracing.tracer = old_tracer +if six.PY3: + from opentracing.scope_managers.asyncio import AsyncioScopeManager + asyncio_scope_managers = [AsyncioScopeManager] + + if sys.version_info[:2] >= (3, 7): + from opentracing.scope_managers.contextvars import \ + ContextVarsScopeManager + # ContextVarsScopeManager is recommended scope manager for + # asyncio applications, but it works with python 3.7.x and higher. + asyncio_scope_managers.append( + ContextVarsScopeManager + ) + + @pytest.fixture(params=asyncio_scope_managers) + def asyncio_scope_manager(request): + return request.param() + +if stack_context_support: + from opentracing.scope_managers.tornado import TornadoScopeManager + + @pytest.fixture + def thread_safe_tracer(): + old_tracer, dummy_tracer = _get_tracers(TornadoScopeManager()) + try: + yield dummy_tracer + finally: + opentracing.tracer = old_tracer diff --git a/tests/opentracing_instrumentation/test_asyncio_request_context.py b/tests/opentracing_instrumentation/test_asyncio_request_context.py new file mode 100644 index 0000000..69d1117 --- /dev/null +++ b/tests/opentracing_instrumentation/test_asyncio_request_context.py @@ -0,0 +1,139 @@ +# Copyright (c) 2015 Uber Technologies, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from __future__ import absolute_import +import pytest +import asyncio +import opentracing +from opentracing.scope_managers.asyncio import AsyncioScopeManager +from opentracing_instrumentation.request_context import ( + get_current_span, + span_in_context, +) +from opentracing.mocktracer.tracer import MockTracer + + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture +def tracer(asyncio_scope_manager): + new_tracer = MockTracer(scope_manager=asyncio_scope_manager) + tracer = opentracing.global_tracer() + opentracing.set_global_tracer(new_tracer) + yield new_tracer + opentracing.set_global_tracer(tracer) + + +async def test_basic(tracer): + + assert opentracing.global_tracer() == tracer + + async def coro(): + span = tracer.start_span(operation_name='foobar') + with span_in_context(span): + span.finish() + return span + + assert get_current_span() is None + + span = await coro() + + assert get_current_span() is None + + assert tracer.finished_spans() == [span] + + +async def test_nested(tracer): + + assert opentracing.global_tracer() == tracer + + async def coro(name, finish): + span = tracer.start_span(operation_name=name) + with span_in_context(span): + if finish: + span.finish() + return span + + assert get_current_span() == None + + with tracer.start_active_span(operation_name='foo') as scope: + outer_span = scope.span + + # Nested span in first coroutine has been finished. + span_bar = await coro('bar', finish=True) + # In second coroutine nested span still alive. + span_baz = await coro('baz', finish=False) + # Nevertheless we returning to outer scope and must get outer span as + # current span. + assert get_current_span() == outer_span + + # 2 of 3 span are finished. + assert tracer.finished_spans() == [span_bar, outer_span] + + assert span_bar.parent_id == outer_span.context.span_id + + # baz is not finished but got outer_span as parent. + assert not span_baz.finished + assert span_baz.parent_id == outer_span.context.span_id + + +async def test_nested_tasks(tracer, asyncio_scope_manager): + + assert opentracing.global_tracer() == tracer + + async def coro(name, finish): + span = tracer.start_span(operation_name=name) + with span_in_context(span): + if finish: + span.finish() + return span + + assert get_current_span() == None + + with tracer.start_active_span(operation_name='foo') as scope: + outer_span = scope.span + + # Nested span in first task will finished. + span_bar = asyncio.ensure_future(coro('bar', finish=True)) + # In second coroutine task span still alive. + span_baz = asyncio.ensure_future(coro('baz', finish=False)) + # Nevertheless we returning to outer scope and must get outer span as + # current span. + assert get_current_span() == outer_span + + span_bar = await span_bar + span_baz = await span_baz + + # 2 of 3 span are finished. + # Note that outer span will be finished first ("bar" goes into event loop). + assert tracer.finished_spans() == [outer_span, span_bar] + + # baz is not finished + assert not span_baz.finished + + if isinstance(asyncio_scope_manager, AsyncioScopeManager): + # AsyncioScopeManager doesn't support context propagation into + # tasks. See https://github.com/opentracing/opentracing-python/blob/master/opentracing/scope_managers/asyncio.py#L37 + assert span_bar.parent_id is None + assert span_baz.parent_id is None + else: + assert span_bar.parent_id == outer_span.context.span_id + assert span_baz.parent_id == outer_span.context.span_id diff --git a/tests/opentracing_instrumentation/test_tornado_asyncio_http.py b/tests/opentracing_instrumentation/test_tornado_asyncio_http.py new file mode 100644 index 0000000..ae185c1 --- /dev/null +++ b/tests/opentracing_instrumentation/test_tornado_asyncio_http.py @@ -0,0 +1,114 @@ +# Copyright (c) 2015 Uber Technologies, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import pytest +from basictracer import BasicTracer +from basictracer.recorder import InMemoryRecorder +from mock import Mock, patch +import opentracing +import tornado.web +import tornado.httpserver +import tornado.netutil +import tornado.httpclient + +from opentracing_instrumentation.client_hooks.tornado_http import ( + install_patches, + reset_patchers +) +from opentracing_instrumentation.http_server import ( + TornadoRequestWrapper, + before_request +) +from opentracing_instrumentation.interceptors import OpenTracingInterceptor + + +pytestmark = pytest.mark.gen_test + + +class Handler(tornado.web.RequestHandler): + + def get(self): + request = TornadoRequestWrapper(self.request) + with before_request(request, tracer=opentracing.tracer) as span: + self.write('{:x}'.format(span.context.trace_id)) + self.set_status(200) + + +@pytest.fixture +def app(): + return tornado.web.Application([ + (r"/", Handler) + ]) + + +@pytest.fixture +def tornado_http_patch(): + install_patches.__original_func() + try: + yield None + finally: + reset_patchers() + + +@pytest.fixture +def tracer(asyncio_scope_manager): + t = BasicTracer( + recorder=InMemoryRecorder(), + scope_manager=asyncio_scope_manager, + ) + t.register_required_propagators() + return t + + +async def test_http_fetch(base_url, http_client, tornado_http_patch, tracer): + + with patch('opentracing.tracer', tracer): + assert opentracing.tracer == tracer + + with tracer.start_active_span('test') as scope: + span = scope.span + trace_id = '{:x}'.format(span.context.trace_id) + + response = await http_client.fetch(base_url) + + assert response.code == 200 + assert response.body.decode('utf-8') == trace_id + + +async def test_http_fetch_with_interceptor(base_url, http_client, tornado_http_patch, tracer): + + with patch('opentracing.tracer', tracer): + assert opentracing.tracer == tracer # sanity check that patch worked + + with tracer.start_active_span('test') as scope: + span = scope.span + trace_id = '{:x}'.format(span.context.trace_id) + + with patch('opentracing_instrumentation.http_client.ClientInterceptors') as MockClientInterceptors: + mock_interceptor = Mock(spec=OpenTracingInterceptor) + MockClientInterceptors.get_interceptors.return_value = [mock_interceptor] + + response = await http_client.fetch(base_url) + + mock_interceptor.process.assert_called_once() + assert mock_interceptor.process.call_args_list[0][1]['span'].tracer == tracer + + assert response.code == 200 + assert response.body.decode('utf-8') == trace_id diff --git a/tests/opentracing_instrumentation/test_traced_function_decorator.py b/tests/opentracing_instrumentation/test_traced_function_decorator.py index 474cb54..671257f 100644 --- a/tests/opentracing_instrumentation/test_traced_function_decorator.py +++ b/tests/opentracing_instrumentation/test_traced_function_decorator.py @@ -26,15 +26,9 @@ import opentracing from opentracing.mocktracer import MockTracer -from opentracing.scope_managers.tornado import TornadoScopeManager from opentracing.scope_managers import ThreadLocalScopeManager -import tornado.stack_context -from tornado.concurrent import is_future -from tornado import gen -from tornado.testing import AsyncTestCase, gen_test from opentracing_instrumentation import traced_function -from opentracing_instrumentation import span_in_stack_context patch_object = mock.patch.object @@ -73,51 +67,22 @@ def regular_with_nested(self, param): self.regular(param) self.regular_with_name(param) - def _coro(self, param): - return tornado.gen.Return(self._func(param)) - @traced_function - @gen.coroutine - def coro(self, param): - raise self._coro(param) - - @traced_function(name='some_name') - @gen.coroutine - def coro_with_name(self, param): - raise self._coro(param) - - @traced_function(on_start=extract_call_site_tag) - @gen.coroutine - def coro_with_hook(self, param1, param2=None, call_site_tag=None): - assert param1 == call_site_tag - assert param2 is None - raise tornado.gen.Return('oh yeah') - - @traced_function(require_active_trace=True) - def coro_require_active_trace(self, param): - raise self._coro(param) - - -class PrepareMixin(object): +class TracedRegularFunctionDecoratorTest(unittest.TestCase): - scope_manager = None + scope_manager = ThreadLocalScopeManager def setUp(self): - super(PrepareMixin, self).setUp() + super(TracedRegularFunctionDecoratorTest, self).setUp() self.patcher = mock.patch( 'opentracing.tracer', MockTracer(self.scope_manager())) self.patcher.start() self.client = Client() def tearDown(self): - super(PrepareMixin, self).tearDown() + super(TracedRegularFunctionDecoratorTest, self).tearDown() self.patcher.stop() - -class TracedRegularFunctionDecoratorTest(PrepareMixin, unittest.TestCase): - - scope_manager = ThreadLocalScopeManager - def test_no_arg_decorator(self): parent = opentracing.tracer.start_span('hello') @@ -240,145 +205,3 @@ def test_nested_functions_with_exception(self): # Check parent context has been restored. assert tracer.scope_manager.active is scope - - -class TracedCoroFunctionDecoratorTest(PrepareMixin, AsyncTestCase): - - scope_manager = TornadoScopeManager - - @gen.coroutine - def call(self, method, *args, **kwargs): - """ - Execute synchronous or asynchronous method of client and return the - result. - """ - result = getattr(self.client, method)(*args, **kwargs) - if is_future(result): - result = yield result - raise tornado.gen.Return(result) - - @gen_test - def test_no_arg_decorator(self): - - parent = opentracing.tracer.start_span('hello') - - @gen.coroutine - def run(): - # test both co-routine and regular function - for func in ('regular', 'coro', ): - child = mock.Mock() - # verify start_child is called with actual function name - with patch_object(opentracing.tracer, 'start_span', - return_value=child) as start_child: - r = yield self.call(func, 123) - start_child.assert_called_once_with( - operation_name=func, - child_of=parent.context, - tags=None) - child.set_tag.assert_not_called() - child.error.assert_not_called() - child.finish.assert_called_once() - assert r == 'oh yeah' - - # verify span.error() is called on exception - child = mock.Mock() - with patch_object(opentracing.tracer, 'start_span') \ - as start_child: - start_child.return_value = child - with pytest.raises(AssertionError): - yield self.call(func, 999) - child.log.assert_called_once() - child.finish.assert_called_once() - - raise tornado.gen.Return(1) - - yield run_coroutine_with_span(span=parent, coro=run) - - @gen_test - def test_decorator_with_name(self): - - parent = opentracing.tracer.start_span('hello') - - @gen.coroutine - def run(): - # verify start_span is called with overridden function name - for func in ('regular_with_name', 'coro_with_name', ): - child = mock.Mock() - with patch_object(opentracing.tracer, 'start_span', - return_value=child) as start_child: - r = yield self.call(func, 123) - assert r == 'oh yeah' - start_child.assert_called_once_with( - operation_name='some_name', # overridden name - child_of=parent.context, - tags=None) - child.set_tag.assert_not_called() - - raise tornado.gen.Return(1) - - yield run_coroutine_with_span(span=parent, coro=run) - - @gen_test - def test_decorator_with_start_hook(self): - - parent = opentracing.tracer.start_span('hello') - - @gen.coroutine - def run(): - # verify call_size_tag argument is extracted and added as tag - for func in ('regular_with_hook', 'coro_with_hook', ): - child = mock.Mock() - with patch_object(opentracing.tracer, 'start_span') \ - as start_child: - start_child.return_value = child - r = yield self.call( - func, 'somewhere', call_site_tag='somewhere') - assert r == 'oh yeah' - start_child.assert_called_once_with( - operation_name=func, - child_of=parent.context, - tags=None) - child.set_tag.assert_called_once_with( - 'call_site_tag', 'somewhere') - - raise tornado.gen.Return(1) - - yield run_coroutine_with_span(span=parent, coro=run) - - @gen_test - def test_no_parent_span(self): - - @gen.coroutine - def run(): - # verify a new trace is started - for func1, func2 in (('regular', 'regular_require_active_trace'), - ('coro', 'coro_require_active_trace')): - with patch_object(opentracing.tracer, 'start_span') as start: - r = yield self.call(func1, 123) - assert r == 'oh yeah' - start.assert_called_once_with( - operation_name=func1, child_of=None, tags=None) - - # verify no new trace or child span is started - with patch_object(opentracing.tracer, 'start_span') as start: - r = yield self.call(func2, 123) - assert r == 'oh yeah' - start.assert_not_called() - - raise tornado.gen.Return(1) - - yield run_coroutine_with_span(span=None, coro=run) - - -def run_coroutine_with_span(span, coro, *args, **kwargs): - """Wrap the execution of a Tornado coroutine func in a tracing span. - - This makes the span available through the get_current_span() function. - - :param span: The tracing span to expose. - :param coro: Co-routine to execute in the scope of tracing span. - :param args: Positional args to func, if any. - :param kwargs: Keyword args to func, if any. - """ - with span_in_stack_context(span=span): - return coro(*args, **kwargs) diff --git a/tests/opentracing_instrumentation/test_traced_function_decorator_tornado_coroutines.py b/tests/opentracing_instrumentation/test_traced_function_decorator_tornado_coroutines.py new file mode 100644 index 0000000..5c712f7 --- /dev/null +++ b/tests/opentracing_instrumentation/test_traced_function_decorator_tornado_coroutines.py @@ -0,0 +1,249 @@ +# Copyright (c) 2015 Uber Technologies, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from __future__ import absolute_import + +import mock +import pytest + +import opentracing +from opentracing.mocktracer import MockTracer +from opentracing.scope_managers.tornado import TornadoScopeManager + +import tornado.stack_context +from tornado.concurrent import is_future +from tornado import gen +from tornado.testing import AsyncTestCase, gen_test +from opentracing_instrumentation import traced_function +from opentracing_instrumentation import span_in_stack_context + +patch_object = mock.patch.object + + +def extract_call_site_tag(span, *_, **kwargs): + if 'call_site_tag' in kwargs: + span.set_tag('call_site_tag', kwargs['call_site_tag']) + + +class Client(object): + + def _func(self, param): + assert param == 123 + return 'oh yeah' + + @traced_function + def regular(self, param): + return self._func(param) + + @traced_function(name='some_name') + def regular_with_name(self, param): + return self._func(param) + + @traced_function(on_start=extract_call_site_tag) + def regular_with_hook(self, param1, param2=None, call_site_tag=None): + assert param1 == call_site_tag + assert param2 is None + return 'oh yeah' + + @traced_function(require_active_trace=True) + def regular_require_active_trace(self, param): + return self._func(param) + + @traced_function() + def regular_with_nested(self, param): + self.regular(param) + self.regular_with_name(param) + + def _coro(self, param): + return tornado.gen.Return(self._func(param)) + + @traced_function + @gen.coroutine + def coro(self, param): + raise self._coro(param) + + @traced_function(name='some_name') + @gen.coroutine + def coro_with_name(self, param): + raise self._coro(param) + + @traced_function(on_start=extract_call_site_tag) + @gen.coroutine + def coro_with_hook(self, param1, param2=None, call_site_tag=None): + assert param1 == call_site_tag + assert param2 is None + raise tornado.gen.Return('oh yeah') + + @traced_function(require_active_trace=True) + def coro_require_active_trace(self, param): + raise self._coro(param) + + +class TracedCoroFunctionDecoratorTest(AsyncTestCase): + + scope_manager = TornadoScopeManager + + def setUp(self): + super(TracedCoroFunctionDecoratorTest, self).setUp() + self.patcher = mock.patch( + 'opentracing.tracer', MockTracer(self.scope_manager())) + self.patcher.start() + self.client = Client() + + def tearDown(self): + super(TracedCoroFunctionDecoratorTest, self).tearDown() + self.patcher.stop() + + @gen.coroutine + def call(self, method, *args, **kwargs): + """ + Execute synchronous or asynchronous method of client and return the + result. + """ + result = getattr(self.client, method)(*args, **kwargs) + if is_future(result): + result = yield result + raise tornado.gen.Return(result) + + @gen_test + def test_no_arg_decorator(self): + + parent = opentracing.tracer.start_span('hello') + + @gen.coroutine + def run(): + # test both co-routine and regular function + for func in ('regular', 'coro', ): + child = mock.Mock() + # verify start_child is called with actual function name + with patch_object(opentracing.tracer, 'start_span', + return_value=child) as start_child: + r = yield self.call(func, 123) + start_child.assert_called_once_with( + operation_name=func, + child_of=parent.context, + tags=None) + child.set_tag.assert_not_called() + child.error.assert_not_called() + child.finish.assert_called_once() + assert r == 'oh yeah' + + # verify span.error() is called on exception + child = mock.Mock() + with patch_object(opentracing.tracer, 'start_span') \ + as start_child: + start_child.return_value = child + with pytest.raises(AssertionError): + yield self.call(func, 999) + child.log.assert_called_once() + child.finish.assert_called_once() + + raise tornado.gen.Return(1) + + yield run_coroutine_with_span(span=parent, coro=run) + + @gen_test + def test_decorator_with_name(self): + + parent = opentracing.tracer.start_span('hello') + + @gen.coroutine + def run(): + # verify start_span is called with overridden function name + for func in ('regular_with_name', 'coro_with_name', ): + child = mock.Mock() + with patch_object(opentracing.tracer, 'start_span', + return_value=child) as start_child: + r = yield self.call(func, 123) + assert r == 'oh yeah' + start_child.assert_called_once_with( + operation_name='some_name', # overridden name + child_of=parent.context, + tags=None) + child.set_tag.assert_not_called() + + raise tornado.gen.Return(1) + + yield run_coroutine_with_span(span=parent, coro=run) + + @gen_test + def test_decorator_with_start_hook(self): + + parent = opentracing.tracer.start_span('hello') + + @gen.coroutine + def run(): + # verify call_size_tag argument is extracted and added as tag + for func in ('regular_with_hook', 'coro_with_hook', ): + child = mock.Mock() + with patch_object(opentracing.tracer, 'start_span') \ + as start_child: + start_child.return_value = child + r = yield self.call( + func, 'somewhere', call_site_tag='somewhere') + assert r == 'oh yeah' + start_child.assert_called_once_with( + operation_name=func, + child_of=parent.context, + tags=None) + child.set_tag.assert_called_once_with( + 'call_site_tag', 'somewhere') + + raise tornado.gen.Return(1) + + yield run_coroutine_with_span(span=parent, coro=run) + + @gen_test + def test_no_parent_span(self): + + @gen.coroutine + def run(): + # verify a new trace is started + for func1, func2 in (('regular', 'regular_require_active_trace'), + ('coro', 'coro_require_active_trace')): + with patch_object(opentracing.tracer, 'start_span') as start: + r = yield self.call(func1, 123) + assert r == 'oh yeah' + start.assert_called_once_with( + operation_name=func1, child_of=None, tags=None) + + # verify no new trace or child span is started + with patch_object(opentracing.tracer, 'start_span') as start: + r = yield self.call(func2, 123) + assert r == 'oh yeah' + start.assert_not_called() + + raise tornado.gen.Return(1) + + yield run_coroutine_with_span(span=None, coro=run) + + +def run_coroutine_with_span(span, coro, *args, **kwargs): + """Wrap the execution of a Tornado coroutine func in a tracing span. + + This makes the span available through the get_current_span() function. + + :param span: The tracing span to expose. + :param coro: Co-routine to execute in the scope of tracing span. + :param args: Positional args to func, if any. + :param kwargs: Keyword args to func, if any. + """ + with span_in_stack_context(span=span): + return coro(*args, **kwargs) diff --git a/tox.ini b/tox.ini index 1d1cf17..4a9e20a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,10 @@ [tox] envlist = py{27,35,36}-celery{3,4} - py37-celery4 - py{27,35,36,37}-missing_modules + py{37,38}-celery4 + py{27,35,36,37,38}-missing_modules + py{27}-tornado{4,5} + py{35,36,37,38}-tornado{4,5,6} skip_missing_interpreters = true [testenv] @@ -11,6 +13,9 @@ setenv = deps = celery3: celery~=3.0 celery4: celery~=4.0 + tornado4: tornado>=4,<5 + tornado5: tornado>=5,<6 + tornado6: tornado>=6 missing_modules: pytest-cov extras = !missing_modules: tests