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):