Skip to content

Commit 64b3cf2

Browse files
majorgreysSkeencodebotenflorimondmanca
authored
asgi: Add ASGI middleware (#716)
Adding an ASGI extension. Co-authored-by: Emil Madsen <[email protected]> Co-authored-by: alrex <[email protected]> Co-authored-by: Florimond Manca <[email protected]>
1 parent 334a534 commit 64b3cf2

File tree

15 files changed

+869
-27
lines changed

15 files changed

+869
-27
lines changed

docs-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ sphinx-rtd-theme~=0.4
33
sphinx-autodoc-typehints~=1.10.2
44

55
# Required by ext packages
6+
asgiref~=3.0
67
ddtrace>=0.34.0
78
aiohttp ~= 3.0
89
Deprecated>=1.2.6

docs/ext/asgi/asgi.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
opentelemetry.ext.asgi package
2+
==============================
3+
4+
Module contents
5+
---------------
6+
7+
.. automodule:: opentelemetry.ext.asgi
8+
:members:
9+
:undoc-members:
10+
:show-inheritance:
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Changelog
2+
3+
## Unreleased
4+
5+
- Add ASGI middleware ([#716](https://github.com/open-telemetry/opentelemetry-python/pull/716))
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
OpenTelemetry ASGI Middleware
2+
=============================
3+
4+
|pypi|
5+
6+
.. |pypi| image:: https://badge.fury.io/py/opentelemetry-ext-asgi.svg
7+
:target: https://pypi.org/project/opentelemetry-ext-asgi/
8+
9+
10+
This library provides a ASGI middleware that can be used on any ASGI framework
11+
(such as Django, Starlette, FastAPI or Quart) to track requests timing through OpenTelemetry.
12+
13+
Installation
14+
------------
15+
16+
::
17+
18+
pip install opentelemetry-ext-asgi
19+
20+
21+
Usage (Quart)
22+
-------------
23+
24+
.. code-block:: python
25+
26+
from quart import Quart
27+
from opentelemetry.ext.asgi import OpenTelemetryMiddleware
28+
29+
app = Quart(__name__)
30+
app.asgi_app = OpenTelemetryMiddleware(app.asgi_app)
31+
32+
@app.route("/")
33+
async def hello():
34+
return "Hello!"
35+
36+
if __name__ == "__main__":
37+
app.run(debug=True)
38+
39+
40+
Usage (Django 3.0)
41+
------------------
42+
43+
Modify the application's ``asgi.py`` file as shown below.
44+
45+
.. code-block:: python
46+
47+
import os
48+
from django.core.asgi import get_asgi_application
49+
from opentelemetry.ext.asgi import OpenTelemetryMiddleware
50+
51+
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'asgi_example.settings')
52+
53+
application = get_asgi_application()
54+
application = OpenTelemetryMiddleware(application)
55+
56+
57+
References
58+
----------
59+
60+
* `OpenTelemetry Project <https://opentelemetry.io/>`_
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
[metadata]
16+
name = opentelemetry-ext-asgi
17+
description = ASGI Middleware for OpenTelemetry
18+
long_description = file: README.rst
19+
long_description_content_type = text/x-rst
20+
author = OpenTelemetry Authors
21+
author_email = [email protected]
22+
url = https://github.com/open-telemetry/opentelemetry-python/ext/opentelemetry-ext-asgi
23+
platforms = any
24+
license = Apache-2.0
25+
classifiers =
26+
Development Status :: 4 - Beta
27+
Intended Audience :: Developers
28+
License :: OSI Approved :: Apache Software License
29+
Programming Language :: Python
30+
Programming Language :: Python :: 3
31+
Programming Language :: Python :: 3.5
32+
Programming Language :: Python :: 3.6
33+
Programming Language :: Python :: 3.7
34+
Programming Language :: Python :: 3.8
35+
36+
[options]
37+
python_requires = >=3.5
38+
package_dir=
39+
=src
40+
packages=find_namespace:
41+
install_requires =
42+
opentelemetry-api == 0.8.dev0
43+
asgiref ~= 3.0
44+
45+
[options.extras_require]
46+
test =
47+
opentelemetry-ext-testutil
48+
49+
[options.packages.find]
50+
where = src
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
import os
15+
16+
import setuptools
17+
18+
BASE_DIR = os.path.dirname(__file__)
19+
VERSION_FILENAME = os.path.join(
20+
BASE_DIR, "src", "opentelemetry", "ext", "asgi", "version.py"
21+
)
22+
PACKAGE_INFO = {}
23+
with open(VERSION_FILENAME) as f:
24+
exec(f.read(), PACKAGE_INFO)
25+
26+
setuptools.setup(version=PACKAGE_INFO["__version__"])
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""
16+
The opentelemetry-ext-asgi package provides an ASGI middleware that can be used
17+
on any ASGI framework (such as Django-channels / Quart) to track requests
18+
timing through OpenTelemetry.
19+
"""
20+
21+
import operator
22+
import typing
23+
import urllib
24+
from functools import wraps
25+
26+
from asgiref.compatibility import guarantee_single_callable
27+
28+
from opentelemetry import context, propagators, trace
29+
from opentelemetry.ext.asgi.version import __version__ # noqa
30+
from opentelemetry.trace.status import Status, StatusCanonicalCode
31+
32+
33+
def get_header_from_scope(scope: dict, header_name: str) -> typing.List[str]:
34+
"""Retrieve a HTTP header value from the ASGI scope.
35+
36+
Returns:
37+
A list with a single string with the header value if it exists, else an empty list.
38+
"""
39+
headers = scope.get("headers")
40+
return [
41+
value.decode("utf8")
42+
for (key, value) in headers
43+
if key.decode("utf8") == header_name
44+
]
45+
46+
47+
def http_status_to_canonical_code(code: int, allow_redirect: bool = True):
48+
# pylint:disable=too-many-branches,too-many-return-statements
49+
if code < 100:
50+
return StatusCanonicalCode.UNKNOWN
51+
if code <= 299:
52+
return StatusCanonicalCode.OK
53+
if code <= 399:
54+
if allow_redirect:
55+
return StatusCanonicalCode.OK
56+
return StatusCanonicalCode.DEADLINE_EXCEEDED
57+
if code <= 499:
58+
if code == 401: # HTTPStatus.UNAUTHORIZED:
59+
return StatusCanonicalCode.UNAUTHENTICATED
60+
if code == 403: # HTTPStatus.FORBIDDEN:
61+
return StatusCanonicalCode.PERMISSION_DENIED
62+
if code == 404: # HTTPStatus.NOT_FOUND:
63+
return StatusCanonicalCode.NOT_FOUND
64+
if code == 429: # HTTPStatus.TOO_MANY_REQUESTS:
65+
return StatusCanonicalCode.RESOURCE_EXHAUSTED
66+
return StatusCanonicalCode.INVALID_ARGUMENT
67+
if code <= 599:
68+
if code == 501: # HTTPStatus.NOT_IMPLEMENTED:
69+
return StatusCanonicalCode.UNIMPLEMENTED
70+
if code == 503: # HTTPStatus.SERVICE_UNAVAILABLE:
71+
return StatusCanonicalCode.UNAVAILABLE
72+
if code == 504: # HTTPStatus.GATEWAY_TIMEOUT:
73+
return StatusCanonicalCode.DEADLINE_EXCEEDED
74+
return StatusCanonicalCode.INTERNAL
75+
return StatusCanonicalCode.UNKNOWN
76+
77+
78+
def collect_request_attributes(scope):
79+
"""Collects HTTP request attributes from the ASGI scope and returns a
80+
dictionary to be used as span creation attributes."""
81+
server = scope.get("server") or ["0.0.0.0", 80]
82+
port = server[1]
83+
server_host = server[0] + (":" + str(port) if port != 80 else "")
84+
full_path = scope.get("root_path", "") + scope.get("path", "")
85+
http_url = scope.get("scheme", "http") + "://" + server_host + full_path
86+
query_string = scope.get("query_string")
87+
if query_string and http_url:
88+
if isinstance(query_string, bytes):
89+
query_string = query_string.decode("utf8")
90+
http_url = http_url + ("?" + urllib.parse.unquote(query_string))
91+
92+
result = {
93+
"component": scope["type"],
94+
"http.scheme": scope.get("scheme"),
95+
"http.host": server_host,
96+
"host.port": port,
97+
"http.flavor": scope.get("http_version"),
98+
"http.target": scope.get("path"),
99+
"http.url": http_url,
100+
}
101+
http_method = scope.get("method")
102+
if http_method:
103+
result["http.method"] = http_method
104+
http_host_value = ",".join(get_header_from_scope(scope, "host"))
105+
if http_host_value:
106+
result["http.server_name"] = http_host_value
107+
http_user_agent = get_header_from_scope(scope, "user-agent")
108+
if len(http_user_agent) > 0:
109+
result["http.user_agent"] = http_user_agent[0]
110+
111+
if "client" in scope and scope["client"] is not None:
112+
result["net.peer.ip"] = scope.get("client")[0]
113+
result["net.peer.port"] = scope.get("client")[1]
114+
115+
# remove None values
116+
result = {k: v for k, v in result.items() if v is not None}
117+
118+
return result
119+
120+
121+
def set_status_code(span, status_code):
122+
"""Adds HTTP response attributes to span using the status_code argument."""
123+
try:
124+
status_code = int(status_code)
125+
except ValueError:
126+
span.set_status(
127+
Status(
128+
StatusCanonicalCode.UNKNOWN,
129+
"Non-integer HTTP status: " + repr(status_code),
130+
)
131+
)
132+
else:
133+
span.set_attribute("http.status_code", status_code)
134+
span.set_status(Status(http_status_to_canonical_code(status_code)))
135+
136+
137+
def get_default_span_name(scope):
138+
"""Default implementation for name_callback"""
139+
method_or_path = scope.get("method") or scope.get("path")
140+
141+
return method_or_path
142+
143+
144+
class OpenTelemetryMiddleware:
145+
"""The ASGI application middleware.
146+
147+
This class is an ASGI middleware that starts and annotates spans for any
148+
requests it is invoked with.
149+
150+
Args:
151+
app: The ASGI application callable to forward requests to.
152+
name_callback: Callback which calculates a generic span name for an
153+
incoming HTTP request based on the ASGI scope.
154+
Optional: Defaults to get_default_span_name.
155+
"""
156+
157+
def __init__(self, app, name_callback=None):
158+
self.app = guarantee_single_callable(app)
159+
self.tracer = trace.get_tracer(__name__, __version__)
160+
self.name_callback = name_callback or get_default_span_name
161+
162+
async def __call__(self, scope, receive, send):
163+
"""The ASGI application
164+
165+
Args:
166+
scope: A ASGI environment.
167+
receive: An awaitable callable yielding dictionaries
168+
send: An awaitable callable taking a single dictionary as argument.
169+
"""
170+
if scope["type"] not in ("http", "websocket"):
171+
return await self.app(scope, receive, send)
172+
173+
token = context.attach(
174+
propagators.extract(get_header_from_scope, scope)
175+
)
176+
span_name = self.name_callback(scope)
177+
178+
try:
179+
with self.tracer.start_as_current_span(
180+
span_name + " asgi",
181+
kind=trace.SpanKind.SERVER,
182+
attributes=collect_request_attributes(scope),
183+
):
184+
185+
@wraps(receive)
186+
async def wrapped_receive():
187+
with self.tracer.start_as_current_span(
188+
span_name + " asgi." + scope["type"] + ".receive"
189+
) as receive_span:
190+
message = await receive()
191+
if message["type"] == "websocket.receive":
192+
set_status_code(receive_span, 200)
193+
receive_span.set_attribute("type", message["type"])
194+
return message
195+
196+
@wraps(send)
197+
async def wrapped_send(message):
198+
with self.tracer.start_as_current_span(
199+
span_name + " asgi." + scope["type"] + ".send"
200+
) as send_span:
201+
if message["type"] == "http.response.start":
202+
status_code = message["status"]
203+
set_status_code(send_span, status_code)
204+
elif message["type"] == "websocket.send":
205+
set_status_code(send_span, 200)
206+
send_span.set_attribute("type", message["type"])
207+
await send(message)
208+
209+
await self.app(scope, wrapped_receive, wrapped_send)
210+
finally:
211+
context.detach(token)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
__version__ = "0.8.dev0"

ext/opentelemetry-ext-asgi/tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)