From 86d10e4900c3bb00a23affd35a6adeb88bd170cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Neum=C3=BCller?= Date: Mon, 7 Oct 2019 16:35:48 +0200 Subject: [PATCH 01/15] WIP --- .../src/opentelemetry/ext/wsgi/__init__.py | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) 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 5e619eb7c6..db6e986a4a 100644 --- a/ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/__init__.py +++ b/ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/__init__.py @@ -25,6 +25,11 @@ from opentelemetry import propagators, trace from opentelemetry.ext.wsgi.version import __version__ # noqa +try: + from flask import request as flask_request +except ImportError: + flask_request = None + class OpenTelemetryMiddleware: """The WSGI application middleware. @@ -36,9 +41,31 @@ class OpenTelemetryMiddleware: wsgi: The WSGI application callable. """ + @classmethod + def wrap_flask(cls, flask): + middleware = cls(flask) + flask.wsgi_app = middleware + return flask + + + def _get_flask(self): + if not Flask: + return None + if isinstance(self.wsgi, Flask): + return self.wsgi + + flask_app = getattr(self.wsgi, "__self__") + if isinstance(flask_app, Flask): + return flask_app + + return None + def __init__(self, wsgi): self.wsgi = wsgi + def _add_flask_attributes(self): + print("REQUEST:", flask_request) + @staticmethod def _add_request_attributes(span, environ): span.set_attribute("component", "http") @@ -92,6 +119,10 @@ def _add_response_attributes(span, status): def _create_start_response(cls, span, start_response): @functools.wraps(start_response) def _start_response(status, response_headers, *args, **kwargs): + if flask_request: + if flask_request.endpoint: + span.update_name(flask_request.endpoint) + span.set_attribute("http.route", flask_request.url_rule.rule) cls._add_response_attributes(span, status) return start_response(status, response_headers, *args, **kwargs) @@ -106,11 +137,12 @@ def __call__(self, environ, start_response): """ tracer = trace.tracer() - path_info = environ["PATH_INFO"] or "/" parent_span = propagators.extract(_get_header_from_environ, environ) + span_name = environ.get("PATH_INFO", "/") + span = tracer.create_span( - path_info, parent_span, kind=trace.SpanKind.SERVER + span_name, parent_span, kind=trace.SpanKind.SERVER ) span.start() try: From d218493e3c001e80c1020d7e3af5c9cc08ca9cf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Neum=C3=BCller?= Date: Mon, 7 Oct 2019 17:41:36 +0200 Subject: [PATCH 02/15] WIP --- .../src/opentelemetry/ext/wsgi/__init__.py | 99 ++++++++++--------- 1 file changed, 53 insertions(+), 46 deletions(-) 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 db6e986a4a..da4ac3e99d 100644 --- a/ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/__init__.py +++ b/ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/__init__.py @@ -22,14 +22,19 @@ import typing import wsgiref.util as wsgiref_util +from opentelemetry.util import time_ns from opentelemetry import propagators, trace from opentelemetry.ext.wsgi.version import __version__ # noqa try: - from flask import request as flask_request + from flask import request as flask_request, Flask except ImportError: flask_request = None + Flask = None +def add_middleware_to_flask(flask): + flask.wsgi_app = _OpenTelemetryFlaskMiddleware(flask.wsgi_app, flask) + return flask class OpenTelemetryMiddleware: """The WSGI application middleware. @@ -41,31 +46,9 @@ class OpenTelemetryMiddleware: wsgi: The WSGI application callable. """ - @classmethod - def wrap_flask(cls, flask): - middleware = cls(flask) - flask.wsgi_app = middleware - return flask - - - def _get_flask(self): - if not Flask: - return None - if isinstance(self.wsgi, Flask): - return self.wsgi - - flask_app = getattr(self.wsgi, "__self__") - if isinstance(flask_app, Flask): - return flask_app - - return None - def __init__(self, wsgi): self.wsgi = wsgi - def _add_flask_attributes(self): - print("REQUEST:", flask_request) - @staticmethod def _add_request_attributes(span, environ): span.set_attribute("component", "http") @@ -115,15 +98,11 @@ def _add_response_attributes(span, status): else: span.set_attribute("http.status_code", status_code) - @classmethod - def _create_start_response(cls, span, start_response): + def _create_start_response(self, environ, start_response): @functools.wraps(start_response) def _start_response(status, response_headers, *args, **kwargs): - if flask_request: - if flask_request.endpoint: - span.update_name(flask_request.endpoint) - span.set_attribute("http.route", flask_request.url_rule.rule) - cls._add_response_attributes(span, status) + span = self._get_env_span(environ) + self._add_response_attributes(span, status) return start_response(status, response_headers, *args, **kwargs) return _start_response @@ -136,6 +115,8 @@ def __call__(self, environ, start_response): start_response: The WSGI start_response callable. """ + start_timestamp = time_ns() + tracer = trace.tracer() parent_span = propagators.extract(_get_header_from_environ, environ) span_name = environ.get("PATH_INFO", "/") @@ -144,20 +125,39 @@ def __call__(self, environ, start_response): span = tracer.create_span( span_name, parent_span, kind=trace.SpanKind.SERVER ) - span.start() + environ[self] = dict(span=span) + span.start(start_timestamp) try: with tracer.use_span(span): self._add_request_attributes(span, environ) start_response = self._create_start_response( - span, start_response + environ, start_response ) iterable = self.wsgi(environ, start_response) - return _end_span_after_iterating(iterable, span, tracer) + return self._end_span_after_iterating(iterable, environ, tracer) except: # noqa span.end() raise + def _get_env_span(self, environ): + return environ[self]["span"] + + # 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). + def _end_span_after_iterating(self, iterable, environ, tracer): + span = self._get_env_span(environ) + try: + with tracer.use_span(): + for yielded in iterable: + yield yielded + finally: + close = getattr(iterable, "close", None) + if close: + close() + span.end() + def _get_header_from_environ( environ: dict, header_name: str @@ -174,16 +174,23 @@ def _get_header_from_environ( 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). -def _end_span_after_iterating(iterable, span, tracer): - try: - with tracer.use_span(span): - for yielded in iterable: - yield yielded - finally: - close = getattr(iterable, "close", None) - if close: - close() - span.end() +class _OpenTelemetryFlaskMiddleware(OpenTelemetryMiddleware): + def __init__(self, wsgi, flask): + super().__init__(wsgi) + self.flask = flask + + def __call__(self, environ, start_response): + + self.flask.before_request(self._before_flask_request) + self.flask.teardown_request(self._teardown_flask_request) + + + start_timestamp = time_ns() + environ[self] = dict(span=None, start_timestamp=start_timestamp) + + start_response = self._create_start_response( + environ, start_response + ) + + iterable = self.wsgi(environ, start_response) + return self._end_span_after_iterating(iterable, environ, tracer()) \ No newline at end of file From bb9bc8f4f68748c9ab21ae9838db0f469aee48b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Neum=C3=BCller?= Date: Mon, 7 Oct 2019 17:56:57 +0200 Subject: [PATCH 03/15] WIP _Context --- .../src/opentelemetry/ext/wsgi/__init__.py | 84 ++++++++++--------- 1 file changed, 46 insertions(+), 38 deletions(-) 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 da4ac3e99d..5d6635fb45 100644 --- a/ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/__init__.py +++ b/ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/__init__.py @@ -49,6 +49,24 @@ class OpenTelemetryMiddleware: def __init__(self, wsgi): self.wsgi = wsgi + class _Context: + def __init__(self): + self.tracer = trace.tracer() + self.start_time = time_ns() + self.span = None + self.activation = None + + def __enter__(self): + return self + + def __exit__(self, *args): + if self.span: + self.span.end() + self.span = None + if self.activation: + self.activation.__exit__(*args) + + @staticmethod def _add_request_attributes(span, environ): span.set_attribute("component", "http") @@ -98,11 +116,15 @@ def _add_response_attributes(span, status): else: span.set_attribute("http.status_code", status_code) - def _create_start_response(self, environ, start_response): + @classmethod + def _create_start_response(cls, span, start_response): @functools.wraps(start_response) def _start_response(status, response_headers, *args, **kwargs): - span = self._get_env_span(environ) - self._add_response_attributes(span, status) + if flask_request: + if flask_request.endpoint: + span.update_name(flask_request.endpoint) + span.set_attribute("http.route", flask_request.url_rule.rule) + cls._add_response_attributes(span, status) return start_response(status, response_headers, *args, **kwargs) return _start_response @@ -125,39 +147,20 @@ def __call__(self, environ, start_response): span = tracer.create_span( span_name, parent_span, kind=trace.SpanKind.SERVER ) - environ[self] = dict(span=span) span.start(start_timestamp) try: with tracer.use_span(span): self._add_request_attributes(span, environ) start_response = self._create_start_response( - environ, start_response + span, start_response ) iterable = self.wsgi(environ, start_response) - return self._end_span_after_iterating(iterable, environ, tracer) + return _end_span_after_iterating(iterable, span, tracer) except: # noqa span.end() raise - def _get_env_span(self, environ): - return environ[self]["span"] - - # 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). - def _end_span_after_iterating(self, iterable, environ, tracer): - span = self._get_env_span(environ) - try: - with tracer.use_span(): - for yielded in iterable: - yield yielded - finally: - close = getattr(iterable, "close", None) - if close: - close() - span.end() - def _get_header_from_environ( environ: dict, header_name: str @@ -174,23 +177,28 @@ def _get_header_from_environ( 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). +def _end_span_after_iterating(iterable, span, tracer): + try: + with tracer.use_span(span): + for yielded in iterable: + yield yielded + finally: + close = getattr(iterable, "close", None) + if close: + close() + span.end() + class _OpenTelemetryFlaskMiddleware(OpenTelemetryMiddleware): + def __init__(self, wsgi, flask): super().__init__(wsgi) self.flask = flask def __call__(self, environ, start_response): - - self.flask.before_request(self._before_flask_request) - self.flask.teardown_request(self._teardown_flask_request) - - - start_timestamp = time_ns() - environ[self] = dict(span=None, start_timestamp=start_timestamp) - - start_response = self._create_start_response( - environ, start_response - ) - - iterable = self.wsgi(environ, start_response) - return self._end_span_after_iterating(iterable, environ, tracer()) \ No newline at end of file + with self._Context() as ctx: + self.flask.before_request(before_request) + self.flask.teardown_request() + From 4fbb01f10a3a5e9816c16322a5375f4cd77743a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Neum=C3=BCller?= Date: Tue, 8 Oct 2019 18:38:08 +0200 Subject: [PATCH 04/15] Add flask integration. --- .../flask_example.py | 4 +- ext/opentelemetry-ext-http-requests/setup.py | 4 + ext/opentelemetry-ext-wsgi/setup.cfg | 3 + .../src/opentelemetry/ext/wsgi/__init__.py | 206 ++++++++---------- .../src/opentelemetry/ext/wsgi/flask_util.py | 88 ++++++++ .../tests/test_flask_integration.py | 139 ++++++++++++ .../tests/test_wsgi_middleware.py | 37 ++-- tox.ini | 37 ++-- 8 files changed, 363 insertions(+), 155 deletions(-) create mode 100644 ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/flask_util.py create mode 100644 ext/opentelemetry-ext-wsgi/tests/test_flask_integration.py 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 229acdfb43..d99da520df 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.wsgi.flask_util import wrap_flask 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) + wrap_flask(flask_app) app = flask.Flask(__name__) diff --git a/ext/opentelemetry-ext-http-requests/setup.py b/ext/opentelemetry-ext-http-requests/setup.py index 2f757e6cde..8d2053457b 100644 --- a/ext/opentelemetry-ext-http-requests/setup.py +++ b/ext/opentelemetry-ext-http-requests/setup.py @@ -11,6 +11,10 @@ # 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. + +# Note: This package is not named "requests" because of +# https://github.com/PyCQA/pylint/issues/2648 + import os import setuptools diff --git a/ext/opentelemetry-ext-wsgi/setup.cfg b/ext/opentelemetry-ext-wsgi/setup.cfg index 4405e37a30..b0673cec24 100644 --- a/ext/opentelemetry-ext-wsgi/setup.cfg +++ b/ext/opentelemetry-ext-wsgi/setup.cfg @@ -41,5 +41,8 @@ packages=find_namespace: install_requires = opentelemetry-api +[options.extras_require] +test = flask~=1.0 + [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 5d6635fb45..a2cf163342 100644 --- a/ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/__init__.py +++ b/ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/__init__.py @@ -22,109 +22,110 @@ import typing import wsgiref.util as wsgiref_util -from opentelemetry.util import time_ns from opentelemetry import propagators, trace from opentelemetry.ext.wsgi.version import __version__ # noqa +from opentelemetry.util import time_ns -try: - from flask import request as flask_request, Flask -except ImportError: - flask_request = None - Flask = None -def add_middleware_to_flask(flask): - flask.wsgi_app = _OpenTelemetryFlaskMiddleware(flask.wsgi_app, flask) - return flask +def get_header_from_environ( + environ: dict, header_name: str +) -> typing.List[str]: + """Retrieve a HTTP header value from the PEP3333-conforming WSGI environ. -class OpenTelemetryMiddleware: - """The WSGI application middleware. + 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 [] - This class is used to create and annotate spans for requests to a WSGI - application. - Args: - wsgi: The WSGI application callable. - """ +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) - def __init__(self, wsgi): - self.wsgi = wsgi + span.set_attribute("http.url", url) - class _Context: - def __init__(self): - self.tracer = trace.tracer() - self.start_time = time_ns() - self.span = None - self.activation = None - def __enter__(self): - return self +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.""" - def __exit__(self, *args): - if self.span: - self.span.end() - self.span = None - if self.activation: - self.activation.__exit__(*args) + 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) - @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) +def get_default_span_name(environ): + """Calculates a (generic) span name for an incoming HTTP request based on the PEP3333 conforming WSGI environ.""" - @staticmethod - def _add_response_attributes(span, status): - status_code, status_text = status.split(" ", 1) - span.set_attribute("http.status_text", status_text) + # TODO: Update once + # https://github.com/open-telemetry/opentelemetry-specification/issues/270 + # is resolved + return environ.get("PATH_INFO", "/") - 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): + +class OpenTelemetryMiddleware: + """The WSGI application middleware. + + 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 to forward requests to. + """ + + def __init__(self, wsgi): + self.wsgi = wsgi + + @staticmethod + def _create_start_response(span, start_response): @functools.wraps(start_response) def _start_response(status, response_headers, *args, **kwargs): - if flask_request: - if flask_request.endpoint: - span.update_name(flask_request.endpoint) - span.set_attribute("http.route", flask_request.url_rule.rule) - cls._add_response_attributes(span, status) + add_response_attributes(span, status, response_headers) return start_response(status, response_headers, *args, **kwargs) return _start_response @@ -140,17 +141,17 @@ def __call__(self, environ, start_response): start_timestamp = time_ns() tracer = trace.tracer() - parent_span = propagators.extract(_get_header_from_environ, environ) - span_name = environ.get("PATH_INFO", "/") - + parent_span = propagators.extract(get_header_from_environ, environ) + span_name = get_default_span_name(environ) span = tracer.create_span( span_name, parent_span, kind=trace.SpanKind.SERVER ) 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 ) @@ -162,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). @@ -190,15 +176,3 @@ def _end_span_after_iterating(iterable, span, tracer): if close: close() span.end() - -class _OpenTelemetryFlaskMiddleware(OpenTelemetryMiddleware): - - def __init__(self, wsgi, flask): - super().__init__(wsgi) - self.flask = flask - - def __call__(self, environ, start_response): - with self._Context() as ctx: - self.flask.before_request(before_request) - self.flask.teardown_request() - diff --git a/ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/flask_util.py b/ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/flask_util.py new file mode 100644 index 0000000000..5fb6f04986 --- /dev/null +++ b/ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/flask_util.py @@ -0,0 +1,88 @@ +# 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 wrap_flask(flask): + 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 OTel 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 OTel 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-wsgi/tests/test_flask_integration.py b/ext/opentelemetry-ext-wsgi/tests/test_flask_integration.py new file mode 100644 index 0000000000..3f9e793d04 --- /dev/null +++ b/ext/opentelemetry-ext-wsgi/tests/test_flask_integration.py @@ -0,0 +1,139 @@ +# 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.wsgi.flask_util as otel_flask +from opentelemetry import trace as trace_api + +from .test_wsgi_middleware 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.wrap_flask(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) + + # 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/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-wsgi/tests/test_wsgi_middleware.py b/ext/opentelemetry-ext-wsgi/tests/test_wsgi_middleware.py index a88782d642..232c0844a8 100644 --- a/ext/opentelemetry-ext-wsgi/tests/test_wsgi_middleware.py +++ b/ext/opentelemetry-ext-wsgi/tests/test_wsgi_middleware.py @@ -19,8 +19,8 @@ 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 class Response: @@ -73,7 +73,7 @@ def error_wsgi(environ, start_response): return [b"*"] -class TestWsgiApplication(unittest.TestCase): +class WsgiTestBase(unittest.TestCase): def setUp(self): tracer = trace_api.tracer() self.span = mock.create_autospec(trace_api.Span, spec_set=True) @@ -85,7 +85,6 @@ def setUp(self): return_value=self.span, ) self.create_span = self.create_span_patcher.start() - self.write_buffer = io.BytesIO() self.write = self.write_buffer.write @@ -101,13 +100,15 @@ def tearDown(self): def start_response(self, status, response_headers, exc_info=None): # The span should have started already - self.span.start.assert_called_once_with() + self.assertEqual(1, self.span.start.call_count) 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 +134,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 +166,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 +180,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 +192,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 +266,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 +275,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 0db2364f19..cb0fb45f4a 100644 --- a/tox.ini +++ b/tox.ini @@ -37,7 +37,7 @@ commands_pre = example-app: pip install {toxinidir}/ext/opentelemetry-ext-wsgi example-app: pip install {toxinidir}/examples/opentelemetry-example-app ext: pip install {toxinidir}/opentelemetry-api - wsgi: pip install {toxinidir}/ext/opentelemetry-ext-wsgi + wsgi: pip install {toxinidir}/ext/opentelemetry-ext-wsgi[test] http-requests: pip install {toxinidir}/ext/opentelemetry-ext-http-requests jaeger: pip install {toxinidir}/opentelemetry-sdk jaeger: pip install {toxinidir}/ext/opentelemetry-ext-jaeger @@ -47,7 +47,7 @@ commands_pre = mypyinstalled: pip install file://{toxinidir}/opentelemetry-api/ commands = - test: python -m unittest discover + test: python -m unittest discover --top-level-directory .. -v mypy: mypy --namespace-packages opentelemetry-api/src/opentelemetry/ ; For test code, we don't want to enforce the full mypy strictness @@ -72,27 +72,34 @@ commands_pre = pip install -e {toxinidir}/ext/opentelemetry-ext-azure-monitor pip install -e {toxinidir}/ext/opentelemetry-ext-http-requests pip install -e {toxinidir}/ext/opentelemetry-ext-jaeger - pip install -e {toxinidir}/ext/opentelemetry-ext-wsgi + pip install -e {toxinidir}/ext/opentelemetry-ext-wsgi[test] pip install -e {toxinidir}/examples/opentelemetry-example-app commands = ; Prefer putting everything in one pylint command to profit from duplication ; warnings. black --check --diff . - pylint opentelemetry-api/src/opentelemetry \ - opentelemetry-api/tests/ \ + pylint \ + opentelemetry-api/src/opentelemetry \ opentelemetry-sdk/src/opentelemetry \ - opentelemetry-sdk/tests/ \ - ext/opentelemetry-ext-azure-monitor/examples/ \ - ext/opentelemetry-ext-azure-monitor/src/ \ - ext/opentelemetry-ext-azure-monitor/tests/ \ - ext/opentelemetry-ext-http-requests/src/ \ - ext/opentelemetry-ext-http-requests/tests/ \ + ext/opentelemetry-ext-azure-monitor/examples \ + ext/opentelemetry-ext-azure-monitor/src/opentelemetry \ + ext/opentelemetry-ext-http-requests/src/opentelemetry \ ext/opentelemetry-ext-jaeger/src/opentelemetry \ - ext/opentelemetry-ext-jaeger/tests/ \ - ext/opentelemetry-ext-wsgi/tests/ \ - examples/opentelemetry-example-app/src/opentelemetry_example_app/ \ - examples/opentelemetry-example-app/tests/ + ext/opentelemetry-ext-wsgi/src/opentelemetry \ + examples/opentelemetry-example-app/src/opentelemetry_example_app \ + examples/opentelemetry-example-app/tests + +; The tests need extra pylint invocations, otherwise we get name clashes within the "tests" modules +; (symptom: imports from test don't work) + + pylint opentelemetry-sdk/tests + pylint opentelemetry-api/tests + pylint ext/opentelemetry-ext-azure-monitor/tests + pylint ext/opentelemetry-ext-http-requests/tests + pylint ext/opentelemetry-ext-jaeger/tests + pylint ext/opentelemetry-ext-wsgi/tests + flake8 . isort --check-only --diff --recursive . From 798315c1d8c696191c52eabee3449974374a3317 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Neum=C3=BCller?= Date: Tue, 15 Oct 2019 16:30:01 +0200 Subject: [PATCH 05/15] Move flask support to new distribution. --- ext/opentelemetry-ext-flask/README.rst | 47 +++ ext/opentelemetry-ext-flask/setup.cfg | 48 +++ ext/opentelemetry-ext-flask/setup.py | 26 ++ .../src/opentelemetry/ext/wsgi/__init__.py | 178 +++++++++++ .../src/opentelemetry/ext/wsgi/flask_util.py | 88 ++++++ .../src/opentelemetry/ext/wsgi/version.py | 15 + ext/opentelemetry-ext-flask/tests/__init__.py | 0 .../tests/test_flask_integration.py | 139 +++++++++ .../tests/test_wsgi_middleware.py | 286 ++++++++++++++++++ ext/opentelemetry-ext-http-requests/setup.py | 4 - tox.ini | 5 +- 11 files changed, 830 insertions(+), 6 deletions(-) create mode 100644 ext/opentelemetry-ext-flask/README.rst create mode 100644 ext/opentelemetry-ext-flask/setup.cfg create mode 100644 ext/opentelemetry-ext-flask/setup.py create mode 100644 ext/opentelemetry-ext-flask/src/opentelemetry/ext/wsgi/__init__.py create mode 100644 ext/opentelemetry-ext-flask/src/opentelemetry/ext/wsgi/flask_util.py create mode 100644 ext/opentelemetry-ext-flask/src/opentelemetry/ext/wsgi/version.py create mode 100644 ext/opentelemetry-ext-flask/tests/__init__.py create mode 100644 ext/opentelemetry-ext-flask/tests/test_flask_integration.py create mode 100644 ext/opentelemetry-ext-flask/tests/test_wsgi_middleware.py diff --git a/ext/opentelemetry-ext-flask/README.rst b/ext/opentelemetry-ext-flask/README.rst new file mode 100644 index 0000000000..d47643c871 --- /dev/null +++ b/ext/opentelemetry-ext-flask/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-flask/setup.cfg b/ext/opentelemetry-ext-flask/setup.cfg new file mode 100644 index 0000000000..b0673cec24 --- /dev/null +++ b/ext/opentelemetry-ext-flask/setup.cfg @@ -0,0 +1,48 @@ +# 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_namespace: +install_requires = + opentelemetry-api + +[options.extras_require] +test = flask~=1.0 + +[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 0000000000..59aa3d5880 --- /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_util", "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/wsgi/__init__.py b/ext/opentelemetry-ext-flask/src/opentelemetry/ext/wsgi/__init__.py new file mode 100644 index 0000000000..a2cf163342 --- /dev/null +++ b/ext/opentelemetry-ext-flask/src/opentelemetry/ext/wsgi/__init__.py @@ -0,0 +1,178 @@ +# 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 typing +import wsgiref.util as wsgiref_util + +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 a PEP 3333 conforming WSGI middleware that starts and + annotates spans for any requests it is invoked with. + + Args: + wsgi: The WSGI application callable to forward requests to. + """ + + def __init__(self, wsgi): + self.wsgi = wsgi + + @staticmethod + def _create_start_response(span, start_response): + @functools.wraps(start_response) + def _start_response(status, response_headers, *args, **kwargs): + add_response_attributes(span, status, response_headers) + 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. + """ + + start_timestamp = time_ns() + + tracer = trace.tracer() + parent_span = propagators.extract(get_header_from_environ, environ) + span_name = get_default_span_name(environ) + + span = tracer.create_span( + span_name, parent_span, kind=trace.SpanKind.SERVER + ) + span.start(start_timestamp) + + try: + with tracer.use_span(span): + add_request_attributes(span, environ) + start_response = self._create_start_response( + span, start_response + ) + + iterable = self.wsgi(environ, start_response) + return _end_span_after_iterating(iterable, span, tracer) + except: # noqa + span.end() + raise + + +# 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). +def _end_span_after_iterating(iterable, span, tracer): + try: + with tracer.use_span(span): + for yielded in iterable: + yield yielded + finally: + close = getattr(iterable, "close", None) + if close: + close() + span.end() diff --git a/ext/opentelemetry-ext-flask/src/opentelemetry/ext/wsgi/flask_util.py b/ext/opentelemetry-ext-flask/src/opentelemetry/ext/wsgi/flask_util.py new file mode 100644 index 0000000000..5fb6f04986 --- /dev/null +++ b/ext/opentelemetry-ext-flask/src/opentelemetry/ext/wsgi/flask_util.py @@ -0,0 +1,88 @@ +# 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 wrap_flask(flask): + 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 OTel 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 OTel 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/wsgi/version.py b/ext/opentelemetry-ext-flask/src/opentelemetry/ext/wsgi/version.py new file mode 100644 index 0000000000..a457c2b665 --- /dev/null +++ b/ext/opentelemetry-ext-flask/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-flask/tests/__init__.py b/ext/opentelemetry-ext-flask/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 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 0000000000..3f9e793d04 --- /dev/null +++ b/ext/opentelemetry-ext-flask/tests/test_flask_integration.py @@ -0,0 +1,139 @@ +# 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.wsgi.flask_util as otel_flask +from opentelemetry import trace as trace_api + +from .test_wsgi_middleware 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.wrap_flask(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) + + # 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/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-flask/tests/test_wsgi_middleware.py b/ext/opentelemetry-ext-flask/tests/test_wsgi_middleware.py new file mode 100644 index 0000000000..232c0844a8 --- /dev/null +++ b/ext/opentelemetry-ext-flask/tests/test_wsgi_middleware.py @@ -0,0 +1,286 @@ +# 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 urllib.parse import urlparse + +import opentelemetry.ext.wsgi as otel_wsgi +from opentelemetry import trace as trace_api + + +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 create_gen_wsgi(response): + def gen_wsgi(environ, start_response): + result = create_iter_wsgi(response)(environ, start_response) + yield from result + getattr(result, "close", lambda: None)() + + return gen_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 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 + + +class TestWsgiApplication(WsgiTestBase): + def validate_response(self, response, error=None): + while True: + try: + value = next(response) + self.assertEqual(0, self.span.end.call_count) + self.assertEqual(value, b"*") + except StopIteration: + self.span.end.assert_called_once_with() + 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.create_span.assert_called_with( + "/", trace_api.INVALID_SPAN_CONTEXT, kind=trace_api.SpanKind.SERVER + ) + self.assertEqual(1, self.span.start.call_count) + + def test_basic_wsgi_call(self): + 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 = 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(1, original_response.close_calls) + + def test_wsgi_generator(self): + original_response = Response() + gen_wsgi = create_gen_wsgi(original_response) + 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) + self.validate_response(response) + + # Verify that close has been called exactly once + self.assertEqual(original_response.close_calls, 1) + + def test_wsgi_exc_info(self): + app = otel_wsgi.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): + self.environ["QUERY_STRING"] = "foo=bar" + + otel_wsgi.add_request_attributes(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/?foo=bar"), + ) + self.assertEqual(self.span.set_attribute.call_count, len(expected)) + self.span.set_attribute.assert_has_calls(expected, any_order=True) + + def validate_url(self, expected_url): + 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 + } + self.assertIn("http.url", attrs) + self.assertEqual(attrs["http.url"], expected_url) + self.assertIn("http.host", attrs) + self.assertEqual(attrs["http.host"], urlparse(expected_url).netloc) + + def test_request_attributes_with_partial_raw_uri(self): + self.environ["RAW_URI"] = "/#top" + self.validate_url("http://127.0.0.1/#top") + + def test_request_attributes_with_partial_raw_uri_and_nonstandard_port( + self + ): + self.environ["RAW_URI"] = "/?" + del self.environ["HTTP_HOST"] + self.environ["SERVER_PORT"] = "8080" + self.validate_url("http://127.0.0.1:8080/?") + + def test_https_uri_port(self): + del self.environ["HTTP_HOST"] + self.environ["SERVER_PORT"] = "443" + self.environ["wsgi.url_scheme"] = "https" + self.validate_url("https://127.0.0.1/") + + self.environ["SERVER_PORT"] = "8080" + self.validate_url("https://127.0.0.1:8080/") + + self.environ["SERVER_PORT"] = "80" + self.validate_url("https://127.0.0.1:80/") + + def test_http_uri_port(self): + del self.environ["HTTP_HOST"] + self.environ["SERVER_PORT"] = "80" + self.environ["wsgi.url_scheme"] = "http" + self.validate_url("http://127.0.0.1/") + + self.environ["SERVER_PORT"] = "8080" + self.validate_url("http://127.0.0.1:8080/") + + self.environ["SERVER_PORT"] = "443" + self.validate_url("http://127.0.0.1:443/") + + def test_request_attributes_with_nonstandard_port_and_no_host(self): + del self.environ["HTTP_HOST"] + self.environ["SERVER_PORT"] = "8080" + self.validate_url("http://127.0.0.1:8080/") + + self.environ["SERVER_PORT"] = "443" + self.validate_url("http://127.0.0.1:443/") + + def test_request_attributes_with_nonstandard_port(self): + self.environ["HTTP_HOST"] += ":8080" + self.validate_url("http://127.0.0.1:8080/") + + def test_request_attributes_with_faux_scheme_relative_raw_uri(self): + self.environ["RAW_URI"] = "//127.0.0.1/?" + self.validate_url("http://127.0.0.1//127.0.0.1/?") + + def test_request_attributes_with_pathless_raw_uri(self): + self.environ["PATH_INFO"] = "" + self.environ["RAW_URI"] = "http://hello" + self.environ["HTTP_HOST"] = "hello" + self.validate_url("http://hello") + + def test_request_attributes_with_full_request_uri(self): + self.environ["HTTP_HOST"] = "127.0.0.1:8080" + self.environ["REQUEST_URI"] = "http://127.0.0.1:8080/?foo=bar#top" + self.validate_url("http://127.0.0.1:8080/?foo=bar#top") + + def test_response_attributes(self): + otel_wsgi.add_response_attributes(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): + 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" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/ext/opentelemetry-ext-http-requests/setup.py b/ext/opentelemetry-ext-http-requests/setup.py index 8d2053457b..2f757e6cde 100644 --- a/ext/opentelemetry-ext-http-requests/setup.py +++ b/ext/opentelemetry-ext-http-requests/setup.py @@ -11,10 +11,6 @@ # 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. - -# Note: This package is not named "requests" because of -# https://github.com/PyCQA/pylint/issues/2648 - import os import setuptools diff --git a/tox.ini b/tox.ini index cb0fb45f4a..5d4cfcfd74 100644 --- a/tox.ini +++ b/tox.ini @@ -76,9 +76,10 @@ commands_pre = pip install -e {toxinidir}/examples/opentelemetry-example-app commands = + black . + ; Prefer putting everything in one pylint command to profit from duplication ; warnings. - black --check --diff . pylint \ opentelemetry-api/src/opentelemetry \ opentelemetry-sdk/src/opentelemetry \ @@ -101,7 +102,7 @@ commands = pylint ext/opentelemetry-ext-wsgi/tests flake8 . - isort --check-only --diff --recursive . + isort --recursive . [testenv:docs] deps = From 5c242c4a46125517e64d4f006adc30a5159004bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Neum=C3=BCller?= Date: Tue, 15 Oct 2019 18:24:24 +0200 Subject: [PATCH 06/15] Fix move. --- examples/opentelemetry-example-app/setup.py | 2 +- .../flask_example.py | 2 +- ext/opentelemetry-ext-flask/setup.cfg | 8 +- .../flask_util.py => flask_util/__init__.py} | 4 +- .../ext/{wsgi => flask_util}/version.py | 0 .../src/opentelemetry/ext/wsgi/__init__.py | 178 ----------- .../tests/test_flask_integration.py | 5 +- .../tests/test_wsgi_middleware.py | 286 ------------------ ext/opentelemetry-ext-wsgi/setup.cfg | 3 - .../src/opentelemetry/ext/wsgi/flask_util.py | 88 ------ .../tests/test_flask_integration.py | 139 --------- .../tests/test_wsgi_middleware.py | 37 +-- ext/test-util/README.txt | 3 + ext/test-util/wsgitestutil.py | 41 +++ tox.ini | 22 +- 15 files changed, 69 insertions(+), 749 deletions(-) rename ext/opentelemetry-ext-flask/src/opentelemetry/ext/{wsgi/flask_util.py => flask_util/__init__.py} (93%) rename ext/opentelemetry-ext-flask/src/opentelemetry/ext/{wsgi => flask_util}/version.py (100%) delete mode 100644 ext/opentelemetry-ext-flask/src/opentelemetry/ext/wsgi/__init__.py delete mode 100644 ext/opentelemetry-ext-flask/tests/test_wsgi_middleware.py delete mode 100644 ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/flask_util.py delete mode 100644 ext/opentelemetry-ext-wsgi/tests/test_flask_integration.py create mode 100644 ext/test-util/README.txt create mode 100644 ext/test-util/wsgitestutil.py diff --git a/examples/opentelemetry-example-app/setup.py b/examples/opentelemetry-example-app/setup.py index 4494d4ad0f..82ca2d834d 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 d99da520df..0b4c7a6790 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.flask_util import wrap_flask +from opentelemetry.ext.flask_util import wrap_flask from opentelemetry.sdk.context.propagation.b3_format import B3Format from opentelemetry.sdk.trace import Tracer diff --git a/ext/opentelemetry-ext-flask/setup.cfg b/ext/opentelemetry-ext-flask/setup.cfg index b0673cec24..238202bcc2 100644 --- a/ext/opentelemetry-ext-flask/setup.cfg +++ b/ext/opentelemetry-ext-flask/setup.cfg @@ -13,13 +13,13 @@ # limitations under the License. # [metadata] -name = opentelemetry-ext-wsgi -description = WSGI Middleware for OpenTelemetry +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-wsgi +url = https://github.com/open-telemetry/opentelemetry-python/ext/opentelemetry-ext-flask platforms = any license = Apache-2.0 classifiers = @@ -39,7 +39,7 @@ package_dir= =src packages=find_namespace: install_requires = - opentelemetry-api + opentelemetry-ext-wsgi [options.extras_require] test = flask~=1.0 diff --git a/ext/opentelemetry-ext-flask/src/opentelemetry/ext/wsgi/flask_util.py b/ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask_util/__init__.py similarity index 93% rename from ext/opentelemetry-ext-flask/src/opentelemetry/ext/wsgi/flask_util.py rename to ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask_util/__init__.py index 5fb6f04986..5a7481693f 100644 --- a/ext/opentelemetry-ext-flask/src/opentelemetry/ext/wsgi/flask_util.py +++ b/ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask_util/__init__.py @@ -33,7 +33,7 @@ def _start_response(status, response_headers, *args, **kwargs): ) else: logger.warning( - "Flask environ's OTel span missing at _start_response(%s)", + "Flask environ's OpenTelemetry span missing at _start_response(%s)", status, ) return start_response(status, response_headers, *args, **kwargs) @@ -75,7 +75,7 @@ def _teardown_flask_request(exc): activation = flask_request.environ.get(_ENVIRON_ACTIVATION_KEY) if not activation: logger.warning( - "Flask environ's OTel activation missing at _teardown_flask_request(%s)", + "Flask environ's OpenTelemetry activation missing at _teardown_flask_request(%s)", exc, ) return diff --git a/ext/opentelemetry-ext-flask/src/opentelemetry/ext/wsgi/version.py b/ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask_util/version.py similarity index 100% rename from ext/opentelemetry-ext-flask/src/opentelemetry/ext/wsgi/version.py rename to ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask_util/version.py diff --git a/ext/opentelemetry-ext-flask/src/opentelemetry/ext/wsgi/__init__.py b/ext/opentelemetry-ext-flask/src/opentelemetry/ext/wsgi/__init__.py deleted file mode 100644 index a2cf163342..0000000000 --- a/ext/opentelemetry-ext-flask/src/opentelemetry/ext/wsgi/__init__.py +++ /dev/null @@ -1,178 +0,0 @@ -# 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 typing -import wsgiref.util as wsgiref_util - -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 a PEP 3333 conforming WSGI middleware that starts and - annotates spans for any requests it is invoked with. - - Args: - wsgi: The WSGI application callable to forward requests to. - """ - - def __init__(self, wsgi): - self.wsgi = wsgi - - @staticmethod - def _create_start_response(span, start_response): - @functools.wraps(start_response) - def _start_response(status, response_headers, *args, **kwargs): - add_response_attributes(span, status, response_headers) - 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. - """ - - start_timestamp = time_ns() - - tracer = trace.tracer() - parent_span = propagators.extract(get_header_from_environ, environ) - span_name = get_default_span_name(environ) - - span = tracer.create_span( - span_name, parent_span, kind=trace.SpanKind.SERVER - ) - span.start(start_timestamp) - - try: - with tracer.use_span(span): - add_request_attributes(span, environ) - start_response = self._create_start_response( - span, start_response - ) - - iterable = self.wsgi(environ, start_response) - return _end_span_after_iterating(iterable, span, tracer) - except: # noqa - span.end() - raise - - -# 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). -def _end_span_after_iterating(iterable, span, tracer): - try: - with tracer.use_span(span): - for yielded in iterable: - yield yielded - finally: - close = getattr(iterable, "close", None) - if close: - close() - span.end() diff --git a/ext/opentelemetry-ext-flask/tests/test_flask_integration.py b/ext/opentelemetry-ext-flask/tests/test_flask_integration.py index 3f9e793d04..6a8c0a424b 100644 --- a/ext/opentelemetry-ext-flask/tests/test_flask_integration.py +++ b/ext/opentelemetry-ext-flask/tests/test_flask_integration.py @@ -18,10 +18,9 @@ from werkzeug.test import Client from werkzeug.wrappers import BaseResponse -import opentelemetry.ext.wsgi.flask_util as otel_flask +import opentelemetry.ext.flask_util as otel_flask from opentelemetry import trace as trace_api - -from .test_wsgi_middleware import WsgiTestBase +from wsgitestutil import WsgiTestBase class TestFlaskIntegration(WsgiTestBase): diff --git a/ext/opentelemetry-ext-flask/tests/test_wsgi_middleware.py b/ext/opentelemetry-ext-flask/tests/test_wsgi_middleware.py deleted file mode 100644 index 232c0844a8..0000000000 --- a/ext/opentelemetry-ext-flask/tests/test_wsgi_middleware.py +++ /dev/null @@ -1,286 +0,0 @@ -# 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 urllib.parse import urlparse - -import opentelemetry.ext.wsgi as otel_wsgi -from opentelemetry import trace as trace_api - - -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 create_gen_wsgi(response): - def gen_wsgi(environ, start_response): - result = create_iter_wsgi(response)(environ, start_response) - yield from result - getattr(result, "close", lambda: None)() - - return gen_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 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 - - -class TestWsgiApplication(WsgiTestBase): - def validate_response(self, response, error=None): - while True: - try: - value = next(response) - self.assertEqual(0, self.span.end.call_count) - self.assertEqual(value, b"*") - except StopIteration: - self.span.end.assert_called_once_with() - 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.create_span.assert_called_with( - "/", trace_api.INVALID_SPAN_CONTEXT, kind=trace_api.SpanKind.SERVER - ) - self.assertEqual(1, self.span.start.call_count) - - def test_basic_wsgi_call(self): - 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 = 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(1, original_response.close_calls) - - def test_wsgi_generator(self): - original_response = Response() - gen_wsgi = create_gen_wsgi(original_response) - 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) - self.validate_response(response) - - # Verify that close has been called exactly once - self.assertEqual(original_response.close_calls, 1) - - def test_wsgi_exc_info(self): - app = otel_wsgi.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): - self.environ["QUERY_STRING"] = "foo=bar" - - otel_wsgi.add_request_attributes(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/?foo=bar"), - ) - self.assertEqual(self.span.set_attribute.call_count, len(expected)) - self.span.set_attribute.assert_has_calls(expected, any_order=True) - - def validate_url(self, expected_url): - 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 - } - self.assertIn("http.url", attrs) - self.assertEqual(attrs["http.url"], expected_url) - self.assertIn("http.host", attrs) - self.assertEqual(attrs["http.host"], urlparse(expected_url).netloc) - - def test_request_attributes_with_partial_raw_uri(self): - self.environ["RAW_URI"] = "/#top" - self.validate_url("http://127.0.0.1/#top") - - def test_request_attributes_with_partial_raw_uri_and_nonstandard_port( - self - ): - self.environ["RAW_URI"] = "/?" - del self.environ["HTTP_HOST"] - self.environ["SERVER_PORT"] = "8080" - self.validate_url("http://127.0.0.1:8080/?") - - def test_https_uri_port(self): - del self.environ["HTTP_HOST"] - self.environ["SERVER_PORT"] = "443" - self.environ["wsgi.url_scheme"] = "https" - self.validate_url("https://127.0.0.1/") - - self.environ["SERVER_PORT"] = "8080" - self.validate_url("https://127.0.0.1:8080/") - - self.environ["SERVER_PORT"] = "80" - self.validate_url("https://127.0.0.1:80/") - - def test_http_uri_port(self): - del self.environ["HTTP_HOST"] - self.environ["SERVER_PORT"] = "80" - self.environ["wsgi.url_scheme"] = "http" - self.validate_url("http://127.0.0.1/") - - self.environ["SERVER_PORT"] = "8080" - self.validate_url("http://127.0.0.1:8080/") - - self.environ["SERVER_PORT"] = "443" - self.validate_url("http://127.0.0.1:443/") - - def test_request_attributes_with_nonstandard_port_and_no_host(self): - del self.environ["HTTP_HOST"] - self.environ["SERVER_PORT"] = "8080" - self.validate_url("http://127.0.0.1:8080/") - - self.environ["SERVER_PORT"] = "443" - self.validate_url("http://127.0.0.1:443/") - - def test_request_attributes_with_nonstandard_port(self): - self.environ["HTTP_HOST"] += ":8080" - self.validate_url("http://127.0.0.1:8080/") - - def test_request_attributes_with_faux_scheme_relative_raw_uri(self): - self.environ["RAW_URI"] = "//127.0.0.1/?" - self.validate_url("http://127.0.0.1//127.0.0.1/?") - - def test_request_attributes_with_pathless_raw_uri(self): - self.environ["PATH_INFO"] = "" - self.environ["RAW_URI"] = "http://hello" - self.environ["HTTP_HOST"] = "hello" - self.validate_url("http://hello") - - def test_request_attributes_with_full_request_uri(self): - self.environ["HTTP_HOST"] = "127.0.0.1:8080" - self.environ["REQUEST_URI"] = "http://127.0.0.1:8080/?foo=bar#top" - self.validate_url("http://127.0.0.1:8080/?foo=bar#top") - - def test_response_attributes(self): - otel_wsgi.add_response_attributes(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): - 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" - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/ext/opentelemetry-ext-wsgi/setup.cfg b/ext/opentelemetry-ext-wsgi/setup.cfg index b0673cec24..4405e37a30 100644 --- a/ext/opentelemetry-ext-wsgi/setup.cfg +++ b/ext/opentelemetry-ext-wsgi/setup.cfg @@ -41,8 +41,5 @@ packages=find_namespace: install_requires = opentelemetry-api -[options.extras_require] -test = flask~=1.0 - [options.packages.find] where = src diff --git a/ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/flask_util.py b/ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/flask_util.py deleted file mode 100644 index 5fb6f04986..0000000000 --- a/ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/flask_util.py +++ /dev/null @@ -1,88 +0,0 @@ -# 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 wrap_flask(flask): - 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 OTel 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 OTel 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-wsgi/tests/test_flask_integration.py b/ext/opentelemetry-ext-wsgi/tests/test_flask_integration.py deleted file mode 100644 index 3f9e793d04..0000000000 --- a/ext/opentelemetry-ext-wsgi/tests/test_flask_integration.py +++ /dev/null @@ -1,139 +0,0 @@ -# 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.wsgi.flask_util as otel_flask -from opentelemetry import trace as trace_api - -from .test_wsgi_middleware 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.wrap_flask(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) - - # 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/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-wsgi/tests/test_wsgi_middleware.py b/ext/opentelemetry-ext-wsgi/tests/test_wsgi_middleware.py index 232c0844a8..e4c9854315 100644 --- a/ext/opentelemetry-ext-wsgi/tests/test_wsgi_middleware.py +++ b/ext/opentelemetry-ext-wsgi/tests/test_wsgi_middleware.py @@ -12,7 +12,6 @@ # 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 @@ -21,6 +20,7 @@ import opentelemetry.ext.wsgi as otel_wsgi from opentelemetry import trace as trace_api +from wsgitestutil import WsgiTestBase class Response: @@ -73,41 +73,6 @@ def error_wsgi(environ, start_response): return [b"*"] -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 - - class TestWsgiApplication(WsgiTestBase): def validate_response(self, response, error=None): while True: diff --git a/ext/test-util/README.txt b/ext/test-util/README.txt new file mode 100644 index 0000000000..5857708731 --- /dev/null +++ b/ext/test-util/README.txt @@ -0,0 +1,3 @@ +This (non-installable) directory should be added to the PYTHONPATH of any unit tests that require it. + +It contains shared uitlities for tests. \ No newline at end of file diff --git a/ext/test-util/wsgitestutil.py b/ext/test-util/wsgitestutil.py new file mode 100644 index 0000000000..d9cc9ff6a9 --- /dev/null +++ b/ext/test-util/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/tox.ini b/tox.ini index 5d4cfcfd74..a29e42ce77 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,7 @@ skipsdist = True skip_missing_interpreters = True envlist = - py3{4,5,6,7,8}-test-{api,sdk,example-app,ext-wsgi,ext-http-requests,ext-jaeger} + py3{4,5,6,7,8}-test-{api,sdk,example-app,ext-wsgi,ext-flask,ext-http-requests,ext-jaeger} pypy3-test-{api,sdk,example-app,ext-wsgi,ext-http-requests,ext-jaeger} lint py37-{mypy,mypyinstalled} @@ -18,6 +18,7 @@ deps = setenv = mypy: MYPYPATH={toxinidir}/opentelemetry-api/src/ + PYTHONPATH={toxinidir}/ext/test-util changedir = test-api: opentelemetry-api/tests @@ -25,6 +26,7 @@ changedir = test-ext-http-requests: ext/opentelemetry-ext-http-requests/tests test-ext-jaeger: ext/opentelemetry-ext-jaeger/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 commands_pre = @@ -35,9 +37,11 @@ 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 ext: pip install {toxinidir}/opentelemetry-api - wsgi: pip install {toxinidir}/ext/opentelemetry-ext-wsgi[test] + wsgi,flask: pip install {toxinidir}/ext/opentelemetry-ext-wsgi + flask: pip install {toxinidir}/ext/opentelemetry-ext-flask[test] http-requests: pip install {toxinidir}/ext/opentelemetry-ext-http-requests jaeger: pip install {toxinidir}/opentelemetry-sdk jaeger: pip install {toxinidir}/ext/opentelemetry-ext-jaeger @@ -72,7 +76,8 @@ commands_pre = pip install -e {toxinidir}/ext/opentelemetry-ext-azure-monitor pip install -e {toxinidir}/ext/opentelemetry-ext-http-requests pip install -e {toxinidir}/ext/opentelemetry-ext-jaeger - pip install -e {toxinidir}/ext/opentelemetry-ext-wsgi[test] + 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 commands = @@ -88,18 +93,19 @@ commands = ext/opentelemetry-ext-http-requests/src/opentelemetry \ ext/opentelemetry-ext-jaeger/src/opentelemetry \ ext/opentelemetry-ext-wsgi/src/opentelemetry \ - examples/opentelemetry-example-app/src/opentelemetry_example_app \ - examples/opentelemetry-example-app/tests - -; The tests need extra pylint invocations, otherwise we get name clashes within the "tests" modules -; (symptom: imports from test don't work) + ext/opentelemetry-ext-flask/src/opentelemetry \ + examples/opentelemetry-example-app/src/opentelemetry_example_app +; The tests need extra pylint invocations, otherwise we get name clashes within the "tests" packages +; (symptom: imports from tests don't work) + pylint examples/opentelemetry-example-app/tests pylint opentelemetry-sdk/tests pylint opentelemetry-api/tests pylint ext/opentelemetry-ext-azure-monitor/tests pylint ext/opentelemetry-ext-http-requests/tests pylint ext/opentelemetry-ext-jaeger/tests pylint ext/opentelemetry-ext-wsgi/tests + pylint ext/opentelemetry-ext-flask/tests flake8 . isort --recursive . From 772d999f12028b0fa45ca18afe061b74f5e3fed3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Neum=C3=BCller?= Date: Tue, 15 Oct 2019 18:32:47 +0200 Subject: [PATCH 07/15] Undo local test changes. --- tox.ini | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index a29e42ce77..97ad7c4255 100644 --- a/tox.ini +++ b/tox.ini @@ -81,8 +81,9 @@ commands_pre = pip install -e {toxinidir}/examples/opentelemetry-example-app commands = - black . - + + black --check --diff . + ; Prefer putting everything in one pylint command to profit from duplication ; warnings. pylint \ @@ -108,7 +109,7 @@ commands = pylint ext/opentelemetry-ext-flask/tests flake8 . - isort --recursive . + isort --check-only --diff --recursive . [testenv:docs] deps = From 4715aa6f5a9a659f3946a5ce2abbff01a7f4666b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Neum=C3=BCller?= Date: Tue, 15 Oct 2019 18:54:15 +0200 Subject: [PATCH 08/15] wrap_flask -> instrument_app, docs. --- .../flask_example.py | 4 +- ext/opentelemetry-ext-flask/README.rst | 37 ++++++------------- .../opentelemetry/ext/flask_util/__init__.py | 7 +++- .../tests/test_flask_integration.py | 2 +- 4 files changed, 21 insertions(+), 29 deletions(-) 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 0b4c7a6790..18883e0a8b 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.flask_util import wrap_flask +from opentelemetry.ext.flask_util 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()) - wrap_flask(flask_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 index d47643c871..04b5ed129c 100644 --- a/ext/opentelemetry-ext-flask/README.rst +++ b/ext/opentelemetry-ext-flask/README.rst @@ -1,20 +1,23 @@ -OpenTelemetry WSGI Middleware -============================= +OpenTelemetry Flask tracing +=========================== -This library provides a WSGI middleware that can be used on any WSGI framework -(such as Django / Flask) to track requests timing through OpenTelemetry. +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 (Flask) -------------- +Usage +----- .. code-block:: python from flask import Flask - from opentelemetry.ext.wsgi import OpenTelemetryMiddleware + from opentelemetry.ext.flask_util import instrument_app app = Flask(__name__) - app.wsgi_app = OpenTelemetryMiddleware(app.wsgi_app) + instrument_app(app) # This is where the magic happens. ✨ @app.route("/") def hello(): @@ -24,24 +27,8 @@ Usage (Flask) 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 `_ +* `OpenTelemetry WSGI extension `_ diff --git a/ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask_util/__init__.py b/ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask_util/__init__.py index 5a7481693f..eedc8d5998 100644 --- a/ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask_util/__init__.py +++ b/ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask_util/__init__.py @@ -16,7 +16,12 @@ _ENVIRON_ACTIVATION_KEY = object() -def wrap_flask(flask): +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): diff --git a/ext/opentelemetry-ext-flask/tests/test_flask_integration.py b/ext/opentelemetry-ext-flask/tests/test_flask_integration.py index 6a8c0a424b..bca804ccde 100644 --- a/ext/opentelemetry-ext-flask/tests/test_flask_integration.py +++ b/ext/opentelemetry-ext-flask/tests/test_flask_integration.py @@ -44,7 +44,7 @@ def hello_endpoint(helloid): self.app.route("/hello/")(hello_endpoint) - otel_flask.wrap_flask(self.app) + otel_flask.instrument_app(self.app) self.client = Client(self.app, BaseResponse) def test_simple(self): From c3d4d98069304c0ce050ded264e8676cf84c841e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Neum=C3=BCller?= Date: Tue, 12 Nov 2019 17:56:38 +0100 Subject: [PATCH 09/15] Fix merge. --- .../src/opentelemetry/ext/flask_util/version.py | 2 +- tox.ini | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask_util/version.py b/ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask_util/version.py index a457c2b665..ed56d30324 100644 --- a/ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask_util/version.py +++ b/ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask_util/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.1.dev0" +__version__ = "0.3dev0" diff --git a/tox.ini b/tox.ini index 4e1cf5b8f6..1e5b5a3116 100644 --- a/tox.ini +++ b/tox.ini @@ -67,6 +67,7 @@ commands_pre = 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-wsgi + coverage: pip install -e {toxinidir}/ext/opentelemetry-ext-flask coverage: pip install -e {toxinidir}/examples/opentelemetry-example-app ; Using file:// here because otherwise tox invokes just "pip install @@ -119,7 +120,7 @@ commands = ext/opentelemetry-ext-azure-monitor/src/opentelemetry \ ext/opentelemetry-ext-http-requests/src/opentelemetry \ ext/opentelemetry-ext-jaeger/src/opentelemetry \ - ext/opentelemetry-ext-opentracing-shim/src/ \ + ext/opentelemetry-ext-opentracing-shim/src/opentelemetry \ ext/opentelemetry-ext-pymongo/src/opentelemetry \ ext/opentelemetry-ext-wsgi/src/opentelemetry \ ext/opentelemetry-ext-flask/src/opentelemetry \ From 6daa08777ea4b8dd09aec364f47715f41c101e39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Neum=C3=BCller?= Date: Wed, 13 Nov 2019 11:03:35 +0100 Subject: [PATCH 10/15] flask integration: Rework build. --- .../flask_example.py | 2 +- ext/opentelemetry-ext-flask/setup.cfg | 4 +- ext/opentelemetry-ext-flask/setup.py | 2 +- .../{flask_util => _flask_impl}/__init__.py | 0 .../src/opentelemetry/ext/flask/__init__.py | 1 + .../ext/{flask_util => flask}/version.py | 0 .../tests/test_flask_integration.py | 4 +- ext/opentelemetry-ext-testutil/setup.cfg | 47 +++++++++++++++ ext/opentelemetry-ext-testutil/setup.py | 26 +++++++++ .../opentelemetry/ext/testutil/__init__.py | 0 .../src/opentelemetry/ext/testutil/version.py | 1 + .../ext/testutil}/wsgitestutil.py | 0 ext/opentelemetry-ext-wsgi/setup.cfg | 4 ++ .../tests/test_wsgi_middleware.py | 2 +- ext/test-util/README.txt | 3 - tox.ini | 58 +++++++++---------- 16 files changed, 114 insertions(+), 40 deletions(-) rename ext/opentelemetry-ext-flask/src/opentelemetry/ext/{flask_util => _flask_impl}/__init__.py (100%) create mode 100644 ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask/__init__.py rename ext/opentelemetry-ext-flask/src/opentelemetry/ext/{flask_util => flask}/version.py (100%) create mode 100644 ext/opentelemetry-ext-testutil/setup.cfg create mode 100644 ext/opentelemetry-ext-testutil/setup.py create mode 100644 ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/__init__.py create mode 100644 ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/version.py rename ext/{test-util => opentelemetry-ext-testutil/src/opentelemetry/ext/testutil}/wsgitestutil.py (100%) delete mode 100644 ext/test-util/README.txt 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 1161ec42a6..85df625efe 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.flask_util import instrument_app +from opentelemetry.ext.flask import instrument_app from opentelemetry.sdk.context.propagation.b3_format import B3Format from opentelemetry.sdk.trace import Tracer diff --git a/ext/opentelemetry-ext-flask/setup.cfg b/ext/opentelemetry-ext-flask/setup.cfg index 238202bcc2..1d4956a82f 100644 --- a/ext/opentelemetry-ext-flask/setup.cfg +++ b/ext/opentelemetry-ext-flask/setup.cfg @@ -42,7 +42,9 @@ install_requires = opentelemetry-ext-wsgi [options.extras_require] -test = flask~=1.0 +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 index 59aa3d5880..34b27c6034 100644 --- a/ext/opentelemetry-ext-flask/setup.py +++ b/ext/opentelemetry-ext-flask/setup.py @@ -17,7 +17,7 @@ BASE_DIR = os.path.dirname(__file__) VERSION_FILENAME = os.path.join( - BASE_DIR, "src", "opentelemetry", "ext", "flask_util", "version.py" + BASE_DIR, "src", "opentelemetry", "ext", "flask", "version.py" ) PACKAGE_INFO = {} with open(VERSION_FILENAME) as f: diff --git a/ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask_util/__init__.py b/ext/opentelemetry-ext-flask/src/opentelemetry/ext/_flask_impl/__init__.py similarity index 100% rename from ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask_util/__init__.py rename to ext/opentelemetry-ext-flask/src/opentelemetry/ext/_flask_impl/__init__.py 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 0000000000..1740412bc1 --- /dev/null +++ b/ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask/__init__.py @@ -0,0 +1 @@ +from opentelemetry.ext._flask_impl import instrument_app, logger diff --git a/ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask_util/version.py b/ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask/version.py similarity index 100% rename from ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask_util/version.py rename to ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask/version.py diff --git a/ext/opentelemetry-ext-flask/tests/test_flask_integration.py b/ext/opentelemetry-ext-flask/tests/test_flask_integration.py index bca804ccde..01f57e9cf8 100644 --- a/ext/opentelemetry-ext-flask/tests/test_flask_integration.py +++ b/ext/opentelemetry-ext-flask/tests/test_flask_integration.py @@ -18,9 +18,9 @@ from werkzeug.test import Client from werkzeug.wrappers import BaseResponse -import opentelemetry.ext.flask_util as otel_flask +import opentelemetry.ext.flask as otel_flask from opentelemetry import trace as trace_api -from wsgitestutil import WsgiTestBase +from opentelemetry.ext.testutil.wsgitestutil import WsgiTestBase class TestFlaskIntegration(WsgiTestBase): diff --git a/ext/opentelemetry-ext-testutil/setup.cfg b/ext/opentelemetry-ext-testutil/setup.cfg new file mode 100644 index 0000000000..170e949cf6 --- /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 0000000000..9de576d356 --- /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 0000000000..e69de29bb2 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 0000000000..8792d3b665 --- /dev/null +++ b/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/version.py @@ -0,0 +1 @@ +__version__ = '0.3dev0' \ No newline at end of file diff --git a/ext/test-util/wsgitestutil.py b/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/wsgitestutil.py similarity index 100% rename from ext/test-util/wsgitestutil.py rename to ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/wsgitestutil.py diff --git a/ext/opentelemetry-ext-wsgi/setup.cfg b/ext/opentelemetry-ext-wsgi/setup.cfg index 4405e37a30..1db49209be 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/tests/test_wsgi_middleware.py b/ext/opentelemetry-ext-wsgi/tests/test_wsgi_middleware.py index f51f05808b..9c77acbb75 100644 --- a/ext/opentelemetry-ext-wsgi/tests/test_wsgi_middleware.py +++ b/ext/opentelemetry-ext-wsgi/tests/test_wsgi_middleware.py @@ -20,7 +20,7 @@ import opentelemetry.ext.wsgi as otel_wsgi from opentelemetry import trace as trace_api -from wsgitestutil import WsgiTestBase +from opentelemetry.ext.testutil.wsgitestutil import WsgiTestBase class Response: diff --git a/ext/test-util/README.txt b/ext/test-util/README.txt deleted file mode 100644 index 5857708731..0000000000 --- a/ext/test-util/README.txt +++ /dev/null @@ -1,3 +0,0 @@ -This (non-installable) directory should be added to the PYTHONPATH of any unit tests that require it. - -It contains shared uitlities for tests. \ No newline at end of file diff --git a/tox.ini b/tox.ini index 1e5b5a3116..339ba33227 100644 --- a/tox.ini +++ b/tox.ini @@ -26,7 +26,6 @@ deps = setenv = mypy: MYPYPATH={toxinidir}/opentelemetry-api/src/ - PYTHONPATH={toxinidir}/ext/test-util changedir = test-api: opentelemetry-api/tests @@ -50,7 +49,8 @@ commands_pre = example-app: pip install {toxinidir}/ext/opentelemetry-ext-flask example-app: pip install {toxinidir}/examples/opentelemetry-example-app ext: pip install {toxinidir}/opentelemetry-api - wsgi,flask: pip install {toxinidir}/ext/opentelemetry-ext-wsgi + wsgi,flask: pip install {toxinidir}/ext/opentelemetry-ext-testutil + wsgi: 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 @@ -66,8 +66,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 + 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 @@ -102,44 +103,38 @@ 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 commands = - - black --check --diff . - ; Prefer putting everything in one pylint command to profit from duplication ; warnings. - pylint \ - opentelemetry-api/src/opentelemetry \ + black --check --diff . + pylint opentelemetry-api/src/opentelemetry \ + opentelemetry-api/tests/ \ opentelemetry-sdk/src/opentelemetry \ - ext/opentelemetry-ext-azure-monitor/examples \ - ext/opentelemetry-ext-azure-monitor/src/opentelemetry \ - ext/opentelemetry-ext-http-requests/src/opentelemetry \ + opentelemetry-sdk/tests/ \ + ext/opentelemetry-ext-azure-monitor/examples/ \ + ext/opentelemetry-ext-azure-monitor/src/ \ + ext/opentelemetry-ext-azure-monitor/tests/ \ + ext/opentelemetry-ext-http-requests/src/ \ + ext/opentelemetry-ext-http-requests/tests/ \ ext/opentelemetry-ext-jaeger/src/opentelemetry \ - ext/opentelemetry-ext-opentracing-shim/src/opentelemetry \ - ext/opentelemetry-ext-pymongo/src/opentelemetry \ - ext/opentelemetry-ext-wsgi/src/opentelemetry \ - ext/opentelemetry-ext-flask/src/opentelemetry \ - examples/opentelemetry-example-app/src/opentelemetry_example_app - -; The tests need extra pylint invocations, otherwise we get name clashes within the "tests" packages -; (symptom: imports from tests don't work) - pylint examples/opentelemetry-example-app/tests - pylint opentelemetry-sdk/tests - pylint opentelemetry-api/tests - pylint ext/opentelemetry-ext-azure-monitor/tests - pylint ext/opentelemetry-ext-http-requests/tests - pylint ext/opentelemetry-ext-jaeger/tests - pylint ext/opentelemetry-ext-opentracing-shim/tests - pylint ext/opentelemetry-ext-wsgi/tests - pylint ext/opentelemetry-ext-flask/tests - pylint ext/opentelemetry-ext-pymongo/tests - - + ext/opentelemetry-ext-jaeger/tests/ \ + ext/opentelemetry-ext-opentracing-shim/src/ \ + ext/opentelemetry-ext-opentracing-shim/tests/ \ + ext/opentelemetry-ext-pymongo/src/opentelemetry \ + ext/opentelemetry-ext-pymongo/tests/ \ + ext/opentelemetry-ext-testutil/src/ \ + 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/ flake8 . isort --check-only --diff --recursive . @@ -168,6 +163,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 From 2357136750483191b262b96846b41e7a1e79c3cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Neum=C3=BCller?= Date: Wed, 13 Nov 2019 11:49:36 +0100 Subject: [PATCH 11/15] Fix lint, reformat README. --- ext/opentelemetry-ext-flask/README.rst | 3 ++- .../src/opentelemetry/ext/testutil/version.py | 2 +- tox.ini | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ext/opentelemetry-ext-flask/README.rst b/ext/opentelemetry-ext-flask/README.rst index 04b5ed129c..dda1a7aba6 100644 --- a/ext/opentelemetry-ext-flask/README.rst +++ b/ext/opentelemetry-ext-flask/README.rst @@ -6,7 +6,8 @@ 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. +* The ``http.route`` Span attribute is set so that one can see which URL rule + matched a request. Usage ----- diff --git a/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/version.py b/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/version.py index 8792d3b665..70ddecedf8 100644 --- a/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/version.py +++ b/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/version.py @@ -1 +1 @@ -__version__ = '0.3dev0' \ No newline at end of file +__version__ = "0.3dev0" diff --git a/tox.ini b/tox.ini index 339ba33227..b50e1bfc91 100644 --- a/tox.ini +++ b/tox.ini @@ -128,7 +128,7 @@ commands = ext/opentelemetry-ext-opentracing-shim/tests/ \ ext/opentelemetry-ext-pymongo/src/opentelemetry \ ext/opentelemetry-ext-pymongo/tests/ \ - ext/opentelemetry-ext-testutil/src/ \ + ext/opentelemetry-ext-testutil/src/opentelemetry \ ext/opentelemetry-ext-wsgi/src/ \ ext/opentelemetry-ext-wsgi/tests/ \ ext/opentelemetry-ext-flask/src/ \ From 636ffdf4f6d8e5ed12e2788ed08e7696e8ee94a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Neum=C3=BCller?= Date: Wed, 13 Nov 2019 13:04:16 +0100 Subject: [PATCH 12/15] Fix test-ext-flask env. --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index b50e1bfc91..2b5fac83a2 100644 --- a/tox.ini +++ b/tox.ini @@ -50,7 +50,7 @@ commands_pre = example-app: pip install {toxinidir}/examples/opentelemetry-example-app ext: pip install {toxinidir}/opentelemetry-api wsgi,flask: pip install {toxinidir}/ext/opentelemetry-ext-testutil - wsgi: pip install {toxinidir}/ext/opentelemetry-ext-wsgi + 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 From 2f7938c6e1fbf26683be62c6d4663456e59c9993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Neum=C3=BCller?= Date: Wed, 13 Nov 2019 13:06:45 +0100 Subject: [PATCH 13/15] Remove _flask_impl workaround. Pylint seems to grok it now. --- .../opentelemetry/ext/_flask_impl/__init__.py | 93 ------------------ .../src/opentelemetry/ext/flask/__init__.py | 94 ++++++++++++++++++- 2 files changed, 93 insertions(+), 94 deletions(-) delete mode 100644 ext/opentelemetry-ext-flask/src/opentelemetry/ext/_flask_impl/__init__.py diff --git a/ext/opentelemetry-ext-flask/src/opentelemetry/ext/_flask_impl/__init__.py b/ext/opentelemetry-ext-flask/src/opentelemetry/ext/_flask_impl/__init__.py deleted file mode 100644 index eedc8d5998..0000000000 --- a/ext/opentelemetry-ext-flask/src/opentelemetry/ext/_flask_impl/__init__.py +++ /dev/null @@ -1,93 +0,0 @@ -# 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/__init__.py b/ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask/__init__.py index 1740412bc1..eedc8d5998 100644 --- a/ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask/__init__.py +++ b/ext/opentelemetry-ext-flask/src/opentelemetry/ext/flask/__init__.py @@ -1 +1,93 @@ -from opentelemetry.ext._flask_impl import instrument_app, logger +# 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) + ) From 1a8ee17291008b96ffe9bb8364644a882f85253c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Neum=C3=BCller?= Date: Fri, 15 Nov 2019 17:39:34 +0100 Subject: [PATCH 14/15] Fix import in README. Co-Authored-By: alrex --- ext/opentelemetry-ext-flask/README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ext/opentelemetry-ext-flask/README.rst b/ext/opentelemetry-ext-flask/README.rst index dda1a7aba6..182f0960b2 100644 --- a/ext/opentelemetry-ext-flask/README.rst +++ b/ext/opentelemetry-ext-flask/README.rst @@ -15,7 +15,7 @@ Usage .. code-block:: python from flask import Flask - from opentelemetry.ext.flask_util import instrument_app + from opentelemetry.ext.flask import instrument_app app = Flask(__name__) instrument_app(app) # This is where the magic happens. ✨ From 35c6595202d0bc057818165df2e642c1a6a13ad2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Neum=C3=BCller?= Date: Fri, 15 Nov 2019 17:47:29 +0100 Subject: [PATCH 15/15] Remove useless comment. --- ext/opentelemetry-ext-flask/tests/test_flask_integration.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/ext/opentelemetry-ext-flask/tests/test_flask_integration.py b/ext/opentelemetry-ext-flask/tests/test_flask_integration.py index 01f57e9cf8..dfb9dee885 100644 --- a/ext/opentelemetry-ext-flask/tests/test_flask_integration.py +++ b/ext/opentelemetry-ext-flask/tests/test_flask_integration.py @@ -59,8 +59,6 @@ def test_simple(self): ) 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(