feat(usage): serve model and task usage from the TokenTracker export
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user