- IUsageRepository.GetDailyByAgentAsync: Tagessummen je Agent, Abo-Tokens getrennt - UnitCosts: je Einheit samt Untereinheiten heute/7/30 Tage, Monat, Vormonat, Durchschnittstag (28 Tage, ab 7 Tagen Verlauf), Maximum = Summe der Agentengrenzen - Meldungen: Abweichung vom eigenen Verlauf, Naehe zur Grenzsumme, Monatsschwellen der Instanz; CostWatch alle 15 min, je Einheit/Tag einmal (Tabelle CostAlerts), als Aufgabe an den Menschen; schaltet nichts ab - API /usage/units, /usage/cost-alerts; Zeitreihe gruppiert nach Einheit - WebUI: Karte "Kosten je Einheit" mit Meldungen in der Verbrauchsansicht; i18n en/de - Einstellungen instance.json costWatch - Tests: UnitCostsTests (8) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
313 lines
15 KiB
C#
313 lines
15 KiB
C#
using ClawdDotNet.App;
|
|
using ClawdDotNet.App.Services;
|
|
using ClawdDotNet.Core.Analytics;
|
|
using ClawdDotNet.Core.Transcripts;
|
|
using ClawdDotNet.Web.Contracts;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Routing;
|
|
|
|
namespace ClawdDotNet.Web.Endpoints;
|
|
|
|
/// <summary>
|
|
/// Usage analysis and prompt history (#660).
|
|
///
|
|
/// <list type="bullet">
|
|
/// <item><c>GET /api/usage/runs</c> - filterable, sortable, paged list of runs with totals over the whole filter</item>
|
|
/// <item><c>GET /api/usage/series</c> - stacked buckets per hour or day for the timeline</item>
|
|
/// <item><c>GET /api/usage/events</c> - markers for the timeline (budget denials, model changes)</item>
|
|
/// <item><c>GET /api/usage/facets</c> - the values the filter dropdowns offer</item>
|
|
/// <item><c>GET /api/runs/{runId}</c> - one run: receipt, tool calls, approvals, transcript</item>
|
|
/// <item><c>GET /api/prompt-history</c> - recorded transcripts with full-text search</item>
|
|
/// <item><c>GET|PUT /api/prompt-history/settings</c> - retention per instance and per agent</item>
|
|
/// </list>
|
|
///
|
|
/// Filters use the same query parameters everywhere: <c>from</c>, <c>to</c> (ISO 8601),
|
|
/// <c>agent</c>, <c>model</c>, <c>source</c>, <c>status</c> (comma separated),
|
|
/// <c>minCost</c>, <c>q</c>. Without <c>from</c> the last 7 days are shown.
|
|
/// </summary>
|
|
public static class AnalyticsEndpoints
|
|
{
|
|
private static readonly TimeSpan DefaultRange = TimeSpan.FromDays(7);
|
|
|
|
public static void MapAnalytics(this RouteGroupBuilder api)
|
|
{
|
|
api.MapGet("/usage/runs", async (AppHost host, HttpRequest request, CancellationToken ct) =>
|
|
{
|
|
if (host.Insights is not { } insights)
|
|
return ApiEndpoints.Unavailable("No usage data: agents are not active.");
|
|
|
|
var page = await insights.Analytics.QueryRunsAsync(ParseQuery(request), ct);
|
|
return Results.Ok(new RunPageDto(
|
|
page.Items.Select(ToDto).ToList(),
|
|
page.Total,
|
|
new RunTotalsDto(page.Totals.Runs, page.Totals.PromptTokens, page.Totals.CompletionTokens,
|
|
page.Totals.CachedTokens, page.Totals.CostUsd, page.Totals.RunsWithUnknownCost, page.Totals.NotRun)));
|
|
});
|
|
|
|
api.MapGet("/usage/series", async (
|
|
AppHost host, HttpRequest request, CancellationToken ct,
|
|
string bucket = "hour", string groupBy = "agent", int tzOffset = 0) =>
|
|
{
|
|
if (host.Insights is not { } insights)
|
|
return ApiEndpoints.Unavailable("No usage data: agents are not active.");
|
|
|
|
var points = await insights.Analytics.SeriesAsync(
|
|
ParseQuery(request),
|
|
bucket == "day" ? "day" : "hour",
|
|
groupBy is "model" or "source" or "budget" ? groupBy : "agent",
|
|
TimeSpan.FromMinutes(Math.Clamp(tzOffset, -14 * 60, 14 * 60)),
|
|
ct);
|
|
|
|
// #699: "What does marketing cost me?" — the agents' series, added up by their unit as it is now.
|
|
if (groupBy == "unit")
|
|
{
|
|
var org = host.Organisation.Current();
|
|
return Results.Ok(points
|
|
.GroupBy(p => (p.BucketStart, Unit: org.UnitOf(p.Group) is { } u ? org.PathOf(u.Id) : "—"))
|
|
.Select(g => new SeriesPointDto(g.Key.BucketStart, g.Key.Unit, g.Sum(p => p.Runs), g.Sum(p => p.Tokens), g.Sum(p => p.CostUsd)))
|
|
.OrderBy(p => p.BucketStart)
|
|
.ToList());
|
|
}
|
|
return Results.Ok(points.Select(p => new SeriesPointDto(p.BucketStart, p.Group, p.Runs, p.Tokens, p.CostUsd)).ToList());
|
|
});
|
|
|
|
// #699: Costs per unit — itself and everything below it — with the alerts raised on them.
|
|
api.MapGet("/usage/units", async (AppHost host, CancellationToken ct) =>
|
|
{
|
|
if (host.CostWatch is not { } watch)
|
|
return ApiEndpoints.Unavailable("No usage data: agents are not active.");
|
|
var lines = await watch.ReportAsync(ct);
|
|
return Results.Ok(lines.Select(l => new UnitCostDto(
|
|
l.UnitId, l.Name, l.Path, l.Depth, l.Agents, l.Today, l.Last7Days, l.Last30Days, l.ThisMonth, l.PreviousMonth,
|
|
l.MaxPerDay, l.AverageDay, l.ShareOfMaxToday, l.SubscriptionTokensToday, l.SubscriptionTokens30Days)).ToList());
|
|
});
|
|
|
|
api.MapGet("/usage/cost-alerts", async (AppHost host, CancellationToken ct, int days = 30) =>
|
|
{
|
|
if (host.CostWatch is not { } watch)
|
|
return Results.Ok(Array.Empty<CostAlertDto>());
|
|
return Results.Ok((await watch.RecentAsync(days, ct))
|
|
.Select(a => new CostAlertDto(a.Period, a.Scope, a.Kind.ToString(), a.Message, a.RaisedAt)).ToList());
|
|
});
|
|
|
|
api.MapGet("/usage/events", async (AppHost host, HttpRequest request, CancellationToken ct) =>
|
|
{
|
|
if (host.Insights is not { } insights)
|
|
return ApiEndpoints.Unavailable("No usage data: agents are not active.");
|
|
|
|
var (from, to) = ParseRange(request);
|
|
var events = await insights.Analytics.EventsAsync(from, to, ct);
|
|
return Results.Ok(events.Select(e => new UsageEventDto(e.At, e.Kind, e.AgentId, e.Text)).ToList());
|
|
});
|
|
|
|
api.MapGet("/usage/facets", async (AppHost host, HttpRequest request, CancellationToken ct) =>
|
|
{
|
|
if (host.Insights is not { } insights)
|
|
return ApiEndpoints.Unavailable("No usage data: agents are not active.");
|
|
|
|
var (from, to) = ParseRange(request);
|
|
var f = await insights.Analytics.FacetsAsync(from, to, ct);
|
|
return Results.Ok(new UsageFacetsDto(f.Agents, f.Models, f.Sources, f.Statuses));
|
|
});
|
|
|
|
api.MapGet("/runs/{runId}", async (AppHost host, string runId, CancellationToken ct) =>
|
|
{
|
|
if (host.Insights is not { } insights)
|
|
return ApiEndpoints.Unavailable("No usage data: agents are not active.");
|
|
|
|
return await insights.GetRunAsync(runId, ct) is { } detail
|
|
? Results.Ok(ToDto(detail))
|
|
: Results.NotFound(new OutcomeDto(false, $"No run with id '{runId}'."));
|
|
});
|
|
|
|
api.MapGet("/prompt-history", async (
|
|
AppHost host, HttpRequest request, CancellationToken ct, int offset = 0, int limit = 50) =>
|
|
{
|
|
if (host.Insights?.Transcripts is not { } store)
|
|
return ApiEndpoints.Unavailable("Prompt history is not available in this instance.");
|
|
|
|
var (from, to) = ParseRange(request);
|
|
var (items, total) = await store.ListAsync(new TranscriptQuery
|
|
{
|
|
AgentId = request.Query["agent"].FirstOrDefault(),
|
|
From = from,
|
|
To = to,
|
|
Search = request.Query["q"].FirstOrDefault(),
|
|
Offset = offset,
|
|
Limit = limit
|
|
}, ct);
|
|
|
|
return Results.Ok(new TranscriptPageDto(items.Select(i => new TranscriptSummaryDto(
|
|
i.RunId, i.AgentId, i.Model, i.Source, i.TaskId, i.StartedAt, i.CompletedAt,
|
|
i.Status, i.Prompt, i.Response, i.SizeChars, i.Truncated)).ToList(), total));
|
|
});
|
|
|
|
api.MapGet("/prompt-history/settings", (AppHost host) => Results.Ok(SettingsDto(host)));
|
|
|
|
api.MapPut("/prompt-history/settings", (AppHost host, PromptHistorySettingsInputDto input) =>
|
|
{
|
|
var messages = new List<string>();
|
|
|
|
if (input.DefaultRetentionDays is { } days)
|
|
{
|
|
var result = host.AgentAdmin.SetDefaultPromptHistoryRetention(days);
|
|
if (!result.Ok) return result.ToResponse();
|
|
messages.Add(result.Message);
|
|
}
|
|
|
|
foreach (var agent in input.Agents ?? [])
|
|
{
|
|
var result = host.AgentAdmin.SetPromptHistoryRetention(agent.AgentId, agent.RetentionDays);
|
|
if (!result.Ok) return result.ToResponse();
|
|
messages.Add(result.Message);
|
|
}
|
|
|
|
return Results.Ok(new OutcomeDto(true, messages.Count == 0 ? "Nothing changed." : string.Join(" ", messages)));
|
|
});
|
|
}
|
|
|
|
// ─── Parsing ───
|
|
|
|
internal static (DateTime From, DateTime To) ParseRange(HttpRequest request)
|
|
{
|
|
var to = ParseDate(request.Query["to"].FirstOrDefault()) ?? DateTime.UtcNow.AddMinutes(1);
|
|
var from = ParseDate(request.Query["from"].FirstOrDefault()) ?? to - DefaultRange;
|
|
return (from, to);
|
|
}
|
|
|
|
internal static RunQuery ParseQuery(HttpRequest request)
|
|
{
|
|
var (from, to) = ParseRange(request);
|
|
var query = request.Query;
|
|
|
|
static IReadOnlyCollection<string> List(string? value)
|
|
=> string.IsNullOrWhiteSpace(value)
|
|
? []
|
|
: value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToHashSet();
|
|
|
|
return new RunQuery
|
|
{
|
|
FromUtc = from,
|
|
ToUtc = to,
|
|
Agents = List(query["agent"].FirstOrDefault()),
|
|
Models = List(query["model"].FirstOrDefault()),
|
|
Sources = List(query["source"].FirstOrDefault()),
|
|
Statuses = List(query["status"].FirstOrDefault()),
|
|
MinCostUsd = decimal.TryParse(query["minCost"].FirstOrDefault(), System.Globalization.NumberStyles.Any,
|
|
System.Globalization.CultureInfo.InvariantCulture, out var min) ? min : null,
|
|
Search = query["q"].FirstOrDefault(),
|
|
Sort = query["sort"].FirstOrDefault() ?? "time",
|
|
Descending = !string.Equals(query["dir"].FirstOrDefault(), "asc", StringComparison.OrdinalIgnoreCase),
|
|
Offset = int.TryParse(query["offset"].FirstOrDefault(), out var o) ? o : 0,
|
|
Limit = int.TryParse(query["limit"].FirstOrDefault(), out var l) ? l : 50
|
|
};
|
|
}
|
|
|
|
private static DateTime? ParseDate(string? value)
|
|
=> DateTime.TryParse(value, System.Globalization.CultureInfo.InvariantCulture,
|
|
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var d)
|
|
? DateTime.SpecifyKind(d, DateTimeKind.Utc)
|
|
: null;
|
|
|
|
// ─── Mapping ───
|
|
|
|
private static RunRowDto ToDto(RunRow row) => Row(row.Receipt, row.TaskTitle);
|
|
|
|
private static RunRowDto Row(Core.Audit.RunReceipt r, string? taskTitle)
|
|
=> new(r.RunId, r.AgentId, r.Model, r.Source, r.TaskId, taskTitle, r.Status,
|
|
r.StepCount, r.PromptTokens, r.CompletionTokens, r.CachedTokens, r.CostUsd, r.CostIsKnown,
|
|
r.DurationMs, r.ResultRef, r.OccurredAt, r.CallerRunId, r.BudgetPot);
|
|
|
|
private static RunDetailDto ToDto(RunDetail d) => new(
|
|
d.RunId,
|
|
d.Receipt is { } r ? Row(r, d.TaskTitle) : null,
|
|
d.ToolCalls.Select(c => new RunToolCallDto(c.OccurredAt, c.Tool, c.Status.ToString(), c.Arguments, c.Summary, c.DurationMs, c.Source)).ToList(),
|
|
d.Approvals.Select(c => c.ToDto()).ToList(),
|
|
d.Transcript is { } t
|
|
? new TranscriptDto(t.RunId, t.AgentId, t.Model, t.Source, t.TaskId, t.StartedAt, t.CompletedAt, t.Status,
|
|
t.Entries, t.SizeChars, t.Truncated)
|
|
: null,
|
|
d.SystemPrompt,
|
|
d.TranscriptState,
|
|
(d.CalledRuns ?? []).Select(c => Row(c, null)).ToList());
|
|
|
|
private static PromptHistorySettingsDto SettingsDto(AppHost host)
|
|
{
|
|
var instanceDays = PromptHistorySettings.Normalize(host.Instance.PromptHistory.RetentionDays);
|
|
return new PromptHistorySettingsDto(
|
|
instanceDays,
|
|
host.Instance.Agents.Select(a => new AgentRetentionDto(
|
|
a.AgentId,
|
|
string.IsNullOrWhiteSpace(a.DisplayName) ? a.AgentId : a.DisplayName,
|
|
a.PromptHistory.RetentionDays,
|
|
PromptHistorySettings.Resolve(a.PromptHistory.RetentionDays, instanceDays))).ToList());
|
|
}
|
|
}
|
|
|
|
// ─── DTOs (#660) ───
|
|
|
|
public sealed record RunRowDto(
|
|
string RunId, string AgentId, string Model, string Source, string? TaskId, string? TaskTitle,
|
|
string Status, int StepCount, int PromptTokens, int CompletionTokens, int CachedTokens,
|
|
decimal CostUsd, bool CostIsKnown, long DurationMs, string Result, DateTime OccurredAt,
|
|
// #697: the run this one was delegated from.
|
|
string? CallerRunId = null,
|
|
// #717: the task whose special budget the run drew on.
|
|
string? BudgetPot = null);
|
|
|
|
public sealed record RunTotalsDto(
|
|
int Runs, long PromptTokens, long CompletionTokens, long CachedTokens,
|
|
decimal CostUsd, int RunsWithUnknownCost, int NotRun);
|
|
|
|
public sealed record RunPageDto(IReadOnlyList<RunRowDto> Items, int Total, RunTotalsDto Totals);
|
|
|
|
public sealed record SeriesPointDto(DateTime BucketStart, string Group, int Runs, long Tokens, decimal CostUsd);
|
|
|
|
public sealed record UsageEventDto(DateTime At, string Kind, string AgentId, string Text);
|
|
|
|
public sealed record UsageFacetsDto(
|
|
IReadOnlyList<string> Agents, IReadOnlyList<string> Models,
|
|
IReadOnlyList<string> Sources, IReadOnlyList<string> Statuses);
|
|
|
|
public sealed record RunToolCallDto(
|
|
DateTime At, string Tool, string Status, string Arguments, string Summary, long DurationMs, string Source);
|
|
|
|
/// <summary>
|
|
/// Entries go out as recorded. They contain model output and tool results - untrusted
|
|
/// text; the UI inserts them as text, never as HTML.
|
|
/// </summary>
|
|
public sealed record TranscriptDto(
|
|
string RunId, string AgentId, string Model, string Source, string? TaskId,
|
|
DateTime StartedAt, DateTime? CompletedAt, string Status,
|
|
IReadOnlyList<TranscriptEntry> Entries, int SizeChars, bool Truncated);
|
|
|
|
/// <param name="TranscriptState">recorded, disabled (retention "never"), missing, unavailable.</param>
|
|
public sealed record RunDetailDto(
|
|
string RunId,
|
|
RunRowDto? Receipt,
|
|
IReadOnlyList<RunToolCallDto> ToolCalls,
|
|
IReadOnlyList<StagedCallDto> Approvals,
|
|
TranscriptDto? Transcript,
|
|
string? SystemPrompt,
|
|
string TranscriptState,
|
|
// #697: the runs this one delegated to.
|
|
IReadOnlyList<RunRowDto>? CalledRuns = null);
|
|
|
|
public sealed record TranscriptSummaryDto(
|
|
string RunId, string AgentId, string Model, string Source, string? TaskId,
|
|
DateTime StartedAt, DateTime? CompletedAt, string Status,
|
|
string Prompt, string Response, int SizeChars, bool Truncated);
|
|
|
|
public sealed record TranscriptPageDto(IReadOnlyList<TranscriptSummaryDto> Items, int Total);
|
|
|
|
/// <param name="RetentionDays">Own setting of the agent; <c>null</c> = instance default.</param>
|
|
/// <param name="EffectiveRetentionDays">What applies: 0 = never, -1 = forever.</param>
|
|
public sealed record AgentRetentionDto(string AgentId, string DisplayName, int? RetentionDays, int EffectiveRetentionDays);
|
|
|
|
public sealed record PromptHistorySettingsDto(int DefaultRetentionDays, IReadOnlyList<AgentRetentionDto> Agents);
|
|
|
|
public sealed record AgentRetentionInputDto(string AgentId, int? RetentionDays);
|
|
|
|
public sealed record PromptHistorySettingsInputDto(int? DefaultRetentionDays, IReadOnlyList<AgentRetentionInputDto>? Agents);
|