Skip to content

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

Closed
wants to merge 16 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions docs/opentelemetry.ext.asgi.rst
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:
3 changes: 3 additions & 0 deletions ext/opentelemetry-ext-asgi/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Changelog

## Unreleased
61 changes: 61 additions & 0 deletions ext/opentelemetry-ext-asgi/README.rst
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/>`_
49 changes: 49 additions & 0 deletions ext/opentelemetry-ext-asgi/setup.cfg
Original file line number Diff line number Diff line change
@@ -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 = [email protected]
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
26 changes: 26 additions & 0 deletions ext/opentelemetry-ext-asgi/setup.py
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__"])
198 changes: 198 additions & 0 deletions ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/__init__.py
Original file line number Diff line number Diff line change
@@ -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):
Copy link
Member

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?

Copy link
Contributor Author

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.

  • We can make this change, if that's something we want.

Copy link
Member

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.

Copy link
Contributor Author

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_x alone.

Do you think I should refactor out the http_status_to_canonical_code to a seperate module? - say opentelemetry-ext-httputil similar to the opentelemetry-ext-testutil? - or would we rather just deal with code-duplication or interdependencies between ext-asgi and ext-wsgi?

Copy link
Contributor

@joshuahlang joshuahlang Feb 22, 2020

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?

# 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)
15 changes: 15 additions & 0 deletions ext/opentelemetry-ext-asgi/src/opentelemetry/ext/asgi/version.py
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"
Empty file.
Loading