diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d564f69c672..48d109f5ef6 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -23,6 +23,7 @@ Please describe the tests that you ran to verify your changes. Provide instructi Answer the following question based on these examples of changes that would require a Contrib Repo Change: - [The OTel specification](https://github.com/open-telemetry/opentelemetry-specification) has changed which prompted this PR to update the method interfaces of `opentelemetry-api/` or `opentelemetry-sdk/` +- The method interfaces of `opentelemetry-instrumentation/` have changed - The method interfaces of `test/util` have changed - Scripts in `scripts/` that were copied over to the Contrib repo have changed - Configuration files that were copied over to the Contrib repo have changed (when consistency between repositories is applicable) such as in diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 04004e76e2e..ebd7ccb23fc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,7 +10,7 @@ env: # Otherwise, set variable to the commit of your branch on # opentelemetry-python-contrib which is compatible with these Core repo # changes. - CONTRIB_REPO_SHA: 7e9964d788c2f91697a682d5f0d01bcfeedf9524 + CONTRIB_REPO_SHA: 793ddb4ed638231f387eef2e11207ffb18c6682a jobs: build: diff --git a/CHANGELOG.md b/CHANGELOG.md index bd7c46fa7a4..592ef2d7cb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Moved `opentelemetry-instrumentation` to core repository. + ([#1959](https://github.com/open-telemetry/opentelemetry-python/pull/1959)) - Dropped attributes/events/links count available exposed on ReadableSpans. ([#1893](https://github.com/open-telemetry/opentelemetry-python/pull/1893)) - Added dropped count to otlp, jaeger and zipkin exporters. diff --git a/docs-requirements.txt b/docs-requirements.txt index f9a7776cb0f..97a75de3334 100644 --- a/docs-requirements.txt +++ b/docs-requirements.txt @@ -10,8 +10,11 @@ sphinx-jekyll-builder ./opentelemetry-semantic-conventions ./opentelemetry-python-contrib/opentelemetry-instrumentation ./opentelemetry-sdk +./opentelemetry-instrumentation # Required by instrumentation and exporter packages +asgiref~=3.0 +asyncpg>=0.12.0 ddtrace>=0.34.0 grpcio~=1.27 Deprecated>=1.2.6 diff --git a/docs/conf.py b/docs/conf.py index c1476acf177..6559298b8ed 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,6 +23,11 @@ settings.configure() + +source_dirs = [ + os.path.abspath("../opentelemetry-instrumentation/src/"), +] + exp = "../exporter" exp_dirs = [ os.path.abspath("/".join(["../exporter", f, "src"])) @@ -37,7 +42,7 @@ if isdir(join(shim, f)) ] -sys.path[:0] = exp_dirs + shim_dirs +sys.path[:0] = source_dirs + exp_dirs + shim_dirs # -- Project information ----------------------------------------------------- diff --git a/eachdist.ini b/eachdist.ini index f574d5f8309..546dc1e4448 100644 --- a/eachdist.ini +++ b/eachdist.ini @@ -7,6 +7,7 @@ ignore= sortfirst= opentelemetry-api opentelemetry-sdk + opentelemetry-instrumentation opentelemetry-proto opentelemetry-distro tests/util diff --git a/opentelemetry-instrumentation/MANIFEST.in b/opentelemetry-instrumentation/MANIFEST.in new file mode 100644 index 00000000000..191b7d19592 --- /dev/null +++ b/opentelemetry-instrumentation/MANIFEST.in @@ -0,0 +1,7 @@ +prune tests +graft src +global-exclude *.pyc +global-exclude *.pyo +global-exclude __pycache__/* +include MANIFEST.in +include README.rst diff --git a/opentelemetry-instrumentation/README.rst b/opentelemetry-instrumentation/README.rst new file mode 100644 index 00000000000..6f74d2232f7 --- /dev/null +++ b/opentelemetry-instrumentation/README.rst @@ -0,0 +1,112 @@ +OpenTelemetry Instrumentation +============================= + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-instrumentation.svg + :target: https://pypi.org/project/opentelemetry-instrumentation/ + +Installation +------------ + +:: + + pip install opentelemetry-instrumentation + + +This package provides a couple of commands that help automatically instruments a program: + + +opentelemetry-bootstrap +----------------------- + +:: + + opentelemetry-bootstrap --action=install|requirements + +This commands inspects the active Python site-packages and figures out which +instrumentation packages the user might want to install. By default it prints out +a list of the suggested instrumentation packages which can be added to a requirements.txt +file. It also supports installing the suggested packages when run with :code:`--action=install` +flag. + + +opentelemetry-instrument +------------------------ + +:: + + opentelemetry-instrument python program.py + +The instrument command will try to automatically detect packages used by your python program +and when possible, apply automatic tracing instrumentation on them. This means your program +will get automatic distributed tracing for free without having to make any code changes +at all. This will also configure a global tracer and tracing exporter without you having to +make any code changes. By default, the instrument command will use the OTLP exporter but +this can be overriden when needed. + +The command supports the following configuration options as CLI arguments and environment vars: + + +* ``--trace-exporter`` or ``OTEL_TRACE_EXPORTER`` + +Used to specify which trace exporter to use. Can be set to one or more of the well-known exporter +names (see below). + + - Defaults to `otlp`. + - Can be set to `none` to disable automatic tracer initialization. + +You can pass multiple values to configure multiple exporters e.g, ``zipkin,prometheus`` + +Well known trace exporter names: + + - jaeger + - opencensus + - otlp + - otlp_proto_grpc_span + - zipkin + +``otlp`` is an alias for ``otlp_proto_grpc_span``. + +* ``--id-generator`` or ``OTEL_PYTHON_ID_GENERATOR`` + +Used to specify which IDs Generator to use for the global Tracer Provider. By default, it +will use the random IDs generator. + +The code in ``program.py`` needs to use one of the packages for which there is +an OpenTelemetry integration. For a list of the available integrations please +check `here `_ + +* ``OTEL_PYTHON_DISABLED_INSTRUMENTATIONS`` + +If set by the user, opentelemetry-instrument will read this environment variable to disable specific instrumentations. +e.g OTEL_PYTHON_DISABLED_INSTRUMENTATIONS = "requests,django" + + +Examples +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:: + + opentelemetry-instrument --trace-exporter otlp flask run --port=3000 + +The above command will pass ``--trace-exporter otlp`` to the instrument command and ``--port=3000`` to ``flask run``. + +:: + + opentelemetry-instrument --trace-exporter zipkin,otlp celery -A tasks worker --loglevel=info + +The above command will configure global trace provider, attach zipkin and otlp exporters to it and then +start celery with the rest of the arguments. + +:: + + opentelemetry-instrument --ids-generator random flask run --port=3000 + +The above command will configure the global trace provider to use the Random IDs Generator, and then +pass ``--port=3000`` to ``flask run``. + +References +---------- + +* `OpenTelemetry Project `_ diff --git a/opentelemetry-instrumentation/setup.cfg b/opentelemetry-instrumentation/setup.cfg new file mode 100644 index 00000000000..0a62858fc7b --- /dev/null +++ b/opentelemetry-instrumentation/setup.cfg @@ -0,0 +1,56 @@ +# 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-instrumentation +description = Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python +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-contrib/tree/main/opentelemetry-instrumentation +platforms = any +license = Apache-2.0 +classifiers = + Development Status :: 4 - 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 + Programming Language :: Python :: 3.9 + +[options] +python_requires = >=3.6 +package_dir= + =src +packages=find_namespace: +zip_safe = False +include_package_data = True +install_requires = + opentelemetry-api ~= 1.3 + wrapt >= 1.0.0, < 2.0.0 + +[options.packages.find] +where = src + +[options.entry_points] +console_scripts = + opentelemetry-instrument = opentelemetry.instrumentation.auto_instrumentation:run + opentelemetry-bootstrap = opentelemetry.instrumentation.bootstrap:run + +[options.extras_require] +test = diff --git a/opentelemetry-instrumentation/setup.py b/opentelemetry-instrumentation/setup.py new file mode 100644 index 00000000000..fb3c8ff9f1d --- /dev/null +++ b/opentelemetry-instrumentation/setup.py @@ -0,0 +1,27 @@ +# 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. + +import os + +import setuptools + +BASE_DIR = os.path.dirname(__file__) +VERSION_FILENAME = os.path.join( + BASE_DIR, "src", "opentelemetry", "instrumentation", "version.py" +) +PACKAGE_INFO = {} +with open(VERSION_FILENAME) as f: + exec(f.read(), PACKAGE_INFO) + +setuptools.setup(version=PACKAGE_INFO["__version__"],) diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/auto_instrumentation/__init__.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/auto_instrumentation/__init__.py new file mode 100644 index 00000000000..45a1f2a2211 --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/auto_instrumentation/__init__.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 + +# 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. + +import argparse +from logging import getLogger +from os import environ, execl, getcwd +from os.path import abspath, dirname, pathsep +from shutil import which + +from opentelemetry.environment_variables import ( + OTEL_PYTHON_ID_GENERATOR, + OTEL_TRACES_EXPORTER, +) + +logger = getLogger(__file__) + + +def parse_args(): + parser = argparse.ArgumentParser( + description=""" + opentelemetry-instrument automatically instruments a Python + program and it's dependencies and then runs the program. + """ + ) + + parser.add_argument( + "--trace-exporter", + required=False, + help=""" + Uses the specified exporter to export spans. + Accepts multiple exporters as comma separated values. + + Examples: + + --trace-exporter=jaeger + """, + ) + + parser.add_argument( + "--id-generator", + required=False, + help=""" + The IDs Generator to be used with the Tracer Provider. + + Examples: + + --id-generator=random + """, + ) + + parser.add_argument("command", help="Your Python application.") + parser.add_argument( + "command_args", + help="Arguments for your application.", + nargs=argparse.REMAINDER, + ) + return parser.parse_args() + + +def load_config_from_cli_args(args): + if args.trace_exporter: + environ[OTEL_TRACES_EXPORTER] = args.trace_exporter + if args.id_generator: + environ[OTEL_PYTHON_ID_GENERATOR] = args.id_generator + + +def run() -> None: + args = parse_args() + load_config_from_cli_args(args) + + python_path = environ.get("PYTHONPATH") + + if not python_path: + python_path = [] + + else: + python_path = python_path.split(pathsep) + + cwd_path = getcwd() + + # This is being added to support applications that are being run from their + # own executable, like Django. + # FIXME investigate if there is another way to achieve this + if cwd_path not in python_path: + python_path.insert(0, cwd_path) + + filedir_path = dirname(abspath(__file__)) + + python_path = [path for path in python_path if path != filedir_path] + + python_path.insert(0, filedir_path) + + environ["PYTHONPATH"] = pathsep.join(python_path) + + executable = which(args.command) + execl(executable, executable, *args.command_args) diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/auto_instrumentation/sitecustomize.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/auto_instrumentation/sitecustomize.py new file mode 100644 index 00000000000..d89b60ec56c --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/auto_instrumentation/sitecustomize.py @@ -0,0 +1,135 @@ +# 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. + +import sys +from logging import getLogger +from os import environ, path +from os.path import abspath, dirname, pathsep +from re import sub + +from pkg_resources import iter_entry_points + +from opentelemetry.environment_variables import ( + OTEL_PYTHON_DISABLED_INSTRUMENTATIONS, +) +from opentelemetry.instrumentation.dependencies import ( + get_dist_dependency_conflicts, +) +from opentelemetry.instrumentation.distro import BaseDistro, DefaultDistro + +logger = getLogger(__file__) + + +def _load_distros() -> BaseDistro: + for entry_point in iter_entry_points("opentelemetry_distro"): + try: + distro = entry_point.load()() + if not isinstance(distro, BaseDistro): + logger.debug( + "%s is not an OpenTelemetry Distro. Skipping", + entry_point.name, + ) + continue + logger.debug( + "Distribution %s will be configured", entry_point.name + ) + return distro + except Exception as exc: # pylint: disable=broad-except + logger.exception( + "Distribution %s configuration failed", entry_point.name + ) + raise exc + return DefaultDistro() + + +def _load_instrumentors(distro): + package_to_exclude = environ.get(OTEL_PYTHON_DISABLED_INSTRUMENTATIONS, []) + if isinstance(package_to_exclude, str): + package_to_exclude = package_to_exclude.split(",") + # to handle users entering "requests , flask" or "requests, flask" with spaces + package_to_exclude = [x.strip() for x in package_to_exclude] + + for entry_point in iter_entry_points("opentelemetry_instrumentor"): + if entry_point.name in package_to_exclude: + logger.debug( + "Instrumentation skipped for library %s", entry_point.name + ) + continue + + try: + conflict = get_dist_dependency_conflicts(entry_point.dist) + if conflict: + logger.debug( + "Skipping instrumentation %s: %s", + entry_point.name, + conflict, + ) + continue + + # tell instrumentation to not run dep checks again as we already did it above + distro.load_instrumentor(entry_point, skip_dep_check=True) + logger.debug("Instrumented %s", entry_point.name) + except Exception as exc: # pylint: disable=broad-except + logger.exception("Instrumenting of %s failed", entry_point.name) + raise exc + + +def _load_configurators(): + configured = None + for entry_point in iter_entry_points("opentelemetry_configurator"): + if configured is not None: + logger.warning( + "Configuration of %s not loaded, %s already loaded", + entry_point.name, + configured, + ) + continue + try: + entry_point.load()().configure() # type: ignore + configured = entry_point.name + except Exception as exc: # pylint: disable=broad-except + logger.exception("Configuration of %s failed", entry_point.name) + raise exc + + +def initialize(): + try: + distro = _load_distros() + distro.configure() + _load_configurators() + _load_instrumentors(distro) + except Exception: # pylint: disable=broad-except + logger.exception("Failed to auto initialize opentelemetry") + finally: + environ["PYTHONPATH"] = sub( + r"{}{}?".format(dirname(abspath(__file__)), pathsep), + "", + environ["PYTHONPATH"], + ) + + +if ( + hasattr(sys, "argv") + and sys.argv[0].split(path.sep)[-1] == "celery" + and "worker" in sys.argv[1:] +): + from celery.signals import worker_process_init # pylint:disable=E0401 + + @worker_process_init.connect(weak=False) + def init_celery(*args, **kwargs): + initialize() + + +else: + initialize() diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap.py new file mode 100644 index 00000000000..e691f0c3609 --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 + +# 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. + +import argparse +import logging +import subprocess +import sys + +import pkg_resources + +from opentelemetry.instrumentation.bootstrap_gen import ( + default_instrumentations, + libraries, +) +from opentelemetry.instrumentation.version import __version__ as version + +logger = logging.getLogger(__file__) + + +def _syscall(func): + def wrapper(package=None): + try: + if package: + return func(package) + return func() + except subprocess.SubprocessError as exp: + cmd = getattr(exp, "cmd", None) + if cmd: + msg = 'Error calling system command "{0}"'.format( + " ".join(cmd) + ) + if package: + msg = '{0} for package "{1}"'.format(msg, package) + raise RuntimeError(msg) + + return wrapper + + +@_syscall +def _sys_pip_install(package): + # explicit upgrade strategy to override potential pip config + subprocess.check_call( + [ + sys.executable, + "-m", + "pip", + "install", + "-U", + "--upgrade-strategy", + "only-if-needed", + package, + ] + ) + + +def _pip_check(): + """Ensures none of the instrumentations have dependency conflicts. + Clean check reported as: + 'No broken requirements found.' + Dependency conflicts are reported as: + 'opentelemetry-instrumentation-flask 1.0.1 has requirement opentelemetry-sdk<2.0,>=1.0, but you have opentelemetry-sdk 0.5.' + To not be too restrictive, we'll only check for relevant packages. + """ + # pylint: disable=consider-using-with + check_pipe = subprocess.Popen( + [sys.executable, "-m", "pip", "check"], stdout=subprocess.PIPE + ) + pip_check = check_pipe.communicate()[0].decode() + pip_check_lower = pip_check.lower() + for package_tup in libraries.values(): + for package in package_tup: + if package.lower() in pip_check_lower: + raise RuntimeError( + "Dependency conflict found: {}".format(pip_check) + ) + + +def _is_installed(req): + if req in sys.modules: + return True + + try: + pkg_resources.get_distribution(req) + except pkg_resources.DistributionNotFound: + return False + except pkg_resources.VersionConflict as exc: + logger.warning( + "instrumentation for package %s is available but version %s is installed. Skipping.", + exc.req, + exc.dist.as_requirement(), # pylint: disable=no-member + ) + return False + return True + + +def _find_installed_libraries(): + libs = default_instrumentations[:] + libs.extend( + [ + v["instrumentation"] + for _, v in libraries.items() + if _is_installed(v["library"]) + ] + ) + return libs + + +def _run_requirements(): + logger.setLevel(logging.ERROR) + print("\n".join(_find_installed_libraries()), end="") + + +def _run_install(): + for lib in _find_installed_libraries(): + _sys_pip_install(lib) + _pip_check() + + +def run() -> None: + action_install = "install" + action_requirements = "requirements" + + parser = argparse.ArgumentParser( + description=""" + opentelemetry-bootstrap detects installed libraries and automatically + installs the relevant instrumentation packages for them. + """ + ) + parser.add_argument( + "-a", + "--action", + choices=[action_install, action_requirements], + default=action_requirements, + help=""" + install - uses pip to install the new requirements using to the + currently active site-package. + requirements - prints out the new requirements to stdout. Action can + be piped and appended to a requirements.txt file. + """, + ) + args = parser.parse_args() + + cmd = { + action_install: _run_install, + action_requirements: _run_requirements, + }[args.action] + cmd() diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py new file mode 100644 index 00000000000..b49f40905f5 --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py @@ -0,0 +1,138 @@ +# 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. + +# DO NOT EDIT. THIS FILE WAS AUTOGENERATED FROM INSTRUMENTATION PACKAGES. +# RUN `python scripts/generate_instrumentation_bootstrap.py` TO REGENERATE. + +libraries = { + "aiohttp": { + "library": "aiohttp ~= 3.0", + "instrumentation": "opentelemetry-instrumentation-aiohttp-client==0.23.dev0", + }, + "aiopg": { + "library": "aiopg >= 0.13.0, < 1.3.0", + "instrumentation": "opentelemetry-instrumentation-aiopg==0.23.dev0", + }, + "asgiref": { + "library": "asgiref ~= 3.0", + "instrumentation": "opentelemetry-instrumentation-asgi==0.23.dev0", + }, + "asyncpg": { + "library": "asyncpg >= 0.12.0", + "instrumentation": "opentelemetry-instrumentation-asyncpg==0.23.dev0", + }, + "boto": { + "library": "boto~=2.0", + "instrumentation": "opentelemetry-instrumentation-boto==0.23.dev0", + }, + "botocore": { + "library": "botocore ~= 1.0", + "instrumentation": "opentelemetry-instrumentation-botocore==0.23.dev0", + }, + "celery": { + "library": "celery >= 4.0, < 6.0", + "instrumentation": "opentelemetry-instrumentation-celery==0.23.dev0", + }, + "django": { + "library": "django >= 1.10", + "instrumentation": "opentelemetry-instrumentation-django==0.23.dev0", + }, + "elasticsearch": { + "library": "elasticsearch >= 2.0", + "instrumentation": "opentelemetry-instrumentation-elasticsearch==0.23.dev0", + }, + "falcon": { + "library": "falcon ~= 2.0", + "instrumentation": "opentelemetry-instrumentation-falcon==0.23.dev0", + }, + "fastapi": { + "library": "fastapi ~= 0.58.1", + "instrumentation": "opentelemetry-instrumentation-fastapi==0.23.dev0", + }, + "flask": { + "library": "flask >= 1.0, < 3.0", + "instrumentation": "opentelemetry-instrumentation-flask==0.23.dev0", + }, + "grpcio": { + "library": "grpcio ~= 1.27", + "instrumentation": "opentelemetry-instrumentation-grpc==0.23.dev0", + }, + "httpx": { + "library": "httpx >= 0.18.0, < 0.19.0", + "instrumentation": "opentelemetry-instrumentation-httpx==0.23.dev0", + }, + "jinja2": { + "library": "jinja2~=2.7", + "instrumentation": "opentelemetry-instrumentation-jinja2==0.23.dev0", + }, + "mysql-connector-python": { + "library": "mysql-connector-python ~= 8.0", + "instrumentation": "opentelemetry-instrumentation-mysql==0.23.dev0", + }, + "psycopg2": { + "library": "psycopg2 >= 2.7.3.1", + "instrumentation": "opentelemetry-instrumentation-psycopg2==0.23.dev0", + }, + "pymemcache": { + "library": "pymemcache ~= 1.3", + "instrumentation": "opentelemetry-instrumentation-pymemcache==0.23.dev0", + }, + "pymongo": { + "library": "pymongo ~= 3.1", + "instrumentation": "opentelemetry-instrumentation-pymongo==0.23.dev0", + }, + "PyMySQL": { + "library": "PyMySQL ~= 0.10.1", + "instrumentation": "opentelemetry-instrumentation-pymysql==0.23.dev0", + }, + "pyramid": { + "library": "pyramid >= 1.7", + "instrumentation": "opentelemetry-instrumentation-pyramid==0.23.dev0", + }, + "redis": { + "library": "redis >= 2.6", + "instrumentation": "opentelemetry-instrumentation-redis==0.23.dev0", + }, + "requests": { + "library": "requests ~= 2.0", + "instrumentation": "opentelemetry-instrumentation-requests==0.23.dev0", + }, + "scikit-learn": { + "library": "scikit-learn ~= 0.24.0", + "instrumentation": "opentelemetry-instrumentation-sklearn==0.23.dev0", + }, + "sqlalchemy": { + "library": "sqlalchemy", + "instrumentation": "opentelemetry-instrumentation-sqlalchemy==0.23.dev0", + }, + "starlette": { + "library": "starlette ~= 0.13.0", + "instrumentation": "opentelemetry-instrumentation-starlette==0.23.dev0", + }, + "tornado": { + "library": "tornado >= 6.0", + "instrumentation": "opentelemetry-instrumentation-tornado==0.23.dev0", + }, + "urllib3": { + "library": "urllib3 >= 1.0.0, < 2.0.0", + "instrumentation": "opentelemetry-instrumentation-urllib3==0.23.dev0", + }, +} +default_instrumentations = [ + "opentelemetry-instrumentation-dbapi==0.23.dev0", + "opentelemetry-instrumentation-logging==0.23.dev0", + "opentelemetry-instrumentation-sqlite3==0.23.dev0", + "opentelemetry-instrumentation-urllib==0.23.dev0", + "opentelemetry-instrumentation-wsgi==0.23.dev0", +] diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/configurator.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/configurator.py new file mode 100644 index 00000000000..3efa71e89e9 --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/configurator.py @@ -0,0 +1,53 @@ +# 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. +# type: ignore + +""" +OpenTelemetry Base Configurator +""" + +from abc import ABC, abstractmethod +from logging import getLogger + +_LOG = getLogger(__name__) + + +class BaseConfigurator(ABC): + """An ABC for configurators + + Configurators are used to configure + SDKs (i.e. TracerProvider, MeterProvider, Processors...) + to reduce the amount of manual configuration required. + """ + + _instance = None + _is_instrumented = False + + def __new__(cls, *args, **kwargs): + + if cls._instance is None: + cls._instance = object.__new__(cls, *args, **kwargs) + + return cls._instance + + @abstractmethod + def _configure(self, **kwargs): + """Configure the SDK""" + + def configure(self, **kwargs): + """Configure the SDK""" + self._configure(**kwargs) + + +__all__ = ["BaseConfigurator"] diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/dependencies.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/dependencies.py new file mode 100644 index 00000000000..0cec55769c3 --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/dependencies.py @@ -0,0 +1,64 @@ +from logging import getLogger +from typing import Collection, Optional + +from pkg_resources import ( + Distribution, + DistributionNotFound, + RequirementParseError, + VersionConflict, + get_distribution, +) + +logger = getLogger(__file__) + + +class DependencyConflict: + required: str = None + found: Optional[str] = None + + def __init__(self, required, found=None): + self.required = required + self.found = found + + def __str__(self): + return 'DependencyConflict: requested: "{0}" but found: "{1}"'.format( + self.required, self.found + ) + + +def get_dist_dependency_conflicts( + dist: Distribution, +) -> Optional[DependencyConflict]: + main_deps = dist.requires() + instrumentation_deps = [] + for dep in dist.requires(("instruments",)): + if dep not in main_deps: + # we set marker to none so string representation of the dependency looks like + # requests ~= 1.0 + # instead of + # requests ~= 1.0; extra = "instruments" + # which does not work with `get_distribution()` + dep.marker = None + instrumentation_deps.append(str(dep)) + + return get_dependency_conflicts(instrumentation_deps) + + +def get_dependency_conflicts( + deps: Collection[str], +) -> Optional[DependencyConflict]: + for dep in deps: + try: + get_distribution(dep) + except VersionConflict as exc: + return DependencyConflict(dep, exc.dist) + except DistributionNotFound: + return DependencyConflict(dep) + except RequirementParseError as exc: + logger.warning( + 'error parsing dependency, reporting as a conflict: "%s" - %s', + dep, + exc, + ) + return DependencyConflict(dep) + return None diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/distro.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/distro.py new file mode 100644 index 00000000000..cc1c99c1e03 --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/distro.py @@ -0,0 +1,71 @@ +# 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. +# type: ignore + +""" +OpenTelemetry Base Distribution (Distro) +""" + +from abc import ABC, abstractmethod +from logging import getLogger + +from pkg_resources import EntryPoint + +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor + +_LOG = getLogger(__name__) + + +class BaseDistro(ABC): + """An ABC for distro""" + + _instance = None + + def __new__(cls, *args, **kwargs): + + if cls._instance is None: + cls._instance = object.__new__(cls, *args, **kwargs) + + return cls._instance + + @abstractmethod + def _configure(self, **kwargs): + """Configure the distribution""" + + def configure(self, **kwargs): + """Configure the distribution""" + self._configure(**kwargs) + + def load_instrumentor( # pylint: disable=no-self-use + self, entry_point: EntryPoint, **kwargs + ): + """Takes a collection of instrumentation entry points + and activates them by instantiating and calling instrument() + on each one. + + Distros can override this method to customize the behavior by + inspecting each entry point and configuring them in special ways, + passing additional arguments, load a replacement/fork instead, + skip loading entirely, etc. + """ + instrumentor: BaseInstrumentor = entry_point.load() + instrumentor().instrument(**kwargs) + + +class DefaultDistro(BaseDistro): + def _configure(self, **kwargs): + pass + + +__all__ = ["BaseDistro", "DefaultDistro"] diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/instrumentor.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/instrumentor.py new file mode 100644 index 00000000000..74ebe867461 --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/instrumentor.py @@ -0,0 +1,132 @@ +# 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. +# type: ignore + +""" +OpenTelemetry Base Instrumentor +""" + +from abc import ABC, abstractmethod +from logging import getLogger +from typing import Collection, Optional + +from opentelemetry.instrumentation.dependencies import ( + DependencyConflict, + get_dependency_conflicts, +) + +_LOG = getLogger(__name__) + + +class BaseInstrumentor(ABC): + """An ABC for instrumentors + + Child classes of this ABC should instrument specific third + party libraries or frameworks either by using the + ``opentelemetry-instrument`` command or by calling their methods + directly. + + Since every third party library or framework is different and has different + instrumentation needs, more methods can be added to the child classes as + needed to provide practical instrumentation to the end user. + """ + + _instance = None + _is_instrumented_by_opentelemetry = False + + def __new__(cls, *args, **kwargs): + + if cls._instance is None: + cls._instance = object.__new__(cls, *args, **kwargs) + + return cls._instance + + @property + def is_instrumented_by_opentelemetry(self): + return self._is_instrumented_by_opentelemetry + + @abstractmethod + def instrumentation_dependencies(self) -> Collection[str]: + """Return a list of python packages with versions that the will be instrumented. + + The format should be the same as used in requirements.txt or setup.py. + + For example, if an instrumentation instruments requests 1.x, this method should look + like: + + def instrumentation_dependencies(self) -> Collection[str]: + return ['requests ~= 1.0'] + + This will ensure that the instrumentation will only be used when the specified library + is present in the environment. + """ + + def _instrument(self, **kwargs): + """Instrument the library""" + + @abstractmethod + def _uninstrument(self, **kwargs): + """Uninstrument the library""" + + def _check_dependency_conflicts(self) -> Optional[DependencyConflict]: + dependencies = self.instrumentation_dependencies() + return get_dependency_conflicts(dependencies) + + def instrument(self, **kwargs): + """Instrument the library + + This method will be called without any optional arguments by the + ``opentelemetry-instrument`` command. + + This means that calling this method directly without passing any + optional values should do the very same thing that the + ``opentelemetry-instrument`` command does. + """ + + if self._is_instrumented_by_opentelemetry: + _LOG.warning("Attempting to instrument while already instrumented") + return None + + # check if instrumentor has any missing or conflicting dependencies + skip_dep_check = kwargs.pop("skip_dep_check", False) + if not skip_dep_check: + conflict = self._check_dependency_conflicts() + if conflict: + _LOG.error(conflict) + return None + + result = self._instrument( # pylint: disable=assignment-from-no-return + **kwargs + ) + self._is_instrumented_by_opentelemetry = True + return result + + def uninstrument(self, **kwargs): + """Uninstrument the library + + See ``BaseInstrumentor.instrument`` for more information regarding the + usage of ``kwargs``. + """ + + if self._is_instrumented_by_opentelemetry: + result = self._uninstrument(**kwargs) + self._is_instrumented_by_opentelemetry = False + return result + + _LOG.warning("Attempting to uninstrument while already uninstrumented") + + return None + + +__all__ = ["BaseInstrumentor"] diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/propagators.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/propagators.py new file mode 100644 index 00000000000..3243e1a886e --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/propagators.py @@ -0,0 +1,126 @@ +# 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. + +""" +This module implements experimental propagators to inject trace context +into response carriers. This is useful for server side frameworks that start traces +when server requests and want to share the trace context with the client so the +client can add it's spans to the same trace. + +This is part of an upcoming W3C spec and will eventually make it to the Otel spec. + +https://w3c.github.io/trace-context/#trace-context-http-response-headers-format +""" + +import typing +from abc import ABC, abstractmethod + +from opentelemetry import trace +from opentelemetry.context.context import Context +from opentelemetry.propagators import textmap +from opentelemetry.trace import format_span_id, format_trace_id + +_HTTP_HEADER_ACCESS_CONTROL_EXPOSE_HEADERS = "Access-Control-Expose-Headers" +_RESPONSE_PROPAGATOR = None + + +def get_global_response_propagator(): + return _RESPONSE_PROPAGATOR + + +def set_global_response_propagator(propagator): + global _RESPONSE_PROPAGATOR # pylint:disable=global-statement + _RESPONSE_PROPAGATOR = propagator + + +class Setter(ABC): + @abstractmethod + def set(self, carrier, key, value): + """Inject the provided key value pair in carrier.""" + + +class DictHeaderSetter(Setter): + def set(self, carrier, key, value): # pylint: disable=no-self-use + old_value = carrier.get(key, "") + if old_value: + value = "{0}, {1}".format(old_value, value) + carrier[key] = value + + +class FuncSetter(Setter): + """FuncSetter coverts a function into a valid Setter. Any function that can + set values in a carrier can be converted into a Setter by using FuncSetter. + This is useful when injecting trace context into non-dict objects such + HTTP Response objects for different framework. + + For example, it can be used to create a setter for Falcon response object as: + + setter = FuncSetter(falcon.api.Response.append_header) + + and then used with the propagator as: + + propagator.inject(falcon_response, setter=setter) + + This would essentially make the propagator call `falcon_response.append_header(key, value)` + """ + + def __init__(self, func): + self._func = func + + def set(self, carrier, key, value): + self._func(carrier, key, value) + + +default_setter = DictHeaderSetter() + + +class ResponsePropagator(ABC): + @abstractmethod + def inject( + self, + carrier: textmap.CarrierT, + context: typing.Optional[Context] = None, + setter: textmap.Setter = default_setter, + ) -> None: + """Injects SpanContext into the HTTP response carrier.""" + + +class TraceResponsePropagator(ResponsePropagator): + """Experimental propagator that injects tracecontext into HTTP responses.""" + + def inject( + self, + carrier: textmap.CarrierT, + context: typing.Optional[Context] = None, + setter: textmap.Setter = default_setter, + ) -> None: + """Injects SpanContext into the HTTP response carrier.""" + span = trace.get_current_span(context) + span_context = span.get_span_context() + if span_context == trace.INVALID_SPAN_CONTEXT: + return + + header_name = "traceresponse" + setter.set( + carrier, + header_name, + "00-{trace_id}-{span_id}-{:02x}".format( + span_context.trace_flags, + trace_id=format_trace_id(span_context.trace_id), + span_id=format_span_id(span_context.span_id), + ), + ) + setter.set( + carrier, _HTTP_HEADER_ACCESS_CONTROL_EXPOSE_HEADERS, header_name, + ) diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/py.typed b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/utils.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/utils.py new file mode 100644 index 00000000000..16f75aae6c8 --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/utils.py @@ -0,0 +1,68 @@ +# 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 typing import Dict, Sequence + +from wrapt import ObjectProxy + +from opentelemetry.context import create_key +from opentelemetry.trace import StatusCode + +# FIXME This is a temporary location for the suppress instrumentation key. +# Once the decision around how to suppress instrumentation is made in the +# spec, this key should be moved accordingly. +_SUPPRESS_INSTRUMENTATION_KEY = create_key("suppress_instrumentation") + + +def extract_attributes_from_object( + obj: any, attributes: Sequence[str], existing: Dict[str, str] = None +) -> Dict[str, str]: + extracted = {} + if existing: + extracted.update(existing) + for attr in attributes: + value = getattr(obj, attr, None) + if value is not None: + extracted[attr] = str(value) + return extracted + + +def http_status_to_status_code( + status: int, allow_redirect: bool = True +) -> StatusCode: + """Converts an HTTP status code to an OpenTelemetry canonical status code + + Args: + status (int): HTTP status code + """ + # See: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/http.md#status + if status < 100: + return StatusCode.ERROR + if status <= 299: + return StatusCode.UNSET + if status <= 399 and allow_redirect: + return StatusCode.UNSET + return StatusCode.ERROR + + +def unwrap(obj, attr: str): + """Given a function that was wrapped by wrapt.wrap_function_wrapper, unwrap it + + Args: + obj: Object that holds a reference to the wrapped function + attr (str): Name of the wrapped function + """ + func = getattr(obj, attr, None) + if func and isinstance(func, ObjectProxy) and hasattr(func, "__wrapped__"): + setattr(obj, attr, func.__wrapped__) diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/version.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/version.py new file mode 100644 index 00000000000..c829b957573 --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/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.23.dev0" diff --git a/opentelemetry-instrumentation/tests/__init__.py b/opentelemetry-instrumentation/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/opentelemetry-instrumentation/tests/test_bootstrap.py b/opentelemetry-instrumentation/tests/test_bootstrap.py new file mode 100644 index 00000000000..d1052de2897 --- /dev/null +++ b/opentelemetry-instrumentation/tests/test_bootstrap.py @@ -0,0 +1,86 @@ +# 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. +# type: ignore + +from io import StringIO +from random import sample +from unittest import TestCase +from unittest.mock import call, patch + +from opentelemetry.instrumentation import bootstrap +from opentelemetry.instrumentation.bootstrap_gen import libraries + + +def sample_packages(packages, rate): + return sample(list(packages), int(len(packages) * rate),) + + +class TestBootstrap(TestCase): + + installed_libraries = {} + installed_instrumentations = {} + + @classmethod + def setUpClass(cls): + cls.installed_libraries = sample_packages( + [lib["instrumentation"] for lib in libraries.values()], 0.6 + ) + + # treat 50% of sampled packages as pre-installed + cls.installed_instrumentations = sample_packages( + cls.installed_libraries, 0.5 + ) + + cls.pkg_patcher = patch( + "opentelemetry.instrumentation.bootstrap._find_installed_libraries", + return_value=cls.installed_libraries, + ) + + cls.pip_install_patcher = patch( + "opentelemetry.instrumentation.bootstrap._sys_pip_install", + ) + cls.pip_check_patcher = patch( + "opentelemetry.instrumentation.bootstrap._pip_check", + ) + + cls.pkg_patcher.start() + cls.mock_pip_install = cls.pip_install_patcher.start() + cls.mock_pip_check = cls.pip_check_patcher.start() + + @classmethod + def tearDownClass(cls): + cls.pip_check_patcher.start() + cls.pip_install_patcher.start() + cls.pkg_patcher.stop() + + @patch("sys.argv", ["bootstrap", "-a", "pipenv"]) + def test_run_unknown_cmd(self): + with self.assertRaises(SystemExit): + bootstrap.run() + + @patch("sys.argv", ["bootstrap", "-a", "requirements"]) + def test_run_cmd_print(self): + with patch("sys.stdout", new=StringIO()) as fake_out: + bootstrap.run() + self.assertEqual( + fake_out.getvalue(), "\n".join(self.installed_libraries), + ) + + @patch("sys.argv", ["bootstrap", "-a", "install"]) + def test_run_cmd_install(self): + bootstrap.run() + self.mock_pip_install.assert_has_calls( + [call(i) for i in self.installed_libraries], any_order=True, + ) + self.assertEqual(self.mock_pip_check.call_count, 1) diff --git a/opentelemetry-instrumentation/tests/test_dependencies.py b/opentelemetry-instrumentation/tests/test_dependencies.py new file mode 100644 index 00000000000..8b2f2e9b392 --- /dev/null +++ b/opentelemetry-instrumentation/tests/test_dependencies.py @@ -0,0 +1,79 @@ +# 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. + +# pylint: disable=protected-access + +import pkg_resources +import pytest + +from opentelemetry.instrumentation.dependencies import ( + DependencyConflict, + get_dependency_conflicts, + get_dist_dependency_conflicts, +) +from opentelemetry.test.test_base import TestBase + + +class TestDependencyConflicts(TestBase): + def setUp(self): + pass + + def test_get_dependency_conflicts_empty(self): + self.assertIsNone(get_dependency_conflicts([])) + + def test_get_dependency_conflicts_no_conflict(self): + self.assertIsNone(get_dependency_conflicts(["pytest"])) + + def test_get_dependency_conflicts_not_installed(self): + conflict = get_dependency_conflicts(["this-package-does-not-exist"]) + self.assertTrue(conflict is not None) + self.assertTrue(isinstance(conflict, DependencyConflict)) + self.assertEqual( + str(conflict), + 'DependencyConflict: requested: "this-package-does-not-exist" but found: "None"', + ) + + def test_get_dependency_conflicts_mismatched_version(self): + conflict = get_dependency_conflicts(["pytest == 5000"]) + self.assertTrue(conflict is not None) + self.assertTrue(isinstance(conflict, DependencyConflict)) + self.assertEqual( + str(conflict), + 'DependencyConflict: requested: "pytest == 5000" but found: "pytest {0}"'.format( + pytest.__version__ + ), + ) + + def test_get_dist_dependency_conflicts(self): + def mock_requires(extras=()): + if "instruments" in extras: + return [ + pkg_resources.Requirement( + 'test-pkg ~= 1.0; extra == "instruments"' + ) + ] + return [] + + dist = pkg_resources.Distribution( + project_name="test-instrumentation", version="1.0" + ) + dist.requires = mock_requires + + conflict = get_dist_dependency_conflicts(dist) + self.assertTrue(conflict is not None) + self.assertTrue(isinstance(conflict, DependencyConflict)) + self.assertEqual( + str(conflict), + 'DependencyConflict: requested: "test-pkg~=1.0" but found: "None"', + ) diff --git a/opentelemetry-instrumentation/tests/test_distro.py b/opentelemetry-instrumentation/tests/test_distro.py new file mode 100644 index 00000000000..399b3f8a654 --- /dev/null +++ b/opentelemetry-instrumentation/tests/test_distro.py @@ -0,0 +1,58 @@ +# 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. +# type: ignore + +from unittest import TestCase + +from pkg_resources import EntryPoint + +from opentelemetry.instrumentation.distro import BaseDistro +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor + + +class MockInstrumetor(BaseInstrumentor): + def instrumentation_dependencies(self): + return [] + + def _instrument(self, **kwargs): + pass + + def _uninstrument(self, **kwargs): + pass + + +class MockEntryPoint(EntryPoint): + def __init__(self, obj): # pylint: disable=super-init-not-called + self._obj = obj + + def load(self, *args, **kwargs): # pylint: disable=signature-differs + return self._obj + + +class MockDistro(BaseDistro): + def _configure(self, **kwargs): + pass + + +class TestDistro(TestCase): + def test_load_instrumentor(self): + # pylint: disable=protected-access + distro = MockDistro() + + instrumentor = MockInstrumetor() + entry_point = MockEntryPoint(MockInstrumetor) + + self.assertFalse(instrumentor._is_instrumented_by_opentelemetry) + distro.load_instrumentor(entry_point) + self.assertTrue(instrumentor._is_instrumented_by_opentelemetry) diff --git a/opentelemetry-instrumentation/tests/test_instrumentor.py b/opentelemetry-instrumentation/tests/test_instrumentor.py new file mode 100644 index 00000000000..dee32c34e45 --- /dev/null +++ b/opentelemetry-instrumentation/tests/test_instrumentor.py @@ -0,0 +1,50 @@ +# 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. +# type: ignore + +from logging import WARNING +from unittest import TestCase + +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor + + +class TestInstrumentor(TestCase): + class Instrumentor(BaseInstrumentor): + def _instrument(self, **kwargs): + return "instrumented" + + def _uninstrument(self, **kwargs): + return "uninstrumented" + + def instrumentation_dependencies(self): + return [] + + def test_protect(self): + instrumentor = self.Instrumentor() + + with self.assertLogs(level=WARNING): + self.assertIs(instrumentor.uninstrument(), None) + + self.assertEqual(instrumentor.instrument(), "instrumented") + + with self.assertLogs(level=WARNING): + self.assertIs(instrumentor.instrument(), None) + + self.assertEqual(instrumentor.uninstrument(), "uninstrumented") + + with self.assertLogs(level=WARNING): + self.assertIs(instrumentor.uninstrument(), None) + + def test_singleton(self): + self.assertIs(self.Instrumentor(), self.Instrumentor()) diff --git a/opentelemetry-instrumentation/tests/test_propagators.py b/opentelemetry-instrumentation/tests/test_propagators.py new file mode 100644 index 00000000000..62461aafa97 --- /dev/null +++ b/opentelemetry-instrumentation/tests/test_propagators.py @@ -0,0 +1,80 @@ +# 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. + +# pylint: disable=protected-access + +from opentelemetry import trace +from opentelemetry.instrumentation import propagators +from opentelemetry.instrumentation.propagators import ( + DictHeaderSetter, + TraceResponsePropagator, + get_global_response_propagator, + set_global_response_propagator, +) +from opentelemetry.test.test_base import TestBase + + +class TestGlobals(TestBase): + def test_get_set(self): + original = propagators._RESPONSE_PROPAGATOR + + propagators._RESPONSE_PROPAGATOR = None + self.assertIsNone(get_global_response_propagator()) + + prop = TraceResponsePropagator() + set_global_response_propagator(prop) + self.assertIs(prop, get_global_response_propagator()) + + propagators._RESPONSE_PROPAGATOR = original + + +class TestDictHeaderSetter(TestBase): + def test_simple(self): + setter = DictHeaderSetter() + carrier = {} + setter.set(carrier, "kk", "vv") + self.assertIn("kk", carrier) + self.assertEqual(carrier["kk"], "vv") + + def test_append(self): + setter = DictHeaderSetter() + carrier = {"kk": "old"} + setter.set(carrier, "kk", "vv") + self.assertIn("kk", carrier) + self.assertEqual(carrier["kk"], "old, vv") + + +class TestTraceResponsePropagator(TestBase): + def test_inject(self): + span = trace.NonRecordingSpan( + trace.SpanContext( + trace_id=1, + span_id=2, + is_remote=False, + trace_flags=trace.DEFAULT_TRACE_OPTIONS, + trace_state=trace.DEFAULT_TRACE_STATE, + ), + ) + + ctx = trace.set_span_in_context(span) + prop = TraceResponsePropagator() + carrier = {} + prop.inject(carrier, ctx) + self.assertEqual( + carrier["Access-Control-Expose-Headers"], "traceresponse" + ) + self.assertEqual( + carrier["traceresponse"], + "00-00000000000000000000000000000001-0000000000000002-00", + ) diff --git a/opentelemetry-instrumentation/tests/test_run.py b/opentelemetry-instrumentation/tests/test_run.py new file mode 100644 index 00000000000..01bd86ed32f --- /dev/null +++ b/opentelemetry-instrumentation/tests/test_run.py @@ -0,0 +1,117 @@ +# 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. +# type: ignore + +from os import environ, getcwd +from os.path import abspath, dirname, pathsep +from unittest import TestCase +from unittest.mock import patch + +from opentelemetry.environment_variables import OTEL_TRACES_EXPORTER +from opentelemetry.instrumentation import auto_instrumentation + + +class TestRun(TestCase): + auto_instrumentation_path = dirname(abspath(auto_instrumentation.__file__)) + + @classmethod + def setUpClass(cls): + cls.execl_patcher = patch( + "opentelemetry.instrumentation.auto_instrumentation.execl" + ) + cls.which_patcher = patch( + "opentelemetry.instrumentation.auto_instrumentation.which" + ) + + cls.execl_patcher.start() + cls.which_patcher.start() + + @classmethod + def tearDownClass(cls): + cls.execl_patcher.stop() + cls.which_patcher.stop() + + @patch("sys.argv", ["instrument", ""]) + @patch.dict("os.environ", {"PYTHONPATH": ""}) + def test_empty(self): + auto_instrumentation.run() + self.assertEqual( + environ["PYTHONPATH"], + pathsep.join([self.auto_instrumentation_path, getcwd()]), + ) + + @patch("sys.argv", ["instrument", ""]) + @patch.dict("os.environ", {"PYTHONPATH": "abc"}) + def test_non_empty(self): + auto_instrumentation.run() + self.assertEqual( + environ["PYTHONPATH"], + pathsep.join([self.auto_instrumentation_path, getcwd(), "abc"]), + ) + + @patch("sys.argv", ["instrument", ""]) + @patch.dict( + "os.environ", + {"PYTHONPATH": pathsep.join(["abc", auto_instrumentation_path])}, + ) + def test_after_path(self): + auto_instrumentation.run() + self.assertEqual( + environ["PYTHONPATH"], + pathsep.join([self.auto_instrumentation_path, getcwd(), "abc"]), + ) + + @patch("sys.argv", ["instrument", ""]) + @patch.dict( + "os.environ", + { + "PYTHONPATH": pathsep.join( + [auto_instrumentation_path, "abc", auto_instrumentation_path] + ) + }, + ) + def test_single_path(self): + auto_instrumentation.run() + self.assertEqual( + environ["PYTHONPATH"], + pathsep.join([self.auto_instrumentation_path, getcwd(), "abc"]), + ) + + +class TestExecl(TestCase): + @patch("sys.argv", ["1", "2", "3"]) + @patch("opentelemetry.instrumentation.auto_instrumentation.which") + @patch("opentelemetry.instrumentation.auto_instrumentation.execl") + def test_execl( + self, mock_execl, mock_which + ): # pylint: disable=no-self-use + mock_which.configure_mock(**{"return_value": "python"}) + + auto_instrumentation.run() + + mock_execl.assert_called_with("python", "python", "3") + + +class TestArgs(TestCase): + @patch("opentelemetry.instrumentation.auto_instrumentation.execl") + def test_exporter(self, _): # pylint: disable=no-self-use + with patch("sys.argv", ["instrument", "2"]): + auto_instrumentation.run() + self.assertIsNone(environ.get(OTEL_TRACES_EXPORTER)) + + with patch( + "sys.argv", ["instrument", "--trace-exporter", "jaeger", "1", "2"] + ): + auto_instrumentation.run() + self.assertEqual(environ.get(OTEL_TRACES_EXPORTER), "jaeger") diff --git a/opentelemetry-instrumentation/tests/test_utils.py b/opentelemetry-instrumentation/tests/test_utils.py new file mode 100644 index 00000000000..273c6f085cc --- /dev/null +++ b/opentelemetry-instrumentation/tests/test_utils.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 http import HTTPStatus + +from opentelemetry.instrumentation.utils import http_status_to_status_code +from opentelemetry.test.test_base import TestBase +from opentelemetry.trace import StatusCode + + +class TestUtils(TestBase): + # See https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/http.md#status + def test_http_status_to_status_code(self): + for status_code, expected in ( + (HTTPStatus.OK, StatusCode.UNSET), + (HTTPStatus.ACCEPTED, StatusCode.UNSET), + (HTTPStatus.IM_USED, StatusCode.UNSET), + (HTTPStatus.MULTIPLE_CHOICES, StatusCode.UNSET), + (HTTPStatus.BAD_REQUEST, StatusCode.ERROR), + (HTTPStatus.UNAUTHORIZED, StatusCode.ERROR), + (HTTPStatus.FORBIDDEN, StatusCode.ERROR), + (HTTPStatus.NOT_FOUND, StatusCode.ERROR), + (HTTPStatus.UNPROCESSABLE_ENTITY, StatusCode.ERROR,), + (HTTPStatus.TOO_MANY_REQUESTS, StatusCode.ERROR,), + (HTTPStatus.NOT_IMPLEMENTED, StatusCode.ERROR), + (HTTPStatus.SERVICE_UNAVAILABLE, StatusCode.ERROR), + (HTTPStatus.GATEWAY_TIMEOUT, StatusCode.ERROR,), + (HTTPStatus.HTTP_VERSION_NOT_SUPPORTED, StatusCode.ERROR,), + (600, StatusCode.ERROR), + (99, StatusCode.ERROR), + ): + with self.subTest(status_code=status_code): + actual = http_status_to_status_code(int(status_code)) + self.assertEqual(actual, expected, status_code) diff --git a/scripts/build.sh b/scripts/build.sh index 8134769775c..2f40f1a0034 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -16,7 +16,7 @@ DISTDIR=dist mkdir -p $DISTDIR rm -rf $DISTDIR/* - for d in opentelemetry-api/ opentelemetry-sdk/ opentelemetry-proto/ opentelemetry-distro/ opentelemetry-semantic-conventions/ exporter/*/ shim/*/ propagator/*/; do + for d in opentelemetry-api/ opentelemetry-sdk/ opentelemetry-instrumentation/ opentelemetry-proto/ opentelemetry-distro/ opentelemetry-semantic-conventions/ exporter/*/ shim/*/ propagator/*/; do ( echo "building $d" cd "$d" diff --git a/tox.ini b/tox.ini index 49b62e1e274..33a9269c856 100644 --- a/tox.ini +++ b/tox.ini @@ -16,6 +16,10 @@ envlist = py3{6,7,8,9}-test-core-sdk pypy3-test-core-sdk + ; opentelemetry-instrumentation + py3{6,7,8,9}-test-core-instrumentation + pypy3-test-core-instrumentation + ; opentelemetry-semantic-conventions py3{6,7,8,9}-test-semantic-conventions pypy3-test-semantic-conventions @@ -96,6 +100,7 @@ changedir = test-core-sdk: opentelemetry-sdk/tests test-core-proto: opentelemetry-proto/tests test-semantic-conventions: opentelemetry-semantic-conventions/tests + test-core-instrumentation: opentelemetry-instrumentation/tests test-core-getting-started: docs/getting_started/tests test-core-opentracing-shim: shim/opentelemetry-opentracing-shim/tests test-core-distro: opentelemetry-distro/tests @@ -124,9 +129,10 @@ commands_pre = test-core-opentracing-shim: pip install {toxinidir}/opentelemetry-python-contrib/opentelemetry-instrumentation test-core-proto: pip install {toxinidir}/opentelemetry-proto - distro: pip install {toxinidir}/opentelemetry-python-contrib/opentelemetry-instrumentation {toxinidir}/opentelemetry-distro + distro: pip install {toxinidir}/opentelemetry-distro {toxinidir}/opentelemetry-distro + instrumentation: pip install {toxinidir}/opentelemetry-instrumentation - getting-started: pip install requests flask -e {toxinidir}/opentelemetry-python-contrib/opentelemetry-instrumentation -e {toxinidir}/opentelemetry-python-contrib/instrumentation/opentelemetry-instrumentation-requests {toxinidir}/opentelemetry-python-contrib/util/opentelemetry-util-http -e {toxinidir}/opentelemetry-python-contrib/instrumentation/opentelemetry-instrumentation-wsgi -e {toxinidir}/opentelemetry-python-contrib/instrumentation/opentelemetry-instrumentation-flask + getting-started: pip install -e {toxinidir}/opentelemetry-instrumentation -e {toxinidir}/opentelemetry-python-contrib/instrumentation/opentelemetry-instrumentation-requests {toxinidir}/opentelemetry-python-contrib/util/opentelemetry-util-http -e {toxinidir}/opentelemetry-python-contrib/instrumentation/opentelemetry-instrumentation-wsgi -e {toxinidir}/opentelemetry-python-contrib/instrumentation/opentelemetry-instrumentation-flask opencensus: pip install {toxinidir}/exporter/opentelemetry-exporter-opencensus @@ -197,7 +203,7 @@ deps = commands_pre = python -m pip install -e {toxinidir}/opentelemetry-api[test] python -m pip install -e {toxinidir}/opentelemetry-semantic-conventions[test] - python -m pip install {toxinidir}/opentelemetry-python-contrib/opentelemetry-instrumentation + python -m pip install -e {toxinidir}/opentelemetry-instrumentation[test] python -m pip install -e {toxinidir}/opentelemetry-sdk[test] python -m pip install -e {toxinidir}/opentelemetry-proto[test] python -m pip install -e {toxinidir}/tests/util[test] @@ -226,9 +232,6 @@ deps = changedir = docs -commands_pre = - python -m pip install {toxinidir}/opentelemetry-python-contrib/opentelemetry-instrumentation - commands = sphinx-build -E -a -W -b html -T . _build/html @@ -244,7 +247,7 @@ deps = commands_pre = pip install -e {toxinidir}/opentelemetry-api \ -e {toxinidir}/opentelemetry-semantic-conventions \ - -e {toxinidir}/opentelemetry-python-contrib/opentelemetry-instrumentation \ + -e {toxinidir}/opentelemetry-instrumentation \ -e {toxinidir}/opentelemetry-sdk \ -e {toxinidir}/opentelemetry-python-contrib/instrumentation/opentelemetry-instrumentation-requests \ -e {toxinidir}/opentelemetry-python-contrib/instrumentation/opentelemetry-instrumentation-wsgi \ @@ -264,7 +267,7 @@ changedir = commands_pre = pip install -e {toxinidir}/opentelemetry-api \ -e {toxinidir}/opentelemetry-semantic-conventions \ - -e {toxinidir}/opentelemetry-python-contrib/opentelemetry-instrumentation \ + -e {toxinidir}/opentelemetry-instrumentation \ -e {toxinidir}/opentelemetry-sdk \ -e {toxinidir}/tests/util \ -e {toxinidir}/exporter/opentelemetry-exporter-opencensus \