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