|
58 | 58 | import os |
59 | 59 | import platform |
60 | 60 | import socket |
| 61 | +import subprocess |
61 | 62 | import sys |
62 | 63 | import threading |
63 | 64 | import uuid |
|
88 | 89 | except ImportError: |
89 | 90 | pass |
90 | 91 |
|
| 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 | + |
91 | 102 | LabelValue = AnyValue |
92 | 103 | Attributes = Mapping[str, LabelValue] |
93 | 104 | logger = logging.getLogger(__name__) |
|
106 | 117 | FAAS_INSTANCE = ResourceAttributes.FAAS_INSTANCE |
107 | 118 | HOST_NAME = ResourceAttributes.HOST_NAME |
108 | 119 | HOST_ARCH = ResourceAttributes.HOST_ARCH |
| 120 | +HOST_ID = ResourceAttributes.HOST_ID |
109 | 121 | HOST_TYPE = ResourceAttributes.HOST_TYPE |
110 | 122 | HOST_IMAGE_NAME = ResourceAttributes.HOST_IMAGE_NAME |
111 | 123 | HOST_IMAGE_ID = ResourceAttributes.HOST_IMAGE_ID |
@@ -493,18 +505,131 @@ def detect(self) -> "Resource": |
493 | 505 | ) |
494 | 506 |
|
495 | 507 |
|
| 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 | + |
496 | 602 | class _HostResourceDetector(ResourceDetector): # type: ignore[reportUnusedClass] |
497 | 603 | """ |
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``. |
499 | 612 | """ |
500 | 613 |
|
501 | 614 | 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) |
508 | 633 |
|
509 | 634 |
|
510 | 635 | class ServiceInstanceIdResourceDetector(ResourceDetector): |
|
0 commit comments