├── .gitignore ├── .travis.yml ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── RELEASE.md ├── docker-compose.yml ├── opentracing_instrumentation ├── __init__.py ├── client_hooks │ ├── __init__.py │ ├── _current_span.py │ ├── _dbapi2.py │ ├── _patcher.py │ ├── _singleton.py │ ├── boto3.py │ ├── celery.py │ ├── mysqldb.py │ ├── psycopg2.py │ ├── requests.py │ ├── sqlalchemy.py │ ├── strict_redis.py │ ├── tornado_http.py │ ├── urllib.py │ └── urllib2.py ├── config.py ├── http_client.py ├── http_server.py ├── interceptors.py ├── local_span.py ├── request_context.py └── utils.py ├── requirements-test.txt ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── conftest.py └── opentracing_instrumentation │ ├── __init__.py │ ├── sql_common.py │ ├── test_boto3.py │ ├── test_celery.py │ ├── test_install_client_hooks.py │ ├── test_local_span.py │ ├── test_middleware.py │ ├── test_missing_modules_handling.py │ ├── test_mysqldb.py │ ├── test_postgres.py │ ├── test_redis.py │ ├── test_requests.py │ ├── test_sqlalchemy.py │ ├── test_sync_client_hooks.py │ ├── test_thread_safe_request_context.py │ ├── test_tornado_http.py │ ├── test_tornado_request_context.py │ ├── test_traced_function_decorator.py │ └── test_wsgi_request.py ├── tox.ini └── travis └── install-protoc.sh /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | *.orig 3 | 4 | __pycache__ 5 | .cache/ 6 | 7 | # Packages 8 | *.egg 9 | *.egg-info 10 | 11 | build/ 12 | dist/ 13 | .envrc 14 | 15 | # Unit test / coverage reports 16 | .pytest_cache/ 17 | .coverage 18 | .coverage* 19 | .tox 20 | .noseids 21 | htmlcov/ 22 | 23 | # Ignore python virtual environments 24 | env* 25 | 26 | # Ignore coverage output 27 | *.xml 28 | coverage/ 29 | 30 | # Ignore benchmarks output 31 | perf.log 32 | perf.svg 33 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | addons: 3 | apt: 4 | packages: 5 | - rabbitmq-server 6 | sudo: true 7 | 8 | language: python 9 | python: 10 | - '2.7' 11 | - '3.5' 12 | - '3.6' 13 | - '3.7' 14 | 15 | install: 16 | - pip install tox-travis coveralls 17 | 18 | services: 19 | - docker 20 | - mysql 21 | - postgresql 22 | - rabbitmq 23 | - redis-server 24 | 25 | before_script: 26 | - docker-compose up -d localstack 27 | - mysql -e 'CREATE DATABASE test;' 28 | - psql -c 'CREATE DATABASE test;' -U postgres 29 | 30 | # waiting for the DynamoDB mock 31 | - | 32 | while [[ $(curl -so /dev/null -w '%{response_code}' localhost:4569) != '400' ]] 33 | do 34 | echo 'waiting 1 sec for DynamoDB...' 35 | sleep 1 36 | done 37 | 38 | # waiting for the S3 mock 39 | - | 40 | while [[ $(curl -so /dev/null -w '%{response_code}' localhost:4572) != '200' ]] 41 | do 42 | echo 'waiting 1 sec for S3...' 43 | sleep 1 44 | done 45 | 46 | script: 47 | - tox 48 | 49 | after_success: 50 | coveralls -v 51 | 52 | deploy: 53 | provider: pypi 54 | distributions: 'sdist bdist_wheel' 55 | user: jaegerbot 56 | password: 57 | secure: aNbg/EKD5xH9FmBEts0668KectIxCewHt5+jwMaOfOJ4t1HfgnBYRHszW41GwEYvK23rrgo95vbIq3+xOqxuEFXGlXFNWevuM9idB9VfFH1ijee7rUfFvsH++BxLirbYTVkHHg6cq8TjVIgd/ATwJoI9it1N82j0d/rIUsOjUdEa9dm9wHTTj7yUbcHrARH7Bo44VnJpbwgz2yNuxo1tiO8CatP9zj8DydhmQlASM1IMJrN5tRwlTZayYDKTVyi/OL3Zi9rzIWxT4V3I3fkbp+YH9nZOTboN3UJlL1DcZWBNN6wcIGm9b2bLi+Guhzduxz+ybN1sTr1hed+f2onTH3H2WKKgfqrdgn+7URCc8EWlP76Fu4U6HgG3k8om+hDmJ5eqMF2Mm7NYuWbz2bDbqlTJtbPe6118l4DoomMCcnD+k18czjFyI1dsE5Uj16GWQz3FWZhm1bovhit8Wemnhjxrk/1q4klPrQnNLcgSSrYDxblUp816NG6/spaws+7/BpaUbZLF+ARL2cNWS5Tzfi1vQhRaSuLbkHdxOEbmGEbhf9LQEH0ax2VRSgjMWEY3LRlj4K882Jjv39tlZ8YFbW01ll212nU4xg+fhYPdkVhG+OFNgFFMKqnNVzgZKNje5RQ8/g3+7u2LkcL9QiimY1SFL/mF0ckpRN6KbB0AhMc= 58 | skip_existing: true 59 | on: 60 | tags: true 61 | python: '3.7' 62 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | .. :changelog: 2 | 3 | History 4 | ------- 5 | 6 | 3.3.2 (unreleased) 7 | ------------------ 8 | 9 | - Nothing changed yet. 10 | 11 | 12 | 3.3.1 (2020-06-23) 13 | ------------------ 14 | 15 | - Fix test hanging on waiting for DynamoDB (#112) 16 | 17 | 18 | 3.3.0 (2020-06-16) 19 | ------------------ 20 | 21 | - Add additional tags to SQLAlchemy instrumentation (#107) 22 | 23 | 24 | 3.2.1 (2019-10-02) 25 | ------------------ 26 | 27 | - Fix db_span function of _dbapi2 to work with bytes (#105) 28 | - Fix handling of missing psycopg2 module (#104) 29 | 30 | 31 | 3.2.0 (2019-09-02) 32 | ------------------ 33 | 34 | - Improve support for Boto 3 and S3 (#102) 35 | - Refactor MySQLdb hook (#100) 36 | - Refactor SQLAlchemy hook (#98) 37 | - Fix span context propagation for Celery (#97) 38 | - Use tox for CI instead of travis's matrix (#96) 39 | - Remove support for Python 3.4 (#95) 40 | 41 | 42 | 3.1.1 (2019-07-05) 43 | ------------------ 44 | 45 | - Set deploy username and password 46 | - Add deploy section into .travis.yml (#94) 47 | - Fix coveralls/boto3 dependency conflict (#93) 48 | 49 | 50 | 3.1.0 (2019-06-16) 51 | ------------------ 52 | 53 | - Support non TornadoScopeManager in traced_function decorator (#86) 54 | - Add support for Celery (#85) 55 | - Add tags.HTTP_METHOD to before_request (#79) 56 | - Using opentracing.ext in all hooks. (#78) 57 | - Add support for boto3 (#77) 58 | - Support Composable object as sql statement for psycopg2 (#82) 59 | - Add support for Tornado 5 (#76) 60 | - Fix compatibility with Peewee ORM (#72) 61 | 62 | 63 | 3.0.1 (2019-04-28) 64 | ------------------ 65 | 66 | - Unpin OpenTracing upper bound to <3 67 | 68 | 69 | 3.0.0 (2019-04-27) 70 | ------------------ 71 | 72 | - Upgrade to OpenTracing 2.0 API (#54) 73 | - Permit the injection of specific parent span (#73) 74 | - Fix `opentracing_instrumentation.client_hooks.strict_redis.reset_patches` method 75 | - Add `response_handler_hook` to `requests` instrumentation (#66) 76 | - Add unit test for ThreadSafeStackContext (#71) 77 | - Improve testing (#70) (5 months ago) 78 | - Add tox.ini 79 | - Update setup.cfg 80 | - Enable branch coverage measurement 81 | - Update the README 82 | - Unify tracer fixture and move it to conftest.py 83 | - Fix some deprecation warnings 84 | - Clean up imports 85 | - Update supported Python versions (+3.7, -3.3) (#69) 86 | - Fix function call in code example (#67) 87 | 88 | 2.4.3 (2018-08-24) 89 | ------------------ 90 | 91 | - Fix Makefile to check Python version to determine setuptools version (#62) 92 | - Fix gettattr for ContextManagerConnectionWrapper (#63) 93 | 94 | 95 | 2.4.2 (2018-08-03) 96 | ------------------ 97 | 98 | - Fix wrapper for psycopg2 connection so type check does not fail (#55) 99 | 100 | 101 | 2.4.1 (2018-04-19) 102 | ------------------ 103 | 104 | - Remove dependency on 'futures' (#47) 105 | 106 | 107 | 2.4.0 (2018-01-09) 108 | ------------------ 109 | 110 | - Add client hooks for psycopg2 (#39) 111 | 112 | 113 | 2.3.0 (2017-10-25) 114 | ------------------ 115 | 116 | - Futurize to support Python 3 117 | - Add interceptor support to ``opentracing_instrumentation.http_client`` 118 | - Add function to install interceptors from list 119 | 120 | 121 | 2.2.0 (2016-10-04) 122 | ------------------ 123 | 124 | - Upgrade to opentracing 1.2 with KV logging 125 | 126 | 127 | 2.1.0 (2016-09-08) 128 | ------------------ 129 | 130 | - Remove url encoding/decoding when using HTTP_HEADERS codecs 131 | 132 | 133 | 2.0.3 (2016-08-11) 134 | ------------------ 135 | 136 | - Match redis.set() signature 137 | 138 | 139 | 2.0.2 (2016-08-10) 140 | ------------------ 141 | 142 | - Fix monkeypatched argument names in redis hooks 143 | 144 | 145 | 2.0.1 (2016-08-09) 146 | ------------------ 147 | 148 | - Correct API in strict_redis patcher. 149 | 150 | 151 | 2.0.0 (2016-08-07) 152 | ------------------ 153 | 154 | - Upgrade to OpenTracing API 1.1 with SpanContext 155 | 156 | 157 | 1.4.1 (2016-08-07) 158 | ------------------ 159 | 160 | - Fix relative import 161 | 162 | 163 | 1.4.1 (2016-08-07) 164 | ------------------ 165 | 166 | - Fix relative import 167 | 168 | 169 | 1.4.0 (2016-08-02) 170 | ------------------ 171 | 172 | - Add more information to Redis hooks 173 | 174 | 175 | 1.3.0 (2016-07-29) 176 | ------------------ 177 | 178 | - Add Redis hooks 179 | 180 | 181 | 1.2.0 (2016-07-19) 182 | ------------------ 183 | 184 | - Add config-based client_hooks patching 185 | 186 | 187 | 1.1.1 (2016-07-14) 188 | ------------------ 189 | 190 | - Support backwards compatible usage of RequestContextManager with span argument 191 | 192 | 193 | 1.1.0 (2016-06-09) 194 | ------------------ 195 | 196 | - Change request context from Span to a wrapper object RequestContext 197 | 198 | 199 | 1.0.1 (2016-06-06) 200 | ------------------ 201 | 202 | - Apply URL quote/unquote to values stored in the headers 203 | 204 | 205 | 1.0.0 (2016-05-24) 206 | ------------------ 207 | 208 | - Upgrade to OpenTracing API 1.0rc4 209 | 210 | 211 | 0.4.2 (2016-03-28) 212 | ------------------ 213 | 214 | - Work around uWSGI collecting wsgi_environ.iteritems() during iteration 215 | 216 | 217 | 0.4.1 (2016-03-03) 218 | ------------------ 219 | 220 | - Fix memory leak in SQL instrumentation 221 | 222 | 223 | 0.4.0 (2016-02-26) 224 | ------------------ 225 | 226 | - Replace Tornado's StackContext with ThreadSafeStackContext 227 | 228 | 229 | 0.3.11 (2016-02-06) 230 | ------------------- 231 | 232 | - Add instrumentation for `requests` library 233 | 234 | 235 | 0.3.9 (2016-02-04) 236 | ------------------ 237 | 238 | - Set SPAN_KIND tag for all RPC spans. 239 | - Allow traced_function to start a trace. 240 | 241 | 242 | 0.3.8 (2016-01-22) 243 | ------------------ 244 | 245 | - Check if MySQLdb can be imported before trying to instrument it. 246 | 247 | 248 | 0.3.7 (2016-01-22) 249 | ------------------ 250 | 251 | - Expose `client_hooks.install_all_patches` convenience method 252 | 253 | 254 | 0.3.6 (2016-01-20) 255 | ------------------ 256 | 257 | - Merge traced_function/traced_coroutine into a single decorator, with custom on-start hook 258 | 259 | 260 | 0.3.5 (2016-01-17) 261 | ------------------ 262 | 263 | - Upgrade to latest OpenTracing (change add_tag to set_tag) 264 | - Add decorators for functions and Tornado coroutines 265 | - Clean-up premature conversion to str and use span.error() for reporting errors 266 | 267 | 268 | 0.3.4 (2016-01-13) 269 | ------------------ 270 | 271 | - Bug fix for empty context manager when there is no parent span. 272 | 273 | 274 | 0.3.3 (2016-01-11) 275 | ------------------ 276 | 277 | - Set upper bound on opentracing version 278 | 279 | 280 | 0.3.2 (2016-01-11) 281 | ------------------ 282 | 283 | - Use wrapt.ObjectProxy to ensure all methods from wrapped connection/cursor are exposed 284 | 285 | 286 | 0.3.1 (2016-01-08) 287 | ------------------ 288 | 289 | - Add support for mysql-python, with a general framework for PEP-249 drivers 290 | 291 | 292 | 0.2.0 (2016-01-06) 293 | ------------------ 294 | 295 | - Upgrade to OpenTracing API 0.4.x 296 | 297 | 298 | 0.1.1 (2016-01-02) 299 | ------------------ 300 | 301 | - Use findpackages 302 | 303 | 304 | 0.1.0 (2016-01-02) 305 | ------------------ 306 | 307 | - Initial version 308 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Uber Technologies, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | project := opentracing_instrumentation 2 | 3 | pytest := PYTHONDONTWRITEBYTECODE=1 py.test --tb short -rxs \ 4 | --cov-config .coveragerc --cov $(project) tests 5 | 6 | html_report := --cov-report=html 7 | test_args := --cov-report xml --cov-report term-missing 8 | 9 | .PHONY: clean-pyc clean-build docs clean 10 | .DEFAULT_GOAL : help 11 | 12 | help: 13 | @echo "bootstrap - initialize local environement for development. Requires virtualenv." 14 | @echo "clean - remove all build, test, coverage and Python artifacts" 15 | @echo "clean-build - remove build artifacts" 16 | @echo "clean-pyc - remove Python file artifacts" 17 | @echo "clean-test - remove test and coverage artifacts" 18 | @echo "lint - check style with flake8" 19 | @echo "test - run tests quickly with the default Python" 20 | @echo "coverage - check code coverage quickly with the default Python" 21 | @echo "docs - generate Sphinx HTML documentation, including API docs" 22 | @echo "release - package and upload a release" 23 | @echo "dist - package" 24 | @echo "install - install the package to the active Python's site-packages" 25 | 26 | check-virtualenv: 27 | @[ -d env ] || echo "Please run 'virtualenv env' first" 28 | @[ -d env ] || exit 1 29 | 30 | bootstrap: check-virtualenv install-deps 31 | 32 | install-deps: 33 | pip install -U pip setuptools wheel 34 | pip install -U --upgrade-strategy eager -r requirements-test.txt 35 | python setup.py develop 36 | 37 | clean: clean-build clean-pyc clean-test 38 | 39 | clean-build: 40 | rm -fr build/ 41 | rm -fr dist/ 42 | rm -fr .eggs/ 43 | find . -name '*.egg-info' -exec rm -fr {} + 44 | find . -name '*.egg' -exec rm -rf {} + 45 | 46 | clean-pyc: 47 | find . -name '*.pyc' -exec rm -f {} + 48 | find . -name '*.pyo' -exec rm -f {} + 49 | find . -name '*~' -exec rm -f {} + 50 | find . -name '__pycache__' -exec rm -fr {} + 51 | 52 | clean-test: 53 | rm -f .coverage 54 | rm -fr htmlcov/ 55 | 56 | lint: 57 | flake8 $(project) 58 | 59 | test: 60 | $(pytest) $(test_args) 61 | 62 | coverage: test 63 | coverage html 64 | open htmlcov/index.html 65 | 66 | docs: 67 | $(MAKE) -C docs clean 68 | $(MAKE) -C docs html 69 | 70 | release: clean 71 | python setup.py sdist upload 72 | python setup.py bdist_wheel upload 73 | 74 | dist: clean 75 | python setup.py sdist 76 | python setup.py bdist_wheel 77 | ls -l dist 78 | 79 | install: install-deps 80 | python setup.py install 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PyPI version][pypi-img]][pypi] [![Python versions][pyver-img]][pypi] [![Pypi Downloads][pydl-img]][pypi] [![Build Status][ci-img]][ci] [![Coverage Status][cov-img]][cov] 2 | 3 | 4 | # opentracing-python-instrumentation 5 | 6 | A collection of instrumentation tools to enable tracing with 7 | [OpenTracing API](http://opentracing.io). 8 | 9 | ## Module 10 | 11 | Make sure you are running recent enough versions of `pip` and `setuptools`, e.g. before installing your project requirements execute this: 12 | 13 | ``` 14 | pip install --upgrade "setuptools>=29" "pip>=9" 15 | ``` 16 | 17 | The module name is `opentracing_instrumentation`. 18 | 19 | ## What's inside 20 | 21 | ### Supported client frameworks 22 | 23 | The following libraries are instrumented for tracing in this module: 24 | * [boto3](https://github.com/boto/boto3) — AWS SDK for Python 25 | * [Celery](https://github.com/celery/celery) — Distributed Task Queue 26 | * `urllib2` 27 | * `requests` 28 | * `SQLAlchemy` 29 | * `MySQLdb` 30 | * `psycopg2` 31 | * Tornado HTTP client 32 | * `redis` 33 | 34 | #### Limitations 35 | 36 | For some operations, `Boto3` uses `ThreadPoolExecutor` under the hood. 37 | So, in order to make it thread-safe, the instrumentation is implemented using 38 | `span_in_stack_context()` which 39 | [forces you](https://github.com/uber-common/opentracing-python-instrumentation#in-process-context-propagation) 40 | to use `TornadoScopeManager`. 41 | 42 | ### Server instrumentation 43 | 44 | For inbound requests a helper function `before_request` is provided for creating middleware for frameworks like Flask and uWSGI. 45 | 46 | ### Manual instrumentation 47 | 48 | Finally, a `@traced_function` decorator is provided for manual instrumentation. 49 | 50 | ### In-process Context Propagation 51 | 52 | As part of the OpenTracing 2.0 API, in-process `Span` propagation happens through the newly defined 53 | [ScopeManager](https://opentracing-python.readthedocs.io/en/latest/api.html#scope-managers) 54 | interface. However, the existing functionality has been kept to provide backwards compatibility and 55 | ease code migration: 56 | 57 | `span_in_context()` implements context propagation using the current `opentracing.tracer.scope_manager`, 58 | expected to be a thread-local based `ScopeManager`, such as `opentracing.scope_managers.ThreadLocalScopeManager`. 59 | 60 | `span_in_stack_context()` implements context propagation for Tornado applications 61 | using the current `opentracing.tracer.scope_manager` too, expected to be an instance of 62 | `opentracing.scope_managers.tornado.TornadoScopeManager`. 63 | 64 | `get_current_span()` returns the currently active `Span`, if any. 65 | 66 | Direct access to the `request_context` module as well as usage of `RequestContext` and `RequestContextManager` 67 | have been **fully** deprecated, as they do not integrate with the new OpenTracing 2.0 API. 68 | Using them along `get_current_span()` is guaranteed to work, but it is **highly** recommended 69 | to switch to the previously mentioned functions. 70 | 71 | ## Usage 72 | 73 | This library provides two types of instrumentation, explicit instrumentation 74 | for server endpoints, and implicit instrumentation for client call sites. 75 | 76 | Server endpoints are instrumented by creating a middleware class that: 77 | 78 | 1. initializes the specific tracer implementation 79 | 2. wraps incoming request handlers into a method that reads the incoming 80 | tracing info from the request and creates a new tracing Span 81 | 82 | Client call sites are instrumented implicitly by executing a set of 83 | available `client_hooks` that monkey-patch some API points in several 84 | common libraries like `SQLAlchemy`, `urllib2`, Tornado Async HTTP Client. 85 | The initialization of those hooks is usually also done from the middleware 86 | class's `__init__` method. 87 | 88 | There is a client-server example using this library with Flask instrumentation 89 | from opentracing-contrib: https://github.com/opentracing-contrib/python-flask/tree/master/example. 90 | 91 | Here's an example of a middleware for [Clay framework](https://github.com/uber/clay): 92 | 93 | ```python 94 | 95 | from opentracing_instrumentation import span_in_context 96 | from opentracing_instrumentation.http_server import before_request 97 | from opentracing_instrumentation.http_server import WSGIRequestWrapper 98 | from opentracing_instrumentation.client_hooks import install_all_patches 99 | 100 | 101 | class TracerMiddleware(object): 102 | 103 | def __init__(self, app, wsgi_app): 104 | self.wsgi_app = wsgi_app 105 | self.service_name = app.name 106 | 107 | CONFIG.app_name = self.service_name 108 | CONFIG.caller_name_headers.append('X-Uber-Source') 109 | CONFIG.callee_endpoint_headers.append('X-Uber-Endpoint') 110 | 111 | install_all_patches() 112 | self.wsgi_app = create_wsgi_middleware(wsgi_app) 113 | self.init_tracer() 114 | 115 | def __call__(self, environ, start_response): 116 | return self.wsgi_app(environ, start_response) 117 | 118 | def init_tracer(self): 119 | # code specific to your tracer implementation 120 | pass 121 | 122 | 123 | def create_wsgi_middleware(other_wsgi, tracer=None): 124 | """ 125 | Create a wrapper middleware for another WSGI response handler. 126 | If tracer is not passed in, 'opentracing.tracer' is used. 127 | """ 128 | 129 | def wsgi_tracing_middleware(environ, start_response): 130 | # TODO find out if the route can be retrieved from somewhere 131 | 132 | request = WSGIRequestWrapper.from_wsgi_environ(environ) 133 | span = before_request(request=request, tracer=tracer) 134 | 135 | # Wrapper around the real start_response object to log 136 | # additional information to opentracing Span 137 | def start_response_wrapper(status, response_headers, exc_info=None): 138 | if exc_info is not None: 139 | span.set_tag('error', str(exc_info)) 140 | span.finish() 141 | 142 | return start_response(status, response_headers) 143 | 144 | with span_in_context(span): 145 | return other_wsgi(environ, start_response_wrapper) 146 | 147 | return wsgi_tracing_middleware 148 | ``` 149 | 150 | And here's an example for middleware in Tornado-based app: 151 | 152 | ```python 153 | 154 | import opentracing 155 | from opentracing.scope_managers.tornado import TornadoScopeManager 156 | from opentracing_instrumentation import span_in_stack_context, http_server 157 | 158 | 159 | opentracing.tracer = MyOpenTracingTracer(scope_manager=TornadoScopeManager()) 160 | 161 | 162 | class TracerMiddleware(object): 163 | 164 | def __init__(self): 165 | # perform initialization similar to above, including installing 166 | # the client_hooks 167 | 168 | @gen.coroutine 169 | def __call__(self, request, handler, next_mw): 170 | request_wrapper = http_server.TornadoRequestWrapper(request=request) 171 | span = http_server.before_request(request=request_wrapper) 172 | 173 | @gen.coroutine 174 | def next_middleware_with_span(): 175 | yield next_mw() 176 | 177 | yield run_coroutine_with_span(span=span, 178 | func=next_middleware_with_span) 179 | 180 | span.finish() 181 | 182 | 183 | def run_coroutine_with_span(span, func, *args, **kwargs): 184 | """Wrap the execution of a Tornado coroutine func in a tracing span. 185 | 186 | This makes the span available through the get_current_span() function. 187 | 188 | :param span: The tracing span to expose. 189 | :param func: Co-routine to execute in the scope of tracing span. 190 | :param args: Positional args to func, if any. 191 | :param kwargs: Keyword args to func, if any. 192 | """ 193 | with span_in_stack_context(span): 194 | return func(*args, **kwargs) 195 | ``` 196 | 197 | ### Customization 198 | 199 | For the `requests` library, in case you want to set custom tags 200 | to spans depending on content or some metadata of responses, 201 | you can set `response_handler_hook`. 202 | The hook must be a method with a signature `(response, span)`, 203 | where `response` and `span` are positional arguments, 204 | so you can use different names for them if needed. 205 | 206 | ```python 207 | from opentracing_instrumentation.client_hooks.requests import patcher 208 | 209 | 210 | def hook(response, span): 211 | if not response.ok: 212 | span.set_tag('error', 'true') 213 | 214 | 215 | patcher.set_response_handler_hook(hook) 216 | ``` 217 | 218 | If you have issues with getting the parent span, it is possible to override 219 | default function that retrieves parent span. 220 | 221 | ```python 222 | from opentracing_instrumentation.client_hooks import install_all_patches, 223 | set_current_span_func 224 | 225 | set_current_span_func(my_custom_extractor_func) 226 | install_all_patches() 227 | 228 | ``` 229 | 230 | ## Development 231 | 232 | `PostgreSQL`, `RabbitMQ`, `Redis`, and `DynamoDB` are required for certain tests. 233 | 234 | ```bash 235 | docker-compose up -d 236 | ``` 237 | 238 | To prepare a development environment please execute the following commands. 239 | ```bash 240 | virtualenv env 241 | source env/bin/activate 242 | make bootstrap 243 | make test 244 | ``` 245 | 246 | You can use [tox](https://tox.readthedocs.io) to run tests as well. 247 | ```bash 248 | tox 249 | ``` 250 | 251 | [ci-img]: https://travis-ci.org/uber-common/opentracing-python-instrumentation.svg?branch=master 252 | [ci]: https://travis-ci.org/uber-common/opentracing-python-instrumentation 253 | [pypi-img]: https://img.shields.io/pypi/v/opentracing_instrumentation.svg 254 | [pypi]: https://pypi.python.org/pypi/opentracing_instrumentation 255 | [cov-img]: https://coveralls.io/repos/github/uber-common/opentracing-python-instrumentation/badge.svg 256 | [cov]: https://coveralls.io/github/uber-common/opentracing-python-instrumentation 257 | [pyver-img]: https://img.shields.io/pypi/pyversions/opentracing-instrumentation.svg 258 | [pydl-img]: https://img.shields.io/pypi/dm/opentracing-instrumentation.svg 259 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | ## Release process 2 | 3 | Before new release, add a summary of changes since last version to CHANGELOG.rst 4 | 5 | ```shell 6 | pip install 'zest.releaser[recommended]' 7 | prerelease 8 | release 9 | git push origin master --follow-tags 10 | ``` 11 | 12 | At this point Travis should start a [build][] for the version tag and the last step 13 | `Python: 3.7 CELERY=4 COVER=1` should upload the release to [pypi][]. 14 | 15 | Once that's done, switch back to development: 16 | 17 | ```shell 18 | postrelease 19 | git push 20 | ``` 21 | 22 | [build]: https://travis-ci.org/uber-common/opentracing-python-instrumentation 23 | [pypi]: https://pypi.org/project/opentracing-instrumentation/ 24 | 25 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | rabbitmq: 5 | image: rabbitmq 6 | ports: 7 | - "5672:5672" 8 | 9 | redis: 10 | image: redis 11 | ports: 12 | - "6379:6379" 13 | 14 | postgres: 15 | image: postgres 16 | environment: 17 | POSTGRES_DB: test 18 | ports: 19 | - "5432:5432" 20 | 21 | mysql: 22 | image: mysql:5 # MySQLdb doesn't support MySQL 8 23 | environment: 24 | - MYSQL_ALLOW_EMPTY_PASSWORD=yes 25 | - MYSQL_DATABASE=test 26 | ports: 27 | - "3306:3306" 28 | 29 | localstack: 30 | image: localstack/localstack 31 | environment: 32 | - SERVICES=dynamodb,s3 33 | ports: 34 | - "4569:4569" # DynamoDB 35 | - "4572:4572" # S3 36 | -------------------------------------------------------------------------------- /opentracing_instrumentation/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | from __future__ import absolute_import 21 | from .request_context import get_current_span # noqa 22 | from .request_context import span_in_context # noqa 23 | from .request_context import span_in_stack_context # noqa 24 | from .local_span import traced_function # noqa 25 | -------------------------------------------------------------------------------- /opentracing_instrumentation/client_hooks/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015-2017 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | from __future__ import absolute_import 21 | 22 | import six 23 | if six.PY2: 24 | from collections import Sequence 25 | else: 26 | from collections.abc import Sequence 27 | 28 | import importlib 29 | import logging 30 | from ._current_span import set_current_span_func # noqa 31 | 32 | 33 | def install_all_patches(): 34 | """ 35 | A convenience method that installs all available hooks. 36 | 37 | If a specific module is not available on the path, it is ignored. 38 | """ 39 | from . import boto3 40 | from . import celery 41 | from . import mysqldb 42 | from . import psycopg2 43 | from . import strict_redis 44 | from . import sqlalchemy 45 | from . import tornado_http 46 | from . import urllib 47 | from . import urllib2 48 | from . import requests 49 | 50 | boto3.install_patches() 51 | celery.install_patches() 52 | mysqldb.install_patches() 53 | psycopg2.install_patches() 54 | strict_redis.install_patches() 55 | sqlalchemy.install_patches() 56 | tornado_http.install_patches() 57 | urllib.install_patches() 58 | urllib2.install_patches() 59 | requests.install_patches() 60 | 61 | 62 | def install_patches(patchers='all'): 63 | """ 64 | Usually called from middleware to install client hooks 65 | specified in the client_hooks section of the configuration. 66 | 67 | :param patchers: a list of patchers to run. Acceptable values include: 68 | * None - installs all client patches 69 | * 'all' - installs all client patches 70 | * empty list - does not install any patches 71 | * list of function names - executes the functions 72 | """ 73 | if patchers is None or patchers == 'all': 74 | install_all_patches() 75 | return 76 | if not _valid_args(patchers): 77 | raise ValueError('patchers argument must be None, "all", or a list') 78 | 79 | for patch_func_name in patchers: 80 | logging.info('Loading client hook %s', patch_func_name) 81 | patch_func = _load_symbol(patch_func_name) 82 | logging.info('Applying client hook %s', patch_func_name) 83 | patch_func() 84 | 85 | 86 | def install_client_interceptors(client_interceptors=()): 87 | """ 88 | Install client interceptors for the patchers. 89 | 90 | :param client_interceptors: a list of client interceptors to install. 91 | Should be a list of classes 92 | """ 93 | if not _valid_args(client_interceptors): 94 | raise ValueError('client_interceptors argument must be a list') 95 | 96 | from ..http_client import ClientInterceptors 97 | 98 | for client_interceptor in client_interceptors: 99 | logging.info('Loading client interceptor %s', client_interceptor) 100 | interceptor_class = _load_symbol(client_interceptor) 101 | logging.info('Adding client interceptor %s', client_interceptor) 102 | ClientInterceptors.append(interceptor_class()) 103 | 104 | 105 | def _valid_args(value): 106 | return isinstance(value, Sequence) and \ 107 | not isinstance(value, six.string_types) 108 | 109 | 110 | def _load_symbol(name): 111 | """Load a symbol by name. 112 | 113 | :param str name: The name to load, specified by `module.attr`. 114 | :returns: The attribute value. If the specified module does not contain 115 | the requested attribute then `None` is returned. 116 | """ 117 | module_name, key = name.rsplit('.', 1) 118 | try: 119 | module = importlib.import_module(module_name) 120 | except ImportError as err: 121 | # it's possible the symbol is a class method 122 | module_name, class_name = module_name.rsplit('.', 1) 123 | module = importlib.import_module(module_name) 124 | cls = getattr(module, class_name, None) 125 | if cls: 126 | attr = getattr(cls, key, None) 127 | else: 128 | raise err 129 | else: 130 | attr = getattr(module, key, None) 131 | if not callable(attr): 132 | raise ValueError('%s is not callable (was %r)' % (name, attr)) 133 | return attr 134 | -------------------------------------------------------------------------------- /opentracing_instrumentation/client_hooks/_current_span.py: -------------------------------------------------------------------------------- 1 | 2 | from .. import get_current_span 3 | 4 | current_span_func = get_current_span 5 | 6 | 7 | def set_current_span_func(span_extractor_func): 8 | """ 9 | A convenience method to override the default method 10 | to extract parent span. 11 | It has to be called before install_patches 12 | :parent span_extractor_func : a function that returns parent span 13 | """ 14 | global current_span_func 15 | current_span_func = span_extractor_func 16 | -------------------------------------------------------------------------------- /opentracing_instrumentation/client_hooks/_dbapi2.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015-2017 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | from __future__ import absolute_import 21 | from builtins import object 22 | import contextlib2 23 | import wrapt 24 | 25 | from opentracing.ext import tags as ext_tags 26 | 27 | from ._current_span import current_span_func 28 | from .. import utils 29 | from ..local_span import func_span 30 | 31 | # Utils for instrumenting DB API v2 compatible drivers. 32 | # PEP-249 - https://www.python.org/dev/peps/pep-0249/ 33 | 34 | _BEGIN = 'begin-trans' 35 | _COMMIT = 'commit' 36 | _ROLLBACK = 'rollback' 37 | _TRANS_TAGS = [_BEGIN, _COMMIT, _ROLLBACK] 38 | 39 | NO_ARG = object() 40 | 41 | 42 | def db_span(sql_statement, 43 | module_name, 44 | sql_parameters=None, 45 | connect_params=None, 46 | cursor_params=None): 47 | span = current_span_func() 48 | 49 | @contextlib2.contextmanager 50 | def empty_ctx_mgr(): 51 | yield None 52 | 53 | if span is None: 54 | return empty_ctx_mgr() 55 | 56 | if bytes is not str and isinstance(sql_statement, bytes): 57 | sql_statement = sql_statement.decode('utf-8', errors='ignore') 58 | 59 | statement = sql_statement.strip() 60 | add_sql_tag = True 61 | if sql_statement in _TRANS_TAGS: 62 | operation = sql_statement 63 | add_sql_tag = False 64 | else: 65 | space_idx = statement.find(' ') 66 | if space_idx == -1: 67 | operation = '' # unrecognized format of the query 68 | else: 69 | operation = statement[0:space_idx] 70 | 71 | tags = {ext_tags.SPAN_KIND: ext_tags.SPAN_KIND_RPC_CLIENT} 72 | if add_sql_tag: 73 | tags['sql'] = statement 74 | if sql_parameters: 75 | tags['sql.params'] = sql_parameters 76 | if connect_params: 77 | tags['sql.conn'] = connect_params 78 | if cursor_params: 79 | tags['sql.cursor'] = cursor_params 80 | 81 | return utils.start_child_span( 82 | operation_name='%s:%s' % (module_name, operation), 83 | parent=span, tags=tags 84 | ) 85 | 86 | 87 | class CursorWrapper(wrapt.ObjectProxy): 88 | __slots__ = ('_module_name', '_connect_params', '_cursor_params') 89 | 90 | def __init__(self, cursor, module_name, 91 | connect_params=None, cursor_params=None): 92 | super(CursorWrapper, self).__init__(wrapped=cursor) 93 | self._module_name = module_name 94 | self._connect_params = connect_params 95 | self._cursor_params = cursor_params 96 | # We could also start a span now and then override close() to capture 97 | # the life time of the cursor 98 | 99 | def execute(self, sql, params=NO_ARG): 100 | with db_span(sql_statement=sql, 101 | sql_parameters=params if params is not NO_ARG else None, 102 | module_name=self._module_name, 103 | connect_params=self._connect_params, 104 | cursor_params=self._cursor_params): 105 | if params is NO_ARG: 106 | return self.__wrapped__.execute(sql) 107 | else: 108 | return self.__wrapped__.execute(sql, params) 109 | 110 | def executemany(self, sql, seq_of_parameters): 111 | with db_span(sql_statement=sql, sql_parameters=seq_of_parameters, 112 | module_name=self._module_name, 113 | connect_params=self._connect_params, 114 | cursor_params=self._cursor_params): 115 | return self.__wrapped__.executemany(sql, seq_of_parameters) 116 | 117 | def callproc(self, proc_name, params=NO_ARG): 118 | with db_span(sql_statement='sproc:%s' % proc_name, 119 | sql_parameters=params if params is not NO_ARG else None, 120 | module_name=self._module_name, 121 | connect_params=self._connect_params, 122 | cursor_params=self._cursor_params): 123 | if params is NO_ARG: 124 | return self.__wrapped__.callproc(proc_name) 125 | else: 126 | return self.__wrapped__.callproc(proc_name, params) 127 | 128 | 129 | class ConnectionFactory(object): 130 | """ 131 | Wraps connect_func of the DB API v2 module by creating a wrapper object 132 | for the actual connection. 133 | """ 134 | 135 | def __init__(self, connect_func, module_name, conn_wrapper_ctor=None, 136 | cursor_wrapper=CursorWrapper): 137 | self._connect_func = connect_func 138 | self._module_name = module_name 139 | if hasattr(connect_func, '__name__'): 140 | self._connect_func_name = '%s:%s' % (module_name, 141 | connect_func.__name__) 142 | else: 143 | self._connect_func_name = '%s:%s' % (module_name, connect_func) 144 | self._wrapper_ctor = conn_wrapper_ctor \ 145 | if conn_wrapper_ctor is not None else ConnectionWrapper 146 | self._cursor_wrapper = cursor_wrapper 147 | 148 | def __call__(self, *args, **kwargs): 149 | safe_kwargs = kwargs 150 | if 'passwd' in kwargs or 'password' in kwargs or 'conv' in kwargs: 151 | safe_kwargs = dict(kwargs) 152 | if 'passwd' in safe_kwargs: 153 | del safe_kwargs['passwd'] 154 | if 'password' in safe_kwargs: 155 | del safe_kwargs['password'] 156 | if 'conv' in safe_kwargs: # don't log conversion functions 157 | del safe_kwargs['conv'] 158 | connect_params = (args, safe_kwargs) if args or safe_kwargs else None 159 | tags = {ext_tags.SPAN_KIND: ext_tags.SPAN_KIND_RPC_CLIENT} 160 | with func_span(self._connect_func_name, tags=tags): 161 | return self._wrapper_ctor( 162 | connection=self._connect_func(*args, **kwargs), 163 | module_name=self._module_name, 164 | connect_params=connect_params, 165 | cursor_wrapper=self._cursor_wrapper) 166 | 167 | 168 | class ConnectionWrapper(wrapt.ObjectProxy): 169 | __slots__ = ('_module_name', '_connect_params', '_cursor_wrapper') 170 | 171 | def __init__(self, connection, module_name, connect_params, 172 | cursor_wrapper): 173 | super(ConnectionWrapper, self).__init__(wrapped=connection) 174 | self._module_name = module_name 175 | self._connect_params = connect_params 176 | self._cursor_wrapper = cursor_wrapper 177 | 178 | def cursor(self, *args, **kwargs): 179 | return self._cursor_wrapper( 180 | cursor=self.__wrapped__.cursor(*args, **kwargs), 181 | module_name=self._module_name, 182 | connect_params=self._connect_params, 183 | cursor_params=(args, kwargs) if args or kwargs else None) 184 | 185 | def begin(self): 186 | with db_span(sql_statement=_BEGIN, module_name=self._module_name): 187 | return self.__wrapped__.begin() 188 | 189 | def commit(self): 190 | with db_span(sql_statement=_COMMIT, module_name=self._module_name): 191 | return self.__wrapped__.commit() 192 | 193 | def rollback(self): 194 | with db_span(sql_statement=_ROLLBACK, module_name=self._module_name): 195 | return self.__wrapped__.rollback() 196 | 197 | 198 | class ContextManagerConnectionWrapper(ConnectionWrapper): 199 | """ 200 | Extends ConnectionWrapper by implementing `__enter__` and `__exit__` 201 | methods of the context manager API, for connections that can be used 202 | in as context managers to control the transactions, e.g. 203 | 204 | .. code-block:: python 205 | 206 | with MySQLdb.connect(...) as cursor: 207 | cursor.execute(...) 208 | """ 209 | 210 | def __init__(self, connection, module_name, connect_params, 211 | cursor_wrapper): 212 | super(ContextManagerConnectionWrapper, self).__init__( 213 | connection=connection, 214 | module_name=module_name, 215 | connect_params=connect_params, 216 | cursor_wrapper=cursor_wrapper 217 | ) 218 | 219 | def __getattr__(self, name): 220 | # Tip suggested here: 221 | # https://gist.github.com/mjallday/3d4c92e7e6805af1e024. 222 | if name == '_sqla_unwrap': 223 | return self.__wrapped__ 224 | return super(ContextManagerConnectionWrapper, self).__getattr__(name) 225 | 226 | def __enter__(self): 227 | with func_span('%s:begin_transaction' % self._module_name): 228 | cursor = self.__wrapped__.__enter__() 229 | 230 | return CursorWrapper(cursor=cursor, 231 | module_name=self._module_name, 232 | connect_params=self._connect_params) 233 | 234 | def __exit__(self, exc, value, tb): 235 | outcome = _COMMIT if exc is None else _ROLLBACK 236 | with db_span(sql_statement=outcome, module_name=self._module_name): 237 | return self.__wrapped__.__exit__(exc, value, tb) 238 | -------------------------------------------------------------------------------- /opentracing_instrumentation/client_hooks/_patcher.py: -------------------------------------------------------------------------------- 1 | class Patcher(object): 2 | 3 | def __init__(self): 4 | self.patches_installed = False 5 | 6 | @property 7 | def applicable(self): 8 | raise NotImplementedError 9 | 10 | def install_patches(self): 11 | if self.patches_installed: 12 | return 13 | 14 | if self.applicable: 15 | self._install_patches() 16 | self.patches_installed = True 17 | 18 | def reset_patches(self): 19 | if self.applicable: 20 | self._reset_patches() 21 | self.patches_installed = False 22 | 23 | def _install_patches(self): 24 | raise NotImplementedError 25 | 26 | def _reset_patches(self): 27 | raise NotImplementedError 28 | 29 | @classmethod 30 | def configure_hook_module(cls, context): 31 | def set_patcher(custom_patcher): 32 | context['patcher'] = custom_patcher 33 | 34 | def install_patches(): 35 | context['patcher'].install_patches() 36 | 37 | def reset_patches(): 38 | context['patcher'].reset_patches() 39 | 40 | context['patcher'] = cls() 41 | context['set_patcher'] = set_patcher 42 | context['install_patches'] = install_patches 43 | context['reset_patches'] = reset_patches 44 | -------------------------------------------------------------------------------- /opentracing_instrumentation/client_hooks/_singleton.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015,2018 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from __future__ import absolute_import 22 | 23 | import functools 24 | 25 | 26 | NOT_CALLED = 1 27 | CALLED = 2 28 | 29 | 30 | def singleton(func): 31 | """ 32 | This decorator allows you to make sure that a function is called once and 33 | only once. Note that recursive functions will still work. 34 | 35 | WARNING: Not thread-safe!!! 36 | """ 37 | 38 | @functools.wraps(func) 39 | def wrapper(*args, **kwargs): 40 | if wrapper.__call_state__ == CALLED: 41 | return 42 | ret = func(*args, **kwargs) 43 | wrapper.__call_state__ = CALLED 44 | return ret 45 | 46 | def reset(): 47 | wrapper.__call_state__ = NOT_CALLED 48 | 49 | wrapper.reset = reset 50 | reset() 51 | 52 | # save original func to be able to patch and restore multiple times from 53 | # unit tests 54 | wrapper.__original_func = func 55 | return wrapper 56 | -------------------------------------------------------------------------------- /opentracing_instrumentation/client_hooks/boto3.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | import logging 3 | 4 | from opentracing.ext import tags 5 | from tornado.stack_context import wrap as keep_stack_context 6 | 7 | from opentracing_instrumentation import utils 8 | from ..request_context import get_current_span, span_in_stack_context 9 | from ._patcher import Patcher 10 | 11 | 12 | try: 13 | from boto3.resources.action import ServiceAction 14 | from boto3.s3 import inject as s3_functions 15 | from botocore import xform_name 16 | from botocore.client import BaseClient 17 | from botocore.exceptions import ClientError 18 | from s3transfer.futures import BoundedExecutor 19 | except ImportError: 20 | pass 21 | else: 22 | _service_action_call = ServiceAction.__call__ 23 | _client_make_api_call = BaseClient._make_api_call 24 | _Executor = BoundedExecutor.EXECUTOR_CLS 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | 29 | class Boto3Patcher(Patcher): 30 | applicable = '_service_action_call' in globals() 31 | 32 | S3_FUNCTIONS_TO_INSTRUMENT = ( 33 | 'copy', 34 | 'download_file', 35 | 'download_fileobj', 36 | 'upload_file', 37 | 'upload_fileobj', 38 | ) 39 | 40 | def __init__(self): 41 | super(Boto3Patcher, self).__init__() 42 | self.s3_original_funcs = {} 43 | 44 | def _install_patches(self): 45 | ServiceAction.__call__ = self._get_service_action_call_wrapper() 46 | BaseClient._make_api_call = self._get_client_make_api_call_wrapper() 47 | BoundedExecutor.EXECUTOR_CLS = self._get_instrumented_executor_cls() 48 | for func_name in self.S3_FUNCTIONS_TO_INSTRUMENT: 49 | func = getattr(s3_functions, func_name, None) 50 | if func: 51 | self.s3_original_funcs[func_name] = func 52 | func_wrapper = self._get_s3_call_wrapper(func) 53 | setattr(s3_functions, func_name, func_wrapper) 54 | else: 55 | logging.warning('S3 function %s not found', func_name) 56 | 57 | def _reset_patches(self): 58 | ServiceAction.__call__ = _service_action_call 59 | BaseClient._make_api_call = _client_make_api_call 60 | BoundedExecutor.EXECUTOR_CLS = _Executor 61 | for func_name, original_func in self.s3_original_funcs.items(): 62 | setattr(s3_functions, func_name, original_func) 63 | 64 | @staticmethod 65 | def set_request_id_tag(span, response): 66 | metadata = response.get('ResponseMetadata') 67 | 68 | # there is no ResponseMetadata for 69 | # boto3:dynamodb:describe_table 70 | if metadata: 71 | request_id = metadata.get('RequestId') 72 | 73 | # when using boto3.client('s3') 74 | # instead of boto3.resource('s3'), 75 | # there is no RequestId for 76 | # boto3:s3:CreateBucket 77 | if request_id: 78 | span.set_tag('aws.request_id', request_id) 79 | 80 | def _get_service_action_call_wrapper(self): 81 | def service_action_call_wrapper(service, parent, *args, **kwargs): 82 | """Wraps ServiceAction.__call__""" 83 | 84 | service_name = parent.meta.service_name 85 | operation_name = xform_name( 86 | service._action_model.request.operation 87 | ) 88 | 89 | return self.perform_call( 90 | _service_action_call, 'resource', 91 | service_name, operation_name, 92 | service, parent, *args, **kwargs 93 | ) 94 | 95 | return service_action_call_wrapper 96 | 97 | def _get_client_make_api_call_wrapper(self): 98 | def make_api_call_wrapper(client, operation_name, api_params): 99 | """Wraps BaseClient._make_api_call""" 100 | 101 | service_name = client._service_model.service_name 102 | formatted_operation_name = xform_name(operation_name) 103 | 104 | return self.perform_call( 105 | _client_make_api_call, 'client', 106 | service_name, formatted_operation_name, 107 | client, operation_name, api_params 108 | ) 109 | 110 | return make_api_call_wrapper 111 | 112 | def _get_s3_call_wrapper(self, original_func): 113 | operation_name = original_func.__name__ 114 | 115 | def s3_call_wrapper(*args, **kwargs): 116 | """Wraps __call__ of S3 client methods""" 117 | 118 | return self.perform_call( 119 | original_func, 'client', 's3', operation_name, *args, **kwargs 120 | ) 121 | 122 | return s3_call_wrapper 123 | 124 | def perform_call(self, original_func, kind, service_name, operation_name, 125 | *args, **kwargs): 126 | span = utils.start_child_span( 127 | operation_name='boto3:{}:{}:{}'.format( 128 | kind, service_name, operation_name 129 | ), 130 | parent=get_current_span() 131 | ) 132 | 133 | span.set_tag(tags.SPAN_KIND, tags.SPAN_KIND_RPC_CLIENT) 134 | span.set_tag(tags.COMPONENT, 'boto3') 135 | span.set_tag('boto3.service_name', service_name) 136 | 137 | with span, span_in_stack_context(span): 138 | try: 139 | response = original_func(*args, **kwargs) 140 | except ClientError as error: 141 | self.set_request_id_tag(span, error.response) 142 | raise 143 | else: 144 | if isinstance(response, dict): 145 | self.set_request_id_tag(span, response) 146 | 147 | return response 148 | 149 | def _get_instrumented_executor_cls(self): 150 | class InstrumentedExecutor(_Executor): 151 | def submit(self, task, *args, **kwargs): 152 | return super(InstrumentedExecutor, self).submit( 153 | keep_stack_context(task), *args, **kwargs 154 | ) 155 | 156 | return InstrumentedExecutor 157 | 158 | 159 | Boto3Patcher.configure_hook_module(globals()) 160 | -------------------------------------------------------------------------------- /opentracing_instrumentation/client_hooks/celery.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import opentracing 4 | from opentracing.ext import tags 5 | 6 | from ..request_context import get_current_span, span_in_context 7 | from ._patcher import Patcher 8 | 9 | 10 | try: 11 | from celery.app.task import Task 12 | from celery.signals import ( 13 | before_task_publish, task_prerun, task_success, task_failure 14 | ) 15 | except ImportError: 16 | pass 17 | else: 18 | _task_apply_async = Task.apply_async 19 | 20 | 21 | def task_apply_async_wrapper(task, args=None, kwargs=None, **other_kwargs): 22 | operation_name = 'Celery:apply_async:{}'.format(task.name) 23 | span = opentracing.tracer.start_span(operation_name=operation_name, 24 | child_of=get_current_span()) 25 | set_common_tags(span, task, tags.SPAN_KIND_RPC_CLIENT) 26 | 27 | with span_in_context(span), span: 28 | result = _task_apply_async(task, args, kwargs, **other_kwargs) 29 | span.set_tag('celery.task_id', result.task_id) 30 | return result 31 | 32 | 33 | def set_common_tags(span, task, span_kind): 34 | span.set_tag(tags.SPAN_KIND, span_kind) 35 | span.set_tag(tags.COMPONENT, 'Celery') 36 | span.set_tag('celery.task_name', task.name) 37 | 38 | 39 | def before_task_publish_handler(headers, **kwargs): 40 | headers['parent_span_context'] = span_context = {} 41 | opentracing.tracer.inject(span_context=get_current_span().context, 42 | format=opentracing.Format.TEXT_MAP, 43 | carrier=span_context) 44 | 45 | 46 | def task_prerun_handler(task, task_id, **kwargs): 47 | request = task.request 48 | 49 | operation_name = 'Celery:run:{}'.format(task.name) 50 | child_of = None 51 | if request.delivery_info.get('is_eager'): 52 | child_of = get_current_span() 53 | else: 54 | if getattr(request, 'headers', None) is not None: 55 | # Celery 3.x 56 | parent_span_context = request.headers.get('parent_span_context') 57 | else: 58 | # Celery 4.x 59 | parent_span_context = getattr(request, 'parent_span_context', None) 60 | 61 | if parent_span_context: 62 | child_of = opentracing.tracer.extract( 63 | opentracing.Format.TEXT_MAP, parent_span_context 64 | ) 65 | 66 | task.request.span = span = opentracing.tracer.start_span( 67 | operation_name=operation_name, 68 | child_of=child_of, 69 | ) 70 | set_common_tags(span, task, tags.SPAN_KIND_RPC_SERVER) 71 | span.set_tag('celery.task_id', task_id) 72 | 73 | request.tracing_context = span_in_context(span) 74 | request.tracing_context.__enter__() 75 | 76 | 77 | def finish_current_span(task, exc_type=None, exc_val=None, exc_tb=None): 78 | task.request.span.finish() 79 | task.request.tracing_context.__exit__(exc_type, exc_val, exc_tb) 80 | 81 | 82 | def task_success_handler(sender, **kwargs): 83 | finish_current_span(task=sender) 84 | 85 | 86 | def task_failure_handler(sender, exception, traceback, **kwargs): 87 | finish_current_span( 88 | task=sender, 89 | exc_type=type(exception), 90 | exc_val=exception, 91 | exc_tb=traceback, 92 | ) 93 | 94 | 95 | class CeleryPatcher(Patcher): 96 | applicable = '_task_apply_async' in globals() 97 | 98 | def _install_patches(self): 99 | Task.apply_async = task_apply_async_wrapper 100 | before_task_publish.connect(before_task_publish_handler) 101 | task_prerun.connect(task_prerun_handler) 102 | task_success.connect(task_success_handler) 103 | task_failure.connect(task_failure_handler) 104 | 105 | def _reset_patches(self): 106 | Task.apply_async = _task_apply_async 107 | before_task_publish.disconnect(before_task_publish_handler) 108 | task_prerun.disconnect(task_prerun_handler) 109 | task_success.disconnect(task_success_handler) 110 | task_failure.disconnect(task_failure_handler) 111 | 112 | 113 | CeleryPatcher.configure_hook_module(globals()) 114 | -------------------------------------------------------------------------------- /opentracing_instrumentation/client_hooks/mysqldb.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015,2019 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from __future__ import absolute_import 22 | from ._dbapi2 import ContextManagerConnectionWrapper as ConnectionWrapper 23 | from ._dbapi2 import ConnectionFactory 24 | from ._patcher import Patcher 25 | 26 | # Try to save the original entry points 27 | try: 28 | import MySQLdb 29 | except ImportError: 30 | pass 31 | else: 32 | _MySQLdb_connect = MySQLdb.connect 33 | 34 | 35 | class MySQLdbPatcher(Patcher): 36 | applicable = '_MySQLdb_connect' in globals() 37 | 38 | def _install_patches(self): 39 | factory = ConnectionFactory(connect_func=MySQLdb.connect, 40 | module_name='MySQLdb', 41 | conn_wrapper_ctor=ConnectionWrapper) 42 | MySQLdb.connect = factory 43 | if hasattr(MySQLdb, 'Connect'): 44 | MySQLdb.Connect = factory 45 | 46 | def _reset_patches(self): 47 | MySQLdb.connect = _MySQLdb_connect 48 | if hasattr(MySQLdb, 'Connect'): 49 | MySQLdb.Connect = _MySQLdb_connect 50 | 51 | 52 | MySQLdbPatcher.configure_hook_module(globals()) 53 | -------------------------------------------------------------------------------- /opentracing_instrumentation/client_hooks/psycopg2.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2017,2019 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from __future__ import absolute_import 22 | from ._dbapi2 import ContextManagerConnectionWrapper as ConnectionWrapper 23 | from ._dbapi2 import ConnectionFactory, CursorWrapper, NO_ARG 24 | from ._singleton import singleton 25 | 26 | # Try to save the original entry points 27 | try: 28 | import psycopg2.extensions 29 | from psycopg2.sql import Composable 30 | except ImportError: 31 | pass 32 | else: 33 | _psycopg2_connect = psycopg2.connect 34 | _psycopg2_extensions_register_type = psycopg2.extensions.register_type 35 | _psycopg2_extensions_quote_ident = psycopg2.extensions.quote_ident 36 | 37 | 38 | class Psycopg2CursorWrapper(CursorWrapper): 39 | """ 40 | Psycopg2 accept not only string as sql statement, but instances of 41 | ``psycopg2.sql.Composable`` that should be represented as string before the 42 | executing. 43 | """ 44 | def execute(self, sql, params=NO_ARG): 45 | if isinstance(sql, Composable): 46 | sql = sql.as_string(self) 47 | return super(Psycopg2CursorWrapper, self).execute(sql, params) 48 | 49 | def executemany(self, sql, seq_of_parameters): 50 | if isinstance(sql, Composable): 51 | sql = sql.as_string(self) 52 | return super(Psycopg2CursorWrapper, self).executemany( 53 | sql, seq_of_parameters 54 | ) 55 | 56 | 57 | @singleton 58 | def install_patches(): 59 | if 'psycopg2' not in globals(): 60 | return 61 | 62 | # the following original methods checks a type of the conn_or_curs 63 | # and it doesn't accept wrappers 64 | def register_type(obj, conn_or_curs=None): 65 | if isinstance(conn_or_curs, (ConnectionWrapper, CursorWrapper)): 66 | conn_or_curs = conn_or_curs.__wrapped__ 67 | _psycopg2_extensions_register_type(obj, conn_or_curs) 68 | 69 | def quote_ident(string, scope): 70 | if isinstance(scope, (ConnectionWrapper, CursorWrapper)): 71 | scope = scope.__wrapped__ 72 | return _psycopg2_extensions_quote_ident(string, scope) 73 | 74 | psycopg2.extensions.register_type = register_type 75 | psycopg2.extensions.quote_ident = quote_ident 76 | 77 | factory = ConnectionFactory(connect_func=psycopg2.connect, 78 | module_name='psycopg2', 79 | conn_wrapper_ctor=ConnectionWrapper, 80 | cursor_wrapper=Psycopg2CursorWrapper) 81 | setattr(psycopg2, 'connect', factory) 82 | if hasattr(psycopg2, 'Connect'): 83 | setattr(psycopg2, 'Connect', factory) 84 | -------------------------------------------------------------------------------- /opentracing_instrumentation/client_hooks/requests.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from __future__ import absolute_import 22 | 23 | from future import standard_library 24 | 25 | standard_library.install_aliases() 26 | import logging 27 | import urllib.parse 28 | 29 | from opentracing.ext import tags 30 | from ..http_client import AbstractRequestWrapper 31 | from ..http_client import before_http_request 32 | from ..http_client import split_host_and_port 33 | from ._patcher import Patcher 34 | from ._current_span import current_span_func 35 | 36 | log = logging.getLogger(__name__) 37 | 38 | # Try to save the original entry points 39 | try: 40 | import requests.adapters 41 | except ImportError: 42 | pass 43 | else: 44 | _HTTPAdapter_send = requests.adapters.HTTPAdapter.send 45 | 46 | 47 | class RequestsPatcher(Patcher): 48 | applicable = '_HTTPAdapter_send' in globals() 49 | response_handler_hook = None 50 | 51 | def set_response_handler_hook(self, response_handler_hook): 52 | """ 53 | Set a hook that will be called when a response is received. 54 | 55 | The hook can be set in purpose to set custom tags to spans 56 | depending on content or some metadata of responses. 57 | 58 | :param response_handler_hook: hook method 59 | It must have a signature `(response, span)`, 60 | where `response` and `span` are positional arguments, 61 | so you can use different names for them if needed. 62 | """ 63 | 64 | self.response_handler_hook = response_handler_hook 65 | 66 | def _install_patches(self): 67 | requests.adapters.HTTPAdapter.send = self._get_send_wrapper() 68 | 69 | def _reset_patches(self): 70 | requests.adapters.HTTPAdapter.send = _HTTPAdapter_send 71 | 72 | def _get_send_wrapper(self): 73 | def send_wrapper(http_adapter, request, **kwargs): 74 | """Wraps HTTPAdapter.send""" 75 | request_wrapper = self.RequestWrapper(request=request) 76 | span = before_http_request(request=request_wrapper, 77 | current_span_extractor=current_span_func 78 | ) 79 | with span: 80 | response = _HTTPAdapter_send(http_adapter, request, **kwargs) 81 | if getattr(response, 'status_code', None) is not None: 82 | span.set_tag(tags.HTTP_STATUS_CODE, response.status_code) 83 | if self.response_handler_hook is not None: 84 | self.response_handler_hook(response, span) 85 | return response 86 | 87 | return send_wrapper 88 | 89 | class RequestWrapper(AbstractRequestWrapper): 90 | def __init__(self, request): 91 | self.request = request 92 | self.scheme, rest = urllib.parse.splittype(request.url) 93 | if self.scheme and rest: 94 | self.host_str, _ = urllib.parse.splithost(rest) 95 | else: 96 | self.host_str = '' 97 | 98 | def add_header(self, key, value): 99 | self.request.headers[key] = value 100 | 101 | @property 102 | def method(self): 103 | return self.request.method 104 | 105 | @property 106 | def full_url(self): 107 | return self.request.url 108 | 109 | @property 110 | def _headers(self): 111 | return self.request.headers 112 | 113 | @property 114 | def host_port(self): 115 | return split_host_and_port(host_string=self.host_str, 116 | scheme=self.scheme) 117 | 118 | 119 | RequestsPatcher.configure_hook_module(globals()) 120 | -------------------------------------------------------------------------------- /opentracing_instrumentation/client_hooks/sqlalchemy.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015,2019 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from __future__ import absolute_import 22 | 23 | import logging 24 | 25 | from opentracing import tags 26 | 27 | 28 | from .. import utils 29 | from ._current_span import current_span_func 30 | from ._patcher import Patcher 31 | 32 | log = logging.getLogger(__name__) 33 | 34 | try: 35 | from sqlalchemy.engine import Engine 36 | from sqlalchemy import event 37 | except ImportError: 38 | pass 39 | 40 | 41 | class SQLAlchemyPatcher(Patcher): 42 | applicable = 'event' in globals() 43 | 44 | def _install_patches(self): 45 | log.info('Instrumenting SQLAlchemy for tracing') 46 | event.listen(Engine, 'before_cursor_execute', 47 | self.before_cursor_execute) 48 | event.listen(Engine, 'after_cursor_execute', 49 | self.after_cursor_execute) 50 | 51 | def _reset_patches(self): 52 | event.remove(Engine, 'before_cursor_execute', 53 | self.before_cursor_execute) 54 | event.remove(Engine, 'after_cursor_execute', 55 | self.after_cursor_execute) 56 | 57 | @staticmethod 58 | def before_cursor_execute(conn, cursor, statement, parameters, context, 59 | executemany): 60 | operation = 'SQL' 61 | statement = statement.strip() 62 | if statement: 63 | operation = '%s %s' % (operation, 64 | statement.split(' ', 1)[0].upper()) 65 | span = utils.start_child_span( 66 | operation_name=operation, parent=current_span_func()) 67 | span.set_tag(tags.SPAN_KIND, tags.SPAN_KIND_RPC_CLIENT) 68 | span.set_tag(tags.COMPONENT, 'sqlalchemy') 69 | if conn.engine: 70 | span.set_tag(tags.DATABASE_TYPE, conn.engine.name) 71 | span.set_tag(tags.DATABASE_INSTANCE, repr(conn.engine.url)) 72 | if statement: 73 | span.set_tag(tags.DATABASE_STATEMENT, statement) 74 | context.opentracing_span = span 75 | 76 | @staticmethod 77 | def after_cursor_execute(conn, cursor, statement, parameters, context, 78 | executemany): 79 | if hasattr(context, 'opentracing_span') and context.opentracing_span: 80 | context.opentracing_span.finish() 81 | context.opentracing_span = None 82 | 83 | 84 | SQLAlchemyPatcher.configure_hook_module(globals()) 85 | -------------------------------------------------------------------------------- /opentracing_instrumentation/client_hooks/strict_redis.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from __future__ import absolute_import 22 | 23 | from opentracing.ext import tags as ext_tags 24 | import re 25 | 26 | from ._current_span import current_span_func 27 | from ._singleton import singleton 28 | from .. import utils 29 | 30 | try: 31 | import redis 32 | except ImportError: 33 | redis = None 34 | 35 | 36 | # regex to match an ipv4 address 37 | IPV4_RE = re.compile(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$') 38 | 39 | METHOD_NAMES = ['execute_command', 'get', 'set', 'setex', 'setnx'] 40 | ORIG_METHODS = {} 41 | 42 | 43 | @singleton 44 | def install_patches(): 45 | if redis is None: 46 | return 47 | 48 | def peer_tags(self): 49 | """Fetch the peer host/port tags for opentracing. 50 | 51 | We do this lazily and cache the result since the host/port won't 52 | change. 53 | """ 54 | if not hasattr(self, '_peer_tags'): 55 | self._peer_tags = [] 56 | conn_info = self.connection_pool.connection_kwargs 57 | host = conn_info.get('host') 58 | if host: 59 | if IPV4_RE.match(host): 60 | self._peer_tags.append((ext_tags.PEER_HOST_IPV4, host)) 61 | else: 62 | self._peer_tags.append((ext_tags.PEER_HOSTNAME, host)) 63 | port = conn_info.get('port') 64 | if port: 65 | self._peer_tags.append((ext_tags.PEER_PORT, port)) 66 | return self._peer_tags 67 | 68 | redis.StrictRedis.peer_tags = peer_tags 69 | 70 | for name in METHOD_NAMES: 71 | ORIG_METHODS[name] = getattr(redis.StrictRedis, name) 72 | 73 | def get(self, name, **kwargs): 74 | self._extra_tags = [('redis.key', name)] 75 | return ORIG_METHODS['get'](self, name, **kwargs) 76 | 77 | def set(self, name, value, ex=None, px=None, nx=False, xx=False, **kwargs): 78 | self._extra_tags = [('redis.key', name)] 79 | return ORIG_METHODS['set'](self, name, value, 80 | ex=ex, px=px, nx=nx, xx=xx, 81 | **kwargs) 82 | 83 | def setex(self, name, time, value, **kwargs): 84 | self._extra_tags = [('redis.key', name), 85 | ('redis.ttl', time)] 86 | return ORIG_METHODS['setex'](self, name, time, value, **kwargs) 87 | 88 | def setnx(self, name, value, **kwargs): 89 | self._extra_tags = [('redis.key', name)] 90 | return ORIG_METHODS['setnx'](self, name, value, **kwargs) 91 | 92 | def execute_command(self, cmd, *args, **kwargs): 93 | operation_name = 'redis:%s' % (cmd,) 94 | span = utils.start_child_span( 95 | operation_name=operation_name, parent=current_span_func()) 96 | span.set_tag(ext_tags.SPAN_KIND, ext_tags.SPAN_KIND_RPC_CLIENT) 97 | span.set_tag(ext_tags.PEER_SERVICE, 'redis') 98 | 99 | # set the peer information (remote host/port) 100 | for tag_key, tag_val in self.peer_tags(): 101 | span.set_tag(tag_key, tag_val) 102 | 103 | # for certain commands we'll add extra attributes such as the redis key 104 | for tag_key, tag_val in getattr(self, '_extra_tags', []): 105 | span.set_tag(tag_key, tag_val) 106 | self._extra_tags = [] 107 | 108 | with span: 109 | return ORIG_METHODS['execute_command'](self, cmd, *args, **kwargs) 110 | 111 | for name in METHOD_NAMES: 112 | setattr(redis.StrictRedis, name, locals()[name]) 113 | 114 | 115 | def reset_patches(): 116 | for name in METHOD_NAMES: 117 | setattr(redis.StrictRedis, name, ORIG_METHODS[name]) 118 | ORIG_METHODS.clear() 119 | install_patches.reset() 120 | -------------------------------------------------------------------------------- /opentracing_instrumentation/client_hooks/tornado_http.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from __future__ import absolute_import 22 | 23 | from future import standard_library 24 | standard_library.install_aliases() 25 | from builtins import object 26 | import functools 27 | import logging 28 | import urllib.parse 29 | 30 | from tornado.httputil import HTTPHeaders 31 | 32 | from opentracing.ext import tags 33 | from opentracing_instrumentation.http_client import AbstractRequestWrapper 34 | from opentracing_instrumentation.http_client import before_http_request 35 | from opentracing_instrumentation.http_client import split_host_and_port 36 | from opentracing_instrumentation import get_current_span 37 | from ._singleton import singleton 38 | 39 | logger = logging.getLogger(__name__) 40 | 41 | 42 | # Try to save the original types for Tornado 43 | try: 44 | import tornado.simple_httpclient 45 | except ImportError: 46 | pass 47 | else: 48 | _SimpleAsyncHTTPClient_fetch_impl = \ 49 | tornado.simple_httpclient.SimpleAsyncHTTPClient.fetch_impl 50 | 51 | 52 | try: 53 | import tornado.curl_httpclient 54 | except ImportError: 55 | pass 56 | else: 57 | _CurlAsyncHTTPClient_fetch_impl = \ 58 | tornado.curl_httpclient.CurlAsyncHTTPClient.fetch_impl 59 | 60 | 61 | class TracedPatcherBuilder(object): 62 | 63 | def patch(self): 64 | for obj, attr, repl in self._tornado(): 65 | self._build_patcher(obj, attr, repl) 66 | 67 | @staticmethod 68 | def _build_patcher(obj, patched_attribute, replacement): 69 | if not hasattr(obj, patched_attribute): 70 | return 71 | return setattr(obj, patched_attribute, replacement) 72 | 73 | @staticmethod 74 | def _tornado(): 75 | try: 76 | import tornado.simple_httpclient as simple 77 | except ImportError: 78 | pass 79 | else: 80 | new_fetch_impl = traced_fetch_impl( 81 | _SimpleAsyncHTTPClient_fetch_impl 82 | ) 83 | yield simple.SimpleAsyncHTTPClient, 'fetch_impl', new_fetch_impl 84 | 85 | try: 86 | import tornado.curl_httpclient as curl 87 | except ImportError: 88 | pass 89 | else: 90 | new_fetch_impl = traced_fetch_impl( 91 | _CurlAsyncHTTPClient_fetch_impl 92 | ) 93 | yield curl.CurlAsyncHTTPClient, 'fetch_impl', new_fetch_impl 94 | 95 | 96 | @singleton 97 | def install_patches(): 98 | builder = TracedPatcherBuilder() 99 | builder.patch() 100 | 101 | 102 | def reset_patchers(): 103 | try: 104 | import tornado.simple_httpclient as simple 105 | except ImportError: 106 | pass 107 | else: 108 | setattr( 109 | simple.SimpleAsyncHTTPClient, 110 | 'fetch_impl', 111 | _SimpleAsyncHTTPClient_fetch_impl, 112 | ) 113 | try: 114 | import tornado.curl_httpclient as curl 115 | except ImportError: 116 | pass 117 | else: 118 | setattr( 119 | curl.CurlAsyncHTTPClient, 120 | 'fetch_impl', 121 | _CurlAsyncHTTPClient_fetch_impl, 122 | ) 123 | 124 | 125 | def traced_fetch_impl(real_fetch_impl): 126 | 127 | @functools.wraps(real_fetch_impl) 128 | def new_fetch_impl(self, request, callback): 129 | request_wrapper = TornadoRequestWrapper(request=request) 130 | span = before_http_request(request=request_wrapper, 131 | current_span_extractor=get_current_span) 132 | 133 | def new_callback(response): 134 | if hasattr(response, 'code') and response.code: 135 | span.set_tag(tags.HTTP_STATUS_CODE, '%s' % response.code) 136 | if hasattr(response, 'error') and response.error: 137 | span.set_tag(tags.ERROR, True) 138 | span.log(event=tags.ERROR, payload='%s' % response.error) 139 | span.finish() 140 | return callback(response) 141 | 142 | real_fetch_impl(self, request, new_callback) 143 | 144 | return new_fetch_impl 145 | 146 | 147 | class TornadoRequestWrapper(AbstractRequestWrapper): 148 | 149 | def __init__(self, request): 150 | self.request = request 151 | self._norm_headers = None 152 | 153 | def add_header(self, key, value): 154 | self.request.headers[key] = value 155 | 156 | @property 157 | def _headers(self): 158 | if self._norm_headers is None: 159 | if type(self.request.headers) is HTTPHeaders: 160 | self._norm_headers = self.request.headers 161 | else: 162 | self._norm_headers = HTTPHeaders(self.request.headers) 163 | return self._norm_headers 164 | 165 | @property 166 | def host_port(self): 167 | res = urllib.parse.urlparse(self.full_url) 168 | if res: 169 | return split_host_and_port(host_string=res.netloc, 170 | scheme=res.scheme) 171 | return None, None 172 | 173 | @property 174 | def method(self): 175 | return self.request.method 176 | 177 | @property 178 | def full_url(self): 179 | return self.request.url 180 | -------------------------------------------------------------------------------- /opentracing_instrumentation/client_hooks/urllib.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from __future__ import absolute_import 22 | 23 | import logging 24 | import six 25 | from opentracing.ext import tags as ext_tags 26 | 27 | 28 | from .. import utils 29 | from ._singleton import singleton 30 | from ._current_span import current_span_func 31 | 32 | log = logging.getLogger(__name__) 33 | 34 | 35 | @singleton 36 | def install_patches(): 37 | if six.PY3: 38 | # The old urllib does not exist in Py3, so delegate to urllib2 patcher 39 | from . import urllib2 40 | urllib2.install_patches() 41 | return 42 | 43 | import urllib 44 | import urlparse 45 | 46 | log.info('Instrumenting urllib methods for tracing') 47 | 48 | class TracedURLOpener(urllib.FancyURLopener): 49 | 50 | def open(self, fullurl, data=None): 51 | parsed_url = urlparse.urlparse(fullurl) 52 | host = parsed_url.hostname or None 53 | port = parsed_url.port or None 54 | 55 | span = utils.start_child_span( 56 | operation_name='urllib', parent=current_span_func()) 57 | 58 | span.set_tag(ext_tags.SPAN_KIND, ext_tags.SPAN_KIND_RPC_CLIENT) 59 | 60 | # use span as context manager so that its finish() method is called 61 | with span: 62 | span.set_tag(ext_tags.HTTP_URL, fullurl) 63 | if host: 64 | span.set_tag(ext_tags.PEER_HOST_IPV4, host) 65 | if port: 66 | span.set_tag(ext_tags.PEER_PORT, port) 67 | # TODO add callee service name 68 | # TODO add headers to propagate trace 69 | # cannot use super here, this is an old style class 70 | fileobj = urllib.FancyURLopener.open(self, fullurl, data) 71 | if fileobj.getcode() is not None: 72 | span.set_tag(ext_tags.HTTP_STATUS_CODE, fileobj.getcode()) 73 | 74 | return fileobj 75 | 76 | def retrieve(self, url, filename=None, reporthook=None, data=None): 77 | raise NotImplementedError 78 | 79 | urllib._urlopener = TracedURLOpener() 80 | -------------------------------------------------------------------------------- /opentracing_instrumentation/client_hooks/urllib2.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from __future__ import absolute_import 22 | 23 | import logging 24 | import six 25 | 26 | from future import standard_library 27 | 28 | standard_library.install_aliases() 29 | 30 | from tornado.httputil import HTTPHeaders 31 | from opentracing.ext import tags as ext_tags 32 | from opentracing_instrumentation.http_client import AbstractRequestWrapper 33 | from opentracing_instrumentation.http_client import before_http_request 34 | from opentracing_instrumentation.http_client import split_host_and_port 35 | from ._singleton import singleton 36 | from ._current_span import current_span_func 37 | 38 | log = logging.getLogger(__name__) 39 | 40 | 41 | @singleton 42 | def install_patches(): 43 | import http.client 44 | import urllib.request 45 | 46 | def build_handler(base_type, base_cls=None): 47 | """Build a urrllib2 handler from a base_type.""" 48 | 49 | class DerivedHandler(base_type): 50 | """The class derived from base_type.""" 51 | 52 | def do_open(self, req, conn): 53 | request_wrapper = Urllib2RequestWrapper(request=req) 54 | span = before_http_request( 55 | request=request_wrapper, 56 | current_span_extractor=current_span_func) 57 | with span: 58 | if base_cls: 59 | # urllib2.AbstractHTTPHandler doesn't support super() 60 | resp = base_cls.do_open(self, conn, req) 61 | else: 62 | resp = super(DerivedHandler, self).do_open(conn, req) 63 | if resp.code is not None: 64 | span.set_tag(ext_tags.HTTP_STATUS_CODE, resp.code) 65 | return resp 66 | 67 | return DerivedHandler 68 | 69 | class Urllib2RequestWrapper(AbstractRequestWrapper): 70 | def __init__(self, request): 71 | self.request = request 72 | self._norm_headers = None 73 | 74 | def add_header(self, key, value): 75 | self.request.add_header(key, value) 76 | 77 | @property 78 | def method(self): 79 | return self.request.get_method() 80 | 81 | @property 82 | def full_url(self): 83 | return self.request.get_full_url() 84 | 85 | @property 86 | def _headers(self): 87 | if self._norm_headers is None: 88 | self._norm_headers = HTTPHeaders(self.request.headers) 89 | return self._norm_headers 90 | 91 | @property 92 | def host_port(self): 93 | host_string = self.request.host 94 | return split_host_and_port(host_string=host_string, 95 | scheme=self.request.type) 96 | 97 | def install_for_module(module, do_open_base=None): 98 | httpBase = build_handler(module.HTTPHandler, do_open_base) 99 | httpsBase = build_handler(module.HTTPSHandler, do_open_base) 100 | 101 | class TracedHTTPHandler(httpBase): 102 | def http_open(self, req): 103 | return self.do_open(req, http.client.HTTPConnection) 104 | 105 | class TracedHTTPSHandler(httpsBase): 106 | def https_open(self, req): 107 | return self.do_open(req, http.client.HTTPSConnection) 108 | 109 | log.info('Instrumenting %s for tracing' % module.__name__) 110 | opener = module.build_opener(TracedHTTPHandler, TracedHTTPSHandler) 111 | module.install_opener(opener) 112 | 113 | if six.PY2: 114 | import urllib2 115 | base = urllib2.AbstractHTTPHandler 116 | install_for_module(urllib2, base) 117 | 118 | install_for_module(urllib.request) 119 | -------------------------------------------------------------------------------- /opentracing_instrumentation/config.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from __future__ import absolute_import 22 | from builtins import object 23 | 24 | 25 | class _Config(object): 26 | def __init__(self, app_name=None): 27 | self.app_name = app_name 28 | 29 | # HTTP headers that may contain the name of the service making 30 | # inbound call 31 | self.caller_name_headers = [] 32 | 33 | # HTTP headers that may contain the name of the remote service being 34 | # called 35 | self.callee_name_headers = [] 36 | 37 | # HTTP headers that may contain the endpoint of the remote service 38 | # being called 39 | self.callee_endpoint_headers = [] 40 | 41 | 42 | # create a singleton 43 | CONFIG = _Config() 44 | -------------------------------------------------------------------------------- /opentracing_instrumentation/http_client.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from __future__ import absolute_import 22 | from builtins import object 23 | import re 24 | import opentracing 25 | import six 26 | 27 | from opentracing import Format 28 | from opentracing.ext import tags 29 | 30 | from opentracing_instrumentation.config import CONFIG 31 | from opentracing_instrumentation.interceptors import ClientInterceptors 32 | from opentracing_instrumentation import utils 33 | 34 | 35 | def before_http_request(request, current_span_extractor): 36 | """ 37 | A hook to be executed before HTTP request is executed. 38 | It returns a Span object that can be used as a context manager around 39 | the actual HTTP call implementation, or in case of async callback, 40 | it needs its `finish()` method to be called explicitly. 41 | 42 | :param request: request must match API defined by AbstractRequestWrapper 43 | :param current_span_extractor: function that extracts current span 44 | from some context 45 | :return: returns child tracing span encapsulating this request 46 | """ 47 | 48 | span = utils.start_child_span( 49 | operation_name=request.operation, 50 | parent=current_span_extractor() 51 | ) 52 | 53 | span.set_tag(tags.SPAN_KIND, tags.SPAN_KIND_RPC_CLIENT) 54 | span.set_tag(tags.HTTP_URL, request.full_url) 55 | 56 | service_name = request.service_name 57 | host, port = request.host_port 58 | if service_name: 59 | span.set_tag(tags.PEER_SERVICE, service_name) 60 | if host: 61 | span.set_tag(tags.PEER_HOST_IPV4, host) 62 | if port: 63 | span.set_tag(tags.PEER_PORT, port) 64 | 65 | # fire interceptors 66 | for interceptor in ClientInterceptors.get_interceptors(): 67 | interceptor.process(request=request, span=span) 68 | 69 | try: 70 | carrier = {} 71 | opentracing.tracer.inject(span_context=span.context, 72 | format=Format.HTTP_HEADERS, 73 | carrier=carrier) 74 | for key, value in six.iteritems(carrier): 75 | request.add_header(key, value) 76 | except opentracing.UnsupportedFormatException: 77 | pass 78 | 79 | return span 80 | 81 | 82 | class AbstractRequestWrapper(object): 83 | 84 | def add_header(self, key, value): 85 | pass 86 | 87 | @property 88 | def _headers(self): 89 | return {} 90 | 91 | @property 92 | def host_port(self): 93 | return None, None 94 | 95 | @property 96 | def service_name(self): 97 | for header in CONFIG.callee_name_headers: 98 | value = self._headers.get(header, None) 99 | if value is not None: 100 | return value 101 | return None 102 | 103 | @property 104 | def operation(self): 105 | for header in CONFIG.callee_endpoint_headers: 106 | value = self._headers.get(header, None) 107 | if value is not None: 108 | return '%s:%s' % (self.method, value) 109 | return self.method 110 | 111 | @property 112 | def method(self): 113 | raise NotImplementedError 114 | 115 | @property 116 | def full_url(self): 117 | raise NotImplementedError 118 | 119 | 120 | HOST_PORT_RE = re.compile(r'^(.*):(\d+)$') 121 | 122 | 123 | def split_host_and_port(host_string, scheme='http'): 124 | is_secure = True if scheme == 'https' else False 125 | m = HOST_PORT_RE.match(host_string) 126 | if m: 127 | host, port = m.groups() 128 | return host, int(port) 129 | elif is_secure is None: 130 | return host_string, None 131 | elif is_secure: 132 | return host_string, 443 133 | else: 134 | return host_string, 80 135 | -------------------------------------------------------------------------------- /opentracing_instrumentation/http_server.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from __future__ import absolute_import 22 | 23 | from future import standard_library 24 | standard_library.install_aliases() 25 | from builtins import object 26 | import logging 27 | import urllib.parse 28 | import opentracing 29 | import six 30 | from opentracing import Format 31 | from opentracing.ext import tags 32 | from opentracing_instrumentation import config 33 | 34 | 35 | def before_request(request, tracer=None): 36 | """ 37 | Attempts to extract a tracing span from incoming request. 38 | If no tracing context is passed in the headers, or the data 39 | cannot be parsed, a new root span is started. 40 | 41 | :param request: HTTP request with `.headers` property exposed 42 | that satisfies a regular dictionary interface 43 | :param tracer: optional tracer instance to use. If not specified 44 | the global opentracing.tracer will be used. 45 | :return: returns a new, already started span. 46 | """ 47 | if tracer is None: 48 | tracer = opentracing.tracer 49 | 50 | # we need to prepare tags upfront, mainly because RPC_SERVER tag must be 51 | # set when starting the span, to support Zipkin's one-span-per-RPC model 52 | tags_dict = { 53 | tags.SPAN_KIND: tags.SPAN_KIND_RPC_SERVER, 54 | tags.HTTP_URL: request.full_url, 55 | tags.HTTP_METHOD: request.method, 56 | } 57 | 58 | remote_ip = request.remote_ip 59 | if remote_ip: 60 | tags_dict[tags.PEER_HOST_IPV4] = remote_ip 61 | 62 | caller_name = request.caller_name 63 | if caller_name: 64 | tags_dict[tags.PEER_SERVICE] = caller_name 65 | 66 | remote_port = request.remote_port 67 | if remote_port: 68 | tags_dict[tags.PEER_PORT] = remote_port 69 | 70 | operation = request.operation 71 | try: 72 | carrier = {} 73 | for key, value in six.iteritems(request.headers): 74 | carrier[key] = value 75 | parent_ctx = tracer.extract( 76 | format=Format.HTTP_HEADERS, carrier=carrier 77 | ) 78 | except Exception as e: 79 | logging.exception('trace extract failed: %s' % e) 80 | parent_ctx = None 81 | 82 | span = tracer.start_span( 83 | operation_name=operation, 84 | child_of=parent_ctx, 85 | tags=tags_dict) 86 | 87 | return span 88 | 89 | 90 | class AbstractRequestWrapper(object): 91 | """ 92 | Exposes several properties used by the tracing methods. 93 | """ 94 | 95 | @property 96 | def caller_name(self): 97 | for header in config.CONFIG.caller_name_headers: 98 | caller = self.headers.get(header.lower(), None) 99 | if caller is not None: 100 | return caller 101 | return None 102 | 103 | @property 104 | def full_url(self): 105 | raise NotImplementedError('full_url') 106 | 107 | @property 108 | def headers(self): 109 | raise NotImplementedError('headers') 110 | 111 | @property 112 | def method(self): 113 | raise NotImplementedError('method') 114 | 115 | @property 116 | def remote_ip(self): 117 | raise NotImplementedError('remote_ip') 118 | 119 | @property 120 | def remote_port(self): 121 | return None 122 | 123 | @property 124 | def server_port(self): 125 | return None 126 | 127 | @property 128 | def operation(self): 129 | return self.method 130 | 131 | 132 | class TornadoRequestWrapper(AbstractRequestWrapper): 133 | """ 134 | Wraps tornado.httputils.HTTPServerRequest and exposes several properties 135 | used by the tracing methods. 136 | """ 137 | 138 | def __init__(self, request): 139 | self.request = request 140 | 141 | @property 142 | def full_url(self): 143 | return self.request.full_url() 144 | 145 | @property 146 | def headers(self): 147 | return self.request.headers 148 | 149 | @property 150 | def method(self): 151 | return self.request.method 152 | 153 | @property 154 | def remote_ip(self): 155 | return self.request.remote_ip 156 | 157 | 158 | class WSGIRequestWrapper(AbstractRequestWrapper): 159 | """ 160 | Wraps WSGI environment and exposes several properties 161 | used by the tracing methods. 162 | """ 163 | 164 | def __init__(self, wsgi_environ, headers): 165 | self.wsgi_environ = wsgi_environ 166 | self._headers = headers 167 | 168 | @classmethod 169 | def from_wsgi_environ(cls, wsgi_environ): 170 | instance = cls(wsgi_environ=wsgi_environ, 171 | headers=cls._parse_wsgi_headers(wsgi_environ)) 172 | return instance 173 | 174 | @staticmethod 175 | def _parse_wsgi_headers(wsgi_environ): 176 | """ 177 | HTTP headers are presented in WSGI environment with 'HTTP_' prefix. 178 | This method finds those headers, removes the prefix, converts 179 | underscores to dashes, and converts to lower case. 180 | 181 | :param wsgi_environ: 182 | :return: returns a dictionary of headers 183 | """ 184 | prefix = 'HTTP_' 185 | p_len = len(prefix) 186 | # use .items() despite suspected memory pressure bc GC occasionally 187 | # collects wsgi_environ.iteritems() during iteration. 188 | headers = { 189 | key[p_len:].replace('_', '-').lower(): 190 | val for (key, val) in wsgi_environ.items() 191 | if key.startswith(prefix)} 192 | return headers 193 | 194 | @property 195 | def full_url(self): 196 | """ 197 | Taken from 198 | http://legacy.python.org/dev/peps/pep-3333/#url-reconstruction 199 | 200 | :return: Reconstructed URL from WSGI environment. 201 | """ 202 | environ = self.wsgi_environ 203 | url = environ['wsgi.url_scheme'] + '://' 204 | 205 | if environ.get('HTTP_HOST'): 206 | url += environ['HTTP_HOST'] 207 | else: 208 | url += environ['SERVER_NAME'] 209 | 210 | if environ['wsgi.url_scheme'] == 'https': 211 | if environ['SERVER_PORT'] != '443': 212 | url += ':' + environ['SERVER_PORT'] 213 | else: 214 | if environ['SERVER_PORT'] != '80': 215 | url += ':' + environ['SERVER_PORT'] 216 | 217 | url += urllib.parse.quote(environ.get('SCRIPT_NAME', '')) 218 | url += urllib.parse.quote(environ.get('PATH_INFO', '')) 219 | if environ.get('QUERY_STRING'): 220 | url += '?' + environ['QUERY_STRING'] 221 | return url 222 | 223 | @property 224 | def headers(self): 225 | return self._headers 226 | 227 | @property 228 | def method(self): 229 | return self.wsgi_environ.get('REQUEST_METHOD') 230 | 231 | @property 232 | def remote_ip(self): 233 | return self.wsgi_environ.get('REMOTE_ADDR', None) 234 | 235 | @property 236 | def remote_port(self): 237 | return self.wsgi_environ.get('REMOTE_PORT', None) 238 | 239 | @property 240 | def server_port(self): 241 | return self.wsgi_environ.get('SERVER_PORT', None) 242 | -------------------------------------------------------------------------------- /opentracing_instrumentation/interceptors.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2017 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from __future__ import absolute_import 22 | 23 | import abc 24 | 25 | import six 26 | 27 | 28 | @six.add_metaclass(abc.ABCMeta) 29 | class OpenTracingInterceptor(object): 30 | """ 31 | Abstract OpenTracing Interceptor class. 32 | 33 | Subclasses are expected to provide a full implementation of 34 | the ``process(..)`` method which is passed the request object, 35 | and the current span. 36 | 37 | A code sample of expected usage: 38 | 39 | .. code-block:: python 40 | 41 | from opentracing_instrumentation.interceptors \ 42 | import OpenTracingInterceptor 43 | 44 | class CustomOpenTracingInterceptor(OpenTracingInterceptor): 45 | 46 | def process(self, request, span): 47 | span.set_baggage_item(..) 48 | 49 | """ 50 | 51 | @abc.abstractmethod 52 | def process(self, request, span): 53 | """Fire the interceptor.""" 54 | pass 55 | 56 | 57 | class ClientInterceptors(object): 58 | """ 59 | Client interceptors executed between span creation and injection. 60 | 61 | Subclassed implementations of ``OpenTracingInterceptor`` can be added 62 | and are executed in order in which they are added, after child 63 | span for current request is created, but before the span baggage 64 | contents are injected into the outbound request. 65 | 66 | A code sample of expected usage: 67 | 68 | from opentracing_instrumentation.interceptors import ClientInterceptors 69 | 70 | from my_project.interceptors import CustomOpenTracingInterceptor 71 | 72 | my_interceptor = CustomOpenTracingInterceptor() 73 | ClientInterceptors.append(my_interceptor) 74 | 75 | """ 76 | 77 | _interceptors = [] 78 | 79 | @classmethod 80 | def append(cls, interceptor): 81 | """ 82 | Add interceptor to the end of the internal list. 83 | 84 | Note: Raises ``ValueError`` if interceptor 85 | does not extend ``OpenTracingInterceptor`` 86 | """ 87 | cls._check(interceptor) 88 | cls._interceptors.append(interceptor) 89 | 90 | @classmethod 91 | def insert(cls, index, interceptor): 92 | """ 93 | Add interceptor to the given index in the internal list. 94 | 95 | Note: Raises ``ValueError`` if interceptor 96 | does not extend ``OpenTracingInterceptor`` 97 | """ 98 | cls._check(interceptor) 99 | cls._interceptors.insert(index, interceptor) 100 | 101 | @classmethod 102 | def _check(cls, interceptor): 103 | if not isinstance(interceptor, OpenTracingInterceptor): 104 | raise ValueError('ClientInterceptors only accepts instances ' 105 | 'of OpenTracingInterceptor') 106 | 107 | @classmethod 108 | def get_interceptors(cls): 109 | """Return a list of interceptors.""" 110 | return cls._interceptors 111 | 112 | @classmethod 113 | def clear(cls): 114 | """Clear the internal list of interceptors.""" 115 | del cls._interceptors[:] 116 | -------------------------------------------------------------------------------- /opentracing_instrumentation/local_span.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | from __future__ import absolute_import 21 | from builtins import str 22 | import functools 23 | import contextlib2 24 | import tornado.concurrent 25 | import opentracing 26 | from opentracing.scope_managers.tornado import TornadoScopeManager 27 | from . import get_current_span, span_in_stack_context, span_in_context, utils 28 | 29 | 30 | def func_span(func, tags=None, require_active_trace=False): 31 | """ 32 | Creates a new local span for execution of the given `func`. 33 | The returned span is best used as a context manager, e.g. 34 | 35 | .. code-block:: python 36 | 37 | with func_span('my_function'): 38 | return my_function(...) 39 | 40 | At this time the func should be a string name. In the future this code 41 | can be enhanced to accept a real function and derive its qualified name. 42 | 43 | :param func: name of the function or method 44 | :param tags: optional tags to add to the child span 45 | :param require_active_trace: controls what to do when there is no active 46 | trace. If require_active_trace=True, then no span is created. 47 | If require_active_trace=False, a new trace is started. 48 | :return: new child span, or a dummy context manager if there is no 49 | active/current parent span 50 | """ 51 | current_span = get_current_span() 52 | 53 | if current_span is None and require_active_trace: 54 | @contextlib2.contextmanager 55 | def empty_ctx_mgr(): 56 | yield None 57 | 58 | return empty_ctx_mgr() 59 | 60 | # TODO convert func to a proper name: module:class.func 61 | operation_name = str(func) 62 | return utils.start_child_span( 63 | operation_name=operation_name, parent=current_span, tags=tags) 64 | 65 | 66 | class _DummyStackContext(object): 67 | """ 68 | Stack context that restores previous scope after exit. 69 | Will be returned by helper `_span_in_stack_context` when tracer scope 70 | manager is not `TornadoScopeManager`. 71 | """ 72 | def __init__(self, context): 73 | self._context = context 74 | 75 | def __enter__(self): 76 | # Need for compatibility with `span_in_stack_context`. 77 | return lambda: None 78 | 79 | def __exit__(self, exc_type, exc_val, exc_tb): 80 | if self._context: 81 | self._context.close() 82 | 83 | 84 | def _span_in_stack_context(span): 85 | if isinstance(opentracing.tracer.scope_manager, TornadoScopeManager): 86 | return span_in_stack_context(span) 87 | else: 88 | return _DummyStackContext(span_in_context(span)) 89 | 90 | 91 | def traced_function(func=None, name=None, on_start=None, 92 | require_active_trace=False): 93 | """ 94 | A decorator that enables tracing of the wrapped function or 95 | Tornado co-routine provided there is a parent span already established. 96 | 97 | .. code-block:: python 98 | 99 | @traced_function 100 | def my_function1(arg1, arg2=None) 101 | ... 102 | 103 | :param func: decorated function or Tornado co-routine 104 | :param name: optional name to use as the Span.operation_name. 105 | If not provided, func.__name__ will be used. 106 | :param on_start: an optional callback to be executed once the child span 107 | is started, but before the decorated function is called. It can be 108 | used to set any additional tags on the span, perhaps by inspecting 109 | the decorated function arguments. The callback must have a signature 110 | `(span, *args, *kwargs)`, where the last two collections are the 111 | arguments passed to the actual decorated function. 112 | 113 | .. code-block:: python 114 | 115 | def extract_call_site_tag(span, *args, *kwargs) 116 | if 'call_site_tag' in kwargs: 117 | span.set_tag('call_site_tag', kwargs['call_site_tag']) 118 | 119 | @traced_function(on_start=extract_call_site_tag) 120 | @tornado.get.coroutine 121 | def my_function(arg1, arg2=None, call_site_tag=None) 122 | ... 123 | 124 | :param require_active_trace: controls what to do when there is no active 125 | trace. If require_active_trace=True, then no span is created. 126 | If require_active_trace=False, a new trace is started. 127 | :return: returns a tracing decorator 128 | """ 129 | 130 | if func is None: 131 | return functools.partial(traced_function, name=name, 132 | on_start=on_start, 133 | require_active_trace=require_active_trace) 134 | 135 | if name: 136 | operation_name = name 137 | else: 138 | operation_name = func.__name__ 139 | 140 | @functools.wraps(func) 141 | def decorator(*args, **kwargs): 142 | parent_span = get_current_span() 143 | if parent_span is None and require_active_trace: 144 | return func(*args, **kwargs) 145 | 146 | span = utils.start_child_span( 147 | operation_name=operation_name, parent=parent_span) 148 | if callable(on_start): 149 | on_start(span, *args, **kwargs) 150 | 151 | # We explicitly invoke deactivation callback for the StackContext, 152 | # because there are scenarios when it gets retained forever, for 153 | # example when a Periodic Callback is scheduled lazily while in the 154 | # scope of a tracing StackContext. 155 | with _span_in_stack_context(span) as deactivate_cb: 156 | try: 157 | res = func(*args, **kwargs) 158 | # Tornado co-routines usually return futures, so we must wait 159 | # until the future is completed, in order to accurately 160 | # capture the function's execution time. 161 | if tornado.concurrent.is_future(res): 162 | def done_callback(future): 163 | deactivate_cb() 164 | exception = future.exception() 165 | if exception is not None: 166 | span.log(event='exception', payload=exception) 167 | span.set_tag('error', 'true') 168 | span.finish() 169 | if res.done(): 170 | done_callback(res) 171 | else: 172 | res.add_done_callback(done_callback) 173 | else: 174 | deactivate_cb() 175 | span.finish() 176 | return res 177 | except Exception as e: 178 | deactivate_cb() 179 | span.log(event='exception', payload=e) 180 | span.set_tag('error', 'true') 181 | span.finish() 182 | raise 183 | return decorator 184 | -------------------------------------------------------------------------------- /opentracing_instrumentation/request_context.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from __future__ import absolute_import 22 | from builtins import object 23 | import threading 24 | 25 | import opentracing 26 | from opentracing.scope_managers.tornado import TornadoScopeManager 27 | from opentracing.scope_managers.tornado import tracer_stack_context 28 | from opentracing.scope_managers.tornado import ThreadSafeStackContext # noqa 29 | 30 | 31 | class RequestContext(object): 32 | """ 33 | DEPRECATED, use either span_in_context() or span_in_stack_context() 34 | instead. 35 | 36 | RequestContext represents the context of a request being executed. 37 | 38 | Useful when a service needs to make downstream calls to other services 39 | and requires access to some aspects of the original request, such as 40 | tracing information. 41 | 42 | It is designed to hold a reference to the current OpenTracing Span, 43 | but the class can be extended to store more information. 44 | """ 45 | 46 | __slots__ = ('span', ) 47 | 48 | def __init__(self, span): 49 | self.span = span 50 | 51 | 52 | class RequestContextManager(object): 53 | """ 54 | DEPRECATED, use either span_in_context() or span_in_stack_context() 55 | instead. 56 | 57 | A context manager that saves RequestContext in thread-local state. 58 | 59 | Intended for use with ThreadSafeStackContext (a thread-safe 60 | replacement for Tornado's StackContext) or as context manager 61 | in a WSGI middleware. 62 | """ 63 | 64 | _state = threading.local() 65 | _state.context = None 66 | 67 | @classmethod 68 | def current_context(cls): 69 | """Get the current request context. 70 | 71 | :rtype: opentracing_instrumentation.RequestContext 72 | :returns: The current request context, or None. 73 | """ 74 | return getattr(cls._state, 'context', None) 75 | 76 | def __init__(self, context=None, span=None): 77 | # normally we want the context parameter, but for backwards 78 | # compatibility we make it optional and allow span as well 79 | if span: 80 | self._context = RequestContext(span=span) 81 | elif isinstance(context, opentracing.Span): 82 | self._context = RequestContext(span=context) 83 | else: 84 | self._context = context 85 | 86 | def __enter__(self): 87 | self._prev_context = self.__class__.current_context() 88 | self.__class__._state.context = self._context 89 | return self._context 90 | 91 | def __exit__(self, *_): 92 | self.__class__._state.context = self._prev_context 93 | self._prev_context = None 94 | return False 95 | 96 | 97 | class _TracerEnteredStackContext(object): 98 | """ 99 | An entered tracer_stack_context() object. 100 | 101 | Intended to have a ready-to-use context where 102 | Span objects can be activated before the context 103 | itself is returned to the user. 104 | """ 105 | 106 | def __init__(self, context): 107 | self._context = context 108 | self._deactivation_cb = context.__enter__() 109 | 110 | def __enter__(self): 111 | return self._deactivation_cb 112 | 113 | def __exit__(self, type, value, traceback): 114 | return self._context.__exit__(type, value, traceback) 115 | 116 | 117 | def get_current_span(): 118 | """ 119 | Access current request context and extract current Span from it. 120 | :return: 121 | Return current span associated with the current request context. 122 | If no request context is present in thread local, or the context 123 | has no span, return None. 124 | """ 125 | # Check against the old, ScopeManager-less implementation, 126 | # for backwards compatibility. 127 | context = RequestContextManager.current_context() 128 | if context is not None: 129 | return context.span 130 | 131 | active = opentracing.tracer.scope_manager.active 132 | return active.span if active else None 133 | 134 | 135 | def span_in_context(span): 136 | """ 137 | Create a context manager that stores the given span in the thread-local 138 | request context. This function should only be used in single-threaded 139 | applications like Flask / uWSGI. 140 | 141 | ## Usage example in WSGI middleware: 142 | 143 | .. code-block:: python 144 | from opentracing_instrumentation.http_server import WSGIRequestWrapper 145 | from opentracing_instrumentation.http_server import before_request 146 | from opentracing_instrumentation import request_context 147 | 148 | def create_wsgi_tracing_middleware(other_wsgi): 149 | 150 | def wsgi_tracing_middleware(environ, start_response): 151 | request = WSGIRequestWrapper.from_wsgi_environ(environ) 152 | span = before_request(request=request, tracer=tracer) 153 | 154 | # Wrapper around the real start_response object to log 155 | # additional information to opentracing Span 156 | def start_response_wrapper(status, response_headers, 157 | exc_info=None): 158 | if exc_info is not None: 159 | span.log(event='exception', payload=exc_info) 160 | span.finish() 161 | 162 | return start_response(status, response_headers) 163 | 164 | with request_context.span_in_context(span): 165 | return other_wsgi(environ, start_response_wrapper) 166 | 167 | return wsgi_tracing_middleware 168 | 169 | :param span: OpenTracing Span 170 | :return: 171 | Return context manager that wraps the request context. 172 | """ 173 | 174 | # Return a no-op Scope if None was specified. 175 | if span is None: 176 | return opentracing.Scope(None, None) 177 | 178 | return opentracing.tracer.scope_manager.activate(span, False) 179 | 180 | 181 | def span_in_stack_context(span): 182 | """ 183 | Create Tornado's StackContext that stores the given span in the 184 | thread-local request context. This function is intended for use 185 | in Tornado applications based on IOLoop, although will work fine 186 | in single-threaded apps like Flask, albeit with more overhead. 187 | 188 | ## Usage example in Tornado application 189 | 190 | Suppose you have a method `handle_request(request)` in the http server. 191 | Instead of calling it directly, use a wrapper: 192 | 193 | .. code-block:: python 194 | 195 | from opentracing_instrumentation import request_context 196 | 197 | @tornado.gen.coroutine 198 | def handle_request_wrapper(request, actual_handler, *args, **kwargs) 199 | 200 | request_wrapper = TornadoRequestWrapper(request=request) 201 | span = http_server.before_request(request=request_wrapper) 202 | 203 | with request_context.span_in_stack_context(span): 204 | return actual_handler(*args, **kwargs) 205 | 206 | :param span: 207 | :return: 208 | Return StackContext that wraps the request context. 209 | """ 210 | 211 | if not isinstance(opentracing.tracer.scope_manager, TornadoScopeManager): 212 | raise RuntimeError('scope_manager is not TornadoScopeManager') 213 | 214 | # Enter the newly created stack context so we have 215 | # storage available for Span activation. 216 | context = tracer_stack_context() 217 | entered_context = _TracerEnteredStackContext(context) 218 | 219 | if span is None: 220 | return entered_context 221 | 222 | opentracing.tracer.scope_manager.activate(span, False) 223 | assert opentracing.tracer.active_span is not None 224 | assert opentracing.tracer.active_span is span 225 | 226 | return entered_context 227 | -------------------------------------------------------------------------------- /opentracing_instrumentation/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | from __future__ import absolute_import 21 | 22 | import opentracing 23 | 24 | 25 | def start_child_span(operation_name, tracer=None, parent=None, tags=None): 26 | """ 27 | Start a new span as a child of parent_span. If parent_span is None, 28 | start a new root span. 29 | 30 | :param operation_name: operation name 31 | :param tracer: Tracer or None (defaults to opentracing.tracer) 32 | :param parent: parent Span or None 33 | :param tags: optional tags 34 | :return: new span 35 | """ 36 | tracer = tracer or opentracing.tracer 37 | return tracer.start_span( 38 | operation_name=operation_name, 39 | child_of=parent.context if parent else None, 40 | tags=tags 41 | ) 42 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | # add dependencies in setup.py 2 | 3 | -r requirements.txt 4 | 5 | -e .[tests] 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # add dependencies in setup.py 2 | 3 | -e . 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 79 3 | # max-complexity = 8 4 | exclude = ./docs/,tests/*,.tox/ 5 | ignore = E402 6 | 7 | [zest.releaser] 8 | release = no 9 | history_file = CHANGELOG.rst 10 | 11 | [coverage:run] 12 | branch = True 13 | omit = 14 | setup.py 15 | tests/* 16 | 17 | [tool:pytest] 18 | addopts = --cov=opentracing_instrumentation --cov-append -rs 19 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | with open('README.md', 'r') as fh: 4 | long_description = fh.read() 5 | 6 | setup( 7 | name='opentracing_instrumentation', 8 | version='3.3.2.dev0', 9 | author='Yuri Shkuro', 10 | author_email='ys@uber.com', 11 | description='Tracing Instrumentation using OpenTracing API ' 12 | '(http://opentracing.io)', 13 | long_description=long_description, 14 | long_description_content_type='text/markdown', 15 | license='MIT', 16 | url='https://github.com/uber-common/opentracing-python-instrumentation', 17 | keywords=['opentracing'], 18 | classifiers=[ 19 | 'Development Status :: 5 - Production/Stable', 20 | 'Intended Audience :: Developers', 21 | 'License :: OSI Approved :: MIT License', 22 | 'Programming Language :: Python :: 2.7', 23 | 'Programming Language :: Python :: 3.5', 24 | 'Programming Language :: Python :: 3.6', 25 | 'Programming Language :: Python :: 3.7', 26 | 'Programming Language :: Python :: Implementation :: PyPy', 27 | 'Topic :: Software Development :: Libraries :: Python Modules', 28 | ], 29 | packages=find_packages(exclude=['tests', 'tests.*']), 30 | include_package_data=True, 31 | zip_safe=False, 32 | platforms='any', 33 | install_requires=[ 34 | 'future', 35 | 'wrapt', 36 | 'tornado>=4.1,<6', 37 | 'contextlib2', 38 | 'opentracing>=2,<3', 39 | 'six', 40 | ], 41 | extras_require={ 42 | 'tests': [ 43 | 'boto3', 44 | 'botocore', 45 | 'celery', 46 | 'doubles', 47 | 'flake8', 48 | 'flake8-quotes', 49 | 'mock', 50 | 'moto', 51 | 'MySQL-python; python_version=="2.7"', 52 | 'psycopg2-binary', 53 | 'sqlalchemy>=1.3.7', 54 | 'pytest', 55 | 'pytest-cov', 56 | 'pytest-localserver', 57 | 'pytest-mock', 58 | 'pytest-tornado', 59 | 'basictracer>=3,<4', 60 | 'redis', 61 | 'Sphinx', 62 | 'sphinx_rtd_theme', 63 | 'testfixtures', 64 | ] 65 | }, 66 | ) 67 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uber-common/opentracing-python-instrumentation/ddae1c5d314902561d81563b4366ccd77d43e15e/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | import opentracing 22 | import pytest 23 | from opentracing.scope_managers.tornado import TornadoScopeManager 24 | 25 | 26 | def _get_tracers(scope_manager=None): 27 | from basictracer.recorder import InMemoryRecorder 28 | from basictracer.tracer import BasicTracer 29 | 30 | dummy_tracer = BasicTracer(recorder=InMemoryRecorder(), 31 | scope_manager=scope_manager) 32 | dummy_tracer.register_required_propagators() 33 | old_tracer = opentracing.tracer 34 | opentracing.tracer = dummy_tracer 35 | 36 | return old_tracer, dummy_tracer 37 | 38 | 39 | @pytest.fixture 40 | def tracer(): 41 | old_tracer, dummy_tracer = _get_tracers() 42 | try: 43 | yield dummy_tracer 44 | finally: 45 | opentracing.tracer = old_tracer 46 | 47 | 48 | @pytest.fixture 49 | def thread_safe_tracer(): 50 | old_tracer, dummy_tracer = _get_tracers(TornadoScopeManager()) 51 | try: 52 | yield dummy_tracer 53 | finally: 54 | opentracing.tracer = old_tracer 55 | -------------------------------------------------------------------------------- /tests/opentracing_instrumentation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uber-common/opentracing-python-instrumentation/ddae1c5d314902561d81563b4366ccd77d43e15e/tests/opentracing_instrumentation/__init__.py -------------------------------------------------------------------------------- /tests/opentracing_instrumentation/sql_common.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import ( 2 | Column, 3 | Integer, 4 | MetaData, 5 | String, 6 | Table, 7 | ) 8 | from sqlalchemy.orm import mapper 9 | 10 | 11 | metadata = MetaData() 12 | user = Table('user', metadata, 13 | Column('id', Integer, primary_key=True), 14 | Column('name', String(50)), 15 | Column('fullname', String(50)), 16 | Column('password', String(12))) 17 | 18 | 19 | class User(object): 20 | 21 | def __init__(self, name, fullname, password): 22 | self.name = name 23 | self.fullname = fullname 24 | self.password = password 25 | 26 | 27 | mapper(User, user) 28 | -------------------------------------------------------------------------------- /tests/opentracing_instrumentation/test_boto3.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import io 3 | 4 | import boto3 5 | import mock 6 | import pytest 7 | import requests 8 | import testfixtures 9 | 10 | from botocore.exceptions import ClientError 11 | from opentracing.ext import tags 12 | 13 | from opentracing_instrumentation.client_hooks import boto3 as boto3_hooks 14 | 15 | 16 | DYNAMODB_ENDPOINT_URL = 'http://localhost:4569' 17 | S3_ENDPOINT_URL = 'http://localhost:4572' 18 | 19 | DYNAMODB_CONFIG = { 20 | 'endpoint_url': DYNAMODB_ENDPOINT_URL, 21 | 'aws_access_key_id': '-', 22 | 'aws_secret_access_key': '-', 23 | 'region_name': 'us-east-1', 24 | } 25 | S3_CONFIG = dict(DYNAMODB_CONFIG, endpoint_url=S3_ENDPOINT_URL) 26 | 27 | 28 | def create_users_table(dynamodb): 29 | dynamodb.create_table( 30 | TableName='users', 31 | KeySchema=[{ 32 | 'AttributeName': 'username', 33 | 'KeyType': 'HASH' 34 | }], 35 | AttributeDefinitions=[{ 36 | 'AttributeName': 'username', 37 | 'AttributeType': 'S' 38 | }], 39 | ProvisionedThroughput={ 40 | 'ReadCapacityUnits': 9, 41 | 'WriteCapacityUnits': 9 42 | } 43 | ) 44 | 45 | 46 | @pytest.fixture 47 | def dynamodb_mock(): 48 | import moto 49 | with moto.mock_dynamodb2(): 50 | dynamodb = boto3.resource('dynamodb', region_name='us-east-1') 51 | create_users_table(dynamodb) 52 | yield dynamodb 53 | 54 | 55 | @pytest.fixture 56 | def dynamodb(): 57 | dynamodb = boto3.resource('dynamodb', **DYNAMODB_CONFIG) 58 | 59 | try: 60 | dynamodb.Table('users').delete() 61 | except ClientError as error: 62 | # you can not just use ResourceNotFoundException class 63 | # to catch an error since it doesn't exist until it's raised 64 | if error.__class__.__name__ != 'ResourceNotFoundException': 65 | raise 66 | 67 | create_users_table(dynamodb) 68 | 69 | # waiting until the table exists 70 | dynamodb.meta.client.get_waiter('table_exists').wait(TableName='users') 71 | 72 | return dynamodb 73 | 74 | 75 | @pytest.fixture 76 | def s3_mock(): 77 | import moto 78 | with moto.mock_s3(): 79 | s3 = boto3.client('s3', region_name='us-east-1') 80 | yield s3 81 | 82 | 83 | @pytest.fixture 84 | def s3(): 85 | return boto3.client('s3', **S3_CONFIG) 86 | 87 | 88 | @pytest.fixture(autouse=True) 89 | def patch_boto3(): 90 | boto3_hooks.install_patches() 91 | try: 92 | yield 93 | finally: 94 | boto3_hooks.reset_patches() 95 | 96 | 97 | def assert_last_span(kind, service_name, operation, tracer, response=None): 98 | span = tracer.recorder.get_spans()[-1] 99 | request_id = response and response['ResponseMetadata'].get('RequestId') 100 | assert span.operation_name == 'boto3:{}:{}:{}'.format( 101 | kind, service_name, operation 102 | ) 103 | assert span.tags.get(tags.SPAN_KIND) == tags.SPAN_KIND_RPC_CLIENT 104 | assert span.tags.get(tags.COMPONENT) == 'boto3' 105 | assert span.tags.get('boto3.service_name') == service_name 106 | if request_id: 107 | assert span.tags.get('aws.request_id') == request_id 108 | 109 | 110 | def _test_dynamodb(dynamodb, tracer): 111 | users = dynamodb.Table('users') 112 | 113 | response = users.put_item(Item={ 114 | 'username': 'janedoe', 115 | 'first_name': 'Jane', 116 | 'last_name': 'Doe', 117 | }) 118 | assert_last_span('resource', 'dynamodb', 'put_item', tracer, response) 119 | 120 | response = users.get_item(Key={'username': 'janedoe'}) 121 | user = response['Item'] 122 | assert user['first_name'] == 'Jane' 123 | assert user['last_name'] == 'Doe' 124 | assert_last_span('resource', 'dynamodb', 'get_item', tracer, response) 125 | 126 | try: 127 | dynamodb.Table('test').delete_item(Key={'username': 'janedoe'}) 128 | except ClientError as error: 129 | response = error.response 130 | assert_last_span('resource', 'dynamodb', 'delete_item', tracer, response) 131 | 132 | response = users.creation_date_time 133 | assert isinstance(response, datetime.datetime) 134 | assert_last_span('resource', 'dynamodb', 'describe_table', tracer) 135 | 136 | 137 | def _test_s3(s3, tracer): 138 | fileobj = io.BytesIO(b'test data') 139 | bucket = 'test-bucket' 140 | 141 | response = s3.create_bucket(Bucket=bucket) 142 | assert_last_span('client', 's3', 'create_bucket', tracer, response) 143 | 144 | response = s3.upload_fileobj(fileobj, bucket, 'test.txt') 145 | assert_last_span('client', 's3', 'upload_fileobj', tracer, response) 146 | 147 | 148 | def is_service_running(endpoint_url, expected_status_code): 149 | try: 150 | # feel free to suggest better solution for this check 151 | response = requests.get(endpoint_url, timeout=1) 152 | return response.status_code == expected_status_code 153 | except requests.exceptions.ConnectionError: 154 | return False 155 | 156 | 157 | def is_dynamodb_running(): 158 | return is_service_running(DYNAMODB_ENDPOINT_URL, 502) 159 | 160 | 161 | def is_s3_running(): 162 | return is_service_running(S3_ENDPOINT_URL, 200) 163 | 164 | 165 | def is_moto_presented(): 166 | try: 167 | import moto 168 | return True 169 | except ImportError: 170 | return False 171 | 172 | 173 | @pytest.mark.skipif(not is_dynamodb_running(), 174 | reason='DynamoDB is not running or cannot connect') 175 | def test_boto3_dynamodb(thread_safe_tracer, dynamodb): 176 | _test_dynamodb(dynamodb, thread_safe_tracer) 177 | 178 | 179 | @pytest.mark.skipif(not is_moto_presented(), 180 | reason='moto module is not presented') 181 | def test_boto3_dynamodb_with_moto(thread_safe_tracer, dynamodb_mock): 182 | _test_dynamodb(dynamodb_mock, thread_safe_tracer) 183 | 184 | 185 | @pytest.mark.skipif(not is_s3_running(), 186 | reason='S3 is not running or cannot connect') 187 | def test_boto3_s3(s3, thread_safe_tracer): 188 | _test_s3(s3, thread_safe_tracer) 189 | 190 | 191 | @pytest.mark.skipif(not is_moto_presented(), 192 | reason='moto module is not presented') 193 | def test_boto3_s3_with_moto(s3_mock, thread_safe_tracer): 194 | _test_s3(s3_mock, thread_safe_tracer) 195 | 196 | 197 | @testfixtures.log_capture() 198 | def test_boto3_s3_missing_func_instrumentation(capture): 199 | class Patcher(boto3_hooks.Boto3Patcher): 200 | S3_FUNCTIONS_TO_INSTRUMENT = 'missing_func', 201 | 202 | Patcher().install_patches() 203 | capture.check(('root', 'WARNING', 'S3 function missing_func not found')) 204 | 205 | 206 | @mock.patch.object(boto3_hooks, 'patcher') 207 | def test_set_custom_patcher(default_patcher): 208 | patcher = mock.Mock() 209 | boto3_hooks.set_patcher(patcher) 210 | 211 | assert boto3_hooks.patcher is not default_patcher 212 | assert boto3_hooks.patcher is patcher 213 | 214 | boto3_hooks.install_patches() 215 | boto3_hooks.reset_patches() 216 | 217 | patcher.install_patches.assert_called_once() 218 | patcher.reset_patches.assert_called_once() 219 | -------------------------------------------------------------------------------- /tests/opentracing_instrumentation/test_celery.py: -------------------------------------------------------------------------------- 1 | import celery as celery_module 2 | import mock 3 | import pytest 4 | 5 | from celery import Celery 6 | from celery.signals import ( 7 | before_task_publish, after_task_publish, task_postrun 8 | ) 9 | from celery.states import SUCCESS, FAILURE 10 | from celery.worker import state as celery_worker_state 11 | from kombu import Connection 12 | from opentracing.ext import tags 13 | 14 | from opentracing_instrumentation.client_hooks import celery as celery_hooks 15 | 16 | 17 | CELERY_3 = celery_module.__version__.split('.', 1)[0] == '3' 18 | 19 | 20 | @pytest.fixture(autouse=True, scope='module') 21 | def patch_celery(): 22 | celery_hooks.install_patches() 23 | try: 24 | yield 25 | finally: 26 | celery_hooks.reset_patches() 27 | 28 | 29 | def assert_span(span, result, operation, span_kind): 30 | assert span.operation_name == 'Celery:{}:foo'.format(operation) 31 | assert span.tags.get(tags.SPAN_KIND) == span_kind 32 | assert span.tags.get(tags.COMPONENT) == 'Celery' 33 | assert span.tags.get('celery.task_name') == 'foo' 34 | assert span.tags.get('celery.task_id') == result.task_id 35 | 36 | 37 | @mock.patch( 38 | 'celery.worker.job.logger' if CELERY_3 else 'celery.app.trace.logger' 39 | ) 40 | def _test_foo_task(celery, task_error, celery_logger): 41 | 42 | @celery.task(name='foo') 43 | def foo(): 44 | foo.called = True 45 | if task_error: 46 | raise ValueError('Task error') 47 | foo.called = False 48 | 49 | result = foo.delay() 50 | assert foo.called 51 | if task_error: 52 | assert result.status == FAILURE 53 | if not ( 54 | CELERY_3 and celery.conf.defaults[0].get('CELERY_ALWAYS_EAGER') 55 | ): 56 | celery_logger.log.assert_called_once() 57 | else: 58 | assert result.status == SUCCESS 59 | celery_logger.log.assert_not_called() 60 | 61 | return result 62 | 63 | 64 | def _test_with_instrumented_client(celery, tracer, task_error): 65 | result = _test_foo_task(celery, task_error) 66 | 67 | span_server, span_client = tracer.recorder.get_spans() 68 | assert span_client.parent_id is None 69 | assert span_client.context.trace_id == span_server.context.trace_id 70 | assert span_client.context.span_id == span_server.parent_id 71 | 72 | assert_span(span_client, result, 'apply_async', tags.SPAN_KIND_RPC_CLIENT) 73 | assert_span(span_server, result, 'run', tags.SPAN_KIND_RPC_SERVER) 74 | 75 | 76 | @mock.patch( 77 | 'celery.app.task.Task.apply_async', new=celery_hooks._task_apply_async 78 | ) 79 | def _test_with_regular_client(celery, tracer, task_error): 80 | before_task_publish.disconnect(celery_hooks.before_task_publish_handler) 81 | try: 82 | result = _test_foo_task(celery, task_error) 83 | 84 | spans = tracer.recorder.get_spans() 85 | assert len(spans) == 1 86 | 87 | span = spans[0] 88 | assert span.parent_id is None 89 | assert_span(span, result, 'run', tags.SPAN_KIND_RPC_SERVER) 90 | finally: 91 | before_task_publish.connect(celery_hooks.before_task_publish_handler) 92 | 93 | 94 | TEST_METHODS = _test_with_instrumented_client, _test_with_regular_client 95 | 96 | 97 | def is_rabbitmq_running(): 98 | try: 99 | Connection('amqp://guest:guest@127.0.0.1:5672//').connect() 100 | return True 101 | except: 102 | return False 103 | 104 | 105 | @pytest.mark.skipif(not is_rabbitmq_running(), 106 | reason='RabbitMQ is not running or cannot connect') 107 | @pytest.mark.parametrize('task_error', (False, True)) 108 | @pytest.mark.parametrize('test_method', TEST_METHODS) 109 | def test_celery_with_rabbitmq(test_method, tracer, task_error): 110 | celery = Celery( 111 | 'test', 112 | 113 | # For Celery 3.x we have to use rpc:// to get the results 114 | # because with Redis we can get only PENDING for the status. 115 | # For Celery 4.x we need redis:// since with RPC we can 116 | # correctly assert status only for the first one task. 117 | # Feel free to suggest a better solution here. 118 | backend='rpc://' if CELERY_3 else 'redis://', 119 | 120 | # avoiding CDeprecationWarning 121 | changes={ 122 | 'CELERY_ACCEPT_CONTENT': ['pickle', 'json'], 123 | } 124 | ) 125 | 126 | @after_task_publish.connect 127 | def run_worker(**kwargs): 128 | celery_worker_state.should_stop = False 129 | after_task_publish.disconnect(run_worker) 130 | worker = celery.Worker(concurrency=1, 131 | pool_cls='solo', 132 | use_eventloop=False, 133 | prefetch_multiplier=1, 134 | quiet=True, 135 | without_heartbeat=True) 136 | 137 | @task_postrun.connect 138 | def stop_worker_soon(**kwargs): 139 | celery_worker_state.should_stop = True 140 | task_postrun.disconnect(stop_worker_soon) 141 | if hasattr(worker.consumer, '_pending_operations'): 142 | # Celery 4.x 143 | 144 | def stop_worker(): 145 | # avoiding AttributeError that makes tests noisy 146 | worker.consumer.connection.drain_events = mock.Mock() 147 | 148 | worker.stop() 149 | 150 | # worker must be stopped not earlier than 151 | # data exchange with RabbitMQ is completed 152 | worker.consumer._pending_operations.insert(0, stop_worker) 153 | else: 154 | # Celery 3.x 155 | worker.stop() 156 | 157 | worker.start() 158 | 159 | test_method(celery, tracer, task_error) 160 | 161 | 162 | @pytest.fixture 163 | def celery_eager(): 164 | celery = Celery('test') 165 | celery.config_from_object({ 166 | 'task_always_eager': True, # Celery 4.x 167 | 'CELERY_ALWAYS_EAGER': True, # Celery 3.x 168 | }) 169 | return celery 170 | 171 | 172 | @pytest.mark.parametrize('task_error', (False, True)) 173 | @pytest.mark.parametrize('test_method', TEST_METHODS) 174 | def test_celery_eager(test_method, celery_eager, tracer, task_error): 175 | test_method(celery_eager, tracer, task_error) 176 | 177 | 178 | @mock.patch.object(celery_hooks, 'patcher') 179 | def test_set_custom_patcher(default_patcher): 180 | patcher = mock.Mock() 181 | celery_hooks.set_patcher(patcher) 182 | 183 | assert celery_hooks.patcher is not default_patcher 184 | assert celery_hooks.patcher is patcher 185 | 186 | celery_hooks.install_patches() 187 | celery_hooks.reset_patches() 188 | 189 | patcher.install_patches.assert_called_once() 190 | patcher.reset_patches.assert_called_once() 191 | -------------------------------------------------------------------------------- /tests/opentracing_instrumentation/test_install_client_hooks.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2017 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | from __future__ import absolute_import 21 | 22 | from mock import patch 23 | import pytest 24 | 25 | from opentracing_instrumentation.client_hooks import install_client_interceptors 26 | from opentracing_instrumentation.interceptors import OpenTracingInterceptor 27 | 28 | 29 | class TestClientInterceptor(OpenTracingInterceptor): 30 | 31 | def process(self, request, span): 32 | pass 33 | 34 | 35 | def Any(cls): 36 | 37 | class Any(cls): 38 | 39 | def __eq__(self, other): 40 | return isinstance(other, cls) 41 | 42 | return Any() 43 | 44 | 45 | def test_install_client_interceptors_non_list_arg(): 46 | with pytest.raises(ValueError): 47 | install_client_interceptors('abc') 48 | 49 | 50 | def test_install_client_interceptors(): 51 | # TODO: can this path be obtained programmatically? 52 | path_to_interceptor = ('tests.opentracing_instrumentation.' 53 | 'test_install_client_hooks.TestClientInterceptor') 54 | with patch('opentracing_instrumentation.http_client.ClientInterceptors') as MockClientInterceptors: 55 | install_client_interceptors([path_to_interceptor]) 56 | 57 | MockClientInterceptors.append.assert_called_once_with(Any(TestClientInterceptor)) 58 | -------------------------------------------------------------------------------- /tests/opentracing_instrumentation/test_local_span.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | from __future__ import absolute_import 21 | 22 | from mock import patch 23 | 24 | import opentracing 25 | from opentracing.scope_managers import ThreadLocalScopeManager 26 | from opentracing_instrumentation.local_span import func_span 27 | from opentracing_instrumentation.client_hooks._dbapi2 import db_span, _COMMIT 28 | from opentracing_instrumentation.client_hooks._singleton import singleton 29 | from opentracing_instrumentation import span_in_context 30 | 31 | 32 | @patch('opentracing.tracer', new=opentracing.Tracer(ThreadLocalScopeManager())) 33 | def test_func_span_without_parent(): 34 | with func_span('test', require_active_trace=False) as span: 35 | assert span is not None 36 | with func_span('test', require_active_trace=True) as span: 37 | assert span is None 38 | 39 | 40 | def test_func_span(): 41 | tracer = opentracing.tracer 42 | span = tracer.start_span(operation_name='parent') 43 | with span_in_context(span=span): 44 | with func_span('test') as child_span: 45 | assert span is child_span 46 | with func_span('test', tags={'x': 'y'}) as child_span: 47 | assert span is child_span 48 | 49 | 50 | @patch('opentracing.tracer', new=opentracing.Tracer(ThreadLocalScopeManager())) 51 | def test_db_span_without_parent(): 52 | with db_span('test', 'MySQLdb') as span: 53 | assert span is None 54 | 55 | 56 | def test_db_span(): 57 | tracer = opentracing.tracer 58 | span = tracer.start_span(operation_name='parent') 59 | with span_in_context(span=span): 60 | with db_span(_COMMIT, 'MySQLdb') as child_span: 61 | assert span is child_span 62 | with db_span('select * from X', 'MySQLdb') as child_span: 63 | assert span is child_span 64 | 65 | 66 | def test_singleton(): 67 | data = [1] 68 | 69 | @singleton 70 | def increment(): 71 | data[0] += 1 72 | 73 | @singleton 74 | def increment2(func=None): 75 | data[0] += 1 76 | if func: 77 | func() 78 | 79 | increment() 80 | assert data[0] == 2 81 | increment() 82 | assert data[0] == 2 83 | increment.__original_func() 84 | assert data[0] == 3 85 | 86 | # recursive call does increment twice 87 | increment2(func=lambda: increment2()) 88 | assert data[0] == 5 89 | # but not on the second round 90 | increment2(func=lambda: increment2()) 91 | assert data[0] == 5 92 | -------------------------------------------------------------------------------- /tests/opentracing_instrumentation/test_middleware.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from __future__ import absolute_import 22 | import unittest 23 | import mock 24 | from opentracing_instrumentation.http_server import TornadoRequestWrapper 25 | import tornado.httputil 26 | import opentracing 27 | from opentracing import Format 28 | from opentracing_instrumentation import http_server 29 | from opentracing_instrumentation import config 30 | 31 | import pytest 32 | 33 | 34 | @pytest.mark.parametrize('with_peer_tags,with_context', [ 35 | (True, True), 36 | (False, True), 37 | (True, False), 38 | (False, False), 39 | ]) 40 | def test_middleware(with_peer_tags, with_context): 41 | """ 42 | Tests http_server.before_request call 43 | 44 | :param with_peer_tags: whether Request object exposes peer properties 45 | :param with_context: whether the inbound request contains tracing context 46 | :return: 47 | """ 48 | request = mock.MagicMock() 49 | request.method = 'GET' 50 | request.full_url = 'http://localhost:12345/test' 51 | request.operation = 'my-test' 52 | if with_peer_tags: 53 | request.remote_ip = 'localhost' 54 | request.remote_port = 12345 55 | request.caller_name = 'test_middleware' 56 | else: 57 | request.remote_ip = None 58 | request.remote_port = None 59 | request.caller_name = None 60 | 61 | tracer = opentracing.tracer 62 | if with_context: 63 | span_ctx = mock.MagicMock() 64 | else: 65 | span_ctx = None 66 | p_extract = mock.patch.object(tracer, 'extract', return_value=span_ctx) 67 | span = mock.MagicMock() 68 | p_start_span = mock.patch.object(tracer, 'start_span', return_value=span) 69 | with p_extract as extract_call, p_start_span as start_span_call: 70 | span2 = http_server.before_request(request=request, tracer=tracer) 71 | assert span == span2 72 | extract_call.assert_called_with( 73 | format=Format.HTTP_HEADERS, carrier={}) 74 | expected_tags = { 75 | 'http.method': 'GET', 76 | 'http.url': 'http://localhost:12345/test', 77 | 'span.kind': 'server', 78 | } 79 | if with_peer_tags: 80 | expected_tags.update({ 81 | 'peer.service': 'test_middleware', 82 | 'span.kind': 'server', 83 | 'peer.ipv4': 'localhost', 84 | 'peer.port': 12345, 85 | }) 86 | start_span_call.assert_called_with( 87 | operation_name='my-test', 88 | tags=expected_tags, 89 | child_of=span_ctx 90 | ) 91 | 92 | 93 | class AbstractRequestWrapperTest(unittest.TestCase): 94 | def test_not_implemented(self): 95 | request = http_server.AbstractRequestWrapper() 96 | self.assertRaises(NotImplementedError, lambda: request.full_url) 97 | self.assertRaises(NotImplementedError, lambda: request.headers) 98 | self.assertRaises(NotImplementedError, lambda: request.method) 99 | self.assertRaises(NotImplementedError, lambda: request.remote_ip) 100 | assert request.remote_port is None 101 | assert request.server_port is None 102 | 103 | def test_operation(self): 104 | request = http_server.AbstractRequestWrapper() 105 | with mock.patch('opentracing_instrumentation.http_server' 106 | '.AbstractRequestWrapper.method', 107 | new_callable=mock.PropertyMock) as method: 108 | method.return_value = 'my-test-method' 109 | assert request.operation == 'my-test-method' 110 | 111 | def test_caller_name(self): 112 | request = http_server.AbstractRequestWrapper() 113 | assert request.caller_name is None 114 | with mock.patch.object(config.CONFIG, 'caller_name_headers', 115 | ['caller']): 116 | headers = tornado.httputil.HTTPHeaders({'caller': 'test-caller'}) 117 | with mock.patch('opentracing_instrumentation.http_server' 118 | '.AbstractRequestWrapper.headers', 119 | new_callable=mock.PropertyMock) as headers_prop: 120 | headers_prop.return_value = headers 121 | assert request.caller_name == 'test-caller' 122 | headers_prop.return_value = {} 123 | assert request.caller_name is None 124 | 125 | 126 | class TornadoRequestWrapperTest(unittest.TestCase): 127 | def test_all(self): 128 | request = mock.MagicMock() 129 | request.full_url = mock.MagicMock(return_value='sample full url') 130 | request.headers = {'a': 'b'} 131 | request.method = 'sample method' 132 | request.remote_ip = 'sample remote ip' 133 | wrapper = TornadoRequestWrapper(request) 134 | assert 'sample full url' == wrapper.full_url 135 | assert {'a': 'b'} == wrapper.headers 136 | assert 'sample method' == wrapper.method 137 | assert 'sample remote ip' == wrapper.remote_ip 138 | 139 | 140 | def find_tag(span, key): 141 | for tag in span.tags: 142 | if key == tag.key: 143 | return tag.value 144 | return None 145 | -------------------------------------------------------------------------------- /tests/opentracing_instrumentation/test_missing_modules_handling.py: -------------------------------------------------------------------------------- 1 | import os 2 | from importlib import import_module 3 | 4 | import pytest 5 | 6 | from opentracing_instrumentation.client_hooks import install_all_patches 7 | 8 | 9 | HOOKS_WITH_PATCHERS = ('boto3', 'celery', 'mysqldb', 'sqlalchemy', 'requests') 10 | 11 | 12 | @pytest.mark.skipif(os.environ.get('TEST_MISSING_MODULES_HANDLING') != '1', 13 | reason='Not this time') 14 | def test_missing_modules_handling(): 15 | install_all_patches() 16 | for name in HOOKS_WITH_PATCHERS: 17 | hook_module = import_module( 18 | 'opentracing_instrumentation.client_hooks.' + name 19 | ) 20 | assert not hook_module.patcher.applicable 21 | -------------------------------------------------------------------------------- /tests/opentracing_instrumentation/test_mysqldb.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | 5 | from opentracing.ext import tags 6 | from sqlalchemy import create_engine 7 | from sqlalchemy.orm import sessionmaker 8 | 9 | from opentracing_instrumentation.client_hooks import mysqldb as mysqldb_hooks 10 | from opentracing_instrumentation.request_context import span_in_context 11 | from .sql_common import metadata, User 12 | 13 | 14 | SKIP_REASON_PYTHON_3 = 'MySQLdb is not compatible with Python 3' 15 | SKIP_REASON_CONNECTION = 'MySQL is not running or cannot connect' 16 | MYSQL_CONNECTION_STRING = 'mysql://root@127.0.0.1/test' 17 | 18 | 19 | @pytest.fixture 20 | def session(): 21 | Session = sessionmaker() 22 | engine = create_engine(MYSQL_CONNECTION_STRING) 23 | Session.configure(bind=engine) 24 | metadata.create_all(engine) 25 | try: 26 | yield Session() 27 | except: 28 | pass 29 | 30 | 31 | @pytest.fixture(autouse=True, scope='module') 32 | def patch_sqlalchemy(): 33 | mysqldb_hooks.install_patches() 34 | try: 35 | yield 36 | finally: 37 | mysqldb_hooks.reset_patches() 38 | 39 | 40 | def is_mysql_running(): 41 | try: 42 | import MySQLdb 43 | with MySQLdb.connect(host='127.0.0.1', user='root'): 44 | pass 45 | return True 46 | except: 47 | return False 48 | 49 | 50 | def assert_span(span, operation, parent=None): 51 | assert span.operation_name == 'MySQLdb:' + operation 52 | assert span.tags.get(tags.SPAN_KIND) == tags.SPAN_KIND_RPC_CLIENT 53 | if parent: 54 | assert span.parent_id == parent.context.span_id 55 | assert span.context.trace_id == parent.context.trace_id 56 | else: 57 | assert span.parent_id is None 58 | 59 | 60 | @pytest.mark.skipif(not is_mysql_running(), reason=SKIP_REASON_CONNECTION) 61 | @pytest.mark.skipif(sys.version_info.major == 3, reason=SKIP_REASON_PYTHON_3) 62 | def test_db(tracer, session): 63 | root_span = tracer.start_span('root-span') 64 | 65 | # span recording works for regular operations within a context only 66 | with span_in_context(root_span): 67 | user = User(name='user', fullname='User', password='password') 68 | session.add(user) 69 | session.commit() 70 | 71 | spans = tracer.recorder.get_spans() 72 | assert len(spans) == 4 73 | 74 | connect_span, insert_span, commit_span, rollback_span = spans 75 | assert_span(connect_span, 'Connect') 76 | assert_span(insert_span, 'INSERT', root_span) 77 | assert_span(commit_span, 'commit', root_span) 78 | assert_span(rollback_span, 'rollback', root_span) 79 | -------------------------------------------------------------------------------- /tests/opentracing_instrumentation/test_postgres.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # Copyright (c) 2018,2019 Uber Technologies, Inc. 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy 5 | # of this software and associated documentation files (the "Software"), to deal 6 | # in the Software without restriction, including without limitation the rights 7 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | # copies of the Software, and to permit persons to whom the Software is 9 | # furnished to do so, subject to the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be included in 12 | # all copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | # THE SOFTWARE. 21 | import mock 22 | import psycopg2 as psycopg2_client 23 | import pytest 24 | from psycopg2 import extensions as pg_extensions, sql 25 | from sqlalchemy import create_engine 26 | from sqlalchemy.orm import sessionmaker 27 | 28 | from opentracing_instrumentation.client_hooks import psycopg2 29 | from .sql_common import metadata, User 30 | 31 | 32 | SKIP_REASON = 'Postgres is not running or cannot connect' 33 | POSTGRES_CONNECTION_STRING = 'postgresql://postgres@localhost/test' 34 | 35 | 36 | @pytest.fixture 37 | def engine(): 38 | try: 39 | yield create_engine(POSTGRES_CONNECTION_STRING) 40 | except: 41 | pass 42 | 43 | 44 | @pytest.fixture 45 | def session(): 46 | Session = sessionmaker() 47 | Session.configure(bind=engine) 48 | try: 49 | yield Session() 50 | except: 51 | pass 52 | 53 | 54 | @pytest.fixture(autouse=True) 55 | def patch_postgres(): 56 | psycopg2.install_patches() 57 | 58 | 59 | @pytest.fixture() 60 | def connection(): 61 | return psycopg2_client.connect(POSTGRES_CONNECTION_STRING) 62 | 63 | 64 | def is_postgres_running(): 65 | try: 66 | with psycopg2_client.connect(POSTGRES_CONNECTION_STRING): 67 | pass 68 | return True 69 | except: 70 | return False 71 | 72 | 73 | @pytest.mark.skipif(not is_postgres_running(), reason=SKIP_REASON) 74 | def test_db(tracer, engine, session): 75 | metadata.create_all(engine) 76 | user1 = User(name='user1', fullname='User 1', password='password') 77 | user2 = User(name='user2', fullname='User 2', password='password') 78 | session.add(user1) 79 | session.add(user2) 80 | # If the test does not raised an error, it is passing 81 | 82 | 83 | @pytest.mark.skipif(not is_postgres_running(), reason=SKIP_REASON) 84 | def test_connection_proxy(connection): 85 | assert isinstance(connection, psycopg2.ConnectionWrapper) 86 | 87 | # Test that connection properties are proxied by 88 | # ContextManagerConnectionWrapper 89 | assert connection.closed == 0 90 | 91 | 92 | def _test_register_type(connection): 93 | assert not connection.string_types 94 | 95 | test_type = pg_extensions.UNICODE 96 | pg_extensions.register_type(test_type, connection) 97 | 98 | assert connection.string_types 99 | for string_type in connection.string_types.values(): 100 | assert string_type is test_type 101 | 102 | 103 | @pytest.mark.skipif(not is_postgres_running(), reason=SKIP_REASON) 104 | def test_register_type_for_wrapped_connection(connection): 105 | _test_register_type(connection) 106 | 107 | 108 | @pytest.mark.skipif(not is_postgres_running(), reason=SKIP_REASON) 109 | def test_register_type_for_raw_connection(connection): 110 | _test_register_type(connection.__wrapped__) 111 | 112 | 113 | @mock.patch.object(psycopg2, 'psycopg2') 114 | @mock.patch.object(psycopg2, 'ConnectionFactory') 115 | def test_install_patches_skip(factory_mock, *mocks): 116 | del psycopg2.psycopg2 117 | psycopg2.install_patches.reset() 118 | psycopg2.install_patches() 119 | factory_mock.assert_not_called() 120 | 121 | 122 | @pytest.mark.skipif(not is_postgres_running(), reason=SKIP_REASON) 123 | @pytest.mark.parametrize('method', ('execute', 'executemany', )) 124 | @pytest.mark.parametrize('query', [ 125 | # plain string 126 | '''SELECT %s;''', 127 | # bytes 128 | b'SELECT %s;', 129 | # Unicode 130 | u'''SELECT %s; -- привет''', 131 | # Composed 132 | sql.Composed([sql.SQL('''SELECT %s;''')]), 133 | # Identifier 134 | sql.SQL('''SELECT %s FROM {} LIMIT 1;''').format( 135 | sql.Identifier('pg_catalog', 'pg_database') 136 | ), 137 | # Literal 138 | sql.SQL('''SELECT {}''').format(sql.Literal('foobar')), 139 | # Placeholder 140 | sql.SQL('''SELECT {}''').format(sql.Placeholder()) 141 | ], ids=('str', 'bytes', 'unicode', 'Composed', 142 | 'Identifier', 'Literal', 'Placeholder')) 143 | def test_execute_sql(tracer, engine, connection, method, query): 144 | 145 | # Check that executing with objects of ``sql.Composable`` subtypes doesn't 146 | # raise any exceptions. 147 | 148 | metadata.create_all(engine) 149 | with tracer.start_active_span('test'): 150 | cur = connection.cursor() 151 | params = ('foobar', ) 152 | if method == 'executemany': 153 | params = [params] 154 | getattr(cur, method)(query, params) 155 | last_span = tracer.recorder.get_spans()[-1] 156 | assert last_span.operation_name == 'psycopg2:SELECT' 157 | assert last_span.tags['sql.params'] == params 158 | -------------------------------------------------------------------------------- /tests/opentracing_instrumentation/test_redis.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from builtins import object 22 | import redis 23 | import random 24 | 25 | import opentracing 26 | from opentracing.ext import tags 27 | 28 | from opentracing_instrumentation.client_hooks import strict_redis 29 | 30 | import pytest 31 | 32 | 33 | VAL = b'opentracing is fun and easy!' 34 | 35 | 36 | @pytest.yield_fixture(autouse=True, scope='module') 37 | def patch_redis(): 38 | strict_redis.install_patches() 39 | try: 40 | yield 41 | finally: 42 | strict_redis.reset_patches() 43 | 44 | 45 | @pytest.fixture() 46 | def client(): 47 | return redis.StrictRedis() 48 | 49 | 50 | @pytest.fixture() 51 | def key(): 52 | return '%x' % random.randint(1, 10000000) 53 | 54 | 55 | class Span(object): 56 | 57 | def __init__(self): 58 | self.tags = {} 59 | 60 | def set_tag(self, key, val): 61 | self.tags[key] = val 62 | 63 | def __enter__(self): 64 | return self 65 | 66 | def __exit__(self, *args, **kwargs): 67 | pass 68 | 69 | 70 | class StartSpan(object): 71 | 72 | def __init__(self, span): 73 | self.kwargs = None 74 | self.args = None 75 | self.span = span 76 | 77 | def __call__(self, *args, **kwargs): 78 | self.kwargs = kwargs 79 | self.args = args 80 | return self.span 81 | 82 | 83 | def spans(monkeypatch): 84 | span = Span() 85 | start_span = StartSpan(span) 86 | monkeypatch.setattr(opentracing.tracer, 'start_span', start_span) 87 | return span, start_span 88 | 89 | 90 | def check_span(span, key): 91 | assert span.tags['redis.key'] == key 92 | assert span.tags[tags.SPAN_KIND] == tags.SPAN_KIND_RPC_CLIENT 93 | assert span.tags[tags.PEER_SERVICE] == 'redis' 94 | 95 | 96 | def is_redis_running(): 97 | try: 98 | return redis.StrictRedis().ping() 99 | except: 100 | return False 101 | 102 | 103 | @pytest.mark.skipif(not is_redis_running(), reason='Redis is not running') 104 | def test_get(monkeypatch, client, key): 105 | span, start_span = spans(monkeypatch) 106 | client.get(key) 107 | assert start_span.kwargs['operation_name'] == 'redis:GET' 108 | check_span(span, key) 109 | client.get(name=key) 110 | 111 | 112 | @pytest.mark.skipif(not is_redis_running(), reason='Redis is not running') 113 | def test_set(monkeypatch, client, key): 114 | span, start_span = spans(monkeypatch) 115 | client.set(key, VAL) 116 | assert start_span.kwargs['operation_name'] == 'redis:SET' 117 | check_span(span, key) 118 | assert client.get(key) == VAL 119 | client.set(name=key, value=VAL, ex=1) 120 | client.set(key, VAL, 1) 121 | 122 | 123 | @pytest.mark.skipif(not is_redis_running(), reason='Redis is not running') 124 | def test_setex(monkeypatch, client, key): 125 | span, start_span = spans(monkeypatch) 126 | client.setex(key, 60, VAL) 127 | assert start_span.kwargs['operation_name'] == 'redis:SETEX' 128 | check_span(span, key) 129 | assert client.get(key) == VAL 130 | client.setex(name=key, time=60, value=VAL) 131 | 132 | 133 | @pytest.mark.skipif(not is_redis_running(), reason='Redis is not running') 134 | def test_setnx(monkeypatch, client, key): 135 | span, start_span = spans(monkeypatch) 136 | client.setnx(key, VAL) 137 | assert start_span.kwargs['operation_name'] == 'redis:SETNX' 138 | check_span(span, key) 139 | assert client.get(key) == VAL 140 | client.setnx(name=key, value=VAL) 141 | 142 | 143 | @pytest.mark.skipif(not is_redis_running(), reason='Redis is not running') 144 | def test_key_is_cleared(monkeypatch, client, key): 145 | # first do a GET that sets the key 146 | span, start_span = spans(monkeypatch) 147 | client.get(key) 148 | assert span.tags['redis.key'] == key 149 | 150 | # now do an ECHO, and make sure redis.key is not used 151 | span, start_span = spans(monkeypatch) 152 | client.echo('hello world') 153 | assert 'redis.key' not in span.tags 154 | -------------------------------------------------------------------------------- /tests/opentracing_instrumentation/test_requests.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | import threading 22 | 23 | import mock 24 | import pytest 25 | import requests 26 | import tornado.httpserver 27 | import tornado.ioloop 28 | import tornado.web 29 | 30 | from opentracing_instrumentation.client_hooks.requests import patcher 31 | from opentracing_instrumentation.request_context import span_in_context 32 | try: 33 | import asyncio 34 | asyncio_available = True 35 | except ImportError: 36 | asyncio_available = False 37 | 38 | 39 | @pytest.fixture(name='response_handler_hook') 40 | def patch_requests(hook): 41 | if hook: 42 | # using regular method instead of mock.Mock() to be sure 43 | # that it works as expected with Python 2.7 44 | def response_handler_hook(response, span): 45 | response_handler_hook.called_with = response, span 46 | response_handler_hook.called_with = None 47 | else: 48 | response_handler_hook = None 49 | 50 | patcher.install_patches() 51 | patcher.set_response_handler_hook(response_handler_hook) 52 | try: 53 | yield response_handler_hook 54 | finally: 55 | patcher.reset_patches() 56 | 57 | 58 | @pytest.fixture 59 | def tornado_url(request, base_url, _unused_port): 60 | 61 | class Handler(tornado.web.RequestHandler): 62 | def get(self): 63 | self.write(self.request.headers['ot-tracer-traceid']) 64 | app.headers = self.request.headers 65 | 66 | app = tornado.web.Application([('/', Handler)]) 67 | 68 | def run_http_server(): 69 | if asyncio_available: 70 | # In python 3+ we should make ioloop in new thread explicitly. 71 | asyncio.set_event_loop(asyncio.new_event_loop()) 72 | io_loop = tornado.ioloop.IOLoop.current() 73 | http_server = tornado.httpserver.HTTPServer(app) 74 | http_server.add_socket(_unused_port[0]) 75 | 76 | def stop(): 77 | http_server.stop() 78 | io_loop.add_callback(io_loop.stop) 79 | thread.join() 80 | 81 | # finalizer should be added before starting of the IO loop 82 | request.addfinalizer(stop) 83 | 84 | io_loop.start() 85 | 86 | # running an http server in a separate thread in purpose 87 | # to make it accessible for the requests from the current thread 88 | thread = threading.Thread(target=run_http_server) 89 | thread.start() 90 | 91 | return base_url + '/' 92 | 93 | 94 | def _test_requests(url, root_span, tracer, response_handler_hook): 95 | if root_span: 96 | root_span = tracer.start_span('root-span') 97 | else: 98 | root_span = None 99 | 100 | with span_in_context(span=root_span): 101 | response = requests.get(url) 102 | 103 | assert len(tracer.recorder.get_spans()) == 1 104 | 105 | span = tracer.recorder.get_spans()[0] 106 | assert span.tags.get('span.kind') == 'client' 107 | assert span.tags.get('http.url') == url 108 | 109 | # verify trace-id was correctly injected into headers 110 | trace_id = '%x' % span.context.trace_id 111 | assert response.text == trace_id 112 | 113 | if response_handler_hook: 114 | assert response_handler_hook.called_with == (response, span) 115 | 116 | 117 | @pytest.mark.parametrize('scheme', ('http', 'https')) 118 | @pytest.mark.parametrize('root_span', (True, False)) 119 | @pytest.mark.parametrize('hook', (True, False)) 120 | @mock.patch('requests.adapters.HTTPAdapter.cert_verify') 121 | @mock.patch('requests.adapters.HTTPAdapter.get_connection') 122 | def test_requests_with_mock(get_connection_mock, cert_verify_mock, 123 | scheme, root_span, tracer, 124 | response_handler_hook): 125 | 126 | def urlopen(headers, **kwargs): 127 | stream_mock = mock.MagicMock( 128 | return_value=[headers['ot-tracer-traceid'].encode()] 129 | ) 130 | return mock.MagicMock(stream=stream_mock) 131 | 132 | get_connection_mock.return_value.urlopen = urlopen 133 | url = scheme + '://example.com/' 134 | _test_requests(url, root_span, tracer, response_handler_hook) 135 | 136 | 137 | @pytest.mark.parametrize('root_span', (True, False)) 138 | @pytest.mark.parametrize('hook', (True, False)) 139 | def test_requests_with_tornado(tornado_url, root_span, tracer, 140 | response_handler_hook): 141 | _test_requests(tornado_url, root_span, tracer, response_handler_hook) 142 | -------------------------------------------------------------------------------- /tests/opentracing_instrumentation/test_sqlalchemy.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import sqlalchemy.engine.url 3 | from opentracing.ext import tags 4 | from sqlalchemy import create_engine 5 | from sqlalchemy.orm import sessionmaker 6 | 7 | from opentracing_instrumentation.client_hooks import ( 8 | sqlalchemy as sqlalchemy_hooks 9 | ) 10 | from .sql_common import metadata, User 11 | 12 | 13 | @pytest.fixture 14 | def session(): 15 | Session = sessionmaker() 16 | engine = create_engine("sqlite://") 17 | Session.configure(bind=engine) 18 | metadata.create_all(engine) 19 | try: 20 | yield Session() 21 | except: 22 | pass 23 | 24 | 25 | @pytest.fixture(autouse=True, scope='module') 26 | def patch_sqlalchemy(): 27 | sqlalchemy_hooks.install_patches() 28 | try: 29 | yield 30 | finally: 31 | sqlalchemy_hooks.reset_patches() 32 | 33 | 34 | def assert_span(span, operation): 35 | assert span.operation_name == 'SQL ' + operation 36 | assert span.tags.get(tags.SPAN_KIND) == tags.SPAN_KIND_RPC_CLIENT 37 | assert span.tags.get(tags.DATABASE_TYPE) == 'sqlite' 38 | assert span.tags.get(tags.DATABASE_INSTANCE) == 'sqlite://' 39 | assert span.tags.get(tags.COMPONENT) == 'sqlalchemy' 40 | 41 | 42 | def test_db(tracer, session): 43 | user = User(name='user', fullname='User', password='password') 44 | session.add(user) 45 | session.commit() 46 | 47 | spans = tracer.recorder.get_spans() 48 | assert len(spans) == 4 49 | 50 | pragma_span_1, pragma_span_2, create_span, insert_span = spans 51 | assert_span(pragma_span_1, 'PRAGMA') 52 | assert_span(pragma_span_2, 'PRAGMA') 53 | assert_span(create_span, 'CREATE') 54 | assert_span(insert_span, 'INSERT') 55 | 56 | 57 | def test_sqlalchemy_password_sanitization(): 58 | """This test is here as a check against SQLAlchemy to make sure that we 59 | notice if the behavior of ``repr()`` on a URL object changes and starts to 60 | include a password""" 61 | 62 | url = sqlalchemy.engine.url.make_url( 63 | 'mysql://username:password@host/database') 64 | assert 'password' not in repr(url) 65 | assert repr(url) == 'mysql://username:***@host/database' 66 | -------------------------------------------------------------------------------- /tests/opentracing_instrumentation/test_sync_client_hooks.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from __future__ import absolute_import 22 | 23 | from future import standard_library 24 | standard_library.install_aliases() 25 | 26 | import mock 27 | import pytest 28 | import six 29 | if six.PY2: 30 | import urllib2 31 | import urllib.request 32 | from tornado.httputil import HTTPHeaders 33 | 34 | from opentracing_instrumentation.client_hooks import urllib2 as urllib2_hooks 35 | from opentracing_instrumentation.config import CONFIG 36 | from opentracing_instrumentation.request_context import span_in_context 37 | 38 | 39 | @pytest.yield_fixture 40 | def install_hooks(request): 41 | urllibver = request.getfixturevalue('urllibver') 42 | 43 | if urllibver == 'urllib2': 44 | if six.PY3: 45 | yield None 46 | return 47 | module = urllib2 48 | else: 49 | module = urllib.request 50 | 51 | old_opener = module._opener 52 | old_callee_headers = CONFIG.callee_name_headers 53 | old_endpoint_headers = CONFIG.callee_endpoint_headers 54 | 55 | urllib2_hooks.install_patches.__original_func() 56 | CONFIG.callee_name_headers = ['Remote-Loc'] 57 | CONFIG.callee_endpoint_headers = ['Remote-Op'] 58 | 59 | yield module 60 | 61 | module.install_opener(old_opener) 62 | CONFIG.callee_name_headers = old_callee_headers 63 | CONFIG.callee_endpoint_headers = old_endpoint_headers 64 | 65 | 66 | @pytest.mark.parametrize('urllibver,scheme,root_span', [ 67 | ('urllib2', 'http', True), 68 | ('urllib2', 'http', False), 69 | ('urllib2', 'https', True), 70 | ('urllib2', 'https', False), 71 | ('urllib.request', 'http', True), 72 | ('urllib.request', 'http', False), 73 | ('urllib.request', 'https', True), 74 | ('urllib.request', 'https', False), 75 | ]) 76 | def test_urllib2(urllibver, scheme, root_span, install_hooks, tracer): 77 | 78 | module = install_hooks 79 | 80 | if module is None: 81 | pytest.skip('Skipping %s on Py3' % urllibver) 82 | 83 | class Response(object): 84 | def __init__(self): 85 | self.code = 200 86 | self.msg = '' 87 | 88 | def info(self): 89 | return None 90 | 91 | if root_span: 92 | root_span = tracer.start_span('root-span') 93 | else: 94 | root_span = None 95 | 96 | # ideally we should have started a test server and tested with real HTTP 97 | # request, but doing that for https is more difficult, so we mock the 98 | # request sending part. 99 | if urllibver == 'urllib2': 100 | p_do_open = mock.patch( 101 | 'urllib2.AbstractHTTPHandler.do_open', return_value=Response() 102 | ) 103 | else: 104 | cls = module.AbstractHTTPHandler 105 | p_do_open = mock.patch.object( 106 | cls, 'do_open', return_value=Response() 107 | ) 108 | 109 | with p_do_open, span_in_context(span=root_span): 110 | request = module.Request( 111 | '%s://localhost:9777/proxy' % scheme, 112 | headers={ 113 | 'Remote-LOC': 'New New York', 114 | 'Remote-Op': 'antiquing' 115 | }) 116 | resp = module.urlopen(request) 117 | 118 | assert resp.code == 200 119 | assert len(tracer.recorder.get_spans()) == 1 120 | 121 | span = tracer.recorder.get_spans()[0] 122 | assert span.tags.get('span.kind') == 'client' 123 | 124 | # verify trace-id was correctly injected into headers 125 | # we wrap the headers to avoid having to deal with upper/lower case 126 | norm_headers = HTTPHeaders(request.headers) 127 | trace_id_header = norm_headers.get('ot-tracer-traceid') 128 | assert trace_id_header == '%x' % span.context.trace_id 129 | -------------------------------------------------------------------------------- /tests/opentracing_instrumentation/test_thread_safe_request_context.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | import time 22 | 23 | from threading import Thread 24 | from tornado.stack_context import wrap 25 | 26 | from opentracing_instrumentation.request_context import ( 27 | get_current_span, span_in_stack_context, 28 | ) 29 | 30 | 31 | def test__request_context_is_thread_safe(thread_safe_tracer): 32 | """ 33 | Port of Uber's internal tornado-extras (by @sema). 34 | 35 | This test illustrates that the default Tornado's StackContext 36 | is not thread-safe. The test can be made to fail by commenting 37 | out these lines in the ThreadSafeStackContext constructor: 38 | 39 | if hasattr(self, 'contexts'): 40 | # only patch if context exists 41 | self.contexts = LocalContexts() 42 | """ 43 | 44 | num_iterations = 1000 45 | num_workers = 10 46 | exception = [0] 47 | 48 | def async_task(): 49 | time.sleep(0.001) 50 | assert get_current_span() is not None 51 | 52 | class Worker(Thread): 53 | def __init__(self, fn): 54 | super(Worker, self).__init__() 55 | self.fn = fn 56 | 57 | def run(self): 58 | try: 59 | for _ in range(0, num_iterations): 60 | self.fn() 61 | except Exception as e: 62 | exception[0] = e 63 | raise 64 | 65 | with span_in_stack_context(span='span'): 66 | workers = [] 67 | for i in range(0, num_workers): 68 | worker = Worker(wrap(async_task)) 69 | workers.append(worker) 70 | 71 | for worker in workers: 72 | worker.start() 73 | 74 | for worker in workers: 75 | worker.join() 76 | 77 | if exception[0]: 78 | raise exception[0] 79 | -------------------------------------------------------------------------------- /tests/opentracing_instrumentation/test_tornado_http.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | import pytest 22 | 23 | from basictracer import BasicTracer 24 | from basictracer.recorder import InMemoryRecorder 25 | from mock import Mock, patch 26 | import opentracing 27 | from opentracing.scope_managers.tornado import TornadoScopeManager 28 | import tornado.gen 29 | import tornado.web 30 | import tornado.httpserver 31 | import tornado.netutil 32 | import tornado.httpclient 33 | 34 | from opentracing_instrumentation import span_in_stack_context 35 | from opentracing_instrumentation.client_hooks.tornado_http import ( 36 | install_patches, 37 | reset_patchers 38 | ) 39 | from opentracing_instrumentation.http_server import ( 40 | TornadoRequestWrapper, 41 | before_request 42 | ) 43 | from opentracing_instrumentation.interceptors import OpenTracingInterceptor 44 | 45 | 46 | class Handler(tornado.web.RequestHandler): 47 | 48 | def get(self): 49 | request = TornadoRequestWrapper(self.request) 50 | with before_request(request, tracer=opentracing.tracer) as span: 51 | self.write('{:x}'.format(span.context.trace_id)) 52 | self.set_status(200) 53 | 54 | 55 | @pytest.fixture 56 | def app(): 57 | return tornado.web.Application([ 58 | (r"/", Handler) 59 | ]) 60 | 61 | 62 | @pytest.yield_fixture 63 | def tornado_http_patch(): 64 | install_patches.__original_func() 65 | try: 66 | yield None 67 | finally: 68 | reset_patchers() 69 | 70 | 71 | @pytest.fixture 72 | def tracer(): 73 | t = BasicTracer( 74 | recorder=InMemoryRecorder(), 75 | scope_manager=TornadoScopeManager(), 76 | ) 77 | t.register_required_propagators() 78 | return t 79 | 80 | 81 | @pytest.mark.gen_test(run_sync=False) 82 | def test_http_fetch(base_url, http_client, tornado_http_patch, tracer): 83 | 84 | @tornado.gen.coroutine 85 | def make_downstream_call(): 86 | resp = yield http_client.fetch(base_url) 87 | raise tornado.gen.Return(resp) 88 | 89 | with patch('opentracing.tracer', tracer): 90 | assert opentracing.tracer == tracer # sanity check that patch worked 91 | 92 | span = tracer.start_span('test') 93 | trace_id = '{:x}'.format(span.context.trace_id) 94 | 95 | with span_in_stack_context(span): 96 | response = make_downstream_call() 97 | response = yield response # cannot yield when in StackContext context 98 | 99 | span.finish() 100 | assert response.code == 200 101 | assert response.body.decode('utf-8') == trace_id 102 | 103 | 104 | @pytest.mark.gen_test(run_sync=False) 105 | def test_http_fetch_with_interceptor(base_url, http_client, tornado_http_patch, tracer): 106 | 107 | @tornado.gen.coroutine 108 | def make_downstream_call(): 109 | resp = yield http_client.fetch(base_url) 110 | raise tornado.gen.Return(resp) 111 | 112 | with patch('opentracing.tracer', tracer): 113 | assert opentracing.tracer == tracer # sanity check that patch worked 114 | 115 | span = tracer.start_span('test') 116 | trace_id = '{:x}'.format(span.context.trace_id) 117 | 118 | with patch('opentracing_instrumentation.http_client.ClientInterceptors') as MockClientInterceptors: 119 | mock_interceptor = Mock(spec=OpenTracingInterceptor) 120 | MockClientInterceptors.get_interceptors.return_value = [mock_interceptor] 121 | 122 | with span_in_stack_context(span): 123 | response = make_downstream_call() 124 | response = yield response # cannot yield when in StackContext context 125 | 126 | mock_interceptor.process.assert_called_once() 127 | assert mock_interceptor.process.call_args_list[0][1]['span'].tracer == tracer 128 | 129 | span.finish() 130 | 131 | assert response.code == 200 132 | assert response.body.decode('utf-8') == trace_id 133 | -------------------------------------------------------------------------------- /tests/opentracing_instrumentation/test_tornado_request_context.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from __future__ import absolute_import 22 | 23 | import opentracing 24 | from opentracing.scope_managers.tornado import TornadoScopeManager 25 | from opentracing_instrumentation.request_context import ( 26 | get_current_span, 27 | span_in_stack_context, 28 | RequestContext, 29 | RequestContextManager, 30 | ) 31 | from mock import patch 32 | from tornado import gen 33 | from tornado import stack_context 34 | from tornado.testing import AsyncTestCase, gen_test 35 | 36 | 37 | @patch('opentracing.tracer', new=opentracing.Tracer(TornadoScopeManager())) 38 | class TornadoTraceContextTest(AsyncTestCase): 39 | 40 | @gen_test 41 | def test_http_fetch(self): 42 | span1 = 'Bender is great!' 43 | span2 = 'Fry is dumb!' 44 | 45 | @gen.coroutine 46 | def check(span_to_check): 47 | assert get_current_span() == span_to_check 48 | 49 | with self.assertRaises(Exception): # passing mismatching spans 50 | yield run_coroutine_with_span(span1, check, span2) 51 | 52 | @gen.coroutine 53 | def nested(nested_span_to_check, span_to_check): 54 | yield run_coroutine_with_span(span1, check, nested_span_to_check) 55 | assert get_current_span() == span_to_check 56 | 57 | with self.assertRaises(Exception): # passing mismatching spans 58 | yield run_coroutine_with_span(span2, nested, span1, span1) 59 | with self.assertRaises(Exception): # passing mismatching spans 60 | yield run_coroutine_with_span(span2, nested, span2, span2) 61 | 62 | # successful case 63 | yield run_coroutine_with_span(span2, nested, span1, span2) 64 | 65 | def test_no_span(self): 66 | ctx = RequestContextManager(context=RequestContext(span='x')) 67 | assert ctx._context.span == 'x' 68 | assert RequestContextManager.current_context() is None 69 | 70 | def test_backwards_compatible(self): 71 | span = opentracing.tracer.start_span(operation_name='test') 72 | mgr = RequestContextManager(span) # span as positional arg 73 | assert mgr._context.span == span 74 | mgr = RequestContextManager(context=span) # span context arg 75 | assert mgr._context.span == span 76 | mgr = RequestContextManager(span=span) # span as span arg 77 | assert mgr._context.span == span 78 | 79 | @gen_test 80 | def test_request_context_manager_backwards_compatible(self): 81 | span = opentracing.tracer.start_span(operation_name='test') 82 | 83 | @gen.coroutine 84 | def check(): 85 | assert get_current_span() == span 86 | 87 | # Bypass ScopeManager/span_in_stack_context() and use 88 | # RequestContextManager directly. 89 | def run_coroutine(span, coro): 90 | def mgr(): 91 | return RequestContextManager(span) 92 | 93 | with stack_context.StackContext(mgr): 94 | return coro() 95 | 96 | yield run_coroutine(span, check) 97 | 98 | 99 | def run_coroutine_with_span(span, coro, *args, **kwargs): 100 | """Wrap the execution of a Tornado coroutine func in a tracing span. 101 | 102 | This makes the span available through the get_current_span() function. 103 | 104 | :param span: The tracing span to expose. 105 | :param coro: Co-routine to execute in the scope of tracing span. 106 | :param args: Positional args to func, if any. 107 | :param kwargs: Keyword args to func, if any. 108 | """ 109 | with span_in_stack_context(span): 110 | return coro(*args, **kwargs) 111 | -------------------------------------------------------------------------------- /tests/opentracing_instrumentation/test_traced_function_decorator.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from __future__ import absolute_import 22 | 23 | import mock 24 | import pytest 25 | import unittest 26 | 27 | import opentracing 28 | from opentracing.mocktracer import MockTracer 29 | from opentracing.scope_managers.tornado import TornadoScopeManager 30 | from opentracing.scope_managers import ThreadLocalScopeManager 31 | 32 | import tornado.stack_context 33 | from tornado.concurrent import is_future 34 | from tornado import gen 35 | from tornado.testing import AsyncTestCase, gen_test 36 | from opentracing_instrumentation import traced_function 37 | from opentracing_instrumentation import span_in_stack_context 38 | 39 | patch_object = mock.patch.object 40 | 41 | 42 | def extract_call_site_tag(span, *_, **kwargs): 43 | if 'call_site_tag' in kwargs: 44 | span.set_tag('call_site_tag', kwargs['call_site_tag']) 45 | 46 | 47 | class Client(object): 48 | 49 | def _func(self, param): 50 | assert param == 123 51 | return 'oh yeah' 52 | 53 | @traced_function 54 | def regular(self, param): 55 | return self._func(param) 56 | 57 | @traced_function(name='some_name') 58 | def regular_with_name(self, param): 59 | return self._func(param) 60 | 61 | @traced_function(on_start=extract_call_site_tag) 62 | def regular_with_hook(self, param1, param2=None, call_site_tag=None): 63 | assert param1 == call_site_tag 64 | assert param2 is None 65 | return 'oh yeah' 66 | 67 | @traced_function(require_active_trace=True) 68 | def regular_require_active_trace(self, param): 69 | return self._func(param) 70 | 71 | @traced_function() 72 | def regular_with_nested(self, param): 73 | self.regular(param) 74 | self.regular_with_name(param) 75 | 76 | def _coro(self, param): 77 | return tornado.gen.Return(self._func(param)) 78 | 79 | @traced_function 80 | @gen.coroutine 81 | def coro(self, param): 82 | raise self._coro(param) 83 | 84 | @traced_function(name='some_name') 85 | @gen.coroutine 86 | def coro_with_name(self, param): 87 | raise self._coro(param) 88 | 89 | @traced_function(on_start=extract_call_site_tag) 90 | @gen.coroutine 91 | def coro_with_hook(self, param1, param2=None, call_site_tag=None): 92 | assert param1 == call_site_tag 93 | assert param2 is None 94 | raise tornado.gen.Return('oh yeah') 95 | 96 | @traced_function(require_active_trace=True) 97 | def coro_require_active_trace(self, param): 98 | raise self._coro(param) 99 | 100 | 101 | class PrepareMixin(object): 102 | 103 | scope_manager = None 104 | 105 | def setUp(self): 106 | super(PrepareMixin, self).setUp() 107 | self.patcher = mock.patch( 108 | 'opentracing.tracer', MockTracer(self.scope_manager())) 109 | self.patcher.start() 110 | self.client = Client() 111 | 112 | def tearDown(self): 113 | super(PrepareMixin, self).tearDown() 114 | self.patcher.stop() 115 | 116 | 117 | class TracedRegularFunctionDecoratorTest(PrepareMixin, unittest.TestCase): 118 | 119 | scope_manager = ThreadLocalScopeManager 120 | 121 | def test_no_arg_decorator(self): 122 | 123 | parent = opentracing.tracer.start_span('hello') 124 | 125 | with opentracing.tracer.scope_manager.activate(parent, True) as scope: 126 | child = mock.Mock() 127 | # verify start_child is called with actual function name 128 | with patch_object(opentracing.tracer, 'start_span', 129 | return_value=child) as start_child: 130 | r = self.client.regular(123) 131 | start_child.assert_called_once_with( 132 | operation_name='regular', 133 | child_of=parent.context, 134 | tags=None) 135 | child.set_tag.assert_not_called() 136 | child.error.assert_not_called() 137 | child.finish.assert_called_once() 138 | assert r == 'oh yeah' 139 | 140 | # verify span.error() is called on exception 141 | child = mock.Mock() 142 | with patch_object(opentracing.tracer, 'start_span') as start_child: 143 | start_child.return_value = child 144 | with pytest.raises(AssertionError): 145 | self.client.regular(999) 146 | child.log.assert_called_once() 147 | child.finish.assert_called_once() 148 | scope.close() 149 | 150 | def test_decorator_with_name(self): 151 | 152 | parent = opentracing.tracer.start_span('hello') 153 | 154 | with opentracing.tracer.scope_manager.activate(parent, True) as scope: 155 | child = mock.Mock() 156 | with patch_object(opentracing.tracer, 'start_span', 157 | return_value=child) as start_child: 158 | r = self.client.regular_with_name(123) 159 | assert r == 'oh yeah' 160 | start_child.assert_called_once_with( 161 | operation_name='some_name', # overridden name 162 | child_of=parent.context, 163 | tags=None) 164 | child.set_tag.assert_not_called() 165 | parent.finish() 166 | scope.close() 167 | 168 | def test_decorator_with_start_hook(self): 169 | 170 | parent = opentracing.tracer.start_span('hello') 171 | 172 | with opentracing.tracer.scope_manager.activate(parent, True) as scope: 173 | # verify call_size_tag argument is extracted and added as tag 174 | child = mock.Mock() 175 | with patch_object(opentracing.tracer, 'start_span') as start_child: 176 | start_child.return_value = child 177 | r = self.client.regular_with_hook( 178 | 'somewhere', call_site_tag='somewhere') 179 | assert r == 'oh yeah' 180 | start_child.assert_called_once_with( 181 | operation_name='regular_with_hook', 182 | child_of=parent.context, 183 | tags=None) 184 | child.set_tag.assert_called_once_with( 185 | 'call_site_tag', 'somewhere') 186 | scope.close() 187 | 188 | def test_no_parent_span(self): 189 | 190 | with patch_object(opentracing.tracer, 'start_span') as start: 191 | r = self.client.regular(123) 192 | assert r == 'oh yeah' 193 | start.assert_called_once_with( 194 | operation_name='regular', child_of=None, tags=None) 195 | 196 | # verify no new trace or child span is started 197 | with patch_object(opentracing.tracer, 'start_span') as start: 198 | r = self.client.regular_require_active_trace(123) 199 | assert r == 'oh yeah' 200 | start.assert_not_called() 201 | 202 | def test_nested_functions(self): 203 | tracer = opentracing.tracer 204 | 205 | parent = opentracing.tracer.start_span('hello') 206 | with opentracing.tracer.scope_manager.activate(parent, True) as scope: 207 | self.client.regular_with_nested(123) 208 | spans = tracer.finished_spans() 209 | assert len(spans) == 3 210 | root = spans[2] 211 | assert root.operation_name == 'regular_with_nested' 212 | 213 | assert spans[0].operation_name == 'regular' 214 | assert spans[0].parent_id == root.context.span_id 215 | assert spans[1].operation_name == 'some_name' 216 | assert spans[1].parent_id == root.context.span_id 217 | 218 | # Check parent context has been restored. 219 | assert tracer.scope_manager.active is scope 220 | 221 | def test_nested_functions_with_exception(self): 222 | tracer = opentracing.tracer 223 | 224 | parent = opentracing.tracer.start_span('hello') 225 | with opentracing.tracer.scope_manager.activate(parent, True) as scope: 226 | # First nested function (`regular`) raises Exception. 227 | with pytest.raises(AssertionError): 228 | self.client.regular_with_nested(999) 229 | spans = tracer.finished_spans() 230 | # Second nested function has not been invoked. 231 | assert len(spans) == 2 232 | root = spans[1] 233 | assert root.operation_name == 'regular_with_nested' 234 | 235 | assert spans[0].operation_name == 'regular' 236 | assert spans[0].parent_id == root.context.span_id 237 | assert len(spans[0].tags) == 1 238 | assert spans[0].tags['error'] == 'true' 239 | assert spans[0].logs[0].key_values['event'] == 'exception' 240 | 241 | # Check parent context has been restored. 242 | assert tracer.scope_manager.active is scope 243 | 244 | 245 | class TracedCoroFunctionDecoratorTest(PrepareMixin, AsyncTestCase): 246 | 247 | scope_manager = TornadoScopeManager 248 | 249 | @gen.coroutine 250 | def call(self, method, *args, **kwargs): 251 | """ 252 | Execute synchronous or asynchronous method of client and return the 253 | result. 254 | """ 255 | result = getattr(self.client, method)(*args, **kwargs) 256 | if is_future(result): 257 | result = yield result 258 | raise tornado.gen.Return(result) 259 | 260 | @gen_test 261 | def test_no_arg_decorator(self): 262 | 263 | parent = opentracing.tracer.start_span('hello') 264 | 265 | @gen.coroutine 266 | def run(): 267 | # test both co-routine and regular function 268 | for func in ('regular', 'coro', ): 269 | child = mock.Mock() 270 | # verify start_child is called with actual function name 271 | with patch_object(opentracing.tracer, 'start_span', 272 | return_value=child) as start_child: 273 | r = yield self.call(func, 123) 274 | start_child.assert_called_once_with( 275 | operation_name=func, 276 | child_of=parent.context, 277 | tags=None) 278 | child.set_tag.assert_not_called() 279 | child.error.assert_not_called() 280 | child.finish.assert_called_once() 281 | assert r == 'oh yeah' 282 | 283 | # verify span.error() is called on exception 284 | child = mock.Mock() 285 | with patch_object(opentracing.tracer, 'start_span') \ 286 | as start_child: 287 | start_child.return_value = child 288 | with pytest.raises(AssertionError): 289 | yield self.call(func, 999) 290 | child.log.assert_called_once() 291 | child.finish.assert_called_once() 292 | 293 | raise tornado.gen.Return(1) 294 | 295 | yield run_coroutine_with_span(span=parent, coro=run) 296 | 297 | @gen_test 298 | def test_decorator_with_name(self): 299 | 300 | parent = opentracing.tracer.start_span('hello') 301 | 302 | @gen.coroutine 303 | def run(): 304 | # verify start_span is called with overridden function name 305 | for func in ('regular_with_name', 'coro_with_name', ): 306 | child = mock.Mock() 307 | with patch_object(opentracing.tracer, 'start_span', 308 | return_value=child) as start_child: 309 | r = yield self.call(func, 123) 310 | assert r == 'oh yeah' 311 | start_child.assert_called_once_with( 312 | operation_name='some_name', # overridden name 313 | child_of=parent.context, 314 | tags=None) 315 | child.set_tag.assert_not_called() 316 | 317 | raise tornado.gen.Return(1) 318 | 319 | yield run_coroutine_with_span(span=parent, coro=run) 320 | 321 | @gen_test 322 | def test_decorator_with_start_hook(self): 323 | 324 | parent = opentracing.tracer.start_span('hello') 325 | 326 | @gen.coroutine 327 | def run(): 328 | # verify call_size_tag argument is extracted and added as tag 329 | for func in ('regular_with_hook', 'coro_with_hook', ): 330 | child = mock.Mock() 331 | with patch_object(opentracing.tracer, 'start_span') \ 332 | as start_child: 333 | start_child.return_value = child 334 | r = yield self.call( 335 | func, 'somewhere', call_site_tag='somewhere') 336 | assert r == 'oh yeah' 337 | start_child.assert_called_once_with( 338 | operation_name=func, 339 | child_of=parent.context, 340 | tags=None) 341 | child.set_tag.assert_called_once_with( 342 | 'call_site_tag', 'somewhere') 343 | 344 | raise tornado.gen.Return(1) 345 | 346 | yield run_coroutine_with_span(span=parent, coro=run) 347 | 348 | @gen_test 349 | def test_no_parent_span(self): 350 | 351 | @gen.coroutine 352 | def run(): 353 | # verify a new trace is started 354 | for func1, func2 in (('regular', 'regular_require_active_trace'), 355 | ('coro', 'coro_require_active_trace')): 356 | with patch_object(opentracing.tracer, 'start_span') as start: 357 | r = yield self.call(func1, 123) 358 | assert r == 'oh yeah' 359 | start.assert_called_once_with( 360 | operation_name=func1, child_of=None, tags=None) 361 | 362 | # verify no new trace or child span is started 363 | with patch_object(opentracing.tracer, 'start_span') as start: 364 | r = yield self.call(func2, 123) 365 | assert r == 'oh yeah' 366 | start.assert_not_called() 367 | 368 | raise tornado.gen.Return(1) 369 | 370 | yield run_coroutine_with_span(span=None, coro=run) 371 | 372 | 373 | def run_coroutine_with_span(span, coro, *args, **kwargs): 374 | """Wrap the execution of a Tornado coroutine func in a tracing span. 375 | 376 | This makes the span available through the get_current_span() function. 377 | 378 | :param span: The tracing span to expose. 379 | :param coro: Co-routine to execute in the scope of tracing span. 380 | :param args: Positional args to func, if any. 381 | :param kwargs: Keyword args to func, if any. 382 | """ 383 | with span_in_stack_context(span=span): 384 | return coro(*args, **kwargs) 385 | -------------------------------------------------------------------------------- /tests/opentracing_instrumentation/test_wsgi_request.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015 Uber Technologies, Inc. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | from __future__ import absolute_import 22 | import mock 23 | from opentracing_instrumentation.http_server import WSGIRequestWrapper 24 | 25 | 26 | def test_creates_instance(): 27 | wsgi_environ = { 28 | 'SERVER_NAME': 'localhost', 29 | 'SERVER_PORT': '8888', 30 | 'REQUEST_METHOD': 'GET', 31 | 'PATH_INFO': '/Farnsworth', 32 | 'HTTP_X_FOO': 'bar', 33 | 'REMOTE_ADDR': 'localhost', 34 | 'REMOTE_PORT': '80', 35 | 'wsgi.url_scheme': 'http' 36 | } 37 | request = WSGIRequestWrapper.from_wsgi_environ(wsgi_environ) 38 | 39 | assert request.server_port == '8888' 40 | assert request.method == 'GET' 41 | assert request.headers.get('x-foo') == 'bar' 42 | assert request.remote_ip == 'localhost' 43 | assert request.remote_port == '80' 44 | assert request.full_url == 'http://localhost:8888/Farnsworth' 45 | assert request.caller_name is None 46 | 47 | wsgi_environ['SERVER_PORT'] = 8888 # int is also acceptable 48 | request = WSGIRequestWrapper.from_wsgi_environ(wsgi_environ) 49 | assert request.server_port == 8888 50 | 51 | 52 | def test_url(): 53 | environ = { 54 | 'wsgi.url_scheme': 'http', 55 | 'HTTP_HOST': 'bender.com' 56 | } 57 | request = WSGIRequestWrapper.from_wsgi_environ(environ) 58 | assert request.full_url == 'http://bender.com' 59 | 60 | environ = { 61 | 'wsgi.url_scheme': 'http', 62 | 'SERVER_NAME': 'bender.com', 63 | 'SERVER_PORT': '80' 64 | } 65 | request = WSGIRequestWrapper.from_wsgi_environ(environ) 66 | assert request.full_url == 'http://bender.com' 67 | 68 | environ['SERVER_PORT'] = '8888' 69 | request = WSGIRequestWrapper.from_wsgi_environ(environ) 70 | assert request.full_url == 'http://bender.com:8888' 71 | 72 | environ['wsgi.url_scheme'] = 'https' 73 | request = WSGIRequestWrapper.from_wsgi_environ(environ) 74 | assert request.full_url == 'https://bender.com:8888' 75 | 76 | environ['SERVER_PORT'] = '443' 77 | request = WSGIRequestWrapper.from_wsgi_environ(environ) 78 | assert request.full_url == 'https://bender.com' 79 | 80 | environ['SCRIPT_NAME'] = '/Farnsworth' 81 | request = WSGIRequestWrapper.from_wsgi_environ(environ) 82 | assert request.full_url == 'https://bender.com/Farnsworth' 83 | 84 | environ['PATH_INFO'] = '/PlanetExpress' 85 | request = WSGIRequestWrapper.from_wsgi_environ(environ) 86 | assert request.full_url == 'https://bender.com/Farnsworth/PlanetExpress' 87 | 88 | environ['QUERY_STRING'] = 'Bender=antiquing' 89 | request = WSGIRequestWrapper.from_wsgi_environ(environ) 90 | assert request.full_url == \ 91 | 'https://bender.com/Farnsworth/PlanetExpress?Bender=antiquing' 92 | 93 | 94 | def test_caller(): 95 | environ = { 96 | 'HTTP_Custom-Caller-Header': 'Zapp', 97 | } 98 | from opentracing_instrumentation import config 99 | 100 | with mock.patch.object(config.CONFIG, 'caller_name_headers', 101 | ['XXX', 'Custom-Caller-Header']): 102 | request = WSGIRequestWrapper.from_wsgi_environ(environ) 103 | assert request.caller_name == 'Zapp' 104 | 105 | environ['HTTP_XXX'] = 'DOOP' 106 | with mock.patch.object(config.CONFIG, 'caller_name_headers', 107 | ['XXX', 'Custom-Caller-Header']): 108 | request = WSGIRequestWrapper.from_wsgi_environ(environ) 109 | # header XXX is earlier in the list ==> higher priority 110 | assert request.caller_name == 'DOOP' 111 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{27,35,36}-celery{3,4} 4 | py37-celery4 5 | py{27,35,36,37}-missing_modules 6 | skip_missing_interpreters = true 7 | 8 | [testenv] 9 | setenv = 10 | missing_modules: TEST_MISSING_MODULES_HANDLING=1 11 | deps = 12 | celery3: celery~=3.0 13 | celery4: celery~=4.0 14 | missing_modules: pytest-cov 15 | extras = 16 | !missing_modules: tests 17 | commands = 18 | !missing_modules: flake8 19 | !missing_modules: pytest 20 | missing_modules: pytest tests/opentracing_instrumentation/test_missing_modules_handling.py 21 | -------------------------------------------------------------------------------- /travis/install-protoc.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | set -x 4 | 5 | protoc_ver=0.9.3 6 | 7 | BUILD="$HOME/.protoc-build" 8 | if [ ! -d "$BUILD" ]; then 9 | mkdir -p "$BUILD" 10 | fi 11 | cd "$BUILD" 12 | 13 | wget https://github.com/google/protobuf/releases/download/v2.6.1/protobuf-2.6.1.tar.gz 14 | tar xzf protobuf-2.6.1.tar.gz 15 | cd protobuf-2.6.1 16 | sudo apt-get update 17 | sudo apt-get install -y build-essential 18 | sudo ./configure 19 | sudo make 20 | sudo make check 21 | sudo make install 22 | sudo ldconfig 23 | protoc --version 24 | --------------------------------------------------------------------------------