Add Django ASGI support (#391)

This commit is contained in:
Michael Manganiello
2021-10-12 14:28:03 -03:00
committed by GitHub
parent 36275f3cbf
commit 5105820fff
8 changed files with 515 additions and 28 deletions

View File

@ -30,6 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
([#706](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/706))
- `opentelemetry-instrumentation-requests` added exclude urls functionality
([#714](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/714))
- `opentelemetry-instrumentation-django` Add ASGI support
([#391](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/391))
### Changed
- `opentelemetry-instrumentation-botocore` Make common span attributes compliant with semantic conventions
@ -64,12 +66,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- `opentelemetry-sdk-extension-aws` Add AWS resource detectors to extension package
([#586](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/586))
- `opentelemetry-instrumentation-asgi`, `opentelemetry-instrumentation-aiohttp-client`, `openetelemetry-instrumentation-fastapi`,
`opentelemetry-instrumentation-starlette`, `opentelemetry-instrumentation-urllib`, `opentelemetry-instrumentation-urllib3` Added `request_hook` and `response_hook` callbacks
- `opentelemetry-instrumentation-asgi`, `opentelemetry-instrumentation-aiohttp-client`, `openetelemetry-instrumentation-fastapi`,
`opentelemetry-instrumentation-starlette`, `opentelemetry-instrumentation-urllib`, `opentelemetry-instrumentation-urllib3` Added `request_hook` and `response_hook` callbacks
([#576](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/576))
- `opentelemetry-instrumentation-pika` added RabbitMQ's pika module instrumentation.
([#680](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/680))
### Changed
- `opentelemetry-instrumentation-fastapi` Allow instrumentation of newer FastAPI versions.

View File

@ -121,7 +121,7 @@ def get_host_port_url_tuple(scope):
"""Returns (host, port, full_url) tuple."""
server = scope.get("server") or ["0.0.0.0", 80]
port = server[1]
server_host = server[0] + (":" + str(port) if port != 80 else "")
server_host = server[0] + (":" + str(port) if str(port) != "80" else "")
full_path = scope.get("root_path", "") + scope.get("path", "")
http_url = scope.get("scheme", "http") + "://" + server_host + full_path
return server_host, port, http_url

View File

@ -45,6 +45,8 @@ install_requires =
opentelemetry-semantic-conventions == 0.24b0
[options.extras_require]
asgi =
opentelemetry-instrumentation-asgi == 0.24b0
test =
opentelemetry-test == 0.24b0

View File

@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import types
from logging import getLogger
from time import time
from typing import Callable
@ -24,11 +25,11 @@ from opentelemetry.instrumentation.propagators import (
get_global_response_propagator,
)
from opentelemetry.instrumentation.utils import extract_attributes_from_object
from opentelemetry.instrumentation.wsgi import add_response_attributes
from opentelemetry.instrumentation.wsgi import (
add_response_attributes,
collect_request_attributes,
wsgi_getter,
collect_request_attributes as wsgi_collect_request_attributes,
)
from opentelemetry.instrumentation.wsgi import wsgi_getter
from opentelemetry.propagate import extract
from opentelemetry.semconv.trace import SpanAttributes
from opentelemetry.trace import Span, SpanKind, use_span
@ -43,6 +44,7 @@ except ImportError:
from django.urls import Resolver404, resolve
DJANGO_2_0 = django_version >= (2, 0)
DJANGO_3_0 = django_version >= (3, 0)
if DJANGO_2_0:
# Since Django 2.0, only `settings.MIDDLEWARE` is supported, so new-style
@ -67,6 +69,26 @@ else:
except ImportError:
MiddlewareMixin = object
if DJANGO_3_0:
from django.core.handlers.asgi import ASGIRequest
else:
ASGIRequest = None
# try/except block exclusive for optional ASGI imports.
try:
from opentelemetry.instrumentation.asgi import asgi_getter
from opentelemetry.instrumentation.asgi import (
collect_request_attributes as asgi_collect_request_attributes,
)
from opentelemetry.instrumentation.asgi import set_status_code
_is_asgi_supported = True
except ImportError:
asgi_getter = None
asgi_collect_request_attributes = None
set_status_code = None
_is_asgi_supported = False
_logger = getLogger(__name__)
_attributes_by_preference = [
@ -91,6 +113,10 @@ _attributes_by_preference = [
]
def _is_asgi_request(request: HttpRequest) -> bool:
return ASGIRequest is not None and isinstance(request, ASGIRequest)
class _DjangoMiddleware(MiddlewareMixin):
"""Django Middleware for OpenTelemetry"""
@ -140,12 +166,25 @@ class _DjangoMiddleware(MiddlewareMixin):
if self._excluded_urls.url_disabled(request.build_absolute_uri("?")):
return
is_asgi_request = _is_asgi_request(request)
if not _is_asgi_supported and is_asgi_request:
return
# pylint:disable=W0212
request._otel_start_time = time()
request_meta = request.META
token = attach(extract(request_meta, getter=wsgi_getter))
if is_asgi_request:
carrier = request.scope
carrier_getter = asgi_getter
collect_request_attributes = asgi_collect_request_attributes
else:
carrier = request_meta
carrier_getter = wsgi_getter
collect_request_attributes = wsgi_collect_request_attributes
token = attach(extract(request_meta, getter=carrier_getter))
span = self._tracer.start_span(
self._get_span_name(request),
@ -155,12 +194,25 @@ class _DjangoMiddleware(MiddlewareMixin):
),
)
attributes = collect_request_attributes(request_meta)
attributes = collect_request_attributes(carrier)
if span.is_recording():
attributes = extract_attributes_from_object(
request, self._traced_request_attrs, attributes
)
if is_asgi_request:
# ASGI requests include extra attributes in request.scope.headers.
attributes = extract_attributes_from_object(
types.SimpleNamespace(
**{
name.decode("latin1"): value.decode("latin1")
for name, value in request.scope.get("headers", [])
}
),
self._traced_request_attrs,
attributes,
)
for key, value in attributes.items():
span.set_attribute(key, value)
@ -207,15 +259,22 @@ class _DjangoMiddleware(MiddlewareMixin):
if self._excluded_urls.url_disabled(request.build_absolute_uri("?")):
return response
is_asgi_request = _is_asgi_request(request)
if not _is_asgi_supported and is_asgi_request:
return response
activation = request.META.pop(self._environ_activation_key, None)
span = request.META.pop(self._environ_span_key, None)
if activation and span:
add_response_attributes(
span,
f"{response.status_code} {response.reason_phrase}",
response,
)
if is_asgi_request:
set_status_code(span, response.status_code)
else:
add_response_attributes(
span,
f"{response.status_code} {response.reason_phrase}",
response,
)
propagator = get_global_response_propagator()
if propagator:
@ -238,7 +297,7 @@ class _DjangoMiddleware(MiddlewareMixin):
activation.__exit__(None, None, None)
if self._environ_token in request.META.keys():
detach(request.environ.get(self._environ_token))
detach(request.META.get(self._environ_token))
request.META.pop(self._environ_token)
return response

View File

@ -15,12 +15,11 @@
from sys import modules
from unittest.mock import Mock, patch
from django import VERSION
from django.conf import settings
from django.conf.urls import url
from django import VERSION, conf
from django.http import HttpRequest, HttpResponse
from django.test import Client
from django.test.client import Client
from django.test.utils import setup_test_environment, teardown_test_environment
from django.urls import re_path
from opentelemetry.instrumentation.django import (
DjangoInstrumentor,
@ -57,13 +56,13 @@ from .views import (
DJANGO_2_2 = VERSION >= (2, 2)
urlpatterns = [
url(r"^traced/", traced),
url(r"^route/(?P<year>[0-9]{4})/template/$", traced_template),
url(r"^error/", error),
url(r"^excluded_arg/", excluded),
url(r"^excluded_noarg/", excluded_noarg),
url(r"^excluded_noarg2/", excluded_noarg2),
url(r"^span_name/([0-9]{4})/$", route_span_name),
re_path(r"^traced/", traced),
re_path(r"^route/(?P<year>[0-9]{4})/template/$", traced_template),
re_path(r"^error/", error),
re_path(r"^excluded_arg/", excluded),
re_path(r"^excluded_noarg/", excluded_noarg),
re_path(r"^excluded_noarg2/", excluded_noarg2),
re_path(r"^span_name/([0-9]{4})/$", route_span_name),
]
_django_instrumentor = DjangoInstrumentor()
@ -71,8 +70,8 @@ _django_instrumentor = DjangoInstrumentor()
class TestMiddleware(TestBase, WsgiTestBase):
@classmethod
def setUpClass(cls):
conf.settings.configure(ROOT_URLCONF=modules[__name__])
super().setUpClass()
settings.configure(ROOT_URLCONF=modules[__name__])
def setUp(self):
super().setUp()
@ -105,6 +104,11 @@ class TestMiddleware(TestBase, WsgiTestBase):
teardown_test_environment()
_django_instrumentor.uninstrument()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
conf.settings = conf.LazySettings()
def test_templated_route_get(self):
Client().get("/route/2020/template/")
@ -357,6 +361,7 @@ class TestMiddleware(TestBase, WsgiTestBase):
class TestMiddlewareWithTracerProvider(TestBase, WsgiTestBase):
@classmethod
def setUpClass(cls):
conf.settings.configure(ROOT_URLCONF=modules[__name__])
super().setUpClass()
def setUp(self):
@ -375,6 +380,11 @@ class TestMiddlewareWithTracerProvider(TestBase, WsgiTestBase):
teardown_test_environment()
_django_instrumentor.uninstrument()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
conf.settings = conf.LazySettings()
def test_tracer_provider_traced(self):
Client().post("/traced/")

View File

@ -0,0 +1,382 @@
# 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.
from sys import modules
from unittest.mock import Mock, patch
import pytest
from django import VERSION, conf
from django.http import HttpRequest, HttpResponse
from django.test import SimpleTestCase
from django.test.utils import setup_test_environment, teardown_test_environment
from django.urls import re_path
from opentelemetry.instrumentation.django import (
DjangoInstrumentor,
_DjangoMiddleware,
)
from opentelemetry.instrumentation.propagators import (
TraceResponsePropagator,
set_global_response_propagator,
)
from opentelemetry.sdk import resources
from opentelemetry.sdk.trace import Span
from opentelemetry.semconv.trace import SpanAttributes
from opentelemetry.test.test_base import TestBase
from opentelemetry.trace import (
SpanKind,
StatusCode,
format_span_id,
format_trace_id,
)
from opentelemetry.util.http import get_excluded_urls, get_traced_request_attrs
# pylint: disable=import-error
from .views import (
async_error,
async_excluded,
async_excluded_noarg,
async_excluded_noarg2,
async_route_span_name,
async_traced,
async_traced_template,
)
DJANGO_3_1 = VERSION >= (3, 1)
urlpatterns = [
re_path(r"^traced/", async_traced),
re_path(r"^route/(?P<year>[0-9]{4})/template/$", async_traced_template),
re_path(r"^error/", async_error),
re_path(r"^excluded_arg/", async_excluded),
re_path(r"^excluded_noarg/", async_excluded_noarg),
re_path(r"^excluded_noarg2/", async_excluded_noarg2),
re_path(r"^span_name/([0-9]{4})/$", async_route_span_name),
]
_django_instrumentor = DjangoInstrumentor()
@pytest.mark.skipif(
not DJANGO_3_1, reason="AsyncClient implemented since Django 3.1"
)
class TestMiddlewareAsgi(SimpleTestCase, TestBase):
@classmethod
def setUpClass(cls):
conf.settings.configure(ROOT_URLCONF=modules[__name__])
super().setUpClass()
def setUp(self):
super().setUp()
setup_test_environment()
_django_instrumentor.instrument()
self.env_patch = patch.dict(
"os.environ",
{
"OTEL_PYTHON_DJANGO_EXCLUDED_URLS": "http://testserver/excluded_arg/123,excluded_noarg",
"OTEL_PYTHON_DJANGO_TRACED_REQUEST_ATTRS": "path_info,content_type,non_existing_variable",
},
)
self.env_patch.start()
self.exclude_patch = patch(
"opentelemetry.instrumentation.django.middleware._DjangoMiddleware._excluded_urls",
get_excluded_urls("DJANGO"),
)
self.traced_patch = patch(
"opentelemetry.instrumentation.django.middleware._DjangoMiddleware._traced_request_attrs",
get_traced_request_attrs("DJANGO"),
)
self.exclude_patch.start()
self.traced_patch.start()
def tearDown(self):
super().tearDown()
self.env_patch.stop()
self.exclude_patch.stop()
self.traced_patch.stop()
teardown_test_environment()
_django_instrumentor.uninstrument()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
conf.settings = conf.LazySettings()
async def test_templated_route_get(self):
await self.async_client.get("/route/2020/template/")
spans = self.memory_exporter.get_finished_spans()
self.assertEqual(len(spans), 1)
span = spans[0]
self.assertEqual(span.name, "^route/(?P<year>[0-9]{4})/template/$")
self.assertEqual(span.kind, SpanKind.SERVER)
self.assertEqual(span.status.status_code, StatusCode.UNSET)
self.assertEqual(span.attributes[SpanAttributes.HTTP_METHOD], "GET")
self.assertEqual(
span.attributes[SpanAttributes.HTTP_URL],
"http://127.0.0.1/route/2020/template/",
)
self.assertEqual(
span.attributes[SpanAttributes.HTTP_ROUTE],
"^route/(?P<year>[0-9]{4})/template/$",
)
self.assertEqual(span.attributes[SpanAttributes.HTTP_SCHEME], "http")
self.assertEqual(span.attributes[SpanAttributes.HTTP_STATUS_CODE], 200)
async def test_traced_get(self):
await self.async_client.get("/traced/")
spans = self.memory_exporter.get_finished_spans()
self.assertEqual(len(spans), 1)
span = spans[0]
self.assertEqual(span.name, "^traced/")
self.assertEqual(span.kind, SpanKind.SERVER)
self.assertEqual(span.status.status_code, StatusCode.UNSET)
self.assertEqual(span.attributes[SpanAttributes.HTTP_METHOD], "GET")
self.assertEqual(
span.attributes[SpanAttributes.HTTP_URL],
"http://127.0.0.1/traced/",
)
self.assertEqual(
span.attributes[SpanAttributes.HTTP_ROUTE], "^traced/"
)
self.assertEqual(span.attributes[SpanAttributes.HTTP_SCHEME], "http")
self.assertEqual(span.attributes[SpanAttributes.HTTP_STATUS_CODE], 200)
async def test_not_recording(self):
mock_tracer = Mock()
mock_span = Mock()
mock_span.is_recording.return_value = False
mock_tracer.start_span.return_value = mock_span
with patch("opentelemetry.trace.get_tracer") as tracer:
tracer.return_value = mock_tracer
await self.async_client.get("/traced/")
self.assertFalse(mock_span.is_recording())
self.assertTrue(mock_span.is_recording.called)
self.assertFalse(mock_span.set_attribute.called)
self.assertFalse(mock_span.set_status.called)
async def test_traced_post(self):
await self.async_client.post("/traced/")
spans = self.memory_exporter.get_finished_spans()
self.assertEqual(len(spans), 1)
span = spans[0]
self.assertEqual(span.name, "^traced/")
self.assertEqual(span.kind, SpanKind.SERVER)
self.assertEqual(span.status.status_code, StatusCode.UNSET)
self.assertEqual(span.attributes[SpanAttributes.HTTP_METHOD], "POST")
self.assertEqual(
span.attributes[SpanAttributes.HTTP_URL],
"http://127.0.0.1/traced/",
)
self.assertEqual(
span.attributes[SpanAttributes.HTTP_ROUTE], "^traced/"
)
self.assertEqual(span.attributes[SpanAttributes.HTTP_SCHEME], "http")
self.assertEqual(span.attributes[SpanAttributes.HTTP_STATUS_CODE], 200)
async def test_error(self):
with self.assertRaises(ValueError):
await self.async_client.get("/error/")
spans = self.memory_exporter.get_finished_spans()
self.assertEqual(len(spans), 1)
span = spans[0]
self.assertEqual(span.name, "^error/")
self.assertEqual(span.kind, SpanKind.SERVER)
self.assertEqual(span.status.status_code, StatusCode.ERROR)
self.assertEqual(span.attributes[SpanAttributes.HTTP_METHOD], "GET")
self.assertEqual(
span.attributes[SpanAttributes.HTTP_URL],
"http://127.0.0.1/error/",
)
self.assertEqual(span.attributes[SpanAttributes.HTTP_ROUTE], "^error/")
self.assertEqual(span.attributes[SpanAttributes.HTTP_SCHEME], "http")
self.assertEqual(span.attributes[SpanAttributes.HTTP_STATUS_CODE], 500)
self.assertEqual(len(span.events), 1)
event = span.events[0]
self.assertEqual(event.name, "exception")
self.assertEqual(
event.attributes[SpanAttributes.EXCEPTION_TYPE], "ValueError"
)
self.assertEqual(
event.attributes[SpanAttributes.EXCEPTION_MESSAGE], "error"
)
async def test_exclude_lists(self):
await self.async_client.get("/excluded_arg/123")
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 0)
await self.async_client.get("/excluded_arg/125")
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 1)
await self.async_client.get("/excluded_noarg/")
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 1)
await self.async_client.get("/excluded_noarg2/")
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 1)
async def test_span_name(self):
# test no query_string
await self.async_client.get("/span_name/1234/")
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 1)
span = span_list[0]
self.assertEqual(span.name, "^span_name/([0-9]{4})/$")
async def test_span_name_for_query_string(self):
"""
request not have query string
"""
await self.async_client.get("/span_name/1234/?query=test")
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 1)
span = span_list[0]
self.assertEqual(span.name, "^span_name/([0-9]{4})/$")
async def test_span_name_404(self):
await self.async_client.get("/span_name/1234567890/")
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 1)
span = span_list[0]
self.assertEqual(span.name, "HTTP GET")
async def test_traced_request_attrs(self):
await self.async_client.get("/span_name/1234/", CONTENT_TYPE="test/ct")
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 1)
span = span_list[0]
self.assertEqual(span.attributes["path_info"], "/span_name/1234/")
self.assertEqual(span.attributes["content_type"], "test/ct")
self.assertNotIn("non_existing_variable", span.attributes)
async def test_hooks(self):
request_hook_args = ()
response_hook_args = ()
def request_hook(span, request):
nonlocal request_hook_args
request_hook_args = (span, request)
def response_hook(span, request, response):
nonlocal response_hook_args
response_hook_args = (span, request, response)
response["hook-header"] = "set by hook"
_DjangoMiddleware._otel_request_hook = request_hook
_DjangoMiddleware._otel_response_hook = response_hook
response = await self.async_client.get("/span_name/1234/")
_DjangoMiddleware._otel_request_hook = (
_DjangoMiddleware._otel_response_hook
) = None
self.assertEqual(response["hook-header"], "set by hook")
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 1)
span = span_list[0]
self.assertEqual(span.attributes["path_info"], "/span_name/1234/")
self.assertEqual(len(request_hook_args), 2)
self.assertEqual(request_hook_args[0].name, span.name)
self.assertIsInstance(request_hook_args[0], Span)
self.assertIsInstance(request_hook_args[1], HttpRequest)
self.assertEqual(len(response_hook_args), 3)
self.assertEqual(request_hook_args[0], response_hook_args[0])
self.assertIsInstance(response_hook_args[1], HttpRequest)
self.assertIsInstance(response_hook_args[2], HttpResponse)
self.assertEqual(response_hook_args[2], response)
async def test_trace_response_headers(self):
response = await self.async_client.get("/span_name/1234/")
self.assertNotIn("Server-Timing", response.headers)
self.memory_exporter.clear()
set_global_response_propagator(TraceResponsePropagator())
response = await self.async_client.get("/span_name/1234/")
span = self.memory_exporter.get_finished_spans()[0]
self.assertIn("traceresponse", response.headers)
self.assertEqual(
response.headers["Access-Control-Expose-Headers"], "traceresponse",
)
self.assertEqual(
response.headers["traceresponse"],
"00-{0}-{1}-01".format(
format_trace_id(span.get_span_context().trace_id),
format_span_id(span.get_span_context().span_id),
),
)
self.memory_exporter.clear()
class TestMiddlewareAsgiWithTracerProvider(SimpleTestCase, TestBase):
@classmethod
def setUpClass(cls):
conf.settings.configure(ROOT_URLCONF=modules[__name__])
super().setUpClass()
def setUp(self):
super().setUp()
setup_test_environment()
resource = resources.Resource.create(
{"resource-key": "resource-value"}
)
result = self.create_tracer_provider(resource=resource)
tracer_provider, exporter = result
self.exporter = exporter
_django_instrumentor.instrument(tracer_provider=tracer_provider)
def tearDown(self):
super().tearDown()
teardown_test_environment()
_django_instrumentor.uninstrument()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
conf.settings = conf.LazySettings()
async def test_tracer_provider_traced(self):
await self.async_client.post("/traced/")
spans = self.exporter.get_finished_spans()
self.assertEqual(len(spans), 1)
span = spans[0]
self.assertEqual(
span.resource.attributes["resource-key"], "resource-value"
)

View File

@ -29,3 +29,35 @@ def route_span_name(
request, *args, **kwargs
): # pylint: disable=unused-argument
return HttpResponse()
async def async_traced(request): # pylint: disable=unused-argument
return HttpResponse()
async def async_traced_template(
request, year
): # pylint: disable=unused-argument
return HttpResponse()
async def async_error(request): # pylint: disable=unused-argument
raise ValueError("error")
async def async_excluded(request): # pylint: disable=unused-argument
return HttpResponse()
async def async_excluded_noarg(request): # pylint: disable=unused-argument
return HttpResponse()
async def async_excluded_noarg2(request): # pylint: disable=unused-argument
return HttpResponse()
async def async_route_span_name(
request, *args, **kwargs
): # pylint: disable=unused-argument
return HttpResponse()

View File

@ -258,7 +258,7 @@ commands_pre =
falcon{2,3},flask,django,pyramid,tornado,starlette,fastapi,aiohttp,asgi,requests,urllib,urllib3,wsgi: pip install {toxinidir}/util/opentelemetry-util-http[test]
wsgi,falcon{2,3},flask,django,pyramid: pip install {toxinidir}/instrumentation/opentelemetry-instrumentation-wsgi[test]
asgi,starlette,fastapi: pip install {toxinidir}/instrumentation/opentelemetry-instrumentation-asgi[test]
asgi,django,starlette,fastapi: pip install {toxinidir}/instrumentation/opentelemetry-instrumentation-asgi[test]
asyncpg: pip install {toxinidir}/instrumentation/opentelemetry-instrumentation-asyncpg[test]