Files
Emídio Neto 9c969f363e autoinstrumentation: catch ModuleNotFoundError when the library is not installed (#3423)
* catch ModuleNotFoundError when the library is not installed and prevent exception from bubbling up

Signed-off-by: emdneto <9735060+emdneto@users.noreply.github.com>

* cleanup

Signed-off-by: emdneto <9735060+emdneto@users.noreply.github.com>

* remove dup test

Signed-off-by: emdneto <9735060+emdneto@users.noreply.github.com>

* Update CHANGELOG.md

---------

Signed-off-by: emdneto <9735060+emdneto@users.noreply.github.com>
2025-04-15 09:43:13 +02:00

437 lines
15 KiB
Python

# Copyright The OpenTelemetry Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# type: ignore
from unittest import TestCase
from unittest.mock import Mock, call, patch
from opentelemetry.instrumentation.auto_instrumentation import _load
from opentelemetry.instrumentation.dependencies import (
DependencyConflict,
DependencyConflictError,
)
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.entry_points"
)
def test_load_configurators(self, iter_mock): # pylint: disable=no-self-use
# 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.entry_points"
)
def test_load_configurators_no_ep(self, iter_mock): # pylint: disable=no-self-use
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.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.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.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.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.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)
@staticmethod
def _instrumentation_failed_to_load_call(entry_point, dependency_conflict):
return call(
"Skipping instrumentation %s: %s", entry_point, dependency_conflict
)
@patch.dict(
"os.environ",
{OTEL_PYTHON_DISABLED_INSTRUMENTATIONS: " instr1 , instr3 "},
)
@patch(
"opentelemetry.instrumentation.auto_instrumentation._load.entry_points"
)
def test_load_instrumentors(self, iter_mock):
# Mock opentelemetry_pre_instrument entry points
# pylint: disable=too-many-locals
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),
]
_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, raise_exception_on_conflict=True),
call(ep_mock4, raise_exception_on_conflict=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._logger")
@patch(
"opentelemetry.instrumentation.auto_instrumentation._load.entry_points"
)
def test_load_instrumentors_dep_conflict(self, iter_mock, mock_logger): # pylint: disable=no-self-use
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"
dependency_conflict = DependencyConflict("1.2.3", None)
distro_mock = Mock()
distro_mock.load_instrumentor.side_effect = [
None,
DependencyConflictError(dependency_conflict),
]
# If a dependency conflict is raised, that instrumentation should not be loaded, but others still should.
# In this case, ep_mock4 will fail to load and ep_mock2 will succeed. (ep_mock1 and ep_mock3 are disabled)
iter_mock.return_value = (ep_mock1, ep_mock2, ep_mock3, ep_mock4)
_load._load_instrumentors(distro_mock)
distro_mock.load_instrumentor.assert_has_calls(
[
call(ep_mock2, raise_exception_on_conflict=True),
call(ep_mock4, raise_exception_on_conflict=True),
]
)
assert distro_mock.load_instrumentor.call_count == 2
mock_logger.debug.assert_has_calls(
[
self._instrumentation_failed_to_load_call(
ep_mock4.name, dependency_conflict
)
]
)
@patch("opentelemetry.instrumentation.auto_instrumentation._load._logger")
@patch(
"opentelemetry.instrumentation.auto_instrumentation._load.entry_points"
)
def test_load_instrumentors_import_error_does_not_stop_everything(
self, iter_mock, mock_logger
):
ep_mock1 = Mock(name="instr1")
ep_mock2 = Mock(name="instr2")
distro_mock = Mock()
distro_mock.load_instrumentor.side_effect = [ImportError, None]
# Mock entry points in order
iter_mock.side_effect = [
(),
(ep_mock1, ep_mock2),
(),
]
_load._load_instrumentors(distro_mock)
distro_mock.load_instrumentor.assert_has_calls(
[
call(ep_mock1, raise_exception_on_conflict=True),
call(ep_mock2, raise_exception_on_conflict=True),
]
)
self.assertEqual(distro_mock.load_instrumentor.call_count, 2)
mock_logger.exception.assert_any_call(
"Importing of %s failed, skipping it",
ep_mock1.name,
)
mock_logger.debug.assert_any_call("Instrumented %s", ep_mock2.name)
@patch(
"opentelemetry.instrumentation.auto_instrumentation._load.entry_points"
)
def test_load_instrumentors_raises_exception(self, iter_mock):
ep_mock1 = Mock(name="instr1")
ep_mock2 = Mock(name="instr2")
distro_mock = Mock()
distro_mock.load_instrumentor.side_effect = [ValueError, None]
# Mock entry points in order
iter_mock.side_effect = [
(),
(ep_mock1, ep_mock2),
(),
]
with self.assertRaises(ValueError):
_load._load_instrumentors(distro_mock)
distro_mock.load_instrumentor.assert_has_calls(
[
call(ep_mock1, raise_exception_on_conflict=True),
]
)
self.assertEqual(distro_mock.load_instrumentor.call_count, 1)
@patch("opentelemetry.instrumentation.auto_instrumentation._load._logger")
@patch(
"opentelemetry.instrumentation.auto_instrumentation._load.entry_points"
)
def test_load_instrumentors_module_not_found_error(
self, iter_mock, mock_logger
):
ep_mock1 = Mock()
ep_mock1.name = "instr1"
ep_mock2 = Mock()
ep_mock2.name = "instr2"
distro_mock = Mock()
distro_mock.load_instrumentor.side_effect = [
ModuleNotFoundError("No module named 'fake_module'"),
None,
]
iter_mock.side_effect = [(), (ep_mock1, ep_mock2), ()]
_load._load_instrumentors(distro_mock)
distro_mock.load_instrumentor.assert_has_calls(
[
call(ep_mock1, raise_exception_on_conflict=True),
call(ep_mock2, raise_exception_on_conflict=True),
]
)
self.assertEqual(distro_mock.load_instrumentor.call_count, 2)
mock_logger.debug.assert_any_call(
"Skipping instrumentation %s: %s",
"instr1",
"No module named 'fake_module'",
)
mock_logger.debug.assert_any_call("Instrumented %s", ep_mock2.name)
def test_load_instrumentors_no_entry_point_mocks(self):
distro_mock = Mock()
_load._load_instrumentors(distro_mock)
# this has no specific assert because it is run for every instrumentation
self.assertTrue(distro_mock)