Skip to main content
Glama
FileLogStorage.cs16.3 kB
/* ┌──────────────────────────────────────────────────────────────────┐ │ Author: Ivan Murzak (https://github.com/IvanMurzak) │ │ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │ │ Copyright (c) 2025 Ivan Murzak │ │ Licensed under the Apache License, Version 2.0. │ │ See the LICENSE file in the project root for more information. │ └──────────────────────────────────────────────────────────────────┘ */ #nullable enable using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using com.IvanMurzak.McpPlugin.Common; using com.IvanMurzak.ReflectorNet; using com.IvanMurzak.ReflectorNet.Utils; using com.IvanMurzak.Unity.MCP.Utils; using Microsoft.Extensions.Logging; using UnityEngine; namespace com.IvanMurzak.Unity.MCP { using ILogger = Microsoft.Extensions.Logging.ILogger; public class FileLogStorage : ILogStorage, IDisposable { protected const int DefaultMaxFileSizeMB = 512; protected readonly ILogger _logger; protected readonly string _directoryPath; protected readonly string _requestedFileName; protected readonly JsonSerializerOptions _jsonOptions; protected readonly SemaphoreSlim _fileLock = new(1, 1); protected readonly int _fileBufferSize; protected readonly long _maxFileSizeBytes; protected readonly ThreadSafeBool _isDisposed = new(false); protected string fileName; protected string filePath; protected FileStream? fileWriteStream; public FileLogStorage( ILogger? logger = null, string? directoryPath = null, string? requestedFileName = null, int fileBufferSize = 4096, int maxFileSizeMB = DefaultMaxFileSizeMB, JsonSerializerOptions? jsonOptions = null) { if (!MainThread.Instance.IsMainThread) throw new Exception($"{nameof(FileLogStorage)} must be initialized on the main thread."); if (fileBufferSize <= 0) throw new ArgumentOutOfRangeException(nameof(fileBufferSize), "File buffer size must be greater than zero."); if (maxFileSizeMB <= 0) throw new ArgumentOutOfRangeException(nameof(maxFileSizeMB), "Max file size must be greater than zero."); _logger = logger ?? UnityLoggerFactory.LoggerFactory.CreateLogger(GetType().GetTypeShortName()); _directoryPath = Path.GetFullPath(directoryPath ?? (Application.isEditor ? $"{Path.GetDirectoryName(Application.dataPath)}/Temp/mcp-server" : $"{Application.persistentDataPath}/Temp/mcp-server")); _requestedFileName = requestedFileName ?? (Application.isEditor ? "ai-editor-logs.txt" : "ai-player-logs.txt"); if (!Directory.Exists(_directoryPath)) Directory.CreateDirectory(_directoryPath); _fileBufferSize = fileBufferSize; _maxFileSizeBytes = maxFileSizeMB * 1024L * 1024L; _jsonOptions = jsonOptions ?? new JsonSerializerOptions { PropertyNameCaseInsensitive = true, WriteIndented = false, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; fileWriteStream = CreateWriteStream(_requestedFileName, out fileName, out filePath); } protected virtual FileStream CreateWriteStream(string fileName, out string resultFileName, out string resultFilePath) { if (_isDisposed.Value) { _logger.LogWarning("{method} called but already disposed, ignored.", nameof(CreateWriteStream)); throw new ObjectDisposedException(GetType().GetTypeShortName()); } var baseName = Path.GetFileNameWithoutExtension(fileName); var extension = Path.GetExtension(fileName); var currentFileName = fileName; int incrementIndex = 1; while (true) { if (incrementIndex > 1000) throw new Exception("Failed to create unique log file name after 1000 attempts."); try { var filePath = Path.GetFullPath(Path.Combine(_directoryPath, currentFileName)); _logger.LogDebug("Creating log file stream: {file}", filePath); var stream = new FileStream(filePath, FileMode.Append, FileAccess.Write, FileShare.Read, bufferSize: _fileBufferSize, useAsync: false) ?? throw new Exception("Failed to create file stream for log storage."); resultFileName = currentFileName; resultFilePath = filePath; return stream; } catch (Exception ex) { _logger.LogError(ex, "Failed to create log file stream for {file}. Retrying with a different file name.", currentFileName); incrementIndex++; currentFileName = $"{baseName}-{incrementIndex}{extension}"; } } } public virtual void Flush() { if (_isDisposed.Value) { _logger.LogWarning("{method} called but already disposed, ignored.", nameof(Flush)); return; } _fileLock.Wait(); try { fileWriteStream?.Flush(); } finally { _fileLock.Release(); } } public virtual async Task FlushAsync() { if (_isDisposed.Value) { _logger.LogWarning("{method} called but already disposed, ignored.", nameof(FlushAsync)); return; } await _fileLock.WaitAsync(); try { if (fileWriteStream != null) await fileWriteStream.FlushAsync(); } finally { _fileLock.Release(); } } public virtual Task AppendAsync(params LogEntry[] entries) { if (_isDisposed.Value) { _logger.LogWarning("{method} called but already disposed, ignored.", nameof(AppendAsync)); return Task.CompletedTask; } return Task.Run(async () => { await _fileLock.WaitAsync(); try { AppendInternal(entries); } finally { _fileLock.Release(); } }); } public virtual void Append(params LogEntry[] entries) { if (_isDisposed.Value) { _logger.LogWarning("{method} called but already disposed, ignored.", nameof(Append)); return; } _fileLock.Wait(); try { AppendInternal(entries); } finally { _fileLock.Release(); } } protected virtual void AppendInternal(params LogEntry[] entries) { if (_isDisposed.Value) { _logger.LogWarning("{method} called but already disposed, ignored.", nameof(AppendInternal)); return; } fileWriteStream ??= CreateWriteStream(_requestedFileName, out fileName, out filePath); // Check if file size limit reached and reset if needed if (fileWriteStream.Length >= _maxFileSizeBytes) { ResetLogFile(); } foreach (var entry in entries) { System.Text.Json.JsonSerializer.Serialize(fileWriteStream, entry, _jsonOptions); fileWriteStream.WriteByte((byte)'\n'); } fileWriteStream.Flush(); } /// <summary> /// Resets the log file by deleting it and creating a new one. /// Called when file size limit is reached. /// </summary> protected virtual void ResetLogFile() { if (_isDisposed.Value) { _logger.LogWarning("{method} called but already disposed, ignored.", nameof(ResetLogFile)); return; } _logger.LogInformation("Log file size limit reached ({maxSizeMB}MB). Resetting log file.", _maxFileSizeBytes / (1024 * 1024)); fileWriteStream?.Flush(); fileWriteStream?.Dispose(); fileWriteStream = null; if (File.Exists(filePath)) File.Delete(filePath); fileWriteStream = CreateWriteStream(_requestedFileName, out this.fileName, out filePath); } /// <summary> /// Closes and disposes the current file stream if open. Clears the log cache file. /// </summary> public virtual void Clear() { if (_isDisposed.Value) { _logger.LogWarning("{method} called but already disposed, ignored.", nameof(Clear)); return; } _fileLock.Wait(); try { fileWriteStream?.Dispose(); fileWriteStream = null; if (File.Exists(filePath)) File.Delete(filePath); if (File.Exists(filePath)) _logger.LogError("Failed to delete cache file: {file}", filePath); } finally { _fileLock.Release(); } } public virtual Task<LogEntry[]> QueryAsync( int maxEntries = 100, LogType? logTypeFilter = null, bool includeStackTrace = false, int lastMinutes = 0) { if (_isDisposed.Value) { _logger.LogWarning("{method} called but already disposed, ignored.", nameof(QueryAsync)); return Task.FromResult(Array.Empty<LogEntry>()); } return Task.Run(() => Query(maxEntries, logTypeFilter, includeStackTrace, lastMinutes)); } public virtual LogEntry[] Query( int maxEntries = 100, LogType? logTypeFilter = null, bool includeStackTrace = false, int lastMinutes = 0) { if (_isDisposed.Value) { _logger.LogWarning("{method} called but already disposed, ignored.", nameof(Query)); return Array.Empty<LogEntry>(); } _fileLock.Wait(); try { return QueryInternal(maxEntries, logTypeFilter, includeStackTrace, lastMinutes); } finally { _fileLock.Release(); } } protected virtual LogEntry[] QueryInternal( int maxEntries = 100, LogType? logTypeFilter = null, bool includeStackTrace = false, int lastMinutes = 0) { if (!File.Exists(filePath)) return Array.Empty<LogEntry>(); using (var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) { var cutoffTime = lastMinutes > 0 ? DateTime.Now.AddMinutes(-lastMinutes) : (DateTime?)null; var allLogs = ReadLogEntriesFromLinesInReverse(fileStream, cutoffTime); // Apply log type filter if (logTypeFilter.HasValue) { allLogs = allLogs .Where(log => log.LogType == logTypeFilter.Value); } // Take the most recent entries (up to maxEntries) var filteredLogs = allLogs .Take(maxEntries) .Reverse() .ToArray(); return filteredLogs; } } protected virtual IEnumerable<LogEntry> ReadLogEntriesFromLinesInReverse(FileStream fileStream, DateTime? cutoffTime = null) { if (_isDisposed.Value) { _logger.LogWarning("{method} called but already disposed, ignored.", nameof(ReadLogEntriesFromLinesInReverse)); yield break; } var position = fileStream.Length; if (position == 0) yield break; var buffer = new byte[_fileBufferSize]; var lineBuffer = new List<byte>(); while (position > 0) { var bytesToRead = (int)Math.Min(position, _fileBufferSize); position -= bytesToRead; fileStream.Seek(position, SeekOrigin.Begin); fileStream.Read(buffer, 0, bytesToRead); for (int i = bytesToRead - 1; i >= 0; i--) { var b = buffer[i]; if (b == '\n') { if (lineBuffer.Count > 0) { lineBuffer.Reverse(); var logEntry = DeserializeLogEntry(lineBuffer); if (logEntry != null) { if (cutoffTime.HasValue && logEntry.Timestamp < cutoffTime.Value) yield break; yield return logEntry; } lineBuffer.Clear(); } } else if (b == '\r') { // Ignore \r } else { lineBuffer.Add(b); } } } if (lineBuffer.Count > 0) { lineBuffer.Reverse(); var logEntry = DeserializeLogEntry(lineBuffer); if (logEntry != null) { if (cutoffTime.HasValue && logEntry.Timestamp < cutoffTime.Value) yield break; yield return logEntry; } } } protected virtual LogEntry? DeserializeLogEntry(List<byte> jsonBytes) { var json = System.Text.Encoding.UTF8.GetString(jsonBytes.ToArray()); return DeserializeLogEntry(json); } protected virtual LogEntry? DeserializeLogEntry(string json) { try { return System.Text.Json.JsonSerializer.Deserialize<LogEntry>(json, _jsonOptions); } catch { return null; } } public virtual void Dispose() { if (!_isDisposed.TrySetTrue()) return; // already disposed try { Flush(); } finally { fileWriteStream?.Dispose(); fileWriteStream = null; } _fileLock.Dispose(); GC.SuppressFinalize(this); } ~FileLogStorage() => Dispose(); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/IvanMurzak/Unity-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server