Distro selection (#1823)

This commit is contained in:
Jeremy Voss
2023-07-11 13:26:14 -07:00
committed by GitHub
parent dadcd01524
commit db90ce38a2
7 changed files with 461 additions and 91 deletions

View File

@ -37,6 +37,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
([#1810](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1810)) ([#1810](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1810))
- `opentelemetry-instrumentation-urllib3` Add support for urllib3 version 2 - `opentelemetry-instrumentation-urllib3` Add support for urllib3 version 2
([#1879](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1879)) ([#1879](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1879))
- Add optional distro and configurator selection for auto-instrumentation
([#1823](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1823))
## Version 1.18.0/0.39b0 (2023-05-10) ## Version 1.18.0/0.39b0 (2023-05-10)
@ -44,7 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add request and response hooks for GRPC instrumentation (client only) - Add request and response hooks for GRPC instrumentation (client only)
([#1706](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1706)) ([#1706](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1706))
- Fix memory leak in SQLAlchemy instrumentation where disposed `Engine` does not get garbage collected - Fix memory leak in SQLAlchemy instrumentation where disposed `Engine` does not get garbage collected
([#1771](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1771) ([#1771](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1771))
- `opentelemetry-instrumentation-pymemcache` Update instrumentation to support pymemcache >4 - `opentelemetry-instrumentation-pymemcache` Update instrumentation to support pymemcache >4
([#1764](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1764)) ([#1764](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1764))
- `opentelemetry-instrumentation-confluent-kafka` Add support for higher versions of confluent_kafka - `opentelemetry-instrumentation-confluent-kafka` Add support for higher versions of confluent_kafka
@ -86,7 +88,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Update HTTP server/client instrumentation span names to comply with spec - Update HTTP server/client instrumentation span names to comply with spec
([#1759](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1759) ([#1759](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1759))
## Version 1.17.0/0.38b0 (2023-03-22) ## Version 1.17.0/0.38b0 (2023-03-22)

View File

@ -18,12 +18,15 @@ This package provides a couple of commands that help automatically instruments a
.. note:: .. note::
You need to install a distro package to get auto instrumentation working. The ``opentelemetry-distro`` You need to install a distro package to get auto instrumentation working. The ``opentelemetry-distro``
package contains the default distro and automatically configures some of the common options for users. package contains the default distro and configurator and automatically configures some of the common options for users.
For more info about ``opentelemetry-distro`` check `here <https://opentelemetry-python.readthedocs.io/en/latest/examples/distro/README.html>`__ For more info about ``opentelemetry-distro`` check `here <https://opentelemetry-python.readthedocs.io/en/latest/examples/distro/README.html>`__
:: ::
pip install opentelemetry-distro[otlp] pip install opentelemetry-distro[otlp]
When creating a custom distro and/or configurator, be sure to add entry points for each under `opentelemetry_distro` and `opentelemetry_configurator` respectfully.
If you have entry points for multiple distros or configurators present in your environment, you should specify the entry point name of the distro and configurator you want to be used via the `OTEL_PYTHON_DISTRO` and `OTEL_PYTHON_CONFIGURATOR` environment variables.
opentelemetry-bootstrap opentelemetry-bootstrap
----------------------- -----------------------
@ -58,6 +61,8 @@ The command supports the following configuration options as CLI arguments and en
* ``--traces_exporter`` or ``OTEL_TRACES_EXPORTER`` * ``--traces_exporter`` or ``OTEL_TRACES_EXPORTER``
* ``--metrics_exporter`` or ``OTEL_METRICS_EXPORTER`` * ``--metrics_exporter`` or ``OTEL_METRICS_EXPORTER``
* ``--distro`` or ``OTEL_PYTHON_DISTRO``
* ``--configurator`` or ``OTEL_PYTHON_CONFIGURATOR``
Used to specify which trace exporter to use. Can be set to one or more of the well-known exporter Used to specify which trace exporter to use. Can be set to one or more of the well-known exporter
names (see below). names (see below).

View File

@ -0,0 +1,124 @@
# Copyright The OpenTelemetry Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from logging import getLogger
from os import environ
from pkg_resources import iter_entry_points
from opentelemetry.instrumentation.dependencies import (
get_dist_dependency_conflicts,
)
from opentelemetry.instrumentation.distro import BaseDistro, DefaultDistro
from opentelemetry.instrumentation.environment_variables import (
OTEL_PYTHON_CONFIGURATOR,
OTEL_PYTHON_DISABLED_INSTRUMENTATIONS,
OTEL_PYTHON_DISTRO,
)
from opentelemetry.instrumentation.version import __version__
_logger = getLogger(__name__)
def _load_distro() -> BaseDistro:
distro_name = environ.get(OTEL_PYTHON_DISTRO, None)
for entry_point in iter_entry_points("opentelemetry_distro"):
try:
# If no distro is specified, use first to come up.
if distro_name is None or distro_name == entry_point.name:
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_pre_instrument"):
entry_point.load()()
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
for entry_point in iter_entry_points("opentelemetry_post_instrument"):
entry_point.load()()
def _load_configurators():
configurator_name = environ.get(OTEL_PYTHON_CONFIGURATOR, None)
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:
if (
configurator_name is None
or configurator_name == entry_point.name
):
entry_point.load()().configure(auto_instrumentation_version=__version__) # type: ignore
configured = entry_point.name
else:
_logger.warning(
"Configuration of %s not loaded because %s is set by %s",
entry_point.name,
configurator_name,
OTEL_PYTHON_CONFIGURATOR,
)
except Exception as exc: # pylint: disable=broad-except
_logger.exception("Configuration of %s failed", entry_point.name)
raise exc

View File

@ -16,99 +16,16 @@ from logging import getLogger
from os import environ from os import environ
from os.path import abspath, dirname, pathsep from os.path import abspath, dirname, pathsep
from pkg_resources import iter_entry_points from opentelemetry.instrumentation.auto_instrumentation._load import (
_load_configurators,
from opentelemetry.instrumentation.dependencies import ( _load_distro,
get_dist_dependency_conflicts, _load_instrumentors,
)
from opentelemetry.instrumentation.distro import BaseDistro, DefaultDistro
from opentelemetry.instrumentation.environment_variables import (
OTEL_PYTHON_DISABLED_INSTRUMENTATIONS,
) )
from opentelemetry.instrumentation.utils import _python_path_without_directory from opentelemetry.instrumentation.utils import _python_path_without_directory
from opentelemetry.instrumentation.version import __version__
logger = getLogger(__name__) logger = getLogger(__name__)
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_pre_instrument"):
entry_point.load()()
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
for entry_point in iter_entry_points("opentelemetry_post_instrument"):
entry_point.load()()
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(auto_instrumentation_version=__version__) # 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(): def initialize():
# prevents auto-instrumentation of subprocesses if code execs another python process # prevents auto-instrumentation of subprocesses if code execs another python process
environ["PYTHONPATH"] = _python_path_without_directory( environ["PYTHONPATH"] = _python_path_without_directory(
@ -116,7 +33,7 @@ def initialize():
) )
try: try:
distro = _load_distros() distro = _load_distro()
distro.configure() distro.configure()
_load_configurators() _load_configurators()
_load_instrumentors(distro) _load_instrumentors(distro)

View File

@ -16,3 +16,13 @@ OTEL_PYTHON_DISABLED_INSTRUMENTATIONS = "OTEL_PYTHON_DISABLED_INSTRUMENTATIONS"
""" """
.. envvar:: OTEL_PYTHON_DISABLED_INSTRUMENTATIONS .. envvar:: OTEL_PYTHON_DISABLED_INSTRUMENTATIONS
""" """
OTEL_PYTHON_DISTRO = "OTEL_PYTHON_DISTRO"
"""
.. envvar:: OTEL_PYTHON_DISTRO
"""
OTEL_PYTHON_CONFIGURATOR = "OTEL_PYTHON_CONFIGURATOR"
"""
.. envvar:: OTEL_PYTHON_CONFIGURATOR
"""

View File

@ -0,0 +1,312 @@
# 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 unittest.mock import Mock, call, patch
from opentelemetry.instrumentation.auto_instrumentation import _load
from opentelemetry.instrumentation.environment_variables import (
OTEL_PYTHON_CONFIGURATOR,
OTEL_PYTHON_DISABLED_INSTRUMENTATIONS,
OTEL_PYTHON_DISTRO,
)
from opentelemetry.instrumentation.version import __version__
class TestLoad(TestCase):
@patch.dict(
"os.environ", {OTEL_PYTHON_CONFIGURATOR: "custom_configurator2"}
)
@patch(
"opentelemetry.instrumentation.auto_instrumentation._load.iter_entry_points"
)
def test_load_configurators(self, iter_mock):
# Add multiple entry points but only specify the 2nd in the environment variable.
ep_mock1 = Mock()
ep_mock1.name = "custom_configurator1"
configurator_mock1 = Mock()
ep_mock1.load.return_value = configurator_mock1
ep_mock2 = Mock()
ep_mock2.name = "custom_configurator2"
configurator_mock2 = Mock()
ep_mock2.load.return_value = configurator_mock2
ep_mock3 = Mock()
ep_mock3.name = "custom_configurator3"
configurator_mock3 = Mock()
ep_mock3.load.return_value = configurator_mock3
iter_mock.return_value = (ep_mock1, ep_mock2, ep_mock3)
_load._load_configurators()
configurator_mock1.assert_not_called()
configurator_mock2().configure.assert_called_once_with(
auto_instrumentation_version=__version__
)
configurator_mock3.assert_not_called()
@patch.dict(
"os.environ", {OTEL_PYTHON_CONFIGURATOR: "custom_configurator2"}
)
@patch(
"opentelemetry.instrumentation.auto_instrumentation._load.iter_entry_points"
)
def test_load_configurators_no_ep(
self,
iter_mock,
):
iter_mock.return_value = ()
# Confirm method does not crash if not entry points exist.
_load._load_configurators()
@patch.dict(
"os.environ", {OTEL_PYTHON_CONFIGURATOR: "custom_configurator2"}
)
@patch(
"opentelemetry.instrumentation.auto_instrumentation._load.iter_entry_points"
)
def test_load_configurators_error(self, iter_mock):
# Add multiple entry points but only specify the 2nd in the environment variable.
ep_mock1 = Mock()
ep_mock1.name = "custom_configurator1"
configurator_mock1 = Mock()
ep_mock1.load.return_value = configurator_mock1
ep_mock2 = Mock()
ep_mock2.name = "custom_configurator2"
configurator_mock2 = Mock()
configurator_mock2().configure.side_effect = Exception()
ep_mock2.load.return_value = configurator_mock2
ep_mock3 = Mock()
ep_mock3.name = "custom_configurator3"
configurator_mock3 = Mock()
ep_mock3.load.return_value = configurator_mock3
iter_mock.return_value = (ep_mock1, ep_mock2, ep_mock3)
# Confirm failed configuration raises exception.
self.assertRaises(Exception, _load._load_configurators)
@patch.dict("os.environ", {OTEL_PYTHON_DISTRO: "custom_distro2"})
@patch(
"opentelemetry.instrumentation.auto_instrumentation._load.isinstance"
)
@patch(
"opentelemetry.instrumentation.auto_instrumentation._load.iter_entry_points"
)
def test_load_distro(self, iter_mock, isinstance_mock):
# Add multiple entry points but only specify the 2nd in the environment variable.
ep_mock1 = Mock()
ep_mock1.name = "custom_distro1"
distro_mock1 = Mock()
ep_mock1.load.return_value = distro_mock1
ep_mock2 = Mock()
ep_mock2.name = "custom_distro2"
distro_mock2 = Mock()
ep_mock2.load.return_value = distro_mock2
ep_mock3 = Mock()
ep_mock3.name = "custom_distro3"
distro_mock3 = Mock()
ep_mock3.load.return_value = distro_mock3
iter_mock.return_value = (ep_mock1, ep_mock2, ep_mock3)
# Mock entry points to be instances of BaseDistro.
isinstance_mock.return_value = True
self.assertEqual(
_load._load_distro(),
distro_mock2(),
)
@patch.dict("os.environ", {OTEL_PYTHON_DISTRO: "custom_distro2"})
@patch(
"opentelemetry.instrumentation.auto_instrumentation._load.isinstance"
)
@patch(
"opentelemetry.instrumentation.auto_instrumentation._load.DefaultDistro"
)
@patch(
"opentelemetry.instrumentation.auto_instrumentation._load.iter_entry_points"
)
def test_load_distro_not_distro(
self, iter_mock, default_distro_mock, isinstance_mock
):
# Add multiple entry points but only specify the 2nd in the environment variable.
ep_mock1 = Mock()
ep_mock1.name = "custom_distro1"
distro_mock1 = Mock()
ep_mock1.load.return_value = distro_mock1
ep_mock2 = Mock()
ep_mock2.name = "custom_distro2"
distro_mock2 = Mock()
ep_mock2.load.return_value = distro_mock2
ep_mock3 = Mock()
ep_mock3.name = "custom_distro3"
distro_mock3 = Mock()
ep_mock3.load.return_value = distro_mock3
iter_mock.return_value = (ep_mock1, ep_mock2, ep_mock3)
# Confirm default distro is used if specified entry point is not a BaseDistro
isinstance_mock.return_value = False
self.assertEqual(
_load._load_distro(),
default_distro_mock(),
)
@patch.dict("os.environ", {OTEL_PYTHON_DISTRO: "custom_distro2"})
@patch(
"opentelemetry.instrumentation.auto_instrumentation._load.DefaultDistro"
)
@patch(
"opentelemetry.instrumentation.auto_instrumentation._load.iter_entry_points"
)
def test_load_distro_no_ep(self, iter_mock, default_distro_mock):
iter_mock.return_value = ()
# Confirm default distro is used if there are no entry points.
self.assertEqual(
_load._load_distro(),
default_distro_mock(),
)
@patch.dict("os.environ", {OTEL_PYTHON_DISTRO: "custom_distro2"})
@patch(
"opentelemetry.instrumentation.auto_instrumentation._load.isinstance"
)
@patch(
"opentelemetry.instrumentation.auto_instrumentation._load.iter_entry_points"
)
def test_load_distro_error(self, iter_mock, isinstance_mock):
ep_mock1 = Mock()
ep_mock1.name = "custom_distro1"
distro_mock1 = Mock()
ep_mock1.load.return_value = distro_mock1
ep_mock2 = Mock()
ep_mock2.name = "custom_distro2"
distro_mock2 = Mock()
distro_mock2.side_effect = Exception()
ep_mock2.load.return_value = distro_mock2
ep_mock3 = Mock()
ep_mock3.name = "custom_distro3"
distro_mock3 = Mock()
ep_mock3.load.return_value = distro_mock3
iter_mock.return_value = (ep_mock1, ep_mock2, ep_mock3)
isinstance_mock.return_value = True
# Confirm method raises exception if it fails to load a distro.
self.assertRaises(Exception, _load._load_distro)
@patch.dict(
"os.environ",
{OTEL_PYTHON_DISABLED_INSTRUMENTATIONS: " instr1 , instr3 "},
)
@patch(
"opentelemetry.instrumentation.auto_instrumentation._load.get_dist_dependency_conflicts"
)
@patch(
"opentelemetry.instrumentation.auto_instrumentation._load.iter_entry_points"
)
def test_load_instrumentors(self, iter_mock, dep_mock):
# Mock opentelemetry_pre_instrument entry points
pre_ep_mock1 = Mock()
pre_ep_mock1.name = "pre1"
pre_mock1 = Mock()
pre_ep_mock1.load.return_value = pre_mock1
pre_ep_mock2 = Mock()
pre_ep_mock2.name = "pre2"
pre_mock2 = Mock()
pre_ep_mock2.load.return_value = pre_mock2
# Mock opentelemetry_instrumentor entry points
ep_mock1 = Mock()
ep_mock1.name = "instr1"
ep_mock2 = Mock()
ep_mock2.name = "instr2"
ep_mock3 = Mock()
ep_mock3.name = "instr3"
ep_mock4 = Mock()
ep_mock4.name = "instr4"
# Mock opentelemetry_instrumentor entry points
post_ep_mock1 = Mock()
post_ep_mock1.name = "post1"
post_mock1 = Mock()
post_ep_mock1.load.return_value = post_mock1
post_ep_mock2 = Mock()
post_ep_mock2.name = "post2"
post_mock2 = Mock()
post_ep_mock2.load.return_value = post_mock2
distro_mock = Mock()
# Mock entry points in order
iter_mock.side_effect = [
(pre_ep_mock1, pre_ep_mock2),
(ep_mock1, ep_mock2, ep_mock3, ep_mock4),
(post_ep_mock1, post_ep_mock2),
]
# No dependency conflict
dep_mock.return_value = None
_load._load_instrumentors(distro_mock)
# All opentelemetry_pre_instrument entry points should be loaded
pre_mock1.assert_called_once()
pre_mock2.assert_called_once()
self.assertEqual(iter_mock.call_count, 3)
# Only non-disabled instrumentations should be loaded
distro_mock.load_instrumentor.assert_has_calls(
[
call(ep_mock2, skip_dep_check=True),
call(ep_mock4, skip_dep_check=True),
]
)
self.assertEqual(distro_mock.load_instrumentor.call_count, 2)
# All opentelemetry_post_instrument entry points should be loaded
post_mock1.assert_called_once()
post_mock2.assert_called_once()
@patch.dict(
"os.environ",
{OTEL_PYTHON_DISABLED_INSTRUMENTATIONS: " instr1 , instr3 "},
)
@patch(
"opentelemetry.instrumentation.auto_instrumentation._load.get_dist_dependency_conflicts"
)
@patch(
"opentelemetry.instrumentation.auto_instrumentation._load.iter_entry_points"
)
def test_load_instrumentors_dep_conflict(self, iter_mock, dep_mock):
ep_mock1 = Mock()
ep_mock1.name = "instr1"
ep_mock2 = Mock()
ep_mock2.name = "instr2"
ep_mock3 = Mock()
ep_mock3.name = "instr3"
ep_mock4 = Mock()
ep_mock4.name = "instr4"
distro_mock = Mock()
iter_mock.return_value = (ep_mock1, ep_mock2, ep_mock3, ep_mock4)
# If a dependency conflict is raised, that instrumentation should not be loaded, but others still should.
dep_mock.side_effect = [None, "DependencyConflict"]
_load._load_instrumentors(distro_mock)
distro_mock.load_instrumentor.assert_has_calls(
[
call(ep_mock2, skip_dep_check=True),
]
)
distro_mock.load_instrumentor.assert_called_once()