Skip to content

Commit fc91c40

Browse files
authored
Use machine fingerprint as cluster ID in telemetry headers (#7)
TL;DR ----- Implements machine fingerprint-based cluster identification that aligns with Vandoor's telemetry architecture requirements for tracking application instances across machine boundaries. Details -------- Generates a stable machine fingerprint using platform-specific identifiers (IOPlatformUUID on macOS, D-Bus machine-id on Linux, MachineGuid on Windows) and hashes them with SHA256 for privacy. This fingerprint initializes once at client creation and propagates to all instances, ensuring consistent cluster identification throughout the client's lifetime. This change completes the telemetry architecture alignment started in PR #5, ensuring the Python SDK matches the behavior established by the Go SDK and meets Vandoor's data model requirements. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent b604bcd commit fc91c40

File tree

4 files changed

+143
-6
lines changed

4 files changed

+143
-6
lines changed

replicated/async_client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from typing import Any, Dict, Optional
22

3+
from .fingerprint import get_machine_fingerprint
34
from .http_client import AsyncHTTPClient
45
from .services import AsyncCustomerService
56
from .state import StateManager
@@ -21,6 +22,7 @@ def __init__(
2122
self.base_url = base_url
2223
self.timeout = timeout
2324
self.state_directory = state_directory
25+
self._machine_id = get_machine_fingerprint()
2426

2527
self.http_client = AsyncHTTPClient(
2628
base_url=base_url,

replicated/client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from typing import Any, Dict, Optional
22

3+
from .fingerprint import get_machine_fingerprint
34
from .http_client import SyncHTTPClient
45
from .services import CustomerService
56
from .state import StateManager
@@ -21,6 +22,7 @@ def __init__(
2122
self.base_url = base_url
2223
self.timeout = timeout
2324
self.state_directory = state_directory
25+
self._machine_id = get_machine_fingerprint()
2426

2527
self.http_client = SyncHTTPClient(
2628
base_url=base_url,

replicated/resources.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ def __init__(
6464
self._client = client
6565
self.customer_id = customer_id
6666
self.instance_id = instance_id
67+
self._machine_id = client._machine_id
6768
self._data = kwargs
6869
self._status = "ready"
6970
self._metrics: dict[str, Union[int, float, str]] = {}
@@ -80,7 +81,7 @@ def send_metric(self, name: str, value: Union[int, float, str]) -> None:
8081
headers = {
8182
**self._client._get_auth_headers(),
8283
"X-Replicated-InstanceID": self.instance_id,
83-
"X-Replicated-ClusterID": self.instance_id,
84+
"X-Replicated-ClusterID": self._machine_id,
8485
"X-Replicated-AppStatus": self._status,
8586
}
8687

@@ -148,11 +149,10 @@ def _report_instance(self) -> None:
148149
json.dumps(instance_tags).encode()
149150
).decode()
150151

151-
# cluster_id is same as instance_id for non-K8s environments
152152
headers = {
153153
**self._client._get_auth_headers(),
154154
"X-Replicated-InstanceID": self.instance_id,
155-
"X-Replicated-ClusterID": self.instance_id,
155+
"X-Replicated-ClusterID": self._machine_id,
156156
"X-Replicated-AppStatus": self._status,
157157
"X-Replicated-InstanceTagData": instance_tags_b64,
158158
}
@@ -185,6 +185,7 @@ def __init__(
185185
self._client = client
186186
self.customer_id = customer_id
187187
self.instance_id = instance_id
188+
self._machine_id = client._machine_id
188189
self._data = kwargs
189190
self._status = "ready"
190191
self._metrics: dict[str, Union[int, float, str]] = {}
@@ -201,7 +202,7 @@ async def send_metric(self, name: str, value: Union[int, float, str]) -> None:
201202
headers = {
202203
**self._client._get_auth_headers(),
203204
"X-Replicated-InstanceID": self.instance_id,
204-
"X-Replicated-ClusterID": self.instance_id,
205+
"X-Replicated-ClusterID": self._machine_id,
205206
"X-Replicated-AppStatus": self._status,
206207
}
207208

@@ -269,11 +270,10 @@ async def _report_instance(self) -> None:
269270
json.dumps(instance_tags).encode()
270271
).decode()
271272

272-
# cluster_id is same as instance_id for non-K8s environments
273273
headers = {
274274
**self._client._get_auth_headers(),
275275
"X-Replicated-InstanceID": self.instance_id,
276-
"X-Replicated-ClusterID": self.instance_id,
276+
"X-Replicated-ClusterID": self._machine_id,
277277
"X-Replicated-AppStatus": self._status,
278278
"X-Replicated-InstanceTagData": instance_tags_b64,
279279
}

tests/test_client.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,68 @@ def test_default_state_directory_unchanged(self):
9898
assert "my-app" in state_dir_str
9999
assert "Replicated" in state_dir_str
100100

101+
def test_client_has_machine_id(self):
102+
"""Test that client initializes with a machine_id."""
103+
client = ReplicatedClient(publishable_key="pk_test_123", app_slug="my-app")
104+
assert hasattr(client, "_machine_id")
105+
assert client._machine_id is not None
106+
assert isinstance(client._machine_id, str)
107+
assert len(client._machine_id) == 64 # SHA256 hash
108+
109+
@patch("replicated.http_client.httpx.Client")
110+
def test_instance_has_machine_id_from_client(self, mock_httpx):
111+
"""Test that instances created from client have the client's machine_id."""
112+
from replicated.resources import Instance
113+
114+
mock_response = Mock()
115+
mock_response.is_success = True
116+
mock_response.json.return_value = {
117+
"customer": {
118+
"id": "customer_123",
119+
"email": "[email protected]",
120+
"name": "test user",
121+
"serviceToken": "service_token_123",
122+
"instanceId": "instance_123",
123+
}
124+
}
125+
126+
mock_client = Mock()
127+
mock_client.request.return_value = mock_response
128+
mock_httpx.return_value = mock_client
129+
130+
client = ReplicatedClient(publishable_key="pk_test_123", app_slug="my-app")
131+
customer = client.customer.get_or_create("[email protected]")
132+
instance = customer.get_or_create_instance()
133+
134+
assert isinstance(instance, Instance)
135+
assert hasattr(instance, "_machine_id")
136+
assert instance._machine_id == client._machine_id
137+
138+
@patch("replicated.http_client.httpx.Client")
139+
def test_instance_uses_machine_id_in_headers(self, mock_httpx):
140+
"""Test that instance methods use machine_id as cluster ID in headers."""
141+
from replicated.resources import Instance
142+
143+
mock_response = Mock()
144+
mock_response.is_success = True
145+
mock_response.json.return_value = {}
146+
147+
mock_client = Mock()
148+
mock_client.request.return_value = mock_response
149+
mock_httpx.return_value = mock_client
150+
151+
client = ReplicatedClient(publishable_key="pk_test_123", app_slug="my-app")
152+
instance = Instance(client, "customer_123", "instance_123")
153+
154+
# Send a metric
155+
instance.send_metric("test_metric", 42)
156+
157+
# Verify the request was made with correct headers
158+
call_args = mock_client.request.call_args
159+
headers = call_args[1]["headers"]
160+
assert "X-Replicated-ClusterID" in headers
161+
assert headers["X-Replicated-ClusterID"] == client._machine_id
162+
101163

102164
class TestAsyncReplicatedClient:
103165
@pytest.mark.asyncio
@@ -168,3 +230,74 @@ async def test_default_state_directory_unchanged(self):
168230
state_dir_str = str(client.state_manager._state_dir)
169231
assert "my-app" in state_dir_str
170232
assert "Replicated" in state_dir_str
233+
234+
@pytest.mark.asyncio
235+
async def test_client_has_machine_id(self):
236+
"""Test that async client initializes with a machine_id."""
237+
client = AsyncReplicatedClient(publishable_key="pk_test_123", app_slug="my-app")
238+
assert hasattr(client, "_machine_id")
239+
assert client._machine_id is not None
240+
assert isinstance(client._machine_id, str)
241+
assert len(client._machine_id) == 64 # SHA256 hash
242+
243+
@pytest.mark.asyncio
244+
async def test_instance_has_machine_id_from_client(self):
245+
"""Test that async instances have the client's machine_id."""
246+
from replicated.resources import AsyncInstance
247+
248+
with patch("replicated.http_client.httpx.AsyncClient") as mock_httpx:
249+
mock_response = Mock()
250+
mock_response.is_success = True
251+
mock_response.json.return_value = {
252+
"customer": {
253+
"id": "customer_123",
254+
"email": "[email protected]",
255+
"name": "test user",
256+
"serviceToken": "service_token_123",
257+
"instanceId": "instance_123",
258+
}
259+
}
260+
261+
mock_client = Mock()
262+
mock_client.request.return_value = mock_response
263+
mock_httpx.return_value = mock_client
264+
265+
client = AsyncReplicatedClient(
266+
publishable_key="pk_test_123", app_slug="my-app"
267+
)
268+
customer = await client.customer.get_or_create("[email protected]")
269+
instance = await customer.get_or_create_instance()
270+
271+
assert isinstance(instance, AsyncInstance)
272+
assert hasattr(instance, "_machine_id")
273+
assert instance._machine_id == client._machine_id
274+
275+
@pytest.mark.asyncio
276+
async def test_instance_uses_machine_id_in_headers(self):
277+
"""Test that async instance methods use machine_id as cluster ID in headers."""
278+
from unittest.mock import AsyncMock
279+
280+
from replicated.resources import AsyncInstance
281+
282+
with patch("replicated.http_client.httpx.AsyncClient") as mock_httpx:
283+
mock_response = Mock()
284+
mock_response.is_success = True
285+
mock_response.json.return_value = {}
286+
287+
mock_client = Mock()
288+
mock_client.request = AsyncMock(return_value=mock_response)
289+
mock_httpx.return_value = mock_client
290+
291+
client = AsyncReplicatedClient(
292+
publishable_key="pk_test_123", app_slug="my-app"
293+
)
294+
instance = AsyncInstance(client, "customer_123", "instance_123")
295+
296+
# Send a metric
297+
await instance.send_metric("test_metric", 42)
298+
299+
# Verify the request was made with correct headers
300+
call_args = mock_client.request.call_args
301+
headers = call_args[1]["headers"]
302+
assert "X-Replicated-ClusterID" in headers
303+
assert headers["X-Replicated-ClusterID"] == client._machine_id

0 commit comments

Comments
 (0)