refactor(usage): drop the transcript aggregation path in favour of TokenTracker

This commit is contained in:
mika kuns
2026-08-24 13:49:49 +02:00
parent 44337d4b94
commit 40879705ac
6 changed files with 23 additions and 281 deletions
@@ -2,9 +2,6 @@ namespace ClaudeDo.Worker.Usage.Interfaces;
public interface ITranscriptUsageReader public interface ITranscriptUsageReader
{ {
Task<IReadOnlyList<UsageAggregateRow>> ReadAsync(
DateOnly start, DateOnly end, CancellationToken ct = default);
/// <summary>Cumulative raw token totals for one session's transcript file /// <summary>Cumulative raw token totals for one session's transcript file
/// (located by <c>{sessionId}.jsonl</c> under the projects root), or null when /// (located by <c>{sessionId}.jsonl</c> under the projects root), or null when
/// no matching transcript file can be found or read.</summary> /// no matching transcript file can be found or read.</summary>
@@ -9,63 +9,13 @@ namespace ClaudeDo.Worker.Usage;
public sealed class TranscriptUsageReader : ITranscriptUsageReader public sealed class TranscriptUsageReader : ITranscriptUsageReader
{ {
private readonly string _projectsRoot; private readonly string _projectsRoot;
private readonly string _centralRoot;
private readonly string _sandboxRoot;
private readonly ConcurrentDictionary<string, FileCacheEntry> _cache = new(); private readonly ConcurrentDictionary<string, FileCacheEntry> _cache = new();
// cfg is unused since the model/scope aggregation moved to TokenTracker, but the parameter
// stays so the DI registration and every existing call site keep working.
public TranscriptUsageReader(WorkerConfig cfg, string? projectsRoot = null) public TranscriptUsageReader(WorkerConfig cfg, string? projectsRoot = null)
{ {
_projectsRoot = projectsRoot ?? Paths.Expand("~/.claude/projects"); _projectsRoot = projectsRoot ?? Paths.Expand("~/.claude/projects");
_centralRoot = NormalizePath(cfg.CentralWorktreeRoot);
_sandboxRoot = NormalizePath(cfg.SandboxRoot);
}
public Task<IReadOnlyList<UsageAggregateRow>> ReadAsync(
DateOnly start, DateOnly end, CancellationToken ct = default)
{
var seenKeys = new HashSet<string>();
var buckets = new Dictionary<(DateOnly Date, string Model, UsageScope Scope), Accumulator>();
if (Directory.Exists(_projectsRoot))
{
// A transcript last written before the window began cannot hold a record inside it, so
// it is skipped unread — that is what keeps a 7-day range off the full history (hundreds
// of MB). One day of slack absorbs local-vs-UTC skew between mtime and record stamps.
var mtimeCutoff = start.ToDateTime(TimeOnly.MinValue).AddDays(-1);
foreach (var file in new DirectoryInfo(_projectsRoot).EnumerateFiles("*.jsonl", SearchOption.AllDirectories))
{
ct.ThrowIfCancellationRequested();
if (file.LastWriteTime < mtimeCutoff) continue;
foreach (var record in GetOrReadFile(file))
{
if (record.Date < start || record.Date > end) continue;
if (!seenKeys.Add(record.DedupeKey)) continue;
var key = (record.Date, record.Model, record.Scope);
if (!buckets.TryGetValue(key, out var acc))
{
acc = new Accumulator();
buckets[key] = acc;
}
acc.Input += record.InputTokens;
acc.Output += record.OutputTokens;
acc.CacheRead += record.CacheReadTokens;
acc.CacheCreation += record.CacheCreationTokens;
acc.Messages++;
}
}
}
var rows = buckets
.Select(kv => new UsageAggregateRow(
kv.Key.Date, kv.Key.Model, kv.Key.Scope,
kv.Value.Input, kv.Value.Output, kv.Value.CacheRead, kv.Value.CacheCreation, kv.Value.Messages))
.OrderBy(r => r.Date).ThenBy(r => r.Model).ThenBy(r => r.Scope)
.ToList();
return Task.FromResult<IReadOnlyList<UsageAggregateRow>>(rows);
} }
public Task<SessionUsageTotals?> ReadSessionTotalsAsync(string sessionId, CancellationToken ct = default) public Task<SessionUsageTotals?> ReadSessionTotalsAsync(string sessionId, CancellationToken ct = default)
@@ -130,7 +80,8 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader
if (!root.TryGetProperty("message", out var msg) || msg.ValueKind != JsonValueKind.Object) continue; if (!root.TryGetProperty("message", out var msg) || msg.ValueKind != JsonValueKind.Object) continue;
if (!msg.TryGetProperty("model", out var modelEl) || modelEl.ValueKind != JsonValueKind.String) continue; if (!msg.TryGetProperty("model", out var modelEl) || modelEl.ValueKind != JsonValueKind.String) continue;
var date = DateOnly.FromDateTime(ts.LocalDateTime); // The timestamp and model are still parsed above: they gate out non-assistant
// lines and <synthetic> models, which is right for the session totals too.
var model = modelEl.GetString()!; var model = modelEl.GetString()!;
if (model == "<synthetic>") continue; if (model == "<synthetic>") continue;
@@ -143,54 +94,26 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader
cacheCreation = GetLong(usage, "cache_creation_input_tokens"); cacheCreation = GetLong(usage, "cache_creation_input_tokens");
} }
var cwd = TryGetString(root, "cwd") ?? "";
var dedupeKey = TryGetString(root, "requestId") var dedupeKey = TryGetString(root, "requestId")
?? TryGetString(msg, "id") ?? TryGetString(msg, "id")
?? Guid.NewGuid().ToString(); ?? Guid.NewGuid().ToString();
records.Add(new UsageMessageRecord( records.Add(new UsageMessageRecord(input, output, cacheRead, cacheCreation, dedupeKey));
date, model, ResolveScope(cwd), input, output, cacheRead, cacheCreation, dedupeKey));
} }
} }
return records; return records;
} }
private UsageScope ResolveScope(string cwd)
{
var norm = NormalizePath(cwd);
if (norm.Length == 0) return UsageScope.Other;
// Sibling-strategy worktrees are placed next to whatever repo they belong to
// (no single root path), but always under a literal ".claudedo-worktrees" segment.
if (norm.Split('\\').Any(seg => seg == ".claudedo-worktrees")) return UsageScope.ClaudeDo;
if (IsUnderRoot(norm, _centralRoot)) return UsageScope.ClaudeDo;
if (IsUnderRoot(norm, _sandboxRoot)) return UsageScope.ClaudeDo;
return UsageScope.Other;
}
private static bool IsUnderRoot(string normPath, string normRoot) =>
normRoot.Length > 0 && (normPath == normRoot || normPath.StartsWith(normRoot + "\\", StringComparison.Ordinal));
private static string? TryGetString(JsonElement obj, string prop) => private static string? TryGetString(JsonElement obj, string prop) =>
obj.TryGetProperty(prop, out var el) && el.ValueKind == JsonValueKind.String ? el.GetString() : null; obj.TryGetProperty(prop, out var el) && el.ValueKind == JsonValueKind.String ? el.GetString() : null;
private static long GetLong(JsonElement obj, string prop) => private static long GetLong(JsonElement obj, string prop) =>
obj.TryGetProperty(prop, out var el) && el.TryGetInt64(out var v) ? v : 0; obj.TryGetProperty(prop, out var el) && el.TryGetInt64(out var v) ? v : 0;
private static string NormalizePath(string p) =>
(p ?? "").Replace('/', '\\').TrimEnd('\\').ToLowerInvariant();
private sealed class Accumulator
{
public long Input, Output, CacheRead, CacheCreation;
public int Messages;
}
private sealed record FileCacheEntry(long Length, DateTime LastWriteUtc, List<UsageMessageRecord> Records); private sealed record FileCacheEntry(long Length, DateTime LastWriteUtc, List<UsageMessageRecord> Records);
private sealed record UsageMessageRecord( private sealed record UsageMessageRecord(
DateOnly Date, string Model, UsageScope Scope,
long InputTokens, long OutputTokens, long CacheReadTokens, long CacheCreationTokens, long InputTokens, long OutputTokens, long CacheReadTokens, long CacheCreationTokens,
string DedupeKey); string DedupeKey);
} }
-16
View File
@@ -17,22 +17,6 @@ public sealed record UsageSnapshot(
IReadOnlyList<UsageLimitRow> Limits, IReadOnlyList<UsageLimitRow> Limits,
DateTime FetchedAtUtc); DateTime FetchedAtUtc);
public enum UsageScope
{
ClaudeDo,
Other,
}
public sealed record UsageAggregateRow(
DateOnly Date,
string Model,
UsageScope Scope,
long InputTokens,
long OutputTokens,
long CacheReadTokens,
long CacheCreationTokens,
int Messages);
/// <summary>Cumulative raw token usage for one Claude Code session (all its transcript /// <summary>Cumulative raw token usage for one Claude Code session (all its transcript
/// lines to date), synthetic messages excluded. Not a per-run delta.</summary> /// lines to date), synthetic messages excluded. Not a per-run delta.</summary>
public sealed record SessionUsageTotals( public sealed record SessionUsageTotals(
@@ -9,10 +9,6 @@ public sealed class FakeTranscriptUsageReader : ITranscriptUsageReader
public void SetTotals(string sessionId, SessionUsageTotals totals) => _totalsBySession[sessionId] = totals; public void SetTotals(string sessionId, SessionUsageTotals totals) => _totalsBySession[sessionId] = totals;
public Task<IReadOnlyList<UsageAggregateRow>> ReadAsync(
DateOnly start, DateOnly end, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<UsageAggregateRow>>(Array.Empty<UsageAggregateRow>());
public Task<SessionUsageTotals?> ReadSessionTotalsAsync(string sessionId, CancellationToken ct = default) => public Task<SessionUsageTotals?> ReadSessionTotalsAsync(string sessionId, CancellationToken ct = default) =>
Task.FromResult(_totalsBySession.TryGetValue(sessionId, out var totals) ? totals : null); Task.FromResult(_totalsBySession.TryGetValue(sessionId, out var totals) ? totals : null);
} }
@@ -155,10 +155,6 @@ public sealed class RunUsagePersistenceTests : IDisposable
private sealed class ThrowingUsageReader : ITranscriptUsageReader private sealed class ThrowingUsageReader : ITranscriptUsageReader
{ {
public Task<IReadOnlyList<UsageAggregateRow>> ReadAsync(
DateOnly start, DateOnly end, CancellationToken ct = default) =>
throw new IOException("boom");
public Task<SessionUsageTotals?> ReadSessionTotalsAsync(string sessionId, CancellationToken ct = default) => public Task<SessionUsageTotals?> ReadSessionTotalsAsync(string sessionId, CancellationToken ct = default) =>
throw new IOException("boom"); throw new IOException("boom");
} }
@@ -4,6 +4,10 @@ using ClaudeDo.Worker.Usage;
namespace ClaudeDo.Worker.Tests.Usage; namespace ClaudeDo.Worker.Tests.Usage;
/// <summary>
/// Covers what is left of the reader after the model/scope aggregation moved to TokenTracker:
/// the per-session totals that back <c>task_runs</c>' per-run token delta.
/// </summary>
public class TranscriptUsageReaderTests : IDisposable public class TranscriptUsageReaderTests : IDisposable
{ {
private readonly string _root; private readonly string _root;
@@ -57,178 +61,6 @@ public class TranscriptUsageReaderTests : IDisposable
private TranscriptUsageReader MakeReader() => private TranscriptUsageReader MakeReader() =>
new(_cfg, Path.Combine(_root, "projects")); new(_cfg, Path.Combine(_root, "projects"));
[Fact]
public async Task Aggregates_By_Date_And_Model_Including_Cache_Tokens()
{
WriteSession("proj", "s.jsonl",
AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 10, 20, 3, 1),
AssistantLine(@"C:\Dev\App", "2026-06-01T09:00:00Z", "claude-sonnet-5", 5, 6, 1, 0),
AssistantLine(@"C:\Dev\App", "2026-06-02T08:00:00Z", "claude-opus-5", 100, 200, 30, 10));
var reader = MakeReader();
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
Assert.Equal(2, result.Count);
var day1 = Assert.Single(result, r => r.Model == "claude-sonnet-5");
Assert.Equal(new DateOnly(2026, 6, 1), day1.Date);
Assert.Equal(15, day1.InputTokens);
Assert.Equal(26, day1.OutputTokens);
Assert.Equal(4, day1.CacheReadTokens);
Assert.Equal(1, day1.CacheCreationTokens);
Assert.Equal(2, day1.Messages);
var day2 = Assert.Single(result, r => r.Model == "claude-opus-5");
Assert.Equal(new DateOnly(2026, 6, 2), day2.Date);
Assert.Equal(100, day2.InputTokens);
Assert.Equal(1, day2.Messages);
}
[Fact]
public async Task Same_RequestId_Across_Files_Counts_Once()
{
WriteSession("proj-a", "s1.jsonl",
AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 10, 20, 0, 0, requestId: "req-1"));
WriteSession("proj-b", "s2.jsonl",
AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 10, 20, 0, 0, requestId: "req-1"));
var reader = MakeReader();
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
var row = Assert.Single(result);
Assert.Equal(1, row.Messages);
Assert.Equal(10, row.InputTokens);
}
[Fact]
public async Task Scope_Split_Covers_Sibling_Central_And_Sandbox_Roots()
{
var siblingCwd = Path.Combine(_root, "some-repo", ".claudedo-worktrees", "list-slug", "task-id");
var centralCwd = Path.Combine(_cfg.CentralWorktreeRoot, "list-slug", "task-id");
var sandboxCwd = Path.Combine(_cfg.SandboxRoot, "task-id");
var otherCwd = Path.Combine(_root, "some-repo");
WriteSession("proj", "s.jsonl",
AssistantLine(siblingCwd, "2026-06-01T08:00:00Z", "claude-sonnet-5", 1, 1, 0, 0, requestId: "sibling"),
AssistantLine(centralCwd, "2026-06-01T08:00:00Z", "claude-sonnet-5", 1, 1, 0, 0, requestId: "central"),
AssistantLine(sandboxCwd, "2026-06-01T08:00:00Z", "claude-sonnet-5", 1, 1, 0, 0, requestId: "sandbox"),
AssistantLine(otherCwd, "2026-06-01T08:00:00Z", "claude-sonnet-5", 1, 1, 0, 0, requestId: "other"));
var reader = MakeReader();
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
Assert.Equal(2, result.Count);
var claudeDo = Assert.Single(result, r => r.Scope == UsageScope.ClaudeDo);
Assert.Equal(3, claudeDo.Messages);
var other = Assert.Single(result, r => r.Scope == UsageScope.Other);
Assert.Equal(1, other.Messages);
}
[Fact]
public async Task Date_Filter_Excludes_Lines_Outside_Window()
{
WriteSession("proj", "s.jsonl",
AssistantLine(@"C:\Dev\App", "2026-05-01T08:00:00Z", "claude-sonnet-5", 1, 1, 0, 0),
AssistantLine(@"C:\Dev\App", "2026-06-02T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0),
AssistantLine(@"C:\Dev\App", "2026-07-01T08:00:00Z", "claude-sonnet-5", 1, 1, 0, 0));
var reader = MakeReader();
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
var row = Assert.Single(result);
Assert.Equal(5, row.InputTokens);
Assert.Equal(1, row.Messages);
}
[Fact]
public async Task Files_Last_Written_Before_The_Window_Are_Not_Read()
{
// Deliberate heuristic: a transcript whose mtime predates the window cannot contain a
// record inside it, so it is skipped unread. Here the content would match the window —
// proving the file was never opened, which is what keeps a 7-day range off the full history.
var path = WriteSession("proj", "old.jsonl",
AssistantLine(@"C:\Dev\App", "2026-06-02T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0));
File.SetLastWriteTime(path, new DateTime(2026, 5, 1, 12, 0, 0));
var reader = MakeReader();
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
Assert.Empty(result);
}
[Fact]
public async Task File_Written_On_The_Window_Start_Day_Is_Still_Read()
{
var path = WriteSession("proj", "edge.jsonl",
AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0));
File.SetLastWriteTime(path, new DateTime(2026, 6, 1, 0, 5, 0));
var reader = MakeReader();
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
Assert.Single(result);
}
[Fact]
public async Task Malformed_Line_Does_Not_Abort_The_Run()
{
WriteSession("proj", "s.jsonl",
"this is not json",
AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0));
var reader = MakeReader();
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
var row = Assert.Single(result);
Assert.Equal(1, row.Messages);
}
[Fact]
public async Task Cache_Skips_Unchanged_File_And_Picks_Up_Appended_Lines()
{
var path = WriteSession("proj", "s.jsonl",
AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0, requestId: "req-a"));
var reader = MakeReader();
var first = Assert.Single(await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3)));
Assert.Equal(1, first.Messages);
// Overwrite with different content but the SAME length and mtime: if the reader honored
// the cache it must still return the ORIGINAL aggregate, proving it did not re-read the file.
var originalBytes = File.ReadAllBytes(path);
var originalWriteUtc = File.GetLastWriteTimeUtc(path);
var tamperedLine = AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 9, 9, 0, 0, requestId: "req-9");
// Pad/truncate to the EXACT same byte length as the original file so the (length, mtime)
// cache key still matches — the reader must then serve the cached (stale) aggregate.
var tamperedPadded = tamperedLine.Length + 1 <= originalBytes.Length
? tamperedLine.PadRight(originalBytes.Length - 1) + "\n"
: tamperedLine[..(originalBytes.Length - 1)] + "\n";
var tamperedBytes = System.Text.Encoding.UTF8.GetBytes(tamperedPadded);
Assert.Equal(originalBytes.Length, tamperedBytes.Length);
File.WriteAllBytes(path, tamperedBytes);
File.SetLastWriteTimeUtc(path, originalWriteUtc);
var stale = Assert.Single(await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3)));
Assert.Equal(5, stale.InputTokens);
// Now really append a new line: length/mtime change, so the file must be re-read.
File.AppendAllLines(path, new[]
{
AssistantLine(@"C:\Dev\App", "2026-06-01T09:00:00Z", "claude-sonnet-5", 3, 3, 0, 0, requestId: "req-b"),
});
var updated = Assert.Single(await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3)));
Assert.Equal(2, updated.Messages);
}
[Fact]
public async Task Missing_ProjectsRoot_Returns_Empty_Without_Throwing()
{
var reader = new TranscriptUsageReader(_cfg, Path.Combine(_root, "does-not-exist"));
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
Assert.Empty(result);
}
[Fact] [Fact]
public async Task ReadSessionTotalsAsync_Sums_All_Assistant_Messages_In_The_Session_File() public async Task ReadSessionTotalsAsync_Sums_All_Assistant_Messages_In_The_Session_File()
{ {
@@ -276,6 +108,20 @@ public class TranscriptUsageReaderTests : IDisposable
Assert.Equal(20, totals.OutputTokens); Assert.Equal(20, totals.OutputTokens);
} }
[Fact]
public async Task ReadSessionTotalsAsync_Malformed_Line_Does_Not_Abort_The_Read()
{
WriteSession("proj", "sess-4.jsonl",
"this is not json",
AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0, requestId: "r1"));
var reader = MakeReader();
var totals = await reader.ReadSessionTotalsAsync("sess-4");
Assert.NotNull(totals);
Assert.Equal(5, totals!.InputTokens);
}
[Fact] [Fact]
public async Task ReadSessionTotalsAsync_Returns_Null_When_No_Matching_Transcript_File() public async Task ReadSessionTotalsAsync_Returns_Null_When_No_Matching_Transcript_File()
{ {