358 lines
14 KiB
C#
358 lines
14 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// 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
|
|
/// </summary>
|
|
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<VibeLogEntry> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Log an info level message with structured context
|
|
/// Only logs when ULOOPENV_KEY_ULOOPMCP_DEBUG symbol is defined
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Log a warning level message with structured context
|
|
/// Only logs when ULOOPENV_KEY_ULOOPMCP_DEBUG symbol is defined
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Log an error level message with structured context
|
|
/// Only logs when ULOOPENV_KEY_ULOOPMCP_DEBUG symbol is defined
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Log a debug level message with structured context
|
|
/// Only logs when ULOOPENV_KEY_ULOOPMCP_DEBUG symbol is defined
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Log an exception with structured context
|
|
/// Only logs when ULOOPENV_KEY_ULOOPMCP_DEBUG symbol is defined
|
|
/// </summary>
|
|
[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<string, object>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generate a new correlation ID for tracking related operations
|
|
/// </summary>
|
|
public static string GenerateCorrelationId()
|
|
{
|
|
return $"unity_{Guid.NewGuid().ToString("N")[..8]}_{DateTime.Now:HHmmss}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get logs for AI analysis (formatted for Claude Code)
|
|
/// Output directory: {project_root}/.uloop/outputs/VibeLogs/
|
|
/// </summary>
|
|
public static string GetLogsForAi(string operation = null, string correlationId = null, int maxCount = 100)
|
|
{
|
|
lock (lockObject)
|
|
{
|
|
var filteredLogs = new List<VibeLogEntry>(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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clear all memory logs
|
|
/// </summary>
|
|
public static void ClearMemoryLogs()
|
|
{
|
|
lock (lockObject)
|
|
{
|
|
memoryLogs.Clear();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Core logging method
|
|
/// </summary>
|
|
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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Save log entry to file with file locking for concurrent access
|
|
/// </summary>
|
|
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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Check if exception is a file sharing violation
|
|
/// </summary>
|
|
private static bool IsFileSharingViolation(IOException ex)
|
|
{
|
|
// ERROR_SHARING_VIOLATION (0x80070020)
|
|
const int ERROR_SHARING_VIOLATION = unchecked((int)0x80070020);
|
|
return ex.HResult == ERROR_SHARING_VIOLATION;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clean up old log files, keeping only the most recent MAX_LOG_FILES
|
|
/// </summary>
|
|
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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get current environment information
|
|
/// </summary>
|
|
private static EnvironmentInfo GetEnvironmentInfo()
|
|
{
|
|
bool isDomainReloadInProgress = EditorApplication.isCompiling || DomainReloadStateRegistry.IsDomainReloadInProgress();
|
|
|
|
return new EnvironmentInfo
|
|
{
|
|
domain_reload_state = isDomainReloadInProgress ? "InProgress" : "Idle"
|
|
};
|
|
}
|
|
}
|
|
}
|