feat(usage): serve model and task usage from the TokenTracker export

This commit is contained in:
mika kuns
2026-08-24 13:41:21 +02:00
parent 3f3638c1ce
commit 44337d4b94
2 changed files with 139 additions and 10 deletions
+131 -10
View File
@@ -19,6 +19,7 @@ using ClaudeDo.Worker.Skills;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Usage;
using ClaudeDo.Worker.Usage.Interfaces;
using ClaudeDo.Worker.Usage.TokenTracker;
using ClaudeDo.Worker.Worktrees;
using System.Text.Json;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -166,7 +167,8 @@ public record ModelUsageRowDto(
long OutputTokens,
long CacheReadTokens,
long CacheCreationTokens,
int Messages);
int Messages,
double? CostUsd = null);
public record TaskUsageRowDto(
string TaskId,
@@ -176,7 +178,21 @@ public record TaskUsageRowDto(
string? Model,
int Runs,
long TokensIn,
long TokensOut);
long TokensOut,
double? CostUsd = null,
int? Retries = null,
bool? Productive = null,
bool? OneShot = null);
public record TokenTrackerStatusDto(
bool Installed,
string? Version,
bool NodeOk,
string? NodeVersion,
DateTime? LastFetchedUtc,
string? LastError,
int? FormatVersion,
int SessionCount);
public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
{
@@ -228,6 +244,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly UsageSnapshotBuilder? _usageSnapshotBuilder;
private readonly ITranscriptUsageReader? _usageReader;
private readonly UsageMonitorService? _usageMonitor;
private readonly TokenTrackerService? _tokenTracker;
private readonly InteractiveReviewSubmissionService? _interactiveReviewSubmission;
public WorkerHub(
@@ -262,7 +279,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
UsageSnapshotBuilder? usageSnapshotBuilder = null,
ITranscriptUsageReader? usageReader = null,
UsageMonitorService? usageMonitor = null,
InteractiveReviewSubmissionService? interactiveReviewSubmission = null)
InteractiveReviewSubmissionService? interactiveReviewSubmission = null,
TokenTrackerService? tokenTracker = null)
{
_queue = queue;
_waker = waker;
@@ -296,6 +314,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_usageReader = usageReader;
_usageMonitor = usageMonitor;
_interactiveReviewSubmission = interactiveReviewSubmission;
_tokenTracker = tokenTracker;
}
// Persistence boundary for the session_skills JSON-array columns (task/list/global).
@@ -1177,18 +1196,108 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
return _usageMonitor.RefreshNowAsync(Context.ConnectionAborted);
});
public Task<TokenTrackerStatusDto> GetTokenTrackerStatus() => HubGuard(async () =>
{
if (_tokenTracker is null)
return new TokenTrackerStatusDto(false, null, false, null, null,
"TokenTracker is not configured on this worker.", null, 0);
var probe = await _tokenTracker.ProbeAsync(ct: Context.ConnectionAborted);
return BuildTokenTrackerStatus(probe);
});
public Task<TokenTrackerStatusDto> RefreshTokenTracker() => HubGuard(async () =>
{
if (_tokenTracker is null)
throw new InvalidOperationException("TokenTracker is not configured.");
await _tokenTracker.RefreshAsync(Context.ConnectionAborted);
return BuildTokenTrackerStatus(await _tokenTracker.ProbeAsync(ct: Context.ConnectionAborted));
});
public Task<TokenTrackerStatusDto> InstallTokenTracker() => HubGuard(async () =>
{
if (_tokenTracker is null)
throw new InvalidOperationException("TokenTracker is not configured.");
// npm writes its progress to stderr; forwarding it line by line is what makes a
// multi-minute global install visible in the footer log strip instead of looking hung.
var progress = new Progress<string>(line =>
_ = _broadcaster.WorkerLog(line, WorkerLogLevel.Info, DateTime.UtcNow));
var result = await _tokenTracker.InstallAsync(progress, Context.ConnectionAborted);
if (!result.Ok)
await _broadcaster.WorkerLog(
result.Error ?? "TokenTracker install failed.", WorkerLogLevel.Error, DateTime.UtcNow);
return BuildTokenTrackerStatus(await _tokenTracker.ProbeAsync(ct: Context.ConnectionAborted));
});
private TokenTrackerStatusDto BuildTokenTrackerStatus(TokenTrackerProbe probe)
{
var state = _tokenTracker!.State;
return new TokenTrackerStatusDto(
probe.Installed,
probe.Version,
probe.NodeOk,
probe.NodeVersion,
state.Export?.FetchedAtUtc,
state.LastError ?? probe.Error,
state.Export?.FormatVersion,
state.Export?.Sessions.Count ?? 0);
}
/// <summary>
/// Model breakdown from the cached TokenTracker export. With no export at all we wait for one
/// (the caller already shows a spinner); with a stale one we serve it and refresh behind the
/// caller's back, because blocking a range switch on a fetch is worse than showing a
/// timestamped older number.
/// </summary>
public Task<IReadOnlyList<ModelUsageRowDto>> GetModelUsage(DateOnly from, DateOnly to) => HubGuard(async () =>
{
if (_usageReader is null)
throw new InvalidOperationException("Transcript usage reader is not configured.");
var rows = await _usageReader.ReadAsync(from, to, Context.ConnectionAborted);
return (IReadOnlyList<ModelUsageRowDto>)rows
if (_tokenTracker is null)
return (IReadOnlyList<ModelUsageRowDto>)Array.Empty<ModelUsageRowDto>();
if (_tokenTracker.State.Export is null)
await _tokenTracker.EnsureFreshAsync(TimeSpan.Zero, Context.ConnectionAborted);
else
_ = _tokenTracker.EnsureFreshAsync(TimeSpan.FromMinutes(15), CancellationToken.None);
var export = _tokenTracker.State.Export;
if (export is null)
return (IReadOnlyList<ModelUsageRowDto>)Array.Empty<ModelUsageRowDto>();
var claudeDoHashes = await ClaudeDoSessionHashesAsync();
return (IReadOnlyList<ModelUsageRowDto>)TokenTrackerAggregator
.ByModel(export.Sessions, from, to, claudeDoHashes)
.Select(r => new ModelUsageRowDto(
r.Date, r.Model, r.Scope == UsageScope.ClaudeDo ? "claudedo" : "other",
r.InputTokens, r.OutputTokens, r.CacheReadTokens, r.CacheCreationTokens, r.Messages))
r.Date, r.Model, r.Scope,
r.InputTokens, r.OutputTokens, r.CacheReadTokens, r.CacheCreationTokens,
r.Sessions, r.CostUsd))
.ToList();
});
/// <summary>Hashes of every session one of our runs owns — the scope split rides on set
/// membership rather than the export's own <c>project_key</c>, which falls back to a bare
/// worktree GUID for exactly the sessions we care about. Note this is narrower than the old
/// cwd-based split: an interactive/planning session in a worktree has no <c>task_runs</c> row
/// and therefore counts as "other".</summary>
private async Task<HashSet<string>> ClaudeDoSessionHashesAsync()
{
await using var ctx = await _dbFactory.CreateDbContextAsync(Context.ConnectionAborted);
var sessionIds = await ctx.TaskRuns
.Where(r => r.SessionId != null)
.Select(r => r.SessionId!)
.Distinct()
.ToListAsync(Context.ConnectionAborted);
return sessionIds
.Select(SessionHash.ForClaudeSession)
.Where(h => h is not null)
.ToHashSet()!;
}
public async Task<IReadOnlyList<TaskUsageRowDto>> GetTaskUsage(DateOnly from, DateOnly to)
{
var fromDt = from.ToDateTime(TimeOnly.MinValue);
@@ -1208,6 +1317,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var listIds = tasks.Select(t => t.ListId).Distinct().ToList();
var lists = await ctx.Lists.Where(l => listIds.Contains(l.Id)).ToDictionaryAsync(l => l.Id, Context.ConnectionAborted);
// Cost/retries are an enrichment, not a dependency: GetModelUsage is what triggers a
// refresh, so a missing export here just means the extras columns stay empty.
var export = _tokenTracker?.State.Export;
return runs
.Where(r => taskById.ContainsKey(r.TaskId))
.GroupBy(r => r.TaskId)
@@ -1216,6 +1329,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var task = taskById[g.Key];
var listName = lists.TryGetValue(task.ListId, out var list) ? list.Name : "";
var latestModel = g.OrderByDescending(r => r.StartedAt).First().Model;
var extras = export is null
? null
: TokenTrackerAggregator.ForSessions(export.Sessions, g.Select(r => r.SessionId));
return new TaskUsageRowDto(
task.Id,
task.Title,
@@ -1224,7 +1341,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
latestModel,
g.Count(),
g.Sum(r => (long)(r.TokensIn ?? 0)),
g.Sum(r => (long)(r.TokensOut ?? 0)));
g.Sum(r => (long)(r.TokensOut ?? 0)),
extras?.CostUsd,
extras?.Retries,
extras?.Productive,
extras?.OneShot);
})
.OrderByDescending(r => r.TokensIn + r.TokensOut)
.Take(100)
+8
View File
@@ -23,6 +23,8 @@ using ClaudeDo.Worker.Report.Interfaces;
using ClaudeDo.Worker.Skills;
using ClaudeDo.Worker.Usage;
using ClaudeDo.Worker.Usage.Interfaces;
using ClaudeDo.Worker.Usage.TokenTracker;
using ClaudeDo.Worker.Usage.TokenTracker.Interfaces;
using ClaudeDo.Worker.Worktrees;
using Microsoft.EntityFrameworkCore;
using Serilog;
@@ -143,6 +145,12 @@ builder.Services.AddSingleton<IWeekReportService, WeekReportService>();
// Usage
builder.Services.AddSingleton<ITranscriptUsageReader, TranscriptUsageReader>();
// TokenTracker — the analytics backend. Optional at runtime: every consumer degrades when the
// CLI is missing, so nothing here is a startup requirement.
builder.Services.AddSingleton<ITokenTrackerClient, TokenTrackerClient>();
builder.Services.AddSingleton<TokenTrackerState>();
builder.Services.AddSingleton<TokenTrackerService>();
// Prime Claude
builder.Services.AddSingleton<IPrimeClock, PrimeClock>();
builder.Services.AddSingleton<PrimeScheduleSignal>();