Skip to content

Commit f68ec98

Browse files
committed
chore(telemetry): added fallbacks for machine id
1 parent 0b6c6f0 commit f68ec98

File tree

6 files changed

+200
-4
lines changed

6 files changed

+200
-4
lines changed

pdm.lock

Lines changed: 15 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ test = [
5353
"isort>=6.0.0",
5454
"black>=25.1.0",
5555
"ruff>=0.9.5",
56+
"pytest-mock>=3.14.0",
5657
]
5758
chat = [
5859
"streamlit>=1.42.0",

src/askui/telemetry/machineid.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import os
2+
import uuid
3+
import machineid
4+
from pathlib import Path
5+
6+
from askui.logger import logger
7+
from askui.telemetry.utils import map_guid_to_uuid4
8+
9+
_HASH_KEY = "askui"
10+
_MACHINE_ID_FILE_PATH = Path.home() / ".askui" / "machine_id"
11+
12+
_machine_id: str | None = None
13+
14+
15+
def _read_machine_id_from_file() -> str | None:
16+
"""Read machine ID from file if it exists."""
17+
try:
18+
if os.path.exists(_MACHINE_ID_FILE_PATH):
19+
with open(_MACHINE_ID_FILE_PATH, "r") as f:
20+
return f.read().strip()
21+
return None
22+
except Exception as e:
23+
logger.warning(f"Failed to read machine ID from file: {str(e)}")
24+
return None
25+
26+
27+
def _write_machine_id_to_file(machine_id: str) -> bool:
28+
"""Write machine ID to file, creating directories if needed."""
29+
try:
30+
# Create directory if it doesn't exist
31+
os.makedirs(os.path.dirname(_MACHINE_ID_FILE_PATH), exist_ok=True)
32+
with open(_MACHINE_ID_FILE_PATH, "w") as f:
33+
f.write(machine_id)
34+
return True
35+
except Exception as e:
36+
logger.warning(f"Failed to write machine ID to file: {str(e)}")
37+
return False
38+
39+
40+
def _is_valid_uuid4(uuid_str: str) -> bool:
41+
"""Check if a string is a valid UUID version 4."""
42+
try:
43+
return str(uuid.UUID(uuid_str, version=4)) == uuid_str
44+
except Exception:
45+
return False
46+
47+
48+
def get_machine_id() -> str:
49+
"""Get the machine ID of the host machine, with file persistence."""
50+
global _machine_id
51+
52+
if _machine_id is None:
53+
_machine_id = _read_machine_id_from_file()
54+
if _machine_id is None or not _is_valid_uuid4(_machine_id):
55+
try:
56+
_machine_id = map_guid_to_uuid4(machineid.hashed_id(app_id=_HASH_KEY)).lower()
57+
_write_machine_id_to_file(_machine_id)
58+
except machineid.MachineIdNotFound:
59+
logger.warning("Failed to get machine ID, generating a random UUID version 4.")
60+
_machine_id = str(uuid.uuid4()).lower()
61+
_write_machine_id_to_file(_machine_id)
62+
return _machine_id.lower()

src/askui/telemetry/telemetry.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from typing import Any, Callable
77
import uuid
88

9-
import machineid
109
from pydantic import BaseModel, Field
1110

1211
from askui.logger import logger
@@ -18,13 +17,13 @@
1817
OSContext,
1918
PlatformContext,
2019
)
20+
from askui.telemetry.machineid import get_machine_id
2121
from askui.telemetry.pkg_version import get_pkg_version
2222
from askui.telemetry.processors import SegmentSettings, TelemetryProcessor
2323
from askui.telemetry.user_identification import (
2424
UserIdentification,
2525
UserIdentificationSettings,
2626
)
27-
from askui.telemetry.utils import map_guid_to_uuid4
2827

2928

3029
class TelemetrySettings(BaseModel):
@@ -44,7 +43,7 @@ class TelemetrySettings(BaseModel):
4443
),
4544
)
4645
device_id: str = Field(
47-
default_factory=lambda: map_guid_to_uuid4(machineid.hashed_id("askui")),
46+
default_factory=get_machine_id,
4847
description=(
4948
"The device ID of the host machine. "
5049
"This is used to identify the device and the user (if anynomous) across AskUI components. "
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import os
2+
import uuid
3+
import pytest
4+
5+
import machineid
6+
from askui.telemetry.machineid import (
7+
get_machine_id,
8+
_read_machine_id_from_file,
9+
_write_machine_id_to_file,
10+
_is_valid_uuid4,
11+
_MACHINE_ID_FILE_PATH,
12+
)
13+
14+
15+
@pytest.fixture(autouse=True)
16+
def reset_machine_id():
17+
"""Reset cached machine ID before each test."""
18+
import askui.telemetry.machineid
19+
askui.telemetry.machineid._machine_id = None
20+
yield
21+
askui.telemetry.machineid._machine_id = None
22+
23+
24+
def test_read_machine_id_from_file(mocker):
25+
mocker.patch("os.path.exists", return_value=True)
26+
mock_file = mocker.mock_open(read_data="test-uuid-1234\n")
27+
mocker.patch("builtins.open", mock_file)
28+
29+
result = _read_machine_id_from_file()
30+
assert result == "test-uuid-1234"
31+
mock_file.assert_called_once_with(_MACHINE_ID_FILE_PATH, "r")
32+
33+
34+
def test_read_machine_id_from_nonexistent_file(mocker):
35+
mocker.patch("os.path.exists", return_value=False)
36+
result = _read_machine_id_from_file()
37+
assert result is None
38+
39+
40+
def test_read_machine_id_file_error(mocker):
41+
mocker.patch("os.path.exists", return_value=True)
42+
mocker.patch("builtins.open", side_effect=IOError("Test IO Error"))
43+
result = _read_machine_id_from_file()
44+
assert result is None
45+
46+
47+
def test_write_machine_id_to_file(mocker):
48+
mocker.patch("os.makedirs")
49+
mock_file = mocker.mock_open()
50+
mocker.patch("builtins.open", mock_file)
51+
52+
result = _write_machine_id_to_file("test-uuid-5678")
53+
assert result is True
54+
os.makedirs.assert_called_once_with(os.path.dirname(_MACHINE_ID_FILE_PATH), exist_ok=True)
55+
mock_file.assert_called_once_with(_MACHINE_ID_FILE_PATH, "w")
56+
mock_file().write.assert_called_once_with("test-uuid-5678")
57+
58+
59+
def test_write_machine_id_file_error(mocker):
60+
mocker.patch("os.makedirs", side_effect=OSError("Test OS Error"))
61+
result = _write_machine_id_to_file("test-uuid-5678")
62+
assert result is False
63+
64+
65+
def test_is_valid_uuid4():
66+
# Valid UUID4
67+
valid_uuid = str(uuid.uuid4())
68+
assert _is_valid_uuid4(valid_uuid) is True
69+
70+
# Invalid UUIDs
71+
assert _is_valid_uuid4("not-a-uuid") is False
72+
assert _is_valid_uuid4("12345678-1234-1234-1234-123456789012") is False # Valid format but not v4
73+
assert _is_valid_uuid4("") is False
74+
75+
76+
def test_get_machine_id_from_file(mocker):
77+
valid_uuid = str(uuid.uuid4()).lower()
78+
mocker.patch("askui.telemetry.machineid._read_machine_id_from_file", return_value=valid_uuid)
79+
mock_write = mocker.patch("askui.telemetry.machineid._write_machine_id_to_file")
80+
mock_hashed_id = mocker.patch("machineid.hashed_id")
81+
82+
result = get_machine_id()
83+
84+
assert result == valid_uuid
85+
mock_hashed_id.assert_not_called()
86+
mock_write.assert_not_called()
87+
88+
89+
def test_get_machine_id_from_system(mocker):
90+
mocker.patch("askui.telemetry.machineid._read_machine_id_from_file", return_value=None)
91+
mock_write = mocker.patch("askui.telemetry.machineid._write_machine_id_to_file")
92+
mock_hashed_id = mocker.patch("machineid.hashed_id")
93+
mock_hashed_id.return_value = "system-machine-id"
94+
result = get_machine_id()
95+
mock_hashed_id.assert_called_once_with(app_id="askui")
96+
mock_write.assert_called_once()
97+
assert _is_valid_uuid4(result)
98+
99+
100+
def test_get_machine_id_fallback_to_random(mocker):
101+
mocker.patch("askui.telemetry.machineid._read_machine_id_from_file", return_value=None)
102+
mock_write = mocker.patch("askui.telemetry.machineid._write_machine_id_to_file")
103+
mock_hashed_id = mocker.patch("machineid.hashed_id")
104+
mock_hashed_id.side_effect = machineid.MachineIdNotFound()
105+
random_uuid = uuid.uuid4()
106+
mocker.patch("uuid.uuid4", return_value=random_uuid)
107+
result = get_machine_id()
108+
assert result == str(random_uuid).lower()
109+
mock_write.assert_called_once_with(str(random_uuid).lower())
110+
111+
112+
def test_get_machine_id_caching(mocker):
113+
valid_uuid = str(uuid.uuid4()).lower()
114+
mock_read = mocker.patch("askui.telemetry.machineid._read_machine_id_from_file", return_value=valid_uuid)
115+
result1 = get_machine_id()
116+
assert result1 == valid_uuid
117+
mock_read.reset_mock()
118+
result2 = get_machine_id()
119+
assert result2 == valid_uuid
120+
mock_read.assert_not_called()

0 commit comments

Comments
 (0)