mcpforunityserver 9.4.0b20260203025228__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli/__init__.py +3 -0
- cli/commands/__init__.py +3 -0
- cli/commands/animation.py +84 -0
- cli/commands/asset.py +280 -0
- cli/commands/audio.py +125 -0
- cli/commands/batch.py +171 -0
- cli/commands/code.py +182 -0
- cli/commands/component.py +190 -0
- cli/commands/editor.py +447 -0
- cli/commands/gameobject.py +487 -0
- cli/commands/instance.py +93 -0
- cli/commands/lighting.py +123 -0
- cli/commands/material.py +239 -0
- cli/commands/prefab.py +248 -0
- cli/commands/scene.py +231 -0
- cli/commands/script.py +222 -0
- cli/commands/shader.py +226 -0
- cli/commands/texture.py +540 -0
- cli/commands/tool.py +58 -0
- cli/commands/ui.py +258 -0
- cli/commands/vfx.py +421 -0
- cli/main.py +281 -0
- cli/utils/__init__.py +31 -0
- cli/utils/config.py +58 -0
- cli/utils/confirmation.py +37 -0
- cli/utils/connection.py +254 -0
- cli/utils/constants.py +23 -0
- cli/utils/output.py +195 -0
- cli/utils/parsers.py +112 -0
- cli/utils/suggestions.py +34 -0
- core/__init__.py +0 -0
- core/config.py +67 -0
- core/constants.py +4 -0
- core/logging_decorator.py +37 -0
- core/telemetry.py +551 -0
- core/telemetry_decorator.py +164 -0
- main.py +845 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/METADATA +328 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/RECORD +105 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/WHEEL +5 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/entry_points.txt +3 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/licenses/LICENSE +21 -0
- mcpforunityserver-9.4.0b20260203025228.dist-info/top_level.txt +7 -0
- models/__init__.py +4 -0
- models/models.py +56 -0
- models/unity_response.py +70 -0
- services/__init__.py +0 -0
- services/api_key_service.py +235 -0
- services/custom_tool_service.py +499 -0
- services/registry/__init__.py +22 -0
- services/registry/resource_registry.py +53 -0
- services/registry/tool_registry.py +51 -0
- services/resources/__init__.py +86 -0
- services/resources/active_tool.py +48 -0
- services/resources/custom_tools.py +57 -0
- services/resources/editor_state.py +304 -0
- services/resources/gameobject.py +243 -0
- services/resources/layers.py +30 -0
- services/resources/menu_items.py +35 -0
- services/resources/prefab.py +191 -0
- services/resources/prefab_stage.py +40 -0
- services/resources/project_info.py +40 -0
- services/resources/selection.py +56 -0
- services/resources/tags.py +31 -0
- services/resources/tests.py +88 -0
- services/resources/unity_instances.py +125 -0
- services/resources/windows.py +48 -0
- services/state/external_changes_scanner.py +245 -0
- services/tools/__init__.py +83 -0
- services/tools/batch_execute.py +93 -0
- services/tools/debug_request_context.py +86 -0
- services/tools/execute_custom_tool.py +43 -0
- services/tools/execute_menu_item.py +32 -0
- services/tools/find_gameobjects.py +110 -0
- services/tools/find_in_file.py +181 -0
- services/tools/manage_asset.py +119 -0
- services/tools/manage_components.py +131 -0
- services/tools/manage_editor.py +64 -0
- services/tools/manage_gameobject.py +260 -0
- services/tools/manage_material.py +111 -0
- services/tools/manage_prefabs.py +209 -0
- services/tools/manage_scene.py +111 -0
- services/tools/manage_script.py +645 -0
- services/tools/manage_scriptable_object.py +87 -0
- services/tools/manage_shader.py +71 -0
- services/tools/manage_texture.py +581 -0
- services/tools/manage_vfx.py +120 -0
- services/tools/preflight.py +110 -0
- services/tools/read_console.py +151 -0
- services/tools/refresh_unity.py +153 -0
- services/tools/run_tests.py +317 -0
- services/tools/script_apply_edits.py +1006 -0
- services/tools/set_active_instance.py +120 -0
- services/tools/utils.py +348 -0
- transport/__init__.py +0 -0
- transport/legacy/port_discovery.py +329 -0
- transport/legacy/stdio_port_registry.py +65 -0
- transport/legacy/unity_connection.py +910 -0
- transport/models.py +68 -0
- transport/plugin_hub.py +787 -0
- transport/plugin_registry.py +182 -0
- transport/unity_instance_middleware.py +262 -0
- transport/unity_transport.py +94 -0
- utils/focus_nudge.py +589 -0
- utils/module_discovery.py +55 -0
core/telemetry.py
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Privacy-focused, anonymous telemetry system for MCP for Unity
|
|
3
|
+
Inspired by Onyx's telemetry implementation with Unity-specific adaptations
|
|
4
|
+
|
|
5
|
+
Fire-and-forget telemetry sender with a single background worker.
|
|
6
|
+
- No context/thread-local propagation to avoid re-entrancy into tool resolution.
|
|
7
|
+
- Small network timeouts to prevent stalls.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from importlib import import_module, metadata
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
import platform
|
|
19
|
+
import queue
|
|
20
|
+
import sys
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from typing import Any
|
|
24
|
+
from urllib.parse import urlparse
|
|
25
|
+
import uuid
|
|
26
|
+
|
|
27
|
+
import tomli
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
import httpx
|
|
31
|
+
HAS_HTTPX = True
|
|
32
|
+
except ImportError:
|
|
33
|
+
httpx = None # type: ignore
|
|
34
|
+
HAS_HTTPX = False
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger("unity-mcp-telemetry")
|
|
37
|
+
PACKAGE_NAME = "mcpforunityserver"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _version_from_local_pyproject() -> str:
|
|
41
|
+
"""Locate the nearest pyproject.toml that matches our package name."""
|
|
42
|
+
current = Path(__file__).resolve()
|
|
43
|
+
for parent in current.parents:
|
|
44
|
+
candidate = parent / "pyproject.toml"
|
|
45
|
+
if not candidate.exists():
|
|
46
|
+
continue
|
|
47
|
+
try:
|
|
48
|
+
with candidate.open("rb") as f:
|
|
49
|
+
data = tomli.load(f)
|
|
50
|
+
except (OSError, tomli.TOMLDecodeError):
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
project_table = data.get("project") or {}
|
|
54
|
+
poetry_table = data.get("tool", {}).get("poetry", {})
|
|
55
|
+
|
|
56
|
+
project_name = project_table.get("name") or poetry_table.get("name")
|
|
57
|
+
if project_name and project_name.lower() != PACKAGE_NAME.lower():
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
version = project_table.get("version") or poetry_table.get("version")
|
|
61
|
+
if version:
|
|
62
|
+
return version
|
|
63
|
+
raise FileNotFoundError("pyproject.toml not found for mcpforunityserver")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_package_version() -> str:
|
|
67
|
+
"""
|
|
68
|
+
Get package version in different ways:
|
|
69
|
+
1. First we try the installed metadata - this is because uvx is used on the asset store
|
|
70
|
+
2. If that fails, we try to read from pyproject.toml - this is available for users who download via Git
|
|
71
|
+
Default is "unknown", but that should never happen
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
return metadata.version(PACKAGE_NAME)
|
|
75
|
+
except Exception:
|
|
76
|
+
# Fallback for development: read from pyproject.toml
|
|
77
|
+
try:
|
|
78
|
+
return _version_from_local_pyproject()
|
|
79
|
+
except Exception:
|
|
80
|
+
return "unknown"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
MCP_VERSION = get_package_version()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class RecordType(str, Enum):
|
|
87
|
+
"""Types of telemetry records we collect"""
|
|
88
|
+
VERSION = "version"
|
|
89
|
+
STARTUP = "startup"
|
|
90
|
+
USAGE = "usage"
|
|
91
|
+
LATENCY = "latency"
|
|
92
|
+
FAILURE = "failure"
|
|
93
|
+
RESOURCE_RETRIEVAL = "resource_retrieval"
|
|
94
|
+
TOOL_EXECUTION = "tool_execution"
|
|
95
|
+
UNITY_CONNECTION = "unity_connection"
|
|
96
|
+
CLIENT_CONNECTION = "client_connection"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class MilestoneType(str, Enum):
|
|
100
|
+
"""Major user journey milestones"""
|
|
101
|
+
FIRST_STARTUP = "first_startup"
|
|
102
|
+
FIRST_TOOL_USAGE = "first_tool_usage"
|
|
103
|
+
FIRST_SCRIPT_CREATION = "first_script_creation"
|
|
104
|
+
FIRST_SCENE_MODIFICATION = "first_scene_modification"
|
|
105
|
+
MULTIPLE_SESSIONS = "multiple_sessions"
|
|
106
|
+
DAILY_ACTIVE_USER = "daily_active_user"
|
|
107
|
+
WEEKLY_ACTIVE_USER = "weekly_active_user"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class TelemetryRecord:
|
|
112
|
+
"""Structure for telemetry data"""
|
|
113
|
+
record_type: RecordType
|
|
114
|
+
timestamp: float
|
|
115
|
+
customer_uuid: str
|
|
116
|
+
session_id: str
|
|
117
|
+
data: dict[str, Any]
|
|
118
|
+
milestone: MilestoneType | None = None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class TelemetryConfig:
|
|
122
|
+
"""Telemetry configuration"""
|
|
123
|
+
|
|
124
|
+
def __init__(self):
|
|
125
|
+
"""
|
|
126
|
+
Prefer config file, then allow env overrides
|
|
127
|
+
"""
|
|
128
|
+
server_config = None
|
|
129
|
+
for modname in (
|
|
130
|
+
# Prefer plain module to respect test-time overrides and sys.path injection
|
|
131
|
+
"src.core.config",
|
|
132
|
+
"config",
|
|
133
|
+
"src.config",
|
|
134
|
+
"Server.config",
|
|
135
|
+
):
|
|
136
|
+
try:
|
|
137
|
+
mod = import_module(modname)
|
|
138
|
+
server_config = getattr(mod, "config", None)
|
|
139
|
+
if server_config is not None:
|
|
140
|
+
break
|
|
141
|
+
except Exception:
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
# Determine enabled flag: config -> env DISABLE_* opt-out
|
|
145
|
+
cfg_enabled = True if server_config is None else bool(
|
|
146
|
+
getattr(server_config, "telemetry_enabled", True))
|
|
147
|
+
self.enabled = cfg_enabled and not self._is_disabled()
|
|
148
|
+
|
|
149
|
+
# Telemetry endpoint (Cloud Run default; override via env)
|
|
150
|
+
cfg_default = None if server_config is None else getattr(
|
|
151
|
+
server_config, "telemetry_endpoint", None)
|
|
152
|
+
default_ep = cfg_default or "https://api-prod.coplay.dev/telemetry/events"
|
|
153
|
+
self.default_endpoint = default_ep
|
|
154
|
+
# Prefer config default; allow explicit env override only when set
|
|
155
|
+
env_ep = os.environ.get("UNITY_MCP_TELEMETRY_ENDPOINT")
|
|
156
|
+
if env_ep is not None and env_ep != "":
|
|
157
|
+
self.endpoint = self._validated_endpoint(env_ep, default_ep)
|
|
158
|
+
else:
|
|
159
|
+
# Validate config-provided default as well to enforce scheme/host rules
|
|
160
|
+
self.endpoint = self._validated_endpoint(default_ep, default_ep)
|
|
161
|
+
try:
|
|
162
|
+
logger.info(
|
|
163
|
+
f"Telemetry configured: endpoint={self.endpoint} (default={default_ep}), timeout_env={os.environ.get('UNITY_MCP_TELEMETRY_TIMEOUT') or '<unset>'}")
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
# Local storage for UUID and milestones
|
|
168
|
+
self.data_dir = self._get_data_directory()
|
|
169
|
+
self.uuid_file = self.data_dir / "customer_uuid.txt"
|
|
170
|
+
self.milestones_file = self.data_dir / "milestones.json"
|
|
171
|
+
|
|
172
|
+
# Request timeout (small, fail fast). Override with UNITY_MCP_TELEMETRY_TIMEOUT
|
|
173
|
+
try:
|
|
174
|
+
self.timeout = float(os.environ.get(
|
|
175
|
+
"UNITY_MCP_TELEMETRY_TIMEOUT", "1.5"))
|
|
176
|
+
except Exception:
|
|
177
|
+
self.timeout = 1.5
|
|
178
|
+
try:
|
|
179
|
+
logger.info(f"Telemetry timeout={self.timeout:.2f}s")
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
# Session tracking
|
|
184
|
+
self.session_id = str(uuid.uuid4())
|
|
185
|
+
|
|
186
|
+
def _is_disabled(self) -> bool:
|
|
187
|
+
"""Check if telemetry is disabled via environment variables"""
|
|
188
|
+
disable_vars = [
|
|
189
|
+
"DISABLE_TELEMETRY",
|
|
190
|
+
"UNITY_MCP_DISABLE_TELEMETRY",
|
|
191
|
+
"MCP_DISABLE_TELEMETRY"
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
for var in disable_vars:
|
|
195
|
+
if os.environ.get(var, "").lower() in ("true", "1", "yes", "on"):
|
|
196
|
+
return True
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
def _get_data_directory(self) -> Path:
|
|
200
|
+
"""Get directory for storing telemetry data"""
|
|
201
|
+
if os.name == 'nt': # Windows
|
|
202
|
+
base_dir = Path(os.environ.get(
|
|
203
|
+
'APPDATA', Path.home() / 'AppData' / 'Roaming'))
|
|
204
|
+
elif os.name == 'posix': # macOS/Linux
|
|
205
|
+
if 'darwin' in os.uname().sysname.lower(): # macOS
|
|
206
|
+
base_dir = Path.home() / 'Library' / 'Application Support'
|
|
207
|
+
else: # Linux
|
|
208
|
+
base_dir = Path(os.environ.get('XDG_DATA_HOME',
|
|
209
|
+
Path.home() / '.local' / 'share'))
|
|
210
|
+
else:
|
|
211
|
+
base_dir = Path.home() / '.unity-mcp'
|
|
212
|
+
|
|
213
|
+
data_dir = base_dir / 'UnityMCP'
|
|
214
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
return data_dir
|
|
216
|
+
|
|
217
|
+
def _validated_endpoint(self, candidate: str, fallback: str) -> str:
|
|
218
|
+
"""Validate telemetry endpoint URL scheme; allow only http/https.
|
|
219
|
+
Falls back to the provided default on error.
|
|
220
|
+
"""
|
|
221
|
+
try:
|
|
222
|
+
parsed = urlparse(candidate)
|
|
223
|
+
if parsed.scheme not in ("https", "http"):
|
|
224
|
+
raise ValueError(f"Unsupported scheme: {parsed.scheme}")
|
|
225
|
+
# Basic sanity: require network location and path
|
|
226
|
+
if not parsed.netloc:
|
|
227
|
+
raise ValueError("Missing netloc in endpoint")
|
|
228
|
+
# Reject localhost/loopback endpoints in production to avoid accidental local overrides
|
|
229
|
+
host = parsed.hostname or ""
|
|
230
|
+
if host in ("localhost", "127.0.0.1", "::1"):
|
|
231
|
+
raise ValueError(
|
|
232
|
+
"Localhost endpoints are not allowed for telemetry")
|
|
233
|
+
return candidate
|
|
234
|
+
except Exception as e:
|
|
235
|
+
logger.debug(
|
|
236
|
+
f"Invalid telemetry endpoint '{candidate}', using default. Error: {e}",
|
|
237
|
+
exc_info=True,
|
|
238
|
+
)
|
|
239
|
+
return fallback
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class TelemetryCollector:
|
|
243
|
+
"""Main telemetry collection class"""
|
|
244
|
+
|
|
245
|
+
def __init__(self):
|
|
246
|
+
self.config = TelemetryConfig()
|
|
247
|
+
self._customer_uuid: str | None = None
|
|
248
|
+
self._milestones: dict[str, dict[str, Any]] = {}
|
|
249
|
+
self._lock: threading.Lock = threading.Lock()
|
|
250
|
+
# Bounded queue with single background worker (records only; no context propagation)
|
|
251
|
+
self._queue: "queue.Queue[TelemetryRecord]" = queue.Queue(maxsize=1000)
|
|
252
|
+
self._shutdown: bool = False
|
|
253
|
+
# Load persistent data before starting worker so first events have UUID
|
|
254
|
+
self._load_persistent_data()
|
|
255
|
+
self._worker: threading.Thread = threading.Thread(
|
|
256
|
+
target=self._worker_loop, daemon=True)
|
|
257
|
+
self._worker.start()
|
|
258
|
+
|
|
259
|
+
def _load_persistent_data(self):
|
|
260
|
+
"""Load UUID and milestones from disk"""
|
|
261
|
+
# Load customer UUID
|
|
262
|
+
try:
|
|
263
|
+
if self.config.uuid_file.exists():
|
|
264
|
+
self._customer_uuid = self.config.uuid_file.read_text(
|
|
265
|
+
encoding="utf-8").strip() or str(uuid.uuid4())
|
|
266
|
+
else:
|
|
267
|
+
self._customer_uuid = str(uuid.uuid4())
|
|
268
|
+
try:
|
|
269
|
+
self.config.uuid_file.write_text(
|
|
270
|
+
self._customer_uuid, encoding="utf-8")
|
|
271
|
+
if os.name == "posix":
|
|
272
|
+
os.chmod(self.config.uuid_file, 0o600)
|
|
273
|
+
except OSError as e:
|
|
274
|
+
logger.debug(
|
|
275
|
+
f"Failed to persist customer UUID: {e}", exc_info=True)
|
|
276
|
+
except OSError as e:
|
|
277
|
+
logger.debug(f"Failed to load customer UUID: {e}", exc_info=True)
|
|
278
|
+
self._customer_uuid = str(uuid.uuid4())
|
|
279
|
+
|
|
280
|
+
# Load milestones (failure here must not affect UUID)
|
|
281
|
+
try:
|
|
282
|
+
if self.config.milestones_file.exists():
|
|
283
|
+
content = self.config.milestones_file.read_text(
|
|
284
|
+
encoding="utf-8")
|
|
285
|
+
self._milestones = json.loads(content) or {}
|
|
286
|
+
if not isinstance(self._milestones, dict):
|
|
287
|
+
self._milestones = {}
|
|
288
|
+
except (OSError, json.JSONDecodeError, ValueError) as e:
|
|
289
|
+
logger.debug(f"Failed to load milestones: {e}", exc_info=True)
|
|
290
|
+
self._milestones = {}
|
|
291
|
+
|
|
292
|
+
def _save_milestones(self):
|
|
293
|
+
"""Save milestones to disk. Caller must hold self._lock."""
|
|
294
|
+
try:
|
|
295
|
+
self.config.milestones_file.write_text(
|
|
296
|
+
json.dumps(self._milestones, indent=2),
|
|
297
|
+
encoding="utf-8",
|
|
298
|
+
)
|
|
299
|
+
except OSError as e:
|
|
300
|
+
logger.warning(f"Failed to save milestones: {e}", exc_info=True)
|
|
301
|
+
|
|
302
|
+
def record_milestone(self, milestone: MilestoneType, data: dict[str, Any] | None = None) -> bool:
|
|
303
|
+
"""Record a milestone event, returns True if this is the first occurrence"""
|
|
304
|
+
if not self.config.enabled:
|
|
305
|
+
return False
|
|
306
|
+
milestone_key = milestone.value
|
|
307
|
+
with self._lock:
|
|
308
|
+
if milestone_key in self._milestones:
|
|
309
|
+
return False # Already recorded
|
|
310
|
+
milestone_data = {
|
|
311
|
+
"timestamp": time.time(),
|
|
312
|
+
"data": data or {},
|
|
313
|
+
}
|
|
314
|
+
self._milestones[milestone_key] = milestone_data
|
|
315
|
+
self._save_milestones()
|
|
316
|
+
|
|
317
|
+
# Also send as telemetry record
|
|
318
|
+
self.record(
|
|
319
|
+
record_type=RecordType.USAGE,
|
|
320
|
+
data={"milestone": milestone_key, **(data or {})},
|
|
321
|
+
milestone=milestone
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
return True
|
|
325
|
+
|
|
326
|
+
def record(self,
|
|
327
|
+
record_type: RecordType,
|
|
328
|
+
data: dict[str, Any],
|
|
329
|
+
milestone: MilestoneType | None = None):
|
|
330
|
+
"""Record a telemetry event (async, non-blocking)"""
|
|
331
|
+
if not self.config.enabled:
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
# Allow fallback sender when httpx is unavailable (no early return)
|
|
335
|
+
|
|
336
|
+
record = TelemetryRecord(
|
|
337
|
+
record_type=record_type,
|
|
338
|
+
timestamp=time.time(),
|
|
339
|
+
customer_uuid=self._customer_uuid or "unknown",
|
|
340
|
+
session_id=self.config.session_id,
|
|
341
|
+
data=data,
|
|
342
|
+
milestone=milestone
|
|
343
|
+
)
|
|
344
|
+
# Enqueue for background worker (non-blocking). Drop on backpressure.
|
|
345
|
+
try:
|
|
346
|
+
self._queue.put_nowait(record)
|
|
347
|
+
except queue.Full:
|
|
348
|
+
logger.debug(
|
|
349
|
+
f"Telemetry queue full; dropping {record.record_type}")
|
|
350
|
+
|
|
351
|
+
def _worker_loop(self):
|
|
352
|
+
"""Background worker that serializes telemetry sends."""
|
|
353
|
+
while not self._shutdown:
|
|
354
|
+
try:
|
|
355
|
+
rec = self._queue.get(timeout=0.5)
|
|
356
|
+
except queue.Empty:
|
|
357
|
+
continue
|
|
358
|
+
try:
|
|
359
|
+
# Run sender directly; do not reuse caller context/thread-locals
|
|
360
|
+
self._send_telemetry(rec)
|
|
361
|
+
except Exception:
|
|
362
|
+
logger.debug("Telemetry worker send failed", exc_info=True)
|
|
363
|
+
finally:
|
|
364
|
+
with contextlib.suppress(Exception):
|
|
365
|
+
self._queue.task_done()
|
|
366
|
+
|
|
367
|
+
def shutdown(self):
|
|
368
|
+
"""Shutdown the telemetry collector and worker thread."""
|
|
369
|
+
self._shutdown = True
|
|
370
|
+
if self._worker and self._worker.is_alive():
|
|
371
|
+
self._worker.join(timeout=2.0)
|
|
372
|
+
|
|
373
|
+
def _send_telemetry(self, record: TelemetryRecord):
|
|
374
|
+
"""Send telemetry data to endpoint"""
|
|
375
|
+
try:
|
|
376
|
+
# System fingerprint (top-level remains concise; details stored in data JSON)
|
|
377
|
+
_platform = platform.system() # 'Darwin' | 'Linux' | 'Windows'
|
|
378
|
+
_source = sys.platform # 'darwin' | 'linux' | 'win32'
|
|
379
|
+
_platform_detail = f"{_platform} {platform.release()} ({platform.machine()})"
|
|
380
|
+
_python_version = platform.python_version()
|
|
381
|
+
|
|
382
|
+
# Enrich data JSON so BigQuery stores detailed fields without schema change
|
|
383
|
+
enriched_data = dict(record.data or {})
|
|
384
|
+
enriched_data.setdefault("platform_detail", _platform_detail)
|
|
385
|
+
enriched_data.setdefault("python_version", _python_version)
|
|
386
|
+
|
|
387
|
+
payload = {
|
|
388
|
+
"record": record.record_type.value,
|
|
389
|
+
"timestamp": record.timestamp,
|
|
390
|
+
"customer_uuid": record.customer_uuid,
|
|
391
|
+
"session_id": record.session_id,
|
|
392
|
+
"data": enriched_data,
|
|
393
|
+
"version": MCP_VERSION,
|
|
394
|
+
"platform": _platform,
|
|
395
|
+
"source": _source,
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if record.milestone:
|
|
399
|
+
payload["milestone"] = record.milestone.value
|
|
400
|
+
|
|
401
|
+
# Prefer httpx when available; otherwise fall back to urllib
|
|
402
|
+
if httpx:
|
|
403
|
+
with httpx.Client(timeout=self.config.timeout) as client:
|
|
404
|
+
# Re-validate endpoint at send time to handle dynamic changes
|
|
405
|
+
endpoint = self.config._validated_endpoint(
|
|
406
|
+
self.config.endpoint, self.config.default_endpoint)
|
|
407
|
+
response = client.post(endpoint, json=payload)
|
|
408
|
+
if 200 <= response.status_code < 300:
|
|
409
|
+
logger.debug(f"Telemetry sent: {record.record_type}")
|
|
410
|
+
else:
|
|
411
|
+
logger.warning(
|
|
412
|
+
f"Telemetry failed: HTTP {response.status_code}")
|
|
413
|
+
else:
|
|
414
|
+
import urllib.request
|
|
415
|
+
import urllib.error
|
|
416
|
+
data_bytes = json.dumps(payload).encode("utf-8")
|
|
417
|
+
endpoint = self.config._validated_endpoint(
|
|
418
|
+
self.config.endpoint, self.config.default_endpoint)
|
|
419
|
+
req = urllib.request.Request(
|
|
420
|
+
endpoint,
|
|
421
|
+
data=data_bytes,
|
|
422
|
+
headers={"Content-Type": "application/json"},
|
|
423
|
+
method="POST",
|
|
424
|
+
)
|
|
425
|
+
try:
|
|
426
|
+
with urllib.request.urlopen(req, timeout=self.config.timeout) as resp:
|
|
427
|
+
if 200 <= resp.getcode() < 300:
|
|
428
|
+
logger.debug(
|
|
429
|
+
f"Telemetry sent (urllib): {record.record_type}")
|
|
430
|
+
else:
|
|
431
|
+
logger.warning(
|
|
432
|
+
f"Telemetry failed (urllib): HTTP {resp.getcode()}")
|
|
433
|
+
except urllib.error.URLError as ue:
|
|
434
|
+
logger.warning(f"Telemetry send failed (urllib): {ue}")
|
|
435
|
+
|
|
436
|
+
except Exception as e:
|
|
437
|
+
# Never let telemetry errors interfere with app functionality
|
|
438
|
+
logger.debug(f"Telemetry send failed: {e}")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# Global telemetry instance
|
|
442
|
+
_telemetry_collector: TelemetryCollector | None = None
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def get_telemetry() -> TelemetryCollector:
|
|
446
|
+
"""Get the global telemetry collector instance"""
|
|
447
|
+
global _telemetry_collector
|
|
448
|
+
if _telemetry_collector is None:
|
|
449
|
+
_telemetry_collector = TelemetryCollector()
|
|
450
|
+
return _telemetry_collector
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def reset_telemetry():
|
|
454
|
+
"""Reset the global telemetry collector. For testing only."""
|
|
455
|
+
global _telemetry_collector
|
|
456
|
+
if _telemetry_collector is not None:
|
|
457
|
+
_telemetry_collector.shutdown()
|
|
458
|
+
_telemetry_collector = None
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def record_telemetry(record_type: RecordType,
|
|
462
|
+
data: dict[str, Any],
|
|
463
|
+
milestone: MilestoneType | None = None):
|
|
464
|
+
"""Convenience function to record telemetry"""
|
|
465
|
+
get_telemetry().record(record_type, data, milestone)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def record_milestone(milestone: MilestoneType, data: dict[str, Any] | None = None) -> bool:
|
|
469
|
+
"""Convenience function to record a milestone"""
|
|
470
|
+
return get_telemetry().record_milestone(milestone, data)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def record_tool_usage(tool_name: str, success: bool, duration_ms: float, error: str | None = None, sub_action: str | None = None):
|
|
474
|
+
"""Record tool usage telemetry
|
|
475
|
+
|
|
476
|
+
Args:
|
|
477
|
+
tool_name: Name of the tool invoked (e.g., 'manage_scene').
|
|
478
|
+
success: Whether the tool completed successfully.
|
|
479
|
+
duration_ms: Execution duration in milliseconds.
|
|
480
|
+
error: Optional error message (truncated if present).
|
|
481
|
+
sub_action: Optional sub-action/operation within the tool (e.g., 'get_hierarchy').
|
|
482
|
+
"""
|
|
483
|
+
data = {
|
|
484
|
+
"tool_name": tool_name,
|
|
485
|
+
"success": success,
|
|
486
|
+
"duration_ms": round(duration_ms, 2)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if sub_action is not None:
|
|
490
|
+
try:
|
|
491
|
+
data["sub_action"] = str(sub_action)
|
|
492
|
+
except Exception:
|
|
493
|
+
# Ensure telemetry is never disruptive
|
|
494
|
+
data["sub_action"] = "unknown"
|
|
495
|
+
|
|
496
|
+
if error:
|
|
497
|
+
data["error"] = str(error)[:200] # Limit error message length
|
|
498
|
+
|
|
499
|
+
record_telemetry(RecordType.TOOL_EXECUTION, data)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def record_resource_usage(resource_name: str, success: bool, duration_ms: float, error: str | None = None):
|
|
503
|
+
"""Record resource usage telemetry
|
|
504
|
+
|
|
505
|
+
Args:
|
|
506
|
+
resource_name: Name of the resource invoked (e.g., 'get_tests').
|
|
507
|
+
success: Whether the resource completed successfully.
|
|
508
|
+
duration_ms: Execution duration in milliseconds.
|
|
509
|
+
error: Optional error message (truncated if present).
|
|
510
|
+
"""
|
|
511
|
+
data = {
|
|
512
|
+
"resource_name": resource_name,
|
|
513
|
+
"success": success,
|
|
514
|
+
"duration_ms": round(duration_ms, 2)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if error:
|
|
518
|
+
data["error"] = str(error)[:200] # Limit error message length
|
|
519
|
+
|
|
520
|
+
record_telemetry(RecordType.RESOURCE_RETRIEVAL, data)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def record_latency(operation: str, duration_ms: float, metadata: dict[str, Any] | None = None):
|
|
524
|
+
"""Record latency telemetry"""
|
|
525
|
+
data = {
|
|
526
|
+
"operation": operation,
|
|
527
|
+
"duration_ms": round(duration_ms, 2)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if metadata:
|
|
531
|
+
data.update(metadata)
|
|
532
|
+
|
|
533
|
+
record_telemetry(RecordType.LATENCY, data)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def record_failure(component: str, error: str, metadata: dict[str, Any] | None = None):
|
|
537
|
+
"""Record failure telemetry"""
|
|
538
|
+
data = {
|
|
539
|
+
"component": component,
|
|
540
|
+
"error": str(error)[:500] # Limit error message length
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if metadata:
|
|
544
|
+
data.update(metadata)
|
|
545
|
+
|
|
546
|
+
record_telemetry(RecordType.FAILURE, data)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def is_telemetry_enabled() -> bool:
|
|
550
|
+
"""Check if telemetry is enabled"""
|
|
551
|
+
return get_telemetry().config.enabled
|