From dac07a4dbd853ed74580ac18e51f0ff2ae68da57 Mon Sep 17 00:00:00 2001 From: Jay DeLuca Date: Thu, 10 Sep 2026 19:36:08 -0400 Subject: [PATCH 1/7] add host.id resource attribute --- .changelog/5638.added | 1 + .../opentelemetry/sdk/resources/__init__.py | 144 ++++++++++++- .../tests/resources/test_resources.py | 204 +++++++++++++++++- 3 files changed, 341 insertions(+), 8 deletions(-) create mode 100644 .changelog/5638.added diff --git a/.changelog/5638.added b/.changelog/5638.added new file mode 100644 index 0000000000..cf9aec0f02 --- /dev/null +++ b/.changelog/5638.added @@ -0,0 +1 @@ +`opentelemetry-sdk`: add `host.id` to the host resource detector diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py index e88121691e..04a755e320 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,136 @@ 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_first_line(path: str) -> str | None: + try: + with open(path, encoding="utf8") as machine_id_file: + for raw_line in machine_id_file: + line = raw_line.strip() + if line: + return line + except OSError as exception: + logger.debug("Failed to read %s: %s", path, exception) + return None + + +def _run_command(command: tuple[str, ...]) -> str: + completed = subprocess.run( + command, + capture_output=True, + text=True, + timeout=_COMMAND_TIMEOUT_SECONDS, + check=True, + ) + return completed.stdout + + +def _get_linux_machine_id() -> str | None: + for path in _LINUX_MACHINE_ID_PATHS: + machine_id = _read_first_line(path) + if machine_id: + return machine_id + return None + + +def _get_bsd_machine_id() -> str | None: + machine_id = _read_first_line(_BSD_HOSTID_PATH) + if machine_id: + return machine_id + output = _run_command(_BSD_KENV_COMMAND) + if output and output.strip(): + return output.strip() + return None + + +def _get_macos_machine_id() -> str | None: + output = _run_command(_MACOS_IOREG_COMMAND) + if not output: + return None + for line in output.splitlines(): + if "IOPlatformUUID" in line: + # The line looks like: ` "IOPlatformUUID" = "AAAAAAAA-BBBB-..."` + _, _, value = line.partition("=") + machine_id = value.strip().strip('"').strip() + if machine_id: + return machine_id + return None + + +def _get_windows_machine_id() -> str | None: + if winreg is None: + logger.warning("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) + if isinstance(machine_guid, str) and machine_guid: + return machine_guid + return None + + +def _get_host_id() -> str | None: + system = platform.system() + match system: + case "Linux": + return _get_linux_machine_id() + case "Darwin": + return _get_macos_machine_id() + case "Windows": + return _get_windows_machine_id() + case _ if system == "DragonFly" or system.endswith("BSD"): + return _get_bsd_machine_id() + case _: + logger.warning("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 read from the non-privileged machine id of the host, as + described by the `Host resource conventions + `_. It is + omitted when the machine id cannot be determined, which never prevents + ``host.name`` and ``host.arch`` from being detected. """ 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 cd9913bb74..7a81dd194e 100644 --- a/opentelemetry-sdk/tests/resources/test_resources.py +++ b/opentelemetry-sdk/tests/resources/test_resources.py @@ -12,7 +12,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, patch from urllib import parse import opentelemetry.sdk.resources as _resources_module @@ -24,6 +24,7 @@ _EMPTY_RESOURCE, _OPENTELEMETRY_SDK_VERSION, HOST_ARCH, + HOST_ID, HOST_NAME, OS_TYPE, OS_VERSION, @@ -51,6 +52,7 @@ Resource, ResourceDetector, ServiceInstanceIdResourceDetector, + _get_host_id, _get_process_dependent_resource, _HostResourceDetector, get_aggregated_resources, @@ -995,7 +997,31 @@ 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" + } +""" + +_MODULE = "opentelemetry.sdk.resources" + + +def _completed(stdout: str = "", returncode: int = 0) -> Mock: + return Mock(stdout=stdout, stderr="", returncode=returncode) + + +def _detect_host_resource() -> Resource: + return get_aggregated_resources([_HostResourceDetector()], Resource({})) + + 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): @@ -1022,6 +1048,182 @@ def test_resource_detector_entry_points_tolerate_missing_detector(self): self.assertEqual(resource.attributes["telemetry.sdk.language"], "python") self.assertIn(HOST_NAME, resource.attributes) + @unittest.skipUnless(sys.platform.startswith("linux"), "Linux only host.id lookup") + def test_host_id_end_to_end_linux(self): + # Read the machine id directly as an independent oracle for the exact + # value the detector is expected to report. + expected = None + for path in ("/etc/machine-id", "/var/lib/dbus/machine-id"): + try: + with open(path, encoding="utf8") as machine_id_file: + expected = machine_id_file.read().strip() + except OSError: + continue + if expected: + break + + resource = _detect_host_resource() + if expected: + self.assertRegex(expected, r"^[0-9a-f]{32}$") + self.assertEqual(resource.attributes[HOST_ID], expected) + else: + # Neither file exists on this host (common in containers), so the + # detector must simply omit host.id. + self._assert_host_name_and_arch_survive(resource) + + @unittest.skipUnless(sys.platform == "win32", "Windows only host.id lookup") + def test_host_id_end_to_end_windows(self): + # The detector reads the registry through winreg, so shell out to + # reg.exe here to get an independent oracle for the expected value. + completed = subprocess.run( + ( + "reg", + "query", + r"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Cryptography", + "/v", + "MachineGuid", + "/reg:64", + ), + capture_output=True, + text=True, + check=True, + ) + expected = next(line.split()[2] for line in completed.stdout.splitlines() if "MachineGuid" in line) + + self.assertEqual(_detect_host_resource().attributes[HOST_ID], expected) + + @patch("platform.system", lambda: "Linux") + @patch(f"{_MODULE}._read_first_line", side_effect=[None, "dbus-machine-id"]) + def test_host_id_linux_falls_back_to_dbus_machine_id(self, mock_read): + self.assertEqual(_detect_host_resource().attributes[HOST_ID], "dbus-machine-id") + self.assertEqual( + [call.args[0] for call in mock_read.call_args_list], + ["/etc/machine-id", "/var/lib/dbus/machine-id"], + ) + + @patch("platform.system", lambda: "Linux") + @patch(f"{_MODULE}._read_first_line", lambda _: None) + def test_host_id_linux_no_machine_id(self): + self._assert_host_name_and_arch_survive(_detect_host_resource()) + + @patch("platform.system", lambda: "Darwin") + @patch(f"{_MODULE}.subprocess.run", lambda *args, **kwargs: _completed(_IOREG_OUTPUT)) + def test_host_id_macos_parses_ioreg_platform_uuid(self): + self.assertEqual( + _detect_host_resource().attributes[HOST_ID], + "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE", + ) + + @patch("platform.system", lambda: "Darwin") + @patch(f"{_MODULE}.subprocess.run", lambda *args, **kwargs: _completed("no uuid here")) + def test_host_id_macos_no_platform_uuid(self): + self._assert_host_name_and_arch_survive(_detect_host_resource()) + + @patch("platform.system", lambda: "Windows") + def test_host_id_windows_reads_machine_guid_from_registry(self): + winreg = MagicMock() + winreg.KEY_READ = 0x20019 + winreg.KEY_WOW64_64KEY = 0x0100 + winreg.QueryValueEx.return_value = ("registry-machine-guid", 1) + with patch(f"{_MODULE}.winreg", winreg): + self.assertEqual(_detect_host_resource().attributes[HOST_ID], "registry-machine-guid") + winreg.OpenKey.assert_called_once_with( + winreg.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Microsoft\Cryptography", + access=0x20119, + ) + self.assertEqual(winreg.QueryValueEx.call_args.args[1], "MachineGuid") + + @patch("platform.system", lambda: "Windows") + def test_host_id_windows_registry_read_fails(self): + winreg = MagicMock() + winreg.OpenKey.side_effect = OSError("no such key") + with patch(f"{_MODULE}.winreg", winreg), self.assertLogs(level=WARNING): + self._assert_host_name_and_arch_survive(_detect_host_resource()) + + @patch("platform.system", lambda: "Windows") + @patch(f"{_MODULE}.winreg", None) + def test_host_id_windows_without_winreg(self): + with self.assertLogs(level=WARNING): + self._assert_host_name_and_arch_survive(_detect_host_resource()) + + @patch("platform.system", lambda: "FreeBSD") + @patch(f"{_MODULE}._read_first_line", return_value="bsd-host-id") + def test_host_id_bsd_reads_etc_hostid(self, mock_read): + self.assertEqual(_detect_host_resource().attributes[HOST_ID], "bsd-host-id") + self.assertEqual(mock_read.call_args.args[0], "/etc/hostid") + + @patch("platform.system", lambda: "NetBSD") + @patch(f"{_MODULE}._read_first_line", lambda _: None) + @patch(f"{_MODULE}.subprocess.run", lambda *args, **kwargs: _completed("bsd-kenv-uuid\n")) + def test_host_id_bsd_falls_back_to_kenv(self): + self.assertEqual(_detect_host_resource().attributes[HOST_ID], "bsd-kenv-uuid") + + @patch("platform.system", lambda: "FreeBSD") + @patch(f"{_MODULE}._read_first_line", lambda _: None) + @patch( + f"{_MODULE}.subprocess.run", + side_effect=subprocess.CalledProcessError(1, _resources_module._BSD_KENV_COMMAND), + ) + def test_host_id_bsd_no_host_id(self, mock_run): + with self.assertLogs(level=WARNING): + self._assert_host_name_and_arch_survive(_detect_host_resource()) + self.assertTrue(mock_run.call_args.kwargs["check"]) + + @patch("platform.system", lambda: "Java") + def test_host_id_unsupported_os(self): + with self.assertLogs(level=WARNING): + self._assert_host_name_and_arch_survive(_detect_host_resource()) + + @patch("platform.system", lambda: "Darwin") + @patch( + f"{_MODULE}.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_host_resource()) + + @patch("platform.system", lambda: "Darwin") + @patch(f"{_MODULE}.subprocess.run", Mock(side_effect=FileNotFoundError)) + def test_host_id_command_not_found(self): + with self.assertLogs(level=WARNING): + self._assert_host_name_and_arch_survive(_detect_host_resource()) + + @patch("platform.system", lambda: "Darwin") + def test_host_id_command_failures_raise_on_error(self): + for exception in ( + subprocess.TimeoutExpired(cmd="ioreg", timeout=2), + FileNotFoundError("ioreg"), + subprocess.CalledProcessError(1, "ioreg"), + ): + with self.subTest(exception=type(exception).__name__): + with ( + patch(f"{_MODULE}.subprocess.run", side_effect=exception), + self.assertRaises(type(exception)) as raised, + self.assertLogs(level=WARNING), + ): + _HostResourceDetector(raise_on_error=True).detect() + self.assertIs(raised.exception, exception) + + @patch(f"{_MODULE}._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(_HostResourceDetector().detect()) + + @patch(f"{_MODULE}._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() + + def test_get_host_id_returns_a_string_or_none(self): + host_id = _get_host_id() + if host_id is not None: + self.assertIsInstance(host_id, str) + self.assertTrue(host_id) + # pylint: disable=protected-access class TestServiceInstanceIdResourceDetector(unittest.TestCase): From 542ab6d5af44f570acbe8fa17355b6479593b125 Mon Sep 17 00:00:00 2001 From: Jay DeLuca Date: Thu, 10 Sep 2026 19:50:02 -0400 Subject: [PATCH 2/7] docs --- .changelog/{5638.added => 5653.added} | 0 docs/sdk/resources.rst | 33 +++++++++++++++++++ .../opentelemetry/sdk/resources/__init__.py | 5 +-- 3 files changed, 36 insertions(+), 2 deletions(-) rename .changelog/{5638.added => 5653.added} (100%) diff --git a/.changelog/5638.added b/.changelog/5653.added similarity index 100% rename from .changelog/5638.added rename to .changelog/5653.added diff --git a/docs/sdk/resources.rst b/docs/sdk/resources.rst index 08732ac025..a7b3225f93 100644 --- a/docs/sdk/resources.rst +++ b/docs/sdk/resources.rst @@ -1,6 +1,39 @@ opentelemetry.sdk.resources package ========================================== +Host resource detection +----------------------- + +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 non-privileged operating system sources: + +* 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 04a755e320..045d2dbdaf 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py @@ -617,8 +617,9 @@ class _HostResourceDetector(ResourceDetector): # type: ignore[reportUnusedClass ``host.id`` is read from the non-privileged machine id of the host, as described by the `Host resource conventions `_. It is - omitted when the machine id cannot be determined, which never prevents - ``host.name`` and ``host.arch`` from being detected. + omitted when the machine id cannot be determined. By default, lookup + failures do not prevent ``host.name`` and ``host.arch`` from being detected. + When ``raise_on_error=True``, lookup exceptions are propagated instead. """ def detect(self) -> "Resource": From 8f6fa38b0f1694d7c7da0569907a987331ed3517 Mon Sep 17 00:00:00 2001 From: Jay DeLuca Date: Fri, 11 Sep 2026 06:36:50 -0400 Subject: [PATCH 3/7] change log levels, rework id validation, cleanup tests --- .../opentelemetry/sdk/resources/__init__.py | 100 ++++++----- .../tests/resources/test_resources.py | 157 +++++------------- 2 files changed, 93 insertions(+), 164 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py index 045d2dbdaf..0ccbe1cfea 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py @@ -523,64 +523,63 @@ def detect(self) -> "Resource": _COMMAND_TIMEOUT_SECONDS = 2 -def _read_first_line(path: str) -> str | None: +def _read_machine_id_file(path: str) -> str | None: try: with open(path, encoding="utf8") as machine_id_file: - for raw_line in machine_id_file: - line = raw_line.strip() - if line: - return line + return machine_id_file.read().strip() or None except OSError as exception: logger.debug("Failed to read %s: %s", path, exception) - return None + return None def _run_command(command: tuple[str, ...]) -> str: - completed = subprocess.run( - command, - capture_output=True, - text=True, - timeout=_COMMAND_TIMEOUT_SECONDS, - check=True, - ) + """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_first_line(path) + machine_id = _read_machine_id_file(path) if machine_id: return machine_id return None def _get_bsd_machine_id() -> str | None: - machine_id = _read_first_line(_BSD_HOSTID_PATH) - if machine_id: - return machine_id - output = _run_command(_BSD_KENV_COMMAND) - if output and output.strip(): - return output.strip() - return 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: - output = _run_command(_MACOS_IOREG_COMMAND) - if not output: - return None - for line in output.splitlines(): - if "IOPlatformUUID" in line: - # The line looks like: ` "IOPlatformUUID" = "AAAAAAAA-BBBB-..."` - _, _, value = line.partition("=") - machine_id = value.strip().strip('"').strip() - if machine_id: - return machine_id + 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.warning("winreg is unavailable, cannot detect %s", HOST_ID) + logger.debug("winreg is unavailable, cannot detect %s", HOST_ID) return None with winreg.OpenKey( winreg.HKEY_LOCAL_MACHINE, @@ -588,25 +587,21 @@ def _get_windows_machine_id() -> str | None: access=winreg.KEY_READ | winreg.KEY_WOW64_64KEY, ) as key: machine_guid, _ = winreg.QueryValueEx(key, _WINDOWS_MACHINE_GUID_VALUE) - if isinstance(machine_guid, str) and machine_guid: - return machine_guid - return None + return str(machine_guid) if machine_guid else None def _get_host_id() -> str | None: system = platform.system() - match system: - case "Linux": - return _get_linux_machine_id() - case "Darwin": - return _get_macos_machine_id() - case "Windows": - return _get_windows_machine_id() - case _ if system == "DragonFly" or system.endswith("BSD"): - return _get_bsd_machine_id() - case _: - logger.warning("Unsupported OS type for %s detection: %s", HOST_ID, system) - return None + 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] @@ -614,12 +609,11 @@ class _HostResourceDetector(ResourceDetector): # type: ignore[reportUnusedClass The HostResourceDetector detects the hostname, architecture and host id attributes. - ``host.id`` is read from the non-privileged machine id of the host, as - described by the `Host resource conventions - `_. It is - omitted when the machine id cannot be determined. By default, lookup - failures do not prevent ``host.name`` and ``host.arch`` from being detected. - When ``raise_on_error=True``, lookup exceptions are propagated instead. + ``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": diff --git a/opentelemetry-sdk/tests/resources/test_resources.py b/opentelemetry-sdk/tests/resources/test_resources.py index 7a81dd194e..7f03ce2f45 100644 --- a/opentelemetry-sdk/tests/resources/test_resources.py +++ b/opentelemetry-sdk/tests/resources/test_resources.py @@ -20,6 +20,7 @@ OTEL_EXPERIMENTAL_RESOURCE_DETECTORS, ) from opentelemetry.sdk.resources import ( + _BSD_KENV_COMMAND, _DEFAULT_RESOURCE, _EMPTY_RESOURCE, _OPENTELEMETRY_SDK_VERSION, @@ -52,7 +53,6 @@ Resource, ResourceDetector, ServiceInstanceIdResourceDetector, - _get_host_id, _get_process_dependent_resource, _HostResourceDetector, get_aggregated_resources, @@ -1007,15 +1007,15 @@ def test_os_detector_solaris(self): _MODULE = "opentelemetry.sdk.resources" -def _completed(stdout: str = "", returncode: int = 0) -> Mock: - return Mock(stdout=stdout, stderr="", returncode=returncode) - - -def _detect_host_resource() -> Resource: - return get_aggregated_resources([_HostResourceDetector()], Resource({})) +def _stdout(text: str): + """A subprocess.run replacement that writes `text` to stdout.""" + return lambda *args, **kwargs: Mock(stdout=text) class TestHostResourceDetector(unittest.TestCase): + def _detect(self) -> Resource: + return _HostResourceDetector().detect() + 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) @@ -1048,76 +1048,32 @@ def test_resource_detector_entry_points_tolerate_missing_detector(self): self.assertEqual(resource.attributes["telemetry.sdk.language"], "python") self.assertIn(HOST_NAME, resource.attributes) - @unittest.skipUnless(sys.platform.startswith("linux"), "Linux only host.id lookup") - def test_host_id_end_to_end_linux(self): - # Read the machine id directly as an independent oracle for the exact - # value the detector is expected to report. - expected = None - for path in ("/etc/machine-id", "/var/lib/dbus/machine-id"): - try: - with open(path, encoding="utf8") as machine_id_file: - expected = machine_id_file.read().strip() - except OSError: - continue - if expected: - break - - resource = _detect_host_resource() - if expected: - self.assertRegex(expected, r"^[0-9a-f]{32}$") - self.assertEqual(resource.attributes[HOST_ID], expected) - else: - # Neither file exists on this host (common in containers), so the - # detector must simply omit host.id. - self._assert_host_name_and_arch_survive(resource) - - @unittest.skipUnless(sys.platform == "win32", "Windows only host.id lookup") - def test_host_id_end_to_end_windows(self): - # The detector reads the registry through winreg, so shell out to - # reg.exe here to get an independent oracle for the expected value. - completed = subprocess.run( - ( - "reg", - "query", - r"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Cryptography", - "/v", - "MachineGuid", - "/reg:64", - ), - capture_output=True, - text=True, - check=True, - ) - expected = next(line.split()[2] for line in completed.stdout.splitlines() if "MachineGuid" in line) - - self.assertEqual(_detect_host_resource().attributes[HOST_ID], expected) - @patch("platform.system", lambda: "Linux") - @patch(f"{_MODULE}._read_first_line", side_effect=[None, "dbus-machine-id"]) + @patch(f"{_MODULE}._read_machine_id_file", side_effect=[None, "dbus-machine-id"]) def test_host_id_linux_falls_back_to_dbus_machine_id(self, mock_read): - self.assertEqual(_detect_host_resource().attributes[HOST_ID], "dbus-machine-id") + self.assertEqual(self._detect().attributes[HOST_ID], "dbus-machine-id") self.assertEqual( [call.args[0] for call in mock_read.call_args_list], ["/etc/machine-id", "/var/lib/dbus/machine-id"], ) @patch("platform.system", lambda: "Linux") - @patch(f"{_MODULE}._read_first_line", lambda _: None) + @patch(f"{_MODULE}._read_machine_id_file", lambda _: None) def test_host_id_linux_no_machine_id(self): - self._assert_host_name_and_arch_survive(_detect_host_resource()) + self._assert_host_name_and_arch_survive(self._detect()) @patch("platform.system", lambda: "Darwin") - @patch(f"{_MODULE}.subprocess.run", lambda *args, **kwargs: _completed(_IOREG_OUTPUT)) + @patch(f"{_MODULE}.subprocess.run", _stdout(_IOREG_OUTPUT)) def test_host_id_macos_parses_ioreg_platform_uuid(self): self.assertEqual( - _detect_host_resource().attributes[HOST_ID], + self._detect().attributes[HOST_ID], "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE", ) @patch("platform.system", lambda: "Darwin") - @patch(f"{_MODULE}.subprocess.run", lambda *args, **kwargs: _completed("no uuid here")) + @patch(f"{_MODULE}.subprocess.run", _stdout("no uuid here")) def test_host_id_macos_no_platform_uuid(self): - self._assert_host_name_and_arch_survive(_detect_host_resource()) + self._assert_host_name_and_arch_survive(self._detect()) @patch("platform.system", lambda: "Windows") def test_host_id_windows_reads_machine_guid_from_registry(self): @@ -1126,12 +1082,11 @@ def test_host_id_windows_reads_machine_guid_from_registry(self): winreg.KEY_WOW64_64KEY = 0x0100 winreg.QueryValueEx.return_value = ("registry-machine-guid", 1) with patch(f"{_MODULE}.winreg", winreg): - self.assertEqual(_detect_host_resource().attributes[HOST_ID], "registry-machine-guid") - winreg.OpenKey.assert_called_once_with( - winreg.HKEY_LOCAL_MACHINE, - r"SOFTWARE\Microsoft\Cryptography", - access=0x20119, - ) + self.assertEqual(self._detect().attributes[HOST_ID], "registry-machine-guid") + self.assertEqual(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(winreg.OpenKey.call_args.kwargs["access"], 0x20119) self.assertEqual(winreg.QueryValueEx.call_args.args[1], "MachineGuid") @patch("platform.system", lambda: "Windows") @@ -1139,41 +1094,49 @@ def test_host_id_windows_registry_read_fails(self): winreg = MagicMock() winreg.OpenKey.side_effect = OSError("no such key") with patch(f"{_MODULE}.winreg", winreg), self.assertLogs(level=WARNING): - self._assert_host_name_and_arch_survive(_detect_host_resource()) + self._assert_host_name_and_arch_survive(self._detect()) @patch("platform.system", lambda: "Windows") @patch(f"{_MODULE}.winreg", None) def test_host_id_windows_without_winreg(self): - with self.assertLogs(level=WARNING): - self._assert_host_name_and_arch_survive(_detect_host_resource()) + with self.assertNoLogs(level=WARNING): + self._assert_host_name_and_arch_survive(self._detect()) @patch("platform.system", lambda: "FreeBSD") - @patch(f"{_MODULE}._read_first_line", return_value="bsd-host-id") + @patch(f"{_MODULE}._read_machine_id_file", return_value="bsd-host-id") def test_host_id_bsd_reads_etc_hostid(self, mock_read): - self.assertEqual(_detect_host_resource().attributes[HOST_ID], "bsd-host-id") + self.assertEqual(self._detect().attributes[HOST_ID], "bsd-host-id") self.assertEqual(mock_read.call_args.args[0], "/etc/hostid") @patch("platform.system", lambda: "NetBSD") - @patch(f"{_MODULE}._read_first_line", lambda _: None) - @patch(f"{_MODULE}.subprocess.run", lambda *args, **kwargs: _completed("bsd-kenv-uuid\n")) + @patch(f"{_MODULE}._read_machine_id_file", lambda _: None) + @patch(f"{_MODULE}.subprocess.run", _stdout("bsd-kenv-uuid\n")) def test_host_id_bsd_falls_back_to_kenv(self): - self.assertEqual(_detect_host_resource().attributes[HOST_ID], "bsd-kenv-uuid") + self.assertEqual(self._detect().attributes[HOST_ID], "bsd-kenv-uuid") @patch("platform.system", lambda: "FreeBSD") - @patch(f"{_MODULE}._read_first_line", lambda _: None) + @patch(f"{_MODULE}._read_machine_id_file", lambda _: None) @patch( f"{_MODULE}.subprocess.run", - side_effect=subprocess.CalledProcessError(1, _resources_module._BSD_KENV_COMMAND), + Mock(side_effect=subprocess.CalledProcessError(1, _BSD_KENV_COMMAND)), ) - def test_host_id_bsd_no_host_id(self, mock_run): - with self.assertLogs(level=WARNING): - self._assert_host_name_and_arch_survive(_detect_host_resource()) - self.assertTrue(mock_run.call_args.kwargs["check"]) + 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(self._detect()) + + @patch("platform.system", lambda: "FreeBSD") + @patch(f"{_MODULE}._read_machine_id_file", lambda _: None) + @patch(f"{_MODULE}.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(self._detect()) @patch("platform.system", lambda: "Java") def test_host_id_unsupported_os(self): - with self.assertLogs(level=WARNING): - self._assert_host_name_and_arch_survive(_detect_host_resource()) + with self.assertNoLogs(level=WARNING): + self._assert_host_name_and_arch_survive(self._detect()) @patch("platform.system", lambda: "Darwin") @patch( @@ -1182,48 +1145,20 @@ def test_host_id_unsupported_os(self): ) def test_host_id_command_timeout(self): with self.assertLogs(level=WARNING): - self._assert_host_name_and_arch_survive(_detect_host_resource()) - - @patch("platform.system", lambda: "Darwin") - @patch(f"{_MODULE}.subprocess.run", Mock(side_effect=FileNotFoundError)) - def test_host_id_command_not_found(self): - with self.assertLogs(level=WARNING): - self._assert_host_name_and_arch_survive(_detect_host_resource()) - - @patch("platform.system", lambda: "Darwin") - def test_host_id_command_failures_raise_on_error(self): - for exception in ( - subprocess.TimeoutExpired(cmd="ioreg", timeout=2), - FileNotFoundError("ioreg"), - subprocess.CalledProcessError(1, "ioreg"), - ): - with self.subTest(exception=type(exception).__name__): - with ( - patch(f"{_MODULE}.subprocess.run", side_effect=exception), - self.assertRaises(type(exception)) as raised, - self.assertLogs(level=WARNING), - ): - _HostResourceDetector(raise_on_error=True).detect() - self.assertIs(raised.exception, exception) + self._assert_host_name_and_arch_survive(self._detect()) @patch(f"{_MODULE}._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(_HostResourceDetector().detect()) + self._assert_host_name_and_arch_survive(self._detect()) @patch(f"{_MODULE}._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() - def test_get_host_id_returns_a_string_or_none(self): - host_id = _get_host_id() - if host_id is not None: - self.assertIsInstance(host_id, str) - self.assertTrue(host_id) - # pylint: disable=protected-access class TestServiceInstanceIdResourceDetector(unittest.TestCase): From eda3832b1d5b703a2e3fbb0bc38e602b3fc5ee10 Mon Sep 17 00:00:00 2001 From: Jay DeLuca Date: Fri, 11 Sep 2026 06:40:27 -0400 Subject: [PATCH 4/7] lint fix --- .../tests/resources/test_resources.py | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/opentelemetry-sdk/tests/resources/test_resources.py b/opentelemetry-sdk/tests/resources/test_resources.py index 7f03ce2f45..362d0ad941 100644 --- a/opentelemetry-sdk/tests/resources/test_resources.py +++ b/opentelemetry-sdk/tests/resources/test_resources.py @@ -1012,10 +1012,11 @@ def _stdout(text: str): return lambda *args, **kwargs: Mock(stdout=text) -class TestHostResourceDetector(unittest.TestCase): - def _detect(self) -> Resource: - return _HostResourceDetector().detect() +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) @@ -1051,7 +1052,7 @@ def test_resource_detector_entry_points_tolerate_missing_detector(self): @patch("platform.system", lambda: "Linux") @patch(f"{_MODULE}._read_machine_id_file", side_effect=[None, "dbus-machine-id"]) def test_host_id_linux_falls_back_to_dbus_machine_id(self, mock_read): - self.assertEqual(self._detect().attributes[HOST_ID], "dbus-machine-id") + self.assertEqual(_detect().attributes[HOST_ID], "dbus-machine-id") self.assertEqual( [call.args[0] for call in mock_read.call_args_list], ["/etc/machine-id", "/var/lib/dbus/machine-id"], @@ -1060,20 +1061,20 @@ def test_host_id_linux_falls_back_to_dbus_machine_id(self, mock_read): @patch("platform.system", lambda: "Linux") @patch(f"{_MODULE}._read_machine_id_file", lambda _: None) def test_host_id_linux_no_machine_id(self): - self._assert_host_name_and_arch_survive(self._detect()) + self._assert_host_name_and_arch_survive(_detect()) @patch("platform.system", lambda: "Darwin") @patch(f"{_MODULE}.subprocess.run", _stdout(_IOREG_OUTPUT)) def test_host_id_macos_parses_ioreg_platform_uuid(self): self.assertEqual( - self._detect().attributes[HOST_ID], + _detect().attributes[HOST_ID], "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE", ) @patch("platform.system", lambda: "Darwin") @patch(f"{_MODULE}.subprocess.run", _stdout("no uuid here")) def test_host_id_macos_no_platform_uuid(self): - self._assert_host_name_and_arch_survive(self._detect()) + self._assert_host_name_and_arch_survive(_detect()) @patch("platform.system", lambda: "Windows") def test_host_id_windows_reads_machine_guid_from_registry(self): @@ -1082,7 +1083,7 @@ def test_host_id_windows_reads_machine_guid_from_registry(self): winreg.KEY_WOW64_64KEY = 0x0100 winreg.QueryValueEx.return_value = ("registry-machine-guid", 1) with patch(f"{_MODULE}.winreg", winreg): - self.assertEqual(self._detect().attributes[HOST_ID], "registry-machine-guid") + self.assertEqual(_detect().attributes[HOST_ID], "registry-machine-guid") self.assertEqual(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. @@ -1094,25 +1095,25 @@ def test_host_id_windows_registry_read_fails(self): winreg = MagicMock() winreg.OpenKey.side_effect = OSError("no such key") with patch(f"{_MODULE}.winreg", winreg), self.assertLogs(level=WARNING): - self._assert_host_name_and_arch_survive(self._detect()) + self._assert_host_name_and_arch_survive(_detect()) @patch("platform.system", lambda: "Windows") @patch(f"{_MODULE}.winreg", None) def test_host_id_windows_without_winreg(self): with self.assertNoLogs(level=WARNING): - self._assert_host_name_and_arch_survive(self._detect()) + self._assert_host_name_and_arch_survive(_detect()) @patch("platform.system", lambda: "FreeBSD") @patch(f"{_MODULE}._read_machine_id_file", return_value="bsd-host-id") def test_host_id_bsd_reads_etc_hostid(self, mock_read): - self.assertEqual(self._detect().attributes[HOST_ID], "bsd-host-id") + 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(f"{_MODULE}._read_machine_id_file", lambda _: None) @patch(f"{_MODULE}.subprocess.run", _stdout("bsd-kenv-uuid\n")) def test_host_id_bsd_falls_back_to_kenv(self): - self.assertEqual(self._detect().attributes[HOST_ID], "bsd-kenv-uuid") + self.assertEqual(_detect().attributes[HOST_ID], "bsd-kenv-uuid") @patch("platform.system", lambda: "FreeBSD") @patch(f"{_MODULE}._read_machine_id_file", lambda _: None) @@ -1124,19 +1125,19 @@ 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(self._detect()) + self._assert_host_name_and_arch_survive(_detect()) @patch("platform.system", lambda: "FreeBSD") @patch(f"{_MODULE}._read_machine_id_file", lambda _: None) @patch(f"{_MODULE}.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(self._detect()) + 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(self._detect()) + self._assert_host_name_and_arch_survive(_detect()) @patch("platform.system", lambda: "Darwin") @patch( @@ -1145,14 +1146,14 @@ def test_host_id_unsupported_os(self): ) def test_host_id_command_timeout(self): with self.assertLogs(level=WARNING): - self._assert_host_name_and_arch_survive(self._detect()) + self._assert_host_name_and_arch_survive(_detect()) @patch(f"{_MODULE}._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(self._detect()) + self._assert_host_name_and_arch_survive(_detect()) @patch(f"{_MODULE}._get_host_id", Mock(side_effect=ValueError("boom"))) def test_host_id_raise_on_error(self): From b0fc229f6273d08f60e2b846017e32f8c4134275 Mon Sep 17 00:00:00 2001 From: Jay DeLuca Date: Sat, 12 Sep 2026 07:48:35 -0400 Subject: [PATCH 5/7] add links to semconv, add integration tests for windows and linux --- docs/sdk/resources.rst | 8 +- .../tests/resources/test_resources.py | 119 ++++++++++++++---- 2 files changed, 99 insertions(+), 28 deletions(-) diff --git a/docs/sdk/resources.rst b/docs/sdk/resources.rst index a7b3225f93..a5279f8c40 100644 --- a/docs/sdk/resources.rst +++ b/docs/sdk/resources.rst @@ -4,6 +4,10 @@ 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: @@ -16,7 +20,9 @@ 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 non-privileged operating system sources: +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``. diff --git a/opentelemetry-sdk/tests/resources/test_resources.py b/opentelemetry-sdk/tests/resources/test_resources.py index 362d0ad941..6356731ffd 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 MagicMock, Mock, patch +from unittest.mock import MagicMock, Mock, call, mock_open, patch from urllib import parse import opentelemetry.sdk.resources as _resources_module @@ -23,6 +24,7 @@ _BSD_KENV_COMMAND, _DEFAULT_RESOURCE, _EMPTY_RESOURCE, + _LINUX_MACHINE_ID_PATHS, _OPENTELEMETRY_SDK_VERSION, HOST_ARCH, HOST_ID, @@ -66,6 +68,11 @@ except ImportError: psutil = None +try: + import winreg +except ImportError: + winreg = None + class DefaultResourceDetector(ResourceDetector): def detect(self) -> Resource: @@ -1004,8 +1011,6 @@ def test_os_detector_solaris(self): } """ -_MODULE = "opentelemetry.sdk.resources" - def _stdout(text: str): """A subprocess.run replacement that writes `text` to stdout.""" @@ -1034,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, @@ -1050,29 +1057,45 @@ def test_resource_detector_entry_points_tolerate_missing_detector(self): self.assertIn(HOST_NAME, resource.attributes) @patch("platform.system", lambda: "Linux") - @patch(f"{_MODULE}._read_machine_id_file", side_effect=[None, "dbus-machine-id"]) - def test_host_id_linux_falls_back_to_dbus_machine_id(self, mock_read): + @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( - [call.args[0] for call in mock_read.call_args_list], - ["/etc/machine-id", "/var/lib/dbus/machine-id"], + 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(f"{_MODULE}._read_machine_id_file", lambda _: None) - def test_host_id_linux_no_machine_id(self): - self._assert_host_name_and_arch_survive(_detect()) + @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(f"{_MODULE}.subprocess.run", _stdout(_IOREG_OUTPUT)) - def test_host_id_macos_parses_ioreg_platform_uuid(self): + @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(f"{_MODULE}.subprocess.run", _stdout("no uuid here")) + @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()) @@ -1082,7 +1105,7 @@ def test_host_id_windows_reads_machine_guid_from_registry(self): winreg.KEY_READ = 0x20019 winreg.KEY_WOW64_64KEY = 0x0100 winreg.QueryValueEx.return_value = ("registry-machine-guid", 1) - with patch(f"{_MODULE}.winreg", winreg): + with patch("opentelemetry.sdk.resources.winreg", winreg): self.assertEqual(_detect().attributes[HOST_ID], "registry-machine-guid") self.assertEqual(winreg.OpenKey.call_args.args[1], r"SOFTWARE\Microsoft\Cryptography") # The 64 bit view must be requested explicitly, or a 32 bit interpreter @@ -1094,31 +1117,38 @@ def test_host_id_windows_reads_machine_guid_from_registry(self): def test_host_id_windows_registry_read_fails(self): winreg = MagicMock() winreg.OpenKey.side_effect = OSError("no such key") - with patch(f"{_MODULE}.winreg", winreg), self.assertLogs(level=WARNING): + with patch("opentelemetry.sdk.resources.winreg", winreg), self.assertLogs(level=WARNING): self._assert_host_name_and_arch_survive(_detect()) @patch("platform.system", lambda: "Windows") - @patch(f"{_MODULE}.winreg", None) + @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(f"{_MODULE}._read_machine_id_file", return_value="bsd-host-id") + @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(f"{_MODULE}._read_machine_id_file", lambda _: None) - @patch(f"{_MODULE}.subprocess.run", _stdout("bsd-kenv-uuid\n")) - def test_host_id_bsd_falls_back_to_kenv(self): + @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(f"{_MODULE}._read_machine_id_file", lambda _: None) + @patch("opentelemetry.sdk.resources._read_machine_id_file", lambda _: None) @patch( - f"{_MODULE}.subprocess.run", + "opentelemetry.sdk.resources.subprocess.run", Mock(side_effect=subprocess.CalledProcessError(1, _BSD_KENV_COMMAND)), ) def test_host_id_bsd_no_host_id(self): @@ -1128,8 +1158,8 @@ def test_host_id_bsd_no_host_id(self): self._assert_host_name_and_arch_survive(_detect()) @patch("platform.system", lambda: "FreeBSD") - @patch(f"{_MODULE}._read_machine_id_file", lambda _: None) - @patch(f"{_MODULE}.subprocess.run", Mock(side_effect=FileNotFoundError)) + @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()) @@ -1141,26 +1171,61 @@ def test_host_id_unsupported_os(self): @patch("platform.system", lambda: "Darwin") @patch( - f"{_MODULE}.subprocess.run", + "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(f"{_MODULE}._get_host_id", Mock(side_effect=ValueError("boom"))) + @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(f"{_MODULE}._get_host_id", Mock(side_effect=ValueError("boom"))) + @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): def setUp(self) -> None: From 287cbdc6344343571e3ea6b3e206ad2b0fde7c64 Mon Sep 17 00:00:00 2001 From: Jay DeLuca Date: Sat, 12 Sep 2026 08:31:51 -0400 Subject: [PATCH 6/7] fix lint by renaming winreg --- .../tests/resources/test_resources.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/opentelemetry-sdk/tests/resources/test_resources.py b/opentelemetry-sdk/tests/resources/test_resources.py index 6356731ffd..8eb59ad9e4 100644 --- a/opentelemetry-sdk/tests/resources/test_resources.py +++ b/opentelemetry-sdk/tests/resources/test_resources.py @@ -1101,23 +1101,23 @@ def test_host_id_macos_no_platform_uuid(self): @patch("platform.system", lambda: "Windows") def test_host_id_windows_reads_machine_guid_from_registry(self): - winreg = MagicMock() - winreg.KEY_READ = 0x20019 - winreg.KEY_WOW64_64KEY = 0x0100 - winreg.QueryValueEx.return_value = ("registry-machine-guid", 1) - with patch("opentelemetry.sdk.resources.winreg", winreg): + 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(winreg.OpenKey.call_args.args[1], r"SOFTWARE\Microsoft\Cryptography") + 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(winreg.OpenKey.call_args.kwargs["access"], 0x20119) - self.assertEqual(winreg.QueryValueEx.call_args.args[1], "MachineGuid") + 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): - winreg = MagicMock() - winreg.OpenKey.side_effect = OSError("no such key") - with patch("opentelemetry.sdk.resources.winreg", winreg), self.assertLogs(level=WARNING): + 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") From 3dfc93b977e09702367bc8def0ed6600e0cd360c Mon Sep 17 00:00:00 2001 From: Jay DeLuca Date: Mon, 14 Sep 2026 07:00:36 -0400 Subject: [PATCH 7/7] re-trigger build