Skip to content

Commit 8d0c504

Browse files
dmanchondecko
authored andcommitted
draft instrumentation for aiohttp server / guillotina
1 parent 1a1163e commit 8d0c504

File tree

7 files changed

+393
-0
lines changed

7 files changed

+393
-0
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
OpenTelemetry aiohttp server Integration
2+
========================================
3+
4+
|pypi|
5+
6+
.. |pypi| image:: https://badge.fury.io/py/opentelemetry-instrumentation-aiohttp-client.svg
7+
:target: https://pypi.org/project/opentelemetry-instrumentation-aiohttp-client/
8+
9+
This library allows tracing HTTP requests made by the
10+
`aiohttp server <https://docs.aiohttp.org/en/stable/server.html>`_ library.
11+
12+
Installation
13+
------------
14+
15+
::
16+
17+
pip install opentelemetry-instrumentation-aiohttp-server
18+
19+
References
20+
----------
21+
22+
* `OpenTelemetry Project <https://opentelemetry.io/>`_
23+
* `aiohttp client Tracing <https://docs.aiohttp.org/en/stable/tracing_reference.html>`_
24+
* `OpenTelemetry Python Examples <https://github.com/open-telemetry/opentelemetry-python/tree/main/docs/examples>`_
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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-instrumentation-aiohttp-server
17+
description = Aiohttp server instrumentation 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-contrib/tree/main/instrumentation/opentelemetry-instrumentation-aiohttp-server
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.6
32+
Programming Language :: Python :: 3.7
33+
Programming Language :: Python :: 3.8
34+
Programming Language :: Python :: 3.9
35+
Programming Language :: Python :: 3.10
36+
37+
[options]
38+
python_requires = >=3.6
39+
package_dir=
40+
=src
41+
packages=find_namespace:
42+
install_requires =
43+
opentelemetry-api ~= 1.3
44+
opentelemetry-semantic-conventions == 0.28b1
45+
opentelemetry-instrumentation == 0.28b1
46+
opentelemetry-util-http == 0.28b1
47+
48+
[options.packages.find]
49+
where = src
50+
51+
[options.extras_require]
52+
test =
53+
opentelemetry-test-utils == 0.28b1
54+
55+
56+
[options.entry_points]
57+
opentelemetry_instrumentor =
58+
aiohttp-server = opentelemetry.instrumentation.aiohttp_server:AioHttpInstrumentor
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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+
# DO NOT EDIT. THIS FILE WAS AUTOGENERATED FROM templates/instrumentation_setup.py.txt.
17+
# RUN `python scripts/generate_setup.py` TO REGENERATE.
18+
19+
20+
import distutils.cmd
21+
import json
22+
import os
23+
from configparser import ConfigParser
24+
25+
import setuptools
26+
27+
config = ConfigParser()
28+
config.read("setup.cfg")
29+
30+
# We provide extras_require parameter to setuptools.setup later which
31+
# overwrites the extra_require section from setup.cfg. To support extra_require
32+
# secion in setup.cfg, we load it here and merge it with the extra_require param.
33+
extras_require = {}
34+
if "options.extras_require" in config:
35+
for key, value in config["options.extras_require"].items():
36+
extras_require[key] = [v for v in value.split("\n") if v.strip()]
37+
38+
BASE_DIR = os.path.dirname(__file__)
39+
PACKAGE_INFO = {}
40+
41+
VERSION_FILENAME = os.path.join(
42+
BASE_DIR, "src", "opentelemetry", "instrumentation", "aiohttp_server", "version.py"
43+
)
44+
with open(VERSION_FILENAME, encoding="utf-8") as f:
45+
exec(f.read(), PACKAGE_INFO)
46+
47+
PACKAGE_FILENAME = os.path.join(
48+
BASE_DIR, "src", "opentelemetry", "instrumentation", "aiohttp_server", "package.py"
49+
)
50+
with open(PACKAGE_FILENAME, encoding="utf-8") as f:
51+
exec(f.read(), PACKAGE_INFO)
52+
53+
# Mark any instruments/runtime dependencies as test dependencies as well.
54+
extras_require["instruments"] = PACKAGE_INFO["_instruments"]
55+
test_deps = extras_require.get("test", [])
56+
for dep in extras_require["instruments"]:
57+
test_deps.append(dep)
58+
59+
extras_require["test"] = test_deps
60+
61+
62+
class JSONMetadataCommand(distutils.cmd.Command):
63+
64+
description = (
65+
"print out package metadata as JSON. This is used by OpenTelemetry dev scripts to ",
66+
"auto-generate code in other places",
67+
)
68+
user_options = []
69+
70+
def initialize_options(self):
71+
pass
72+
73+
def finalize_options(self):
74+
pass
75+
76+
def run(self):
77+
metadata = {
78+
"name": config["metadata"]["name"],
79+
"version": PACKAGE_INFO["__version__"],
80+
"instruments": PACKAGE_INFO["_instruments"],
81+
}
82+
print(json.dumps(metadata))
83+
84+
85+
setuptools.setup(
86+
cmdclass={"meta": JSONMetadataCommand},
87+
version=PACKAGE_INFO["__version__"],
88+
extras_require=extras_require,
89+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import urllib
2+
from aiohttp import web
3+
from guillotina.utils import get_dotted_name
4+
from multidict import CIMultiDictProxy
5+
from opentelemetry import context, trace
6+
from opentelemetry.instrumentation.aiohttp_server.package import _instruments
7+
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
8+
from opentelemetry.instrumentation.utils import http_status_to_status_code
9+
from opentelemetry.propagate import extract
10+
from opentelemetry.propagators.textmap import Getter
11+
from opentelemetry.semconv.trace import SpanAttributes
12+
from opentelemetry.trace.status import Status, StatusCode
13+
from opentelemetry.util.http import get_excluded_urls
14+
from opentelemetry.util.http import remove_url_credentials
15+
16+
from typing import Tuple
17+
18+
19+
_SUPPRESS_HTTP_INSTRUMENTATION_KEY = "suppress_http_instrumentation"
20+
21+
tracer = trace.get_tracer(__name__)
22+
_excluded_urls = get_excluded_urls("FLASK")
23+
24+
25+
def get_default_span_details(request: web.Request) -> Tuple[str, dict]:
26+
"""Default implementation for get_default_span_details
27+
Args:
28+
scope: the asgi scope dictionary
29+
Returns:
30+
a tuple of the span name, and any attributes to attach to the span.
31+
"""
32+
span_name = request.path.strip() or f"HTTP {request.method}"
33+
return span_name, {}
34+
35+
36+
def _get_view_func(request) -> str:
37+
"""TODO: is this only working for guillotina?"""
38+
try:
39+
return get_dotted_name(request.found_view)
40+
except AttributeError:
41+
return "unknown"
42+
43+
44+
def collect_request_attributes(request: web.Request):
45+
"""Collects HTTP request attributes from the ASGI scope and returns a
46+
dictionary to be used as span creation attributes."""
47+
48+
server_host, port, http_url = (
49+
request.url.host,
50+
request.url.port,
51+
str(request.url),
52+
)
53+
query_string = request.query_string
54+
if query_string and http_url:
55+
if isinstance(query_string, bytes):
56+
query_string = query_string.decode("utf8")
57+
http_url += "?" + urllib.parse.unquote(query_string)
58+
59+
result = {
60+
SpanAttributes.HTTP_SCHEME: request.scheme,
61+
SpanAttributes.HTTP_HOST: server_host,
62+
SpanAttributes.NET_HOST_PORT: port,
63+
SpanAttributes.HTTP_ROUTE: _get_view_func(request),
64+
SpanAttributes.HTTP_FLAVOR: f"{request.version.major}.{request.version.minor}",
65+
SpanAttributes.HTTP_TARGET: request.path,
66+
SpanAttributes.HTTP_URL: remove_url_credentials(http_url),
67+
}
68+
69+
http_method = request.method
70+
if http_method:
71+
result[SpanAttributes.HTTP_METHOD] = http_method
72+
73+
http_host_value_list = (
74+
[request.host] if type(request.host) != list else request.host
75+
)
76+
if http_host_value_list:
77+
result[SpanAttributes.HTTP_SERVER_NAME] = ",".join(
78+
http_host_value_list
79+
)
80+
http_user_agent = request.headers.get("user-agent")
81+
if http_user_agent:
82+
result[SpanAttributes.HTTP_USER_AGENT] = http_user_agent
83+
84+
# remove None values
85+
result = {k: v for k, v in result.items() if v is not None}
86+
87+
return result
88+
89+
90+
def set_status_code(span, status_code):
91+
"""Adds HTTP response attributes to span using the status_code argument."""
92+
if not span.is_recording():
93+
return
94+
try:
95+
status_code = int(status_code)
96+
except ValueError:
97+
span.set_status(
98+
Status(
99+
StatusCode.ERROR,
100+
"Non-integer HTTP status: " + repr(status_code),
101+
)
102+
)
103+
else:
104+
span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, status_code)
105+
span.set_status(
106+
Status(http_status_to_status_code(status_code, server_span=True))
107+
)
108+
109+
110+
class AiohttpGetter(Getter):
111+
"""Extract current trace from headers"""
112+
113+
def get(self, carrier, key: str):
114+
"""Getter implementation to retrieve a HTTP header value from the ASGI
115+
scope.
116+
117+
Args:
118+
carrier: ASGI scope object
119+
key: header name in scope
120+
Returns:
121+
A list with a single string with the header value if it exists,
122+
else None.
123+
"""
124+
headers: CIMultiDictProxy = carrier.headers
125+
if not headers:
126+
return None
127+
return headers.getall(key, None)
128+
129+
def keys(self, carrier: dict):
130+
return list(carrier.keys())
131+
132+
133+
getter = AiohttpGetter()
134+
135+
136+
@web.middleware
137+
async def middleware(request, handler):
138+
"""Middleware for aiohttp implementing tracing logic"""
139+
if (
140+
context.get_value("suppress_instrumentation")
141+
or context.get_value(_SUPPRESS_HTTP_INSTRUMENTATION_KEY)
142+
or not _excluded_urls.url_disabled(request.url)
143+
):
144+
return await handler(request)
145+
146+
token = context.attach(extract(request, getter=getter))
147+
span_name, additional_attributes = get_default_span_details(request)
148+
149+
with tracer.start_as_current_span(
150+
span_name,
151+
kind=trace.SpanKind.SERVER,
152+
) as span:
153+
if span.is_recording():
154+
attributes = collect_request_attributes(request)
155+
attributes.update(additional_attributes)
156+
for key, value in attributes.items():
157+
span.set_attribute(key, value)
158+
try:
159+
resp = await handler(request)
160+
set_status_code(span, resp.status)
161+
finally:
162+
context.detach(token)
163+
return resp
164+
165+
166+
class _InstrumentedApplication(web.Application):
167+
"""Insert tracing middleware"""
168+
169+
def __init__(self, *args, **kwargs):
170+
middlewares = kwargs.pop("middlewares", [])
171+
middlewares.insert(0, middleware)
172+
kwargs["middlewares"] = middlewares
173+
super().__init__(*args, **kwargs)
174+
175+
176+
class AioHttpInstrumentor(BaseInstrumentor):
177+
# pylint: disable=protected-access,attribute-defined-outside-init
178+
"""An instrumentor for aiohttp.web.Application
179+
180+
See `BaseInstrumentor`
181+
"""
182+
183+
def _instrument(self, **kwargs):
184+
self._original_app = web.Application
185+
setattr(web, "Application", _InstrumentedApplication)
186+
187+
def _uninstrument(self, **kwargs):
188+
setattr(web, "Application", self._original_app)
189+
190+
def instrumentation_dependencies(self):
191+
return _instruments
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
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+
_instruments = ("aiohttp ~= 3.8",)
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.28b1"

instrumentation/opentelemetry-instrumentation-aiohttp-server/tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)