added optional telemetry
parent
22e8016aee
commit
e5039c8acc
12
README.md
12
README.md
|
|
@ -331,6 +331,18 @@ Help make MCP for Unity better!
|
|||
|
||||
---
|
||||
|
||||
## 📊 Telemetry & Privacy
|
||||
|
||||
Unity MCP includes **privacy-focused, anonymous telemetry** to help us improve the product. We collect usage analytics and performance data, but **never** your code, project names, or personal information.
|
||||
|
||||
- **🔒 Anonymous**: Random UUIDs only, no personal data
|
||||
- **🚫 Easy opt-out**: Set `DISABLE_TELEMETRY=true` environment variable
|
||||
- **📖 Transparent**: See [TELEMETRY.md](TELEMETRY.md) for full details
|
||||
|
||||
Your privacy matters to us. All telemetry is optional and designed to respect your workflow.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting ❓
|
||||
|
||||
<details>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
# Unity MCP Telemetry
|
||||
|
||||
Unity MCP includes privacy-focused, anonymous telemetry to help us improve the product. This document explains what data is collected, how to opt out, and our privacy practices.
|
||||
|
||||
## 🔒 Privacy First
|
||||
|
||||
- **Anonymous**: We use randomly generated UUIDs - no personal information
|
||||
- **Non-blocking**: Telemetry never interferes with your Unity workflow
|
||||
- **Easy opt-out**: Simple environment variable or Unity Editor setting
|
||||
- **Transparent**: All collected data types are documented here
|
||||
|
||||
## 📊 What We Collect
|
||||
|
||||
### Usage Analytics
|
||||
- **Tool Usage**: Which MCP tools you use (manage_script, manage_scene, etc.)
|
||||
- **Performance**: Execution times and success/failure rates
|
||||
- **System Info**: Unity version, platform (Windows/Mac/Linux), MCP version
|
||||
- **Milestones**: First-time usage events (first script creation, first tool use, etc.)
|
||||
|
||||
### Technical Diagnostics
|
||||
- **Connection Events**: Bridge startup/connection success/failures
|
||||
- **Error Reports**: Anonymized error messages (truncated to 200 chars)
|
||||
- **Server Health**: Startup time, connection latency
|
||||
|
||||
### What We **DON'T** Collect
|
||||
- ❌ Your code or script contents
|
||||
- ❌ Project names, file names, or paths
|
||||
- ❌ Personal information or identifiers
|
||||
- ❌ Sensitive project data
|
||||
- ❌ IP addresses (beyond what's needed for HTTP requests)
|
||||
|
||||
## 🚫 How to Opt Out
|
||||
|
||||
### Method 1: Environment Variable (Recommended)
|
||||
Set any of these environment variables to `true`:
|
||||
|
||||
```bash
|
||||
# Disable all telemetry
|
||||
export DISABLE_TELEMETRY=true
|
||||
|
||||
# Unity MCP specific
|
||||
export UNITY_MCP_DISABLE_TELEMETRY=true
|
||||
|
||||
# MCP protocol wide
|
||||
export MCP_DISABLE_TELEMETRY=true
|
||||
```
|
||||
|
||||
### Method 2: Unity Editor (Coming Soon)
|
||||
In Unity Editor: `Window > MCP for Unity > Settings > Disable Telemetry`
|
||||
|
||||
### Method 3: Manual Config
|
||||
Add to your MCP client config:
|
||||
```json
|
||||
{
|
||||
"env": {
|
||||
"DISABLE_TELEMETRY": "true"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 Technical Implementation
|
||||
|
||||
### Architecture
|
||||
- **Python Server**: Core telemetry collection and transmission
|
||||
- **Unity Bridge**: Local event collection from Unity Editor
|
||||
- **Anonymous UUIDs**: Generated per-installation for aggregate analytics
|
||||
- **Thread-safe**: Non-blocking background transmission
|
||||
- **Fail-safe**: Errors never interrupt your workflow
|
||||
|
||||
### Data Storage
|
||||
Telemetry data is stored locally in:
|
||||
- **Windows**: `%APPDATA%\UnityMCP\`
|
||||
- **macOS**: `~/Library/Application Support/UnityMCP/`
|
||||
- **Linux**: `~/.local/share/UnityMCP/`
|
||||
|
||||
Files created:
|
||||
- `customer_uuid.txt`: Anonymous identifier
|
||||
- `milestones.json`: One-time events tracker
|
||||
|
||||
### Data Transmission
|
||||
- **Endpoint**: `https://telemetry.coplay.dev/unity-mcp/anonymous`
|
||||
- **Method**: HTTPS POST with JSON payload
|
||||
- **Retry**: Background thread with graceful failure
|
||||
- **Timeout**: 10 second timeout, no retries on failure
|
||||
|
||||
## 📈 How We Use This Data
|
||||
|
||||
### Product Improvement
|
||||
- **Feature Usage**: Understand which tools are most/least used
|
||||
- **Performance**: Identify slow operations to optimize
|
||||
- **Reliability**: Track error rates and connection issues
|
||||
- **Compatibility**: Ensure Unity version compatibility
|
||||
|
||||
### Development Priorities
|
||||
- **Roadmap**: Focus development on most-used features
|
||||
- **Bug Fixes**: Prioritize fixes based on error frequency
|
||||
- **Platform Support**: Allocate resources based on platform usage
|
||||
- **Documentation**: Improve docs for commonly problematic areas
|
||||
|
||||
### What We Don't Do
|
||||
- ❌ Sell data to third parties
|
||||
- ❌ Use data for advertising/marketing
|
||||
- ❌ Track individual developers
|
||||
- ❌ Store sensitive project information
|
||||
|
||||
## 🛠️ For Developers
|
||||
|
||||
### Testing Telemetry
|
||||
```bash
|
||||
cd UnityMcpBridge/UnityMcpServer~/src
|
||||
python test_telemetry.py
|
||||
```
|
||||
|
||||
### Custom Telemetry Events
|
||||
```python
|
||||
from telemetry import record_telemetry, RecordType
|
||||
|
||||
record_telemetry(RecordType.USAGE, {
|
||||
"custom_event": "my_feature_used",
|
||||
"metadata": "optional_data"
|
||||
})
|
||||
```
|
||||
|
||||
### Telemetry Status Check
|
||||
```python
|
||||
from telemetry import is_telemetry_enabled
|
||||
|
||||
if is_telemetry_enabled():
|
||||
print("Telemetry is active")
|
||||
else:
|
||||
print("Telemetry is disabled")
|
||||
```
|
||||
|
||||
## 📋 Data Retention Policy
|
||||
|
||||
- **Aggregated Data**: Retained indefinitely for product insights
|
||||
- **Raw Events**: Automatically purged after 90 days
|
||||
- **Personal Data**: None collected, so none to purge
|
||||
- **Opt-out**: Immediate - no data sent after opting out
|
||||
|
||||
## 🤝 Contact & Transparency
|
||||
|
||||
- **Questions**: [Discord Community](https://discord.gg/y4p8KfzrN4)
|
||||
- **Issues**: [GitHub Issues](https://github.com/CoplayDev/unity-mcp/issues)
|
||||
- **Privacy Concerns**: Create a GitHub issue with "Privacy" label
|
||||
- **Source Code**: All telemetry code is open source in this repository
|
||||
|
||||
## 📊 Example Telemetry Event
|
||||
|
||||
Here's what a typical telemetry event looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"record": "tool_execution",
|
||||
"timestamp": 1704067200,
|
||||
"customer_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"session_id": "abc123-def456-ghi789",
|
||||
"version": "3.0.2",
|
||||
"platform": "posix",
|
||||
"data": {
|
||||
"tool_name": "manage_script",
|
||||
"success": true,
|
||||
"duration_ms": 42.5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notice:
|
||||
- ✅ Anonymous UUID (randomly generated)
|
||||
- ✅ Tool performance metrics
|
||||
- ✅ Success/failure tracking
|
||||
- ❌ No code content
|
||||
- ❌ No project information
|
||||
- ❌ No personal data
|
||||
|
||||
---
|
||||
|
||||
*Unity MCP Telemetry is designed to respect your privacy while helping us build a better tool. Thank you for helping improve Unity MCP!*
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Unity Bridge telemetry helper for collecting usage analytics
|
||||
/// Following privacy-first approach with easy opt-out mechanisms
|
||||
/// </summary>
|
||||
public static class TelemetryHelper
|
||||
{
|
||||
private const string TELEMETRY_DISABLED_KEY = "MCPForUnity.TelemetryDisabled";
|
||||
private const string CUSTOMER_UUID_KEY = "MCPForUnity.CustomerUUID";
|
||||
|
||||
/// <summary>
|
||||
/// Check if telemetry is enabled (can be disabled via Environment Variable or EditorPrefs)
|
||||
/// </summary>
|
||||
public static bool IsEnabled
|
||||
{
|
||||
get
|
||||
{
|
||||
// Check environment variables first
|
||||
var envDisable = Environment.GetEnvironmentVariable("DISABLE_TELEMETRY");
|
||||
if (!string.IsNullOrEmpty(envDisable) &&
|
||||
(envDisable.ToLower() == "true" || envDisable == "1"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var unityMcpDisable = Environment.GetEnvironmentVariable("UNITY_MCP_DISABLE_TELEMETRY");
|
||||
if (!string.IsNullOrEmpty(unityMcpDisable) &&
|
||||
(unityMcpDisable.ToLower() == "true" || unityMcpDisable == "1"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check EditorPrefs
|
||||
return !UnityEditor.EditorPrefs.GetBool(TELEMETRY_DISABLED_KEY, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get or generate customer UUID for anonymous tracking
|
||||
/// </summary>
|
||||
public static string GetCustomerUUID()
|
||||
{
|
||||
var uuid = UnityEditor.EditorPrefs.GetString(CUSTOMER_UUID_KEY, "");
|
||||
if (string.IsNullOrEmpty(uuid))
|
||||
{
|
||||
uuid = System.Guid.NewGuid().ToString();
|
||||
UnityEditor.EditorPrefs.SetString(CUSTOMER_UUID_KEY, uuid);
|
||||
}
|
||||
return uuid;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disable telemetry (stored in EditorPrefs)
|
||||
/// </summary>
|
||||
public static void DisableTelemetry()
|
||||
{
|
||||
UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enable telemetry (stored in EditorPrefs)
|
||||
/// </summary>
|
||||
public static void EnableTelemetry()
|
||||
{
|
||||
UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send telemetry data to Python server for processing
|
||||
/// This is a lightweight bridge - the actual telemetry logic is in Python
|
||||
/// </summary>
|
||||
public static void RecordEvent(string eventType, Dictionary<string, object> data = null)
|
||||
{
|
||||
if (!IsEnabled)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var telemetryData = new Dictionary<string, object>
|
||||
{
|
||||
["event_type"] = eventType,
|
||||
["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||
["customer_uuid"] = GetCustomerUUID(),
|
||||
["unity_version"] = Application.unityVersion,
|
||||
["platform"] = Application.platform.ToString(),
|
||||
["source"] = "unity_bridge"
|
||||
};
|
||||
|
||||
if (data != null)
|
||||
{
|
||||
telemetryData["data"] = data;
|
||||
}
|
||||
|
||||
// Send to Python server via existing bridge communication
|
||||
// The Python server will handle actual telemetry transmission
|
||||
SendTelemetryToPythonServer(telemetryData);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// Never let telemetry errors interfere with functionality
|
||||
if (IsDebugEnabled())
|
||||
{
|
||||
Debug.LogWarning($"Telemetry error (non-blocking): {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record bridge startup event
|
||||
/// </summary>
|
||||
public static void RecordBridgeStartup()
|
||||
{
|
||||
RecordEvent("bridge_startup", new Dictionary<string, object>
|
||||
{
|
||||
["bridge_version"] = "3.0.2",
|
||||
["auto_connect"] = MCPForUnityBridge.IsAutoConnectMode
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record bridge connection event
|
||||
/// </summary>
|
||||
public static void RecordBridgeConnection(bool success, string error = null)
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["success"] = success
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
data["error"] = error.Substring(0, Math.Min(200, error.Length));
|
||||
}
|
||||
|
||||
RecordEvent("bridge_connection", data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record tool execution from Unity side
|
||||
/// </summary>
|
||||
public static void RecordToolExecution(string toolName, bool success, float durationMs, string error = null)
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["tool_name"] = toolName,
|
||||
["success"] = success,
|
||||
["duration_ms"] = Math.Round(durationMs, 2)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
data["error"] = error.Substring(0, Math.Min(200, error.Length));
|
||||
}
|
||||
|
||||
RecordEvent("tool_execution_unity", data);
|
||||
}
|
||||
|
||||
private static void SendTelemetryToPythonServer(Dictionary<string, object> telemetryData)
|
||||
{
|
||||
// This would integrate with the existing bridge communication system
|
||||
// For now, we'll just log it when debug is enabled
|
||||
if (IsDebugEnabled())
|
||||
{
|
||||
Debug.Log($"<b><color=#2EA3FF>MCP-TELEMETRY</color></b>: {telemetryData["event_type"]}");
|
||||
}
|
||||
|
||||
// TODO: Integrate with MCPForUnityBridge command system
|
||||
// We would send this as a special telemetry command to the Python server
|
||||
}
|
||||
|
||||
private static bool IsDebugEnabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
return UnityEditor.EditorPrefs.GetBool("MCPForUnity.DebugLogs", false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b8f3c2d1e7a94f6c8a9b5e3d2c1a0f9e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -67,10 +67,16 @@ namespace MCPForUnity.Editor
|
|||
currentUnityPort = PortManager.GetPortWithFallback();
|
||||
Start();
|
||||
isAutoConnectMode = true;
|
||||
|
||||
// Record telemetry for bridge startup
|
||||
TelemetryHelper.RecordBridgeStartup();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"Auto-connect failed: {ex.Message}");
|
||||
|
||||
// Record telemetry for connection failure
|
||||
TelemetryHelper.RecordBridgeConnection(false, ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,5 +31,9 @@ class ServerConfig:
|
|||
# 40 × 250ms ≈ 10s default window
|
||||
reload_max_retries: int = 40
|
||||
|
||||
# Telemetry settings
|
||||
telemetry_enabled: bool = True
|
||||
telemetry_endpoint: str = "https://telemetry.coplay.dev/unity-mcp/anonymous"
|
||||
|
||||
# Create a global config instance
|
||||
config = ServerConfig()
|
||||
|
|
@ -6,6 +6,8 @@ from typing import AsyncIterator, Dict, Any, List
|
|||
from config import config
|
||||
from tools import register_all_tools
|
||||
from unity_connection import get_unity_connection, UnityConnection
|
||||
from telemetry import record_telemetry, record_milestone, RecordType, MilestoneType
|
||||
import time
|
||||
|
||||
# Configure logging using settings from config
|
||||
logging.basicConfig(
|
||||
|
|
@ -22,12 +24,38 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
|
|||
"""Handle server startup and shutdown."""
|
||||
global _unity_connection
|
||||
logger.info("MCP for Unity Server starting up")
|
||||
|
||||
# Record server startup telemetry
|
||||
start_time = time.time()
|
||||
record_telemetry(RecordType.STARTUP, {
|
||||
"server_version": "3.0.2",
|
||||
"startup_time": start_time
|
||||
})
|
||||
|
||||
# Record first startup milestone
|
||||
record_milestone(MilestoneType.FIRST_STARTUP)
|
||||
|
||||
try:
|
||||
_unity_connection = get_unity_connection()
|
||||
logger.info("Connected to Unity on startup")
|
||||
|
||||
# Record successful Unity connection
|
||||
record_telemetry(RecordType.UNITY_CONNECTION, {
|
||||
"status": "connected",
|
||||
"connection_time_ms": (time.time() - start_time) * 1000
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not connect to Unity on startup: {str(e)}")
|
||||
_unity_connection = None
|
||||
|
||||
# Record connection failure
|
||||
record_telemetry(RecordType.UNITY_CONNECTION, {
|
||||
"status": "failed",
|
||||
"error": str(e)[:200],
|
||||
"connection_time_ms": (time.time() - start_time) * 1000
|
||||
})
|
||||
|
||||
try:
|
||||
# Yield the connection object so it can be attached to the context
|
||||
# The key 'bridge' matches how tools like read_console expect to access it (ctx.bridge)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,289 @@
|
|||
"""
|
||||
Privacy-focused, anonymous telemetry system for Unity MCP
|
||||
Inspired by Onyx's telemetry implementation with Unity-specific adaptations
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import threading
|
||||
import contextvars
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
import logging
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Optional, Dict, Any, List
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import httpx
|
||||
HAS_HTTPX = True
|
||||
except ImportError:
|
||||
httpx = None # type: ignore
|
||||
HAS_HTTPX = False
|
||||
|
||||
logger = logging.getLogger("unity-mcp-telemetry")
|
||||
|
||||
class RecordType(str, Enum):
|
||||
"""Types of telemetry records we collect"""
|
||||
VERSION = "version"
|
||||
STARTUP = "startup"
|
||||
USAGE = "usage"
|
||||
LATENCY = "latency"
|
||||
FAILURE = "failure"
|
||||
TOOL_EXECUTION = "tool_execution"
|
||||
UNITY_CONNECTION = "unity_connection"
|
||||
CLIENT_CONNECTION = "client_connection"
|
||||
|
||||
class MilestoneType(str, Enum):
|
||||
"""Major user journey milestones"""
|
||||
FIRST_STARTUP = "first_startup"
|
||||
FIRST_TOOL_USAGE = "first_tool_usage"
|
||||
FIRST_SCRIPT_CREATION = "first_script_creation"
|
||||
FIRST_SCENE_MODIFICATION = "first_scene_modification"
|
||||
MULTIPLE_SESSIONS = "multiple_sessions"
|
||||
DAILY_ACTIVE_USER = "daily_active_user"
|
||||
WEEKLY_ACTIVE_USER = "weekly_active_user"
|
||||
|
||||
@dataclass
|
||||
class TelemetryRecord:
|
||||
"""Structure for telemetry data"""
|
||||
record_type: RecordType
|
||||
timestamp: float
|
||||
customer_uuid: str
|
||||
session_id: str
|
||||
data: Dict[str, Any]
|
||||
milestone: Optional[MilestoneType] = None
|
||||
|
||||
class TelemetryConfig:
|
||||
"""Telemetry configuration"""
|
||||
def __init__(self):
|
||||
# Check environment variables for opt-out
|
||||
self.enabled = not self._is_disabled()
|
||||
|
||||
# Telemetry endpoint - can be configured via environment
|
||||
self.endpoint = os.environ.get(
|
||||
"UNITY_MCP_TELEMETRY_ENDPOINT",
|
||||
"https://telemetry.coplay.dev/unity-mcp/anonymous"
|
||||
)
|
||||
|
||||
# Local storage for UUID and milestones
|
||||
self.data_dir = self._get_data_directory()
|
||||
self.uuid_file = self.data_dir / "customer_uuid.txt"
|
||||
self.milestones_file = self.data_dir / "milestones.json"
|
||||
|
||||
# Request timeout
|
||||
self.timeout = 10.0
|
||||
|
||||
# Session tracking
|
||||
self.session_id = str(uuid.uuid4())
|
||||
|
||||
def _is_disabled(self) -> bool:
|
||||
"""Check if telemetry is disabled via environment variables"""
|
||||
disable_vars = [
|
||||
"DISABLE_TELEMETRY",
|
||||
"UNITY_MCP_DISABLE_TELEMETRY",
|
||||
"MCP_DISABLE_TELEMETRY"
|
||||
]
|
||||
|
||||
for var in disable_vars:
|
||||
if os.environ.get(var, "").lower() in ("true", "1", "yes", "on"):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _get_data_directory(self) -> Path:
|
||||
"""Get directory for storing telemetry data"""
|
||||
if os.name == 'nt': # Windows
|
||||
base_dir = Path(os.environ.get('APPDATA', Path.home() / 'AppData' / 'Roaming'))
|
||||
elif os.name == 'posix': # macOS/Linux
|
||||
if 'darwin' in os.uname().sysname.lower(): # macOS
|
||||
base_dir = Path.home() / 'Library' / 'Application Support'
|
||||
else: # Linux
|
||||
base_dir = Path(os.environ.get('XDG_DATA_HOME', Path.home() / '.local' / 'share'))
|
||||
else:
|
||||
base_dir = Path.home() / '.unity-mcp'
|
||||
|
||||
data_dir = base_dir / 'UnityMCP'
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
return data_dir
|
||||
|
||||
class TelemetryCollector:
|
||||
"""Main telemetry collection class"""
|
||||
|
||||
def __init__(self):
|
||||
self.config = TelemetryConfig()
|
||||
self._customer_uuid: Optional[str] = None
|
||||
self._milestones: Dict[str, Dict[str, Any]] = {}
|
||||
self._load_persistent_data()
|
||||
|
||||
def _load_persistent_data(self):
|
||||
"""Load UUID and milestones from disk"""
|
||||
try:
|
||||
# Load customer UUID
|
||||
if self.config.uuid_file.exists():
|
||||
self._customer_uuid = self.config.uuid_file.read_text().strip()
|
||||
else:
|
||||
self._customer_uuid = str(uuid.uuid4())
|
||||
self.config.uuid_file.write_text(self._customer_uuid)
|
||||
|
||||
# Load milestones
|
||||
if self.config.milestones_file.exists():
|
||||
self._milestones = json.loads(self.config.milestones_file.read_text())
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load telemetry data: {e}")
|
||||
self._customer_uuid = str(uuid.uuid4())
|
||||
self._milestones = {}
|
||||
|
||||
def _save_milestones(self):
|
||||
"""Save milestones to disk"""
|
||||
try:
|
||||
self.config.milestones_file.write_text(json.dumps(self._milestones, indent=2))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save milestones: {e}")
|
||||
|
||||
def record_milestone(self, milestone: MilestoneType, data: Optional[Dict[str, Any]] = None) -> bool:
|
||||
"""Record a milestone event, returns True if this is the first occurrence"""
|
||||
if not self.config.enabled:
|
||||
return False
|
||||
|
||||
milestone_key = milestone.value
|
||||
if milestone_key in self._milestones:
|
||||
return False # Already recorded
|
||||
|
||||
milestone_data = {
|
||||
"timestamp": time.time(),
|
||||
"data": data or {}
|
||||
}
|
||||
|
||||
self._milestones[milestone_key] = milestone_data
|
||||
self._save_milestones()
|
||||
|
||||
# Also send as telemetry record
|
||||
self.record(
|
||||
record_type=RecordType.USAGE,
|
||||
data={"milestone": milestone_key, **(data or {})},
|
||||
milestone=milestone
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def record(self,
|
||||
record_type: RecordType,
|
||||
data: Dict[str, Any],
|
||||
milestone: Optional[MilestoneType] = None):
|
||||
"""Record a telemetry event (async, non-blocking)"""
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
if not HAS_HTTPX:
|
||||
logger.debug("Telemetry disabled: httpx not available")
|
||||
return
|
||||
|
||||
record = TelemetryRecord(
|
||||
record_type=record_type,
|
||||
timestamp=time.time(),
|
||||
customer_uuid=self._customer_uuid or "unknown",
|
||||
session_id=self.config.session_id,
|
||||
data=data,
|
||||
milestone=milestone
|
||||
)
|
||||
|
||||
# Send in background thread to avoid blocking
|
||||
current_context = contextvars.copy_context()
|
||||
thread = threading.Thread(
|
||||
target=lambda: current_context.run(self._send_telemetry, record),
|
||||
daemon=True
|
||||
)
|
||||
thread.start()
|
||||
|
||||
def _send_telemetry(self, record: TelemetryRecord):
|
||||
"""Send telemetry data to endpoint"""
|
||||
try:
|
||||
payload = {
|
||||
"record": record.record_type.value,
|
||||
"timestamp": record.timestamp,
|
||||
"customer_uuid": record.customer_uuid,
|
||||
"session_id": record.session_id,
|
||||
"data": record.data,
|
||||
"version": "3.0.2", # Unity MCP version
|
||||
"platform": os.name
|
||||
}
|
||||
|
||||
if record.milestone:
|
||||
payload["milestone"] = record.milestone.value
|
||||
|
||||
if not httpx:
|
||||
return
|
||||
|
||||
with httpx.Client(timeout=self.config.timeout) as client:
|
||||
response = client.post(self.config.endpoint, json=payload)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.debug(f"Telemetry sent: {record.record_type}")
|
||||
else:
|
||||
logger.debug(f"Telemetry failed: HTTP {response.status_code}")
|
||||
|
||||
except Exception as e:
|
||||
# Never let telemetry errors interfere with app functionality
|
||||
logger.debug(f"Telemetry send failed: {e}")
|
||||
|
||||
# Global telemetry instance
|
||||
_telemetry_collector: Optional[TelemetryCollector] = None
|
||||
|
||||
def get_telemetry() -> TelemetryCollector:
|
||||
"""Get the global telemetry collector instance"""
|
||||
global _telemetry_collector
|
||||
if _telemetry_collector is None:
|
||||
_telemetry_collector = TelemetryCollector()
|
||||
return _telemetry_collector
|
||||
|
||||
def record_telemetry(record_type: RecordType,
|
||||
data: Dict[str, Any],
|
||||
milestone: Optional[MilestoneType] = None):
|
||||
"""Convenience function to record telemetry"""
|
||||
get_telemetry().record(record_type, data, milestone)
|
||||
|
||||
def record_milestone(milestone: MilestoneType, data: Optional[Dict[str, Any]] = None) -> bool:
|
||||
"""Convenience function to record a milestone"""
|
||||
return get_telemetry().record_milestone(milestone, data)
|
||||
|
||||
def record_tool_usage(tool_name: str, success: bool, duration_ms: float, error: Optional[str] = None):
|
||||
"""Record tool usage telemetry"""
|
||||
data = {
|
||||
"tool_name": tool_name,
|
||||
"success": success,
|
||||
"duration_ms": round(duration_ms, 2)
|
||||
}
|
||||
|
||||
if error:
|
||||
data["error"] = str(error)[:200] # Limit error message length
|
||||
|
||||
record_telemetry(RecordType.TOOL_EXECUTION, data)
|
||||
|
||||
def record_latency(operation: str, duration_ms: float, metadata: Optional[Dict[str, Any]] = None):
|
||||
"""Record latency telemetry"""
|
||||
data = {
|
||||
"operation": operation,
|
||||
"duration_ms": round(duration_ms, 2)
|
||||
}
|
||||
|
||||
if metadata:
|
||||
data.update(metadata)
|
||||
|
||||
record_telemetry(RecordType.LATENCY, data)
|
||||
|
||||
def record_failure(component: str, error: str, metadata: Optional[Dict[str, Any]] = None):
|
||||
"""Record failure telemetry"""
|
||||
data = {
|
||||
"component": component,
|
||||
"error": str(error)[:500] # Limit error message length
|
||||
}
|
||||
|
||||
if metadata:
|
||||
data.update(metadata)
|
||||
|
||||
record_telemetry(RecordType.FAILURE, data)
|
||||
|
||||
def is_telemetry_enabled() -> bool:
|
||||
"""Check if telemetry is enabled"""
|
||||
return get_telemetry().config.enabled
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"""
|
||||
Telemetry decorator for Unity MCP tools
|
||||
"""
|
||||
|
||||
import functools
|
||||
import time
|
||||
from typing import Callable, Any
|
||||
from telemetry import record_tool_usage, record_milestone, MilestoneType
|
||||
|
||||
def telemetry_tool(tool_name: str):
|
||||
"""Decorator to add telemetry tracking to MCP tools"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs) -> Any:
|
||||
start_time = time.time()
|
||||
success = False
|
||||
error = None
|
||||
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
success = True
|
||||
|
||||
# Record tool-specific milestones
|
||||
if tool_name == "manage_script" and kwargs.get("action") == "create":
|
||||
record_milestone(MilestoneType.FIRST_SCRIPT_CREATION)
|
||||
elif tool_name.startswith("manage_scene"):
|
||||
record_milestone(MilestoneType.FIRST_SCENE_MODIFICATION)
|
||||
|
||||
# Record general first tool usage
|
||||
record_milestone(MilestoneType.FIRST_TOOL_USAGE)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
raise
|
||||
finally:
|
||||
duration_ms = (time.time() - start_time) * 1000
|
||||
record_tool_usage(tool_name, success, duration_ms, error)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for Unity MCP Telemetry System
|
||||
Run this to verify telemetry is working correctly
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add src to Python path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
def test_telemetry_basic():
|
||||
"""Test basic telemetry functionality"""
|
||||
print("🧪 Testing Unity MCP Telemetry System...")
|
||||
|
||||
try:
|
||||
from telemetry import (
|
||||
get_telemetry, record_telemetry, record_milestone,
|
||||
RecordType, MilestoneType, is_telemetry_enabled
|
||||
)
|
||||
print("✅ Telemetry module imported successfully")
|
||||
except ImportError as e:
|
||||
print(f"❌ Failed to import telemetry module: {e}")
|
||||
return False
|
||||
|
||||
# Test telemetry enabled status
|
||||
print(f"📊 Telemetry enabled: {is_telemetry_enabled()}")
|
||||
|
||||
# Test basic record
|
||||
try:
|
||||
record_telemetry(RecordType.VERSION, {
|
||||
"version": "3.0.2",
|
||||
"test_run": True
|
||||
})
|
||||
print("✅ Basic telemetry record sent")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to send basic telemetry: {e}")
|
||||
return False
|
||||
|
||||
# Test milestone recording
|
||||
try:
|
||||
is_first = record_milestone(MilestoneType.FIRST_STARTUP, {
|
||||
"test_mode": True
|
||||
})
|
||||
print(f"✅ Milestone recorded (first time: {is_first})")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to record milestone: {e}")
|
||||
return False
|
||||
|
||||
# Test telemetry collector
|
||||
try:
|
||||
collector = get_telemetry()
|
||||
print(f"✅ Telemetry collector initialized (UUID: {collector._customer_uuid[:8]}...)")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to get telemetry collector: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def test_telemetry_disabled():
|
||||
"""Test telemetry with disabled state"""
|
||||
print("\n🚫 Testing telemetry disabled state...")
|
||||
|
||||
# Set environment variable to disable telemetry
|
||||
os.environ["DISABLE_TELEMETRY"] = "true"
|
||||
|
||||
# Re-import to get fresh config
|
||||
import importlib
|
||||
import telemetry
|
||||
importlib.reload(telemetry)
|
||||
|
||||
from telemetry import is_telemetry_enabled, record_telemetry, RecordType
|
||||
|
||||
print(f"📊 Telemetry enabled (should be False): {is_telemetry_enabled()}")
|
||||
|
||||
if not is_telemetry_enabled():
|
||||
print("✅ Telemetry correctly disabled via environment variable")
|
||||
|
||||
# Test that records are ignored when disabled
|
||||
record_telemetry(RecordType.USAGE, {"test": "should_be_ignored"})
|
||||
print("✅ Telemetry record ignored when disabled")
|
||||
|
||||
return True
|
||||
else:
|
||||
print("❌ Telemetry not disabled by environment variable")
|
||||
return False
|
||||
|
||||
def test_data_storage():
|
||||
"""Test data storage functionality"""
|
||||
print("\n💾 Testing data storage...")
|
||||
|
||||
try:
|
||||
from telemetry import get_telemetry
|
||||
|
||||
collector = get_telemetry()
|
||||
data_dir = collector.config.data_dir
|
||||
|
||||
print(f"📁 Data directory: {data_dir}")
|
||||
print(f"🏷️ UUID file: {collector.config.uuid_file}")
|
||||
print(f"🎯 Milestones file: {collector.config.milestones_file}")
|
||||
|
||||
# Check if files exist
|
||||
if collector.config.uuid_file.exists():
|
||||
print("✅ UUID file exists")
|
||||
else:
|
||||
print("ℹ️ UUID file will be created on first use")
|
||||
|
||||
if collector.config.milestones_file.exists():
|
||||
print("✅ Milestones file exists")
|
||||
else:
|
||||
print("ℹ️ Milestones file will be created on first milestone")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Data storage test failed: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""Run all telemetry tests"""
|
||||
print("🚀 Unity MCP Telemetry Test Suite")
|
||||
print("=" * 50)
|
||||
|
||||
tests = [
|
||||
test_telemetry_basic,
|
||||
test_data_storage,
|
||||
test_telemetry_disabled,
|
||||
]
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
for test in tests:
|
||||
try:
|
||||
if test():
|
||||
passed += 1
|
||||
print("✅ PASSED\n")
|
||||
else:
|
||||
failed += 1
|
||||
print("❌ FAILED\n")
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
print(f"❌ FAILED with exception: {e}\n")
|
||||
|
||||
print("=" * 50)
|
||||
print(f"📊 Test Results: {passed} passed, {failed} failed")
|
||||
|
||||
if failed == 0:
|
||||
print("🎉 All telemetry tests passed!")
|
||||
return True
|
||||
else:
|
||||
print(f"⚠️ {failed} test(s) failed")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
||||
|
|
@ -5,11 +5,22 @@ from config import config
|
|||
import time
|
||||
import os
|
||||
import base64
|
||||
try:
|
||||
from telemetry_decorator import telemetry_tool
|
||||
from telemetry import record_milestone, MilestoneType
|
||||
HAS_TELEMETRY = True
|
||||
except ImportError:
|
||||
HAS_TELEMETRY = False
|
||||
def telemetry_tool(tool_name: str):
|
||||
def decorator(func):
|
||||
return func
|
||||
return decorator
|
||||
|
||||
def register_manage_script_tools(mcp: FastMCP):
|
||||
"""Register all script management tools with the MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
@telemetry_tool("manage_script")
|
||||
def manage_script(
|
||||
ctx: Context,
|
||||
action: str,
|
||||
|
|
|
|||
Loading…
Reference in New Issue