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 =