using ClawdDotNet.Core.Audit;
namespace ClawdDotNet.Core.Analytics;
/// Filter, sort order and page of .
public sealed record RunQuery
{
public DateTime FromUtc { get; init; }
public DateTime ToUtc { get; init; }
public IReadOnlyCollection Agents { get; init; } = [];
public IReadOnlyCollection Models { get; init; } = [];
public IReadOnlyCollection Sources { get; init; } = [];
public IReadOnlyCollection Statuses { get; init; } = [];
public decimal? MinCostUsd { get; init; }
/// Free text over run id, agent, model, task id, task title and result.
public string? Search { get; init; }
/// time, agent, model, source, status, steps, prompt, completion, tokens, cost, duration.
public string Sort { get; init; } = "time";
public bool Descending { get; init; } = true;
public int Offset { get; init; }
public int Limit { get; init; } = 50;
}
public sealed record RunRow(RunReceipt Receipt, string? TaskTitle)
{
public int TotalTokens => Receipt.PromptTokens + Receipt.CompletionTokens;
}
/// Sums over the whole filter, not only the current page.
public sealed record RunTotals(
int Runs, long PromptTokens, long CompletionTokens, long CachedTokens,
decimal CostUsd, int RunsWithUnknownCost, int NotRun);
public sealed record RunPage(IReadOnlyList Items, int Total, RunTotals Totals);
/// One stacked bar segment: within the bucket starting at .
public sealed record SeriesPoint(DateTime BucketStart, string Group, int Runs, long Tokens, decimal CostUsd);
/// Something worth a marker on the time axis.
public sealed record UsageEvent(DateTime At, string Kind, string AgentId, string Text);
/// Values that occur in the range - the options of the filter dropdowns.
public sealed record UsageFacets(
IReadOnlyList Agents, IReadOnlyList Models,
IReadOnlyList Sources, IReadOnlyList Statuses);
///
/// Usage analysis over run receipts (#660). Receipts rather than RunUsage: only they carry
/// run id, source, task and the runs that never happened (budget denials, #608).
///
/// Filtering, sorting and bucketing happen in memory over the receipts of the
/// requested range. That keeps every rule in one testable place; the volume of a single
/// instance (a few hundred runs a day) is far below where this would matter. The range is
/// capped at receipts.
///
public sealed class UsageAnalytics(
IAuditRepository audit,
Func>? taskTitle = null)
{
public const int MaxReceipts = 50_000;
/// Statuses of runs that were refused before any model call.
private static readonly HashSet NotRunStatuses =
new(StringComparer.OrdinalIgnoreCase) { "BudgetExceeded", "ShuttingDown", "TargetBusy" };
public async Task QueryRunsAsync(RunQuery q, CancellationToken ct)
{
var rows = await LoadAsync(q.FromUtc, q.ToUtc, ct);
var filtered = rows.Where(r => Matches(r, q)).ToList();
var totals = new RunTotals(
filtered.Count,
filtered.Sum(r => (long)r.Receipt.PromptTokens),
filtered.Sum(r => (long)r.Receipt.CompletionTokens),
filtered.Sum(r => (long)r.Receipt.CachedTokens),
filtered.Sum(r => r.Receipt.CostUsd),
filtered.Count(r => !r.Receipt.CostIsKnown && !NotRunStatuses.Contains(r.Receipt.Status)),
filtered.Count(r => NotRunStatuses.Contains(r.Receipt.Status)));
var page = Sort(filtered, q.Sort, q.Descending)
.Skip(Math.Max(0, q.Offset))
.Take(Math.Clamp(q.Limit, 1, 500))
.ToList();
return new RunPage(page, filtered.Count, totals);
}
/// hour or day.
/// agent, model, source or budget (#717: daily or a special budget).
/// Offset of the viewer's local time - a "day" is the viewer's day.
public async Task> SeriesAsync(
RunQuery q, string bucket, string groupBy, TimeSpan utcOffset, CancellationToken ct)
{
var rows = (await LoadAsync(q.FromUtc, q.ToUtc, ct)).Where(r => Matches(r, q));
return rows
.GroupBy(r => (Start: BucketStart(r.Receipt.OccurredAt, bucket, utcOffset), Group: GroupKey(r.Receipt, groupBy)))
.Select(g => new SeriesPoint(
g.Key.Start, g.Key.Group, g.Count(),
g.Sum(r => (long)r.TotalTokens), g.Sum(r => r.Receipt.CostUsd)))
.OrderBy(p => p.BucketStart).ThenBy(p => p.Group, StringComparer.Ordinal)
.ToList();
}
/// Budget denials and model changes - the usual explanation for a jump in a chart.
public async Task> EventsAsync(DateTime fromUtc, DateTime toUtc, CancellationToken ct)
{
var rows = await LoadAsync(fromUtc, toUtc, ct);
var events = new List();
// One marker per agent and hour is enough for a denial streak.
foreach (var g in rows.Where(r => r.Receipt.Status == "BudgetExceeded")
.GroupBy(r => (r.Receipt.AgentId, Hour: Truncate(r.Receipt.OccurredAt, TimeSpan.FromHours(1)))))
{
events.Add(new UsageEvent(g.Min(r => r.Receipt.OccurredAt), "budget_exceeded", g.Key.AgentId,
$"{g.Count()} run(s) refused: daily budget exhausted"));
}
foreach (var agent in rows.GroupBy(r => r.Receipt.AgentId))
{
string? previous = null;
foreach (var r in agent.Where(r => !string.IsNullOrWhiteSpace(r.Receipt.Model)))
{
if (previous is not null && previous != r.Receipt.Model)
events.Add(new UsageEvent(r.Receipt.OccurredAt, "model_changed", agent.Key,
$"{previous} → {r.Receipt.Model}"));
previous = r.Receipt.Model;
}
}
return events.OrderBy(e => e.At).ToList();
}
public async Task FacetsAsync(DateTime fromUtc, DateTime toUtc, CancellationToken ct)
{
var rows = await LoadAsync(fromUtc, toUtc, ct);
static IReadOnlyList Distinct(IEnumerable values)
=> values.Where(v => !string.IsNullOrWhiteSpace(v)).Distinct().Order(StringComparer.Ordinal).ToList();
return new UsageFacets(
Distinct(rows.Select(r => r.Receipt.AgentId)),
Distinct(rows.Select(r => r.Receipt.Model)),
Distinct(rows.Select(r => r.Receipt.Source)),
Distinct(rows.Select(r => r.Receipt.Status)));
}
// ─── Building blocks ───
private async Task> LoadAsync(DateTime fromUtc, DateTime toUtc, CancellationToken ct)
{
var receipts = await audit.ListReceiptsAsync(fromUtc, toUtc, MaxReceipts, ct);
if (taskTitle is null)
return receipts.Select(r => new RunRow(r, null)).ToList();
var titles = new Dictionary();
foreach (var id in receipts.Select(r => r.TaskId).OfType().Distinct())
titles[id] = await taskTitle(id, ct);
return receipts.Select(r => new RunRow(r, r.TaskId is { } id ? titles.GetValueOrDefault(id) : null)).ToList();
}
internal static bool Matches(RunRow row, RunQuery q)
{
var r = row.Receipt;
if (q.Agents.Count > 0 && !q.Agents.Contains(r.AgentId)) return false;
if (q.Models.Count > 0 && !q.Models.Contains(r.Model)) return false;
if (q.Sources.Count > 0 && !q.Sources.Contains(r.Source)) return false;
if (q.Statuses.Count > 0 && !q.Statuses.Contains(r.Status)) return false;
if (q.MinCostUsd is { } min && r.CostUsd < min) return false;
if (!string.IsNullOrWhiteSpace(q.Search))
{
var s = q.Search.Trim();
bool Has(string? v) => v?.Contains(s, StringComparison.OrdinalIgnoreCase) == true;
if (!(Has(r.RunId) || Has(r.AgentId) || Has(r.Model) || Has(r.TaskId) || Has(row.TaskTitle) || Has(r.ResultRef)))
return false;
}
return true;
}
private static IEnumerable Sort(IEnumerable rows, string sort, bool descending)
{
Func key = (sort ?? "time").ToLowerInvariant() switch
{
"agent" => r => r.Receipt.AgentId,
"model" => r => r.Receipt.Model,
"source" => r => r.Receipt.Source,
"status" => r => r.Receipt.Status,
"steps" => r => r.Receipt.StepCount,
"prompt" => r => r.Receipt.PromptTokens,
"completion" => r => r.Receipt.CompletionTokens,
"tokens" => r => r.TotalTokens,
"cost" => r => r.Receipt.CostUsd,
"duration" => r => r.Receipt.DurationMs,
_ => r => r.Receipt.OccurredAt
};
// Newest first as the tie-breaker, whatever the primary order.
return descending
? rows.OrderByDescending(key).ThenByDescending(r => r.Receipt.OccurredAt)
: rows.OrderBy(key).ThenByDescending(r => r.Receipt.OccurredAt);
}
internal static DateTime BucketStart(DateTime occurredUtc, string bucket, TimeSpan utcOffset)
{
var local = DateTime.SpecifyKind(occurredUtc, DateTimeKind.Utc) + utcOffset;
var start = bucket == "day"
? local.Date
: new DateTime(local.Year, local.Month, local.Day, local.Hour, 0, 0);
return DateTime.SpecifyKind(start - utcOffset, DateTimeKind.Utc);
}
private static string GroupKey(RunReceipt r, string groupBy) => groupBy switch
{
"model" => string.IsNullOrWhiteSpace(r.Model) ? "(unknown)" : r.Model,
"source" => r.Source,
// #717: A special budget is visible apart — it is outside the daily limits.
"budget" => string.IsNullOrWhiteSpace(r.BudgetPot) ? "daily" : "special:" + r.BudgetPot,
_ => r.AgentId
};
private static DateTime Truncate(DateTime value, TimeSpan unit)
=> new(value.Ticks - value.Ticks % unit.Ticks, value.Kind);
}