From 106f9a374a3d62f849417f1f8237c72421cfba8e Mon Sep 17 00:00:00 2001 From: mika kuns Date: Mon, 24 Aug 2026 13:34:01 +0200 Subject: [PATCH] feat(usage): parse the TokenTracker sessions export defensively --- .../TokenTracker/TokenTrackerExportParser.cs | 98 +++++++++++++++++ .../TokenTrackerExportParserTests.cs | 100 ++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerExportParser.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerExportParserTests.cs diff --git a/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerExportParser.cs b/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerExportParser.cs new file mode 100644 index 00000000..95afbe73 --- /dev/null +++ b/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerExportParser.cs @@ -0,0 +1,98 @@ +using System.Text.Json; + +namespace ClaudeDo.Worker.Usage.TokenTracker; + +/// +/// Parses tokentracker sessions --format json. The export is not a promised API, so every +/// field is read defensively: a missing number becomes 0, an unreadable row is skipped, and +/// unparseable output returns null rather than throwing into the caller's face. +/// +public static class TokenTrackerExportParser +{ + /// The version value this parser was written against (CLI v0.88.4). + public const int SupportedFormatVersion = 11; + + public static TokenTrackerExport? Parse(string? json, DateTime fetchedAtUtc) + { + if (string.IsNullOrWhiteSpace(json)) return null; + + JsonDocument doc; + try { doc = JsonDocument.Parse(json); } + catch (JsonException) { return null; } + + using (doc) + { + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) return null; + if (!root.TryGetProperty("sessions", out var sessions) || + sessions.ValueKind != JsonValueKind.Array) return null; + + var parsed = new List(); + var version = 0; + + foreach (var row in sessions.EnumerateArray()) + { + if (row.ValueKind != JsonValueKind.Object) continue; + + version = Math.Max(version, (int)GetLong(row, "version")); + + var hash = GetString(row, "session_hash"); + if (string.IsNullOrWhiteSpace(hash)) continue; + + var source = GetString(row, "source") ?? ""; + if (!string.Equals(source, "claude", StringComparison.OrdinalIgnoreCase)) continue; + + if (!DateTimeOffset.TryParse(GetString(row, "started_at"), out var startedAt)) continue; + + var tokens = row.TryGetProperty("tokens", out var t) && t.ValueKind == JsonValueKind.Object + ? t + : default; + + parsed.Add(new TokenTrackerSession( + SessionHash: hash!, + Source: source, + Model: GetString(row, "model") ?? "", + StartedAt: startedAt, + InputTokens: GetLong(tokens, "input_tokens"), + OutputTokens: GetLong(tokens, "output_tokens"), + CacheReadTokens: GetLong(tokens, "cached_input_tokens"), + CacheCreationTokens: GetLong(tokens, "cache_creation_input_tokens"), + CostUsd: GetDouble(row, "cost_usd"), + Turns: (int)GetLong(row, "turns"), + RetryTurns: (int)GetLong(row, "retry_turns"), + Productive: GetBool(row, "productive"), + OneShot: GetBool(row, "one_shot"))); + } + + return new TokenTrackerExport(version, parsed, fetchedAtUtc); + } + } + + private static string? GetString(JsonElement obj, string prop) => + obj.ValueKind == JsonValueKind.Object && + obj.TryGetProperty(prop, out var el) && + el.ValueKind == JsonValueKind.String + ? el.GetString() + : null; + + private static long GetLong(JsonElement obj, string prop) => + obj.ValueKind == JsonValueKind.Object && + obj.TryGetProperty(prop, out var el) && + el.ValueKind == JsonValueKind.Number && + el.TryGetInt64(out var v) + ? v + : 0; + + private static double GetDouble(JsonElement obj, string prop) => + obj.ValueKind == JsonValueKind.Object && + obj.TryGetProperty(prop, out var el) && + el.ValueKind == JsonValueKind.Number && + el.TryGetDouble(out var v) + ? v + : 0; + + private static bool GetBool(JsonElement obj, string prop) => + obj.ValueKind == JsonValueKind.Object && + obj.TryGetProperty(prop, out var el) && + el.ValueKind == JsonValueKind.True; +} diff --git a/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerExportParserTests.cs b/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerExportParserTests.cs new file mode 100644 index 00000000..c0637089 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerExportParserTests.cs @@ -0,0 +1,100 @@ +using ClaudeDo.Worker.Usage.TokenTracker; + +namespace ClaudeDo.Worker.Tests.Usage.TokenTracker; + +public sealed class TokenTrackerExportParserTests +{ + private static readonly DateTime FetchedAt = new(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc); + + [Fact] + public void Parse_ReadsAllSessions() + { + var export = TokenTrackerExportParser.Parse(TokenTrackerFixtures.SessionsJson, FetchedAt); + + Assert.NotNull(export); + Assert.Equal(4, export!.Sessions.Count); + Assert.Equal(11, export.FormatVersion); + Assert.Equal(FetchedAt, export.FetchedAtUtc); + } + + [Fact] + public void Parse_MapsTokenFieldsAndCost() + { + var export = TokenTrackerExportParser.Parse(TokenTrackerFixtures.SessionsJson, FetchedAt)!; + var first = export.Sessions.Single(s => s.SessionHash == "b24babcbf2b615730459773f"); + + Assert.Equal("claude-opus-5", first.Model); + Assert.Equal(100, first.InputTokens); + Assert.Equal(400, first.OutputTokens); + Assert.Equal(900, first.CacheReadTokens); + Assert.Equal(50, first.CacheCreationTokens); + Assert.Equal(2.5, first.CostUsd); + Assert.Equal(4, first.Turns); + Assert.Equal(1, first.RetryTurns); + Assert.True(first.Productive); + Assert.True(first.OneShot); + } + + [Fact] + public void Parse_UnsupportedVersion_StillParsesButReportsIt() + { + var export = TokenTrackerExportParser.Parse(TokenTrackerFixtures.UnsupportedVersionJson, FetchedAt); + + Assert.NotNull(export); + Assert.Equal(99, export!.FormatVersion); + } + + [Fact] + public void Parse_MissingFields_DefaultToZero() + { + const string json = """ + { "sessions": [ { "version": 11, "session_hash": "abc", "source": "claude", + "model": "claude-opus-5", "started_at": "2026-08-20T10:00:00.000Z" } ] } + """; + + var session = TokenTrackerExportParser.Parse(json, FetchedAt)!.Sessions.Single(); + + Assert.Equal(0, session.InputTokens); + Assert.Equal(0, session.CostUsd); + Assert.Equal(0, session.RetryTurns); + Assert.False(session.Productive); + } + + [Fact] + public void Parse_SkipsRowsWithoutHashOrTimestamp() + { + const string json = """ + { "sessions": [ { "version": 11, "source": "claude", "model": "m" }, + { "version": 11, "session_hash": "abc", "source": "claude", "model": "m" }, + { "version": 11, "session_hash": "def", "source": "claude", "model": "m", + "started_at": "2026-08-20T10:00:00.000Z" } ] } + """; + + var export = TokenTrackerExportParser.Parse(json, FetchedAt); + + Assert.Single(export!.Sessions); + Assert.Equal("def", export.Sessions[0].SessionHash); + } + + [Fact] + public void Parse_SkipsNonClaudeSources() + { + const string json = """ + { "sessions": [ { "version": 11, "session_hash": "abc", "source": "codex", "model": "gpt", + "started_at": "2026-08-20T10:00:00.000Z" } ] } + """; + + Assert.Empty(TokenTrackerExportParser.Parse(json, FetchedAt)!.Sessions); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("not json at all")] + [InlineData("[1,2,3]")] + [InlineData("{\"available\": false}")] + public void Parse_Garbage_ReturnsNull(string json) + { + Assert.Null(TokenTrackerExportParser.Parse(json, FetchedAt)); + } +}