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); }