Files
ClawdDotNet/src/ClawdDotNet.Web/Endpoints/AnalyticsEndpoints.cs
T
RichardandClaude Opus 5.5 da34b215f7 Kosten je Einheit aufrechnen und Abweichungen melden (#699)
- 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>
2026-10-01 09:09:42 +02:00

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