Files
AX-Copilot-Codex/src/AxCopilot/Services/UsageStatisticsService.cs
lacvet f9fd144bd0
Some checks failed
Release Gate / gate (push) Has been cancelled
컨텍스트 카드 모델별 사용량 가시화 보강
- AX Agent 하단 컨텍스트 카드에 현재 서비스·모델 기준 오늘 사용량을 함께 표시하고 hover에서 현재 모델 usage·compact 이후 usage·오늘 상위 모델 usage를 확인할 수 있게 함

- UsageStatisticsService에 오늘 통계 스냅샷 API를 추가하고 long-safe 토큰 포맷 경로를 넣어 per-model 집계가 커져도 K/M 단위로 안정적으로 표시되게 함

- README.md와 docs/DEVELOPMENT.md에 2026-04-05 00:34 (KST) 기준 이력을 반영함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ / 경고 0 / 오류 0
2026-04-05 00:22:56 +09:00

253 lines
9.0 KiB
C#

using System.IO;
using System.Text.Json;
using AxCopilot.Models;
namespace AxCopilot.Services;
/// <summary>
/// 일별 사용 통계를 기록하고 조회합니다.
/// 저장 위치: %APPDATA%\AxCopilot\stats\YYYY-MM-DD.json
/// 30일 초과 파일은 자동 삭제(롤링).
/// </summary>
internal static class UsageStatisticsService
{
private static readonly string _statsDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "stats");
private static DailyUsageStats _today = new();
private static bool _initialized;
private static readonly object _lock = new();
private const int RetentionDays = 30;
// ─── 공개 API ────────────────────────────────────────────────────────────
public static void RecordLauncherOpen()
{
EnsureInitialized();
lock (_lock) { _today.LauncherOpens++; }
_ = SaveTodayAsync();
}
public static void RecordCommandUsage(string commandKey)
{
if (string.IsNullOrWhiteSpace(commandKey)) return;
EnsureInitialized();
lock (_lock)
{
_today.CommandUsage.TryGetValue(commandKey, out var cur);
_today.CommandUsage[commandKey] = cur + 1;
}
_ = SaveTodayAsync();
}
public static void AddActiveSeconds(int seconds)
{
if (seconds <= 0) return;
EnsureInitialized();
lock (_lock) { _today.ActiveSeconds += seconds; }
_ = SaveTodayAsync();
}
/// <summary>당일 기록된 PC 활성 시간(초)을 반환합니다. 재시작 전 누적분 포함.</summary>
public static int GetTodayActiveSeconds()
{
EnsureInitialized();
lock (_lock) { return _today.ActiveSeconds; }
}
/// <summary>AX Agent 대화를 탭별로 기록합니다.</summary>
public static void RecordChat(string tab)
{
if (string.IsNullOrWhiteSpace(tab)) tab = "Chat";
EnsureInitialized();
lock (_lock)
{
_today.ChatCounts.TryGetValue(tab, out var cur);
_today.ChatCounts[tab] = cur + 1;
}
_ = SaveTodayAsync();
}
/// <summary>토큰 사용량을 기록합니다.</summary>
public static void RecordTokens(int promptTokens, int completionTokens, string? service = null, string? model = null, bool isPostCompaction = false)
{
if (promptTokens <= 0 && completionTokens <= 0) return;
EnsureInitialized();
lock (_lock)
{
_today.PromptTokens += promptTokens;
_today.CompletionTokens += completionTokens;
_today.TotalTokens += promptTokens + completionTokens;
var key = BuildModelUsageKey(service, model);
if (!string.IsNullOrWhiteSpace(key))
{
_today.ModelPromptTokens.TryGetValue(key, out var modelPrompt);
_today.ModelCompletionTokens.TryGetValue(key, out var modelCompletion);
_today.ModelPromptTokens[key] = modelPrompt + promptTokens;
_today.ModelCompletionTokens[key] = modelCompletion + completionTokens;
if (isPostCompaction)
{
_today.PostCompactionPromptTokens.TryGetValue(key, out var postPrompt);
_today.PostCompactionCompletionTokens.TryGetValue(key, out var postCompletion);
_today.PostCompactionPromptTokens[key] = postPrompt + promptTokens;
_today.PostCompactionCompletionTokens[key] = postCompletion + completionTokens;
}
}
}
_ = SaveTodayAsync();
}
/// <summary>최근 <paramref name="days"/>일치 통계를 날짜 오름차순으로 반환합니다.</summary>
public static List<DailyUsageStats> GetStats(int days = 30)
{
EnsureInitialized();
var result = new List<DailyUsageStats>();
var today = DateTime.Today;
for (int i = days - 1; i >= 0; i--)
{
var date = today.AddDays(-i);
var dateStr = date.ToString("yyyy-MM-dd");
if (dateStr == _today.Date)
{
DailyUsageStats snapshot;
lock (_lock) { snapshot = CloneToday(); }
result.Add(snapshot);
continue;
}
var path = GetFilePath(dateStr);
if (File.Exists(path))
{
try
{
var json = File.ReadAllText(path);
var stat = JsonSerializer.Deserialize<DailyUsageStats>(json);
if (stat != null) result.Add(stat);
else result.Add(new DailyUsageStats { Date = dateStr });
}
catch { result.Add(new DailyUsageStats { Date = dateStr }); }
}
else
{
result.Add(new DailyUsageStats { Date = dateStr });
}
}
return result;
}
/// <summary>오늘자 통계 스냅샷을 반환합니다.</summary>
public static DailyUsageStats GetTodaySnapshot()
{
EnsureInitialized();
lock (_lock)
{
return CloneToday();
}
}
// ─── 내부 ────────────────────────────────────────────────────────────────
private static void EnsureInitialized()
{
if (_initialized) return;
lock (_lock)
{
if (_initialized) return;
try
{
Directory.CreateDirectory(_statsDir);
var todayStr = DateTime.Today.ToString("yyyy-MM-dd");
var path = GetFilePath(todayStr);
if (File.Exists(path))
{
var json = File.ReadAllText(path);
var stat = JsonSerializer.Deserialize<DailyUsageStats>(json);
_today = stat ?? new DailyUsageStats { Date = todayStr };
}
else
{
_today = new DailyUsageStats { Date = todayStr };
}
PurgeOldFiles();
}
catch (Exception ex)
{
LogService.Warn($"통계 서비스 초기화 실패: {ex.Message}");
_today = new DailyUsageStats { Date = DateTime.Today.ToString("yyyy-MM-dd") };
}
_initialized = true;
}
}
private static void PurgeOldFiles()
{
try
{
var cutoff = DateTime.Today.AddDays(-RetentionDays);
foreach (var file in Directory.GetFiles(_statsDir, "????-??-??.json"))
{
var name = Path.GetFileNameWithoutExtension(file);
if (DateTime.TryParse(name, out var fileDate) && fileDate < cutoff)
File.Delete(file);
}
}
catch (Exception ex)
{
LogService.Warn($"통계 파일 정리 실패: {ex.Message}");
}
}
private static async Task SaveTodayAsync()
{
try
{
DailyUsageStats snapshot;
lock (_lock) { snapshot = CloneToday(); }
var path = GetFilePath(snapshot.Date);
var json = JsonSerializer.Serialize(snapshot, new JsonSerializerOptions { WriteIndented = false });
await File.WriteAllTextAsync(path, json);
}
catch (Exception ex)
{
LogService.Warn($"통계 저장 실패: {ex.Message}");
}
}
private static DailyUsageStats CloneToday() => new()
{
Date = _today.Date,
LauncherOpens = _today.LauncherOpens,
ActiveSeconds = _today.ActiveSeconds,
CommandUsage = new Dictionary<string, int>(_today.CommandUsage),
ChatCounts = new Dictionary<string, int>(_today.ChatCounts),
TotalTokens = _today.TotalTokens,
PromptTokens = _today.PromptTokens,
CompletionTokens = _today.CompletionTokens,
ModelPromptTokens = new Dictionary<string, long>(_today.ModelPromptTokens),
ModelCompletionTokens = new Dictionary<string, long>(_today.ModelCompletionTokens),
PostCompactionPromptTokens = new Dictionary<string, long>(_today.PostCompactionPromptTokens),
PostCompactionCompletionTokens = new Dictionary<string, long>(_today.PostCompactionCompletionTokens),
};
private static string GetFilePath(string dateStr)
=> Path.Combine(_statsDir, $"{dateStr}.json");
private static string BuildModelUsageKey(string? service, string? model)
{
var normalizedService = (service ?? "").Trim().ToLowerInvariant();
var normalizedModel = (model ?? "").Trim();
if (string.IsNullOrWhiteSpace(normalizedService) || string.IsNullOrWhiteSpace(normalizedModel))
return "";
return $"{normalizedService}:{normalizedModel}";
}
}