-
Notifications
You must be signed in to change notification settings - Fork 864
Add: opentelemetry-ext-asgi #402
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 9 commits
c2502a1
b38e378
9b09035
7806f66
263ec71
ccb42a0
a10e230
f067ced
749d32e
e0184fb
ede28b2
c544259
4a96060
9c4242d
775f58b
710586d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| opentelemetry.ext.asgi package | ||
| ========================================== | ||
|
|
||
| Module contents | ||
| --------------- | ||
|
|
||
| .. automodule:: opentelemetry.ext.asgi | ||
| :members: | ||
| :undoc-members: | ||
| :show-inheritance: |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| # Changelog | ||
|
|
||
| ## Unreleased |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <https://opentelemetry.io/>`_ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| # Copyright 2019, OpenTelemetry Authors | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
| # | ||
| [metadata] | ||
| name = opentelemetry-ext-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.7 | ||
|
|
||
| [options] | ||
| python_requires = >=3.7 | ||
| package_dir= | ||
| =src | ||
| packages=find_namespace: | ||
| install_requires = | ||
| opentelemetry-api | ||
| asgiref | ||
|
|
||
| [options.extras_require] | ||
| test = | ||
| opentelemetry-ext-testutil | ||
|
|
||
| [options.packages.find] | ||
| where = src |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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__"]) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,206 @@ | ||
| # 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.""" | ||
|
|
||
| port = scope.get("server")[1] | ||
|
toumorokoshi marked this conversation as resolved.
Outdated
|
||
| 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": scope.get("type"), | ||
| "http.method": scope.get("method"), | ||
| "http.server_name": scope.get("server")[0], | ||
|
toumorokoshi marked this conversation as resolved.
Outdated
|
||
| "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, | ||
| } | ||
|
|
||
|
toumorokoshi marked this conversation as resolved.
|
||
| if "client" in scope: | ||
| 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: | ||
|
toumorokoshi marked this conversation as resolved.
|
||
| 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): | ||
| """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", "/") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. looking at the conversation in the opentelemetry-specification, we should use the HTTP method rather than the specialized path, since it has very high cardinality: https://github.com/open-telemetry/opentelemetry-specification/pull/416/files. But this is a bug with the existing wsgi implementation as well.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here's a tracking ticket for the wsgi implementation, but it'd be good to fix it in ASGI now: #409
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Acknowledged, I'll change it to utilize the method_name. Do you feel like I should make a similar change in the WSGI implementation to keep them equivalent, or would you rather have that change seperately?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It appears the recent update to the specification includes
Might I suggest adding an optional callback to this middleware to allow clients to specify a override the default span name?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'll add an optional callback to this, and pass it through.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Resolved in: ede28b2
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WSGI equivalent: #440 |
||
|
|
||
|
|
||
| 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. | ||
| """ | ||
|
|
||
| def __init__(self, app): | ||
| self.app = guarantee_single_callable(app) | ||
|
Skeen marked this conversation as resolved.
|
||
| self.tracer = trace.tracer_source().get_tracer(__name__, __version__) | ||
|
|
||
| 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 = get_default_span_name(scope) | ||
|
|
||
| with self.tracer.start_as_current_span( | ||
|
Skeen marked this conversation as resolved.
toumorokoshi marked this conversation as resolved.
|
||
| span_name, | ||
| 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 + " (unknown-receive)" | ||
|
toumorokoshi marked this conversation as resolved.
Outdated
|
||
| ) 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( | ||
|
toumorokoshi marked this conversation as resolved.
Outdated
|
||
| span_name + " (" + payload["type"] + ")" | ||
| ) | ||
| send_span.set_attribute("type", payload["type"]) | ||
| await send(payload) | ||
|
|
||
| await self.app(scope, wrapped_receive, wrapped_send) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
|
toumorokoshi marked this conversation as resolved.
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It feels like we should share this with the wsgi implementation somehow. Would it be bad if we just imported methods from the wsgi implementation, and made those more general?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was considering this myself, and had a version which imported this function from the WSGI extension instead of keeping it here, as it is untouched and thus identical.
My argument against this, and why I changed it back, is as this would introduce a dependency between the two implementation, and I did not feel comfortable with that with regards to packaging and shipping the package.
Thus, if I was to share code between the modules, I'd argue to refractor out common functionality into a utilities module, but I'm not sure where to put that.
As for sharing the get_header_from_x functionality, it would simply require a function transforming WSGI headers to ASGI headers or vice versa.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I see less of a rationale for get_headers_form_x since it requires a conversion of wsgi headers (although I could see us abstracting that out with a constructor).
I feel like MAYBE this is something we can put in the API. But would like maybe a second opinion on that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Alright, I'll leave the
get_headers_from_xalone.Do you think I should refactor out the
http_status_to_canonical_codeto a seperate module? - sayopentelemetry-ext-httputilsimilar to theopentelemetry-ext-testutil? - or would we rather just deal with code-duplication or interdependencies betweenext-asgiandext-wsgi?Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My two cents: I'd vote for a separate module, or even add it to the SDK (new ext-sdk?) The OTEL spec defines this mapping explicitly, so may as well provide it in the SDK right?