feat(usage): parse the TokenTracker sessions export defensively
This commit is contained in:
@@ -0,0 +1,98 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Usage.TokenTracker;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses <c>tokentracker sessions --format json</c>. 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.
|
||||||
|
/// </summary>
|
||||||
|
public static class TokenTrackerExportParser
|
||||||
|
{
|
||||||
|
/// <summary>The <c>version</c> value this parser was written against (CLI v0.88.4).</summary>
|
||||||
|
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<TokenTrackerSession>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user