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.
Files changed (105) hide show
  1. cli/__init__.py +3 -0
  2. cli/commands/__init__.py +3 -0
  3. cli/commands/animation.py +84 -0
  4. cli/commands/asset.py +280 -0
  5. cli/commands/audio.py +125 -0
  6. cli/commands/batch.py +171 -0
  7. cli/commands/code.py +182 -0
  8. cli/commands/component.py +190 -0
  9. cli/commands/editor.py +447 -0
  10. cli/commands/gameobject.py +487 -0
  11. cli/commands/instance.py +93 -0
  12. cli/commands/lighting.py +123 -0
  13. cli/commands/material.py +239 -0
  14. cli/commands/prefab.py +248 -0
  15. cli/commands/scene.py +231 -0
  16. cli/commands/script.py +222 -0
  17. cli/commands/shader.py +226 -0
  18. cli/commands/texture.py +540 -0
  19. cli/commands/tool.py +58 -0
  20. cli/commands/ui.py +258 -0
  21. cli/commands/vfx.py +421 -0
  22. cli/main.py +281 -0
  23. cli/utils/__init__.py +31 -0
  24. cli/utils/config.py +58 -0
  25. cli/utils/confirmation.py +37 -0
  26. cli/utils/connection.py +254 -0
  27. cli/utils/constants.py +23 -0
  28. cli/utils/output.py +195 -0
  29. cli/utils/parsers.py +112 -0
  30. cli/utils/suggestions.py +34 -0
  31. core/__init__.py +0 -0
  32. core/config.py +67 -0
  33. core/constants.py +4 -0
  34. core/logging_decorator.py +37 -0
  35. core/telemetry.py +551 -0
  36. core/telemetry_decorator.py +164 -0
  37. main.py +845 -0
  38. mcpforunityserver-9.4.0b20260203025228.dist-info/METADATA +328 -0
  39. mcpforunityserver-9.4.0b20260203025228.dist-info/RECORD +105 -0
  40. mcpforunityserver-9.4.0b20260203025228.dist-info/WHEEL +5 -0
  41. mcpforunityserver-9.4.0b20260203025228.dist-info/entry_points.txt +3 -0
  42. mcpforunityserver-9.4.0b20260203025228.dist-info/licenses/LICENSE +21 -0
  43. mcpforunityserver-9.4.0b20260203025228.dist-info/top_level.txt +7 -0
  44. models/__init__.py +4 -0
  45. models/models.py +56 -0
  46. models/unity_response.py +70 -0
  47. services/__init__.py +0 -0
  48. services/api_key_service.py +235 -0
  49. services/custom_tool_service.py +499 -0
  50. services/registry/__init__.py +22 -0
  51. services/registry/resource_registry.py +53 -0
  52. services/registry/tool_registry.py +51 -0
  53. services/resources/__init__.py +86 -0
  54. services/resources/active_tool.py +48 -0
  55. services/resources/custom_tools.py +57 -0
  56. services/resources/editor_state.py +304 -0
  57. services/resources/gameobject.py +243 -0
  58. services/resources/layers.py +30 -0
  59. services/resources/menu_items.py +35 -0
  60. services/resources/prefab.py +191 -0
  61. services/resources/prefab_stage.py +40 -0
  62. services/resources/project_info.py +40 -0
  63. services/resources/selection.py +56 -0
  64. services/resources/tags.py +31 -0
  65. services/resources/tests.py +88 -0
  66. services/resources/unity_instances.py +125 -0
  67. services/resources/windows.py +48 -0
  68. services/state/external_changes_scanner.py +245 -0
  69. services/tools/__init__.py +83 -0
  70. services/tools/batch_execute.py +93 -0
  71. services/tools/debug_request_context.py +86 -0
  72. services/tools/execute_custom_tool.py +43 -0
  73. services/tools/execute_menu_item.py +32 -0
  74. services/tools/find_gameobjects.py +110 -0
  75. services/tools/find_in_file.py +181 -0
  76. services/tools/manage_asset.py +119 -0
  77. services/tools/manage_components.py +131 -0
  78. services/tools/manage_editor.py +64 -0
  79. services/tools/manage_gameobject.py +260 -0
  80. services/tools/manage_material.py +111 -0
  81. services/tools/manage_prefabs.py +209 -0
  82. services/tools/manage_scene.py +111 -0
  83. services/tools/manage_script.py +645 -0
  84. services/tools/manage_scriptable_object.py +87 -0
  85. services/tools/manage_shader.py +71 -0
  86. services/tools/manage_texture.py +581 -0
  87. services/tools/manage_vfx.py +120 -0
  88. services/tools/preflight.py +110 -0
  89. services/tools/read_console.py +151 -0
  90. services/tools/refresh_unity.py +153 -0
  91. services/tools/run_tests.py +317 -0
  92. services/tools/script_apply_edits.py +1006 -0
  93. services/tools/set_active_instance.py +120 -0
  94. services/tools/utils.py +348 -0
  95. transport/__init__.py +0 -0
  96. transport/legacy/port_discovery.py +329 -0
  97. transport/legacy/stdio_port_registry.py +65 -0
  98. transport/legacy/unity_connection.py +910 -0
  99. transport/models.py +68 -0
  100. transport/plugin_hub.py +787 -0
  101. transport/plugin_registry.py +182 -0
  102. transport/unity_instance_middleware.py +262 -0
  103. transport/unity_transport.py +94 -0
  104. utils/focus_nudge.py +589 -0
  105. 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