Skip to content

Commit 1a995c9

Browse files
jaydelucaherin049emdneto
authored
Add host.id to host resource attributes (#5653)
* add host.id resource attribute * docs * change log levels, rework id validation, cleanup tests * lint fix * add links to semconv, add integration tests for windows and linux * fix lint by renaming winreg * re-trigger build --------- Co-authored-by: Lukas Hering <40302054+herin049@users.noreply.github.com> Co-authored-by: Emídio <9735060+emdneto@users.noreply.github.com>
1 parent b1f499b commit 1a995c9

4 files changed

Lines changed: 376 additions & 8 deletions

File tree

.changelog/5653.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
`opentelemetry-sdk`: add `host.id` to the host resource detector

docs/sdk/resources.rst

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,45 @@
11
opentelemetry.sdk.resources package
22
==========================================
33

4+
Host resource detection
5+
-----------------------
6+
7+
The host resource detector populates the attributes defined by the
8+
`host resource semantic conventions
9+
<https://opentelemetry.io/docs/specs/semconv/resource/host/>`_.
10+
11+
Enable the host resource detector by setting
12+
:envvar:`OTEL_EXPERIMENTAL_RESOURCE_DETECTORS` before starting your application:
13+
14+
.. code-block:: sh
15+
16+
export OTEL_EXPERIMENTAL_RESOURCE_DETECTORS=host
17+
18+
Resources created with :meth:`opentelemetry.sdk.resources.Resource.create`
19+
will then include ``host.name``, ``host.arch``, and, when available, ``host.id``.
20+
If you already configure other detectors, add ``host`` to the comma-separated
21+
list.
22+
23+
The detector obtains ``host.id`` using the sources listed for a
24+
`non-privileged machine id lookup
25+
<https://opentelemetry.io/docs/specs/semconv/resource/host/#non-privileged-machine-id-lookup>`_:
26+
27+
* Linux: ``/etc/machine-id``, falling back to ``/var/lib/dbus/machine-id``.
28+
* BSD: ``/etc/hostid``, falling back to ``/bin/kenv -q smbios.system.uuid``.
29+
* macOS: ``IOPlatformUUID`` from ``/usr/sbin/ioreg -rd1 -c IOPlatformExpertDevice``.
30+
* Windows: ``MachineGuid`` from
31+
``HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Cryptography``, using the 64-bit registry
32+
view.
33+
34+
If the lookup fails or the operating system is unsupported, ``host.id`` is
35+
omitted by default while ``host.name`` and ``host.arch`` are retained. You can
36+
provide an explicit value through :envvar:`OTEL_RESOURCE_ATTRIBUTES`, for
37+
example ``OTEL_RESOURCE_ATTRIBUTES=host.id=my-host-id``. With the detector order
38+
shown above, this value takes precedence over the detected value.
39+
40+
API
41+
---
42+
443
.. automodule:: opentelemetry.sdk.resources
544
:members:
645
:undoc-members:

opentelemetry-sdk/src/opentelemetry/sdk/resources/__init__.py

Lines changed: 132 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
import os
5959
import platform
6060
import socket
61+
import subprocess
6162
import sys
6263
import threading
6364
import uuid
@@ -88,6 +89,16 @@
8889
except ImportError:
8990
pass
9091

92+
# Only available on Windows, where it is used to read the MachineGuid for host.id.
93+
winreg: ModuleType | None = None
94+
95+
try:
96+
import winreg as winreg_module
97+
98+
winreg = winreg_module
99+
except ImportError:
100+
pass
101+
91102
LabelValue = AnyValue
92103
Attributes = Mapping[str, LabelValue]
93104
logger = logging.getLogger(__name__)
@@ -106,6 +117,7 @@
106117
FAAS_INSTANCE = ResourceAttributes.FAAS_INSTANCE
107118
HOST_NAME = ResourceAttributes.HOST_NAME
108119
HOST_ARCH = ResourceAttributes.HOST_ARCH
120+
HOST_ID = ResourceAttributes.HOST_ID
109121
HOST_TYPE = ResourceAttributes.HOST_TYPE
110122
HOST_IMAGE_NAME = ResourceAttributes.HOST_IMAGE_NAME
111123
HOST_IMAGE_ID = ResourceAttributes.HOST_IMAGE_ID
@@ -493,18 +505,131 @@ def detect(self) -> "Resource":
493505
)
494506

495507

508+
# Non-privileged machine id sources per the semantic conventions:
509+
# https://opentelemetry.io/docs/specs/semconv/resource/host/#non-privileged-machine-id-lookup
510+
_LINUX_MACHINE_ID_PATHS = ("/etc/machine-id", "/var/lib/dbus/machine-id")
511+
_BSD_HOSTID_PATH = "/etc/hostid"
512+
_BSD_KENV_COMMAND = ("/bin/kenv", "-q", "smbios.system.uuid")
513+
_MACOS_IOREG_COMMAND = ("/usr/sbin/ioreg", "-rd1", "-c", "IOPlatformExpertDevice")
514+
_WINDOWS_CRYPTOGRAPHY_KEY = r"SOFTWARE\Microsoft\Cryptography"
515+
_WINDOWS_MACHINE_GUID_VALUE = "MachineGuid"
516+
# Deliberately below get_aggregated_resources' per detector timeout so that a
517+
# hung command still leaves time for host.name and host.arch to be returned.
518+
_COMMAND_TIMEOUT_SECONDS = 2
519+
520+
521+
def _read_machine_id_file(path: str) -> str | None:
522+
try:
523+
with open(path, encoding="utf8") as machine_id_file:
524+
return machine_id_file.read().strip() or None
525+
except OSError as exception:
526+
logger.debug("Failed to read %s: %s", path, exception)
527+
return None
528+
529+
530+
def _run_command(command: tuple[str, ...]) -> str:
531+
"""Returns the command's stdout, or "" when the source is unavailable here.
532+
533+
A non-zero exit or a missing binary means this host has no machine id to
534+
offer.
535+
"""
536+
try:
537+
completed = subprocess.run(
538+
command,
539+
capture_output=True,
540+
text=True,
541+
timeout=_COMMAND_TIMEOUT_SECONDS,
542+
check=True,
543+
)
544+
except (subprocess.CalledProcessError, FileNotFoundError) as exception:
545+
logger.debug("Failed to run %s: %s", command[0], exception)
546+
return ""
547+
return completed.stdout
548+
549+
550+
def _get_linux_machine_id() -> str | None:
551+
for path in _LINUX_MACHINE_ID_PATHS:
552+
machine_id = _read_machine_id_file(path)
553+
if machine_id:
554+
return machine_id
555+
return None
556+
557+
558+
def _get_bsd_machine_id() -> str | None:
559+
return _read_machine_id_file(_BSD_HOSTID_PATH) or _run_command(_BSD_KENV_COMMAND).strip() or None
560+
561+
562+
def _get_macos_machine_id() -> str | None:
563+
for line in _run_command(_MACOS_IOREG_COMMAND).splitlines():
564+
# The line looks like: ` "IOPlatformUUID" = "AAAAAAAA-BBBB-..."`
565+
key, separator, value = line.partition("=")
566+
if not separator or key.strip().strip('"') != "IOPlatformUUID":
567+
continue
568+
569+
machine_id = value.strip().strip('"')
570+
if machine_id:
571+
return machine_id
572+
return None
573+
574+
575+
def _get_windows_machine_id() -> str | None:
576+
if winreg is None:
577+
logger.debug("winreg is unavailable, cannot detect %s", HOST_ID)
578+
return None
579+
with winreg.OpenKey(
580+
winreg.HKEY_LOCAL_MACHINE,
581+
_WINDOWS_CRYPTOGRAPHY_KEY,
582+
access=winreg.KEY_READ | winreg.KEY_WOW64_64KEY,
583+
) as key:
584+
machine_guid, _ = winreg.QueryValueEx(key, _WINDOWS_MACHINE_GUID_VALUE)
585+
return str(machine_guid) if machine_guid else None
586+
587+
588+
def _get_host_id() -> str | None:
589+
system = platform.system()
590+
if system == "Linux":
591+
return _get_linux_machine_id()
592+
if system == "Darwin":
593+
return _get_macos_machine_id()
594+
if system == "Windows":
595+
return _get_windows_machine_id()
596+
if system == "DragonFly" or system.endswith("BSD"):
597+
return _get_bsd_machine_id()
598+
logger.debug("Unsupported OS type for %s detection: %s", HOST_ID, system)
599+
return None
600+
601+
496602
class _HostResourceDetector(ResourceDetector): # type: ignore[reportUnusedClass]
497603
"""
498-
The HostResourceDetector detects the hostname and architecture attributes.
604+
The HostResourceDetector detects the hostname, architecture and host id
605+
attributes.
606+
607+
``host.id`` is the non-privileged machine id described by the `Host resource
608+
conventions <https://opentelemetry.io/docs/specs/semconv/resource/host/>`_,
609+
and is omitted when it cannot be determined. A failed lookup does not
610+
prevent ``host.name`` and ``host.arch`` from being detected unless
611+
``raise_on_error=True``.
499612
"""
500613

501614
def detect(self) -> "Resource":
502-
return Resource(
503-
{
504-
HOST_NAME: socket.gethostname(),
505-
HOST_ARCH: platform.machine(),
506-
}
507-
)
615+
resource_info: dict[str, AnyValue] = {
616+
HOST_NAME: socket.gethostname(),
617+
HOST_ARCH: platform.machine(),
618+
}
619+
620+
# A failed host id lookup must not cost the caller the attributes above,
621+
# so it is guarded here rather than relying on the handling in
622+
# get_aggregated_resources: detect() is also called directly.
623+
try:
624+
if host_id := _get_host_id():
625+
resource_info[HOST_ID] = host_id
626+
# pylint: disable=broad-exception-caught
627+
except Exception as exception:
628+
logger.warning("Failed to detect %s: %s", HOST_ID, exception)
629+
if self.raise_on_error:
630+
raise
631+
632+
return Resource(resource_info)
508633

509634

510635
class ServiceInstanceIdResourceDetector(ResourceDetector):

0 commit comments

Comments
 (0)