From c2502a1042189bde5d8973f85e4250e361d29a82 Mon Sep 17 00:00:00 2001 From: Emil Madsen Date: Sat, 8 Feb 2020 15:52:23 +0100 Subject: [PATCH 01/15] ext-wsgi duplicated as ext-asgi --- ext/opentelemetry-ext-asgi/CHANGELOG.md | 23 ++ ext/opentelemetry-ext-asgi/README.rst | 60 ++++ ext/opentelemetry-ext-asgi/setup.cfg | 49 +++ ext/opentelemetry-ext-asgi/setup.py | 26 ++ .../src/opentelemetry/ext/wsgi/__init__.py | 219 +++++++++++++ .../src/opentelemetry/ext/wsgi/version.py | 15 + ext/opentelemetry-ext-asgi/tests/__init__.py | 0 .../tests/test_wsgi_middleware.py | 297 ++++++++++++++++++ 8 files changed, 689 insertions(+) create mode 100644 ext/opentelemetry-ext-asgi/CHANGELOG.md create mode 100644 ext/opentelemetry-ext-asgi/README.rst create mode 100644 ext/opentelemetry-ext-asgi/setup.cfg create mode 100644 ext/opentelemetry-ext-asgi/setup.py create mode 100644 ext/opentelemetry-ext-asgi/src/opentelemetry/ext/wsgi/__init__.py create mode 100644 ext/opentelemetry-ext-asgi/src/opentelemetry/ext/wsgi/version.py create mode 100644 ext/opentelemetry-ext-asgi/tests/__init__.py create mode 100644 ext/opentelemetry-ext-asgi/tests/test_wsgi_middleware.py diff --git a/ext/opentelemetry-ext-asgi/CHANGELOG.md b/ext/opentelemetry-ext-asgi/CHANGELOG.md new file mode 100644 index 00000000000..62d8a5baf0f --- /dev/null +++ b/ext/opentelemetry-ext-asgi/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +## Unreleased + +## 0.3a0 + +Released 2019-12-11 + +- Support new semantic conventions + ([#299](https://github.com/open-telemetry/opentelemetry-python/pull/299)) +- Updates for core library changes + +## 0.2a0 + +Released 2019-10-29 + +- Updates for core library changes + +## 0.1a0 + +Released 2019-09-30 + +- Initial release diff --git a/ext/opentelemetry-ext-asgi/README.rst b/ext/opentelemetry-ext-asgi/README.rst new file mode 100644 index 00000000000..82641bcaa4a --- /dev/null +++ b/ext/opentelemetry-ext-asgi/README.rst @@ -0,0 +1,60 @@ +OpenTelemetry WSGI Middleware +============================= + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-ext-wsgi.svg + :target: https://pypi.org/project/opentelemetry-ext-wsgi/ + + +This library provides a WSGI middleware that can be used on any WSGI framework +(such as Django / Flask) to track requests timing through OpenTelemetry. + +Installation +------------ + +:: + + pip install opentelemetry-ext-wsgi + + +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-asgi/setup.cfg b/ext/opentelemetry-ext-asgi/setup.cfg new file mode 100644 index 00000000000..1db49209bec --- /dev/null +++ b/ext/opentelemetry-ext-asgi/setup.cfg @@ -0,0 +1,49 @@ +# 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 = + opentelemetry-ext-testutil + +[options.packages.find] +where = src diff --git a/ext/opentelemetry-ext-asgi/setup.py b/ext/opentelemetry-ext-asgi/setup.py new file mode 100644 index 00000000000..3f8ef9cc5fc --- /dev/null +++ b/ext/opentelemetry-ext-asgi/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", "wsgi", "version.py" +) +PACKAGE_INFO = {} +with open(VERSION_FILENAME) as f: + exec(f.read(), PACKAGE_INFO) + +setuptools.setup(version=PACKAGE_INFO["__version__"]) diff --git a/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/wsgi/__init__.py b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/wsgi/__init__.py new file mode 100644 index 00000000000..e6751f34ced --- /dev/null +++ b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/wsgi/__init__.py @@ -0,0 +1,219 @@ +# 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__ +from opentelemetry.trace.status import Status, StatusCanonicalCode + +_HTTP_VERSION_PREFIX = "HTTP/" + + +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 setifnotnone(dic, key, value): + if value is not None: + dic[key] = value + + +def http_status_to_canonical_code(code: int, allow_redirect: bool = True): + # pylint:disable=too-many-branches,too-many-return-statements + if code < 100: + return StatusCanonicalCode.UNKNOWN + if code <= 299: + return StatusCanonicalCode.OK + if code <= 399: + if allow_redirect: + return StatusCanonicalCode.OK + return StatusCanonicalCode.DEADLINE_EXCEEDED + if code <= 499: + if code == 401: # HTTPStatus.UNAUTHORIZED: + return StatusCanonicalCode.UNAUTHENTICATED + if code == 403: # HTTPStatus.FORBIDDEN: + return StatusCanonicalCode.PERMISSION_DENIED + if code == 404: # HTTPStatus.NOT_FOUND: + return StatusCanonicalCode.NOT_FOUND + if code == 429: # HTTPStatus.TOO_MANY_REQUESTS: + return StatusCanonicalCode.RESOURCE_EXHAUSTED + return StatusCanonicalCode.INVALID_ARGUMENT + if code <= 599: + if code == 501: # HTTPStatus.NOT_IMPLEMENTED: + return StatusCanonicalCode.UNIMPLEMENTED + if code == 503: # HTTPStatus.SERVICE_UNAVAILABLE: + return StatusCanonicalCode.UNAVAILABLE + if code == 504: # HTTPStatus.GATEWAY_TIMEOUT: + return StatusCanonicalCode.DEADLINE_EXCEEDED + return StatusCanonicalCode.INTERNAL + return StatusCanonicalCode.UNKNOWN + + +def collect_request_attributes(environ): + """Collects HTTP request attributes from the PEP3333-conforming + WSGI environ and returns a dictionary to be used as span creation attributes.""" + + result = { + "component": "http", + "http.method": environ["REQUEST_METHOD"], + "http.server_name": environ["SERVER_NAME"], + "http.scheme": environ["wsgi.url_scheme"], + "host.port": int(environ["SERVER_PORT"]), + } + + setifnotnone(result, "http.host", environ.get("HTTP_HOST")) + target = environ.get("RAW_URI") + if target is None: # Note: `"" or None is None` + target = environ.get("REQUEST_URI") + if target is not None: + result["http.target"] = target + else: + result["http.url"] = wsgiref_util.request_uri(environ) + + remote_addr = environ.get("REMOTE_ADDR") + if remote_addr: + result["net.peer.ip"] = remote_addr + remote_host = environ.get("REMOTE_HOST") + if remote_host and remote_host != remote_addr: + result["net.peer.name"] = remote_host + + setifnotnone(result, "net.peer.port", environ.get("REMOTE_PORT")) + flavor = environ.get("SERVER_PROTOCOL", "") + if flavor.upper().startswith(_HTTP_VERSION_PREFIX): + flavor = flavor[len(_HTTP_VERSION_PREFIX) :] + if flavor: + result["http.flavor"] = flavor + + return result + + +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: + span.set_status( + Status( + StatusCanonicalCode.UNKNOWN, + "Non-integer HTTP status: " + repr(status_code), + ) + ) + else: + span.set_attribute("http.status_code", status_code) + span.set_status(Status(http_status_to_canonical_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 + self.tracer = trace.tracer_source().get_tracer(__name__, __version__) + + @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. + """ + + parent_span = propagators.extract(get_header_from_environ, environ) + span_name = get_default_span_name(environ) + + span = self.tracer.start_span( + span_name, + parent_span, + kind=trace.SpanKind.SERVER, + attributes=collect_request_attributes(environ), + ) + + try: + with self.tracer.use_span(span): + start_response = self._create_start_response( + span, start_response + ) + iterable = self.wsgi(environ, start_response) + return _end_span_after_iterating(iterable, span, self.tracer) + except: # noqa + # TODO Set span status (cf. https://github.com/open-telemetry/opentelemetry-python/issues/292) + 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-asgi/src/opentelemetry/ext/wsgi/version.py b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/wsgi/version.py new file mode 100644 index 00000000000..2f792fff802 --- /dev/null +++ b/ext/opentelemetry-ext-asgi/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.4.dev0" diff --git a/ext/opentelemetry-ext-asgi/tests/__init__.py b/ext/opentelemetry-ext-asgi/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ext/opentelemetry-ext-asgi/tests/test_wsgi_middleware.py b/ext/opentelemetry-ext-asgi/tests/test_wsgi_middleware.py new file mode 100644 index 00000000000..1912dd0079f --- /dev/null +++ b/ext/opentelemetry-ext-asgi/tests/test_wsgi_middleware.py @@ -0,0 +1,297 @@ +# 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 sys +import unittest +import unittest.mock as mock +import wsgiref.util as wsgiref_util +from urllib.parse import urlsplit + +import opentelemetry.ext.wsgi as otel_wsgi +from opentelemetry import trace as trace_api +from opentelemetry.ext.testutil.wsgitestutil import WsgiTestBase + + +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 TestWsgiApplication(WsgiTestBase): + def validate_response(self, response, error=None): + while True: + try: + value = next(response) + self.assertEqual(value, b"*") + except StopIteration: + 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) + + span_list = self.memory_exporter.get_finished_spans() + self.assertEqual(len(span_list), 1) + self.assertEqual(span_list[0].name, "/") + self.assertEqual(span_list[0].kind, trace_api.SpanKind.SERVER) + self.assertEqual( + span_list[0].attributes, + { + "component": "http", + "http.method": "GET", + "http.server_name": "127.0.0.1", + "http.scheme": "http", + "host.port": 80, + "http.host": "127.0.0.1", + "http.flavor": "1.0", + "http.url": "http://127.0.0.1/", + "http.status_text": "OK", + "http.status_code": 200, + }, + ) + + 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" + + attrs = otel_wsgi.collect_request_attributes(self.environ) + self.assertDictEqual( + attrs, + { + "component": "http", + "http.method": "GET", + "http.host": "127.0.0.1", + "http.url": "http://127.0.0.1/?foo=bar", + "host.port": 80, + "http.scheme": "http", + "http.server_name": "127.0.0.1", + "http.flavor": "1.0", + }, + ) + + def validate_url(self, expected_url, raw=False, has_host=True): + parts = urlsplit(expected_url) + expected = { + "http.scheme": parts.scheme, + "host.port": parts.port or (80 if parts.scheme == "http" else 443), + "http.server_name": parts.hostname, # Not true in the general case, but for all tests. + } + if raw: + expected["http.target"] = expected_url.split(parts.netloc, 1)[1] + else: + expected["http.url"] = expected_url + if has_host: + expected["http.host"] = parts.hostname + + attrs = otel_wsgi.collect_request_attributes(self.environ) + self.assertGreaterEqual( + attrs.items(), expected.items(), expected_url + " expected." + ) + + def test_request_attributes_with_partial_raw_uri(self): + self.environ["RAW_URI"] = "/#top" + self.validate_url("http://127.0.0.1/#top", raw=True) + + 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/?", raw=True, has_host=False) + + 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/", has_host=False) + + self.environ["SERVER_PORT"] = "8080" + self.validate_url("https://127.0.0.1:8080/", has_host=False) + + self.environ["SERVER_PORT"] = "80" + self.validate_url("https://127.0.0.1:80/", has_host=False) + + 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/", has_host=False) + + self.environ["SERVER_PORT"] = "8080" + self.validate_url("http://127.0.0.1:8080/", has_host=False) + + self.environ["SERVER_PORT"] = "443" + self.validate_url("http://127.0.0.1:443/", has_host=False) + + 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/", has_host=False) + + self.environ["SERVER_PORT"] = "443" + self.validate_url("http://127.0.0.1:443/", has_host=False) + + def test_request_attributes_with_conflicting_nonstandard_port(self): + self.environ[ + "HTTP_HOST" + ] += ":8080" # Note that we do not correct SERVER_PORT + expected = { + "http.host": "127.0.0.1:8080", + "http.url": "http://127.0.0.1:8080/", + "host.port": 80, + } + self.assertGreaterEqual( + otel_wsgi.collect_request_attributes(self.environ).items(), + expected.items(), + ) + + 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/?", raw=True) + + def test_request_attributes_pathless(self): + self.environ["RAW_URI"] = "" + expected = {"http.target": ""} + self.assertGreaterEqual( + otel_wsgi.collect_request_attributes(self.environ).items(), + expected.items(), + ) + + def test_request_attributes_with_full_request_uri(self): + self.environ["HTTP_HOST"] = "127.0.0.1:8080" + self.environ["REQUEST_METHOD"] = "CONNECT" + self.environ[ + "REQUEST_URI" + ] = "127.0.0.1:8080" # Might happen in a CONNECT request + expected = { + "http.host": "127.0.0.1:8080", + "http.target": "127.0.0.1:8080", + } + self.assertGreaterEqual( + otel_wsgi.collect_request_attributes(self.environ).items(), + expected.items(), + ) + + 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() From b38e378b0d2a25418f273b92f1614325ca350726 Mon Sep 17 00:00:00 2001 From: Emil Madsen Date: Sat, 8 Feb 2020 16:08:38 +0100 Subject: [PATCH 02/15] Rename WSGI files --- .../src/opentelemetry/ext/{wsgi => asgi}/__init__.py | 0 .../src/opentelemetry/ext/{wsgi => asgi}/version.py | 0 .../tests/{test_wsgi_middleware.py => test_asgi_middleware.py} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename ext/opentelemetry-ext-asgi/src/opentelemetry/ext/{wsgi => asgi}/__init__.py (100%) rename ext/opentelemetry-ext-asgi/src/opentelemetry/ext/{wsgi => asgi}/version.py (100%) rename ext/opentelemetry-ext-asgi/tests/{test_wsgi_middleware.py => test_asgi_middleware.py} (100%) diff --git a/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/wsgi/__init__.py b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py similarity index 100% rename from ext/opentelemetry-ext-asgi/src/opentelemetry/ext/wsgi/__init__.py rename to ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py diff --git a/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/wsgi/version.py b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/version.py similarity index 100% rename from ext/opentelemetry-ext-asgi/src/opentelemetry/ext/wsgi/version.py rename to ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/version.py diff --git a/ext/opentelemetry-ext-asgi/tests/test_wsgi_middleware.py b/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py similarity index 100% rename from ext/opentelemetry-ext-asgi/tests/test_wsgi_middleware.py rename to ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py From 9b09035095109cac6537e5fda12628d14494014f Mon Sep 17 00:00:00 2001 From: Emil Madsen Date: Sat, 8 Feb 2020 16:09:17 +0100 Subject: [PATCH 03/15] WSGI to ASGI --- ext/opentelemetry-ext-asgi/CHANGELOG.md | 20 - ext/opentelemetry-ext-asgi/README.rst | 33 +- ext/opentelemetry-ext-asgi/setup.cfg | 9 +- ext/opentelemetry-ext-asgi/setup.py | 2 +- .../src/opentelemetry/ext/asgi/__init__.py | 204 +++++----- .../tests/test_asgi_middleware.py | 373 +++++++----------- 6 files changed, 246 insertions(+), 395 deletions(-) diff --git a/ext/opentelemetry-ext-asgi/CHANGELOG.md b/ext/opentelemetry-ext-asgi/CHANGELOG.md index 62d8a5baf0f..1512c421622 100644 --- a/ext/opentelemetry-ext-asgi/CHANGELOG.md +++ b/ext/opentelemetry-ext-asgi/CHANGELOG.md @@ -1,23 +1,3 @@ # Changelog ## Unreleased - -## 0.3a0 - -Released 2019-12-11 - -- Support new semantic conventions - ([#299](https://github.com/open-telemetry/opentelemetry-python/pull/299)) -- Updates for core library changes - -## 0.2a0 - -Released 2019-10-29 - -- Updates for core library changes - -## 0.1a0 - -Released 2019-09-30 - -- Initial release diff --git a/ext/opentelemetry-ext-asgi/README.rst b/ext/opentelemetry-ext-asgi/README.rst index 82641bcaa4a..09e8bcc1fa0 100644 --- a/ext/opentelemetry-ext-asgi/README.rst +++ b/ext/opentelemetry-ext-asgi/README.rst @@ -1,13 +1,13 @@ -OpenTelemetry WSGI Middleware +OpenTelemetry ASGI Middleware ============================= |pypi| -.. |pypi| image:: https://badge.fury.io/py/opentelemetry-ext-wsgi.svg - :target: https://pypi.org/project/opentelemetry-ext-wsgi/ +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-ext-asgi.svg + :target: https://pypi.org/project/opentelemetry-ext-asgi/ -This library provides a WSGI middleware that can be used on any WSGI framework +This library provides a ASGI middleware that can be used on any ASGI framework (such as Django / Flask) to track requests timing through OpenTelemetry. Installation @@ -15,22 +15,22 @@ Installation :: - pip install opentelemetry-ext-wsgi + pip install opentelemetry-ext-asgi -Usage (Flask) +Usage (Quart) ------------- .. code-block:: python - from flask import Flask - from opentelemetry.ext.wsgi import OpenTelemetryMiddleware + from quart import Quart + from opentelemetry.ext.asgi import OpenTelemetryMiddleware - app = Flask(__name__) - app.wsgi_app = OpenTelemetryMiddleware(app.wsgi_app) + app = Quart(__name__) + app.asgi_app = OpenTelemetryMiddleware(app.asgi_app) @app.route("/") - def hello(): + async def hello(): return "Hello!" if __name__ == "__main__": @@ -40,21 +40,22 @@ Usage (Flask) Usage (Django) -------------- -Modify the application's ``wsgi.py`` file as shown below. +Modify the application's ``asgi.py`` file as shown below. .. code-block:: python import os - from opentelemetry.ext.wsgi import OpenTelemetryMiddleware - from django.core.wsgi import get_wsgi_application + import django + from channels.routing import get_default_application + from opentelemetry.ext.asgi import OpenTelemetryMiddleware os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'application.settings') + django.setup() - application = get_wsgi_application() + application = get_default_application() application = OpenTelemetryMiddleware(application) References ---------- * `OpenTelemetry Project `_ -* `WSGI `_ diff --git a/ext/opentelemetry-ext-asgi/setup.cfg b/ext/opentelemetry-ext-asgi/setup.cfg index 1db49209bec..903ad7e6624 100644 --- a/ext/opentelemetry-ext-asgi/setup.cfg +++ b/ext/opentelemetry-ext-asgi/setup.cfg @@ -13,13 +13,13 @@ # limitations under the License. # [metadata] -name = opentelemetry-ext-wsgi -description = WSGI Middleware for OpenTelemetry +name = opentelemetry-ext-asgi +description = ASGI 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 +url = https://github.com/open-telemetry/opentelemetry-python/ext/opentelemetry-ext-asgi platforms = any license = Apache-2.0 classifiers = @@ -28,9 +28,6 @@ classifiers = 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] diff --git a/ext/opentelemetry-ext-asgi/setup.py b/ext/opentelemetry-ext-asgi/setup.py index 3f8ef9cc5fc..42c82506eb0 100644 --- a/ext/opentelemetry-ext-asgi/setup.py +++ b/ext/opentelemetry-ext-asgi/setup.py @@ -17,7 +17,7 @@ BASE_DIR = os.path.dirname(__file__) VERSION_FILENAME = os.path.join( - BASE_DIR, "src", "opentelemetry", "ext", "wsgi", "version.py" + BASE_DIR, "src", "opentelemetry", "ext", "asgi", "version.py" ) PACKAGE_INFO = {} with open(VERSION_FILENAME) as f: diff --git a/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py index e6751f34ced..4f520e679bf 100644 --- a/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py +++ b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py @@ -13,40 +13,36 @@ # 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. +The opentelemetry-ext-asgi package provides an ASGI middleware that can be used +on any ASGI framework (such as Django-channels / Quart) to track requests +timing through OpenTelemetry. """ -import functools +from functools import wraps import typing -import wsgiref.util as wsgiref_util +import operator +from asgiref.compatibility import guarantee_single_callable from opentelemetry import propagators, trace -from opentelemetry.ext.wsgi.version import __version__ +from opentelemetry.ext.asgi.version import __version__ # noqa from opentelemetry.trace.status import Status, StatusCanonicalCode _HTTP_VERSION_PREFIX = "HTTP/" -def get_header_from_environ( - environ: dict, header_name: str +def get_header_from_scope( + scope: dict, header_name: str ) -> typing.List[str]: - """Retrieve a HTTP header value from the PEP3333-conforming WSGI environ. + """Retrieve a HTTP header value from the ASGI scope. 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 setifnotnone(dic, key, value): - if value is not None: - dic[key] = value + headers = scope.get('headers') + return [ + value.decode('utf8') for (key,value) in headers + if key.decode('utf8') == header_name + ] def http_status_to_canonical_code(code: int, allow_redirect: bool = True): @@ -80,53 +76,39 @@ def http_status_to_canonical_code(code: int, allow_redirect: bool = True): return StatusCanonicalCode.UNKNOWN -def collect_request_attributes(environ): - """Collects HTTP request attributes from the PEP3333-conforming - WSGI environ and returns a dictionary to be used as span creation attributes.""" +def collect_request_attributes(scope): + """Collects HTTP request attributes from the ASGI scope and returns a + dictionary to be used as span creation attributes.""" + + port = scope.get("server")[1] + server_host = ( + scope.get("server")[0] + (":" + str(port) if port != 80 else "") + ) + http_url = scope.get("scheme") + "://" + server_host + scope.get("path") + if scope.get("query_string"): + http_url = http_url + ("?" + scope.get("query_string").decode("utf8")) result = { - "component": "http", - "http.method": environ["REQUEST_METHOD"], - "http.server_name": environ["SERVER_NAME"], - "http.scheme": environ["wsgi.url_scheme"], - "host.port": int(environ["SERVER_PORT"]), + "component": scope.get("type"), + "http.method": scope.get("method"), + "http.server_name": scope.get("server")[0], + "http.scheme": scope.get("scheme"), + "http.host": server_host, + "host.port": port, + "http.flavor": scope.get("http_version"), + "http.target": scope.get("path"), + "http.url": http_url, } - setifnotnone(result, "http.host", environ.get("HTTP_HOST")) - target = environ.get("RAW_URI") - if target is None: # Note: `"" or None is None` - target = environ.get("REQUEST_URI") - if target is not None: - result["http.target"] = target - else: - result["http.url"] = wsgiref_util.request_uri(environ) - - remote_addr = environ.get("REMOTE_ADDR") - if remote_addr: - result["net.peer.ip"] = remote_addr - remote_host = environ.get("REMOTE_HOST") - if remote_host and remote_host != remote_addr: - result["net.peer.name"] = remote_host - - setifnotnone(result, "net.peer.port", environ.get("REMOTE_PORT")) - flavor = environ.get("SERVER_PROTOCOL", "") - if flavor.upper().startswith(_HTTP_VERSION_PREFIX): - flavor = flavor[len(_HTTP_VERSION_PREFIX) :] - if flavor: - result["http.flavor"] = flavor + if "client" in scope: + result["net.peer.ip"] = scope.get("client")[0] + result["net.peer.port"] = scope.get("client")[1] return result -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) - +def set_status_code(span, status_code): + """Adds HTTP response attributes to span using the status_code argument.""" try: status_code = int(status_code) except ValueError: @@ -141,79 +123,69 @@ def add_response_attributes( span.set_status(Status(http_status_to_canonical_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.""" +def get_default_span_name(scope): + """Calculates a (generic) span name for an incoming HTTP request based on the ASGI scope.""" # TODO: Update once # https://github.com/open-telemetry/opentelemetry-specification/issues/270 # is resolved - return environ.get("PATH_INFO", "/") + return scope.get("path", "/") class OpenTelemetryMiddleware: - """The WSGI application middleware. + """The ASGI application middleware. - This class is a PEP 3333 conforming WSGI middleware that starts and - annotates spans for any requests it is invoked with. + This class is an ASGI middleware that starts and annotates spans for any + requests it is invoked with. Args: - wsgi: The WSGI application callable to forward requests to. + app: The ASGI application callable to forward requests to. """ - def __init__(self, wsgi): - self.wsgi = wsgi + def __init__(self, app): + self.app = guarantee_single_callable(app) self.tracer = trace.tracer_source().get_tracer(__name__, __version__) - @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 + async def __call__(self, scope, receive, send): + """The ASGI application Args: - environ: A WSGI environment. - start_response: The WSGI start_response callable. + scope: A ASGI environment. + receive: An awaitable callable yielding dictionaries + send: An awaitable callable taking a single dictionary as argument. """ - parent_span = propagators.extract(get_header_from_environ, environ) - span_name = get_default_span_name(environ) - - span = self.tracer.start_span( - span_name, - parent_span, - kind=trace.SpanKind.SERVER, - attributes=collect_request_attributes(environ), - ) - - try: - with self.tracer.use_span(span): - start_response = self._create_start_response( - span, start_response - ) - iterable = self.wsgi(environ, start_response) - return _end_span_after_iterating(iterable, span, self.tracer) - except: # noqa - # TODO Set span status (cf. https://github.com/open-telemetry/opentelemetry-python/issues/292) - 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() + parent_span = propagators.extract(get_header_from_scope, scope) + span_name = get_default_span_name(scope) + + with self.tracer.start_as_current_span( + span_name, parent_span, kind=trace.SpanKind.SERVER, + attributes=collect_request_attributes(scope)) as connection_span: + + @wraps(receive) + async def wrapped_receive(): + with self.tracer.start_as_current_span(span_name + " (unknown-receive)") as receive_span: + payload = await receive() + if payload['type'] == "websocket.receive": + set_status_code(receive_span, 200) + receive_span.set_attribute("http.status_text", payload['text']) + + receive_span.update_name(span_name + " (" + payload['type'] + ")") + receive_span.set_attribute('type', payload['type']) + return payload + + @wraps(send) + async def wrapped_send(payload): + with self.tracer.start_as_current_span(span_name + " (unknown-send)") as send_span: + if payload['type'] == "http.response.start": + status_code = payload['status'] + set_status_code(send_span, status_code) + elif payload['type'] == "websocket.send": + set_status_code(send_span, 200) + send_span.set_attribute("http.status_text", payload['text']) + + send_span.update_name(span_name + " (" + payload['type'] + ")") + send_span.set_attribute('type', payload['type']) + await send(payload) + + await self.app(scope, wrapped_receive, wrapped_send) diff --git a/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py b/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py index 1912dd0079f..47fb461af30 100644 --- a/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py +++ b/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py @@ -15,282 +15,183 @@ import sys import unittest import unittest.mock as mock -import wsgiref.util as wsgiref_util from urllib.parse import urlsplit -import opentelemetry.ext.wsgi as otel_wsgi +import opentelemetry.ext.asgi as otel_asgi from opentelemetry import trace as trace_api -from opentelemetry.ext.testutil.wsgitestutil import WsgiTestBase - - -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 TestWsgiApplication(WsgiTestBase): - def validate_response(self, response, error=None): - while True: - try: - value = next(response) - self.assertEqual(value, b"*") - except StopIteration: - break - - self.assertEqual(self.status, "200 OK") - self.assertEqual( - self.response_headers, [("Content-Type", "text/plain")] - ) +from opentelemetry.ext.testutil.asgitestutil import ( + AsgiTestBase, setup_testing_defaults +) + + +async def simple_asgi(scope, receive, send): + assert isinstance(scope, dict) + assert scope.get('type') == "http" + payload = await receive() + if payload.get('type') == "http.request": + await send({ + 'type': 'http.response.start', + 'status': 200, + 'headers': [ + [b'Content-Type', b'text/plain'], + ], + }) + await send({ + 'type': 'http.response.body', + 'body': b"*" + }) + + +async def error_asgi(scope, receive, send): + assert isinstance(scope, dict) + assert scope.get('type') == "http" + payload = await receive() + if payload.get('type') == "http.request": + try: + raise ValueError + except ValueError: + scope['hack_exc_info'] = sys.exc_info() + await send({ + 'type': 'http.response.start', + 'status': 200, + 'headers': [ + [b'Content-Type', b'text/plain'], + ], + }) + await send({ + 'type': 'http.response.body', + 'body': b"*" + }) + + +class TestAsgiApplication(AsgiTestBase): + def validate_outputs(self, outputs, error=None): + # Check for expected outputs + self.assertEqual(len(outputs), 2) + response_start = outputs[0] + response_body = outputs[1] + self.assertEqual(response_start['type'], 'http.response.start') + self.assertEqual(response_body['type'], 'http.response.body') + + # Check http response body + self.assertEqual(response_body['body'], b"*") + + # Check http response start + self.assertEqual(response_start['status'], 200) + self.assertEqual(response_start['headers'], [[b'Content-Type', b'text/plain']]) + + exc_info = self.scope.get('hack_exc_info') if error: - self.assertIs(self.exc_info[0], error) - self.assertIsInstance(self.exc_info[1], error) - self.assertIsNotNone(self.exc_info[2]) + self.assertIs(exc_info[0], error) + self.assertIsInstance(exc_info[1], error) + self.assertIsNotNone(exc_info[2]) else: - self.assertIsNone(self.exc_info) + self.assertIsNone(exc_info) + # Check spans span_list = self.memory_exporter.get_finished_spans() - self.assertEqual(len(span_list), 1) - self.assertEqual(span_list[0].name, "/") - self.assertEqual(span_list[0].kind, trace_api.SpanKind.SERVER) - self.assertEqual( - span_list[0].attributes, + self.assertEqual(len(span_list), 4) + expected = [ { - "component": "http", - "http.method": "GET", - "http.server_name": "127.0.0.1", - "http.scheme": "http", - "host.port": 80, - "http.host": "127.0.0.1", - "http.flavor": "1.0", - "http.url": "http://127.0.0.1/", - "http.status_text": "OK", - "http.status_code": 200, + 'name': "/ (http.request)", + 'kind': trace_api.SpanKind.INTERNAL, + 'attributes': { + "type": "http.request", + }, }, - ) - - 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) + { + 'name': "/ (http.response.start)", + 'kind': trace_api.SpanKind.INTERNAL, + 'attributes': { + "http.status_code": 200, + "type": "http.response.start", + }, + }, + { + 'name': "/ (http.response.body)", + 'kind': trace_api.SpanKind.INTERNAL, + 'attributes': { + "type": "http.response.body", + }, + }, + { + 'name': "/", + 'kind': trace_api.SpanKind.SERVER, + 'attributes': { + "component": "http", + "http.method": "GET", + "http.server_name": "127.0.0.1", + "http.scheme": "http", + "host.port": 80, + "http.host": "127.0.0.1", + "http.flavor": "1.0", + "http.target": "/", + "http.url": "http://127.0.0.1/", + "net.peer.ip": "127.0.0.1", + "net.peer.port": 32767, + }, + }, + ] + for span, expected in zip(span_list, expected): + self.assertEqual(span.name, expected['name']) + self.assertEqual(span.kind, expected['kind']) + self.assertEqual(span.attributes, expected['attributes']) + + def test_basic_asgi_call(self): + app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) + self.seed_app(app) + self.send_default_request() + outputs = self.get_all_output() + self.validate_outputs(outputs) 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) + app = otel_asgi.OpenTelemetryMiddleware(error_asgi) + self.seed_app(app) + self.send_default_request() + outputs = self.get_all_output() + self.validate_outputs(outputs, error=ValueError) -class TestWsgiAttributes(unittest.TestCase): +class TestAsgiAttributes(unittest.TestCase): def setUp(self): - self.environ = {} - wsgiref_util.setup_testing_defaults(self.environ) + self.scope = {} + setup_testing_defaults(self.scope) self.span = mock.create_autospec(trace_api.Span, spec_set=True) def test_request_attributes(self): - self.environ["QUERY_STRING"] = "foo=bar" + self.scope["query_string"] = b"foo=bar" - attrs = otel_wsgi.collect_request_attributes(self.environ) + attrs = otel_asgi.collect_request_attributes(self.scope) self.assertDictEqual( attrs, { "component": "http", "http.method": "GET", "http.host": "127.0.0.1", + "http.target": "/", "http.url": "http://127.0.0.1/?foo=bar", "host.port": 80, "http.scheme": "http", "http.server_name": "127.0.0.1", "http.flavor": "1.0", + "net.peer.ip": "127.0.0.1", + "net.peer.port": 32767 }, ) - def validate_url(self, expected_url, raw=False, has_host=True): - parts = urlsplit(expected_url) - expected = { - "http.scheme": parts.scheme, - "host.port": parts.port or (80 if parts.scheme == "http" else 443), - "http.server_name": parts.hostname, # Not true in the general case, but for all tests. - } - if raw: - expected["http.target"] = expected_url.split(parts.netloc, 1)[1] - else: - expected["http.url"] = expected_url - if has_host: - expected["http.host"] = parts.hostname - - attrs = otel_wsgi.collect_request_attributes(self.environ) - self.assertGreaterEqual( - attrs.items(), expected.items(), expected_url + " expected." - ) - - def test_request_attributes_with_partial_raw_uri(self): - self.environ["RAW_URI"] = "/#top" - self.validate_url("http://127.0.0.1/#top", raw=True) - - 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/?", raw=True, has_host=False) - - 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/", has_host=False) - - self.environ["SERVER_PORT"] = "8080" - self.validate_url("https://127.0.0.1:8080/", has_host=False) - - self.environ["SERVER_PORT"] = "80" - self.validate_url("https://127.0.0.1:80/", has_host=False) - - 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/", has_host=False) - - self.environ["SERVER_PORT"] = "8080" - self.validate_url("http://127.0.0.1:8080/", has_host=False) - - self.environ["SERVER_PORT"] = "443" - self.validate_url("http://127.0.0.1:443/", has_host=False) - - 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/", has_host=False) - - self.environ["SERVER_PORT"] = "443" - self.validate_url("http://127.0.0.1:443/", has_host=False) - - def test_request_attributes_with_conflicting_nonstandard_port(self): - self.environ[ - "HTTP_HOST" - ] += ":8080" # Note that we do not correct SERVER_PORT - expected = { - "http.host": "127.0.0.1:8080", - "http.url": "http://127.0.0.1:8080/", - "host.port": 80, - } - self.assertGreaterEqual( - otel_wsgi.collect_request_attributes(self.environ).items(), - expected.items(), - ) - - 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/?", raw=True) - - def test_request_attributes_pathless(self): - self.environ["RAW_URI"] = "" - expected = {"http.target": ""} - self.assertGreaterEqual( - otel_wsgi.collect_request_attributes(self.environ).items(), - expected.items(), - ) - - def test_request_attributes_with_full_request_uri(self): - self.environ["HTTP_HOST"] = "127.0.0.1:8080" - self.environ["REQUEST_METHOD"] = "CONNECT" - self.environ[ - "REQUEST_URI" - ] = "127.0.0.1:8080" # Might happen in a CONNECT request - expected = { - "http.host": "127.0.0.1:8080", - "http.target": "127.0.0.1:8080", - } - self.assertGreaterEqual( - otel_wsgi.collect_request_attributes(self.environ).items(), - expected.items(), - ) - def test_response_attributes(self): - otel_wsgi.add_response_attributes(self.span, "404 Not Found", {}) + otel_asgi.set_status_code(self.span, 404) 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.assertEqual(self.span.set_attribute.call_count, 1) + self.assertEqual(self.span.set_attribute.call_count, 1) 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" - ) + otel_asgi.set_status_code(self.span, "Invalid Status Code") + self.assertEqual(self.span.set_status.call_count, 1) if __name__ == "__main__": From 7806f66519ed4c4f2839a78e7b5cca772ec6bf01 Mon Sep 17 00:00:00 2001 From: Emil Madsen Date: Sat, 8 Feb 2020 16:10:52 +0100 Subject: [PATCH 04/15] Fix ext/testutil --- .../ext/testutil/asgitestutil.py | 58 +++++++++++++++++++ .../ext/testutil/spantestutil.py | 31 ++++++++++ .../ext/testutil/wsgitestutil.py | 32 +--------- 3 files changed, 92 insertions(+), 29 deletions(-) create mode 100644 ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/asgitestutil.py create mode 100644 ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/spantestutil.py diff --git a/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/asgitestutil.py b/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/asgitestutil.py new file mode 100644 index 00000000000..7b84d2b52d8 --- /dev/null +++ b/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/asgitestutil.py @@ -0,0 +1,58 @@ +import asyncio +from asgiref.testing import ApplicationCommunicator +from opentelemetry.ext.testutil.spantestutil import SpanTestBase + + +def setup_testing_defaults(scope): + scope.update({ + 'client': ('127.0.0.1', 32767), + 'headers': [], + 'http_version': '1.0', + 'method': 'GET', + 'path': '/', + 'query_string': b'', + 'scheme': 'http', + 'server': ('127.0.0.1', 80), + 'type': 'http' + }) + + +class AsgiTestBase(SpanTestBase): + def setUp(self): + super().setUp() + + self.scope = {} + setup_testing_defaults(self.scope) + self.communicator = None + + def tearDown(self): + if self.communicator: + asyncio.get_event_loop().run_until_complete( + self.communicator.wait() + ) + + def seed_app(self, app): + self.communicator = ApplicationCommunicator(app, self.scope) + + def send_input(self, payload): + asyncio.get_event_loop().run_until_complete( + self.communicator.send_input(payload) + ) + + def send_default_request(self): + self.send_input({'type': 'http.request', 'body': b''}) + + def get_output(self): + output = asyncio.get_event_loop().run_until_complete( + self.communicator.receive_output(0) + ) + return output + + def get_all_output(self): + outputs = [] + while True: + try: + outputs.append(self.get_output()) + except asyncio.TimeoutError as e: + break + return outputs diff --git a/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/spantestutil.py b/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/spantestutil.py new file mode 100644 index 00000000000..b82fb630c0f --- /dev/null +++ b/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/spantestutil.py @@ -0,0 +1,31 @@ +import unittest +from importlib import reload + +from opentelemetry import trace as trace_api +from opentelemetry.sdk.trace import TracerSource, export +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) + +_MEMORY_EXPORTER = None + + +class SpanTestBase(unittest.TestCase): + @classmethod + def setUpClass(cls): + global _MEMORY_EXPORTER # pylint:disable=global-statement + trace_api.set_preferred_tracer_source_implementation( + lambda T: TracerSource() + ) + tracer_source = trace_api.tracer_source() + _MEMORY_EXPORTER = InMemorySpanExporter() + span_processor = export.SimpleExportSpanProcessor(_MEMORY_EXPORTER) + tracer_source.add_span_processor(span_processor) + + @classmethod + def tearDownClass(cls): + reload(trace_api) + + def setUp(self): + self.memory_exporter = _MEMORY_EXPORTER + self.memory_exporter.clear() diff --git a/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/wsgitestutil.py b/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/wsgitestutil.py index 5f99d08df04..6b00e499ee2 100644 --- a/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/wsgitestutil.py +++ b/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/wsgitestutil.py @@ -1,37 +1,11 @@ import io -import unittest import wsgiref.util as wsgiref_util -from importlib import reload +from opentelemetry.ext.testutil.spantestutil import SpanTestBase -from opentelemetry import trace as trace_api -from opentelemetry.sdk.trace import TracerSource, export -from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( - InMemorySpanExporter, -) - -_MEMORY_EXPORTER = None - - -class WsgiTestBase(unittest.TestCase): - @classmethod - def setUpClass(cls): - global _MEMORY_EXPORTER # pylint:disable=global-statement - trace_api.set_preferred_tracer_source_implementation( - lambda T: TracerSource() - ) - tracer_source = trace_api.tracer_source() - _MEMORY_EXPORTER = InMemorySpanExporter() - span_processor = export.SimpleExportSpanProcessor(_MEMORY_EXPORTER) - tracer_source.add_span_processor(span_processor) - - @classmethod - def tearDownClass(cls): - reload(trace_api) +class WsgiTestBase(SpanTestBase): def setUp(self): - - self.memory_exporter = _MEMORY_EXPORTER - self.memory_exporter.clear() + super().setUp() self.write_buffer = io.BytesIO() self.write = self.write_buffer.write From 263ec71510e28f610b942cbb46743a44d533a709 Mon Sep 17 00:00:00 2001 From: Emil Madsen Date: Sat, 8 Feb 2020 16:37:24 +0100 Subject: [PATCH 05/15] Black reformatting --- .../src/opentelemetry/ext/asgi/__init__.py | 62 ++++++---- .../tests/test_asgi_middleware.py | 106 ++++++++---------- .../ext/testutil/asgitestutil.py | 28 ++--- 3 files changed, 101 insertions(+), 95 deletions(-) diff --git a/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py index 4f520e679bf..0b7fab697bf 100644 --- a/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py +++ b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py @@ -30,18 +30,17 @@ _HTTP_VERSION_PREFIX = "HTTP/" -def get_header_from_scope( - scope: dict, header_name: str -) -> typing.List[str]: +def get_header_from_scope(scope: dict, header_name: str) -> typing.List[str]: """Retrieve a HTTP header value from the ASGI scope. Returns: A list with a single string with the header value if it exists, else an empty list. """ - headers = scope.get('headers') + headers = scope.get("headers") return [ - value.decode('utf8') for (key,value) in headers - if key.decode('utf8') == header_name + value.decode("utf8") + for (key, value) in headers + if key.decode("utf8") == header_name ] @@ -81,8 +80,8 @@ def collect_request_attributes(scope): dictionary to be used as span creation attributes.""" port = scope.get("server")[1] - server_host = ( - scope.get("server")[0] + (":" + str(port) if port != 80 else "") + server_host = scope.get("server")[0] + ( + ":" + str(port) if port != 80 else "" ) http_url = scope.get("scheme") + "://" + server_host + scope.get("path") if scope.get("query_string"): @@ -159,33 +158,48 @@ async def __call__(self, scope, receive, send): span_name = get_default_span_name(scope) with self.tracer.start_as_current_span( - span_name, parent_span, kind=trace.SpanKind.SERVER, - attributes=collect_request_attributes(scope)) as connection_span: + span_name, + parent_span, + kind=trace.SpanKind.SERVER, + attributes=collect_request_attributes(scope), + ) as connection_span: @wraps(receive) async def wrapped_receive(): - with self.tracer.start_as_current_span(span_name + " (unknown-receive)") as receive_span: + with self.tracer.start_as_current_span( + span_name + " (unknown-receive)" + ) as receive_span: payload = await receive() - if payload['type'] == "websocket.receive": + if payload["type"] == "websocket.receive": set_status_code(receive_span, 200) - receive_span.set_attribute("http.status_text", payload['text']) - - receive_span.update_name(span_name + " (" + payload['type'] + ")") - receive_span.set_attribute('type', payload['type']) + receive_span.set_attribute( + "http.status_text", payload["text"] + ) + + receive_span.update_name( + span_name + " (" + payload["type"] + ")" + ) + receive_span.set_attribute("type", payload["type"]) return payload @wraps(send) async def wrapped_send(payload): - with self.tracer.start_as_current_span(span_name + " (unknown-send)") as send_span: - if payload['type'] == "http.response.start": - status_code = payload['status'] + with self.tracer.start_as_current_span( + span_name + " (unknown-send)" + ) as send_span: + if payload["type"] == "http.response.start": + status_code = payload["status"] set_status_code(send_span, status_code) - elif payload['type'] == "websocket.send": + elif payload["type"] == "websocket.send": set_status_code(send_span, 200) - send_span.set_attribute("http.status_text", payload['text']) - - send_span.update_name(span_name + " (" + payload['type'] + ")") - send_span.set_attribute('type', payload['type']) + send_span.set_attribute( + "http.status_text", payload["text"] + ) + + send_span.update_name( + span_name + " (" + payload["type"] + ")" + ) + send_span.set_attribute("type", payload["type"]) await send(payload) await self.app(scope, wrapped_receive, wrapped_send) diff --git a/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py b/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py index 47fb461af30..e2e5b6f9db4 100644 --- a/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py +++ b/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py @@ -20,48 +20,43 @@ import opentelemetry.ext.asgi as otel_asgi from opentelemetry import trace as trace_api from opentelemetry.ext.testutil.asgitestutil import ( - AsgiTestBase, setup_testing_defaults + AsgiTestBase, + setup_testing_defaults, ) async def simple_asgi(scope, receive, send): assert isinstance(scope, dict) - assert scope.get('type') == "http" + assert scope.get("type") == "http" payload = await receive() - if payload.get('type') == "http.request": - await send({ - 'type': 'http.response.start', - 'status': 200, - 'headers': [ - [b'Content-Type', b'text/plain'], - ], - }) - await send({ - 'type': 'http.response.body', - 'body': b"*" - }) + if payload.get("type") == "http.request": + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [[b"Content-Type", b"text/plain"]], + } + ) + await send({"type": "http.response.body", "body": b"*"}) async def error_asgi(scope, receive, send): assert isinstance(scope, dict) - assert scope.get('type') == "http" + assert scope.get("type") == "http" payload = await receive() - if payload.get('type') == "http.request": + if payload.get("type") == "http.request": try: raise ValueError except ValueError: - scope['hack_exc_info'] = sys.exc_info() - await send({ - 'type': 'http.response.start', - 'status': 200, - 'headers': [ - [b'Content-Type', b'text/plain'], - ], - }) - await send({ - 'type': 'http.response.body', - 'body': b"*" - }) + scope["hack_exc_info"] = sys.exc_info() + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [[b"Content-Type", b"text/plain"]], + } + ) + await send({"type": "http.response.body", "body": b"*"}) class TestAsgiApplication(AsgiTestBase): @@ -70,17 +65,19 @@ def validate_outputs(self, outputs, error=None): self.assertEqual(len(outputs), 2) response_start = outputs[0] response_body = outputs[1] - self.assertEqual(response_start['type'], 'http.response.start') - self.assertEqual(response_body['type'], 'http.response.body') + self.assertEqual(response_start["type"], "http.response.start") + self.assertEqual(response_body["type"], "http.response.body") # Check http response body - self.assertEqual(response_body['body'], b"*") + self.assertEqual(response_body["body"], b"*") # Check http response start - self.assertEqual(response_start['status'], 200) - self.assertEqual(response_start['headers'], [[b'Content-Type', b'text/plain']]) + self.assertEqual(response_start["status"], 200) + self.assertEqual( + response_start["headers"], [[b"Content-Type", b"text/plain"]] + ) - exc_info = self.scope.get('hack_exc_info') + exc_info = self.scope.get("hack_exc_info") if error: self.assertIs(exc_info[0], error) self.assertIsInstance(exc_info[1], error) @@ -93,32 +90,27 @@ def validate_outputs(self, outputs, error=None): self.assertEqual(len(span_list), 4) expected = [ { - 'name': "/ (http.request)", - 'kind': trace_api.SpanKind.INTERNAL, - 'attributes': { - "type": "http.request", - }, + "name": "/ (http.request)", + "kind": trace_api.SpanKind.INTERNAL, + "attributes": {"type": "http.request"}, }, - { - 'name': "/ (http.response.start)", - 'kind': trace_api.SpanKind.INTERNAL, - 'attributes': { + "name": "/ (http.response.start)", + "kind": trace_api.SpanKind.INTERNAL, + "attributes": { "http.status_code": 200, "type": "http.response.start", }, }, { - 'name': "/ (http.response.body)", - 'kind': trace_api.SpanKind.INTERNAL, - 'attributes': { - "type": "http.response.body", - }, + "name": "/ (http.response.body)", + "kind": trace_api.SpanKind.INTERNAL, + "attributes": {"type": "http.response.body"}, }, { - 'name': "/", - 'kind': trace_api.SpanKind.SERVER, - 'attributes': { + "name": "/", + "kind": trace_api.SpanKind.SERVER, + "attributes": { "component": "http", "http.method": "GET", "http.server_name": "127.0.0.1", @@ -134,9 +126,9 @@ def validate_outputs(self, outputs, error=None): }, ] for span, expected in zip(span_list, expected): - self.assertEqual(span.name, expected['name']) - self.assertEqual(span.kind, expected['kind']) - self.assertEqual(span.attributes, expected['attributes']) + self.assertEqual(span.name, expected["name"]) + self.assertEqual(span.kind, expected["kind"]) + self.assertEqual(span.attributes, expected["attributes"]) def test_basic_asgi_call(self): app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) @@ -176,15 +168,13 @@ def test_request_attributes(self): "http.server_name": "127.0.0.1", "http.flavor": "1.0", "net.peer.ip": "127.0.0.1", - "net.peer.port": 32767 + "net.peer.port": 32767, }, ) def test_response_attributes(self): otel_asgi.set_status_code(self.span, 404) - expected = ( - mock.call("http.status_code", 404), - ) + expected = (mock.call("http.status_code", 404),) self.assertEqual(self.span.set_attribute.call_count, 1) self.assertEqual(self.span.set_attribute.call_count, 1) self.span.set_attribute.assert_has_calls(expected, any_order=True) diff --git a/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/asgitestutil.py b/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/asgitestutil.py index 7b84d2b52d8..1461f3e8462 100644 --- a/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/asgitestutil.py +++ b/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/asgitestutil.py @@ -4,17 +4,19 @@ def setup_testing_defaults(scope): - scope.update({ - 'client': ('127.0.0.1', 32767), - 'headers': [], - 'http_version': '1.0', - 'method': 'GET', - 'path': '/', - 'query_string': b'', - 'scheme': 'http', - 'server': ('127.0.0.1', 80), - 'type': 'http' - }) + scope.update( + { + "client": ("127.0.0.1", 32767), + "headers": [], + "http_version": "1.0", + "method": "GET", + "path": "/", + "query_string": b"", + "scheme": "http", + "server": ("127.0.0.1", 80), + "type": "http", + } + ) class AsgiTestBase(SpanTestBase): @@ -38,9 +40,9 @@ def send_input(self, payload): asyncio.get_event_loop().run_until_complete( self.communicator.send_input(payload) ) - + def send_default_request(self): - self.send_input({'type': 'http.request', 'body': b''}) + self.send_input({"type": "http.request", "body": b""}) def get_output(self): output = asyncio.get_event_loop().run_until_complete( From ccb42a07ad5b26a172340de1a51ddf9ec328aea5 Mon Sep 17 00:00:00 2001 From: Emil Madsen Date: Sat, 8 Feb 2020 16:48:40 +0100 Subject: [PATCH 06/15] Isort reformatting --- .../src/opentelemetry/ext/asgi/__init__.py | 5 +++-- .../src/opentelemetry/ext/testutil/asgitestutil.py | 2 ++ .../src/opentelemetry/ext/testutil/wsgitestutil.py | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py index 0b7fab697bf..e36dc515fc1 100644 --- a/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py +++ b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py @@ -18,9 +18,10 @@ timing through OpenTelemetry. """ -from functools import wraps -import typing import operator +import typing +from functools import wraps + from asgiref.compatibility import guarantee_single_callable from opentelemetry import propagators, trace diff --git a/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/asgitestutil.py b/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/asgitestutil.py index 1461f3e8462..6149efe2571 100644 --- a/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/asgitestutil.py +++ b/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/asgitestutil.py @@ -1,5 +1,7 @@ import asyncio + from asgiref.testing import ApplicationCommunicator + from opentelemetry.ext.testutil.spantestutil import SpanTestBase diff --git a/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/wsgitestutil.py b/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/wsgitestutil.py index 6b00e499ee2..1e915c0543d 100644 --- a/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/wsgitestutil.py +++ b/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/wsgitestutil.py @@ -1,5 +1,6 @@ import io import wsgiref.util as wsgiref_util + from opentelemetry.ext.testutil.spantestutil import SpanTestBase From a10e2307ca30c316261fad4989cc1ac69ef39826 Mon Sep 17 00:00:00 2001 From: Emil Madsen Date: Sat, 8 Feb 2020 17:02:55 +0100 Subject: [PATCH 07/15] Flake8 reformatting --- .../src/opentelemetry/ext/asgi/__init__.py | 2 +- ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py | 2 +- .../src/opentelemetry/ext/testutil/asgitestutil.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py index e36dc515fc1..816bf5c1f67 100644 --- a/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py +++ b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py @@ -163,7 +163,7 @@ async def __call__(self, scope, receive, send): parent_span, kind=trace.SpanKind.SERVER, attributes=collect_request_attributes(scope), - ) as connection_span: + ): @wraps(receive) async def wrapped_receive(): diff --git a/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py b/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py index e2e5b6f9db4..3d9477163f5 100644 --- a/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py +++ b/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py @@ -137,7 +137,7 @@ def test_basic_asgi_call(self): outputs = self.get_all_output() self.validate_outputs(outputs) - def test_wsgi_exc_info(self): + def test_asgi_exc_info(self): app = otel_asgi.OpenTelemetryMiddleware(error_asgi) self.seed_app(app) self.send_default_request() diff --git a/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/asgitestutil.py b/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/asgitestutil.py index 6149efe2571..4f5fb94f0b2 100644 --- a/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/asgitestutil.py +++ b/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/asgitestutil.py @@ -57,6 +57,6 @@ def get_all_output(self): while True: try: outputs.append(self.get_output()) - except asyncio.TimeoutError as e: + except asyncio.TimeoutError: break return outputs From f067ced8ba281a852765e33f110ed28a7c7fa924 Mon Sep 17 00:00:00 2001 From: Emil Madsen Date: Sat, 8 Feb 2020 17:10:25 +0100 Subject: [PATCH 08/15] Pylint reformatting --- ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py b/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py index 3d9477163f5..480305b1ae3 100644 --- a/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py +++ b/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py @@ -15,7 +15,6 @@ import sys import unittest import unittest.mock as mock -from urllib.parse import urlsplit import opentelemetry.ext.asgi as otel_asgi from opentelemetry import trace as trace_api From 749d32ea9a0c441ac3a5238fc79ed3b3240f14ca Mon Sep 17 00:00:00 2001 From: Emil Madsen Date: Sat, 8 Feb 2020 17:18:29 +0100 Subject: [PATCH 09/15] Documentation --- docs/index.rst | 1 + docs/opentelemetry.ext.asgi.rst | 10 ++++++++++ ext/opentelemetry-ext-asgi/setup.cfg | 3 ++- scripts/coverage.sh | 1 + tox.ini | 3 ++- 5 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 docs/opentelemetry.ext.asgi.rst diff --git a/docs/index.rst b/docs/index.rst index c597d4a681f..b32bf4e0a5c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -33,6 +33,7 @@ and integration packages. :maxdepth: 1 :caption: OpenTelemetry Integrations: + opentelemetry.ext.asgi opentelemetry.ext.flask opentelemetry.ext.http_requests opentelemetry.ext.jaeger diff --git a/docs/opentelemetry.ext.asgi.rst b/docs/opentelemetry.ext.asgi.rst new file mode 100644 index 00000000000..2d8f4e99c7a --- /dev/null +++ b/docs/opentelemetry.ext.asgi.rst @@ -0,0 +1,10 @@ +opentelemetry.ext.asgi package +========================================== + +Module contents +--------------- + +.. automodule:: opentelemetry.ext.asgi + :members: + :undoc-members: + :show-inheritance: diff --git a/ext/opentelemetry-ext-asgi/setup.cfg b/ext/opentelemetry-ext-asgi/setup.cfg index 903ad7e6624..5a282479904 100644 --- a/ext/opentelemetry-ext-asgi/setup.cfg +++ b/ext/opentelemetry-ext-asgi/setup.cfg @@ -31,12 +31,13 @@ classifiers = Programming Language :: Python :: 3.7 [options] -python_requires = >=3.4 +python_requires = >=3.7 package_dir= =src packages=find_namespace: install_requires = opentelemetry-api + asgiref [options.extras_require] test = diff --git a/scripts/coverage.sh b/scripts/coverage.sh index 9b981b0817c..0becf96ef62 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -17,6 +17,7 @@ coverage erase cov opentelemetry-api cov opentelemetry-sdk +cov ext/opentelemetry-ext-asgi cov ext/opentelemetry-ext-flask cov ext/opentelemetry-ext-http-requests cov ext/opentelemetry-ext-jaeger diff --git a/tox.ini b/tox.ini index 4195e8629da..85b9a35f29f 100644 --- a/tox.ini +++ b/tox.ini @@ -132,6 +132,7 @@ deps = thrift>=0.10.0 pymongo ~= 3.1 flask~=1.0 + asgiref~=3.2.3 changedir = docs @@ -175,4 +176,4 @@ commands = pytest {posargs} commands_post = - docker-compose down \ No newline at end of file + docker-compose down From e0184fbcb67d07d9ae0f9103f943ec7c1295f769 Mon Sep 17 00:00:00 2001 From: Emil Madsen Date: Wed, 19 Feb 2020 21:07:41 +0100 Subject: [PATCH 10/15] ASGI only runs under Python 3.5+, fixes to tox/coverage Signed-off-by: Emil Madsen --- ext/opentelemetry-ext-asgi/setup.cfg | 4 +++- scripts/coverage.sh | 9 ++++++++- tox.ini | 12 ++++++++---- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/ext/opentelemetry-ext-asgi/setup.cfg b/ext/opentelemetry-ext-asgi/setup.cfg index 5a282479904..fdbc0dee22e 100644 --- a/ext/opentelemetry-ext-asgi/setup.cfg +++ b/ext/opentelemetry-ext-asgi/setup.cfg @@ -28,10 +28,12 @@ classifiers = License :: OSI Approved :: Apache Software License Programming Language :: Python Programming Language :: Python :: 3 + Programming Language :: Python :: 3.5 + Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 [options] -python_requires = >=3.7 +python_requires = >=3.5 package_dir= =src packages=find_namespace: diff --git a/scripts/coverage.sh b/scripts/coverage.sh index 0becf96ef62..51e3cfefb41 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -12,6 +12,8 @@ function cov { ${1} } +PYTHON_VERSION=$(python -c 'import sys; print(".".join(map(str, sys.version_info[:3])))') +PYTHON_VERSION_INFO=(${PYTHON_VERSION//./ }) coverage erase @@ -26,5 +28,10 @@ cov ext/opentelemetry-ext-wsgi cov ext/opentelemetry-ext-zipkin cov examples/opentelemetry-example-app -coverage report +# ext-asgi is only supported on Python 3.5+. +if [ ${PYTHON_VERSION_INFO[1]} -gt 4 ]; then + cov ext/opentelemetry-ext-asgi +fi + +coverage report --show-missing coverage xml diff --git a/tox.ini b/tox.ini index 85b9a35f29f..57bcb474fc9 100644 --- a/tox.ini +++ b/tox.ini @@ -3,9 +3,11 @@ skipsdist = True skip_missing_interpreters = True envlist = py3{4,5,6,7,8}-test-{api,sdk,example-app,ext-wsgi,ext-flask,ext-http-requests,ext-jaeger,ext-dbapi,ext-mysql,ext-psycopg2,ext-pymongo,ext-zipkin,opentracing-shim} - pypy3-test-{api,sdk,example-app,ext-wsgi,ext-flask,ext-http-requests,ext-jaeger,ext-dbapi,ext-mysql,ext-pymongo,ext-zipkin,opentracing-shim} + py3{5,6,7,8}-test-{ext-asgi} + pypy3-test-{api,sdk,example-app,ext-wsgi,ext-asgi,ext-flask,ext-http-requests,ext-jaeger,ext-dbapi,ext-mysql,ext-pymongo,ext-zipkin,opentracing-shim} py3{4,5,6,7,8}-test-{api,sdk,example-app,example-basic-tracer,example-http,ext-wsgi,ext-flask,ext-http-requests,ext-jaeger,ext-dbapi,ext-mysql,ext-psycopg2,ext-pymongo,ext-zipkin,opentracing-shim} - pypy3-test-{api,sdk,example-app,example-basic-tracer,example-http,ext-wsgi,ext-flask,ext-http-requests,ext-jaeger,ext-dbapi,ext-mysql,ext-pymongo,ext-zipkin,opentracing-shim} + py3{5,6,7,8}-test-{ext-asgi} + pypy3-test-{api,sdk,example-app,example-basic-tracer,example-http,ext-wsgi,ext-asgi,ext-flask,ext-http-requests,ext-jaeger,ext-dbapi,ext-mysql,ext-pymongo,ext-zipkin,opentracing-shim} py3{4,5,6,7,8}-coverage ; Coverage is temporarily disabled for pypy3 due to the pytest bug. @@ -41,6 +43,7 @@ changedir = test-ext-mysql: ext/opentelemetry-ext-mysql/tests test-ext-pymongo: ext/opentelemetry-ext-pymongo/tests test-ext-psycopg2: ext/opentelemetry-ext-psycopg2/tests + test-ext-asgi: ext/opentelemetry-ext-asgi/tests test-ext-wsgi: ext/opentelemetry-ext-wsgi/tests test-ext-zipkin: ext/opentelemetry-ext-zipkin/tests test-ext-flask: ext/opentelemetry-ext-flask/tests @@ -68,9 +71,10 @@ commands_pre = example-http: pip install -r {toxinidir}/examples/http/requirements.txt ext: pip install {toxinidir}/opentelemetry-api - wsgi,flask: pip install {toxinidir}/ext/opentelemetry-ext-testutil + wsgi,flask,asgi: pip install {toxinidir}/ext/opentelemetry-ext-testutil + wsgi,flask,asgi: pip install {toxinidir}/opentelemetry-sdk wsgi,flask: pip install {toxinidir}/ext/opentelemetry-ext-wsgi - wsgi,flask: pip install {toxinidir}/opentelemetry-sdk + asgi: pip install {toxinidir}/ext/opentelemetry-ext-asgi flask: pip install {toxinidir}/ext/opentelemetry-ext-flask[test] dbapi: pip install {toxinidir}/ext/opentelemetry-ext-dbapi mysql: pip install {toxinidir}/ext/opentelemetry-ext-dbapi From ede28b2cc42551cb57c015f3af0d153efbdf814d Mon Sep 17 00:00:00 2001 From: Emil Madsen Date: Fri, 21 Feb 2020 17:44:31 +0100 Subject: [PATCH 11/15] Added callback to override default span-name, default span name to HTTP METHOD Signed-off-by: Emil Madsen --- .../src/opentelemetry/ext/asgi/__init__.py | 18 ++++----- .../tests/test_asgi_middleware.py | 37 ++++++++++++++++--- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py index 816bf5c1f67..2297998d676 100644 --- a/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py +++ b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py @@ -124,12 +124,8 @@ def set_status_code(span, status_code): def get_default_span_name(scope): - """Calculates a (generic) span name for an incoming HTTP request based on the ASGI scope.""" - - # TODO: Update once - # https://github.com/open-telemetry/opentelemetry-specification/issues/270 - # is resolved - return scope.get("path", "/") + """Default implementation for name_callback, returns HTTP {METHOD_NAME}.""" + return "HTTP " + scope.get("method") class OpenTelemetryMiddleware: @@ -140,11 +136,15 @@ class OpenTelemetryMiddleware: Args: app: The ASGI application callable to forward requests to. + name_callback: Callback which calculates a generic span name for an + incoming HTTP request based on the ASGI scope. + Optional: Defaults to get_default_span_name. """ - def __init__(self, app): + def __init__(self, app, name_callback=None): self.app = guarantee_single_callable(app) self.tracer = trace.tracer_source().get_tracer(__name__, __version__) + self.name_callback = name_callback or get_default_span_name async def __call__(self, scope, receive, send): """The ASGI application @@ -156,10 +156,10 @@ async def __call__(self, scope, receive, send): """ parent_span = propagators.extract(get_header_from_scope, scope) - span_name = get_default_span_name(scope) + span_name = self.name_callback(scope) with self.tracer.start_as_current_span( - span_name, + span_name + " (connection)", parent_span, kind=trace.SpanKind.SERVER, attributes=collect_request_attributes(scope), diff --git a/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py b/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py index 480305b1ae3..fd860f432d9 100644 --- a/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py +++ b/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py @@ -59,7 +59,9 @@ async def error_asgi(scope, receive, send): class TestAsgiApplication(AsgiTestBase): - def validate_outputs(self, outputs, error=None): + def validate_outputs(self, outputs, error=None, modifiers=None): + # Ensure modifiers is a list + modifiers = modifiers or [] # Check for expected outputs self.assertEqual(len(outputs), 2) response_start = outputs[0] @@ -89,12 +91,12 @@ def validate_outputs(self, outputs, error=None): self.assertEqual(len(span_list), 4) expected = [ { - "name": "/ (http.request)", + "name": "HTTP GET (http.request)", "kind": trace_api.SpanKind.INTERNAL, "attributes": {"type": "http.request"}, }, { - "name": "/ (http.response.start)", + "name": "HTTP GET (http.response.start)", "kind": trace_api.SpanKind.INTERNAL, "attributes": { "http.status_code": 200, @@ -102,12 +104,12 @@ def validate_outputs(self, outputs, error=None): }, }, { - "name": "/ (http.response.body)", + "name": "HTTP GET (http.response.body)", "kind": trace_api.SpanKind.INTERNAL, "attributes": {"type": "http.response.body"}, }, { - "name": "/", + "name": "HTTP GET (connection)", "kind": trace_api.SpanKind.SERVER, "attributes": { "component": "http", @@ -124,12 +126,17 @@ def validate_outputs(self, outputs, error=None): }, }, ] + # Run our expected modifiers + for modifier in modifiers: + expected = modifier(expected) + # Check that output matches for span, expected in zip(span_list, expected): self.assertEqual(span.name, expected["name"]) self.assertEqual(span.kind, expected["kind"]) self.assertEqual(span.attributes, expected["attributes"]) def test_basic_asgi_call(self): + """Test that spans are emitted as expected.""" app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) self.seed_app(app) self.send_default_request() @@ -137,12 +144,32 @@ def test_basic_asgi_call(self): self.validate_outputs(outputs) def test_asgi_exc_info(self): + """Test that exception information is emitted as expected.""" app = otel_asgi.OpenTelemetryMiddleware(error_asgi) self.seed_app(app) self.send_default_request() outputs = self.get_all_output() self.validate_outputs(outputs, error=ValueError) + def test_override_span_name(self): + """Test that span_names can be overwritten by our callback function.""" + span_name = "Dymaxion" + def get_predefined_span_name(scope): + return span_name + def update_expected_span_name(expected): + for entry in expected: + entry['name'] = " ".join( + [span_name] + entry['name'].split(' ')[-1:] + ) + return expected + app = otel_asgi.OpenTelemetryMiddleware( + simple_asgi, name_callback=get_predefined_span_name + ) + self.seed_app(app) + self.send_default_request() + outputs = self.get_all_output() + self.validate_outputs(outputs, modifiers=[update_expected_span_name]) + class TestAsgiAttributes(unittest.TestCase): def setUp(self): From 4a960608314abe107b40106acbddc3163a76a2d8 Mon Sep 17 00:00:00 2001 From: Emil Madsen Date: Fri, 21 Feb 2020 18:12:54 +0100 Subject: [PATCH 12/15] Set send_span name immediately, instead of updating it --- .../src/opentelemetry/ext/asgi/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py index 3f1fad1d77d..aaf1ec326c5 100644 --- a/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py +++ b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py @@ -186,7 +186,7 @@ async def wrapped_receive(): @wraps(send) async def wrapped_send(payload): with self.tracer.start_as_current_span( - span_name + " (unknown-send)" + span_name + " (" + payload["type"] + ")" ) as send_span: if payload["type"] == "http.response.start": status_code = payload["status"] @@ -197,9 +197,6 @@ async def wrapped_send(payload): "http.status_text", payload["text"] ) - send_span.update_name( - span_name + " (" + payload["type"] + ")" - ) send_span.set_attribute("type", payload["type"]) await send(payload) From 9c4242d742bb91b5727d9c2c7408a324c999b86c Mon Sep 17 00:00:00 2001 From: Emil Madsen Date: Fri, 21 Feb 2020 19:17:36 +0100 Subject: [PATCH 13/15] Changed span-names to asgi.{scope["type"]}.send/receive --- .../src/opentelemetry/ext/asgi/__init__.py | 11 +++-------- .../tests/test_asgi_middleware.py | 8 ++++---- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py index aaf1ec326c5..06208e63575 100644 --- a/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py +++ b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py @@ -159,7 +159,7 @@ async def __call__(self, scope, receive, send): span_name = self.name_callback(scope) with self.tracer.start_as_current_span( - span_name + " (connection)", + span_name + " (asgi.connection)", parent_span, kind=trace.SpanKind.SERVER, attributes=collect_request_attributes(scope), @@ -168,7 +168,7 @@ async def __call__(self, scope, receive, send): @wraps(receive) async def wrapped_receive(): with self.tracer.start_as_current_span( - span_name + " (unknown-receive)" + span_name + " (asgi." + scope["type"] + ".receive)" ) as receive_span: payload = await receive() if payload["type"] == "websocket.receive": @@ -176,17 +176,13 @@ async def wrapped_receive(): receive_span.set_attribute( "http.status_text", payload["text"] ) - - receive_span.update_name( - span_name + " (" + payload["type"] + ")" - ) receive_span.set_attribute("type", payload["type"]) return payload @wraps(send) async def wrapped_send(payload): with self.tracer.start_as_current_span( - span_name + " (" + payload["type"] + ")" + span_name + " (asgi." + scope["type"] + ".send)" ) as send_span: if payload["type"] == "http.response.start": status_code = payload["status"] @@ -196,7 +192,6 @@ async def wrapped_send(payload): send_span.set_attribute( "http.status_text", payload["text"] ) - send_span.set_attribute("type", payload["type"]) await send(payload) diff --git a/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py b/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py index fd860f432d9..e85efb953d0 100644 --- a/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py +++ b/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py @@ -91,12 +91,12 @@ def validate_outputs(self, outputs, error=None, modifiers=None): self.assertEqual(len(span_list), 4) expected = [ { - "name": "HTTP GET (http.request)", + "name": "HTTP GET (asgi.http.receive)", "kind": trace_api.SpanKind.INTERNAL, "attributes": {"type": "http.request"}, }, { - "name": "HTTP GET (http.response.start)", + "name": "HTTP GET (asgi.http.send)", "kind": trace_api.SpanKind.INTERNAL, "attributes": { "http.status_code": 200, @@ -104,12 +104,12 @@ def validate_outputs(self, outputs, error=None, modifiers=None): }, }, { - "name": "HTTP GET (http.response.body)", + "name": "HTTP GET (asgi.http.send)", "kind": trace_api.SpanKind.INTERNAL, "attributes": {"type": "http.response.body"}, }, { - "name": "HTTP GET (connection)", + "name": "HTTP GET (asgi.connection)", "kind": trace_api.SpanKind.SERVER, "attributes": { "component": "http", From 775f58be2d966146bfe1c2ee9356f3deed55274a Mon Sep 17 00:00:00 2001 From: Emil Madsen Date: Fri, 21 Feb 2020 19:44:42 +0100 Subject: [PATCH 14/15] Handle scope["server"] = None, by defaulting to 0.0.0.0:80 --- .../src/opentelemetry/ext/asgi/__init__.py | 10 ++++------ .../tests/test_asgi_middleware.py | 20 ++++++++++++++++++- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py index 06208e63575..ed2bd87a9de 100644 --- a/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py +++ b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py @@ -79,11 +79,9 @@ def http_status_to_canonical_code(code: int, allow_redirect: bool = True): def collect_request_attributes(scope): """Collects HTTP request attributes from the ASGI scope and returns a dictionary to be used as span creation attributes.""" - - port = scope.get("server")[1] - server_host = scope.get("server")[0] + ( - ":" + str(port) if port != 80 else "" - ) + server = scope.get("server") or ['0.0.0.0', 80] + port = server[1] + server_host = server[0] + (":" + str(port) if port != 80 else "") http_url = scope.get("scheme") + "://" + server_host + scope.get("path") if scope.get("query_string"): http_url = http_url + ("?" + scope.get("query_string").decode("utf8")) @@ -91,7 +89,7 @@ def collect_request_attributes(scope): result = { "component": scope.get("type"), "http.method": scope.get("method"), - "http.server_name": scope.get("server")[0], + "http.server_name": server[0], "http.scheme": scope.get("scheme"), "http.host": server_host, "host.port": port, diff --git a/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py b/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py index e85efb953d0..3c2022464a7 100644 --- a/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py +++ b/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py @@ -133,7 +133,7 @@ def validate_outputs(self, outputs, error=None, modifiers=None): for span, expected in zip(span_list, expected): self.assertEqual(span.name, expected["name"]) self.assertEqual(span.kind, expected["kind"]) - self.assertEqual(span.attributes, expected["attributes"]) + self.assertDictEqual(dict(span.attributes), expected["attributes"]) def test_basic_asgi_call(self): """Test that spans are emitted as expected.""" @@ -170,6 +170,24 @@ def update_expected_span_name(expected): outputs = self.get_all_output() self.validate_outputs(outputs, modifiers=[update_expected_span_name]) + def test_behavior_with_scope_server_as_none(self): + """Test that middleware is ok when server is none in scope.""" + def update_expected_server(expected): + expected[3]['attributes'].update({ + 'http.server_name': '0.0.0.0', + 'http.host': '0.0.0.0', + 'host.port': 80, + 'http.url': 'http://0.0.0.0/' + }) + return expected + self.scope["server"] = None + app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) + self.seed_app(app) + self.send_default_request() + outputs = self.get_all_output() + self.validate_outputs(outputs, modifiers=[update_expected_server]) + + class TestAsgiAttributes(unittest.TestCase): def setUp(self): From 710586da53141de2d4ba51f5f5dd0c6c8ec705f9 Mon Sep 17 00:00:00 2001 From: Emil Madsen Date: Fri, 21 Feb 2020 20:43:38 +0100 Subject: [PATCH 15/15] Set http.server_name based on Host header --- .../src/opentelemetry/ext/asgi/__init__.py | 6 ++++-- .../tests/test_asgi_middleware.py | 18 +++++++++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py index ed2bd87a9de..1e67553b6e9 100644 --- a/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py +++ b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py @@ -89,7 +89,6 @@ def collect_request_attributes(scope): result = { "component": scope.get("type"), "http.method": scope.get("method"), - "http.server_name": server[0], "http.scheme": scope.get("scheme"), "http.host": server_host, "host.port": port, @@ -97,8 +96,11 @@ def collect_request_attributes(scope): "http.target": scope.get("path"), "http.url": http_url, } + http_host_value = ",".join(get_header_from_scope(scope, "host")) + if http_host_value: + result['http.server_name'] = http_host_value - if "client" in scope: + if "client" in scope and scope["client"] is not None: result["net.peer.ip"] = scope.get("client")[0] result["net.peer.port"] = scope.get("client")[1] diff --git a/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py b/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py index 3c2022464a7..866c0220f5c 100644 --- a/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py +++ b/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py @@ -114,7 +114,6 @@ def validate_outputs(self, outputs, error=None, modifiers=None): "attributes": { "component": "http", "http.method": "GET", - "http.server_name": "127.0.0.1", "http.scheme": "http", "host.port": 80, "http.host": "127.0.0.1", @@ -174,7 +173,6 @@ def test_behavior_with_scope_server_as_none(self): """Test that middleware is ok when server is none in scope.""" def update_expected_server(expected): expected[3]['attributes'].update({ - 'http.server_name': '0.0.0.0', 'http.host': '0.0.0.0', 'host.port': 80, 'http.url': 'http://0.0.0.0/' @@ -187,6 +185,21 @@ def update_expected_server(expected): outputs = self.get_all_output() self.validate_outputs(outputs, modifiers=[update_expected_server]) + def test_host_header(self): + """Test that host header is converted to http.server_name.""" + hostname = b"server_name_1" + def update_expected_server(expected): + expected[3]['attributes'].update({ + 'http.server_name': hostname.decode('utf8') + }) + return expected + self.scope["headers"].append([b'host', hostname]) + app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) + self.seed_app(app) + self.send_default_request() + outputs = self.get_all_output() + self.validate_outputs(outputs, modifiers=[update_expected_server]) + class TestAsgiAttributes(unittest.TestCase): @@ -209,7 +222,6 @@ def test_request_attributes(self): "http.url": "http://127.0.0.1/?foo=bar", "host.port": 80, "http.scheme": "http", - "http.server_name": "127.0.0.1", "http.flavor": "1.0", "net.peer.ip": "127.0.0.1", "net.peer.port": 32767,