diff --git a/.changelog/5653.added b/.changelog/5653.added new file mode 100644 index 00000000000..cf9aec0f024 --- /dev/null +++ b/.changelog/5653.added @@ -0,0 +1 @@ +`opentelemetry-sdk`: add `host.id` to the host resource detector diff --git a/docs/sdk/resources.rst b/docs/sdk/resources.rst index 08732ac0253..a5279f8c402 100644 --- a/docs/sdk/resources.rst +++ b/docs/sdk/resources.rst @@ -1,6 +1,45 @@ opentelemetry.sdk.resources package ========================================== +Host resource detection +----------------------- + +The host resource detector populates the attributes defined by the +`host resource semantic conventions +`_. + +Enable the host resource detector by setting +:envvar:`OTEL_EXPERIMENTAL_RESOURCE_DETECTORS` before starting your application: + +.. code-block:: sh + + export OTEL_EXPERIMENTAL_RESOURCE_DETECTORS=host + +Resources created with :meth:`opentelemetry.sdk.resources.Resource.create` +will then include ``host.name``, ``host.arch``, and, when available, ``host.id``. +If you already configure other detectors, add ``host`` to the comma-separated +list. + +The detector obtains ``host.id`` using the sources listed for a +`non-privileged machine id lookup +`_: + +* Linux: ``/etc/machine-id``, falling back to ``/var/lib/dbus/machine-id``. +* BSD: ``/etc/hostid``, falling back to ``/bin/kenv -q smbios.system.uuid``. +* macOS: ``IOPlatformUUID`` from ``/usr/sbin/ioreg -rd1 -c IOPlatformExpertDevice``. +* Windows: ``MachineGuid`` from + ``HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Cryptography``, using the 64-bit registry + view. + +If the lookup fails or the operating system is unsupported, ``host.id`` is +omitted by default while ``host.name`` and ``host.arch`` are retained. You can +provide an explicit value through :envvar:`OTEL_RESOURCE_ATTRIBUTES`, for +example ``OTEL_RESOURCE_ATTRIBUTES=host.id=my-host-id``. With the detector order +shown above, this value takes precedence over the detected value. + +API +--- + .. automodule:: opentelemetry.sdk.resources :members: :undoc-members: diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py index e88121691e3..0ccbe1cfeaa 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py @@ -58,6 +58,7 @@ import os import platform import socket +import subprocess import sys import threading import uuid @@ -89,6 +90,16 @@ except ImportError: pass +# Only available on Windows, where it is used to read the MachineGuid for host.id. +winreg: ModuleType | None = None + +try: + import winreg as winreg_module + + winreg = winreg_module +except ImportError: + pass + LabelValue = AnyValue Attributes = Mapping[str, LabelValue] logger = logging.getLogger(__name__) @@ -107,6 +118,7 @@ FAAS_INSTANCE = ResourceAttributes.FAAS_INSTANCE HOST_NAME = ResourceAttributes.HOST_NAME HOST_ARCH = ResourceAttributes.HOST_ARCH +HOST_ID = ResourceAttributes.HOST_ID HOST_TYPE = ResourceAttributes.HOST_TYPE HOST_IMAGE_NAME = ResourceAttributes.HOST_IMAGE_NAME HOST_IMAGE_ID = ResourceAttributes.HOST_IMAGE_ID @@ -498,18 +510,131 @@ def detect(self) -> "Resource": ) +# Non-privileged machine id sources per the semantic conventions: +# https://opentelemetry.io/docs/specs/semconv/resource/host/#non-privileged-machine-id-lookup +_LINUX_MACHINE_ID_PATHS = ("/etc/machine-id", "/var/lib/dbus/machine-id") +_BSD_HOSTID_PATH = "/etc/hostid" +_BSD_KENV_COMMAND = ("/bin/kenv", "-q", "smbios.system.uuid") +_MACOS_IOREG_COMMAND = ("/usr/sbin/ioreg", "-rd1", "-c", "IOPlatformExpertDevice") +_WINDOWS_CRYPTOGRAPHY_KEY = r"SOFTWARE\Microsoft\Cryptography" +_WINDOWS_MACHINE_GUID_VALUE = "MachineGuid" +# Deliberately below get_aggregated_resources' per detector timeout so that a +# hung command still leaves time for host.name and host.arch to be returned. +_COMMAND_TIMEOUT_SECONDS = 2 + + +def _read_machine_id_file(path: str) -> str | None: + try: + with open(path, encoding="utf8") as machine_id_file: + return machine_id_file.read().strip() or None + except OSError as exception: + logger.debug("Failed to read %s: %s", path, exception) + return None + + +def _run_command(command: tuple[str, ...]) -> str: + """Returns the command's stdout, or "" when the source is unavailable here. + + A non-zero exit or a missing binary means this host has no machine id to + offer. + """ + try: + completed = subprocess.run( + command, + capture_output=True, + text=True, + timeout=_COMMAND_TIMEOUT_SECONDS, + check=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError) as exception: + logger.debug("Failed to run %s: %s", command[0], exception) + return "" + return completed.stdout + + +def _get_linux_machine_id() -> str | None: + for path in _LINUX_MACHINE_ID_PATHS: + machine_id = _read_machine_id_file(path) + if machine_id: + return machine_id + return None + + +def _get_bsd_machine_id() -> str | None: + return _read_machine_id_file(_BSD_HOSTID_PATH) or _run_command(_BSD_KENV_COMMAND).strip() or None + + +def _get_macos_machine_id() -> str | None: + for line in _run_command(_MACOS_IOREG_COMMAND).splitlines(): + # The line looks like: ` "IOPlatformUUID" = "AAAAAAAA-BBBB-..."` + key, separator, value = line.partition("=") + if not separator or key.strip().strip('"') != "IOPlatformUUID": + continue + + machine_id = value.strip().strip('"') + if machine_id: + return machine_id + return None + + +def _get_windows_machine_id() -> str | None: + if winreg is None: + logger.debug("winreg is unavailable, cannot detect %s", HOST_ID) + return None + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, + _WINDOWS_CRYPTOGRAPHY_KEY, + access=winreg.KEY_READ | winreg.KEY_WOW64_64KEY, + ) as key: + machine_guid, _ = winreg.QueryValueEx(key, _WINDOWS_MACHINE_GUID_VALUE) + return str(machine_guid) if machine_guid else None + + +def _get_host_id() -> str | None: + system = platform.system() + if system == "Linux": + return _get_linux_machine_id() + if system == "Darwin": + return _get_macos_machine_id() + if system == "Windows": + return _get_windows_machine_id() + if system == "DragonFly" or system.endswith("BSD"): + return _get_bsd_machine_id() + logger.debug("Unsupported OS type for %s detection: %s", HOST_ID, system) + return None + + class _HostResourceDetector(ResourceDetector): # type: ignore[reportUnusedClass] """ - The HostResourceDetector detects the hostname and architecture attributes. + The HostResourceDetector detects the hostname, architecture and host id + attributes. + + ``host.id`` is the non-privileged machine id described by the `Host resource + conventions `_, + and is omitted when it cannot be determined. A failed lookup does not + prevent ``host.name`` and ``host.arch`` from being detected unless + ``raise_on_error=True``. """ def detect(self) -> "Resource": - return Resource( - { - HOST_NAME: socket.gethostname(), - HOST_ARCH: platform.machine(), - } - ) + resource_info: dict[str, AnyValue] = { + HOST_NAME: socket.gethostname(), + HOST_ARCH: platform.machine(), + } + + # A failed host id lookup must not cost the caller the attributes above, + # so it is guarded here rather than relying on the handling in + # get_aggregated_resources: detect() is also called directly. + try: + if host_id := _get_host_id(): + resource_info[HOST_ID] = host_id + # pylint: disable=broad-exception-caught + except Exception as exception: + logger.warning("Failed to detect %s: %s", HOST_ID, exception) + if self.raise_on_error: + raise + + return Resource(resource_info) class ServiceInstanceIdResourceDetector(ResourceDetector): diff --git a/opentelemetry-sdk/tests/resources/test_resources.py b/opentelemetry-sdk/tests/resources/test_resources.py index cd9913bb740..8eb59ad9e43 100644 --- a/opentelemetry-sdk/tests/resources/test_resources.py +++ b/opentelemetry-sdk/tests/resources/test_resources.py @@ -4,6 +4,7 @@ # pylint: disable=too-many-lines import os +import platform import subprocess import sys import time @@ -12,7 +13,7 @@ from concurrent.futures import TimeoutError from logging import ERROR, WARNING from os import environ -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, call, mock_open, patch from urllib import parse import opentelemetry.sdk.resources as _resources_module @@ -20,10 +21,13 @@ OTEL_EXPERIMENTAL_RESOURCE_DETECTORS, ) from opentelemetry.sdk.resources import ( + _BSD_KENV_COMMAND, _DEFAULT_RESOURCE, _EMPTY_RESOURCE, + _LINUX_MACHINE_ID_PATHS, _OPENTELEMETRY_SDK_VERSION, HOST_ARCH, + HOST_ID, HOST_NAME, OS_TYPE, OS_VERSION, @@ -64,6 +68,11 @@ except ImportError: psutil = None +try: + import winreg +except ImportError: + winreg = None + class DefaultResourceDetector(ResourceDetector): def detect(self) -> Resource: @@ -995,7 +1004,30 @@ def test_os_detector_solaris(self): self.assertEqual(resource.attributes[OS_VERSION], "666.4.0.15.0") +_IOREG_OUTPUT = """+-o IOPlatformExpertDevice + { + "IOPlatformUUID" = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE" + "IOPlatformSerialNumber" = "C02XXXXXXXXX" + } +""" + + +def _stdout(text: str): + """A subprocess.run replacement that writes `text` to stdout.""" + return lambda *args, **kwargs: Mock(stdout=text) + + +def _detect() -> Resource: + return _HostResourceDetector().detect() + + class TestHostResourceDetector(unittest.TestCase): + def _assert_host_name_and_arch_survive(self, resource: Resource) -> None: + """A failed host.id lookup must not cost the other two attributes.""" + self.assertNotIn(HOST_ID, resource.attributes) + self.assertIn(HOST_NAME, resource.attributes) + self.assertIn(HOST_ARCH, resource.attributes) + @patch("socket.gethostname", lambda: "foo") @patch("platform.machine", lambda: "AMD64") def test_host_resource_detector(self): @@ -1007,10 +1039,12 @@ def test_host_resource_detector(self): self.assertEqual(resource.attributes[HOST_ARCH], "AMD64") @patch.dict(environ, {OTEL_EXPERIMENTAL_RESOURCE_DETECTORS: "host"}, clear=True) + @patch("opentelemetry.sdk.resources._get_host_id", lambda: "host-id") def test_resource_detector_entry_points_host(self): resource = Resource({}).create() self.assertIn(HOST_NAME, resource.attributes) self.assertIn(HOST_ARCH, resource.attributes) + self.assertEqual(resource.attributes[HOST_ID], "host-id") @patch.dict( environ, @@ -1022,6 +1056,175 @@ def test_resource_detector_entry_points_tolerate_missing_detector(self): self.assertEqual(resource.attributes["telemetry.sdk.language"], "python") self.assertIn(HOST_NAME, resource.attributes) + @patch("platform.system", lambda: "Linux") + @patch("builtins.open", new_callable=mock_open, read_data=" primary-machine-id\n") + def test_host_id_linux_prefers_primary_machine_id(self, open_mock): + self.assertEqual(_detect().attributes[HOST_ID], "primary-machine-id") + open_mock.assert_called_once_with("/etc/machine-id", encoding="utf8") + + @patch("platform.system", lambda: "Linux") + @patch("builtins.open") + def test_host_id_linux_falls_back_to_dbus_machine_id(self, open_mock): + open_mock.side_effect = [mock_open(read_data=" \n")(), mock_open(read_data="dbus-machine-id\n")()] + self.assertEqual(_detect().attributes[HOST_ID], "dbus-machine-id") + self.assertEqual( + open_mock.call_args_list, + [call("/etc/machine-id", encoding="utf8"), call("/var/lib/dbus/machine-id", encoding="utf8")], + ) + + @patch("platform.system", lambda: "Linux") + @patch("builtins.open") + def test_host_id_linux_no_machine_id(self, open_mock): + open_mock.side_effect = [PermissionError("access denied"), mock_open(read_data="")()] + with self.assertNoLogs(level=WARNING): + self._assert_host_name_and_arch_survive(_detect()) + + @patch("platform.system", lambda: "Darwin") + @patch("opentelemetry.sdk.resources.subprocess.run", return_value=Mock(stdout=_IOREG_OUTPUT)) + def test_host_id_macos_parses_ioreg_platform_uuid(self, run_mock): + self.assertEqual( + _detect().attributes[HOST_ID], + "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE", + ) + run_mock.assert_called_once_with( + ("/usr/sbin/ioreg", "-rd1", "-c", "IOPlatformExpertDevice"), + capture_output=True, + text=True, + timeout=2, + check=True, + ) + + @patch("platform.system", lambda: "Darwin") + @patch("opentelemetry.sdk.resources.subprocess.run", _stdout("no uuid here")) + def test_host_id_macos_no_platform_uuid(self): + self._assert_host_name_and_arch_survive(_detect()) + + @patch("platform.system", lambda: "Windows") + def test_host_id_windows_reads_machine_guid_from_registry(self): + mock_winreg = MagicMock() + mock_winreg.KEY_READ = 0x20019 + mock_winreg.KEY_WOW64_64KEY = 0x0100 + mock_winreg.QueryValueEx.return_value = ("registry-machine-guid", 1) + with patch("opentelemetry.sdk.resources.winreg", mock_winreg): + self.assertEqual(_detect().attributes[HOST_ID], "registry-machine-guid") + self.assertEqual(mock_winreg.OpenKey.call_args.args[1], r"SOFTWARE\Microsoft\Cryptography") + # The 64 bit view must be requested explicitly, or a 32 bit interpreter + # reads the WOW6432Node copy of the key. + self.assertEqual(mock_winreg.OpenKey.call_args.kwargs["access"], 0x20119) + self.assertEqual(mock_winreg.QueryValueEx.call_args.args[1], "MachineGuid") + + @patch("platform.system", lambda: "Windows") + def test_host_id_windows_registry_read_fails(self): + mock_winreg = MagicMock() + mock_winreg.OpenKey.side_effect = OSError("no such key") + with patch("opentelemetry.sdk.resources.winreg", mock_winreg), self.assertLogs(level=WARNING): + self._assert_host_name_and_arch_survive(_detect()) + + @patch("platform.system", lambda: "Windows") + @patch("opentelemetry.sdk.resources.winreg", None) + def test_host_id_windows_without_winreg(self): + with self.assertNoLogs(level=WARNING): + self._assert_host_name_and_arch_survive(_detect()) + + @patch("platform.system", lambda: "FreeBSD") + @patch("opentelemetry.sdk.resources._read_machine_id_file", return_value="bsd-host-id") + def test_host_id_bsd_reads_etc_hostid(self, mock_read): + self.assertEqual(_detect().attributes[HOST_ID], "bsd-host-id") + self.assertEqual(mock_read.call_args.args[0], "/etc/hostid") + + @patch("platform.system", lambda: "NetBSD") + @patch("opentelemetry.sdk.resources._read_machine_id_file", lambda _: None) + @patch("opentelemetry.sdk.resources.subprocess.run", return_value=Mock(stdout="bsd-kenv-uuid\n")) + def test_host_id_bsd_falls_back_to_kenv(self, run_mock): + self.assertEqual(_detect().attributes[HOST_ID], "bsd-kenv-uuid") + run_mock.assert_called_once_with( + ("/bin/kenv", "-q", "smbios.system.uuid"), + capture_output=True, + text=True, + timeout=2, + check=True, + ) + + @patch("platform.system", lambda: "FreeBSD") + @patch("opentelemetry.sdk.resources._read_machine_id_file", lambda _: None) + @patch( + "opentelemetry.sdk.resources.subprocess.run", + Mock(side_effect=subprocess.CalledProcessError(1, _BSD_KENV_COMMAND)), + ) + def test_host_id_bsd_no_host_id(self): + # `kenv -q` exits non-zero when the host has no SMBIOS UUID. That is an + # ordinary outcome, so it must not warn on every detection. + with self.assertNoLogs(level=WARNING): + self._assert_host_name_and_arch_survive(_detect()) + + @patch("platform.system", lambda: "FreeBSD") + @patch("opentelemetry.sdk.resources._read_machine_id_file", lambda _: None) + @patch("opentelemetry.sdk.resources.subprocess.run", Mock(side_effect=FileNotFoundError)) + def test_host_id_bsd_without_kenv(self): + with self.assertNoLogs(level=WARNING): + self._assert_host_name_and_arch_survive(_detect()) + + @patch("platform.system", lambda: "Java") + def test_host_id_unsupported_os(self): + with self.assertNoLogs(level=WARNING): + self._assert_host_name_and_arch_survive(_detect()) + + @patch("platform.system", lambda: "Darwin") + @patch( + "opentelemetry.sdk.resources.subprocess.run", + Mock(side_effect=subprocess.TimeoutExpired(cmd="ioreg", timeout=2)), + ) + def test_host_id_command_timeout(self): + with self.assertLogs(level=WARNING): + self._assert_host_name_and_arch_survive(_detect()) + + @patch("opentelemetry.sdk.resources._get_host_id", Mock(side_effect=ValueError("boom"))) + def test_host_id_error_swallowed_by_default(self): + # detect() is called directly here, without the handling in + # get_aggregated_resources, to prove the detector guards itself. + with self.assertLogs(level=WARNING): + self._assert_host_name_and_arch_survive(_detect()) + + @patch("opentelemetry.sdk.resources._get_host_id", Mock(side_effect=ValueError("boom"))) + def test_host_id_raise_on_error(self): + with self.assertRaises(ValueError), self.assertLogs(level=WARNING): + _HostResourceDetector(raise_on_error=True).detect() + + +class TestHostResourceDetectorIntegration(unittest.TestCase): + @unittest.skipUnless(platform.system() == "Linux", "requires Linux machine-id files") + def test_host_id_matches_machine_id_file(self): + # Check the source independently so a detector regression cannot turn + # this test into a skip. Empty or unreadable files are valid on containers. + for path in _LINUX_MACHINE_ID_PATHS: + try: + with open(path, encoding="utf8") as machine_id_file: + expected_id = machine_id_file.read().strip() + except OSError: + continue + if expected_id: + self.assertEqual(_detect().attributes[HOST_ID], expected_id) + return + self.skipTest("no readable, nonempty machine-id file on this host") + + @unittest.skipUnless(winreg is not None, "requires the Windows registry") + def test_host_id_matches_windows_machine_guid(self): + if winreg is None: + self.skipTest("requires the Windows registry") + # Read the native source independently of the detector's helpers. + try: + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Microsoft\Cryptography", + access=winreg.KEY_READ | winreg.KEY_WOW64_64KEY, + ) as key: + expected_id, _ = winreg.QueryValueEx(key, "MachineGuid") + except OSError as exception: + self.skipTest(f"MachineGuid is unavailable: {exception}") + if not expected_id: + self.skipTest("MachineGuid is empty") + self.assertEqual(_detect().attributes[HOST_ID], expected_id) + # pylint: disable=protected-access class TestServiceInstanceIdResourceDetector(unittest.TestCase):