using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using Newtonsoft.Json; using UnityEditor; using UnityEngine; namespace io.github.hatayama.uLoopMCP { /// /// AI-friendly structured logger for Unity MCP /// /// Design document reference: Packages/src/TypeScriptServer~/ARCHITECTURE.md /// /// Related classes: /// - McpLogger: Traditional Unity logger that this class replaces /// - McpServerController: Uses this logger for domain reload tracking /// - UnityMcpServer: Uses this logger for connection event tracking /// /// Key features: /// - Structured JSON logging with operation, context, correlation_id /// - AI-friendly format for Claude Code analysis /// - Automatic file rotation and memory management /// - Correlation ID tracking for related operations /// public static class VibeLogger { private static readonly string LOG_DIRECTORY = Path.Combine(Application.dataPath, "..", McpConstants.OUTPUT_ROOT_DIR, McpConstants.VIBE_LOGS_DIR); private static readonly string LOG_FILE_PREFIX = "unity_vibe"; private static readonly int MAX_FILE_SIZE_MB = 10; private static readonly int MAX_MEMORY_LOGS = 1000; private static readonly int MAX_LOG_FILES = 3; private static readonly List memoryLogs = new(); private static readonly object lockObject = new(); private static bool hasCleanedUpOnStartup = false; [Serializable] public class VibeLogEntry { public string timestamp; public string level; public string operation; public string message; public object context; public string correlation_id; public string source; public string human_note; public string ai_todo; public string stack_trace; public EnvironmentInfo environment; } [Serializable] public class EnvironmentInfo { public string domain_reload_state; } /// /// Log an info level message with structured context /// Only logs when ULOOPENV_KEY_ULOOPMCP_DEBUG symbol is defined /// [Conditional(McpConstants.ENV_KEY_ULOOPMCP_DEBUG)] public static void LogInfo(string operation, string message, object context = null, string correlationId = null, string humanNote = null, string aiTodo = null, bool includeStackTrace = false) { Log("INFO", operation, message, context, correlationId, humanNote, aiTodo, includeStackTrace); } /// /// Log a warning level message with structured context /// Only logs when ULOOPENV_KEY_ULOOPMCP_DEBUG symbol is defined /// [Conditional(McpConstants.ENV_KEY_ULOOPMCP_DEBUG)] public static void LogWarning(string operation, string message, object context = null, string correlationId = null, string humanNote = null, string aiTodo = null, bool includeStackTrace = true) { Log("WARNING", operation, message, context, correlationId, humanNote, aiTodo, includeStackTrace); } /// /// Log an error level message with structured context /// Only logs when ULOOPENV_KEY_ULOOPMCP_DEBUG symbol is defined /// [Conditional(McpConstants.ENV_KEY_ULOOPMCP_DEBUG)] public static void LogError(string operation, string message, object context = null, string correlationId = null, string humanNote = null, string aiTodo = null, bool includeStackTrace = true) { Log("ERROR", operation, message, context, correlationId, humanNote, aiTodo, includeStackTrace); } /// /// Log a debug level message with structured context /// Only logs when ULOOPENV_KEY_ULOOPMCP_DEBUG symbol is defined /// [Conditional(McpConstants.ENV_KEY_ULOOPMCP_DEBUG)] public static void LogDebug(string operation, string message, object context = null, string correlationId = null, string humanNote = null, string aiTodo = null, bool includeStackTrace = false) { Log("DEBUG", operation, message, context, correlationId, humanNote, aiTodo, includeStackTrace); } /// /// Log an exception with structured context /// Only logs when ULOOPENV_KEY_ULOOPMCP_DEBUG symbol is defined /// [Conditional(McpConstants.ENV_KEY_ULOOPMCP_DEBUG)] public static void LogException(string operation, Exception exception, object context = null, string correlationId = null, string humanNote = null, string aiTodo = null, bool includeStackTrace = true) { var exceptionContext = new Dictionary(); if (context != null) { exceptionContext["original_context"] = context; } exceptionContext["exception"] = new { type = exception.GetType().Name, message = exception.Message, stack_trace = exception.StackTrace, inner_exception = exception.InnerException?.Message }; Log("ERROR", operation, $"Exception occurred: {exception.Message}", exceptionContext, correlationId, humanNote, aiTodo, includeStackTrace); } /// /// Generate a new correlation ID for tracking related operations /// public static string GenerateCorrelationId() { return $"unity_{Guid.NewGuid().ToString("N")[..8]}_{DateTime.Now:HHmmss}"; } /// /// Get logs for AI analysis (formatted for Claude Code) /// Output directory: {project_root}/.uloop/outputs/VibeLogs/ /// public static string GetLogsForAi(string operation = null, string correlationId = null, int maxCount = 100) { lock (lockObject) { var filteredLogs = new List(memoryLogs); if (!string.IsNullOrEmpty(operation)) { filteredLogs = filteredLogs.FindAll(log => log.operation.Contains(operation)); } if (!string.IsNullOrEmpty(correlationId)) { filteredLogs = filteredLogs.FindAll(log => log.correlation_id == correlationId); } if (filteredLogs.Count > maxCount) { filteredLogs = filteredLogs.GetRange(filteredLogs.Count - maxCount, maxCount); } return JsonConvert.SerializeObject(filteredLogs, Formatting.Indented); } } /// /// Clear all memory logs /// public static void ClearMemoryLogs() { lock (lockObject) { memoryLogs.Clear(); } } /// /// Core logging method /// private static void Log(string level, string operation, string message, object context, string correlationId, string humanNote, string aiTodo, bool includeStackTrace = true) { var logEntry = new VibeLogEntry { timestamp = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.fffzzz"), level = level, operation = operation, message = message, context = context, correlation_id = correlationId ?? GenerateCorrelationId(), source = "Unity", human_note = humanNote, ai_todo = aiTodo, stack_trace = includeStackTrace ? new StackTrace(true).ToString() : null, environment = GetEnvironmentInfo() }; // Add to memory logs lock (lockObject) { memoryLogs.Add(logEntry); // Rotate memory logs if too many if (memoryLogs.Count > MAX_MEMORY_LOGS) { memoryLogs.RemoveAt(0); } } // Save to file try { SaveLogToFile(logEntry); } catch (Exception ex) { // Fallback to Unity console if file logging fails UnityEngine.Debug.LogError($"[VibeLogger] Failed to save log to file: {ex.Message}"); UnityEngine.Debug.Log($"[VibeLogger] {level} | {operation} | {message}"); } } /// /// Save log entry to file with file locking for concurrent access /// private static void SaveLogToFile(VibeLogEntry logEntry) { if (!Directory.Exists(LOG_DIRECTORY)) { Directory.CreateDirectory(LOG_DIRECTORY); } // Clean up old log files on first access only lock (lockObject) { if (!hasCleanedUpOnStartup) { CleanupOldLogFiles(); hasCleanedUpOnStartup = true; } } string fileName = $"{LOG_FILE_PREFIX}_{DateTime.UtcNow:yyyyMMdd}.json"; string filePath = Path.Combine(LOG_DIRECTORY, fileName); // Check file size and rotate if necessary if (File.Exists(filePath)) { var fileInfo = new FileInfo(filePath); if (fileInfo.Length > MAX_FILE_SIZE_MB * 1024 * 1024) { string rotatedFileName = $"{LOG_FILE_PREFIX}_{DateTime.UtcNow:yyyyMMdd_HHmmss}.json"; string rotatedFilePath = Path.Combine(LOG_DIRECTORY, rotatedFileName); File.Move(filePath, rotatedFilePath); // Clean up old files after rotation CleanupOldLogFiles(); } } string jsonLog = JsonConvert.SerializeObject(logEntry) + "\n"; // Use file locking with retry mechanism to handle concurrent access int maxRetries = 3; int retryDelayMs = 50; for (int retry = 0; retry < maxRetries; retry++) { try { using (var fileStream = new FileStream(filePath, FileMode.Append, FileAccess.Write, FileShare.Read)) using (var writer = new StreamWriter(fileStream)) { writer.Write(jsonLog); writer.Flush(); return; // Success - exit retry loop } } catch (IOException ex) when (IsFileSharingViolation(ex) && retry < maxRetries - 1) { // Wait and retry for sharing violations System.Threading.Thread.Sleep(retryDelayMs * (retry + 1)); } catch (Exception ex) { // For other exceptions, throw immediately throw new InvalidOperationException($"Failed to write VibeLogger entry to file: {ex.Message}", ex); } } // If all retries failed, throw with sharing violation context throw new InvalidOperationException($"Failed to write VibeLogger entry after {maxRetries} retries due to file sharing violations"); } /// /// Check if exception is a file sharing violation /// private static bool IsFileSharingViolation(IOException ex) { // ERROR_SHARING_VIOLATION (0x80070020) const int ERROR_SHARING_VIOLATION = unchecked((int)0x80070020); return ex.HResult == ERROR_SHARING_VIOLATION; } /// /// Clean up old log files, keeping only the most recent MAX_LOG_FILES /// private static void CleanupOldLogFiles() { try { if (!Directory.Exists(LOG_DIRECTORY)) return; // Get all vibe log files, sorted by creation time (newest first) var logFiles = Directory.GetFiles(LOG_DIRECTORY, $"{LOG_FILE_PREFIX}_*.json") .Select(file => new FileInfo(file)) .OrderByDescending(file => file.CreationTime) .ToArray(); // Delete files beyond the limit for (int i = MAX_LOG_FILES; i < logFiles.Length; i++) { try { logFiles[i].Delete(); } catch (Exception ex) { // Log deletion failure but don't crash UnityEngine.Debug.LogWarning($"[VibeLogger] Failed to delete old log file {logFiles[i].Name}: {ex.Message}"); } } } catch (Exception ex) { UnityEngine.Debug.LogWarning($"[VibeLogger] Failed to cleanup old log files: {ex.Message}"); } } /// /// Get current environment information /// private static EnvironmentInfo GetEnvironmentInfo() { bool isDomainReloadInProgress = EditorApplication.isCompiling || DomainReloadStateRegistry.IsDomainReloadInProgress(); return new EnvironmentInfo { domain_reload_state = isDomainReloadInProgress ? "InProgress" : "Idle" }; } } }