From e4747450becd955b3d190bf26f4a05d534714728 Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Thu, 9 Oct 2025 11:03:29 -0400 Subject: [PATCH 01/20] Adopts metrics endpoints from the existing SDK --- replicated/resources.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/replicated/resources.py b/replicated/resources.py index 1799fda..842108d 100644 --- a/replicated/resources.py +++ b/replicated/resources.py @@ -71,7 +71,7 @@ def send_metric(self, name: str, value: Union[int, float, str]) -> None: self._client.http_client._make_request( "POST", - f"/api/v1/instances/{self.instance_id}/metrics", + f"/v1/instances/{self.instance_id}/metrics", json_data={"name": name, "value": value}, headers=self._client._get_auth_headers(), ) @@ -83,7 +83,7 @@ def delete_metric(self, name: str) -> None: self._client.http_client._make_request( "DELETE", - f"/api/v1/instances/{self.instance_id}/metrics/{name}", + f"/application/custom-metrics/{name}", headers=self._client._get_auth_headers(), ) @@ -161,7 +161,7 @@ async def send_metric(self, name: str, value: Union[int, float, str]) -> None: await self._client.http_client._make_request_async( "POST", - f"/api/v1/instances/{self.instance_id}/metrics", + f"/application/custom-metrics", json_data={"name": name, "value": value}, headers=self._client._get_auth_headers(), ) @@ -173,7 +173,7 @@ async def delete_metric(self, name: str) -> None: await self._client.http_client._make_request_async( "DELETE", - f"/api/v1/instances/{self.instance_id}/metrics/{name}", + f"/application/custom-metrics/{name}", headers=self._client._get_auth_headers(), ) From fe675931048b1fa1ecd9f3402e72f10c17634adc Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Thu, 9 Oct 2025 11:45:21 -0400 Subject: [PATCH 02/20] Updates initial metric send endpoint --- replicated/resources.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/replicated/resources.py b/replicated/resources.py index 842108d..4c6d2cf 100644 --- a/replicated/resources.py +++ b/replicated/resources.py @@ -71,7 +71,7 @@ def send_metric(self, name: str, value: Union[int, float, str]) -> None: self._client.http_client._make_request( "POST", - f"/v1/instances/{self.instance_id}/metrics", + f"/application/custom-metrics/{name}", json_data={"name": name, "value": value}, headers=self._client._get_auth_headers(), ) From 5c8a6e5e9229cae6040bf225362a0dcaf3bd6e3a Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Thu, 9 Oct 2025 13:53:55 -0400 Subject: [PATCH 03/20] Migrates Python SDK to telemetry-based instance tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes instance management to match Go SDK architecture and align with Vandoor endpoints: - Generates deterministic instance IDs client-side from machine fingerprints (no API calls) - Adds _report_instance() methods that send telemetry to /kots_metrics/license_instance/info - Fixes metrics format to use {"data": {name: value}} and correct endpoint /application/custom-metrics - Removes delete_metric(), set_status(), and set_version() methods (endpoints don't exist in Vandoor) This fixes endpoint alignment issues between the Python SDK and Vandoor. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- replicated/resources.py | 153 ++++++++++++++++------------------------ 1 file changed, 60 insertions(+), 93 deletions(-) diff --git a/replicated/resources.py b/replicated/resources.py index 4c6d2cf..8ed75bc 100644 --- a/replicated/resources.py +++ b/replicated/resources.py @@ -1,6 +1,5 @@ from typing import TYPE_CHECKING, Any, Optional, Union -from .enums import InstanceStatus from .fingerprint import get_machine_fingerprint if TYPE_CHECKING: @@ -68,51 +67,17 @@ def send_metric(self, name: str, value: Union[int, float, str]) -> None: """Send a metric for this instance.""" if not self.instance_id: self._ensure_instance() + self._report_instance() self._client.http_client._make_request( "POST", - f"/application/custom-metrics/{name}", - json_data={"name": name, "value": value}, - headers=self._client._get_auth_headers(), - ) - - def delete_metric(self, name: str) -> None: - """Delete a metric for this instance.""" - if not self.instance_id: - self._ensure_instance() - - self._client.http_client._make_request( - "DELETE", - f"/application/custom-metrics/{name}", - headers=self._client._get_auth_headers(), - ) - - def set_status(self, status: InstanceStatus) -> None: - """Set the status of this instance.""" - if not self.instance_id: - self._ensure_instance() - - self._client.http_client._make_request( - "PATCH", - f"/api/v1/instances/{self.instance_id}", - json_data={"status": status.value}, - headers=self._client._get_auth_headers(), - ) - - def set_version(self, version: str) -> None: - """Set the version of this instance.""" - if not self.instance_id: - self._ensure_instance() - - self._client.http_client._make_request( - "PATCH", - f"/api/v1/instances/{self.instance_id}", - json_data={"version": version}, + "/application/custom-metrics", + json_data={"data": {name: value}}, headers=self._client._get_auth_headers(), ) def _ensure_instance(self) -> None: - """Ensure the instance exists and is cached.""" + """Ensure the instance ID is generated and cached.""" if self.instance_id: return @@ -122,18 +87,36 @@ def _ensure_instance(self) -> None: self.instance_id = cached_instance_id return - # Create new instance + # Generate deterministic instance_id from fingerprint + import uuid fingerprint = get_machine_fingerprint() - response = self._client.http_client._make_request( + # Use first 16 bytes of SHA256 hash as UUID + instance_id = str(uuid.UUID(bytes=bytes.fromhex(fingerprint[:32]))) + + self.instance_id = instance_id + self._client.state_manager.set_instance_id(instance_id) + + def _report_instance(self) -> None: + """Send instance telemetry to vandoor.""" + if not self.instance_id: + self._ensure_instance() + + # cluster_id is same as instance_id for non-K8s environments + headers = { + **self._client._get_auth_headers(), + "X-Replicated-InstanceID": self.instance_id, + "X-Replicated-ClusterID": self.instance_id, + "X-Replicated-AppStatus": "ready", + "X-Replicated-ReplicatedSDKVersion": "1.0.0", + } + + self._client.http_client._make_request( "POST", - f"/api/v1/customers/{self.customer_id}/instances", - json_data={"fingerprint": fingerprint}, - headers=self._client._get_auth_headers(), + "/kots_metrics/license_instance/info", + headers=headers, + json_data={}, ) - self.instance_id = response["id"] - self._client.state_manager.set_instance_id(self.instance_id) - def __getattr__(self, name: str) -> Any: """Access additional instance data.""" return self._data.get(name) @@ -158,51 +141,17 @@ async def send_metric(self, name: str, value: Union[int, float, str]) -> None: """Send a metric for this instance.""" if not self.instance_id: await self._ensure_instance() + await self._report_instance() await self._client.http_client._make_request_async( "POST", - f"/application/custom-metrics", - json_data={"name": name, "value": value}, - headers=self._client._get_auth_headers(), - ) - - async def delete_metric(self, name: str) -> None: - """Delete a metric for this instance.""" - if not self.instance_id: - await self._ensure_instance() - - await self._client.http_client._make_request_async( - "DELETE", - f"/application/custom-metrics/{name}", - headers=self._client._get_auth_headers(), - ) - - async def set_status(self, status: InstanceStatus) -> None: - """Set the status of this instance.""" - if not self.instance_id: - await self._ensure_instance() - - await self._client.http_client._make_request_async( - "PATCH", - f"/api/v1/instances/{self.instance_id}", - json_data={"status": status.value}, - headers=self._client._get_auth_headers(), - ) - - async def set_version(self, version: str) -> None: - """Set the version of this instance.""" - if not self.instance_id: - await self._ensure_instance() - - await self._client.http_client._make_request_async( - "PATCH", - f"/api/v1/instances/{self.instance_id}", - json_data={"version": version}, + "/application/custom-metrics", + json_data={"data": {name: value}}, headers=self._client._get_auth_headers(), ) async def _ensure_instance(self) -> None: - """Ensure the instance exists and is cached.""" + """Ensure the instance ID is generated and cached.""" if self.instance_id: return @@ -212,18 +161,36 @@ async def _ensure_instance(self) -> None: self.instance_id = cached_instance_id return - # Create new instance + # Generate deterministic instance_id from fingerprint + import uuid fingerprint = get_machine_fingerprint() - response = await self._client.http_client._make_request_async( + # Use first 16 bytes of SHA256 hash as UUID + instance_id = str(uuid.UUID(bytes=bytes.fromhex(fingerprint[:32]))) + + self.instance_id = instance_id + self._client.state_manager.set_instance_id(instance_id) + + async def _report_instance(self) -> None: + """Send instance telemetry to vandoor.""" + if not self.instance_id: + await self._ensure_instance() + + # cluster_id is same as instance_id for non-K8s environments + headers = { + **self._client._get_auth_headers(), + "X-Replicated-InstanceID": self.instance_id, + "X-Replicated-ClusterID": self.instance_id, + "X-Replicated-AppStatus": "ready", + "X-Replicated-ReplicatedSDKVersion": "1.0.0", + } + + await self._client.http_client._make_request_async( "POST", - f"/api/v1/customers/{self.customer_id}/instances", - json_data={"fingerprint": fingerprint}, - headers=self._client._get_auth_headers(), + "/kots_metrics/license_instance/info", + headers=headers, + json_data={}, ) - self.instance_id = response["id"] - self._client.state_manager.set_instance_id(self.instance_id) - def __getattr__(self, name: str) -> Any: """Access additional instance data.""" return self._data.get(name) From e886e0f4d566ae8eb2b93822cd4743905b5f1949 Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Thu, 9 Oct 2025 13:53:58 -0400 Subject: [PATCH 04/20] Updates examples to remove deprecated method calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes usage of set_status() and set_version() methods that were deleted in the telemetry migration, and removes unused InstanceStatus import. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- examples/async_example.py | 9 +-------- examples/sync_example.py | 10 +--------- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/examples/async_example.py b/examples/async_example.py index 1ce8a99..9d6f36f 100644 --- a/examples/async_example.py +++ b/examples/async_example.py @@ -5,7 +5,7 @@ import asyncio -from replicated import AsyncReplicatedClient, InstanceStatus +from replicated import AsyncReplicatedClient async def main(): @@ -31,13 +31,6 @@ async def main(): ) print("Metrics sent successfully") - # Set the instance status and version concurrently - await asyncio.gather( - instance.set_status(InstanceStatus.RUNNING), - instance.set_version("1.2.0"), - ) - print("Instance status set to RUNNING and version set to 1.2.0") - print("Example completed successfully!") diff --git a/examples/sync_example.py b/examples/sync_example.py index ddd0c10..2d2f487 100644 --- a/examples/sync_example.py +++ b/examples/sync_example.py @@ -3,7 +3,7 @@ Synchronous example of using the Replicated Python SDK. """ -from replicated import InstanceStatus, ReplicatedClient +from replicated import ReplicatedClient def main(): @@ -27,14 +27,6 @@ def main(): instance.send_metric("disk_usage", 0.45) print("Metrics sent successfully") - # Set the instance status - instance.set_status(InstanceStatus.RUNNING) - print("Instance status set to RUNNING") - - # Set the application version - instance.set_version("1.2.0") - print("Instance version set to 1.2.0") - print("Example completed successfully!") From 411639a4187a672a50a1faf42eb86833fadfc017 Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Thu, 9 Oct 2025 14:23:12 -0400 Subject: [PATCH 05/20] Makes telemetry reporting resilient to authentication failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps telemetry calls in try/except to prevent 401 errors from blocking metric sends. Telemetry is optional - metrics should still work even if instance reporting fails. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .envrc | 2 ++ replicated/resources.py | 68 +++++++++++++++++++++++------------------ 2 files changed, 40 insertions(+), 30 deletions(-) create mode 100644 .envrc diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..5db6648 --- /dev/null +++ b/.envrc @@ -0,0 +1,2 @@ +layout python python3.12 +dotenv_if_exists diff --git a/replicated/resources.py b/replicated/resources.py index 8ed75bc..bcb4a3d 100644 --- a/replicated/resources.py +++ b/replicated/resources.py @@ -101,21 +101,25 @@ def _report_instance(self) -> None: if not self.instance_id: self._ensure_instance() - # cluster_id is same as instance_id for non-K8s environments - headers = { - **self._client._get_auth_headers(), - "X-Replicated-InstanceID": self.instance_id, - "X-Replicated-ClusterID": self.instance_id, - "X-Replicated-AppStatus": "ready", - "X-Replicated-ReplicatedSDKVersion": "1.0.0", - } - - self._client.http_client._make_request( - "POST", - "/kots_metrics/license_instance/info", - headers=headers, - json_data={}, - ) + try: + # cluster_id is same as instance_id for non-K8s environments + headers = { + **self._client._get_auth_headers(), + "X-Replicated-InstanceID": self.instance_id, + "X-Replicated-ClusterID": self.instance_id, + "X-Replicated-AppStatus": "ready", + "X-Replicated-ReplicatedSDKVersion": "1.0.0", + } + + self._client.http_client._make_request( + "POST", + "/kots_metrics/license_instance/info", + headers=headers, + json_data={}, + ) + except Exception: + # Telemetry is optional - don't fail if it doesn't work + pass def __getattr__(self, name: str) -> Any: """Access additional instance data.""" @@ -175,21 +179,25 @@ async def _report_instance(self) -> None: if not self.instance_id: await self._ensure_instance() - # cluster_id is same as instance_id for non-K8s environments - headers = { - **self._client._get_auth_headers(), - "X-Replicated-InstanceID": self.instance_id, - "X-Replicated-ClusterID": self.instance_id, - "X-Replicated-AppStatus": "ready", - "X-Replicated-ReplicatedSDKVersion": "1.0.0", - } - - await self._client.http_client._make_request_async( - "POST", - "/kots_metrics/license_instance/info", - headers=headers, - json_data={}, - ) + try: + # cluster_id is same as instance_id for non-K8s environments + headers = { + **self._client._get_auth_headers(), + "X-Replicated-InstanceID": self.instance_id, + "X-Replicated-ClusterID": self.instance_id, + "X-Replicated-AppStatus": "ready", + "X-Replicated-ReplicatedSDKVersion": "1.0.0", + } + + await self._client.http_client._make_request_async( + "POST", + "/kots_metrics/license_instance/info", + headers=headers, + json_data={}, + ) + except Exception: + # Telemetry is optional - don't fail if it doesn't work + pass def __getattr__(self, name: str) -> Any: """Access additional instance data.""" From 2c74eac19c8448cb5346a83f9a0a02d6ecf63e00 Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Thu, 9 Oct 2025 14:31:50 -0400 Subject: [PATCH 06/20] Fixes service token authentication format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Service tokens should be sent as raw Authorization header values (not Bearer tokens). Vandoor's PreloadKotsLicenseFromToken middleware expects raw token values for service account lookup. - Publishable keys: "Bearer " (for customer creation) - Service tokens: "" (raw value, no Bearer prefix) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- replicated/async_client.py | 4 +++- replicated/client.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/replicated/async_client.py b/replicated/async_client.py index 6a34ace..ad37ed4 100644 --- a/replicated/async_client.py +++ b/replicated/async_client.py @@ -39,6 +39,8 @@ def _get_auth_headers(self) -> Dict[str, str]: # Try to use dynamic token first, fall back to publishable key dynamic_token = self.state_manager.get_dynamic_token() if dynamic_token: - return {"Authorization": f"Bearer {dynamic_token}"} + # Service tokens are sent without Bearer prefix + return {"Authorization": dynamic_token} else: + # Publishable keys use Bearer prefix return {"Authorization": f"Bearer {self.publishable_key}"} diff --git a/replicated/client.py b/replicated/client.py index 7ea7d25..0bf990a 100644 --- a/replicated/client.py +++ b/replicated/client.py @@ -39,6 +39,8 @@ def _get_auth_headers(self) -> Dict[str, str]: # Try to use dynamic token first, fall back to publishable key dynamic_token = self.state_manager.get_dynamic_token() if dynamic_token: - return {"Authorization": f"Bearer {dynamic_token}"} + # Service tokens are sent without Bearer prefix + return {"Authorization": dynamic_token} else: + # Publishable keys use Bearer prefix return {"Authorization": f"Bearer {self.publishable_key}"} From fa3f43f35679571407e5c36910eff58105975b7e Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Thu, 9 Oct 2025 14:41:30 -0400 Subject: [PATCH 07/20] Adds hostname as instance tag in telemetry reporting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sends hostname via X-Replicated-InstanceTagData header as a tag named "name". This allows Vandoor to display a human-readable identifier for each instance. - Gets hostname via socket.gethostname() - Encodes as base64 JSON: {"name": "hostname"} - Falls back to "unknown" if hostname unavailable - Applied to both sync and async implementations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .gitignore | 5 ++- examples/metrics_example.py | 85 +++++++++++++++++++++++++++++++++++++ replicated/resources.py | 36 ++++++++++++++++ 3 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 examples/metrics_example.py diff --git a/.gitignore b/.gitignore index 763efa2..5f88784 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,7 @@ share/python-wheels/ *.egg MANIFEST -# Virtual environments +# Virtual environments .env .venv env/ @@ -31,6 +31,7 @@ venv/ ENV/ env.bak/ venv.bak/ +.direnv # IDE .vscode/ @@ -61,4 +62,4 @@ Thumbs.db # Replicated SDK state Library/ .local/ -AppData/ \ No newline at end of file +AppData/ diff --git a/examples/metrics_example.py b/examples/metrics_example.py new file mode 100644 index 0000000..ca83a35 --- /dev/null +++ b/examples/metrics_example.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +""" +Basic example of using the Replicated Python SDK. +This script initializes the replicated package, creates a customer and instance. +""" + +import argparse +import asyncio + +from replicated import AsyncReplicatedClient + + +async def main(): + parser = argparse.ArgumentParser(description="Basic Replicated SDK example") + parser.add_argument( + "--base-url", + default="https://replicated.app", + help="Base URL for the Replicated API (default: https://replicated.app)", + ) + parser.add_argument( + "--publishable-key", + required=True, + help="Your Replicated publishable key (required)", + ) + parser.add_argument( + "--app-slug", required=True, help="Your application slug (required)" + ) + parser.add_argument( + "--customer-email", + default="user@example.com", + help="Customer email address (default: user@example.com)", + ) + parser.add_argument("--channel", help="Channel for the customer (optional)") + parser.add_argument("--customer-name", help="Customer name (optional)") + + args = parser.parse_args() + + print("Initializing Replicated client...") + print(f"Base URL: {args.base_url}") + print(f"App Slug: {args.app_slug}") + + # Initialize the client + async with AsyncReplicatedClient( + publishable_key=args.publishable_key, + app_slug=args.app_slug, + base_url=args.base_url, + ) as client: + print("✓ Replicated client initialized successfully") + + # Create or get customer + channel_info = f" (channel: {args.channel})" if args.channel else "" + name_info = f" (name: {args.customer_name})" if args.customer_name else "" + print( + f"\nCreating/getting customer with email: " + f"{args.customer_email}{channel_info}{name_info}" + ) + customer = await client.customer.get_or_create( + email_address=args.customer_email, + channel=args.channel, + name=args.customer_name, + ) + print(f"✓ Customer created/retrieved - ID: {customer.customer_id}") + + # Get or create the associated instance + instance = await customer.get_or_create_instance() + print(f"Instance ID: {instance.instance_id}") + print(f"✓ Instance created/retrieved - ID: {instance.instance_id}") + + # Get or create the associated instance + instance = await customer.get_or_create_instance() + print(f"Instance ID: {instance.instance_id}") + + # Send some metrics concurrently + await asyncio.gather( + instance.send_metric("cpu_usage", 0.83), + instance.send_metric("memory_usage", 0.67), + instance.send_metric("disk_usage", 0.45), + ) + print("Metrics sent successfully") + + print(f"Instance ID: {instance.instance_id}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/replicated/resources.py b/replicated/resources.py index bcb4a3d..31dcbc9 100644 --- a/replicated/resources.py +++ b/replicated/resources.py @@ -89,6 +89,7 @@ def _ensure_instance(self) -> None: # Generate deterministic instance_id from fingerprint import uuid + fingerprint = get_machine_fingerprint() # Use first 16 bytes of SHA256 hash as UUID instance_id = str(uuid.UUID(bytes=bytes.fromhex(fingerprint[:32]))) @@ -102,6 +103,22 @@ def _report_instance(self) -> None: self._ensure_instance() try: + import base64 + import json + import socket + + # Get hostname for instance tag + try: + hostname = socket.gethostname() + except Exception: + hostname = "unknown" + + # Create instance tags with hostname + instance_tags = {"name": hostname} + instance_tags_b64 = base64.b64encode( + json.dumps(instance_tags).encode() + ).decode() + # cluster_id is same as instance_id for non-K8s environments headers = { **self._client._get_auth_headers(), @@ -109,6 +126,7 @@ def _report_instance(self) -> None: "X-Replicated-ClusterID": self.instance_id, "X-Replicated-AppStatus": "ready", "X-Replicated-ReplicatedSDKVersion": "1.0.0", + "X-Replicated-InstanceTagData": instance_tags_b64, } self._client.http_client._make_request( @@ -167,6 +185,7 @@ async def _ensure_instance(self) -> None: # Generate deterministic instance_id from fingerprint import uuid + fingerprint = get_machine_fingerprint() # Use first 16 bytes of SHA256 hash as UUID instance_id = str(uuid.UUID(bytes=bytes.fromhex(fingerprint[:32]))) @@ -180,6 +199,22 @@ async def _report_instance(self) -> None: await self._ensure_instance() try: + import base64 + import json + import socket + + # Get hostname for instance tag + try: + hostname = socket.gethostname() + except Exception: + hostname = "unknown" + + # Create instance tags with hostname + instance_tags = {"name": hostname} + instance_tags_b64 = base64.b64encode( + json.dumps(instance_tags).encode() + ).decode() + # cluster_id is same as instance_id for non-K8s environments headers = { **self._client._get_auth_headers(), @@ -187,6 +222,7 @@ async def _report_instance(self) -> None: "X-Replicated-ClusterID": self.instance_id, "X-Replicated-AppStatus": "ready", "X-Replicated-ReplicatedSDKVersion": "1.0.0", + "X-Replicated-InstanceTagData": instance_tags_b64, } await self._client.http_client._make_request_async( From 14dad81df05954eac6fddd3799fb1f74a65a25c3 Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Thu, 9 Oct 2025 14:48:34 -0400 Subject: [PATCH 08/20] Fixes instance tag data format to match Vandoor expectations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The instance tag data must be in the format: {"force": bool, "tags": {"key": "value"}} Not just the raw tags object. This matches the format expected by Vandoor's InstanceTagData schema. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- replicated/resources.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/replicated/resources.py b/replicated/resources.py index 31dcbc9..712e7ec 100644 --- a/replicated/resources.py +++ b/replicated/resources.py @@ -113,8 +113,8 @@ def _report_instance(self) -> None: except Exception: hostname = "unknown" - # Create instance tags with hostname - instance_tags = {"name": hostname} + # Create instance tags with hostname in correct format + instance_tags = {"force": False, "tags": {"name": hostname}} instance_tags_b64 = base64.b64encode( json.dumps(instance_tags).encode() ).decode() @@ -129,14 +129,19 @@ def _report_instance(self) -> None: "X-Replicated-InstanceTagData": instance_tags_b64, } + print(f"DEBUG: Sending telemetry with hostname tag: {hostname}") + print(f"DEBUG: Instance tags (base64): {instance_tags_b64}") + self._client.http_client._make_request( "POST", "/kots_metrics/license_instance/info", headers=headers, json_data={}, ) - except Exception: + print("DEBUG: Telemetry sent successfully") + except Exception as e: # Telemetry is optional - don't fail if it doesn't work + print(f"DEBUG: Telemetry failed: {e}") pass def __getattr__(self, name: str) -> Any: @@ -209,8 +214,8 @@ async def _report_instance(self) -> None: except Exception: hostname = "unknown" - # Create instance tags with hostname - instance_tags = {"name": hostname} + # Create instance tags with hostname in correct format + instance_tags = {"force": False, "tags": {"name": hostname}} instance_tags_b64 = base64.b64encode( json.dumps(instance_tags).encode() ).decode() @@ -225,14 +230,19 @@ async def _report_instance(self) -> None: "X-Replicated-InstanceTagData": instance_tags_b64, } + print(f"DEBUG: Sending telemetry with hostname tag: {hostname}") + print(f"DEBUG: Instance tags (base64): {instance_tags_b64}") + await self._client.http_client._make_request_async( "POST", "/kots_metrics/license_instance/info", headers=headers, json_data={}, ) - except Exception: + print("DEBUG: Telemetry sent successfully") + except Exception as e: # Telemetry is optional - don't fail if it doesn't work + print(f"DEBUG: Telemetry failed: {e}") pass def __getattr__(self, name: str) -> Any: From 896d627227a09aedc0da48ee1f14483af9ac5549 Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Thu, 9 Oct 2025 14:54:25 -0400 Subject: [PATCH 09/20] Uses force=true to override service account name tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vandoor's reporting-api automatically creates a "name" tag using the service account name (sdk-{timestamp}). To override this with the actual hostname, we need to use force=true in the tag data. The reconcileTags function checks: if (!dbKeys[key] || forced) This ensures our hostname tag replaces the auto-generated one. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- replicated/resources.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/replicated/resources.py b/replicated/resources.py index 712e7ec..8cbe900 100644 --- a/replicated/resources.py +++ b/replicated/resources.py @@ -114,7 +114,8 @@ def _report_instance(self) -> None: hostname = "unknown" # Create instance tags with hostname in correct format - instance_tags = {"force": False, "tags": {"name": hostname}} + # Use force=True to override the service account name that gets set automatically + instance_tags = {"force": True, "tags": {"name": hostname}} instance_tags_b64 = base64.b64encode( json.dumps(instance_tags).encode() ).decode() @@ -215,7 +216,8 @@ async def _report_instance(self) -> None: hostname = "unknown" # Create instance tags with hostname in correct format - instance_tags = {"force": False, "tags": {"name": hostname}} + # Use force=True to override the service account name that gets set automatically + instance_tags = {"force": True, "tags": {"name": hostname}} instance_tags_b64 = base64.b64encode( json.dumps(instance_tags).encode() ).decode() From b3e285228494bdd883dd740e7972f98d00019b01 Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Thu, 9 Oct 2025 14:58:05 -0400 Subject: [PATCH 10/20] Removes debug print statements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleans up all debug logging that was added for troubleshooting authentication and telemetry issues. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- replicated/http_client.py | 5 ----- replicated/resources.py | 14 ++------------ replicated/services.py | 16 ---------------- 3 files changed, 2 insertions(+), 33 deletions(-) diff --git a/replicated/http_client.py b/replicated/http_client.py index 9d2e37b..00849b5 100644 --- a/replicated/http_client.py +++ b/replicated/http_client.py @@ -51,11 +51,6 @@ def _handle_response(self, response: httpx.Response) -> Dict[str, Any]: error_message = json_body.get("message", default_msg) error_code = json_body.get("code") - # Debug: print the full error response - print(f"DEBUG: HTTP {response.status_code} Error Response:") - print(f"DEBUG: Response body: {response.text}") - print(f"DEBUG: JSON body: {json_body}") - if response.status_code == 401: raise ReplicatedAuthError( message=error_message, diff --git a/replicated/resources.py b/replicated/resources.py index 8cbe900..11b3f1d 100644 --- a/replicated/resources.py +++ b/replicated/resources.py @@ -130,19 +130,14 @@ def _report_instance(self) -> None: "X-Replicated-InstanceTagData": instance_tags_b64, } - print(f"DEBUG: Sending telemetry with hostname tag: {hostname}") - print(f"DEBUG: Instance tags (base64): {instance_tags_b64}") - self._client.http_client._make_request( "POST", "/kots_metrics/license_instance/info", headers=headers, json_data={}, ) - print("DEBUG: Telemetry sent successfully") - except Exception as e: + except Exception: # Telemetry is optional - don't fail if it doesn't work - print(f"DEBUG: Telemetry failed: {e}") pass def __getattr__(self, name: str) -> Any: @@ -232,19 +227,14 @@ async def _report_instance(self) -> None: "X-Replicated-InstanceTagData": instance_tags_b64, } - print(f"DEBUG: Sending telemetry with hostname tag: {hostname}") - print(f"DEBUG: Instance tags (base64): {instance_tags_b64}") - await self._client.http_client._make_request_async( "POST", "/kots_metrics/license_instance/info", headers=headers, json_data={}, ) - print("DEBUG: Telemetry sent successfully") - except Exception as e: + except Exception: # Telemetry is optional - don't fail if it doesn't work - print(f"DEBUG: Telemetry failed: {e}") pass def __getattr__(self, name: str) -> Any: diff --git a/replicated/services.py b/replicated/services.py index ed7f94a..5986d7a 100644 --- a/replicated/services.py +++ b/replicated/services.py @@ -25,10 +25,6 @@ def get_or_create( cached_email = self._client.state_manager.get_customer_email() if cached_customer_id and cached_email == email_address: - print( - f"DEBUG: Using cached customer ID {cached_customer_id} " - f"for email {email_address}" - ) return Customer( self._client, cached_customer_id, @@ -36,10 +32,6 @@ def get_or_create( channel, ) elif cached_customer_id and cached_email != email_address: - print( - f"DEBUG: Email changed from {cached_email} to " - f"{email_address}, clearing cache" - ) self._client.state_manager.clear_state() # Create or fetch customer @@ -55,7 +47,6 @@ def get_or_create( headers=self._client._get_auth_headers(), ) - print(f"DEBUG: API Response: {response}") customer_id = response["customer"]["id"] self._client.state_manager.set_customer_id(customer_id) self._client.state_manager.set_customer_email(email_address) @@ -67,7 +58,6 @@ def get_or_create( elif "customer" in response and "serviceToken" in response["customer"]: service_token = response["customer"]["serviceToken"] self._client.state_manager.set_dynamic_token(service_token) - print(f"DEBUG: Stored service token: {service_token[:20]}...") response_data = response.copy() response_data.pop("email_address", None) @@ -110,10 +100,6 @@ async def get_or_create( channel, ) elif cached_customer_id and cached_email != email_address: - print( - f"DEBUG: Email changed from {cached_email} to " - f"{email_address}, clearing cache" - ) self._client.state_manager.clear_state() # Create or fetch customer @@ -129,7 +115,6 @@ async def get_or_create( headers=self._client._get_auth_headers(), ) - print(f"DEBUG: API Response: {response}") customer_id = response["customer"]["id"] self._client.state_manager.set_customer_id(customer_id) self._client.state_manager.set_customer_email(email_address) @@ -141,7 +126,6 @@ async def get_or_create( elif "customer" in response and "serviceToken" in response["customer"]: service_token = response["customer"]["serviceToken"] self._client.state_manager.set_dynamic_token(service_token) - print(f"DEBUG: Stored service token: {service_token[:20]}...") response_data = response.copy() response_data.pop("email_address", None) From d14dd68f16e53013a1dc271e8da2c0abe36c0c23 Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Thu, 9 Oct 2025 14:59:03 -0400 Subject: [PATCH 11/20] Removes remaining debug print from async customer service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missed one debug print statement in AsyncCustomerService.get_or_create. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- replicated/services.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/replicated/services.py b/replicated/services.py index 5986d7a..fd478a7 100644 --- a/replicated/services.py +++ b/replicated/services.py @@ -89,10 +89,6 @@ async def get_or_create( cached_email = self._client.state_manager.get_customer_email() if cached_customer_id and cached_email == email_address: - print( - f"DEBUG: Using cached customer ID {cached_customer_id} " - f"for email {email_address}" - ) return AsyncCustomer( self._client, cached_customer_id, From d72251adf04eae2cdaa60de4b42cf435df08de55 Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Thu, 9 Oct 2025 15:19:13 -0400 Subject: [PATCH 12/20] Refines slop-y comments --- replicated/resources.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/replicated/resources.py b/replicated/resources.py index 11b3f1d..1e35d6b 100644 --- a/replicated/resources.py +++ b/replicated/resources.py @@ -113,8 +113,7 @@ def _report_instance(self) -> None: except Exception: hostname = "unknown" - # Create instance tags with hostname in correct format - # Use force=True to override the service account name that gets set automatically + # Create instance tags with hostname as instance name instance_tags = {"force": True, "tags": {"name": hostname}} instance_tags_b64 = base64.b64encode( json.dumps(instance_tags).encode() @@ -210,8 +209,7 @@ async def _report_instance(self) -> None: except Exception: hostname = "unknown" - # Create instance tags with hostname in correct format - # Use force=True to override the service account name that gets set automatically + # Create instance tags with hostname as instance name instance_tags = {"force": True, "tags": {"name": hostname}} instance_tags_b64 = base64.b64encode( json.dumps(instance_tags).encode() From 021c83d4538a13828ea94f62e76648812c298a71 Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Thu, 9 Oct 2025 15:42:47 -0400 Subject: [PATCH 13/20] Logs telemetry exceptions --- replicated/resources.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/replicated/resources.py b/replicated/resources.py index 1e35d6b..e502fb3 100644 --- a/replicated/resources.py +++ b/replicated/resources.py @@ -1,7 +1,10 @@ +import logging from typing import TYPE_CHECKING, Any, Optional, Union from .fingerprint import get_machine_fingerprint +logger = logging.getLogger(__name__) + if TYPE_CHECKING: from .async_client import AsyncReplicatedClient from .client import ReplicatedClient @@ -110,7 +113,8 @@ def _report_instance(self) -> None: # Get hostname for instance tag try: hostname = socket.gethostname() - except Exception: + except Exception as e: + logger.debug(f"Failed to get hostname: {e}") hostname = "unknown" # Create instance tags with hostname as instance name @@ -135,9 +139,9 @@ def _report_instance(self) -> None: headers=headers, json_data={}, ) - except Exception: + except Exception as e: # Telemetry is optional - don't fail if it doesn't work - pass + logger.debug(f"Failed to report instance telemetry: {e}") def __getattr__(self, name: str) -> Any: """Access additional instance data.""" @@ -206,7 +210,8 @@ async def _report_instance(self) -> None: # Get hostname for instance tag try: hostname = socket.gethostname() - except Exception: + except Exception as e: + logger.debug(f"Failed to get hostname: {e}") hostname = "unknown" # Create instance tags with hostname as instance name @@ -231,9 +236,9 @@ async def _report_instance(self) -> None: headers=headers, json_data={}, ) - except Exception: + except Exception as e: # Telemetry is optional - don't fail if it doesn't work - pass + logger.debug(f"Failed to report instance telemetry: {e}") def __getattr__(self, name: str) -> Any: """Access additional instance data.""" From 7bb6cf39adff60e64dbea2b4c0ccbb39740e13c2 Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Thu, 9 Oct 2025 16:10:58 -0400 Subject: [PATCH 14/20] Add dynamic status tracking and SDK version identification - Use package metadata for version with "+python" suffix to distinguish from Go SDK - Send SDK version via User-Agent header as "Replicated-SDK/{version}" - Add set_status() method to allow dynamic app status changes - Replace hardcoded "ready" status with configurable _status field - Remove hardcoded SDK version from telemetry headers (now sent via User-Agent) --- replicated/__init__.py | 9 ++++++++- replicated/http_client.py | 5 ++++- replicated/resources.py | 20 ++++++++++++++++---- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/replicated/__init__.py b/replicated/__init__.py index f576e8b..0c8056a 100644 --- a/replicated/__init__.py +++ b/replicated/__init__.py @@ -9,7 +9,14 @@ ReplicatedRateLimitError, ) -__version__ = "1.0.0" +# Try to get version from package metadata, fall back to hardcoded version +try: + from importlib.metadata import version as _get_version + + __version__ = _get_version("replicated") + "+python" +except Exception: + # Fallback for development or if package isn't installed + __version__ = "1.0.0+python" __all__ = [ "ReplicatedClient", "AsyncReplicatedClient", diff --git a/replicated/http_client.py b/replicated/http_client.py index 00849b5..71cd77b 100644 --- a/replicated/http_client.py +++ b/replicated/http_client.py @@ -3,6 +3,7 @@ import httpx +from . import __version__ from .exceptions import ( ReplicatedAPIError, ReplicatedAuthError, @@ -28,9 +29,11 @@ def _build_headers( self, headers: Optional[Dict[str, str]] = None ) -> Dict[str, str]: """Build request headers.""" + # Format: "Replicated-SDK/{version}" as expected by Vandoor + user_agent = f"Replicated-SDK/{__version__}" request_headers = { "Content-Type": "application/json", - "User-Agent": "replicated-python/1.0.0", + "User-Agent": user_agent, **self.default_headers, } if headers: diff --git a/replicated/resources.py b/replicated/resources.py index e502fb3..3294851 100644 --- a/replicated/resources.py +++ b/replicated/resources.py @@ -65,6 +65,7 @@ def __init__( self.customer_id = customer_id self.instance_id = instance_id self._data = kwargs + self._status = "ready" def send_metric(self, name: str, value: Union[int, float, str]) -> None: """Send a metric for this instance.""" @@ -79,6 +80,13 @@ def send_metric(self, name: str, value: Union[int, float, str]) -> None: headers=self._client._get_auth_headers(), ) + def set_status(self, status: str) -> None: + """Set the status of this instance for telemetry reporting.""" + if not self.instance_id: + self._ensure_instance() + self._status = status + self._report_instance() + def _ensure_instance(self) -> None: """Ensure the instance ID is generated and cached.""" if self.instance_id: @@ -128,8 +136,7 @@ def _report_instance(self) -> None: **self._client._get_auth_headers(), "X-Replicated-InstanceID": self.instance_id, "X-Replicated-ClusterID": self.instance_id, - "X-Replicated-AppStatus": "ready", - "X-Replicated-ReplicatedSDKVersion": "1.0.0", + "X-Replicated-AppStatus": self._status, "X-Replicated-InstanceTagData": instance_tags_b64, } @@ -162,6 +169,7 @@ def __init__( self.customer_id = customer_id self.instance_id = instance_id self._data = kwargs + self._status = "ready" async def send_metric(self, name: str, value: Union[int, float, str]) -> None: """Send a metric for this instance.""" @@ -176,6 +184,11 @@ async def send_metric(self, name: str, value: Union[int, float, str]) -> None: headers=self._client._get_auth_headers(), ) + def set_status(self, status: str) -> None: + """Set the status of this instance for telemetry reporting.""" + self._status = status + await self._report_instance() + async def _ensure_instance(self) -> None: """Ensure the instance ID is generated and cached.""" if self.instance_id: @@ -225,8 +238,7 @@ async def _report_instance(self) -> None: **self._client._get_auth_headers(), "X-Replicated-InstanceID": self.instance_id, "X-Replicated-ClusterID": self.instance_id, - "X-Replicated-AppStatus": "ready", - "X-Replicated-ReplicatedSDKVersion": "1.0.0", + "X-Replicated-AppStatus": self._status, "X-Replicated-InstanceTagData": instance_tags_b64, } From c86734010049deaeb5caedad9a9fbf47754c0a70 Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Thu, 9 Oct 2025 16:20:17 -0400 Subject: [PATCH 15/20] Fix circular import and async syntax error - Move __version__ import inside _build_headers() to avoid circular import - Make AsyncInstance.set_status() async to match await usage --- replicated/http_client.py | 4 +++- replicated/resources.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/replicated/http_client.py b/replicated/http_client.py index 71cd77b..b7bf265 100644 --- a/replicated/http_client.py +++ b/replicated/http_client.py @@ -3,7 +3,6 @@ import httpx -from . import __version__ from .exceptions import ( ReplicatedAPIError, ReplicatedAuthError, @@ -29,6 +28,9 @@ def _build_headers( self, headers: Optional[Dict[str, str]] = None ) -> Dict[str, str]: """Build request headers.""" + # Import here to avoid circular import + from . import __version__ + # Format: "Replicated-SDK/{version}" as expected by Vandoor user_agent = f"Replicated-SDK/{__version__}" request_headers = { diff --git a/replicated/resources.py b/replicated/resources.py index 3294851..029e9d3 100644 --- a/replicated/resources.py +++ b/replicated/resources.py @@ -184,7 +184,7 @@ async def send_metric(self, name: str, value: Union[int, float, str]) -> None: headers=self._client._get_auth_headers(), ) - def set_status(self, status: str) -> None: + async def set_status(self, status: str) -> None: """Set the status of this instance for telemetry reporting.""" self._status = status await self._report_instance() From 45720fe11b8acfc89e511507a96dc19a81582bf8 Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Fri, 10 Oct 2025 13:03:04 -0400 Subject: [PATCH 16/20] Reverts back to instance creation on the server --- replicated/resources.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/replicated/resources.py b/replicated/resources.py index 029e9d3..8b2f192 100644 --- a/replicated/resources.py +++ b/replicated/resources.py @@ -84,6 +84,7 @@ def set_status(self, status: str) -> None: """Set the status of this instance for telemetry reporting.""" if not self.instance_id: self._ensure_instance() + self._status = status self._report_instance() @@ -98,15 +99,17 @@ def _ensure_instance(self) -> None: self.instance_id = cached_instance_id return - # Generate deterministic instance_id from fingerprint - import uuid - + # Create new instance fingerprint = get_machine_fingerprint() - # Use first 16 bytes of SHA256 hash as UUID - instance_id = str(uuid.UUID(bytes=bytes.fromhex(fingerprint[:32]))) + response = self._client.http_client._make_request( + "POST", + f"/api/v1/customers/{self.customer_id}/instances", + json_data={"fingerprint": fingerprint}, + headers=self._client._get_auth_headers(), + ) - self.instance_id = instance_id - self._client.state_manager.set_instance_id(instance_id) + self.instance_id = response["id"] + self._client.state_manager.set_instance_id(self.instance_id) def _report_instance(self) -> None: """Send instance telemetry to vandoor.""" From 352b9eb6ad0f276c77184ee53ed1fd933880e08e Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Fri, 10 Oct 2025 13:43:46 -0400 Subject: [PATCH 17/20] Allows status toggle --- examples/metrics_example.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/examples/metrics_example.py b/examples/metrics_example.py index ca83a35..d40158c 100644 --- a/examples/metrics_example.py +++ b/examples/metrics_example.py @@ -32,6 +32,12 @@ async def main(): ) parser.add_argument("--channel", help="Channel for the customer (optional)") parser.add_argument("--customer-name", help="Customer name (optional)") + parser.add_argument( + "--status", + choices=["missing", "unavailable", "ready", "updating", "degraded"], + default="ready", + help="Instance status (default: ready)", + ) args = parser.parse_args() @@ -70,6 +76,10 @@ async def main(): instance = await customer.get_or_create_instance() print(f"Instance ID: {instance.instance_id}") + # Set instance status + await instance.set_status(args.status) + print(f"✓ Instance status set to: {args.status}") + # Send some metrics concurrently await asyncio.gather( instance.send_metric("cpu_usage", 0.83), From fc3db1c8c96caf8e5dde49eaa7aef419b790a72c Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Fri, 10 Oct 2025 13:44:47 -0400 Subject: [PATCH 18/20] Gets metrics posting --- replicated/resources.py | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/replicated/resources.py b/replicated/resources.py index 8b2f192..f936723 100644 --- a/replicated/resources.py +++ b/replicated/resources.py @@ -66,6 +66,7 @@ def __init__( self.instance_id = instance_id self._data = kwargs self._status = "ready" + self._metrics: dict[str, Union[int, float, str]] = {} def send_metric(self, name: str, value: Union[int, float, str]) -> None: """Send a metric for this instance.""" @@ -73,11 +74,22 @@ def send_metric(self, name: str, value: Union[int, float, str]) -> None: self._ensure_instance() self._report_instance() + # Merge metric with existing metrics (overwrite = false behavior) + self._metrics[name] = value + + # Build headers with instance data + headers = { + **self._client._get_auth_headers(), + "X-Replicated-InstanceID": self.instance_id, + "X-Replicated-ClusterID": self.instance_id, + "X-Replicated-AppStatus": self._status, + } + self._client.http_client._make_request( "POST", "/application/custom-metrics", - json_data={"data": {name: value}}, - headers=self._client._get_auth_headers(), + json_data={"data": self._metrics}, + headers=headers, ) def set_status(self, status: str) -> None: @@ -173,6 +185,7 @@ def __init__( self.instance_id = instance_id self._data = kwargs self._status = "ready" + self._metrics: dict[str, Union[int, float, str]] = {} async def send_metric(self, name: str, value: Union[int, float, str]) -> None: """Send a metric for this instance.""" @@ -180,11 +193,22 @@ async def send_metric(self, name: str, value: Union[int, float, str]) -> None: await self._ensure_instance() await self._report_instance() + # Merge metric with existing metrics (overwrite = false behavior) + self._metrics[name] = value + + # Build headers with instance data + headers = { + **self._client._get_auth_headers(), + "X-Replicated-InstanceID": self.instance_id, + "X-Replicated-ClusterID": self.instance_id, + "X-Replicated-AppStatus": self._status, + } + await self._client.http_client._make_request_async( "POST", "/application/custom-metrics", - json_data={"data": {name: value}}, - headers=self._client._get_auth_headers(), + json_data={"data": self._metrics}, + headers=headers, ) async def set_status(self, status: str) -> None: From cc9d345fccbe0055cd1292eb3b398b36c7242d0a Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Fri, 10 Oct 2025 16:07:08 -0400 Subject: [PATCH 19/20] Firms up instance handling on the server side --- replicated/resources.py | 39 ++++++++++++++++++++++++--------------- replicated/services.py | 4 ++++ 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/replicated/resources.py b/replicated/resources.py index f936723..53c6431 100644 --- a/replicated/resources.py +++ b/replicated/resources.py @@ -31,10 +31,10 @@ def get_or_create_instance(self) -> Union["Instance", "AsyncInstance"]: """Get or create an instance for this customer.""" if hasattr(self._client, "_get_or_create_instance_async"): # type: ignore[arg-type] - return AsyncInstance(self._client, self.customer_id) + return AsyncInstance(self._client, self.customer_id, self.instance_id) else: # type: ignore[arg-type] - return Instance(self._client, self.customer_id) + return Instance(self._client, self.customer_id, self.instance_id) def __getattr__(self, name: str) -> Any: """Access additional customer data.""" @@ -48,7 +48,7 @@ class AsyncCustomer(Customer): async def get_or_create_instance(self) -> "AsyncInstance": """Get or create an instance for this customer.""" # type: ignore[arg-type] - return AsyncInstance(self._client, self.customer_id) + return AsyncInstance(self._client, self.customer_id, self.instance_id) class Instance: @@ -72,7 +72,6 @@ def send_metric(self, name: str, value: Union[int, float, str]) -> None: """Send a metric for this instance.""" if not self.instance_id: self._ensure_instance() - self._report_instance() # Merge metric with existing metrics (overwrite = false behavior) self._metrics[name] = value @@ -115,12 +114,15 @@ def _ensure_instance(self) -> None: fingerprint = get_machine_fingerprint() response = self._client.http_client._make_request( "POST", - f"/api/v1/customers/{self.customer_id}/instances", - json_data={"fingerprint": fingerprint}, + "/v3/instance", + json_data={ + "machine_fingerprint": fingerprint, + "app_status": "missing", + }, headers=self._client._get_auth_headers(), ) - self.instance_id = response["id"] + self.instance_id = response["instance_id"] self._client.state_manager.set_instance_id(self.instance_id) def _report_instance(self) -> None: @@ -191,7 +193,6 @@ async def send_metric(self, name: str, value: Union[int, float, str]) -> None: """Send a metric for this instance.""" if not self.instance_id: await self._ensure_instance() - await self._report_instance() # Merge metric with existing metrics (overwrite = false behavior) self._metrics[name] = value @@ -213,6 +214,9 @@ async def send_metric(self, name: str, value: Union[int, float, str]) -> None: async def set_status(self, status: str) -> None: """Set the status of this instance for telemetry reporting.""" + if not self.instance_id: + await self._ensure_instance() + self._status = status await self._report_instance() @@ -227,15 +231,20 @@ async def _ensure_instance(self) -> None: self.instance_id = cached_instance_id return - # Generate deterministic instance_id from fingerprint - import uuid - + # Create new instance fingerprint = get_machine_fingerprint() - # Use first 16 bytes of SHA256 hash as UUID - instance_id = str(uuid.UUID(bytes=bytes.fromhex(fingerprint[:32]))) + response = await self._client.http_client._make_request_async( + "POST", + "/v3/instance", + json_data={ + "machine_fingerprint": fingerprint, + "app_status": "missing", + }, + headers=self._client._get_auth_headers(), + ) - self.instance_id = instance_id - self._client.state_manager.set_instance_id(instance_id) + self.instance_id = response["instance_id"] + self._client.state_manager.set_instance_id(self.instance_id) async def _report_instance(self) -> None: """Send instance telemetry to vandoor.""" diff --git a/replicated/services.py b/replicated/services.py index fd478a7..e27dcf4 100644 --- a/replicated/services.py +++ b/replicated/services.py @@ -48,8 +48,10 @@ def get_or_create( ) customer_id = response["customer"]["id"] + instance_id = response["customer"]["instanceId"] self._client.state_manager.set_customer_id(customer_id) self._client.state_manager.set_customer_email(email_address) + self._client.state_manager.set_instance_id(instance_id) # Store dynamic token if provided if "dynamic_token" in response: @@ -112,8 +114,10 @@ async def get_or_create( ) customer_id = response["customer"]["id"] + instance_id = response["customer"]["instanceId"] self._client.state_manager.set_customer_id(customer_id) self._client.state_manager.set_customer_email(email_address) + self._client.state_manager.set_instance_id(instance_id) # Store dynamic token if provided if "dynamic_token" in response: From 1e13907e26832a2e10fdb2ed65de633818596c60 Mon Sep 17 00:00:00 2001 From: Chuck D'Antonio Date: Fri, 10 Oct 2025 16:22:37 -0400 Subject: [PATCH 20/20] Includes expected fields in customer data --- tests/test_client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_client.py b/tests/test_client.py index b103831..75a3773 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -27,6 +27,8 @@ def test_customer_creation(self, mock_httpx): "id": "customer_123", "email": "test@example.com", "name": "test user", + "serviceToken": "service_token_123", + "instanceId": "instance_123", } }