diff --git a/docs-requirements.txt b/docs-requirements.txt index 55b647402f6..358316fa7e7 100644 --- a/docs-requirements.txt +++ b/docs-requirements.txt @@ -3,6 +3,7 @@ sphinx-rtd-theme~=0.4 sphinx-autodoc-typehints~=1.10.2 # Required by ext packages +asgiref~=3.0 ddtrace>=0.34.0 aiohttp ~= 3.0 Deprecated>=1.2.6 diff --git a/docs/ext/asgi/asgi.rst b/docs/ext/asgi/asgi.rst new file mode 100644 index 00000000000..82c98330693 --- /dev/null +++ b/docs/ext/asgi/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..7936dd50fc4 --- /dev/null +++ b/ext/opentelemetry-ext-asgi/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## Unreleased + +- Add ASGI middleware ([#716](https://github.com/open-telemetry/opentelemetry-python/pull/716)) diff --git a/ext/opentelemetry-ext-asgi/README.rst b/ext/opentelemetry-ext-asgi/README.rst new file mode 100644 index 00000000000..bc286e5c8b1 --- /dev/null +++ b/ext/opentelemetry-ext-asgi/README.rst @@ -0,0 +1,60 @@ +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, Starlette, FastAPI or Quart) 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 3.0) +------------------ + +Modify the application's ``asgi.py`` file as shown below. + +.. code-block:: python + + import os + from django.core.asgi import get_asgi_application + from opentelemetry.ext.asgi import OpenTelemetryMiddleware + + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'asgi_example.settings') + + application = get_asgi_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..f8347377357 --- /dev/null +++ b/ext/opentelemetry-ext-asgi/setup.cfg @@ -0,0 +1,50 @@ +# Copyright The 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 :: 4 - Beta + 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 + Programming Language :: Python :: 3.8 + +[options] +python_requires = >=3.5 +package_dir= + =src +packages=find_namespace: +install_requires = + opentelemetry-api == 0.8.dev0 + asgiref ~= 3.0 + +[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..8bf19bd4225 --- /dev/null +++ b/ext/opentelemetry-ext-asgi/setup.py @@ -0,0 +1,26 @@ +# Copyright The 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..69c30848da3 --- /dev/null +++ b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py @@ -0,0 +1,211 @@ +# Copyright The 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 +import urllib +from functools import wraps + +from asgiref.compatibility import guarantee_single_callable + +from opentelemetry import context, propagators, trace +from opentelemetry.ext.asgi.version import __version__ # noqa +from opentelemetry.trace.status import Status, StatusCanonicalCode + + +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 "") + full_path = scope.get("root_path", "") + scope.get("path", "") + http_url = scope.get("scheme", "http") + "://" + server_host + full_path + query_string = scope.get("query_string") + if query_string and http_url: + if isinstance(query_string, bytes): + query_string = query_string.decode("utf8") + http_url = http_url + ("?" + urllib.parse.unquote(query_string)) + + result = { + "component": scope["type"], + "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_method = scope.get("method") + if http_method: + result["http.method"] = http_method + http_host_value = ",".join(get_header_from_scope(scope, "host")) + if http_host_value: + result["http.server_name"] = http_host_value + http_user_agent = get_header_from_scope(scope, "user-agent") + if len(http_user_agent) > 0: + result["http.user_agent"] = http_user_agent[0] + + 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] + + # remove None values + result = {k: v for k, v in result.items() if v is not None} + + 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""" + method_or_path = scope.get("method") or scope.get("path") + + return method_or_path + + +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. + """ + if scope["type"] not in ("http", "websocket"): + return await self.app(scope, receive, send) + + token = context.attach( + propagators.extract(get_header_from_scope, scope) + ) + span_name = self.name_callback(scope) + + try: + with self.tracer.start_as_current_span( + span_name + " asgi", + 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: + message = await receive() + if message["type"] == "websocket.receive": + set_status_code(receive_span, 200) + receive_span.set_attribute("type", message["type"]) + return message + + @wraps(send) + async def wrapped_send(message): + with self.tracer.start_as_current_span( + span_name + " asgi." + scope["type"] + ".send" + ) as send_span: + if message["type"] == "http.response.start": + status_code = message["status"] + set_status_code(send_span, status_code) + elif message["type"] == "websocket.send": + set_status_code(send_span, 200) + send_span.set_attribute("type", message["type"]) + await send(message) + + await self.app(scope, wrapped_receive, wrapped_send) + finally: + context.detach(token) 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..bcf6a357770 --- /dev/null +++ b/ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/version.py @@ -0,0 +1,15 @@ +# Copyright The 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.8.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..edc90444a7b --- /dev/null +++ b/ext/opentelemetry-ext-asgi/tests/test_asgi_middleware.py @@ -0,0 +1,345 @@ +# Copyright The 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.test.asgitestutil import ( + AsgiTestBase, + setup_testing_defaults, +) + + +async def http_app(scope, receive, send): + message = await receive() + assert scope["type"] == "http" + if message.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 websocket_app(scope, receive, send): + assert scope["type"] == "websocket" + while True: + message = await receive() + if message.get("type") == "websocket.connect": + await send({"type": "websocket.accept"}) + + if message.get("type") == "websocket.receive": + if message.get("text") == "ping": + await send({"type": "websocket.send", "text": "pong"}) + + if message.get("type") == "websocket.disconnect": + break + + +async def simple_asgi(scope, receive, send): + assert isinstance(scope, dict) + if scope["type"] == "http": + await http_app(scope, receive, send) + elif scope["type"] == "websocket": + await websocket_app(scope, receive, send) + + +async def error_asgi(scope, receive, send): + assert isinstance(scope, dict) + assert scope["type"] == "http" + message = await receive() + if message.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": "GET asgi.http.receive", + "kind": trace_api.SpanKind.INTERNAL, + "attributes": {"type": "http.request"}, + }, + { + "name": "GET asgi.http.send", + "kind": trace_api.SpanKind.INTERNAL, + "attributes": { + "http.status_code": 200, + "type": "http.response.start", + }, + }, + { + "name": "GET asgi.http.send", + "kind": trace_api.SpanKind.INTERNAL, + "attributes": {"type": "http.response.body"}, + }, + { + "name": "GET asgi", + "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" + + # pylint:disable=unused-argument + 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]) + + def test_user_agent(self): + """Test that host header is converted to http.server_name.""" + user_agent = b"test-agent" + + def update_expected_user_agent(expected): + expected[3]["attributes"].update( + {"http.user_agent": user_agent.decode("utf8")} + ) + return expected + + self.scope["headers"].append([b"user-agent", user_agent]) + 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_user_agent]) + + def test_websocket(self): + self.scope = { + "type": "websocket", + "http_version": "1.1", + "scheme": "ws", + "path": "/", + "query_string": b"", + "headers": [], + "client": ("127.0.0.1", 32767), + "server": ("127.0.0.1", 80), + } + app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) + self.seed_app(app) + self.send_input({"type": "websocket.connect"}) + self.send_input({"type": "websocket.receive", "text": "ping"}) + self.send_input({"type": "websocket.disconnect"}) + self.get_all_output() + span_list = self.memory_exporter.get_finished_spans() + self.assertEqual(len(span_list), 6) + expected = [ + "/ asgi.websocket.receive", + "/ asgi.websocket.send", + "/ asgi.websocket.receive", + "/ asgi.websocket.send", + "/ asgi.websocket.receive", + "/ asgi", + ] + actual = [span.name for span in span_list] + self.assertListEqual(actual, expected) + + def test_lifespan(self): + self.scope["type"] = "lifespan" + app = otel_asgi.OpenTelemetryMiddleware(simple_asgi) + self.seed_app(app) + self.send_default_request() + span_list = self.memory_exporter.get_finished_spans() + self.assertEqual(len(span_list), 0) + + +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_query_string(self): + self.scope["query_string"] = b"foo=bar" + attrs = otel_asgi.collect_request_attributes(self.scope) + self.assertEqual(attrs["http.url"], "http://127.0.0.1/?foo=bar") + + def test_query_string_percent_bytes(self): + self.scope["query_string"] = b"foo%3Dbar" + attrs = otel_asgi.collect_request_attributes(self.scope) + self.assertEqual(attrs["http.url"], "http://127.0.0.1/?foo=bar") + + def test_query_string_percent_str(self): + self.scope["query_string"] = "foo%3Dbar" + attrs = otel_asgi.collect_request_attributes(self.scope) + self.assertEqual(attrs["http.url"], "http://127.0.0.1/?foo=bar") + + 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/scripts/coverage.sh b/scripts/coverage.sh index 248e5faea8b..8e09ae23a31 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -31,6 +31,9 @@ cov docs/examples/opentelemetry-example-app # aiohttp is only supported on Python 3.5+. if [ ${PYTHON_VERSION_INFO[1]} -gt 4 ]; then cov ext/opentelemetry-ext-aiohttp-client +# 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 diff --git a/tests/util/src/opentelemetry/test/asgitestutil.py b/tests/util/src/opentelemetry/test/asgitestutil.py new file mode 100644 index 00000000000..7d23039bf30 --- /dev/null +++ b/tests/util/src/opentelemetry/test/asgitestutil.py @@ -0,0 +1,76 @@ +# Copyright The 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 asyncio + +from asgiref.testing import ApplicationCommunicator + +from opentelemetry.test.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, message): + asyncio.get_event_loop().run_until_complete( + self.communicator.send_input(message) + ) + + 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/tests/util/src/opentelemetry/test/spantestutil.py b/tests/util/src/opentelemetry/test/spantestutil.py new file mode 100644 index 00000000000..4ae4669a040 --- /dev/null +++ b/tests/util/src/opentelemetry/test/spantestutil.py @@ -0,0 +1,43 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from importlib import reload + +from opentelemetry import trace as trace_api +from opentelemetry.sdk.trace import TracerProvider, 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_tracer_provider(TracerProvider()) + tracer_provider = trace_api.get_tracer_provider() + _MEMORY_EXPORTER = InMemorySpanExporter() + span_processor = export.SimpleExportSpanProcessor(_MEMORY_EXPORTER) + tracer_provider.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/tests/util/src/opentelemetry/test/wsgitestutil.py b/tests/util/src/opentelemetry/test/wsgitestutil.py index cdce28b9078..349db8f6944 100644 --- a/tests/util/src/opentelemetry/test/wsgitestutil.py +++ b/tests/util/src/opentelemetry/test/wsgitestutil.py @@ -1,35 +1,26 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import io -import unittest import wsgiref.util as wsgiref_util -from importlib import reload - -from opentelemetry import trace as trace_api -from opentelemetry.sdk.trace import TracerProvider, export -from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( - InMemorySpanExporter, -) - -_MEMORY_EXPORTER = None +from opentelemetry.test.spantestutil import SpanTestBase -class WsgiTestBase(unittest.TestCase): - @classmethod - def setUpClass(cls): - global _MEMORY_EXPORTER # pylint:disable=global-statement - trace_api.set_tracer_provider(TracerProvider()) - tracer_provider = trace_api.get_tracer_provider() - _MEMORY_EXPORTER = InMemorySpanExporter() - span_processor = export.SimpleExportSpanProcessor(_MEMORY_EXPORTER) - tracer_provider.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/tox.ini b/tox.ini index 918e99f7ca1..abb1355be96 100644 --- a/tox.ini +++ b/tox.ini @@ -79,6 +79,10 @@ envlist = py3{4,5,6,7,8}-test-ext-pymysql pypy3-test-ext-pymysql + ; opentelemetry-ext-asgi + py3{5,6,7,8}-test-ext-asgi + pypy3-test-ext-asgi + ; opentelemetry-ext-sqlite3 py3{4,5,6,7,8}-test-ext-sqlite3 pypy3-test-ext-sqlite3 @@ -148,6 +152,7 @@ changedir = test-ext-pymongo: ext/opentelemetry-ext-pymongo/tests test-ext-psycopg2: ext/opentelemetry-ext-psycopg2/tests test-ext-pymysql: ext/opentelemetry-ext-pymysql/tests + test-ext-asgi: ext/opentelemetry-ext-asgi/tests test-ext-sqlite3: ext/opentelemetry-ext-sqlite3/tests test-ext-wsgi: ext/opentelemetry-ext-wsgi/tests test-ext-zipkin: ext/opentelemetry-ext-zipkin/tests @@ -173,9 +178,10 @@ commands_pre = grpc: pip install {toxinidir}/ext/opentelemetry-ext-grpc[test] - wsgi,flask,django: pip install {toxinidir}/tests/util + wsgi,flask,django,asgi: pip install {toxinidir}/tests/util wsgi,flask,django: pip install {toxinidir}/ext/opentelemetry-ext-wsgi flask,django: pip install {toxinidir}/opentelemetry-auto-instrumentation + 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[test]