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.State;
|
||||||
using ClaudeDo.Worker.Usage;
|
using ClaudeDo.Worker.Usage;
|
||||||
using ClaudeDo.Worker.Usage.Interfaces;
|
using ClaudeDo.Worker.Usage.Interfaces;
|
||||||
|
using ClaudeDo.Worker.Usage.TokenTracker;
|
||||||
using ClaudeDo.Worker.Worktrees;
|
using ClaudeDo.Worker.Worktrees;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
@@ -166,7 +167,8 @@ public record ModelUsageRowDto(
|
|||||||
long OutputTokens,
|
long OutputTokens,
|
||||||
long CacheReadTokens,
|
long CacheReadTokens,
|
||||||
long CacheCreationTokens,
|
long CacheCreationTokens,
|
||||||
int Messages);
|
int Messages,
|
||||||
|
double? CostUsd = null);
|
||||||
|
|
||||||
public record TaskUsageRowDto(
|
public record TaskUsageRowDto(
|
||||||
string TaskId,
|
string TaskId,
|
||||||
@@ -176,7 +178,21 @@ public record TaskUsageRowDto(
|
|||||||
string? Model,
|
string? Model,
|
||||||
int Runs,
|
int Runs,
|
||||||
long TokensIn,
|
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
|
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 UsageSnapshotBuilder? _usageSnapshotBuilder;
|
||||||
private readonly ITranscriptUsageReader? _usageReader;
|
private readonly ITranscriptUsageReader? _usageReader;
|
||||||
private readonly UsageMonitorService? _usageMonitor;
|
private readonly UsageMonitorService? _usageMonitor;
|
||||||
|
private readonly TokenTrackerService? _tokenTracker;
|
||||||
private readonly InteractiveReviewSubmissionService? _interactiveReviewSubmission;
|
private readonly InteractiveReviewSubmissionService? _interactiveReviewSubmission;
|
||||||
|
|
||||||
public WorkerHub(
|
public WorkerHub(
|
||||||
@@ -262,7 +279,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
UsageSnapshotBuilder? usageSnapshotBuilder = null,
|
UsageSnapshotBuilder? usageSnapshotBuilder = null,
|
||||||
ITranscriptUsageReader? usageReader = null,
|
ITranscriptUsageReader? usageReader = null,
|
||||||
UsageMonitorService? usageMonitor = null,
|
UsageMonitorService? usageMonitor = null,
|
||||||
InteractiveReviewSubmissionService? interactiveReviewSubmission = null)
|
InteractiveReviewSubmissionService? interactiveReviewSubmission = null,
|
||||||
|
TokenTrackerService? tokenTracker = null)
|
||||||
{
|
{
|
||||||
_queue = queue;
|
_queue = queue;
|
||||||
_waker = waker;
|
_waker = waker;
|
||||||
@@ -296,6 +314,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
_usageReader = usageReader;
|
_usageReader = usageReader;
|
||||||
_usageMonitor = usageMonitor;
|
_usageMonitor = usageMonitor;
|
||||||
_interactiveReviewSubmission = interactiveReviewSubmission;
|
_interactiveReviewSubmission = interactiveReviewSubmission;
|
||||||
|
_tokenTracker = tokenTracker;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persistence boundary for the session_skills JSON-array columns (task/list/global).
|
// 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);
|
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 () =>
|
public Task<IReadOnlyList<ModelUsageRowDto>> GetModelUsage(DateOnly from, DateOnly to) => HubGuard(async () =>
|
||||||
{
|
{
|
||||||
if (_usageReader is null)
|
if (_tokenTracker is null)
|
||||||
throw new InvalidOperationException("Transcript usage reader is not configured.");
|
return (IReadOnlyList<ModelUsageRowDto>)Array.Empty<ModelUsageRowDto>();
|
||||||
var rows = await _usageReader.ReadAsync(from, to, Context.ConnectionAborted);
|
|
||||||
return (IReadOnlyList<ModelUsageRowDto>)rows
|
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(
|
.Select(r => new ModelUsageRowDto(
|
||||||
r.Date, r.Model, r.Scope == UsageScope.ClaudeDo ? "claudedo" : "other",
|
r.Date, r.Model, r.Scope,
|
||||||
r.InputTokens, r.OutputTokens, r.CacheReadTokens, r.CacheCreationTokens, r.Messages))
|
r.InputTokens, r.OutputTokens, r.CacheReadTokens, r.CacheCreationTokens,
|
||||||
|
r.Sessions, r.CostUsd))
|
||||||
.ToList();
|
.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)
|
public async Task<IReadOnlyList<TaskUsageRowDto>> GetTaskUsage(DateOnly from, DateOnly to)
|
||||||
{
|
{
|
||||||
var fromDt = from.ToDateTime(TimeOnly.MinValue);
|
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 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);
|
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
|
return runs
|
||||||
.Where(r => taskById.ContainsKey(r.TaskId))
|
.Where(r => taskById.ContainsKey(r.TaskId))
|
||||||
.GroupBy(r => r.TaskId)
|
.GroupBy(r => r.TaskId)
|
||||||
@@ -1216,6 +1329,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
var task = taskById[g.Key];
|
var task = taskById[g.Key];
|
||||||
var listName = lists.TryGetValue(task.ListId, out var list) ? list.Name : "";
|
var listName = lists.TryGetValue(task.ListId, out var list) ? list.Name : "";
|
||||||
var latestModel = g.OrderByDescending(r => r.StartedAt).First().Model;
|
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(
|
return new TaskUsageRowDto(
|
||||||
task.Id,
|
task.Id,
|
||||||
task.Title,
|
task.Title,
|
||||||
@@ -1224,7 +1341,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
latestModel,
|
latestModel,
|
||||||
g.Count(),
|
g.Count(),
|
||||||
g.Sum(r => (long)(r.TokensIn ?? 0)),
|
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)
|
.OrderByDescending(r => r.TokensIn + r.TokensOut)
|
||||||
.Take(100)
|
.Take(100)
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ using ClaudeDo.Worker.Report.Interfaces;
|
|||||||
using ClaudeDo.Worker.Skills;
|
using ClaudeDo.Worker.Skills;
|
||||||
using ClaudeDo.Worker.Usage;
|
using ClaudeDo.Worker.Usage;
|
||||||
using ClaudeDo.Worker.Usage.Interfaces;
|
using ClaudeDo.Worker.Usage.Interfaces;
|
||||||
|
using ClaudeDo.Worker.Usage.TokenTracker;
|
||||||
|
using ClaudeDo.Worker.Usage.TokenTracker.Interfaces;
|
||||||
using ClaudeDo.Worker.Worktrees;
|
using ClaudeDo.Worker.Worktrees;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
@@ -143,6 +145,12 @@ builder.Services.AddSingleton<IWeekReportService, WeekReportService>();
|
|||||||
// Usage
|
// Usage
|
||||||
builder.Services.AddSingleton<ITranscriptUsageReader, TranscriptUsageReader>();
|
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
|
// Prime Claude
|
||||||
builder.Services.AddSingleton<IPrimeClock, PrimeClock>();
|
builder.Services.AddSingleton<IPrimeClock, PrimeClock>();
|
||||||
builder.Services.AddSingleton<PrimeScheduleSignal>();
|
builder.Services.AddSingleton<PrimeScheduleSignal>();
|
||||||
|
|||||||
Reference in New Issue
Block a user