Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ namespace AIUsageMonitor.Core.Providers.Claude;
/// <param name="statsCacheBuilder">Builds a <see cref="StatsCache"/> from session transcripts when the cache is missing or stale.</param>
/// <param name="recentActivityBuilder">Builds a summary of recent activity from session transcripts.</param>
/// <param name="hourlyActivityBuilder">Builds hourly activity data from session transcripts.</param>
/// <param name="sessionActivityTracker">Tracks the latest session file write time without re-scanning the projects directory on every call.</param>
/// <param name="logger">Logger used to report cache fallback diagnostics.</param>
public sealed class ClaudeUsageProvider(
ClaudeDataLocator locator,
StatsCacheParser statsCacheParser,
StatsCacheBuilder statsCacheBuilder,
RecentActivityBuilder recentActivityBuilder,
HourlyActivityBuilder hourlyActivityBuilder,
SessionActivityTracker sessionActivityTracker,
ILogger<ClaudeUsageProvider> logger) : IUsageProvider
{
/// <summary>Gets the display name of this usage provider.</summary>
Expand Down Expand Up @@ -65,7 +67,9 @@ public StatsCache GetStatsCache(IProgress<int>? progress = null)
{
var cache = statsCacheParser.Parse(locator.StatsCachePath);
var today = DateOnly.FromDateTime(DateTimeOffset.Now.LocalDateTime);
if (cache.LastComputedDate >= today)
var cacheWriteTime = File.GetLastWriteTimeUtc(locator.StatsCachePath);

if (cache.LastComputedDate >= today && cacheWriteTime >= sessionActivityTracker.LatestWriteUtc)
{
progress?.Report(100);
return cache;
Expand Down
53 changes: 53 additions & 0 deletions src/AIUsageMonitor.Core/Providers/Claude/SessionActivityTracker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
namespace AIUsageMonitor.Core.Providers.Claude;

/// <summary>
/// Tracks the most recent write time across all Claude session transcript files (<c>*.jsonl</c>)
/// without repeatedly re-scanning the projects directory. The first access to <see cref="LatestWriteUtc"/>
/// takes a one-time baseline scan; after that, callers who already observe file-system change events
/// (e.g. <see cref="Services.DataService"/>'s session-file watcher) feed updates via <see cref="Observe"/>
/// in O(1), so no further scans are needed.
/// </summary>
/// <param name="locator">Locates Claude session transcript files on disk, used only for the initial baseline scan.</param>
public sealed class SessionActivityTracker(ClaudeDataLocator locator)
{
private readonly Lock _lock = new();
private DateTime? _latestWriteUtc;

/// <summary>
/// Gets the latest known write time (UTC) across all session transcript files. Triggers a one-time
/// directory scan on first access if no write has been observed yet via <see cref="Observe"/>.
/// </summary>
public DateTime LatestWriteUtc
{
get
{
lock (_lock)
{
_latestWriteUtc ??= ScanForLatestWriteUtc();
return _latestWriteUtc.Value;
}
}
}

/// <summary>
/// Records a newly observed write time, advancing <see cref="LatestWriteUtc"/> if it is more recent
/// than what's currently known.
/// </summary>
/// <param name="writeTimeUtc">The write time (UTC) observed for a session transcript file.</param>
public void Observe(DateTime writeTimeUtc)
{
lock (_lock)
{
if (_latestWriteUtc is null || writeTimeUtc > _latestWriteUtc)
{
_latestWriteUtc = writeTimeUtc;
}
}
}

private DateTime ScanForLatestWriteUtc()
{
var files = locator.GetSessionFiles();
return files.Count == 0 ? DateTime.MinValue : files.Max(File.GetLastWriteTimeUtc);
}
}
13 changes: 13 additions & 0 deletions src/AIUsageMonitor.Core/Services/DataService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ public sealed class DataService : IDisposable
/// </summary>
private readonly IUsageProvider _provider;

/// <summary>
/// Tracks the latest session file write time, fed incrementally from <see cref="_sessionsWatcher"/>
/// events so <see cref="Providers.Claude.ClaudeUsageProvider"/> can check staleness without
/// re-scanning the projects directory.
/// </summary>
private readonly SessionActivityTracker _sessionActivityTracker;

/// <summary>
/// The session file cache used to cache parsed session transcript rows. This field is initialized in the constructor and is used to store the results of parsing session transcript files, allowing for faster access to the data without needing to repeatedly read and parse the files from disk.
/// </summary>
Expand All @@ -64,16 +71,19 @@ public sealed class DataService : IDisposable
/// <param name="provider">The usage provider used to retrieve AI usage data.</param>
/// <param name="analyzer">The usage analyzer used to analyze AI usage data.</param>
/// <param name="sessionFileCache">The session file cache used to cache parsed session transcript rows.</param>
/// <param name="sessionActivityTracker">Tracks the latest session file write time from file-system watcher events.</param>
/// <param name="logger">The logger instance used for logging warnings and errors.</param>
public DataService(
IUsageProvider provider,
UsageAnalyzer analyzer,
SessionFileCache sessionFileCache,
SessionActivityTracker sessionActivityTracker,
ILogger<DataService> logger)
{
_provider = provider;
_analyzer = analyzer;
_sessionFileCache = sessionFileCache;
_sessionActivityTracker = sessionActivityTracker;
_logger = logger;

if (provider is ClaudeUsageProvider claudeProvider)
Expand Down Expand Up @@ -206,11 +216,14 @@ private void SessionsWatcher_Changed(object sender, FileSystemEventArgs e)
{
_logger.LogTrace("Session file change detected: {ChangeType} - {FullPath}", e.ChangeType, e.FullPath);

_cache.Remove(StatsCacheKey);

switch (e.ChangeType)
{
case WatcherChangeTypes.Changed:
case WatcherChangeTypes.Created:
_sessionFileCache.Set(e.FullPath);
_sessionActivityTracker.Observe(File.GetLastWriteTimeUtc(e.FullPath));
break;

case WatcherChangeTypes.Deleted:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public static IServiceCollection AddClaudeUsageCore(this IServiceCollection serv
services.AddSingleton<StatsCacheBuilder>();
services.AddSingleton<SessionParser>();
services.AddSingleton<SessionFileCache>();
services.AddSingleton<SessionActivityTracker>();
services.AddSingleton<CostCalculator>();
services.AddSingleton<RecentActivityBuilder>();
services.AddSingleton<HourlyActivityBuilder>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using AIUsageMonitor.Core.Analytics;
using AIUsageMonitor.Core.Providers.Claude;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;

namespace AIUsageMonitor.Core.Tests.Providers.Claude;

public class ClaudeUsageProviderTests : IDisposable
{
private readonly string _claudeDir = Path.Combine(Path.GetTempPath(), $"claude-{Guid.NewGuid()}");
private readonly ClaudeUsageProvider _sut;

public ClaudeUsageProviderTests()
{
Directory.CreateDirectory(Path.Combine(_claudeDir, "projects"));

var locator = new ClaudeDataLocator(_claudeDir);
var sessionParser = new SessionParser(NullLogger<SessionParser>.Instance);
var sessionFileCache = new SessionFileCache(sessionParser);
var costCalculator = new CostCalculator();

_sut = new ClaudeUsageProvider(
locator,
new StatsCacheParser(),
new StatsCacheBuilder(sessionFileCache),
new RecentActivityBuilder(sessionFileCache, costCalculator),
new HourlyActivityBuilder(sessionFileCache),
new SessionActivityTracker(locator),
NullLogger<ClaudeUsageProvider>.Instance);
}

public void Dispose()
{
if (Directory.Exists(_claudeDir))
{
Directory.Delete(_claudeDir, recursive: true);
}
}

[Fact]
public void GetStatsCache_SessionFileNewerThanStatsCache_RebuildsFromTranscripts()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var statsCachePath = Path.Combine(_claudeDir, "stats-cache.json");
File.WriteAllText(statsCachePath, $$"""
{
"version": 1,
"lastComputedDate": "{{today:yyyy-MM-dd}}",
"totalSessions": 1,
"totalMessages": 1,
"modelUsage": {
"stale-model": { "inputTokens": 1, "outputTokens": 1 }
}
}
""");
File.SetLastWriteTimeUtc(statsCachePath, DateTime.UtcNow.AddMinutes(-30));

var sessionFile = Path.Combine(_claudeDir, "projects", "session.jsonl");
var timestamp = DateTimeOffset.UtcNow.ToString("O");
File.WriteAllLines(sessionFile,
[
"{\"type\":\"user\",\"timestamp\":\"" + timestamp + "\",\"sessionId\":\"s1\"}",
"{\"type\":\"assistant\",\"timestamp\":\"" + timestamp + "\",\"sessionId\":\"s1\",\"message\":{\"role\":\"assistant\",\"model\":\"fresh-model\",\"usage\":{\"input_tokens\":100,\"output_tokens\":50}}}",
]);
File.SetLastWriteTimeUtc(sessionFile, DateTime.UtcNow);

var cache = _sut.GetStatsCache();

Assert.True(cache.ModelUsage.ContainsKey("fresh-model"));
Assert.False(cache.ModelUsage.ContainsKey("stale-model"));
}

[Fact]
public void GetStatsCache_StatsCacheNewerThanSessions_UsesStatsCacheAsIs()
{
var sessionFile = Path.Combine(_claudeDir, "projects", "session.jsonl");
var timestamp = DateTimeOffset.UtcNow.ToString("O");
File.WriteAllLines(sessionFile,
[
"{\"type\":\"user\",\"timestamp\":\"" + timestamp + "\",\"sessionId\":\"s1\"}",
]);
File.SetLastWriteTimeUtc(sessionFile, DateTime.UtcNow.AddMinutes(-30));

var today = DateOnly.FromDateTime(DateTime.UtcNow);
var statsCachePath = Path.Combine(_claudeDir, "stats-cache.json");
File.WriteAllText(statsCachePath, $$"""
{
"version": 1,
"lastComputedDate": "{{today:yyyy-MM-dd}}",
"totalSessions": 1,
"totalMessages": 1,
"modelUsage": {
"cached-model": { "inputTokens": 1, "outputTokens": 1 }
}
}
""");
File.SetLastWriteTimeUtc(statsCachePath, DateTime.UtcNow);

var cache = _sut.GetStatsCache();

Assert.True(cache.ModelUsage.ContainsKey("cached-model"));
}
}
Loading