분석 로그를 1MB 롤링과 14일 보관 기준으로 정리한다
- app/perf/audit/workflow 로그에 공통 RollingTextLogStore를 적용해 날짜별 파일이 1MB를 넘지 않도록 오래된 내용을 밀어내며 저장한다. - 공통 로그, 성능 로그, 감사 로그는 14일 보관으로 맞추고 워크플로우 상세 로그는 기존 설정을 따르되 최대 14일 상한을 적용한다. - RollingTextLogStoreTests 3건을 추가해 파일 크기 상한과 오래된 파일/날짜 디렉터리 정리 동작을 검증한다. - 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_logroll\\ -p:IntermediateOutputPath=obj\\verify_logroll\\ 경고 0 / 오류 0 - 검증: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter RollingTextLogStoreTests -p:OutputPath=bin\\verify_logroll_tests\\ -p:IntermediateOutputPath=obj\\verify_logroll_tests\\ 통과 3 (기존 WorkspaceContextGeneratorTests.cs(76) nullable 경고 1건 유지)
This commit is contained in:
@@ -6,8 +6,11 @@ namespace AxCopilot.Services;
|
||||
|
||||
public static class AgentPerformanceLogService
|
||||
{
|
||||
private const int RetentionDays = 14;
|
||||
private const long MaxLogFileBytes = 1L * 1024 * 1024;
|
||||
private static readonly string PerfDir;
|
||||
private static readonly object _lock = new();
|
||||
private static bool _purged;
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
@@ -48,8 +51,10 @@ public static class AgentPerformanceLogService
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
File.AppendAllText(filePath, json + Environment.NewLine, Encoding.UTF8);
|
||||
RollingTextLogStore.AppendLine(filePath, json, MaxLogFileBytes, Encoding.UTF8);
|
||||
}
|
||||
|
||||
if (!_purged) { _purged = true; PurgeOldLogs(); }
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -66,6 +71,17 @@ public static class AgentPerformanceLogService
|
||||
}
|
||||
|
||||
public static string GetPerformanceFolder() => PerfDir;
|
||||
|
||||
public static void PurgeOldLogs()
|
||||
{
|
||||
try
|
||||
{
|
||||
RollingTextLogStore.PurgeOldFiles(PerfDir, "performance-*.json", RetentionDays);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AgentPerformanceEntry
|
||||
|
||||
@@ -10,8 +10,11 @@ namespace AxCopilot.Services;
|
||||
/// </summary>
|
||||
public static class AuditLogService
|
||||
{
|
||||
private const int RetentionDays = 14;
|
||||
private const long MaxLogFileBytes = 1L * 1024 * 1024;
|
||||
private static readonly string AuditDir;
|
||||
private static readonly object _lock = new();
|
||||
private static bool _purged;
|
||||
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
@@ -37,8 +40,10 @@ public static class AuditLogService
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
File.AppendAllText(filePath, json + "\n", Encoding.UTF8);
|
||||
RollingTextLogStore.AppendLine(filePath, json, MaxLogFileBytes, Encoding.UTF8);
|
||||
}
|
||||
|
||||
if (!_purged) { _purged = true; PurgeOldLogs(RetentionDays); }
|
||||
}
|
||||
catch { /* 감사 로그 실패는 무시 — 앱 동작에 영향 없음 */ }
|
||||
}
|
||||
@@ -123,16 +128,11 @@ public static class AuditLogService
|
||||
}
|
||||
|
||||
/// <summary>30일 이전 감사 로그를 삭제합니다.</summary>
|
||||
public static void PurgeOldLogs(int retentionDays = 30)
|
||||
public static void PurgeOldLogs(int retentionDays = RetentionDays)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cutoff = DateTime.Now.AddDays(-retentionDays);
|
||||
foreach (var f in Directory.GetFiles(AuditDir, "*.json"))
|
||||
{
|
||||
if (File.GetLastWriteTime(f) < cutoff)
|
||||
File.Delete(f);
|
||||
}
|
||||
RollingTextLogStore.PurgeOldFiles(AuditDir, "*.json", retentionDays);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ public enum LogLevel { Debug, Info, Warn, Error }
|
||||
|
||||
public static class LogService
|
||||
{
|
||||
private const int RetentionDays = 14;
|
||||
private const long MaxLogFileBytes = 1L * 1024 * 1024;
|
||||
private static readonly string LogDir =
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot", "logs");
|
||||
@@ -14,8 +16,6 @@ public static class LogService
|
||||
Path.Combine(LogDir, $"app-{DateTime.Now:yyyy-MM-dd}.log");
|
||||
|
||||
private static readonly object _lock = new();
|
||||
|
||||
private const int RetentionDays = 14; // 14일 이상 지난 로그 파일 자동 삭제
|
||||
private static bool _purged;
|
||||
|
||||
public static LogLevel MinLevel { get; set; } = LogLevel.Info;
|
||||
@@ -33,7 +33,7 @@ public static class LogService
|
||||
Directory.CreateDirectory(LogDir);
|
||||
var line = $"[{DateTime.Now:HH:mm:ss.fff}] [{level,-5}] {msg}";
|
||||
lock (_lock)
|
||||
File.AppendAllText(LogFile, line + Environment.NewLine);
|
||||
RollingTextLogStore.AppendLine(LogFile, line, MaxLogFileBytes);
|
||||
|
||||
// 앱 세션 당 1회 오래된 로그 삭제
|
||||
if (!_purged) { _purged = true; PurgeOldLogs(); }
|
||||
@@ -45,12 +45,7 @@ public static class LogService
|
||||
{
|
||||
try
|
||||
{
|
||||
var cutoff = DateTime.Now.AddDays(-RetentionDays);
|
||||
foreach (var file in Directory.GetFiles(LogDir, "app-*.log"))
|
||||
{
|
||||
if (File.GetCreationTime(file) < cutoff)
|
||||
File.Delete(file);
|
||||
}
|
||||
RollingTextLogStore.PurgeOldFiles(LogDir, "app-*.log", RetentionDays);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
116
src/AxCopilot/Services/RollingTextLogStore.cs
Normal file
116
src/AxCopilot/Services/RollingTextLogStore.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
public static class RollingTextLogStore
|
||||
{
|
||||
public static void AppendLine(string filePath, string line, long maxBytes, Encoding? encoding = null)
|
||||
{
|
||||
encoding ??= new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
|
||||
var directory = Path.GetDirectoryName(filePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
var appendedBytes = encoding.GetBytes(line + Environment.NewLine);
|
||||
if (appendedBytes.LongLength > maxBytes)
|
||||
appendedBytes = TrimToLastBytes(appendedBytes, maxBytes);
|
||||
|
||||
byte[] retainedBytes = [];
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var existingBytes = File.ReadAllBytes(filePath);
|
||||
var retainBudget = Math.Max(0, maxBytes - appendedBytes.LongLength);
|
||||
retainedBytes = SliceLatestWholeLines(existingBytes, retainBudget);
|
||||
}
|
||||
|
||||
using var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read);
|
||||
if (retainedBytes.Length > 0)
|
||||
stream.Write(retainedBytes, 0, retainedBytes.Length);
|
||||
stream.Write(appendedBytes, 0, appendedBytes.Length);
|
||||
}
|
||||
|
||||
public static void PurgeOldFiles(string directory, string searchPattern, int retentionDays)
|
||||
{
|
||||
if (retentionDays <= 0 || !Directory.Exists(directory))
|
||||
return;
|
||||
|
||||
var cutoff = DateTime.Now.Date.AddDays(-retentionDays);
|
||||
foreach (var file in Directory.GetFiles(directory, searchPattern))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.GetLastWriteTime(file) < cutoff)
|
||||
File.Delete(file);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void PurgeOldDirectoriesByDateName(string rootDirectory, int retentionDays, string format = "yyyy-MM-dd")
|
||||
{
|
||||
if (retentionDays <= 0 || !Directory.Exists(rootDirectory))
|
||||
return;
|
||||
|
||||
var cutoff = DateTime.Now.Date.AddDays(-retentionDays);
|
||||
foreach (var directory in Directory.GetDirectories(rootDirectory))
|
||||
{
|
||||
try
|
||||
{
|
||||
var name = Path.GetFileName(directory);
|
||||
if (!DateTime.TryParseExact(
|
||||
name,
|
||||
format,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.None,
|
||||
out var dirDate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dirDate < cutoff)
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] SliceLatestWholeLines(byte[] source, long maxBytes)
|
||||
{
|
||||
if (source.Length == 0 || maxBytes <= 0)
|
||||
return [];
|
||||
|
||||
if (source.LongLength <= maxBytes)
|
||||
return source;
|
||||
|
||||
var start = (int)Math.Max(0, source.LongLength - maxBytes);
|
||||
while (start < source.Length && source[start] != (byte)'\n')
|
||||
start++;
|
||||
while (start < source.Length && (source[start] == (byte)'\n' || source[start] == (byte)'\r'))
|
||||
start++;
|
||||
|
||||
if (start >= source.Length)
|
||||
return [];
|
||||
|
||||
var length = source.Length - start;
|
||||
var result = new byte[length];
|
||||
Buffer.BlockCopy(source, start, result, 0, length);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static byte[] TrimToLastBytes(byte[] source, long maxBytes)
|
||||
{
|
||||
if (source.LongLength <= maxBytes)
|
||||
return source;
|
||||
|
||||
var start = (int)(source.LongLength - maxBytes);
|
||||
var length = (int)Math.Min(maxBytes, source.LongLength);
|
||||
var result = new byte[length];
|
||||
Buffer.BlockCopy(source, start, result, 0, length);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ namespace AxCopilot.Services;
|
||||
/// </summary>
|
||||
public static class WorkflowLogService
|
||||
{
|
||||
private const long MaxLogFileBytes = 1L * 1024 * 1024;
|
||||
private static readonly string WorkflowDir;
|
||||
private static readonly object _lock = new();
|
||||
private static bool _purged;
|
||||
@@ -37,7 +38,7 @@ public static class WorkflowLogService
|
||||
public static bool IsEnabled { get; set; }
|
||||
|
||||
/// <summary>보관 기간 (일). 이 기간이 지난 로그는 자동 삭제됩니다.</summary>
|
||||
public static int RetentionDays { get; set; } = 3;
|
||||
public static int RetentionDays { get; set; } = 14;
|
||||
|
||||
// ─── LlmService 등 하위 계층에서 사용할 현재 컨텍스트 ───
|
||||
// AgentLoopService가 LLM 호출 직전에 설정하고, 완료 후 리셋합니다.
|
||||
@@ -86,7 +87,7 @@ public static class WorkflowLogService
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
File.AppendAllText(filePath, json + "\n", Encoding.UTF8);
|
||||
RollingTextLogStore.AppendLine(filePath, json, MaxLogFileBytes, Encoding.UTF8);
|
||||
}
|
||||
|
||||
if (!_purged) { _purged = true; PurgeOldLogs(); }
|
||||
@@ -251,20 +252,7 @@ public static class WorkflowLogService
|
||||
{
|
||||
try
|
||||
{
|
||||
var cutoff = DateTime.Now.Date.AddDays(-RetentionDays);
|
||||
foreach (var dir in Directory.GetDirectories(WorkflowDir))
|
||||
{
|
||||
var dirName = Path.GetFileName(dir);
|
||||
if (DateTime.TryParseExact(dirName, "yyyy-MM-dd",
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.None, out var dirDate))
|
||||
{
|
||||
if (dirDate < cutoff)
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
RollingTextLogStore.PurgeOldDirectoriesByDateName(WorkflowDir, RetentionDays);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user