diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9ee9022b49..2dc510365a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,7 +6,7 @@ on: - 'release/*' pull_request: env: - CORE_REPO_SHA: 3e628b56f154d651816ba806b49940c6cc9a3556 + CORE_REPO_SHA: 2ac247e8b666c6b5a735719ab78dc0cd94907d9b jobs: build: diff --git a/CHANGELOG.md b/CHANGELOG.md index c140fd1962..57976ae4ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/open-telemetry/opentelemetry-python-contrib/compare/v0.200...HEAD) +### Added +- Move `opentelemetry-instrumentation` from core repository + ([#465](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/465)) + ## [0.20b0](https://github.com/open-telemetry/opentelemetry-python-contrib/releases/tag/v0.20b0) - 2021-04-20 ### Changed diff --git a/docs-requirements.txt b/docs-requirements.txt index ade98c2665..3026d27bea 100644 --- a/docs-requirements.txt +++ b/docs-requirements.txt @@ -7,7 +7,6 @@ sphinx-autodoc-typehints -e "git+https://github.com/open-telemetry/opentelemetry-python.git#egg=opentelemetry-api&subdirectory=opentelemetry-api" -e "git+https://github.com/open-telemetry/opentelemetry-python.git#egg=opentelemetry-semantic-conventions&subdirectory=opentelemetry-semantic-conventions" -e "git+https://github.com/open-telemetry/opentelemetry-python.git#egg=opentelemetry-sdk&subdirectory=opentelemetry-sdk" --e "git+https://github.com/open-telemetry/opentelemetry-python.git#egg=opentelemetry-instrumentation&subdirectory=opentelemetry-instrumentation" # Required by opentelemetry-instrumentation fastapi~=0.58.1 diff --git a/docs/conf.py b/docs/conf.py index a3a46b4475..0c72e82b80 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,6 +24,10 @@ settings.configure() +source_dirs = [ + os.path.abspath("../opentelemetry-instrumentation/src/"), +] + exp = "../exporter" exp_dirs = [ os.path.abspath("/".join(["../exporter", f, "src"])) @@ -45,7 +49,7 @@ if isdir(join(sdk_ext, f)) ] -sys.path[:0] = exp_dirs + instr_dirs + sdk_ext_dirs +sys.path[:0] = source_dirs + exp_dirs + instr_dirs + sdk_ext_dirs # -- Project information ----------------------------------------------------- diff --git a/docs/instrumentation/base/instrumentation.rst b/docs/instrumentation/base/instrumentation.rst new file mode 100644 index 0000000000..9c01b6b6f4 --- /dev/null +++ b/docs/instrumentation/base/instrumentation.rst @@ -0,0 +1,15 @@ +OpenTelemetry Python Instrumentor +================================= + +.. automodule:: opentelemetry.instrumentation + :members: + :undoc-members: + :show-inheritance: + +Submodules +---------- + +.. toctree:: + :maxdepth: 1 + + instrumentor diff --git a/docs/instrumentation/base/instrumentor.rst b/docs/instrumentation/base/instrumentor.rst new file mode 100644 index 0000000000..5c0c010ff6 --- /dev/null +++ b/docs/instrumentation/base/instrumentor.rst @@ -0,0 +1,7 @@ +opentelemetry.instrumentation.instrumentor package +================================================== + +.. automodule:: opentelemetry.instrumentation.instrumentor + :members: + :undoc-members: + :show-inheritance: diff --git a/opentelemetry-instrumentation/MANIFEST.in b/opentelemetry-instrumentation/MANIFEST.in new file mode 100644 index 0000000000..191b7d1959 --- /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 0000000000..6f74d2232f --- /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 0000000000..382e5725b0 --- /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.2.0.dev0 + 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 0000000000..d4f84f738b --- /dev/null +++ b/opentelemetry-instrumentation/setup.py @@ -0,0 +1,29 @@ +# 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 0000000000..45a1f2a221 --- /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 0000000000..10c8faf899 --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/auto_instrumentation/sitecustomize.py @@ -0,0 +1,109 @@ +# 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, +) + +logger = getLogger(__file__) + + +def _load_distros(): + for entry_point in iter_entry_points("opentelemetry_distro"): + try: + entry_point.load()().configure() # type: ignore + logger.debug("Distribution %s configured", entry_point.name) + except Exception as exc: # pylint: disable=broad-except + logger.exception( + "Distribution %s configuration failed", entry_point.name + ) + raise exc + + +def _load_instrumentors(): + 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"): + try: + if entry_point.name in package_to_exclude: + logger.debug( + "Instrumentation skipped for library %s", entry_point.name + ) + continue + entry_point.load()().instrument() # type: ignore + 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: + _load_distros() + _load_configurators() + _load_instrumentors() + 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 0000000000..095189b974 --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap.py @@ -0,0 +1,253 @@ +#!/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 pkgutil +import subprocess +import sys +from logging import getLogger + +from opentelemetry.instrumentation.version import __version__ as version + +logger = getLogger(__file__) + + +# A mapping of "target library" to "desired instrumentor path/versioned package +# name". Used as part of the `opentelemetry-bootstrap` command which looks at +# libraries used by the application that is to be instrumented, and handles +# automatically installing the appropriate instrumentations for that app. +# This helps for those who prefer to turn on as much instrumentation as +# possible, and don't want to go through the manual process of combing through +# the libraries their application uses to figure which one can be +# instrumented. +# NOTE: system-metrics is not to be included. +def all_instrumentations(): + pkg_instrumentation_map = { + "aiohttp-client": "opentelemetry-instrumentation-aiohttp-client", + "aiopg": "opentelemetry-instrumentation-aiopg", + "asyncpg": "opentelemetry-instrumentation-asyncpg", + "boto": "opentelemetry-instrumentation-boto", + "botocore": "opentelemetry-instrumentation-botocore", + "celery": "opentelemetry-instrumentation-celery", + "dbapi": "opentelemetry-instrumentation-dbapi", + "django": "opentelemetry-instrumentation-django", + "elasticsearch": "opentelemetry-instrumentation-elasticsearch", + "falcon": "opentelemetry-instrumentation-falcon", + "fastapi": "opentelemetry-instrumentation-fastapi", + "flask": "opentelemetry-instrumentation-flask", + "grpc": "opentelemetry-instrumentation-grpc", + "jinja2": "opentelemetry-instrumentation-jinja2", + "mysql": "opentelemetry-instrumentation-mysql", + "psycopg2": "opentelemetry-instrumentation-psycopg2", + "pymemcache": "opentelemetry-instrumentation-pymemcache", + "pymongo": "opentelemetry-instrumentation-pymongo", + "pymysql": "opentelemetry-instrumentation-pymysql", + "pyramid": "opentelemetry-instrumentation-pyramid", + "redis": "opentelemetry-instrumentation-redis", + "requests": "opentelemetry-instrumentation-requests", + "sklearn": "opentelemetry-instrumentation-sklearn", + "sqlalchemy": "opentelemetry-instrumentation-sqlalchemy", + "sqlite3": "opentelemetry-instrumentation-sqlite3", + "starlette": "opentelemetry-instrumentation-starlette", + "tornado": "opentelemetry-instrumentation-tornado", + "urllib": "opentelemetry-instrumentation-urllib", + } + for pkg, instrumentation in pkg_instrumentation_map.items(): + pkg_instrumentation_map[pkg] = "{0}=={1}".format( + instrumentation, version + ) + return pkg_instrumentation_map + + +instrumentations = all_instrumentations() + +# relevant instrumentors and tracers to uninstall and check for conflicts for target libraries +libraries = { + "aiohttp-client": ("opentelemetry-instrumentation-aiohttp-client",), + "aiopg": ("opentelemetry-instrumentation-aiopg",), + "asyncpg": ("opentelemetry-instrumentation-asyncpg",), + "boto": ("opentelemetry-instrumentation-boto",), + "botocore": ("opentelemetry-instrumentation-botocore",), + "celery": ("opentelemetry-instrumentation-celery",), + "dbapi": ("opentelemetry-instrumentation-dbapi",), + "django": ("opentelemetry-instrumentation-django",), + "elasticsearch": ("opentelemetry-instrumentation-elasticsearch",), + "falcon": ("opentelemetry-instrumentation-falcon",), + "fastapi": ("opentelemetry-instrumentation-fastapi",), + "flask": ("opentelemetry-instrumentation-flask",), + "grpc": ("opentelemetry-instrumentation-grpc",), + "jinja2": ("opentelemetry-instrumentation-jinja2",), + "mysql": ("opentelemetry-instrumentation-mysql",), + "psycopg2": ("opentelemetry-instrumentation-psycopg2",), + "pymemcache": ("opentelemetry-instrumentation-pymemcache",), + "pymongo": ("opentelemetry-instrumentation-pymongo",), + "pymysql": ("opentelemetry-instrumentation-pymysql",), + "pyramid": ("opentelemetry-instrumentation-pyramid",), + "redis": ("opentelemetry-instrumentation-redis",), + "requests": ("opentelemetry-instrumentation-requests",), + "sklearn": ("opentelemetry-instrumentation-sklearn",), + "sqlalchemy": ("opentelemetry-instrumentation-sqlalchemy",), + "sqlite3": ("opentelemetry-instrumentation-sqlite3",), + "starlette": ("opentelemetry-instrumentation-starlette",), + "tornado": ("opentelemetry-instrumentation-tornado",), + "urllib": ("opentelemetry-instrumentation-urllib",), +} + + +def _install_package(library, instrumentation): + """ + Ensures that desired version is installed w/o upgrading its dependencies + by uninstalling where necessary (if `target` is not provided). + + + OpenTelemetry auto-instrumentation packages often have traced libraries + as instrumentation dependency (e.g. flask for + opentelemetry-instrumentation-flask), so using -I on library could cause + likely undesired Flask upgrade.Using --no-dependencies alone would leave + potential for nonfunctional installations. + """ + pip_list = _sys_pip_freeze() + for package in libraries[library]: + if "{}==".format(package).lower() in pip_list: + logger.info( + "Existing %s installation detected. Uninstalling.", package + ) + _sys_pip_uninstall(package) + _sys_pip_install(instrumentation) + + +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_freeze(): + return ( + subprocess.check_output([sys.executable, "-m", "pip", "freeze"]) + .decode() + .lower() + ) + + +@_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, + ] + ) + + +@_syscall +def _sys_pip_uninstall(package): + subprocess.check_call( + [sys.executable, "-m", "pip", "uninstall", "-y", 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(library): + return library in sys.modules or pkgutil.find_loader(library) is not None + + +def _find_installed_libraries(): + return {k: v for k, v in instrumentations.items() if _is_installed(k)} + + +def _run_requirements(packages): + print("\n".join(packages.values()), end="") + + +def _run_install(packages): + for pkg, inst in packages.items(): + _install_package(pkg, inst) + + _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(_find_installed_libraries()) diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/configurator.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/configurator.py new file mode 100644 index 0000000000..3efa71e89e --- /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/distro.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/distro.py new file mode 100644 index 0000000000..63cacf1f6d --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/distro.py @@ -0,0 +1,47 @@ +# 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 + +_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) + + +__all__ = ["BaseDistro"] diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/instrumentor.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/instrumentor.py new file mode 100644 index 0000000000..8cd77a8a5b --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/instrumentor.py @@ -0,0 +1,94 @@ +# 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 + +_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 = False + + def __new__(cls, *args, **kwargs): + + if cls._instance is None: + cls._instance = object.__new__(cls, *args, **kwargs) + + return cls._instance + + @abstractmethod + def _instrument(self, **kwargs): + """Instrument the library""" + + @abstractmethod + def _uninstrument(self, **kwargs): + """Uninstrument the library""" + + 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 not self._is_instrumented: + result = self._instrument(**kwargs) + self._is_instrumented = True + return result + + _LOG.warning("Attempting to instrument while already instrumented") + + return None + + def uninstrument(self, **kwargs): + """Uninstrument the library + + See ``BaseInstrumentor.instrument`` for more information regarding the + usage of ``kwargs``. + """ + + if self._is_instrumented: + result = self._uninstrument(**kwargs) + self._is_instrumented = 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 0000000000..2fb246c9d9 --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/propagators.py @@ -0,0 +1,128 @@ +# 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 + +import opentelemetry.trace as 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 0000000000..e69de29bb2 diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/utils.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/utils.py new file mode 100644 index 0000000000..dec070570f --- /dev/null +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/utils.py @@ -0,0 +1,62 @@ +# 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.trace import StatusCode + + +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 0000000000..2b08175266 --- /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.21.dev0" diff --git a/opentelemetry-instrumentation/tests/__init__.py b/opentelemetry-instrumentation/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/opentelemetry-instrumentation/tests/test_bootstrap.py b/opentelemetry-instrumentation/tests/test_bootstrap.py new file mode 100644 index 0000000000..de978eb6d5 --- /dev/null +++ b/opentelemetry-instrumentation/tests/test_bootstrap.py @@ -0,0 +1,128 @@ +# 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 functools import reduce +from io import StringIO +from random import sample +from unittest import TestCase +from unittest.mock import call, patch + +from opentelemetry.instrumentation import bootstrap + + +def sample_packages(packages, rate): + sampled = sample( + list(packages), + int(len(packages) * rate), + ) + return {k: v for k, v in packages.items() if k in sampled} + + +class TestBootstrap(TestCase): + + installed_libraries = {} + installed_instrumentations = {} + + @classmethod + def setUpClass(cls): + # select random 60% of instrumentations + cls.installed_libraries = sample_packages( + bootstrap.instrumentations, 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, + ) + + pip_freeze_output = [] + for inst in cls.installed_instrumentations.values(): + inst = inst.replace(">=", "==") + if "==" not in inst: + inst = "{}==x.y".format(inst) + pip_freeze_output.append(inst) + + cls.pip_freeze_patcher = patch( + "opentelemetry.instrumentation.bootstrap._sys_pip_freeze", + return_value="\n".join(pip_freeze_output), + ) + cls.pip_install_patcher = patch( + "opentelemetry.instrumentation.bootstrap._sys_pip_install", + ) + cls.pip_uninstall_patcher = patch( + "opentelemetry.instrumentation.bootstrap._sys_pip_uninstall", + ) + cls.pip_check_patcher = patch( + "opentelemetry.instrumentation.bootstrap._pip_check", + ) + + cls.pkg_patcher.start() + cls.mock_pip_freeze = cls.pip_freeze_patcher.start() + cls.mock_pip_install = cls.pip_install_patcher.start() + cls.mock_pip_uninstall = cls.pip_uninstall_patcher.start() + cls.mock_pip_check = cls.pip_check_patcher.start() + + @classmethod + def tearDownClass(cls): + cls.pip_check_patcher.start() + cls.pip_uninstall_patcher.start() + cls.pip_install_patcher.start() + cls.pip_freeze_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.values()), + ) + + @patch("sys.argv", ["bootstrap", "-a", "install"]) + def test_run_cmd_install(self): + bootstrap.run() + + self.assertEqual( + self.mock_pip_freeze.call_count, len(self.installed_libraries) + ) + + to_uninstall = reduce( + lambda x, y: x + y, + [ + pkgs + for lib, pkgs in bootstrap.libraries.items() + if lib in self.installed_instrumentations + ], + ) + self.mock_pip_uninstall.assert_has_calls( + [call(i) for i in to_uninstall], any_order=True + ) + + self.mock_pip_install.assert_has_calls( + [call(i) for i in self.installed_libraries.values()], + any_order=True, + ) + self.assertEqual(self.mock_pip_check.call_count, 1) diff --git a/opentelemetry-instrumentation/tests/test_instrumentor.py b/opentelemetry-instrumentation/tests/test_instrumentor.py new file mode 100644 index 0000000000..19104a3246 --- /dev/null +++ b/opentelemetry-instrumentation/tests/test_instrumentor.py @@ -0,0 +1,47 @@ +# 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 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 0000000000..62461aafa9 --- /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 0000000000..01bd86ed32 --- /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 0000000000..e5246335c9 --- /dev/null +++ b/opentelemetry-instrumentation/tests/test_utils.py @@ -0,0 +1,57 @@ +# 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 255b3334e5..a5a44afc2a 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -16,7 +16,7 @@ DISTDIR=dist mkdir -p $DISTDIR rm -rf $DISTDIR/* - for d in exporter/*/ instrumentation/*/ propagator/*/ sdk-extension/*/ util/*/ ; do + for d in exporter/*/ instrumentation/*/ opentelemetry-instrumentation/*/ propagator/*/ sdk-extension/*/ util/*/ ; do ( echo "building $d" cd "$d" diff --git a/tox.ini b/tox.ini index 3aa5859317..99cf0eeed8 100644 --- a/tox.ini +++ b/tox.ini @@ -5,6 +5,10 @@ envlist = ; Environments are organized by individual package, allowing ; for specifying supported Python versions per package. + ; opentelemetry-instrumentation + py3{6,7,8,9}-test-instrumentation-base + pypy3-test-instrumentation-base + ; opentelemetry-sdk-extension-aws py3{6,7,8}-test-sdkextension-aws pypy3-test-sdkextension-aws @@ -172,6 +176,7 @@ deps = ; FIXME: add mypy testing changedir = + test-instrumentation-base: opentelemetry-instrumentation/tests test-instrumentation-aiohttp-client: instrumentation/opentelemetry-instrumentation-aiohttp-client/tests test-instrumentation-aiopg: instrumentation/opentelemetry-instrumentation-aiopg/tests test-instrumentation-asgi: instrumentation/opentelemetry-instrumentation-asgi/tests @@ -217,10 +222,11 @@ commands_pre = ; cases but it saves a lot of boilerplate in this file. test: pip install {toxinidir}/opentelemetry-python-core/opentelemetry-api test: pip install {toxinidir}/opentelemetry-python-core/opentelemetry-semantic-conventions - test: pip install {toxinidir}/opentelemetry-python-core/opentelemetry-instrumentation test: pip install {toxinidir}/opentelemetry-python-core/opentelemetry-sdk test: pip install {toxinidir}/opentelemetry-python-core/tests/util + test: pip install {toxinidir}/opentelemetry-instrumentation + celery: pip install {toxinidir}/instrumentation/opentelemetry-instrumentation-celery[test] grpc: pip install {toxinidir}/instrumentation/opentelemetry-instrumentation-grpc[test] @@ -286,7 +292,7 @@ commands_pre = sqlalchemy: pip install {toxinidir}/instrumentation/opentelemetry-instrumentation-sqlalchemy - elasticsearch{2,5,6,7}: pip install {toxinidir}/opentelemetry-python-core/opentelemetry-instrumentation {toxinidir}/instrumentation/opentelemetry-instrumentation-elasticsearch[test] + elasticsearch{2,5,6,7}: pip install {toxinidir}/instrumentation/opentelemetry-instrumentation-elasticsearch[test] aws: pip install requests {toxinidir}/sdk-extension/opentelemetry-sdk-extension-aws @@ -298,6 +304,7 @@ commands_pre = coverage: python {toxinidir}/scripts/eachdist.py install --editable commands = + test: pwd test: pytest {posargs} coverage: {toxinidir}/scripts/coverage.sh @@ -311,7 +318,7 @@ commands_pre = python -m pip install {toxinidir}/opentelemetry-python-core/opentelemetry-api python -m pip install {toxinidir}/opentelemetry-python-core/opentelemetry-semantic-conventions python -m pip install {toxinidir}/opentelemetry-python-core/opentelemetry-sdk - python -m pip install {toxinidir}/opentelemetry-python-core/opentelemetry-instrumentation + python -m pip install {toxinidir}/opentelemetry-instrumentation python -m pip install {toxinidir}/util/opentelemetry-util-http changedir = docs @@ -337,10 +344,10 @@ commands_pre = sudo apt-get install libsnappy-dev python -m pip install {toxinidir}/opentelemetry-python-core/opentelemetry-api python -m pip install {toxinidir}/opentelemetry-python-core/opentelemetry-semantic-conventions - python -m pip install {toxinidir}/opentelemetry-python-core/opentelemetry-instrumentation python -m pip install {toxinidir}/opentelemetry-python-core/opentelemetry-sdk python -m pip install {toxinidir}/opentelemetry-python-core/tests/util - python -m pip install {toxinidir}/util/opentelemetry-util-http + python -m pip install -e {toxinidir}/opentelemetry-instrumentation[test] + python -m pip install -e {toxinidir}/util/opentelemetry-util-http[test] python -m pip install -e {toxinidir}/instrumentation/opentelemetry-instrumentation-wsgi[test] python -m pip install -e {toxinidir}/instrumentation/opentelemetry-instrumentation-dbapi[test] python -m pip install -e {toxinidir}/instrumentation/opentelemetry-instrumentation-asgi[test] @@ -378,7 +385,7 @@ commands_pre = python -m pip install -e {toxinidir}/propagator/opentelemetry-propagator-ot-trace[test] commands = - python scripts/eachdist.py lint --check-only + python scripts/eachdist.py lint [testenv:docker-tests] deps = @@ -402,9 +409,9 @@ changedir = commands_pre = pip install -e {toxinidir}/opentelemetry-python-core/opentelemetry-api \ -e {toxinidir}/opentelemetry-python-core/opentelemetry-semantic-conventions \ - -e {toxinidir}/opentelemetry-python-core/opentelemetry-instrumentation \ -e {toxinidir}/opentelemetry-python-core/opentelemetry-sdk \ -e {toxinidir}/opentelemetry-python-core/tests/util \ + -e {toxinidir}/opentelemetry-instrumentation \ -e {toxinidir}/instrumentation/opentelemetry-instrumentation-asyncpg \ -e {toxinidir}/instrumentation/opentelemetry-instrumentation-celery \ -e {toxinidir}/instrumentation/opentelemetry-instrumentation-dbapi \