From 2fca0cc4d3e1ff70e171581e3008d9bb0676d1c5 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Mon, 24 Aug 2026 13:35:36 +0200 Subject: [PATCH] feat(usage): aggregate TokenTracker sessions by model and per task --- .../TokenTracker/TokenTrackerAggregator.cs | 105 +++++++++++++++++ .../TokenTrackerAggregatorTests.cs | 106 ++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerAggregator.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerAggregatorTests.cs diff --git a/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerAggregator.cs b/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerAggregator.cs new file mode 100644 index 00000000..9b3308b1 --- /dev/null +++ b/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerAggregator.cs @@ -0,0 +1,105 @@ +namespace ClaudeDo.Worker.Usage.TokenTracker; + +public sealed record TokenTrackerTotals(long TotalTokens, double CostUsd, int Sessions); + +/// +/// Pure aggregation over a parsed export. One fetch covers the whole history — --from and +/// --to are accepted by the CLI but do not filter its output (measured against +/// v0.88.4: identical 886 rows with and without a range) — so filtering here is load-bearing, and +/// switching the range in the UI costs nothing. +/// +public static class TokenTrackerAggregator +{ + public const string ScopeClaudeDo = "claudedo"; + public const string ScopeOther = "other"; + + public static IReadOnlyList ByModel( + IReadOnlyList sessions, + DateOnly from, + DateOnly to, + ISet claudeDoHashes) + { + var buckets = new Dictionary<(DateOnly Date, string Model, string Scope), Accumulator>(); + + foreach (var session in InRange(sessions, from, to)) + { + var key = ( + DateOf(session), + session.Model, + claudeDoHashes.Contains(session.SessionHash) ? ScopeClaudeDo : ScopeOther); + + if (!buckets.TryGetValue(key, out var acc)) + { + acc = new Accumulator(); + buckets[key] = acc; + } + + acc.Input += session.InputTokens; + acc.Output += session.OutputTokens; + acc.CacheRead += session.CacheReadTokens; + acc.CacheCreation += session.CacheCreationTokens; + acc.Cost += session.CostUsd; + acc.Sessions++; + } + + return buckets + .Select(kv => new TokenTrackerModelRow( + kv.Key.Date, kv.Key.Model, kv.Key.Scope, + kv.Value.Input, kv.Value.Output, kv.Value.CacheRead, kv.Value.CacheCreation, + kv.Value.Sessions, kv.Value.Cost)) + .OrderBy(r => r.Date).ThenBy(r => r.Model).ThenBy(r => r.Scope) + .ToList(); + } + + /// Cost and efficiency for one task, given the session ids of its runs. Null when + /// none of them appear in the export — the caller then shows tokens without cost. Several + /// export rows can share one session hash (they are disjoint segments of the same session), + /// so summing every match is correct rather than double-counting. + public static TokenTrackerTaskExtras? ForSessions( + IReadOnlyList sessions, + IEnumerable sessionIds) + { + var wanted = sessionIds + .Select(SessionHash.ForClaudeSession) + .Where(h => h is not null) + .ToHashSet()!; + + if (wanted.Count == 0) return null; + + var matched = sessions.Where(s => wanted.Contains(s.SessionHash)).ToList(); + if (matched.Count == 0) return null; + + return new TokenTrackerTaskExtras( + CostUsd: matched.Sum(s => s.CostUsd), + Retries: matched.Sum(s => s.RetryTurns), + Productive: matched.Any(s => s.Productive), + OneShot: matched.All(s => s.OneShot)); + } + + public static TokenTrackerTotals Totals( + IReadOnlyList sessions, DateOnly from, DateOnly to) + { + var inRange = InRange(sessions, from, to).ToList(); + return new TokenTrackerTotals( + TotalTokens: inRange.Sum(s => + s.InputTokens + s.OutputTokens + s.CacheReadTokens + s.CacheCreationTokens), + CostUsd: inRange.Sum(s => s.CostUsd), + Sessions: inRange.Count); + } + + private static IEnumerable InRange( + IReadOnlyList sessions, DateOnly from, DateOnly to) => + sessions.Where(s => DateOf(s) >= from && DateOf(s) <= to); + + /// Local start date — the UI's pickers and the old transcript reader both work in + /// local time, so a UTC-based date would shift rows across midnight for the user. + private static DateOnly DateOf(TokenTrackerSession session) => + DateOnly.FromDateTime(session.StartedAt.LocalDateTime); + + private sealed class Accumulator + { + public long Input, Output, CacheRead, CacheCreation; + public double Cost; + public int Sessions; + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerAggregatorTests.cs b/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerAggregatorTests.cs new file mode 100644 index 00000000..ffebe5ac --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerAggregatorTests.cs @@ -0,0 +1,106 @@ +using ClaudeDo.Worker.Usage.TokenTracker; + +namespace ClaudeDo.Worker.Tests.Usage.TokenTracker; + +public sealed class TokenTrackerAggregatorTests +{ + private static readonly DateOnly RangeStart = new(2026, 8, 20); + private static readonly DateOnly RangeEnd = new(2026, 8, 21); + + private static IReadOnlyList Sessions() => + TokenTrackerExportParser.Parse( + TokenTrackerFixtures.SessionsJson, + new DateTime(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc))!.Sessions; + + private static HashSet ClaudeDoHashes() => + [ + SessionHash.ForClaudeSession(TokenTrackerFixtures.ClaudeDoSessionIdA)!, + SessionHash.ForClaudeSession(TokenTrackerFixtures.ClaudeDoSessionIdB)!, + ]; + + [Fact] + public void ByModel_ExcludesSessionsOutsideTheRange() + { + var rows = TokenTrackerAggregator.ByModel(Sessions(), RangeStart, RangeEnd, ClaudeDoHashes()); + + Assert.All(rows, r => Assert.InRange(r.Date, RangeStart, RangeEnd)); + Assert.DoesNotContain(rows, r => r.CostUsd == 99.0); + } + + [Fact] + public void ByModel_SplitsClaudeDoFromOther() + { + var rows = TokenTrackerAggregator.ByModel(Sessions(), RangeStart, RangeEnd, ClaudeDoHashes()); + + var opus = rows.Single(r => r.Model == "claude-opus-5" && r.Scope == "claudedo"); + Assert.Equal(110, opus.InputTokens); + Assert.Equal(440, opus.OutputTokens); + Assert.Equal(990, opus.CacheReadTokens); + Assert.Equal(55, opus.CacheCreationTokens); + Assert.Equal(2, opus.Sessions); + Assert.Equal(3.5, opus.CostUsd, 6); + + var sonnet = rows.Single(r => r.Model == "claude-sonnet-5"); + Assert.Equal("other", sonnet.Scope); + Assert.Equal(0.25, sonnet.CostUsd, 6); + } + + [Fact] + public void ByModel_UnknownHash_CountsAsOther() + { + var rows = TokenTrackerAggregator.ByModel(Sessions(), RangeStart, RangeEnd, new HashSet()); + + Assert.All(rows, r => Assert.Equal("other", r.Scope)); + } + + [Fact] + public void ByModel_EmptyInput_ReturnsEmpty() + { + Assert.Empty(TokenTrackerAggregator.ByModel([], RangeStart, RangeEnd, new HashSet())); + } + + [Fact] + public void ForSessions_SumsCostAndRetriesOfMatchingRows() + { + var extras = TokenTrackerAggregator.ForSessions( + Sessions(), + [TokenTrackerFixtures.ClaudeDoSessionIdA, TokenTrackerFixtures.ClaudeDoSessionIdB]); + + Assert.NotNull(extras); + Assert.Equal(3.5, extras!.CostUsd, 6); + Assert.Equal(3, extras.Retries); + Assert.True(extras.Productive); // A is productive + Assert.False(extras.OneShot); // B is not one-shot + } + + [Fact] + public void ForSessions_SingleOneShotSession_ReportsOneShot() + { + var extras = TokenTrackerAggregator.ForSessions( + Sessions(), [TokenTrackerFixtures.ClaudeDoSessionIdA]); + + Assert.True(extras!.OneShot); + Assert.Equal(2.5, extras.CostUsd, 6); + } + + [Fact] + public void ForSessions_NoMatch_ReturnsNull() + { + Assert.Null(TokenTrackerAggregator.ForSessions(Sessions(), ["11111111-2222-3333-4444-555555555555"])); + } + + [Fact] + public void ForSessions_BlankIds_ReturnsNull() + { + Assert.Null(TokenTrackerAggregator.ForSessions(Sessions(), [null, "", " "])); + } + + [Fact] + public void Totals_SumsTokensAndCostInRange() + { + var totals = TokenTrackerAggregator.Totals(Sessions(), RangeStart, RangeEnd); + + Assert.Equal(1615, totals.TotalTokens); // 1450 + 145 + 20 + Assert.Equal(3.75, totals.CostUsd, 6); + } +}