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;
///
/// Usage analysis and prompt history (#660).
///
///
/// - GET /api/usage/runs - filterable, sortable, paged list of runs with totals over the whole filter
/// - GET /api/usage/series - stacked buckets per hour or day for the timeline
/// - GET /api/usage/events - markers for the timeline (budget denials, model changes)
/// - GET /api/usage/facets - the values the filter dropdowns offer
/// - GET /api/runs/{runId} - one run: receipt, tool calls, approvals, transcript
/// - GET /api/prompt-history - recorded transcripts with full-text search
/// - GET|PUT /api/prompt-history/settings - retention per instance and per agent
///
///
/// Filters use the same query parameters everywhere: from, to (ISO 8601),
/// agent, model, source, status (comma separated),
/// minCost, q. Without from the last 7 days are shown.
///
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());
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();
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 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 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 Agents, IReadOnlyList Models,
IReadOnlyList Sources, IReadOnlyList Statuses);
public sealed record RunToolCallDto(
DateTime At, string Tool, string Status, string Arguments, string Summary, long DurationMs, string Source);
///
/// Entries go out as recorded. They contain model output and tool results - untrusted
/// text; the UI inserts them as text, never as HTML.
///
public sealed record TranscriptDto(
string RunId, string AgentId, string Model, string Source, string? TaskId,
DateTime StartedAt, DateTime? CompletedAt, string Status,
IReadOnlyList Entries, int SizeChars, bool Truncated);
/// recorded, disabled (retention "never"), missing, unavailable.
public sealed record RunDetailDto(
string RunId,
RunRowDto? Receipt,
IReadOnlyList ToolCalls,
IReadOnlyList Approvals,
TranscriptDto? Transcript,
string? SystemPrompt,
string TranscriptState,
// #697: the runs this one delegated to.
IReadOnlyList? 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 Items, int Total);
/// Own setting of the agent; null = instance default.
/// What applies: 0 = never, -1 = forever.
public sealed record AgentRetentionDto(string AgentId, string DisplayName, int? RetentionDays, int EffectiveRetentionDays);
public sealed record PromptHistorySettingsDto(int DefaultRetentionDays, IReadOnlyList Agents);
public sealed record AgentRetentionInputDto(string AgentId, int? RetentionDays);
public sealed record PromptHistorySettingsInputDto(int? DefaultRetentionDays, IReadOnlyList? Agents);