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/CHANGELOG.md b/ext/opentelemetry-ext-asgi/CHANGELOG.md new file mode 100644 index 00000000000..1512c421622 --- /dev/null +++ b/ext/opentelemetry-ext-asgi/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +## Unreleased diff --git a/ext/opentelemetry-ext-asgi/README.rst b/ext/opentelemetry-ext-asgi/README.rst new file mode 100644 index 00000000000..09e8bcc1fa0 --- /dev/null +++ b/ext/opentelemetry-ext-asgi/README.rst @@ -0,0 +1,61 @@ +OpenTelemetry ASGI Middleware +============================= + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-ext-asgi.svg + :target: https://pypi.org/project/opentelemetry-ext-asgi/ + + +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 +------------ + +:: + + pip install opentelemetry-ext-asgi + + +Usage (Quart) +------------- + +.. code-block:: python + + from quart import Quart + from opentelemetry.ext.asgi import OpenTelemetryMiddleware + + app = Quart(__name__) + app.asgi_app = OpenTelemetryMiddleware(app.asgi_app) + + @app.route("/") + async def hello(): + return "Hello!" + + if __name__ == "__main__": + app.run(debug=True) + + +Usage (Django) +-------------- + +Modify the application's ``asgi.py`` file as shown below. + +.. code-block:: python + + import os + 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_default_application() + application = OpenTelemetryMiddleware(application) + +References +---------- + +* `OpenTelemetry Project `_ diff --git a/ext/opentelemetry-ext-asgi/setup.cfg b/ext/opentelemetry-ext-asgi/setup.cfg new file mode 100644 index 00000000000..fdbc0dee22e --- /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-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-asgi +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.5 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + +[options] +python_requires = >=3.5 +package_dir= + =src +packages=find_namespace: +install_requires = + opentelemetry-api + asgiref + +[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..42c82506eb0 --- /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", "asgi", "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/asgi/__init__.py b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py new file mode 100644 index 00000000000..1e67553b6e9 --- /dev/null +++ b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py @@ -0,0 +1,198 @@ +# 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-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 operator +import typing +from functools import wraps + +from asgiref.compatibility import guarantee_single_callable + +from opentelemetry import propagators, trace +from opentelemetry.ext.asgi.version import __version__ # noqa +from opentelemetry.trace.status import Status, StatusCanonicalCode + +_HTTP_VERSION_PREFIX = "HTTP/" + + +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") + 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): + # 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(scope): + """Collects HTTP request attributes from the ASGI scope and returns a + dictionary to be used as span creation attributes.""" + 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")) + + result = { + "component": scope.get("type"), + "http.method": scope.get("method"), + "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, + } + 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 and scope["client"] is not None: + result["net.peer.ip"] = scope.get("client")[0] + result["net.peer.port"] = scope.get("client")[1] + + return result + + +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: + 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(scope): + """Default implementation for name_callback, returns HTTP {METHOD_NAME}.""" + return "HTTP " + scope.get("method") + + +class OpenTelemetryMiddleware: + """The ASGI application middleware. + + This class is an ASGI middleware that starts and annotates spans for any + requests it is invoked with. + + 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, name_callback=None): + self.app = guarantee_single_callable(app) + self.tracer = trace.get_tracer(__name__, __version__) + self.name_callback = name_callback or get_default_span_name + + async def __call__(self, scope, receive, send): + """The ASGI application + + Args: + 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_scope, scope) + span_name = self.name_callback(scope) + + with self.tracer.start_as_current_span( + span_name + " (asgi.connection)", + parent_span, + kind=trace.SpanKind.SERVER, + attributes=collect_request_attributes(scope), + ): + + @wraps(receive) + async def wrapped_receive(): + with self.tracer.start_as_current_span( + span_name + " (asgi." + scope["type"] + ".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.set_attribute("type", payload["type"]) + return payload + + @wraps(send) + async def wrapped_send(payload): + with self.tracer.start_as_current_span( + span_name + " (asgi." + scope["type"] + ".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.set_attribute("type", payload["type"]) + await send(payload) + + await self.app(scope, wrapped_receive, wrapped_send) diff --git a/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/version.py b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/version.py new file mode 100644 index 00000000000..2f792fff802 --- /dev/null +++ b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/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_asgi_middleware.py b/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py new file mode 100644 index 00000000000..866c0220f5c --- /dev/null +++ b/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py @@ -0,0 +1,244 @@ +# 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 opentelemetry.ext.asgi as otel_asgi +from opentelemetry import trace as trace_api +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, modifiers=None): + # Ensure modifiers is a list + modifiers = modifiers or [] + # 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(exc_info[0], error) + self.assertIsInstance(exc_info[1], error) + self.assertIsNotNone(exc_info[2]) + else: + self.assertIsNone(exc_info) + + # Check spans + span_list = self.memory_exporter.get_finished_spans() + self.assertEqual(len(span_list), 4) + expected = [ + { + "name": "HTTP GET (asgi.http.receive)", + "kind": trace_api.SpanKind.INTERNAL, + "attributes": {"type": "http.request"}, + }, + { + "name": "HTTP GET (asgi.http.send)", + "kind": trace_api.SpanKind.INTERNAL, + "attributes": { + "http.status_code": 200, + "type": "http.response.start", + }, + }, + { + "name": "HTTP GET (asgi.http.send)", + "kind": trace_api.SpanKind.INTERNAL, + "attributes": {"type": "http.response.body"}, + }, + { + "name": "HTTP GET (asgi.connection)", + "kind": trace_api.SpanKind.SERVER, + "attributes": { + "component": "http", + "http.method": "GET", + "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, + }, + }, + ] + # 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.assertDictEqual(dict(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() + outputs = self.get_all_output() + 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]) + + 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.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]) + + 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): + def setUp(self): + self.scope = {} + setup_testing_defaults(self.scope) + self.span = mock.create_autospec(trace_api.Span, spec_set=True) + + def test_request_attributes(self): + self.scope["query_string"] = b"foo=bar" + + 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.flavor": "1.0", + "net.peer.ip": "127.0.0.1", + "net.peer.port": 32767, + }, + ) + + def test_response_attributes(self): + otel_asgi.set_status_code(self.span, 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) + + def test_response_attributes_invalid_status_code(self): + otel_asgi.set_status_code(self.span, "Invalid Status Code") + self.assertEqual(self.span.set_status.call_count, 1) + + +if __name__ == "__main__": + unittest.main() 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..4f5fb94f0b2 --- /dev/null +++ b/ext/opentelemetry-ext-testutil/src/opentelemetry/ext/testutil/asgitestutil.py @@ -0,0 +1,62 @@ +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: + 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..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,37 +1,12 @@ import io -import unittest import wsgiref.util as wsgiref_util -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, -) +from opentelemetry.ext.testutil.spantestutil import SpanTestBase -_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 diff --git a/scripts/coverage.sh b/scripts/coverage.sh index 9b981b0817c..51e3cfefb41 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -12,11 +12,14 @@ function cov { ${1} } +PYTHON_VERSION=$(python -c 'import sys; print(".".join(map(str, sys.version_info[:3])))') +PYTHON_VERSION_INFO=(${PYTHON_VERSION//./ }) 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 @@ -25,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 8d5fe1d9fea..6a0d784bb1a 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,6 @@ skipsdist = True skip_missing_interpreters = True envlist = - ; Environments are organized by individual package, allowing ; for specifying supported Python versions per package. ; opentelemetry-api @@ -53,6 +52,10 @@ envlist = py3{4,5,6,7,8}-test-ext-pymongo pypy3-test-ext-pymongo + ; opentelemetry-ext-asgi + py3{5,6,7,8}-test-ext-wsgi + pypy3-test-ext-wsgi + ; opentelemetry-ext-wsgi py3{4,5,6,7,8}-test-ext-wsgi pypy3-test-ext-wsgi @@ -65,7 +68,6 @@ envlist = py3{4,5,6,7,8}-test-opentracing-shim pypy3-test-opentracing-shim - py3{4,5,6,7,8}-coverage ; Coverage is temporarily disabled for pypy3 due to the pytest bug. @@ -101,6 +103,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 @@ -128,9 +131,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 @@ -193,6 +197,7 @@ deps = thrift>=0.10.0 pymongo ~= 3.1 flask~=1.0 + asgiref~=3.2.3 changedir = docs @@ -236,4 +241,4 @@ commands = pytest {posargs} commands_post = - docker-compose down \ No newline at end of file + docker-compose down