diff --git a/.flake8 b/.flake8 index 5922f31d8f..f511c4c3e0 100644 --- a/.flake8 +++ b/.flake8 @@ -2,7 +2,8 @@ ignore = E501 # line too long, defer to black F401 # unused import, defer to pylint - W503 # allow line breaks after binary ops, not after + W503 # allow line breaks before binary ops + W504 # allow line breaks after binary ops E203 # allow whitespace before ':' (https://github.com/psf/black#slices) exclude = .bzr diff --git a/.gitignore b/.gitignore index 42e6d0bf04..75cdf09293 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,7 @@ _build/ # mypy .mypy_cache/ target + +# Django example + +docs/examples/django/db.sqlite3 diff --git a/docs/examples/django/README.rst b/docs/examples/django/README.rst new file mode 100644 index 0000000000..2b2f158e7a --- /dev/null +++ b/docs/examples/django/README.rst @@ -0,0 +1,108 @@ +OpenTelemetry Django Instrumentation Example +============================================ + +This shows how to use `opentelemetry-ext-django` to automatically instrument a +Django app. + +For more user convenience, a Django app is already provided in this directory. + +Preparation +----------- + +This example will be executed in a separate virtual environment: + +.. code-block:: + + $ mkdir django_auto_instrumentation + $ virtualenv django_auto_instrumentation + $ source django_auto_instrumentation/bin/activate + + +Installation +------------ + +.. code-block:: + + $ pip install opentelemetry-sdk + $ pip install opentelemetry-ext-django + $ pip install requests + + +Execution +--------- + +Execution of the Django app +........................... + +Set these environment variables first: + +#. `export OPENTELEMETRY_PYTHON_DJANGO_INSTRUMENT=True` +#. `export DJANGO_SETTINGS_MODULE=instrumentation_example.settings` + +The way to achieve OpenTelemetry instrumentation for your Django app is to use +an `opentelemetry.ext.django.DjangoInstrumentor` to instrument the app. + +Clone the `opentelemetry-python` repository and go to `opentelemetry-python/docs/examples/django`. + +Once there, open the `manage.py` file. The call to `DjangoInstrumentor().instrument()` +in `main` is all that is needed to make the app be instrumented. + +Run the Django app with `python manage.py runserver`. + +Execution of the client +....................... + +Open up a new console and activate the previous virtual environment there too: + +`source django_auto_instrumentation/bin/activate` + +Go to `opentelemetry-python/ext/opentelemetry-ext-django/example`, once there +run the client with: + +`python client.py hello` + +Go to the previous console, where the Django app is running. You should see +output similar to this one: + +.. code-block:: + + { + "name": "home_page_view", + "context": { + "trace_id": "0xed88755c56d95d05a506f5f70e7849b9", + "span_id": "0x0a94c7a60e0650d5", + "trace_state": "{}" + }, + "kind": "SpanKind.SERVER", + "parent_id": "0x3096ef92e621c22d", + "start_time": "2020-04-26T01:49:57.205833Z", + "end_time": "2020-04-26T01:49:57.206214Z", + "status": { + "canonical_code": "OK" + }, + "attributes": { + "component": "http", + "http.method": "GET", + "http.server_name": "localhost", + "http.scheme": "http", + "host.port": 8000, + "http.host": "localhost:8000", + "http.url": "http://localhost:8000/?param=hello", + "net.peer.ip": "127.0.0.1", + "http.flavor": "1.1", + "http.status_text": "OK", + "http.status_code": 200 + }, + "events": [], + "links": [] + } + +The last output shows spans automatically generated by the OpenTelemetry Django +Instrumentation package. + +References +---------- + +* `Django `_ +* `OpenTelemetry Project `_ +* `OpenTelemetry Django extension `_ diff --git a/docs/examples/django/client.py b/docs/examples/django/client.py new file mode 100644 index 0000000000..e65285c35d --- /dev/null +++ b/docs/examples/django/client.py @@ -0,0 +1,45 @@ +# 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 argv + +from requests import get + +from opentelemetry import propagators, trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import ( + ConsoleSpanExporter, + SimpleExportSpanProcessor, +) + +trace.set_tracer_provider(TracerProvider()) +tracer = trace.get_tracer_provider().get_tracer(__name__) + +trace.get_tracer_provider().add_span_processor( + SimpleExportSpanProcessor(ConsoleSpanExporter()) +) + + +with tracer.start_as_current_span("client"): + + with tracer.start_as_current_span("client-server"): + headers = {} + propagators.inject(dict.__setitem__, headers) + requested = get( + "http://localhost:8000", + params={"param": argv[1]}, + headers=headers, + ) + + assert requested.status_code == 200 diff --git a/docs/examples/django/instrumentation_example/__init__.py b/docs/examples/django/instrumentation_example/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/examples/django/instrumentation_example/asgi.py b/docs/examples/django/instrumentation_example/asgi.py new file mode 100644 index 0000000000..dd8fb568f4 --- /dev/null +++ b/docs/examples/django/instrumentation_example/asgi.py @@ -0,0 +1,31 @@ +# 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. +""" +ASGI config for instrumentation_example project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault( + "DJANGO_SETTINGS_MODULE", "instrumentation_example.settings" +) + +application = get_asgi_application() diff --git a/docs/examples/django/instrumentation_example/settings.py b/docs/examples/django/instrumentation_example/settings.py new file mode 100644 index 0000000000..b5b8897b91 --- /dev/null +++ b/docs/examples/django/instrumentation_example/settings.py @@ -0,0 +1,133 @@ +# 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. +""" +Django settings for instrumentation_example project. + +Generated by "django-admin startproject" using Django 3.0.4. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.0/ref/settings/ +""" + +import os + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "it%*!=l2(fcawu=!m-06n&#j(iq2j#%$fu6)myi*b9i5ojk+6+" + +# SECURITY WARNING: don"t run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "instrumentation_example.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "instrumentation_example.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/3.0/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, "db.sqlite3"), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/3.0/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.0/howto/static-files/ + +STATIC_URL = "/static/" diff --git a/docs/examples/django/instrumentation_example/urls.py b/docs/examples/django/instrumentation_example/urls.py new file mode 100644 index 0000000000..292467155f --- /dev/null +++ b/docs/examples/django/instrumentation_example/urls.py @@ -0,0 +1,35 @@ +# 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. +"""instrumentation_example URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/3.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path("", views.home, name="home") +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path("", Home.as_view(), name="home") +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path("blog/", include("blog.urls")) +""" +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + path("", include("pages.urls")), +] diff --git a/docs/examples/django/instrumentation_example/wsgi.py b/docs/examples/django/instrumentation_example/wsgi.py new file mode 100644 index 0000000000..70ea9e0db5 --- /dev/null +++ b/docs/examples/django/instrumentation_example/wsgi.py @@ -0,0 +1,31 @@ +# 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. +""" +WSGI config for instrumentation_example project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault( + "DJANGO_SETTINGS_MODULE", "instrumentation_example.settings" +) + +application = get_wsgi_application() diff --git a/docs/examples/django/manage.py b/docs/examples/django/manage.py new file mode 100755 index 0000000000..fdf32287c5 --- /dev/null +++ b/docs/examples/django/manage.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +# 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. + +"""Django"s command-line utility for administrative tasks.""" +import os +import sys + +from opentelemetry.ext.django import DjangoInstrumentor + + +def main(): + + # This call is what makes the Django application be instrumented + DjangoInstrumentor().instrument() + + os.environ.setdefault( + "DJANGO_SETTINGS_MODULE", "instrumentation_example.settings" + ) + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/docs/examples/django/pages/__init__.py b/docs/examples/django/pages/__init__.py new file mode 100644 index 0000000000..5855e41f3a --- /dev/null +++ b/docs/examples/django/pages/__init__.py @@ -0,0 +1,15 @@ +# 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. + +default_app_config = "pages.apps.PagesConfig" diff --git a/docs/examples/django/pages/apps.py b/docs/examples/django/pages/apps.py new file mode 100644 index 0000000000..0f12b7b66c --- /dev/null +++ b/docs/examples/django/pages/apps.py @@ -0,0 +1,18 @@ +# 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 django.apps import AppConfig + + +class PagesConfig(AppConfig): + name = "pages" diff --git a/docs/examples/django/pages/migrations/__init__.py b/docs/examples/django/pages/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/examples/django/pages/urls.py b/docs/examples/django/pages/urls.py new file mode 100644 index 0000000000..99c95765a4 --- /dev/null +++ b/docs/examples/django/pages/urls.py @@ -0,0 +1,18 @@ +# 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 django.urls import path + +from .views import home_page_view + +urlpatterns = [path("", home_page_view, name="home")] diff --git a/docs/examples/django/pages/views.py b/docs/examples/django/pages/views.py new file mode 100644 index 0000000000..d54633c329 --- /dev/null +++ b/docs/examples/django/pages/views.py @@ -0,0 +1,32 @@ +# 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 django.http import HttpResponse + +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import ( + ConsoleSpanExporter, + SimpleExportSpanProcessor, +) + +trace.set_tracer_provider(TracerProvider()) +tracer = trace.get_tracer_provider().get_tracer(__name__) + +trace.get_tracer_provider().add_span_processor( + SimpleExportSpanProcessor(ConsoleSpanExporter()) +) + + +def home_page_view(request): + return HttpResponse("Hello, world") diff --git a/docs/ext/django/django.rst b/docs/ext/django/django.rst new file mode 100644 index 0000000000..1a2c844e28 --- /dev/null +++ b/docs/ext/django/django.rst @@ -0,0 +1,7 @@ +OpenTelemetry Django Instrumentation +==================================== + +.. automodule:: opentelemetry.ext.django + :members: + :undoc-members: + :show-inheritance: diff --git a/ext/opentelemetry-ext-django/CHANGELOG.md b/ext/opentelemetry-ext-django/CHANGELOG.md new file mode 100644 index 0000000000..3e04402cea --- /dev/null +++ b/ext/opentelemetry-ext-django/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## Unreleased + +- Initial release diff --git a/ext/opentelemetry-ext-django/README.rst b/ext/opentelemetry-ext-django/README.rst new file mode 100644 index 0000000000..b922046ca6 --- /dev/null +++ b/ext/opentelemetry-ext-django/README.rst @@ -0,0 +1,24 @@ +OpenTelemetry Django Tracing +============================ + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-ext-django.svg + :target: https://pypi.org/project/opentelemetry-ext-django/ + +This library allows tracing requests for Django applications. + +Installation +------------ + +:: + + pip install opentelemetry-ext-django + + +References +---------- + +* `Django `_ +* `OpenTelemetry Django Tracing `_ +* `OpenTelemetry Project `_ diff --git a/ext/opentelemetry-ext-django/setup.cfg b/ext/opentelemetry-ext-django/setup.cfg new file mode 100644 index 0000000000..c308a6f352 --- /dev/null +++ b/ext/opentelemetry-ext-django/setup.cfg @@ -0,0 +1,55 @@ +# 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. +# +[metadata] +name = opentelemetry-ext-django +description = OpenTelemetry Instrumentation for Django +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/tree/master/ext/opentelemetry-ext-django +platforms = any +license = Apache-2.0 +classifiers = + Development Status :: 3 - Beta + Intended Audience :: Developers + License :: OSI Approved :: Apache Software License + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + +[options] +python_requires = >=3.6 +package_dir= + =src +packages=find_namespace: +install_requires = + django >= 2.2 + opentelemetry-ext-wsgi == 0.7.dev0 + opentelemetry-auto-instrumentation == 0.7.dev0 + opentelemetry-api == 0.7.dev0 + +[options.extras_require] +test = + opentelemetry-test == 0.7.dev0 + +[options.packages.find] +where = src + +[options.entry_points] +opentelemetry_instrumentor = + django = opentelemetry.ext.django:DjangoInstrumentor diff --git a/ext/opentelemetry-ext-django/setup.py b/ext/opentelemetry-ext-django/setup.py new file mode 100644 index 0000000000..45cc68c0f4 --- /dev/null +++ b/ext/opentelemetry-ext-django/setup.py @@ -0,0 +1,32 @@ +# 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 os.path import dirname, join + +from setuptools import setup + +PACKAGE_INFO = {} +with open( + join( + dirname(__file__), + "src", + "opentelemetry", + "ext", + "django", + "version.py", + ) +) as f: + exec(f.read(), PACKAGE_INFO) + +setup(version=PACKAGE_INFO["__version__"]) diff --git a/ext/opentelemetry-ext-django/src/opentelemetry/ext/django/__init__.py b/ext/opentelemetry-ext-django/src/opentelemetry/ext/django/__init__.py new file mode 100644 index 0000000000..f59d90b490 --- /dev/null +++ b/ext/opentelemetry-ext-django/src/opentelemetry/ext/django/__init__.py @@ -0,0 +1,72 @@ +# 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 logging import getLogger + +from django.conf import settings + +from opentelemetry.auto_instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.configuration import Configuration +from opentelemetry.ext.django.middleware import _DjangoMiddleware + +_logger = getLogger(__name__) + + +class DjangoInstrumentor(BaseInstrumentor): + """An instrumentor for Django + + See `BaseInstrumentor` + """ + + _opentelemetry_middleware = ".".join( + [_DjangoMiddleware.__module__, _DjangoMiddleware.__qualname__] + ) + + def _instrument(self, **kwargs): + + # FIXME this is probably a pattern that will show up in the rest of the + # ext. Find a better way of implementing this. + # FIXME Probably the evaluation of strings into boolean values can be + # built inside the Configuration class itself with the magic method + # __bool__ + + if Configuration().DJANGO_INSTRUMENT != "True": + return + + # This can not be solved, but is an inherent problem of this approach: + # the order of middleware entries matters, and here you have no control + # on that: + # https://docs.djangoproject.com/en/3.0/topics/http/middleware/#activating-middleware + # https://docs.djangoproject.com/en/3.0/ref/middleware/#middleware-ordering + + settings_middleware = getattr(settings, "MIDDLEWARE", []) + settings_middleware.append(self._opentelemetry_middleware) + + setattr(settings, "MIDDLEWARE", settings_middleware) + + def _uninstrument(self, **kwargs): + settings_middleware = getattr(settings, "MIDDLEWARE", None) + + # FIXME This is starting to smell like trouble. We have 2 mechanisms + # that may make this condition be True, one implemented in + # BaseInstrumentor and another one implemented in _instrument. Both + # stop _instrument from running and thus, settings_middleware not being + # set. + if settings_middleware is None or ( + self._opentelemetry_middleware not in settings_middleware + ): + return + + settings_middleware.remove(self._opentelemetry_middleware) + setattr(settings, "MIDDLEWARE", settings_middleware) diff --git a/ext/opentelemetry-ext-django/src/opentelemetry/ext/django/middleware.py b/ext/opentelemetry-ext-django/src/opentelemetry/ext/django/middleware.py new file mode 100644 index 0000000000..5974c5e503 --- /dev/null +++ b/ext/opentelemetry-ext-django/src/opentelemetry/ext/django/middleware.py @@ -0,0 +1,114 @@ +# 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 logging import getLogger + +from django.utils.deprecation import MiddlewareMixin + +from opentelemetry.context import attach, detach +from opentelemetry.ext.django.version import __version__ +from opentelemetry.ext.wsgi import ( + add_response_attributes, + collect_request_attributes, + get_header_from_environ, +) +from opentelemetry.propagators import extract +from opentelemetry.trace import SpanKind, get_tracer + +_logger = getLogger(__name__) + + +class _DjangoMiddleware(MiddlewareMixin): + """Django Middleware for OpenTelemetry + """ + + _environ_activation_key = ( + "opentelemetry-instrumentor-django.activation_key" + ) + _environ_token = "opentelemetry-instrumentor-django.token" + _environ_span_key = "opentelemetry-instrumentor-django.span_key" + + def process_view( + self, request, view_func, view_args, view_kwargs + ): # pylint: disable=unused-argument + # request.META is a dictionary containing all available HTTP headers + # Read more about request.META here: + # https://docs.djangoproject.com/en/3.0/ref/request-response/#django.http.HttpRequest.META + + # environ = { + # key.lower().replace('_', '-').replace("http-", "", 1): value + # for key, value in request.META.items() + # } + + environ = request.META + + token = attach(extract(get_header_from_environ, environ)) + + tracer = get_tracer(__name__, __version__) + + attributes = collect_request_attributes(environ) + + span = tracer.start_span( + view_func.__name__, + kind=SpanKind.SERVER, + attributes=attributes, + start_time=environ.get( + "opentelemetry-instrumentor-django.starttime_key" + ), + ) + + activation = tracer.use_span(span, end_on_exit=True) + activation.__enter__() + + request.META[self._environ_activation_key] = activation + request.META[self._environ_span_key] = span + request.META[self._environ_token] = token + + def process_exception(self, request, exception): + # Django can call this method and process_response later. In order + # to avoid __exit__ and detach from being called twice then, the + # respective keys are being removed here. + if self._environ_activation_key in request.META.keys(): + request.META[self._environ_activation_key].__exit__( + type(exception), + exception, + getattr(exception, "__traceback__", None), + ) + request.META.pop(self._environ_activation_key) + + detach(request.environ[self._environ_token]) + request.META.pop(self._environ_token, None) + + def process_response(self, request, response): + if ( + self._environ_activation_key in request.META.keys() + and self._environ_span_key in request.META.keys() + ): + add_response_attributes( + request.META[self._environ_span_key], + "{} {}".format(response.status_code, response.reason_phrase), + response, + ) + request.META.pop(self._environ_span_key) + + request.META[self._environ_activation_key].__exit__( + None, None, None + ) + request.META.pop(self._environ_activation_key) + + if self._environ_token in request.META.keys(): + detach(request.environ.get(self._environ_token)) + request.META.pop(self._environ_token) + + return response diff --git a/ext/opentelemetry-ext-django/src/opentelemetry/ext/django/version.py b/ext/opentelemetry-ext-django/src/opentelemetry/ext/django/version.py new file mode 100644 index 0000000000..86c61362ab --- /dev/null +++ b/ext/opentelemetry-ext-django/src/opentelemetry/ext/django/version.py @@ -0,0 +1,15 @@ +# 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. + +__version__ = "0.7.dev0" diff --git a/ext/opentelemetry-ext-django/tests/__init__.py b/ext/opentelemetry-ext-django/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ext/opentelemetry-ext-django/tests/conftest.py b/ext/opentelemetry-ext-django/tests/conftest.py new file mode 100644 index 0000000000..b2b39bc049 --- /dev/null +++ b/ext/opentelemetry-ext-django/tests/conftest.py @@ -0,0 +1,19 @@ +# 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 os import environ + + +def pytest_sessionstart(session): # pylint: disable=unused-argument + environ.setdefault("OPENTELEMETRY_PYTHON_DJANGO_INSTRUMENT", "True") diff --git a/ext/opentelemetry-ext-django/tests/test_middleware.py b/ext/opentelemetry-ext-django/tests/test_middleware.py new file mode 100644 index 0000000000..afee6acf77 --- /dev/null +++ b/ext/opentelemetry-ext-django/tests/test_middleware.py @@ -0,0 +1,108 @@ +# 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 django.conf import settings +from django.conf.urls import url +from django.test import Client +from django.test.utils import setup_test_environment, teardown_test_environment + +from opentelemetry.ext.django import DjangoInstrumentor +from opentelemetry.test.wsgitestutil import WsgiTestBase +from opentelemetry.trace import SpanKind +from opentelemetry.trace.status import StatusCanonicalCode + +from .views import error, traced # pylint: disable=import-error + +urlpatterns = [ + url(r"^traced/", traced), + url(r"^error/", error), +] +_django_instrumentor = DjangoInstrumentor() + + +class TestMiddleware(WsgiTestBase): + @classmethod + def setUpClass(cls): + super().setUpClass() + settings.configure(ROOT_URLCONF=modules[__name__]) + + def setUp(self): + super().setUp() + setup_test_environment() + _django_instrumentor.instrument() + + def tearDown(self): + super().tearDown() + teardown_test_environment() + _django_instrumentor.uninstrument() + + def test_traced_get(self): + 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.canonical_code, StatusCanonicalCode.OK) + self.assertEqual(span.attributes["http.method"], "GET") + self.assertEqual( + span.attributes["http.url"], "http://testserver/traced/" + ) + self.assertEqual(span.attributes["http.scheme"], "http") + self.assertEqual(span.attributes["http.status_code"], 200) + self.assertEqual(span.attributes["http.status_text"], "OK") + + def test_traced_post(self): + 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.canonical_code, StatusCanonicalCode.OK) + self.assertEqual(span.attributes["http.method"], "POST") + self.assertEqual( + span.attributes["http.url"], "http://testserver/traced/" + ) + self.assertEqual(span.attributes["http.scheme"], "http") + self.assertEqual(span.attributes["http.status_code"], 200) + self.assertEqual(span.attributes["http.status_text"], "OK") + + def test_error(self): + with self.assertRaises(ValueError): + 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.canonical_code, StatusCanonicalCode.UNKNOWN + ) + self.assertEqual(span.attributes["http.method"], "GET") + self.assertEqual( + span.attributes["http.url"], "http://testserver/error/" + ) + self.assertEqual(span.attributes["http.scheme"], "http") diff --git a/ext/opentelemetry-ext-django/tests/views.py b/ext/opentelemetry-ext-django/tests/views.py new file mode 100644 index 0000000000..498a4518ed --- /dev/null +++ b/ext/opentelemetry-ext-django/tests/views.py @@ -0,0 +1,9 @@ +from django.http import HttpResponse + + +def traced(request): # pylint: disable=unused-argument + return HttpResponse() + + +def error(request): # pylint: disable=unused-argument + raise ValueError("error") diff --git a/tox.ini b/tox.ini index 1570df787c..84dd157dba 100644 --- a/tox.ini +++ b/tox.ini @@ -28,6 +28,9 @@ envlist = py3{4,5,6,7,8}-test-example-http pypy3-test-example-http + py3{6,7,8}-test-ext-django + pypy3-test-ext-django + ; opentelemetry-ext-dbapi py3{4,5,6,7,8}-test-ext-dbapi pypy3-test-ext-dbapi @@ -127,6 +130,7 @@ changedir = test-ext-requests: ext/opentelemetry-ext-requests/tests test-ext-jaeger: ext/opentelemetry-ext-jaeger/tests test-ext-dbapi: ext/opentelemetry-ext-dbapi/tests + test-ext-django: ext/opentelemetry-ext-django/tests test-ext-mysql: ext/opentelemetry-ext-mysql/tests test-ext-otcollector: ext/opentelemetry-ext-otcollector/tests test-ext-prometheus: ext/opentelemetry-ext-prometheus/tests @@ -168,13 +172,15 @@ commands_pre = grpc: pip install {toxinidir}/ext/opentelemetry-ext-grpc[test] - wsgi,flask: pip install {toxinidir}/ext/opentelemetry-ext-wsgi - - flask: pip install {toxinidir}/opentelemetry-auto-instrumentation + wsgi,flask,django: pip install {toxinidir}/tests/util + wsgi,flask,django: pip install {toxinidir}/ext/opentelemetry-ext-wsgi + flask,django: pip install {toxinidir}/opentelemetry-auto-instrumentation flask: pip install {toxinidir}/ext/opentelemetry-ext-flask[test] dbapi: pip install {toxinidir}/ext/opentelemetry-ext-dbapi[test] + django: pip install {toxinidir}/ext/opentelemetry-ext-django[test] + mysql: pip install {toxinidir}/ext/opentelemetry-ext-dbapi mysql: pip install {toxinidir}/ext/opentelemetry-ext-mysql[test]