diff --git a/examples/opentelemetry-example-app/setup.py b/examples/opentelemetry-example-app/setup.py
index 73a3e207842..0e577cd2649 100644
--- a/examples/opentelemetry-example-app/setup.py
+++ b/examples/opentelemetry-example-app/setup.py
@@ -38,7 +38,7 @@
"opentelemetry-api",
"opentelemetry-sdk",
"opentelemetry-ext-http-requests",
- "opentelemetry-ext-wsgi",
+ "opentelemetry-ext-flask",
"flask",
"requests",
],
diff --git a/examples/opentelemetry-example-app/src/opentelemetry_example_app/flask_example.py b/examples/opentelemetry-example-app/src/opentelemetry_example_app/flask_example.py
index 18dffa30008..85df625efef 100644
--- a/examples/opentelemetry-example-app/src/opentelemetry_example_app/flask_example.py
+++ b/examples/opentelemetry-example-app/src/opentelemetry_example_app/flask_example.py
@@ -21,7 +21,7 @@
import opentelemetry.ext.http_requests
from opentelemetry import propagators, trace
-from opentelemetry.ext.wsgi import OpenTelemetryMiddleware
+from opentelemetry.ext.flask import instrument_app
from opentelemetry.sdk.context.propagation.b3_format import B3Format
from opentelemetry.sdk.trace import Tracer
@@ -57,7 +57,7 @@ def configure_opentelemetry(flask_app: flask.Flask):
# and the frameworks and libraries that are used together, automatically
# creating Spans and propagating context as appropriate.
opentelemetry.ext.http_requests.enable(trace.tracer())
- flask_app.wsgi_app = OpenTelemetryMiddleware(flask_app.wsgi_app)
+ instrument_app(flask_app)
app = flask.Flask(__name__)
diff --git a/ext/opentelemetry-ext-flask/README.rst b/ext/opentelemetry-ext-flask/README.rst
new file mode 100644
index 00000000000..182f0960b27
--- /dev/null
+++ b/ext/opentelemetry-ext-flask/README.rst
@@ -0,0 +1,35 @@
+OpenTelemetry Flask tracing
+===========================
+
+This library builds on the OpenTelemetry WSGI middleware to track web requests
+in Flask applications. In addition to opentelemetry-ext-wsgi, it supports
+flask-specific features such as:
+
+* The Flask endpoint name is used as the Span name.
+* The ``http.route`` Span attribute is set so that one can see which URL rule
+ matched a request.
+
+Usage
+-----
+
+.. code-block:: python
+
+ from flask import Flask
+ from opentelemetry.ext.flask import instrument_app
+
+ app = Flask(__name__)
+ instrument_app(app) # This is where the magic happens. ✨
+
+ @app.route("/")
+ def hello():
+ return "Hello!"
+
+ if __name__ == "__main__":
+ app.run(debug=True)
+
+
+References
+----------
+
+* `OpenTelemetry Project `_
+* `OpenTelemetry WSGI extension `_
diff --git a/ext/opentelemetry-ext-flask/setup.cfg b/ext/opentelemetry-ext-flask/setup.cfg
new file mode 100644
index 00000000000..1d4956a82f7
--- /dev/null
+++ b/ext/opentelemetry-ext-flask/setup.cfg
@@ -0,0 +1,50 @@
+# 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-flask
+description = Flask tracing for OpenTelemetry (based on opentelemetry-ext-wsgi)
+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-flask
+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_namespace:
+install_requires =
+ opentelemetry-ext-wsgi
+
+[options.extras_require]
+test =
+ flask~=1.0
+ opentelemetry-ext-testutil
+
+[options.packages.find]
+where = src
diff --git a/ext/opentelemetry-ext-flask/setup.py b/ext/opentelemetry-ext-flask/setup.py
new file mode 100644
index 00000000000..34b27c60342
--- /dev/null
+++ b/ext/opentelemetry-ext-flask/setup.py
@@ -0,0 +1,26 @@
+# 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", "flask", "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-flask/src/opentelemetry/ext/flask/__init__.py b/ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask/__init__.py
new file mode 100644
index 00000000000..eedc8d59988
--- /dev/null
+++ b/ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask/__init__.py
@@ -0,0 +1,93 @@
+# Note: This package is not named "flask" because of
+# https://github.com/PyCQA/pylint/issues/2648
+
+import logging
+
+from flask import request as flask_request
+
+import opentelemetry.ext.wsgi as otel_wsgi
+from opentelemetry import propagators, trace
+from opentelemetry.util import time_ns
+
+logger = logging.getLogger(__name__)
+
+_ENVIRON_STARTTIME_KEY = object()
+_ENVIRON_SPAN_KEY = object()
+_ENVIRON_ACTIVATION_KEY = object()
+
+
+def instrument_app(flask):
+ """Makes the passed-in Flask object traced by OpenTelemetry.
+
+ You must not call this function multiple times on the same Flask object.
+ """
+
+ wsgi = flask.wsgi_app
+
+ def wrapped_app(environ, start_response):
+ # We want to measure the time for route matching, etc.
+ # In theory, we could start the span here and use update_name later
+ # but that API is "highly discouraged" so we better avoid it.
+ environ[_ENVIRON_STARTTIME_KEY] = time_ns()
+
+ def _start_response(status, response_headers, *args, **kwargs):
+ span = flask_request.environ.get(_ENVIRON_SPAN_KEY)
+ if span:
+ otel_wsgi.add_response_attributes(
+ span, status, response_headers
+ )
+ else:
+ logger.warning(
+ "Flask environ's OpenTelemetry span missing at _start_response(%s)",
+ status,
+ )
+ return start_response(status, response_headers, *args, **kwargs)
+
+ return wsgi(environ, _start_response)
+
+ flask.wsgi_app = wrapped_app
+
+ flask.before_request(_before_flask_request)
+ flask.teardown_request(_teardown_flask_request)
+
+
+def _before_flask_request():
+ environ = flask_request.environ
+ span_name = flask_request.endpoint or otel_wsgi.get_default_span_name(
+ environ
+ )
+ parent_span = propagators.extract(
+ otel_wsgi.get_header_from_environ, environ
+ )
+
+ tracer = trace.tracer()
+
+ span = tracer.create_span(
+ span_name, parent_span, kind=trace.SpanKind.SERVER
+ )
+ span.start(environ.get(_ENVIRON_STARTTIME_KEY))
+ activation = tracer.use_span(span, end_on_exit=True)
+ activation.__enter__()
+ environ[_ENVIRON_ACTIVATION_KEY] = activation
+ environ[_ENVIRON_SPAN_KEY] = span
+ otel_wsgi.add_request_attributes(span, environ)
+ if flask_request.url_rule:
+ # For 404 that result from no route found, etc, we don't have a url_rule.
+ span.set_attribute("http.route", flask_request.url_rule.rule)
+
+
+def _teardown_flask_request(exc):
+ activation = flask_request.environ.get(_ENVIRON_ACTIVATION_KEY)
+ if not activation:
+ logger.warning(
+ "Flask environ's OpenTelemetry activation missing at _teardown_flask_request(%s)",
+ exc,
+ )
+ return
+
+ if exc is None:
+ activation.__exit__(None, None, None)
+ else:
+ activation.__exit__(
+ type(exc), exc, getattr(exc, "__traceback__", None)
+ )
diff --git a/ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask/version.py b/ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask/version.py
new file mode 100644
index 00000000000..ed56d30324d
--- /dev/null
+++ b/ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask/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.3dev0"
diff --git a/ext/opentelemetry-ext-flask/tests/__init__.py b/ext/opentelemetry-ext-flask/tests/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/ext/opentelemetry-ext-flask/tests/test_flask_integration.py b/ext/opentelemetry-ext-flask/tests/test_flask_integration.py
new file mode 100644
index 00000000000..dfb9dee885c
--- /dev/null
+++ b/ext/opentelemetry-ext-flask/tests/test_flask_integration.py
@@ -0,0 +1,136 @@
+# 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 unittest
+
+from flask import Flask
+from werkzeug.test import Client
+from werkzeug.wrappers import BaseResponse
+
+import opentelemetry.ext.flask as otel_flask
+from opentelemetry import trace as trace_api
+from opentelemetry.ext.testutil.wsgitestutil import WsgiTestBase
+
+
+class TestFlaskIntegration(WsgiTestBase):
+ def setUp(self):
+ super().setUp()
+
+ self.span_attrs = {}
+
+ def setspanattr(key, value):
+ self.assertIsInstance(key, str)
+ self.span_attrs[key] = value
+
+ self.span.set_attribute = setspanattr
+
+ self.app = Flask(__name__)
+
+ def hello_endpoint(helloid):
+ if helloid == 500:
+ raise ValueError(":-(")
+ return "Hello: " + str(helloid)
+
+ self.app.route("/hello/")(hello_endpoint)
+
+ otel_flask.instrument_app(self.app)
+ self.client = Client(self.app, BaseResponse)
+
+ def test_simple(self):
+ resp = self.client.get("/hello/123")
+ self.assertEqual(200, resp.status_code)
+ self.assertEqual([b"Hello: 123"], list(resp.response))
+
+ self.create_span.assert_called_with(
+ "hello_endpoint",
+ trace_api.INVALID_SPAN_CONTEXT,
+ kind=trace_api.SpanKind.SERVER,
+ )
+ self.assertEqual(1, self.span.start.call_count)
+
+ # TODO: Change this test to use the SDK, as mocking becomes painful
+
+ self.assertEqual(
+ self.span_attrs,
+ {
+ "component": "http",
+ "http.method": "GET",
+ "http.host": "localhost",
+ "http.url": "http://localhost/hello/123",
+ "http.route": "/hello/",
+ "http.status_code": 200,
+ "http.status_text": "OK",
+ },
+ )
+
+ def test_404(self):
+ resp = self.client.post("/bye")
+ self.assertEqual(404, resp.status_code)
+ resp.close()
+
+ self.create_span.assert_called_with(
+ "/bye",
+ trace_api.INVALID_SPAN_CONTEXT,
+ kind=trace_api.SpanKind.SERVER,
+ )
+ self.assertEqual(1, self.span.start.call_count)
+
+ # Nope, this uses Tracer.use_span(end_on_exit)
+ # self.assertEqual(1, self.span.end.call_count)
+ # TODO: Change this test to use the SDK, as mocking becomes painful
+
+ self.assertEqual(
+ self.span_attrs,
+ {
+ "component": "http",
+ "http.method": "POST",
+ "http.host": "localhost",
+ "http.url": "http://localhost/bye",
+ "http.status_code": 404,
+ "http.status_text": "NOT FOUND",
+ },
+ )
+
+ def test_internal_error(self):
+ resp = self.client.get("/hello/500")
+ self.assertEqual(500, resp.status_code)
+ resp.close()
+
+ self.create_span.assert_called_with(
+ "hello_endpoint",
+ trace_api.INVALID_SPAN_CONTEXT,
+ kind=trace_api.SpanKind.SERVER,
+ )
+ self.assertEqual(1, self.span.start.call_count)
+
+ # Nope, this uses Tracer.use_span(end_on_exit)
+ # self.assertEqual(1, self.span.end.call_count)
+ # TODO: Change this test to use the SDK, as mocking becomes painful
+
+ self.assertEqual(
+ self.span_attrs,
+ {
+ "component": "http",
+ "http.method": "GET",
+ "http.host": "localhost",
+ "http.url": "http://localhost/hello/500",
+ "http.route": "/hello/",
+ "http.status_code": 500,
+ "http.status_text": "INTERNAL SERVER ERROR",
+ },
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/ext/opentelemetry-ext-testutil/setup.cfg b/ext/opentelemetry-ext-testutil/setup.cfg
new file mode 100644
index 00000000000..170e949cf67
--- /dev/null
+++ b/ext/opentelemetry-ext-testutil/setup.cfg
@@ -0,0 +1,47 @@
+# 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-testutil
+description = Test utilities for OpenTelemetry unit tests
+author = OpenTelemetry Authors
+author_email = cncf-opentelemetry-contributors@lists.cncf.io
+url = https://github.com/open-telemetry/opentelemetry-python/ext/opentelemetry-ext-testutil
+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_namespace:
+install_requires =
+ opentelemetry-ext-wsgi
+
+[options.extras_require]
+test = flask~=1.0
+
+[options.packages.find]
+where = src
diff --git a/ext/opentelemetry-ext-testutil/setup.py b/ext/opentelemetry-ext-testutil/setup.py
new file mode 100644
index 00000000000..9de576d3567
--- /dev/null
+++ b/ext/opentelemetry-ext-testutil/setup.py
@@ -0,0 +1,26 @@
+# 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", "testutil", "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-testutil/src/opentelemetry/ext/testutil/__init__.py b/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/version.py b/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/version.py
new file mode 100644
index 00000000000..70ddecedf86
--- /dev/null
+++ b/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/version.py
@@ -0,0 +1 @@
+__version__ = "0.3dev0"
diff --git a/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/wsgitestutil.py b/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/wsgitestutil.py
new file mode 100644
index 00000000000..d9cc9ff6a92
--- /dev/null
+++ b/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/wsgitestutil.py
@@ -0,0 +1,41 @@
+import io
+import unittest
+import unittest.mock as mock
+import wsgiref.util as wsgiref_util
+
+from opentelemetry import trace as trace_api
+
+
+class WsgiTestBase(unittest.TestCase):
+ def setUp(self):
+ tracer = trace_api.tracer()
+ self.span = mock.create_autospec(trace_api.Span, spec_set=True)
+ self.create_span_patcher = mock.patch.object(
+ tracer,
+ "create_span",
+ autospec=True,
+ spec_set=True,
+ return_value=self.span,
+ )
+ self.create_span = self.create_span_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.create_span_patcher.stop()
+
+ def start_response(self, status, response_headers, exc_info=None):
+ # The span should have started already
+ self.assertEqual(1, self.span.start.call_count)
+
+ self.status = status
+ self.response_headers = response_headers
+ self.exc_info = exc_info
+ return self.write
diff --git a/ext/opentelemetry-ext-wsgi/setup.cfg b/ext/opentelemetry-ext-wsgi/setup.cfg
index 4405e37a302..1db49209bec 100644
--- a/ext/opentelemetry-ext-wsgi/setup.cfg
+++ b/ext/opentelemetry-ext-wsgi/setup.cfg
@@ -41,5 +41,9 @@ packages=find_namespace:
install_requires =
opentelemetry-api
+[options.extras_require]
+test =
+ opentelemetry-ext-testutil
+
[options.packages.find]
where = src
diff --git a/ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/__init__.py b/ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/__init__.py
index 5e619eb7c6a..a2cf163342e 100644
--- a/ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/__init__.py
+++ b/ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/__init__.py
@@ -24,75 +24,108 @@
from opentelemetry import propagators, trace
from opentelemetry.ext.wsgi.version import __version__ # noqa
+from opentelemetry.util import time_ns
+
+
+def get_header_from_environ(
+ environ: dict, header_name: str
+) -> typing.List[str]:
+ """Retrieve a HTTP header value from the PEP3333-conforming WSGI environ.
+
+ Returns:
+ A list with a single string with the header value if it exists, else an empty list.
+ """
+ environ_key = "HTTP_" + header_name.upper().replace("-", "_")
+ value = environ.get(environ_key)
+ if value is not None:
+ return [value]
+ return []
+
+
+def add_request_attributes(span, environ):
+ """Adds HTTP request attributes from the PEP3333-conforming WSGI environ to span."""
+
+ span.set_attribute("component", "http")
+ span.set_attribute("http.method", environ["REQUEST_METHOD"])
+
+ host = environ.get("HTTP_HOST")
+ if not host:
+ host = environ["SERVER_NAME"]
+ port = environ["SERVER_PORT"]
+ scheme = environ["wsgi.url_scheme"]
+ if (
+ scheme == "http"
+ and port != "80"
+ or scheme == "https"
+ and port != "443"
+ ):
+ host += ":" + port
+
+ # NOTE: Nonstandard (but see
+ # https://github.com/open-telemetry/opentelemetry-specification/pull/263)
+ span.set_attribute("http.host", host)
+
+ url = environ.get("REQUEST_URI") or environ.get("RAW_URI")
+
+ if url:
+ if url[0] == "/":
+ # We assume that no scheme-relative URLs will be in url here.
+ # After all, if a request is made to http://myserver//foo, we may get
+ # //foo which looks like scheme-relative but isn't.
+ url = environ["wsgi.url_scheme"] + "://" + host + url
+ elif not url.startswith(environ["wsgi.url_scheme"] + ":"):
+ # Something fishy is in RAW_URL. Let's fall back to request_uri()
+ url = wsgiref_util.request_uri(environ)
+ else:
+ url = wsgiref_util.request_uri(environ)
+
+ span.set_attribute("http.url", url)
+
+
+def add_response_attributes(
+ span, start_response_status, response_headers
+): # pylint: disable=unused-argument
+ """Adds HTTP response attributes to span using the arguments
+ passed to a PEP3333-conforming start_response callable."""
+
+ status_code, status_text = start_response_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)
+
+
+def get_default_span_name(environ):
+ """Calculates a (generic) span name for an incoming HTTP request based on the PEP3333 conforming WSGI environ."""
+
+ # TODO: Update once
+ # https://github.com/open-telemetry/opentelemetry-specification/issues/270
+ # is resolved
+ return environ.get("PATH_INFO", "/")
class OpenTelemetryMiddleware:
"""The WSGI application middleware.
- This class is used to create and annotate spans for requests to a WSGI
- application.
+ This class is a PEP 3333 conforming WSGI middleware that starts and
+ annotates spans for any requests it is invoked with.
Args:
- wsgi: The WSGI application callable.
+ wsgi: The WSGI application callable to forward requests to.
"""
def __init__(self, wsgi):
self.wsgi = wsgi
@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")
- if not host:
- host = environ["SERVER_NAME"]
- port = environ["SERVER_PORT"]
- scheme = environ["wsgi.url_scheme"]
- if (
- scheme == "http"
- and port != "80"
- or scheme == "https"
- and port != "443"
- ):
- host += ":" + port
-
- # NOTE: Nonstandard
- span.set_attribute("http.host", host)
-
- url = environ.get("REQUEST_URI") or environ.get("RAW_URI")
-
- if url:
- if url[0] == "/":
- # We assume that no scheme-relative URLs will be in url here.
- # After all, if a request is made to http://myserver//foo, we may get
- # //foo which looks like scheme-relative but isn't.
- url = environ["wsgi.url_scheme"] + "://" + host + url
- elif not url.startswith(environ["wsgi.url_scheme"] + ":"):
- # Something fishy is in RAW_URL. Let's fall back to request_uri()
- url = wsgiref_util.request_uri(environ)
- else:
- url = wsgiref_util.request_uri(environ)
-
- 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):
+ def _create_start_response(span, start_response):
@functools.wraps(start_response)
def _start_response(status, response_headers, *args, **kwargs):
- cls._add_response_attributes(span, status)
+ add_response_attributes(span, status, response_headers)
return start_response(status, response_headers, *args, **kwargs)
return _start_response
@@ -105,17 +138,20 @@ def __call__(self, environ, start_response):
start_response: The WSGI start_response callable.
"""
+ start_timestamp = time_ns()
+
tracer = trace.tracer()
- path_info = environ["PATH_INFO"] or "/"
- parent_span = propagators.extract(_get_header_from_environ, environ)
+ parent_span = propagators.extract(get_header_from_environ, environ)
+ span_name = get_default_span_name(environ)
span = tracer.create_span(
- path_info, parent_span, kind=trace.SpanKind.SERVER
+ span_name, parent_span, kind=trace.SpanKind.SERVER
)
- span.start()
+ span.start(start_timestamp)
+
try:
with tracer.use_span(span):
- self._add_request_attributes(span, environ)
+ add_request_attributes(span, environ)
start_response = self._create_start_response(
span, start_response
)
@@ -127,21 +163,6 @@ def __call__(self, environ, start_response):
raise
-def _get_header_from_environ(
- environ: dict, header_name: str
-) -> typing.List[str]:
- """Retrieve the header value from the wsgi environ dictionary.
-
- Returns:
- A string with the header value if it exists, else None.
- """
- environ_key = "HTTP_" + header_name.upper().replace("-", "_")
- value = environ.get(environ_key)
- if value:
- return [value]
- return []
-
-
# Put this in a subfunction to not delay the call to the wrapped
# WSGI application (instrumentation should change the application
# behavior as little as possible).
diff --git a/ext/opentelemetry-ext-wsgi/tests/test_wsgi_middleware.py b/ext/opentelemetry-ext-wsgi/tests/test_wsgi_middleware.py
index e5dc9654fd2..9c77acbb75b 100644
--- a/ext/opentelemetry-ext-wsgi/tests/test_wsgi_middleware.py
+++ b/ext/opentelemetry-ext-wsgi/tests/test_wsgi_middleware.py
@@ -12,15 +12,15 @@
# 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 urllib.parse import urlparse
+import opentelemetry.ext.wsgi as otel_wsgi
from opentelemetry import trace as trace_api
-from opentelemetry.ext.wsgi import OpenTelemetryMiddleware
+from opentelemetry.ext.testutil.wsgitestutil import WsgiTestBase
class Response:
@@ -73,41 +73,7 @@ def error_wsgi(environ, start_response):
return [b"*"]
-class TestWsgiApplication(unittest.TestCase):
- def setUp(self):
- tracer = trace_api.tracer()
- self.span = mock.create_autospec(trace_api.Span, spec_set=True)
- self.create_span_patcher = mock.patch.object(
- tracer,
- "create_span",
- autospec=True,
- spec_set=True,
- return_value=self.span,
- )
- self.create_span = self.create_span_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.create_span_patcher.stop()
-
- def start_response(self, status, response_headers, exc_info=None):
- # The span should have started already
- self.span.start.assert_called_once_with()
-
- self.status = status
- self.response_headers = response_headers
- self.exc_info = exc_info
- return self.write
-
+class TestWsgiApplication(WsgiTestBase):
def validate_response(self, response, error=None):
while True:
try:
@@ -133,29 +99,29 @@ def validate_response(self, response, error=None):
self.create_span.assert_called_with(
"/", trace_api.INVALID_SPAN_CONTEXT, kind=trace_api.SpanKind.SERVER
)
- self.span.start.assert_called_with()
+ self.assertEqual(1, self.span.start.call_count)
def test_basic_wsgi_call(self):
- app = OpenTelemetryMiddleware(simple_wsgi)
+ app = otel_wsgi.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)
+ app = otel_wsgi.OpenTelemetryMiddleware(iter_wsgi)
response = app(self.environ, self.start_response)
# Verify that start_response has been called
self.assertTrue(self.status)
self.validate_response(response)
# Verify that close has been called exactly once
- self.assertEqual(original_response.close_calls, 1)
+ self.assertEqual(1, original_response.close_calls)
def test_wsgi_generator(self):
original_response = Response()
gen_wsgi = create_gen_wsgi(original_response)
- app = OpenTelemetryMiddleware(gen_wsgi)
+ app = otel_wsgi.OpenTelemetryMiddleware(gen_wsgi)
response = app(self.environ, self.start_response)
# Verify that start_response has not been called
self.assertIsNone(self.status)
@@ -165,7 +131,7 @@ def test_wsgi_generator(self):
self.assertEqual(original_response.close_calls, 1)
def test_wsgi_exc_info(self):
- app = OpenTelemetryMiddleware(error_wsgi)
+ app = otel_wsgi.OpenTelemetryMiddleware(error_wsgi)
response = app(self.environ, self.start_response)
self.validate_response(response, error=ValueError)
@@ -179,9 +145,7 @@ def setUp(self):
def test_request_attributes(self):
self.environ["QUERY_STRING"] = "foo=bar"
- OpenTelemetryMiddleware._add_request_attributes( # noqa pylint: disable=protected-access
- self.span, self.environ
- )
+ otel_wsgi.add_request_attributes(self.span, self.environ)
expected = (
mock.call("component", "http"),
@@ -193,9 +157,7 @@ def test_request_attributes(self):
self.span.set_attribute.assert_has_calls(expected, any_order=True)
def validate_url(self, expected_url):
- OpenTelemetryMiddleware._add_request_attributes( # noqa pylint: disable=protected-access
- self.span, self.environ
- )
+ otel_wsgi.add_request_attributes(self.span, self.environ)
attrs = {
args[0][0]: args[0][1]
for args in self.span.set_attribute.call_args_list
@@ -269,9 +231,7 @@ def test_request_attributes_with_full_request_uri(self):
self.validate_url("http://127.0.0.1:8080/?foo=bar#top")
def test_response_attributes(self):
- OpenTelemetryMiddleware._add_response_attributes( # noqa pylint: disable=protected-access
- self.span, "404 Not Found"
- )
+ otel_wsgi.add_response_attributes(self.span, "404 Not Found", {})
expected = (
mock.call("http.status_code", 404),
mock.call("http.status_text", "Not Found"),
@@ -280,9 +240,7 @@ def test_response_attributes(self):
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"
- )
+ otel_wsgi.add_response_attributes(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"
diff --git a/tox.ini b/tox.ini
index 8f0d02d1ccb..385b0be9f21 100644
--- a/tox.ini
+++ b/tox.ini
@@ -2,8 +2,10 @@
skipsdist = True
skip_missing_interpreters = True
envlist =
- py3{4,5,6,7,8}-test-{api,sdk,example-app,example-basic-tracer,example-http,ext-wsgi,ext-http-requests,ext-jaeger,ext-pymongo,opentracing-shim}
- pypy3-test-{api,sdk,example-app,example-basic-tracer,example-http,ext-wsgi,ext-http-requests,ext-jaeger,ext-pymongo,opentracing-shim}
+ py3{4,5,6,7,8}-test-{api,sdk,example-app,ext-wsgi,ext-flask,ext-http-requests,ext-jaeger,ext-pymongo,opentracing-shim}
+ pypy3-test-{api,sdk,example-app,ext-wsgi,ext-flask,ext-http-requests,ext-jaeger,ext-pymongo,opentracing-shim}
+ py3{4,5,6,7,8}-test-{api,sdk,example-app,example-basic-tracer,example-http,ext-wsgi,ext-flask,ext-http-requests,ext-jaeger,ext-pymongo,opentracing-shim}
+ pypy3-test-{api,sdk,example-app,example-basic-tracer,example-http,ext-wsgi,ext-flask,ext-http-requests,ext-jaeger,ext-pymongo,opentracing-shim}
py3{4,5,6,7,8}-coverage
; Coverage is temporarily disabled for pypy3 due to the pytest bug.
@@ -37,6 +39,7 @@ changedir =
test-ext-jaeger: ext/opentelemetry-ext-jaeger/tests
test-ext-pymongo: ext/opentelemetry-ext-pymongo/tests
test-ext-wsgi: ext/opentelemetry-ext-wsgi/tests
+ test-ext-flask: ext/opentelemetry-ext-flask/tests
test-example-app: examples/opentelemetry-example-app/tests
test-example-basic-tracer: examples/basic_tracer/tests
test-example-http: examples/http/tests
@@ -50,6 +53,7 @@ commands_pre =
example-app: pip install {toxinidir}/opentelemetry-sdk
example-app: pip install {toxinidir}/ext/opentelemetry-ext-http-requests
example-app: pip install {toxinidir}/ext/opentelemetry-ext-wsgi
+ example-app: pip install {toxinidir}/ext/opentelemetry-ext-flask
example-app: pip install {toxinidir}/examples/opentelemetry-example-app
example-basic-tracer: pip install -e {toxinidir}/opentelemetry-api
example-basic-tracer: pip install -e {toxinidir}/opentelemetry-sdk
@@ -60,7 +64,9 @@ commands_pre =
example-http: pip install -r {toxinidir}/examples/http/requirements.txt
ext: pip install {toxinidir}/opentelemetry-api
- wsgi: pip install {toxinidir}/ext/opentelemetry-ext-wsgi
+ wsgi,flask: pip install {toxinidir}/ext/opentelemetry-ext-testutil
+ wsgi,flask: pip install {toxinidir}/ext/opentelemetry-ext-wsgi
+ flask: pip install {toxinidir}/ext/opentelemetry-ext-flask[test]
pymongo: pip install {toxinidir}/ext/opentelemetry-ext-pymongo
http-requests: pip install {toxinidir}/ext/opentelemetry-ext-http-requests
jaeger: pip install {toxinidir}/opentelemetry-sdk
@@ -75,7 +81,9 @@ commands_pre =
coverage: pip install -e {toxinidir}/ext/opentelemetry-ext-http-requests
coverage: pip install -e {toxinidir}/ext/opentelemetry-ext-jaeger
coverage: pip install -e {toxinidir}/ext/opentelemetry-ext-opentracing-shim
+ coverage: pip install -e {toxinidir}/ext/opentelemetry-ext-testutil
coverage: pip install -e {toxinidir}/ext/opentelemetry-ext-wsgi
+ coverage: pip install -e {toxinidir}/ext/opentelemetry-ext-flask[test]
coverage: pip install -e {toxinidir}/examples/opentelemetry-example-app
; Using file:// here because otherwise tox invokes just "pip install
@@ -110,7 +118,9 @@ commands_pre =
pip install -e {toxinidir}/ext/opentelemetry-ext-http-requests
pip install -e {toxinidir}/ext/opentelemetry-ext-jaeger
pip install -e {toxinidir}/ext/opentelemetry-ext-pymongo
+ pip install -e {toxinidir}/ext/opentelemetry-ext-testutil
pip install -e {toxinidir}/ext/opentelemetry-ext-wsgi
+ pip install -e {toxinidir}/ext/opentelemetry-ext-flask[test]
pip install -e {toxinidir}/examples/opentelemetry-example-app
pip install -e {toxinidir}/ext/opentelemetry-ext-opentracing-shim
@@ -133,7 +143,11 @@ commands =
ext/opentelemetry-ext-opentracing-shim/tests/ \
ext/opentelemetry-ext-pymongo/src/opentelemetry \
ext/opentelemetry-ext-pymongo/tests/ \
+ ext/opentelemetry-ext-testutil/src/opentelemetry \
+ ext/opentelemetry-ext-wsgi/src/ \
ext/opentelemetry-ext-wsgi/tests/ \
+ ext/opentelemetry-ext-flask/src/ \
+ ext/opentelemetry-ext-flask/tests/ \
examples/opentelemetry-example-app/src/opentelemetry_example_app/ \
examples/opentelemetry-example-app/tests/ \
examples/basic_tracer/ \
@@ -166,6 +180,7 @@ commands_pre =
pip install -e {toxinidir}/opentelemetry-sdk
pip install -e {toxinidir}/ext/opentelemetry-ext-http-requests
pip install -e {toxinidir}/ext/opentelemetry-ext-wsgi
+ pip install -e {toxinidir}/ext/opentelemetry-ext-flask
commands =
{toxinidir}/scripts/tracecontext-integration-test.sh