diff --git a/ext/opentelemetry-ext-wsgi/README.rst b/ext/opentelemetry-ext-wsgi/README.rst new file mode 100644 index 00000000000..d47643c8711 --- /dev/null +++ b/ext/opentelemetry-ext-wsgi/README.rst @@ -0,0 +1,47 @@ +OpenTelemetry WSGI Middleware +============================= + +This library provides a WSGI middleware that can be used on any WSGI framework +(such as Django / Flask) to track requests timing through OpenTelemetry. + + +Usage (Flask) +------------- + +.. code-block:: python + + from flask import Flask + from opentelemetry.ext.wsgi import OpenTelemetryMiddleware + + app = Flask(__name__) + app.wsgi_app = OpenTelemetryMiddleware(app.wsgi_app) + + @app.route("/") + def hello(): + return "Hello!" + + if __name__ == "__main__": + app.run(debug=True) + + +Usage (Django) +-------------- + +Modify the application's ``wsgi.py`` file as shown below. + +.. code-block:: python + + import os + from opentelemetry.ext.wsgi import OpenTelemetryMiddleware + from django.core.wsgi import get_wsgi_application + + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'application.settings') + + application = get_wsgi_application() + application = OpenTelemetryMiddleware(application) + +References +---------- + +* `OpenTelemetry Project `_ +* `WSGI `_ diff --git a/ext/opentelemetry-ext-wsgi/setup.cfg b/ext/opentelemetry-ext-wsgi/setup.cfg new file mode 100644 index 00000000000..a77e9fd1fb3 --- /dev/null +++ b/ext/opentelemetry-ext-wsgi/setup.cfg @@ -0,0 +1,45 @@ +# Copyright 2019, OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +[metadata] +name = opentelemetry-ext-wsgi +description = WSGI Middleware for OpenTelemetry +long_description = file: README.rst +long_description_content_type = text/x-rst +author = OpenTelemetry Authors +author_email = cncf-opentelemetry-contributors@lists.cncf.io +url = https://github.com/open-telemetry/opentelemetry-python/ext/opentelemetry-ext-wsgi +platforms = any +license = Apache-2.0 +classifiers = + Development Status :: 3 - Alpha + Intended Audience :: Developers + License :: OSI Approved :: Apache Software License + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.4 + Programming Language :: Python :: 3.5 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + +[options] +python_requires = >=3.4 +package_dir= + =src +packages=find: +install_requires = + opentelemetry-api + +[options.packages.find] +where = src diff --git a/ext/opentelemetry-ext-wsgi/setup.py b/ext/opentelemetry-ext-wsgi/setup.py new file mode 100644 index 00000000000..f2496022614 --- /dev/null +++ b/ext/opentelemetry-ext-wsgi/setup.py @@ -0,0 +1,30 @@ +# Copyright 2019, OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import os +import setuptools + +BASE_DIR = os.path.dirname(__file__) +VERSION_FILENAME = os.path.join( + BASE_DIR, + "src", + "opentelemetry", + "ext", + "wsgi", + "version.py", +) +PACKAGE_INFO = {} +with open(VERSION_FILENAME) as f: + exec(f.read(), PACKAGE_INFO) + +setuptools.setup(version=PACKAGE_INFO["__version__"]) diff --git a/ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/__init__.py b/ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/__init__.py new file mode 100644 index 00000000000..29d57567a0e --- /dev/null +++ b/ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/__init__.py @@ -0,0 +1,105 @@ +# Copyright 2019, OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +The opentelemetry-ext-wsgi package provides a WSGI middleware that can be used +on any WSGI framework (such as Django / Flask) to track requests timing through +OpenTelemetry. +""" + +import functools +import wsgiref.util as wsgiref_util + +from opentelemetry import trace +from opentelemetry.ext.wsgi.version import __version__ # noqa + + +class OpenTelemetryMiddleware: + """The WSGI application middleware. + + This class is used to create and annotate spans for requests to a WSGI + application. + + Args: + wsgi: The WSGI application callable. + """ + + def __init__( + self, + wsgi, + propagators=None, + ): + self.wsgi = wsgi + + # TODO: implement context propagation + self.propagators = propagators + + @staticmethod + def _add_request_attributes(span, environ): + span.set_attribute("component", "http") + span.set_attribute("http.method", environ["REQUEST_METHOD"]) + + host = environ.get("HTTP_HOST") or environ["SERVER_NAME"] + span.set_attribute("http.host", host) + + url = ( + environ.get("REQUEST_URI") or + environ.get("RAW_URI") or + wsgiref_util.request_uri(environ, include_query=False) + ) + span.set_attribute("http.url", url) + + @staticmethod + def _add_response_attributes(span, status): + status_code, status_text = status.split(" ", 1) + span.set_attribute("http.status_text", status_text) + + try: + status_code = int(status_code) + except ValueError: + pass + else: + span.set_attribute("http.status_code", status_code) + + @classmethod + def _create_start_response(cls, span, start_response): + @functools.wraps(start_response) + def _start_response(status, response_headers, *args, **kwargs): + cls._add_response_attributes(span, status) + return start_response(status, response_headers, *args, **kwargs) + + return _start_response + + def __call__(self, environ, start_response): + """The WSGI application + + Args: + environ: A WSGI environment. + start_response: The WSGI start_response callable. + """ + + tracer = trace.tracer() + path_info = environ["PATH_INFO"] or "/" + + with tracer.start_span(path_info) as span: + self._add_request_attributes(span, environ) + start_response = self._create_start_response(span, start_response) + + iterable = self.wsgi(environ, start_response) + try: + for yielded in iterable: + yield yielded + finally: + if hasattr(iterable, 'close'): + iterable.close() diff --git a/ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/version.py b/ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/version.py new file mode 100644 index 00000000000..a457c2b6651 --- /dev/null +++ b/ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/version.py @@ -0,0 +1,15 @@ +# Copyright 2019, OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +__version__ = "0.1.dev0" diff --git a/ext/opentelemetry-ext-wsgi/tests/__init__.py b/ext/opentelemetry-ext-wsgi/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ext/opentelemetry-ext-wsgi/tests/test_wsgi_middleware.py b/ext/opentelemetry-ext-wsgi/tests/test_wsgi_middleware.py new file mode 100644 index 00000000000..70b7e8fcf63 --- /dev/null +++ b/ext/opentelemetry-ext-wsgi/tests/test_wsgi_middleware.py @@ -0,0 +1,195 @@ +# Copyright 2019, OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import io +import sys +import unittest +import unittest.mock as mock +import wsgiref.util as wsgiref_util + +from opentelemetry import trace as trace_api +from opentelemetry.ext.wsgi import OpenTelemetryMiddleware + + +class Response: + def __init__(self): + self.iter = iter([b"*"]) + self.close_calls = 0 + + def __iter__(self): + return self + + def __next__(self): + return next(self.iter) + + def close(self): + self.close_calls += 1 + + +def simple_wsgi(environ, start_response): + assert isinstance(environ, dict) + start_response("200 OK", [("Content-Type", "text/plain")]) + return [b"*"] + + +def create_iter_wsgi(response): + def iter_wsgi(environ, start_response): + assert isinstance(environ, dict) + start_response("200 OK", [("Content-Type", "text/plain")]) + return response + return iter_wsgi + + +def error_wsgi(environ, start_response): + assert isinstance(environ, dict) + try: + raise ValueError + except ValueError: + exc_info = sys.exc_info() + start_response("200 OK", [("Content-Type", "text/plain")], exc_info) + exc_info = None + return [b"*"] + + +class TestWsgiApplication(unittest.TestCase): + def setUp(self): + tracer = trace_api.tracer() + self.span_context_manager = mock.MagicMock() + self.span_context_manager.__enter__.return_value = \ + mock.create_autospec( + trace_api.Span, spec_set=True + ) + self.patcher = mock.patch.object( + tracer, + "start_span", + autospec=True, + spec_set=True, + return_value=self.span_context_manager, + ) + self.start_span = self.patcher.start() + + self.write_buffer = io.BytesIO() + self.write = self.write_buffer.write + + self.environ = {} + wsgiref_util.setup_testing_defaults(self.environ) + + self.status = None + self.response_headers = None + self.exc_info = None + + def tearDown(self): + self.patcher.stop() + + def start_response(self, status, response_headers, exc_info=None): + # The span should have started already + self.span_context_manager.__enter__.assert_called() + + self.status = status + self.response_headers = response_headers + self.exc_info = exc_info + return self.write + + def validate_response(self, response, error=None): + while True: + try: + value = next(response) + self.span_context_manager.__exit__.assert_not_called() + self.assertEqual(value, b"*") + except StopIteration: + self.span_context_manager.__exit__.assert_called() + break + + self.assertEqual(self.status, "200 OK") + self.assertEqual( + self.response_headers, + [("Content-Type", "text/plain")] + ) + if error: + self.assertIs(self.exc_info[0], error) + self.assertIsInstance(self.exc_info[1], error) + self.assertIsNotNone(self.exc_info[2]) + else: + self.assertIsNone(self.exc_info) + + # Verify that start_span has been called + self.start_span.assert_called_once_with("/") + + def test_basic_wsgi_call(self): + app = OpenTelemetryMiddleware(simple_wsgi) + response = app(self.environ, self.start_response) + self.validate_response(response) + + def test_wsgi_iterable(self): + original_response = Response() + iter_wsgi = create_iter_wsgi(original_response) + app = OpenTelemetryMiddleware(iter_wsgi) + response = app(self.environ, self.start_response) + # Verify that start_response has not been called yet + self.assertIsNone(self.status) + self.validate_response(response) + + # Verify that close has been called exactly once + assert original_response.close_calls == 1 + + def test_wsgi_exc_info(self): + app = OpenTelemetryMiddleware(error_wsgi) + response = app(self.environ, self.start_response) + self.validate_response(response, error=ValueError) + + +class TestWsgiAttributes(unittest.TestCase): + def setUp(self): + self.environ = {} + wsgiref_util.setup_testing_defaults(self.environ) + self.span = mock.create_autospec(trace_api.Span, spec_set=True) + + def test_request_attributes(self): + OpenTelemetryMiddleware._add_request_attributes( # noqa pylint: disable=protected-access + self.span, + self.environ, + ) + expected = ( + mock.call("component", "http"), + mock.call("http.method", "GET"), + mock.call("http.host", "127.0.0.1"), + mock.call("http.url", "http://127.0.0.1/"), + ) + self.assertEqual(self.span.set_attribute.call_count, len(expected)) + self.span.set_attribute.assert_has_calls(expected, any_order=True) + + def test_response_attributes(self): + OpenTelemetryMiddleware._add_response_attributes( # noqa pylint: disable=protected-access + self.span, "404 Not Found", + ) + expected = ( + mock.call("http.status_code", 404), + mock.call("http.status_text", "Not Found"), + ) + self.assertEqual(self.span.set_attribute.call_count, len(expected)) + self.span.set_attribute.assert_has_calls(expected, any_order=True) + + def test_response_attributes_invalid_status_code(self): + OpenTelemetryMiddleware._add_response_attributes( # noqa pylint: disable=protected-access + self.span, "Invalid Status Code", + ) + self.assertEqual(self.span.set_attribute.call_count, 1) + self.span.set_attribute.assert_called_with( + "http.status_text", + "Status Code", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tox.ini b/tox.ini index cd711243618..82178115c09 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,11 @@ [tox] skipsdist = True -envlist = py{34,35,36,37}-test-{api,sdk}, lint, py37-mypy, docs +envlist = + py{34,35,36,37}-test-{api,sdk} + py{34,35,36,37}-test-ext-wsgi + lint + py37-mypy + docs [travis] python = @@ -16,17 +21,19 @@ setenv = changedir = test-api: opentelemetry-api/tests test-sdk: opentelemetry-sdk/tests + test-ext-wsgi: ext/opentelemetry-ext-wsgi/tests commands_pre = - test-api: pip install -e {toxinidir}/opentelemetry-api - test-sdk: pip install -e {toxinidir}/opentelemetry-api + test: pip install -e {toxinidir}/opentelemetry-api test-sdk: pip install -e {toxinidir}/opentelemetry-sdk + ext: pip install -e {toxinidir}/opentelemetry-api + wsgi: pip install -e {toxinidir}/ext/opentelemetry-ext-wsgi commands = mypy: mypy --namespace-packages opentelemetry-api/src/opentelemetry/ ; For test code, we don't want to enforce the full mypy strictness mypy: mypy --namespace-packages --config-file=mypy-relaxed.ini opentelemetry-api/tests/ - test-{api,sdk}: python -m unittest discover + test: python -m unittest discover [testenv:lint] deps = @@ -37,13 +44,14 @@ deps = commands_pre = pip install -e {toxinidir}/opentelemetry-api pip install -e {toxinidir}/opentelemetry-sdk + pip install -e {toxinidir}/ext/opentelemetry-ext-wsgi commands = ; Prefer putting everything in one pylint command to profit from duplication ; warnings. - pylint opentelemetry-api/src/opentelemetry/ opentelemetry-api/tests/ opentelemetry-sdk/src/opentelemetry/ opentelemetry-sdk/tests/ - flake8 opentelemetry-api/src/opentelemetry/ opentelemetry-api/tests/ opentelemetry-sdk/src/opentelemetry/ opentelemetry-sdk/tests/ - isort --check-only --recursive opentelemetry-api/src opentelemetry-sdk/src + pylint opentelemetry-api/src/opentelemetry/ opentelemetry-api/tests/ opentelemetry-sdk/src/opentelemetry/ opentelemetry-sdk/tests/ ext/opentelemetry-ext-wsgi/src/ ext/opentelemetry-ext-wsgi/tests/ + flake8 opentelemetry-api/src/opentelemetry/ opentelemetry-api/tests/ opentelemetry-sdk/src/opentelemetry/ opentelemetry-sdk/tests/ ext/opentelemetry-ext-wsgi/src/ ext/opentelemetry-ext-wsgi/tests/ + isort --check-only --recursive opentelemetry-api/src opentelemetry-sdk/src ext/opentelemetry-ext-wsgi/src ext/opentelemetry-ext-wsgi/tests [testenv:docs] deps =