Files

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"
};
}
}
}