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 ❓
|
## Troubleshooting ❓
|
||||||
|
|
||||||
<details>
|
<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();
|
currentUnityPort = PortManager.GetPortWithFallback();
|
||||||
Start();
|
Start();
|
||||||
isAutoConnectMode = true;
|
isAutoConnectMode = true;
|
||||||
|
|
||||||
|
// Record telemetry for bridge startup
|
||||||
|
TelemetryHelper.RecordBridgeStartup();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Debug.LogError($"Auto-connect failed: {ex.Message}");
|
Debug.LogError($"Auto-connect failed: {ex.Message}");
|
||||||
|
|
||||||
|
// Record telemetry for connection failure
|
||||||
|
TelemetryHelper.RecordBridgeConnection(false, ex.Message);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,10 @@ class ServerConfig:
|
||||||
# Number of polite retries when Unity reports reloading
|
# Number of polite retries when Unity reports reloading
|
||||||
# 40 × 250ms ≈ 10s default window
|
# 40 × 250ms ≈ 10s default window
|
||||||
reload_max_retries: int = 40
|
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
|
# Create a global config instance
|
||||||
config = ServerConfig()
|
config = ServerConfig()
|
||||||
|
|
@ -6,6 +6,8 @@ from typing import AsyncIterator, Dict, Any, List
|
||||||
from config import config
|
from config import config
|
||||||
from tools import register_all_tools
|
from tools import register_all_tools
|
||||||
from unity_connection import get_unity_connection, UnityConnection
|
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
|
# Configure logging using settings from config
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|
@ -22,12 +24,38 @@ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
|
||||||
"""Handle server startup and shutdown."""
|
"""Handle server startup and shutdown."""
|
||||||
global _unity_connection
|
global _unity_connection
|
||||||
logger.info("MCP for Unity Server starting up")
|
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:
|
try:
|
||||||
_unity_connection = get_unity_connection()
|
_unity_connection = get_unity_connection()
|
||||||
logger.info("Connected to Unity on startup")
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"Could not connect to Unity on startup: {str(e)}")
|
logger.warning(f"Could not connect to Unity on startup: {str(e)}")
|
||||||
_unity_connection = None
|
_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:
|
try:
|
||||||
# Yield the connection object so it can be attached to the context
|
# 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)
|
# 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 time
|
||||||
import os
|
import os
|
||||||
import base64
|
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):
|
def register_manage_script_tools(mcp: FastMCP):
|
||||||
"""Register all script management tools with the MCP server."""
|
"""Register all script management tools with the MCP server."""
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@telemetry_tool("manage_script")
|
||||||
def manage_script(
|
def manage_script(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
action: str,
|
action: str,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue