From f283c55398607762ba073053327185342156e7ee Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Wed, 5 Aug 2026 22:11:56 +0800 Subject: [PATCH] refactor: reorder members and add XML docs across Core/Cli Alphabetizes usings and sorts members within touched types, adds XML doc comments, and fixes three behavioral regressions surfaced by review: SessionFileCache.GetRows now re-validates file mtime before returning cached rows, StatsCacheBuilder prunes stale session-file cache entries again, and DataService's stats-cache expiration uses UTC instead of local time to avoid DST drift. --- .../Commands/ExportCommand.cs | 20 +- .../Commands/HoursCommand.cs | 8 + .../Commands/ModelsCommand.cs | 10 +- .../Commands/MonthCommand.cs | 10 +- .../Commands/SessionsCommand.cs | 10 +- .../Commands/TodayCommand.cs | 12 +- .../Commands/WatchCommand.cs | 12 +- .../Commands/WeekCommand.cs | 10 +- src/AIUsageMonitor.Cli/Program.cs | 2 +- .../Rendering/ProgressReporter.cs | 19 +- .../Rendering/SpectreRenderer.cs | 221 ++++++++++++------ .../Analytics/CostCalculator.cs | 17 ++ .../Analytics/UsageAnalyzer.cs | 87 +++++-- .../Models/AnalyticsDtos.cs | 88 +++++++ src/AIUsageMonitor.Core/Models/JsonContext.cs | 5 +- .../Providers/Claude/ClaudeDataLocator.cs | 78 ++++--- .../Providers/Claude/ClaudeUsageProvider.cs | 30 +++ .../Providers/Claude/HistoryParser.cs | 12 +- .../Providers/Claude/HourlyActivityBuilder.cs | 82 ++++--- .../Claude/Models/DateOnlyJsonConverter.cs | 47 ++-- .../Providers/Claude/Models/HistoryEntry.cs | 23 +- .../Providers/Claude/Models/SessionMessage.cs | 105 ++++++--- .../Providers/Claude/Models/StatsCache.cs | 99 ++++++++ .../Providers/Claude/RecentActivityBuilder.cs | 61 ++--- .../Providers/Claude/SessionFileCache.cs | 69 +++++- .../Claude/SessionMessageAnalysis.cs | 12 +- .../Providers/Claude/SessionParser.cs | 20 +- .../Providers/Claude/StatsCacheBuilder.cs | 9 +- .../Providers/Claude/StatsCacheParser.cs | 11 +- .../Providers/IUsageProvider.cs | 22 ++ .../Services/DataService.cs | 148 ++++++++++-- .../Services/ServiceCollectionExtensions.cs | 11 +- 32 files changed, 1084 insertions(+), 286 deletions(-) diff --git a/src/AIUsageMonitor.Cli/Commands/ExportCommand.cs b/src/AIUsageMonitor.Cli/Commands/ExportCommand.cs index 2cdba7f..fa26aa6 100644 --- a/src/AIUsageMonitor.Cli/Commands/ExportCommand.cs +++ b/src/AIUsageMonitor.Cli/Commands/ExportCommand.cs @@ -1,15 +1,23 @@ -using System.CommandLine; -using System.Text.Json; using AIUsageMonitor.Cli.Rendering; using AIUsageMonitor.Core.Models; using AIUsageMonitor.Core.Providers.Claude.Models; using AIUsageMonitor.Core.Services; using Spectre.Console; +using System.CommandLine; +using System.Text.Json; namespace AIUsageMonitor.Cli.Commands; +/// +/// Represents the "export" command for exporting usage data in JSON or CSV format. This command retrieves usage statistics and model distribution data from the provided and outputs it to a specified file or standard output. +/// public static class ExportCommand { + /// + /// Creates a new instance of the "export" command with the specified . The command supports options for specifying the output format (JSON or CSV) and the output file path. If no output file is specified, the data will be printed to standard output. + /// + /// The data service used to retrieve usage statistics and model distribution data. + /// A configured instance for exporting usage data. public static Command Create(DataService dataService) { var command = new Command("export", "Export usage data"); @@ -58,6 +66,12 @@ public static Command Create(DataService dataService) return command; } + /// + /// Exports the usage data and model distribution to a CSV formatted string. The CSV includes daily activity statistics and model distribution details, with appropriate headers for each section. + /// + /// The cached usage statistics to export. + /// The model distribution data to export. + /// A CSV formatted string representing the usage data and model distribution. private static string ExportCsv( StatsCache cache, List models) @@ -78,4 +92,4 @@ private static string ExportCsv( return sb.ToString(); } -} +} \ No newline at end of file diff --git a/src/AIUsageMonitor.Cli/Commands/HoursCommand.cs b/src/AIUsageMonitor.Cli/Commands/HoursCommand.cs index 41a358f..707f3a3 100644 --- a/src/AIUsageMonitor.Cli/Commands/HoursCommand.cs +++ b/src/AIUsageMonitor.Cli/Commands/HoursCommand.cs @@ -4,8 +4,16 @@ namespace AIUsageMonitor.Cli.Commands; +/// +/// Represents the "hours" command, which shows the hourly activity distribution of AI usage. This command retrieves hourly activity data from the provided and renders it using the . +/// public static class HoursCommand { + /// + /// Creates a new instance of the "hours" command with the specified . The command retrieves hourly activity data and renders it using the . + /// + /// The data service used to retrieve hourly activity data. + /// A configured instance for showing hourly activity distribution. public static Command Create(DataService dataService) { var command = new Command("hours", "Show hourly activity distribution"); diff --git a/src/AIUsageMonitor.Cli/Commands/ModelsCommand.cs b/src/AIUsageMonitor.Cli/Commands/ModelsCommand.cs index b9f59a3..5c7ebf9 100644 --- a/src/AIUsageMonitor.Cli/Commands/ModelsCommand.cs +++ b/src/AIUsageMonitor.Cli/Commands/ModelsCommand.cs @@ -1,11 +1,19 @@ -using System.CommandLine; using AIUsageMonitor.Cli.Rendering; using AIUsageMonitor.Core.Services; +using System.CommandLine; namespace AIUsageMonitor.Cli.Commands; +/// +/// Represents the command for displaying model usage distribution in the AI Usage Monitor CLI application. +/// public static class ModelsCommand { + /// + /// Creates a new instance of the "models" command, which shows the model usage distribution. + /// + /// The data service used to retrieve model usage data. + /// A configured instance for showing model usage distribution. public static Command Create(DataService dataService) { var command = new Command("models", "Show model usage distribution"); diff --git a/src/AIUsageMonitor.Cli/Commands/MonthCommand.cs b/src/AIUsageMonitor.Cli/Commands/MonthCommand.cs index 585da61..ca489c7 100644 --- a/src/AIUsageMonitor.Cli/Commands/MonthCommand.cs +++ b/src/AIUsageMonitor.Cli/Commands/MonthCommand.cs @@ -1,11 +1,19 @@ -using System.CommandLine; using AIUsageMonitor.Cli.Rendering; using AIUsageMonitor.Core.Services; +using System.CommandLine; namespace AIUsageMonitor.Cli.Commands; +/// +/// Represents the command to show the last 30 days summary of AI usage. +/// public static class MonthCommand { + /// + /// Creates a new instance of the MonthCommand. + /// + /// The data service used to retrieve AI usage data. + /// A configured instance for showing the last 30 days summary of AI usage. public static Command Create(DataService dataService) { var command = new Command("month", "Show last 30 days summary"); diff --git a/src/AIUsageMonitor.Cli/Commands/SessionsCommand.cs b/src/AIUsageMonitor.Cli/Commands/SessionsCommand.cs index e46d9dd..1eeb5fd 100644 --- a/src/AIUsageMonitor.Cli/Commands/SessionsCommand.cs +++ b/src/AIUsageMonitor.Cli/Commands/SessionsCommand.cs @@ -1,11 +1,19 @@ -using System.CommandLine; using AIUsageMonitor.Cli.Rendering; using AIUsageMonitor.Core.Services; +using System.CommandLine; namespace AIUsageMonitor.Cli.Commands; +/// +/// Represents the command for displaying session statistics in the AI Usage Monitor CLI application. +/// public static class SessionsCommand { + /// + /// Creates a new instance of the "sessions" command, which displays session statistics. + /// + /// The data service used to retrieve session statistics. + /// A configured instance for showing session statistics. public static Command Create(DataService dataService) { var command = new Command("sessions", "Show session statistics"); diff --git a/src/AIUsageMonitor.Cli/Commands/TodayCommand.cs b/src/AIUsageMonitor.Cli/Commands/TodayCommand.cs index ed1b026..e14c7d0 100644 --- a/src/AIUsageMonitor.Cli/Commands/TodayCommand.cs +++ b/src/AIUsageMonitor.Cli/Commands/TodayCommand.cs @@ -1,12 +1,20 @@ -using System.CommandLine; using AIUsageMonitor.Cli.Rendering; using AIUsageMonitor.Core.Services; using Spectre.Console; +using System.CommandLine; namespace AIUsageMonitor.Cli.Commands; +/// +/// Represents the "today" command in the CLI application, which shows today's usage summary. +/// public static class TodayCommand { + /// + /// Creates a new instance of the "today" command with the specified data service. + /// + /// The data service used to retrieve today's usage summary and recent activity. + /// A configured instance for showing today's usage summary. public static Command Create(DataService dataService) { var command = new Command("today", "Show today's usage summary"); @@ -39,4 +47,4 @@ public static Command Create(DataService dataService) }); return command; } -} +} \ No newline at end of file diff --git a/src/AIUsageMonitor.Cli/Commands/WatchCommand.cs b/src/AIUsageMonitor.Cli/Commands/WatchCommand.cs index 6141309..a5d2719 100644 --- a/src/AIUsageMonitor.Cli/Commands/WatchCommand.cs +++ b/src/AIUsageMonitor.Cli/Commands/WatchCommand.cs @@ -1,13 +1,21 @@ -using System.CommandLine; using AIUsageMonitor.Cli.Rendering; using AIUsageMonitor.Core.Services; using Spectre.Console; using Spectre.Console.Rendering; +using System.CommandLine; namespace AIUsageMonitor.Cli.Commands; +/// +/// Represents the "watch" command, which continuously refreshes a usage view at a fixed interval. +/// public static class WatchCommand { + /// + /// Creates the "watch" command with its options and action. + /// + /// The data service used to retrieve usage data for the specified view. + /// A configured instance for continuously refreshing a usage view. public static Command Create(DataService dataService) { var command = new Command("watch", "Continuously refresh a usage view at a fixed interval"); @@ -79,4 +87,4 @@ await AnsiConsole.Live(initial) return command; } -} +} \ No newline at end of file diff --git a/src/AIUsageMonitor.Cli/Commands/WeekCommand.cs b/src/AIUsageMonitor.Cli/Commands/WeekCommand.cs index 9f0989b..040cf1e 100644 --- a/src/AIUsageMonitor.Cli/Commands/WeekCommand.cs +++ b/src/AIUsageMonitor.Cli/Commands/WeekCommand.cs @@ -1,11 +1,19 @@ -using System.CommandLine; using AIUsageMonitor.Cli.Rendering; using AIUsageMonitor.Core.Services; +using System.CommandLine; namespace AIUsageMonitor.Cli.Commands; +/// +/// Represents the "week" command that shows a summary of usage data for the last 7 days. +/// public static class WeekCommand { + /// + /// Creates a new instance of the "week" command. + /// + /// The data service used to retrieve usage data for the last 7 days. + /// A configured instance for showing the last 7 days summary. public static Command Create(DataService dataService) { var command = new Command("week", "Show last 7 days summary"); diff --git a/src/AIUsageMonitor.Cli/Program.cs b/src/AIUsageMonitor.Cli/Program.cs index f824565..44cb2b5 100644 --- a/src/AIUsageMonitor.Cli/Program.cs +++ b/src/AIUsageMonitor.Cli/Program.cs @@ -32,4 +32,4 @@ { AnsiConsole.MarkupLine($"[red]Fatal error: {ex.Message}[/]"); return 1; -} +} \ No newline at end of file diff --git a/src/AIUsageMonitor.Cli/Rendering/ProgressReporter.cs b/src/AIUsageMonitor.Cli/Rendering/ProgressReporter.cs index 26cac66..b63531f 100644 --- a/src/AIUsageMonitor.Cli/Rendering/ProgressReporter.cs +++ b/src/AIUsageMonitor.Cli/Rendering/ProgressReporter.cs @@ -2,8 +2,18 @@ namespace AIUsageMonitor.Cli.Rendering; +/// +/// Provides a utility for running a task with a progress bar in the console using Spectre.Console. +/// public static class ProgressReporter { + /// + /// Runs a task with a progress bar in the console. + /// + /// The type of the result produced by the task. + /// A description of the task to be displayed in the progress bar. + /// A function that performs the task and reports progress. + /// The result produced by the task. public static T Run(string description, Func, T> body) { var result = default(T)!; @@ -17,11 +27,12 @@ public static T Run(string description, Func, T> body) return result; } - // System.Progress marshals callbacks through the captured SynchronizationContext (or the - // thread pool if there is none), which would race with Spectre's render loop in a console app. - // Report synchronously on the calling thread instead so the progress bar updates in step. + /// + /// A private implementation of IProgress<int> that reports progress synchronously to a provided action. + /// + /// The action to be invoked when progress is reported. private sealed class SynchronousProgress(Action onReport) : IProgress { public void Report(int value) => onReport(value); } -} +} \ No newline at end of file diff --git a/src/AIUsageMonitor.Cli/Rendering/SpectreRenderer.cs b/src/AIUsageMonitor.Cli/Rendering/SpectreRenderer.cs index 0209b81..b9386de 100644 --- a/src/AIUsageMonitor.Cli/Rendering/SpectreRenderer.cs +++ b/src/AIUsageMonitor.Cli/Rendering/SpectreRenderer.cs @@ -4,10 +4,18 @@ namespace AIUsageMonitor.Cli.Rendering; +/// +/// Builds and renders Spectre.Console renderables (tables, charts, and rows) for usage statistics. +/// public static class SpectreRenderer { - public static void RenderDailySummary(DailySummary summary) => AnsiConsole.Write(BuildDailySummary(summary)); + private static readonly Color[] HourlyBarColors = [Color.Blue, Color.SkyBlue1, Color.Green, Color.Yellow3]; + /// + /// Builds a renderable summary for a single day, including key stats and a token distribution chart by model. + /// + /// The daily summary data to render. + /// An representing the daily summary. public static IRenderable BuildDailySummary(DailySummary summary) { var table = BuildStatsTable( @@ -34,35 +42,45 @@ public static IRenderable BuildDailySummary(DailySummary summary) return new Rows(table, new Rule().RuleStyle("grey"), chart); } - public static void RenderPeriodSummary(PeriodSummary summary) => AnsiConsole.Write(BuildPeriodSummary(summary)); - - public static IRenderable BuildPeriodSummary(PeriodSummary summary) + /// + /// Builds a bar chart of tokens consumed per hour. + /// + /// The hourly activity data to render. + /// An bar chart of tokens by hour. + public static IRenderable BuildHourlyActivity(List hours) { - var table = BuildStatsTable( - $"{summary.From:yyyy-MM-dd} ~ {summary.To:yyyy-MM-dd}", - ("Messages", $"{summary.TotalMessages:N0}"), - ("Sessions", $"{summary.TotalSessions:N0}"), - ("Tool Calls", $"{summary.TotalToolCalls:N0}"), - ("Total Tokens", FormatTokens(summary.TotalTokens)), - ("Est. Cost", $"{summary.EstimatedCost:C2}")); - - if (summary.DailyBreakdown.Count == 0) + var chart = new BarChart().Label("[bold]Tokens by Hour[/]").Width(80).UseValueFormatter(v => FormatTokens((long)v)); + var colorIndex = 0; + foreach (var h in hours) { - return table; + chart.AddItem($"{h.Hour:D2}:00", h.TotalTokens, HourlyBarColors[colorIndex % HourlyBarColors.Length]); + colorIndex++; } + return chart; + } - var chart = new BarChart().Label("[bold]Daily Tokens[/]").Width(80).UseValueFormatter(v => FormatTokens((long)v)); + /// + /// Builds a bar chart of tokens consumed per hour from hour buckets. + /// + /// The hour buckets containing token totals. + /// An bar chart of tokens by hour. + public static IRenderable BuildHourlyTokenChart(List buckets) + { + var chart = new BarChart().Label("[bold]Tokens by Hour[/]").Width(80).UseValueFormatter(v => FormatTokens((long)v)); var colorIndex = 0; - foreach (var day in summary.DailyBreakdown) + foreach (var bucket in buckets) { - chart.AddItem(day.Date.ToString("MM-dd"), day.TotalTokens, HourlyBarColors[colorIndex % HourlyBarColors.Length]); + chart.AddItem(bucket.HourStart.ToString("HH:00"), bucket.TotalTokens, HourlyBarColors[colorIndex % HourlyBarColors.Length]); colorIndex++; } - return new Rows(table, new Rule().RuleStyle("grey"), chart); + return chart; } - public static void RenderModelDistribution(List models) => AnsiConsole.Write(BuildModelDistribution(models)); - + /// + /// Builds a table showing token usage and cost breakdown per model. + /// + /// The per-model distribution data to render. + /// An table of model token distribution. public static IRenderable BuildModelDistribution(List models) { var table = new Table(); @@ -91,54 +109,41 @@ public static IRenderable BuildModelDistribution(List models) return table; } - public static void RenderSessionStats(SessionStats stats) => AnsiConsole.Write(BuildSessionStats(stats)); - - public static IRenderable BuildSessionStats(SessionStats stats) + /// + /// Builds a renderable summary for a date range, including key stats and a daily token chart. + /// + /// The period summary data to render. + /// An representing the period summary. + public static IRenderable BuildPeriodSummary(PeriodSummary summary) { var table = BuildStatsTable( - "Session Stats", - ("Total Sessions", $"{stats.Total:N0}"), - ("Avg Messages/Session", $"{stats.AvgMessages:F1}"), - ("Longest Session", FormatDuration(stats.LongestDuration))); + $"{summary.From:yyyy-MM-dd} ~ {summary.To:yyyy-MM-dd}", + ("Messages", $"{summary.TotalMessages:N0}"), + ("Sessions", $"{summary.TotalSessions:N0}"), + ("Tool Calls", $"{summary.TotalToolCalls:N0}"), + ("Total Tokens", FormatTokens(summary.TotalTokens)), + ("Est. Cost", $"{summary.EstimatedCost:C2}")); - if (stats.LongestSessionId is null) + if (summary.DailyBreakdown.Count == 0) { return table; } - return new Rows(table, new Markup($"[grey]Longest Session ID: {stats.LongestSessionId}[/]")); - } - - public static void RenderHourlyActivity(List hours) => AnsiConsole.Write(BuildHourlyActivity(hours)); - - private static readonly Color[] HourlyBarColors = { Color.Blue, Color.SkyBlue1, Color.Green, Color.Yellow3 }; - - public static IRenderable BuildHourlyActivity(List hours) - { - var chart = new BarChart().Label("[bold]Tokens by Hour[/]").Width(80).UseValueFormatter(v => FormatTokens((long)v)); - var colorIndex = 0; - foreach (var h in hours) - { - chart.AddItem($"{h.Hour:D2}:00", h.TotalTokens, HourlyBarColors[colorIndex % HourlyBarColors.Length]); - colorIndex++; - } - return chart; - } - - public static IRenderable BuildHourlyTokenChart(List buckets) - { - var chart = new BarChart().Label("[bold]Tokens by Hour[/]").Width(80).UseValueFormatter(v => FormatTokens((long)v)); + var chart = new BarChart().Label("[bold]Daily Tokens[/]").Width(80).UseValueFormatter(v => FormatTokens((long)v)); var colorIndex = 0; - foreach (var bucket in buckets) + foreach (var day in summary.DailyBreakdown) { - chart.AddItem(bucket.HourStart.ToString("HH:00"), bucket.TotalTokens, HourlyBarColors[colorIndex % HourlyBarColors.Length]); + chart.AddItem(day.Date.ToString("MM-dd"), day.TotalTokens, HourlyBarColors[colorIndex % HourlyBarColors.Length]); colorIndex++; } - return chart; + return new Rows(table, new Rule().RuleStyle("grey"), chart); } - public static void RenderRecentActivity(RecentActivitySummary recent) => AnsiConsole.Write(BuildRecentActivity(recent)); - + /// + /// Builds a renderable summary of recent activity, including key stats and a message-by-hour trend chart. + /// + /// The recent activity summary data to render. + /// An representing the recent activity summary. public static IRenderable BuildRecentActivity(RecentActivitySummary recent) { var hours = (int)Math.Round(recent.Window.TotalHours); @@ -165,6 +170,69 @@ public static IRenderable BuildRecentActivity(RecentActivitySummary recent) return new Rows(table, chart); } + /// + /// Builds a renderable summary of session statistics. + /// + /// The session statistics data to render. + /// An representing the session stats. + public static IRenderable BuildSessionStats(SessionStats stats) + { + var table = BuildStatsTable( + "Session Stats", + ("Total Sessions", $"{stats.Total:N0}"), + ("Avg Messages/Session", $"{stats.AvgMessages:F1}"), + ("Longest Session", FormatDuration(stats.LongestDuration))); + + if (stats.LongestSessionId is null) + { + return table; + } + + return new Rows(table, new Markup($"[grey]Longest Session ID: {stats.LongestSessionId}[/]")); + } + + /// + /// Renders the daily summary directly to the console. + /// + /// The daily summary data to render. + public static void RenderDailySummary(DailySummary summary) => AnsiConsole.Write(BuildDailySummary(summary)); + + /// + /// Renders the hourly activity chart directly to the console. + /// + /// The hourly activity data to render. + public static void RenderHourlyActivity(List hours) => AnsiConsole.Write(BuildHourlyActivity(hours)); + + /// + /// Renders the model distribution table directly to the console. + /// + /// The per-model distribution data to render. + public static void RenderModelDistribution(List models) => AnsiConsole.Write(BuildModelDistribution(models)); + + /// + /// Renders the period summary directly to the console. + /// + /// The period summary data to render. + public static void RenderPeriodSummary(PeriodSummary summary) => AnsiConsole.Write(BuildPeriodSummary(summary)); + + /// + /// Renders the recent activity summary directly to the console. + /// + /// The recent activity summary data to render. + public static void RenderRecentActivity(RecentActivitySummary recent) => AnsiConsole.Write(BuildRecentActivity(recent)); + + /// + /// Renders the session stats summary directly to the console. + /// + /// The session statistics data to render. + public static void RenderSessionStats(SessionStats stats) => AnsiConsole.Write(BuildSessionStats(stats)); + + /// + /// Builds a titled table with a single row of centered stat values, one column per stat. + /// + /// The table title. + /// The label/value pairs to render as columns and their values. + /// A configured with the stats rendered. private static Table BuildStatsTable(string title, params (string Label, string Value)[] stats) { var table = new Table().Border(TableBorder.Rounded).Title($"[bold yellow]{title}[/]").Width(80); @@ -176,6 +244,29 @@ private static Table BuildStatsTable(string title, params (string Label, string return table; } + /// + /// Formats a into a compact human-readable duration string. + /// + /// The duration to format. + /// A human-readable representation of the duration. + private static string FormatDuration(TimeSpan ts) + { + if (ts.TotalDays >= 1) + { + return $"{ts.Days}d {ts.Hours}h {ts.Minutes}m"; + } + if (ts.TotalHours >= 1) + { + return $"{ts.Hours}h {ts.Minutes}m"; + } + return $"{ts.Minutes}m {ts.Seconds}s"; + } + + /// + /// Formats a raw token count into a compact string using B/M/K suffixes. + /// + /// The token count to format. + /// A compact, human-readable token count string. private static string FormatTokens(long tokens) => tokens switch { >= 1_000_000_000 => $"{tokens / 1_000_000_000.0:F2}B", @@ -184,6 +275,11 @@ private static Table BuildStatsTable(string title, params (string Label, string _ => tokens.ToString("N0") }; + /// + /// Shortens a model name by removing the "claude-" prefix and trailing date suffix, if present. + /// + /// The full model name. + /// The shortened model name. private static string ShortenModelName(string model) { var name = model; @@ -200,17 +296,4 @@ private static string ShortenModelName(string model) return name; } - - private static string FormatDuration(TimeSpan ts) - { - if (ts.TotalDays >= 1) - { - return $"{ts.Days}d {ts.Hours}h {ts.Minutes}m"; - } - if (ts.TotalHours >= 1) - { - return $"{ts.Hours}h {ts.Minutes}m"; - } - return $"{ts.Minutes}m {ts.Seconds}s"; - } -} +} \ No newline at end of file diff --git a/src/AIUsageMonitor.Core/Analytics/CostCalculator.cs b/src/AIUsageMonitor.Core/Analytics/CostCalculator.cs index 06fb78e..7e7fcd8 100644 --- a/src/AIUsageMonitor.Core/Analytics/CostCalculator.cs +++ b/src/AIUsageMonitor.Core/Analytics/CostCalculator.cs @@ -2,8 +2,16 @@ namespace AIUsageMonitor.Core.Analytics; +/// +/// Estimates the monetary cost (in USD) of model usage based on token counts +/// and a per-model pricing table. +/// public sealed class CostCalculator { + /// + /// Pricing per million tokens (input, output, cache read, cache creation) keyed by + /// a substring that identifies the model name. + /// private static readonly Dictionary PricingTable = new() { ["fable-5"] = new(10m, 50m, 1m, 12.5m), @@ -15,6 +23,15 @@ public sealed class CostCalculator ["haiku-4"] = new(1m, 5m, 0.10m, 1.25m), }; + /// + /// Estimates the cost in USD for a request based on raw token counts. + /// + /// The model name (or a string containing it) used to resolve pricing. + /// Number of input tokens consumed. + /// Number of output tokens generated. + /// Number of tokens read from cache. + /// Number of tokens used to create cache entries. + /// The estimated cost in USD, or 0 if the model's pricing could not be resolved. public decimal EstimateCost(string modelName, long inputTokens, long outputTokens, long cacheReadTokens, long cacheCreationTokens) { diff --git a/src/AIUsageMonitor.Core/Analytics/UsageAnalyzer.cs b/src/AIUsageMonitor.Core/Analytics/UsageAnalyzer.cs index 2de9eeb..0adf0dc 100644 --- a/src/AIUsageMonitor.Core/Analytics/UsageAnalyzer.cs +++ b/src/AIUsageMonitor.Core/Analytics/UsageAnalyzer.cs @@ -3,8 +3,22 @@ namespace AIUsageMonitor.Core.Analytics; +/// +/// Provides analytics over cached usage statistics, such as daily and period summaries, +/// model distribution breakdowns, and session statistics. +/// +/// The calculator used to estimate token usage costs. public sealed class UsageAnalyzer(CostCalculator costCalculator) { + /// + /// Builds a summary of usage activity for a single day. + /// + /// The cached usage statistics to read from. + /// The date to summarize. + /// + /// The for the given date, or if no + /// activity was recorded for that date. + /// public DailySummary? GetDailySummary(StatsCache cache, DateOnly date) { var activity = cache.DailyActivity.FirstOrDefault(a => a.Date == date); @@ -23,28 +37,14 @@ public sealed class UsageAnalyzer(CostCalculator costCalculator) activity.ToolCallCount, totalTokens, new(tokensByModel), cost); } - public PeriodSummary GetPeriodSummary(StatsCache cache, DateOnly from, DateOnly to) - { - var days = new List(); - for (var date = from; date <= to; date = date.AddDays(1)) - { - var summary = GetDailySummary(cache, date); - if (summary is not null) - { - days.Add(summary); - } - } - - return new( - from, to, - days.Sum(d => d.Messages), - days.Sum(d => d.Sessions), - days.Sum(d => d.ToolCalls), - days.Sum(d => d.TotalTokens), - days.Sum(d => d.EstimatedCost), - days); - } - + /// + /// Computes the token usage distribution and estimated cost across all models. + /// + /// The cached usage statistics to read from. + /// + /// A list of entries ordered by total tokens descending, + /// or an empty list if no tokens have been recorded. + /// public List GetModelDistribution(StatsCache cache) { var totalAllTokens = cache.ModelUsage.Values @@ -74,6 +74,41 @@ public List GetModelDistribution(StatsCache cache) .ToList(); } + /// + /// Aggregates daily summaries over an inclusive date range into a single period summary. + /// + /// The cached usage statistics to read from. + /// The inclusive start date of the period. + /// The inclusive end date of the period. + /// A aggregating totals for the requested period. + public PeriodSummary GetPeriodSummary(StatsCache cache, DateOnly from, DateOnly to) + { + var days = new List(); + for (var date = from; date <= to; date = date.AddDays(1)) + { + var summary = GetDailySummary(cache, date); + if (summary is not null) + { + days.Add(summary); + } + } + + return new( + from, to, + days.Sum(d => d.Messages), + days.Sum(d => d.Sessions), + days.Sum(d => d.ToolCalls), + days.Sum(d => d.TotalTokens), + days.Sum(d => d.EstimatedCost), + days); + } + + /// + /// Computes overall session statistics, including average messages per session and + /// information about the longest recorded session. + /// + /// The cached usage statistics to read from. + /// The computed . public SessionStats GetSessionStats(StatsCache cache) { var longestDuration = cache.LongestSession is not null @@ -88,6 +123,12 @@ public SessionStats GetSessionStats(StatsCache cache) cache.LongestSession?.SessionId); } + /// + /// Estimates the cost of daily token usage per model by approximating the split between + /// input, output, and cache-related tokens. + /// + /// The total token counts recorded for each model. + /// The estimated total cost across all models. private decimal EstimateDailyTokensCost(Dictionary tokensByModel) { var cost = 0m; @@ -97,4 +138,4 @@ private decimal EstimateDailyTokensCost(Dictionary tokensByModel) } return cost; } -} +} \ No newline at end of file diff --git a/src/AIUsageMonitor.Core/Models/AnalyticsDtos.cs b/src/AIUsageMonitor.Core/Models/AnalyticsDtos.cs index ca0ba82..f77d208 100644 --- a/src/AIUsageMonitor.Core/Models/AnalyticsDtos.cs +++ b/src/AIUsageMonitor.Core/Models/AnalyticsDtos.cs @@ -2,6 +2,16 @@ namespace AIUsageMonitor.Core.Models; +/// +/// Represents a summary of usage activity for a single calendar day. +/// +/// The calendar date the summary covers. +/// The total number of messages sent on this date. +/// The total number of sessions active on this date. +/// The total number of tool calls made on this date. +/// The total number of tokens used on this date. +/// A mapping of model name to the number of tokens consumed by that model. +/// The estimated monetary cost of usage for this date. public sealed record DailySummary( DateOnly Date, int Messages, @@ -11,6 +21,17 @@ public sealed record DailySummary( Dictionary TokensByModel, decimal EstimatedCost); +/// +/// Represents an aggregated summary of usage activity over a date range. +/// +/// The start date of the period (inclusive). +/// The end date of the period (inclusive). +/// The total number of messages sent during the period. +/// The total number of sessions active during the period. +/// The total number of tool calls made during the period. +/// The total number of tokens used during the period. +/// The estimated monetary cost of usage for the period. +/// A per-day breakdown of usage within the period. public sealed record PeriodSummary( DateOnly From, DateOnly To, @@ -21,6 +42,17 @@ public sealed record PeriodSummary( decimal EstimatedCost, List DailyBreakdown); +/// +/// Represents the token usage distribution for a specific model. +/// +/// The name of the model. +/// The number of input tokens consumed by the model. +/// The number of output tokens produced by the model. +/// The number of tokens read from cache. +/// The number of tokens used to create cache entries. +/// The total number of tokens consumed by the model. +/// The percentage of overall token usage attributed to this model. +/// The estimated monetary cost of usage for this model. public sealed record ModelDistribution( string ModelName, long InputTokens, @@ -31,10 +63,32 @@ public sealed record ModelDistribution( double Percentage, decimal EstimatedCost); +/// +/// Represents the total token usage for a specific hour of the day. +/// +/// The hour of the day, in the range 0-23. +/// The total number of tokens used during that hour. public sealed record HourlyActivity(int Hour, long TotalTokens); +/// +/// Represents a bucket of usage activity aggregated over a single hour. +/// +/// The start timestamp of the hour bucket. +/// The number of messages sent during the hour. +/// The total number of tokens used during the hour. public sealed record HourBucket(DateTimeOffset HourStart, int Messages, long TotalTokens); +/// +/// Represents a summary of recent usage activity within a sliding time window. +/// +/// The duration of the time window covered by the summary. +/// The total number of messages sent within the window. +/// The total number of sessions active within the window. +/// The total number of tool calls made within the window. +/// The total number of tokens used within the window. +/// A mapping of model name to the number of tokens consumed by that model. +/// The estimated monetary cost of usage within the window. +/// A per-hour breakdown of usage within the window. public sealed record RecentActivitySummary( TimeSpan Window, int Messages, @@ -45,6 +99,14 @@ public sealed record RecentActivitySummary( decimal EstimatedCost, List HourlyTrend); +/// +/// Represents aggregated statistics across all sessions. +/// +/// The total number of sessions. +/// The average session duration. +/// The average number of messages per session. +/// The duration of the longest session. +/// The identifier of the longest session, or if unavailable. public sealed record SessionStats( int Total, TimeSpan AvgDuration, @@ -52,6 +114,17 @@ public sealed record SessionStats( TimeSpan LongestDuration, string? LongestSessionId); +/// +/// Represents a summary of usage activity for a single session. +/// +/// The unique identifier of the session. +/// The name of the project associated with the session, or if unavailable. +/// The timestamp when the session started. +/// The timestamp when the session ended. +/// The total duration of the session. +/// The total number of messages sent during the session. +/// The total number of tokens used during the session. +/// A mapping of model name to the number of tokens consumed by that model. public sealed record SessionSummary( string SessionId, string? Project, @@ -62,6 +135,14 @@ public sealed record SessionSummary( long TotalTokens, Dictionary TokensByModel); +/// +/// Represents the full data payload produced when exporting usage data. +/// +/// The total number of sessions included in the export. +/// The total number of messages included in the export. +/// The per-day activity records included in the export. +/// The per-day, per-model token usage records included in the export. +/// The overall token usage distribution across models included in the export. public sealed record ExportPayload( int TotalSessions, int TotalMessages, @@ -69,6 +150,13 @@ public sealed record ExportPayload( List DailyModelTokens, List ModelDistribution); +/// +/// Represents the token usage distribution for a specific model within an export payload. +/// +/// The name of the model. +/// The total number of tokens consumed by the model. +/// The percentage of overall token usage attributed to this model. +/// The estimated monetary cost of usage for this model. public sealed record ExportModelDistribution( string ModelName, long TotalTokens, diff --git a/src/AIUsageMonitor.Core/Models/JsonContext.cs b/src/AIUsageMonitor.Core/Models/JsonContext.cs index a718f27..784d145 100644 --- a/src/AIUsageMonitor.Core/Models/JsonContext.cs +++ b/src/AIUsageMonitor.Core/Models/JsonContext.cs @@ -1,8 +1,11 @@ -using System.Text.Json.Serialization; using AIUsageMonitor.Core.Providers.Claude.Models; +using System.Text.Json.Serialization; namespace AIUsageMonitor.Core.Models; +/// +/// Represents the JSON serialization context for the core models used in the AIUsageMonitor application. This context is used to generate source code for JSON serialization and deserialization of specific types, including , , , and . +/// [JsonSerializable(typeof(StatsCache))] [JsonSerializable(typeof(SessionMessage))] [JsonSerializable(typeof(HistoryEntry))] diff --git a/src/AIUsageMonitor.Core/Providers/Claude/ClaudeDataLocator.cs b/src/AIUsageMonitor.Core/Providers/Claude/ClaudeDataLocator.cs index 92f791f..cf4fc66 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/ClaudeDataLocator.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/ClaudeDataLocator.cs @@ -1,53 +1,71 @@ namespace AIUsageMonitor.Core.Providers.Claude; -public sealed class ClaudeDataLocator +/// +/// Locates Claude Code data files and directories on disk, such as history logs, +/// project session files, and the stats cache. +/// +/// +/// Optional path to the Claude data directory. When , defaults to +/// the .claude folder under the current user's profile directory. +/// +public sealed class ClaudeDataLocator(string? claudeDir = null) { - private readonly string _claudeDir; - - public ClaudeDataLocator(string? claudeDir = null) - { - _claudeDir = claudeDir - ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude"); - } + private readonly string _claudeDir = + claudeDir ?? + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude"); + /// + /// Gets the root Claude data directory (e.g. %USERPROFILE%\.claude). + /// public string ClaudeDir => _claudeDir; - public string StatsCachePath => Path.Combine(_claudeDir, "stats-cache.json"); - + /// + /// Gets the full path to the history.jsonl file containing Claude command history. + /// public string HistoryPath => Path.Combine(_claudeDir, "history.jsonl"); + /// + /// Gets the full path to the projects directory containing per-project session data. + /// public string ProjectsDir => Path.Combine(_claudeDir, "projects"); - public IReadOnlyList GetSessionFiles() + /// + /// Gets the full path to the stats-cache.json file used to cache computed usage statistics. + /// + public string StatsCachePath => Path.Combine(_claudeDir, "stats-cache.json"); + + /// + /// Enumerates the project directories under . + /// + /// + /// A list of tuples containing each project's encoded directory name and its full path, + /// or an empty list if does not exist. + /// + public IReadOnlyList<(string EncodedName, string FullPath)> GetProjectDirectories() { - var projectsDir = ProjectsDir; - if (!Directory.Exists(projectsDir)) + if (!Directory.Exists(ProjectsDir)) { return []; } - var files = new List(); - foreach (var projectDir in Directory.EnumerateDirectories(projectsDir)) - { - foreach (var file in Directory.EnumerateFiles(projectDir, "*.jsonl")) - { - files.Add(file); - } - } - - return files; + return Directory.EnumerateDirectories(ProjectsDir) + .Select(d => (Path.GetFileName(d), d)) + .ToList(); } - public IReadOnlyList<(string EncodedName, string FullPath)> GetProjectDirectories() + /// + /// Finds all session log files (*.jsonl) recursively under . + /// + /// + /// A list of full paths to session files, or an empty list if does not exist. + /// + public IReadOnlyList GetSessionFiles() { - var projectsDir = ProjectsDir; - if (!Directory.Exists(projectsDir)) + if (!Directory.Exists(ProjectsDir)) { return []; } - return Directory.EnumerateDirectories(projectsDir) - .Select(d => (Path.GetFileName(d), d)) - .ToList(); + return Directory.GetFiles(ProjectsDir, "*.jsonl", SearchOption.AllDirectories); } -} +} \ No newline at end of file diff --git a/src/AIUsageMonitor.Core/Providers/Claude/ClaudeUsageProvider.cs b/src/AIUsageMonitor.Core/Providers/Claude/ClaudeUsageProvider.cs index 9f7c75d..8247ce2 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/ClaudeUsageProvider.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/ClaudeUsageProvider.cs @@ -4,6 +4,15 @@ namespace AIUsageMonitor.Core.Providers.Claude; +/// +/// Provides Claude usage data by parsing local session transcripts and the stats cache file. +/// +/// Locates Claude data files (session transcripts and stats cache) on disk. +/// Parses the stats-cache.json file into a . +/// Builds a from session transcripts when the cache is missing or stale. +/// Builds a summary of recent activity from session transcripts. +/// Builds hourly activity data from session transcripts. +/// Logger used to report cache fallback diagnostics. public sealed class ClaudeUsageProvider( ClaudeDataLocator locator, StatsCacheParser statsCacheParser, @@ -12,21 +21,42 @@ public sealed class ClaudeUsageProvider( HourlyActivityBuilder hourlyActivityBuilder, ILogger logger) : IUsageProvider { + /// Gets the display name of this usage provider. public string Name => "Claude"; + /// Gets the directory containing Claude project/session data. public string ProjectsDir => locator.ProjectsDir; + + /// Gets the path to the Claude stats-cache.json file. public string StatsCachePath => locator.StatsCachePath; + /// + /// Builds hourly activity data from Claude session transcripts. + /// + /// Optional progress reporter for tracking build progress (0-100). + /// A list of entries. public List GetHourlyActivity(IProgress? progress = null) { return hourlyActivityBuilder.Build(locator.GetSessionFiles(), progress); } + /// + /// Builds a summary of recent Claude activity within the specified time window. + /// + /// The time window to look back over, relative to now. + /// Optional progress reporter for tracking build progress (0-100). + /// A describing recent activity. public RecentActivitySummary GetRecentActivity(TimeSpan window, IProgress? progress = null) { return recentActivityBuilder.Build(locator.GetSessionFiles(), window, progress); } + /// + /// Gets the current usage stats cache, using stats-cache.json when present and up to date, + /// or computing it from session transcripts otherwise. + /// + /// Optional progress reporter for tracking build progress (0-100). + /// The resolved . public StatsCache GetStatsCache(IProgress? progress = null) { if (File.Exists(locator.StatsCachePath)) diff --git a/src/AIUsageMonitor.Core/Providers/Claude/HistoryParser.cs b/src/AIUsageMonitor.Core/Providers/Claude/HistoryParser.cs index 3b4f32e..9739b13 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/HistoryParser.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/HistoryParser.cs @@ -1,11 +1,19 @@ -using System.Text.Json; using AIUsageMonitor.Core.Models; using AIUsageMonitor.Core.Providers.Claude.Models; +using System.Text.Json; namespace AIUsageMonitor.Core.Providers.Claude; +/// +/// Parses a history file containing JSON lines into a collection of objects. +/// public sealed class HistoryParser { + /// + /// Parses the specified history file and returns a collection of objects. + /// + /// The path to the history file to parse. + /// A collection of objects. public IEnumerable Parse(string filePath) { if (!File.Exists(filePath)) @@ -36,4 +44,4 @@ public IEnumerable Parse(string filePath) } } } -} +} \ No newline at end of file diff --git a/src/AIUsageMonitor.Core/Providers/Claude/HourlyActivityBuilder.cs b/src/AIUsageMonitor.Core/Providers/Claude/HourlyActivityBuilder.cs index 73d6cef..829f560 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/HourlyActivityBuilder.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/HourlyActivityBuilder.cs @@ -8,6 +8,12 @@ namespace AIUsageMonitor.Core.Providers.Claude; /// public sealed class HourlyActivityBuilder(SessionFileCache sessionFileCache) { + /// + /// Builds a list of objects representing the total token usage for each hour of the day across all provided session files. + /// + /// A list of session file paths to process. + /// An optional progress reporter for tracking build progress (0-100). + /// A list of objects. public List Build(IReadOnlyList sessionFiles, IProgress? progress = null) { var tokensByHour = new long[24]; @@ -16,7 +22,7 @@ public List Build(IReadOnlyList sessionFiles, IProgress< { try { - ProcessFile(sessionFiles[fileIndex]); + ProcessFile(sessionFiles[fileIndex], tokensByHour); } finally { @@ -24,46 +30,56 @@ public List Build(IReadOnlyList sessionFiles, IProgress< } } - void ProcessFile(string file) + return Enumerable.Range(0, 24) + .Select(h => new HourlyActivity(h, tokensByHour[h])) + .ToList(); + } + + /// + /// Processes a single session file, updating the provided tokensByHour array with the total token usage for each hour of the day. + /// + /// The path to the session file to process. + /// An array representing the total token usage for each hour of the day. + private void ProcessFile(string file, long[] tokensByHour) + { + IReadOnlyList parsed; + try { - List parsed; - try + parsed = sessionFileCache.GetRows(file); + } + catch + { + return; + } + + foreach (var msg in parsed) + { + if (msg.Type != "assistant" || + msg.Timestamp is null || + !DateTimeOffset.TryParse(msg.Timestamp, out var ts)) { - parsed = sessionFileCache.GetRows(file); + continue; } - catch + + var usage = msg.Message?.Usage; + if (usage is null) { - return; + continue; } - foreach (var msg in parsed) + var model = msg.Message?.Model ?? "unknown"; + if (model == "") { - if (msg.Type != "assistant" || msg.Timestamp is null - || !DateTimeOffset.TryParse(msg.Timestamp, out var ts)) - { - continue; - } - - var usage = msg.Message?.Usage; - if (usage is null) - { - continue; - } + continue; + } - var model = msg.Message?.Model ?? "unknown"; - if (model == "") - { - continue; - } + var tokens = + usage.InputTokens + + usage.OutputTokens + + usage.CacheReadInputTokens + + usage.CacheCreationInputTokens; - var tokens = usage.InputTokens + usage.OutputTokens - + usage.CacheReadInputTokens + usage.CacheCreationInputTokens; - tokensByHour[ts.LocalDateTime.Hour] += tokens; - } + tokensByHour[ts.LocalDateTime.Hour] += tokens; } - - return Enumerable.Range(0, 24) - .Select(h => new HourlyActivity(h, tokensByHour[h])) - .ToList(); } -} +} \ No newline at end of file diff --git a/src/AIUsageMonitor.Core/Providers/Claude/Models/DateOnlyJsonConverter.cs b/src/AIUsageMonitor.Core/Providers/Claude/Models/DateOnlyJsonConverter.cs index c1af88d..f218c56 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/Models/DateOnlyJsonConverter.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/Models/DateOnlyJsonConverter.cs @@ -3,40 +3,39 @@ namespace AIUsageMonitor.Core.Providers.Claude.Models; +/// +/// A custom JSON converter for that handles both "yyyy-MM-dd" format and full ISO-8601 timestamps. +/// public sealed class DateOnlyJsonConverter : JsonConverter { private const string Format = "yyyy-MM-dd"; + /// + /// Reads a value from JSON, tolerating both "yyyy-MM-dd" format and full ISO-8601 timestamps. + /// + /// The to read from. + /// The type to convert. + /// The serialization options. + /// The parsed value. public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => ParseDateOnly(reader.GetString()!); + /// + /// Writes a value to JSON in "yyyy-MM-dd" format. + /// + /// The to write to. + /// The value to write. + /// The serialization options. public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options) => writer.WriteStringValue(value.ToString(Format)); - // Claude Code's own stats-cache.json has been observed writing this field as either a bare - // "yyyy-MM-dd" date or a full ISO-8601 timestamp (e.g. "2026-06-08T02:30:16.971Z"); tolerate both. - internal static DateOnly ParseDateOnly(string value) => + /// + /// Claude Code's own stats-cache.json has been observed writing this field as either a bare "yyyy-MM-dd" date or a full ISO-8601 timestamp (e.g. "2026-06-08T02:30:16.971Z"); tolerate both. + /// + /// The string representation of the date. + /// The parsed value. + private static DateOnly ParseDateOnly(string value) => DateOnly.TryParseExact(value, Format, out var dateOnly) ? dateOnly : DateOnly.FromDateTime(DateTimeOffset.Parse(value).LocalDateTime); -} - -public sealed class NullableDateOnlyJsonConverter : JsonConverter -{ - private const string Format = "yyyy-MM-dd"; - - public override DateOnly? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => - reader.TokenType == JsonTokenType.Null ? null : DateOnlyJsonConverter.ParseDateOnly(reader.GetString()!); - - public override void Write(Utf8JsonWriter writer, DateOnly? value, JsonSerializerOptions options) - { - if (value is null) - { - writer.WriteNullValue(); - } - else - { - writer.WriteStringValue(value.Value.ToString(Format)); - } - } -} +} \ No newline at end of file diff --git a/src/AIUsageMonitor.Core/Providers/Claude/Models/HistoryEntry.cs b/src/AIUsageMonitor.Core/Providers/Claude/Models/HistoryEntry.cs index d10f908..3fb6cb9 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/Models/HistoryEntry.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/Models/HistoryEntry.cs @@ -2,17 +2,32 @@ namespace AIUsageMonitor.Core.Providers.Claude.Models; +/// +/// Represents a single entry in the usage history, containing information about the display name, timestamp, associated project, and session ID. +/// public sealed class HistoryEntry { + /// + /// Gets the display name associated with this history entry. + /// [JsonPropertyName("display")] public string Display { get; init; } = ""; - [JsonPropertyName("timestamp")] - public long Timestamp { get; init; } - + /// + /// Gets the project associated with this history entry, if any. + /// [JsonPropertyName("project")] public string? Project { get; init; } + /// + /// Gets the session ID associated with this history entry, if any. + /// [JsonPropertyName("sessionId")] public string? SessionId { get; init; } -} + + /// + /// Gets the timestamp of this history entry, represented as a long integer (typically in milliseconds since the Unix epoch). + /// + [JsonPropertyName("timestamp")] + public long Timestamp { get; init; } +} \ No newline at end of file diff --git a/src/AIUsageMonitor.Core/Providers/Claude/Models/SessionMessage.cs b/src/AIUsageMonitor.Core/Providers/Claude/Models/SessionMessage.cs index b87e620..8875990 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/Models/SessionMessage.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/Models/SessionMessage.cs @@ -2,53 +2,104 @@ namespace AIUsageMonitor.Core.Providers.Claude.Models; -public sealed class SessionMessage +/// +/// Represents the content of a message in a session transcript, including the message content, model used, role of the sender, and token usage information. +/// +public sealed class MessageContent { - [JsonPropertyName("type")] - public string Type { get; init; } = ""; + /// + /// Gets the raw content of the message, which may be a string or a structured JSON element. + /// + [JsonPropertyName("content")] + public System.Text.Json.JsonElement? Content { get; init; } - [JsonPropertyName("timestamp")] - public string? Timestamp { get; init; } + /// + /// Gets the name of the model that generated the message. + /// + [JsonPropertyName("model")] + public string? Model { get; init; } - [JsonPropertyName("uuid")] - public string? Uuid { get; init; } + /// + /// Gets the role of the message sender (e.g., "user" or "assistant"). + /// + [JsonPropertyName("role")] + public string Role { get; init; } = ""; - [JsonPropertyName("sessionId")] - public string? SessionId { get; init; } + /// + /// Gets the token usage information associated with the message. + /// + [JsonPropertyName("usage")] + public TokenUsage? Usage { get; init; } +} +/// +/// Represents a single message in a session transcript, containing information about the message type, timestamp, UUID, session ID, current working directory, and the message content. +/// +public sealed class SessionMessage +{ + /// + /// Gets the current working directory recorded at the time the message was created. + /// [JsonPropertyName("cwd")] public string? Cwd { get; init; } + /// + /// Gets the message content, including role, model, and token usage. + /// [JsonPropertyName("message")] public MessageContent? Message { get; init; } -} -public sealed class MessageContent -{ - [JsonPropertyName("role")] - public string Role { get; init; } = ""; + /// + /// Gets the identifier of the session this message belongs to. + /// + [JsonPropertyName("sessionId")] + public string? SessionId { get; init; } - [JsonPropertyName("model")] - public string? Model { get; init; } + /// + /// Gets the timestamp indicating when the message was recorded. + /// + [JsonPropertyName("timestamp")] + public string? Timestamp { get; init; } - [JsonPropertyName("usage")] - public TokenUsage? Usage { get; init; } + /// + /// Gets the type of the message (e.g., "user" or "assistant"). + /// + [JsonPropertyName("type")] + public string Type { get; init; } = ""; - [JsonPropertyName("content")] - public System.Text.Json.JsonElement? Content { get; init; } + /// + /// Gets the unique identifier of the message. + /// + [JsonPropertyName("uuid")] + public string? Uuid { get; init; } } +/// +/// Represents token usage statistics for a message, including cache and input/output token counts. +/// public sealed class TokenUsage { + /// + /// Gets the number of tokens used to create the cache. + /// + [JsonPropertyName("cache_creation_input_tokens")] + public long CacheCreationInputTokens { get; init; } + + /// + /// Gets the number of tokens read from the cache. + /// + [JsonPropertyName("cache_read_input_tokens")] + public long CacheReadInputTokens { get; init; } + + /// + /// Gets the number of input tokens consumed by the message. + /// [JsonPropertyName("input_tokens")] public long InputTokens { get; init; } + /// + /// Gets the number of output tokens produced by the message. + /// [JsonPropertyName("output_tokens")] public long OutputTokens { get; init; } - - [JsonPropertyName("cache_read_input_tokens")] - public long CacheReadInputTokens { get; init; } - - [JsonPropertyName("cache_creation_input_tokens")] - public long CacheCreationInputTokens { get; init; } -} +} \ No newline at end of file diff --git a/src/AIUsageMonitor.Core/Providers/Claude/Models/StatsCache.cs b/src/AIUsageMonitor.Core/Providers/Claude/Models/StatsCache.cs index 173d70a..dffb4bb 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/Models/StatsCache.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/Models/StatsCache.cs @@ -2,104 +2,203 @@ namespace AIUsageMonitor.Core.Providers.Claude.Models; +/// +/// Represents aggregated activity metrics for a single calendar day. +/// public sealed class DailyActivity { + /// + /// Gets the calendar date the activity was recorded for. + /// [JsonPropertyName("date")] [JsonConverter(typeof(DateOnlyJsonConverter))] public DateOnly Date { get; init; } + /// + /// Gets the total number of messages sent on this date. + /// [JsonPropertyName("messageCount")] public int MessageCount { get; init; } + /// + /// Gets the total number of sessions started on this date. + /// [JsonPropertyName("sessionCount")] public int SessionCount { get; init; } + /// + /// Gets the total number of tool calls made on this date. + /// [JsonPropertyName("toolCallCount")] public int ToolCallCount { get; init; } } +/// +/// Represents token usage broken down by model for a single calendar day. +/// public sealed class DailyModelTokens { + /// + /// Gets the calendar date the token usage was recorded for. + /// [JsonPropertyName("date")] [JsonConverter(typeof(DateOnlyJsonConverter))] public DateOnly Date { get; init; } + /// + /// Gets the number of tokens consumed on this date, keyed by model name. + /// [JsonPropertyName("tokensByModel")] public Dictionary TokensByModel { get; init; } = []; } +/// +/// Represents cumulative token usage and cost statistics for a single model. +/// public sealed class ModelUsageEntry { + /// + /// Gets the number of tokens used to create cache entries. + /// [JsonPropertyName("cacheCreationInputTokens")] public long CacheCreationInputTokens { get; init; } + /// + /// Gets the number of tokens read from cache. + /// [JsonPropertyName("cacheReadInputTokens")] public long CacheReadInputTokens { get; init; } + /// + /// Gets the maximum context window size, in tokens, supported by the model. + /// [JsonPropertyName("contextWindow")] public int ContextWindow { get; init; } + /// + /// Gets the estimated cost, in US dollars, incurred by usage of this model. + /// [JsonPropertyName("costUSD")] public decimal CostUSD { get; init; } + /// + /// Gets the total number of input tokens sent to the model. + /// [JsonPropertyName("inputTokens")] public long InputTokens { get; init; } + /// + /// Gets the maximum number of output tokens the model is allowed to generate. + /// [JsonPropertyName("maxOutputTokens")] public int MaxOutputTokens { get; init; } + /// + /// Gets the total number of output tokens generated by the model. + /// [JsonPropertyName("outputTokens")] public long OutputTokens { get; init; } + /// + /// Gets the total number of web search requests made by the model. + /// [JsonPropertyName("webSearchRequests")] public int WebSearchRequests { get; init; } } +/// +/// Represents the cached, precomputed usage statistics for the Claude provider. +/// public sealed class StatsCache { + /// + /// Gets the list of daily activity records. + /// [JsonPropertyName("dailyActivity")] public List DailyActivity { get; init; } = []; + /// + /// Gets the list of daily model token usage records. + /// [JsonPropertyName("dailyModelTokens")] public List DailyModelTokens { get; init; } = []; + /// + /// Gets the timestamp of the earliest recorded session, if any. + /// [JsonPropertyName("firstSessionDate")] public DateTimeOffset? FirstSessionDate { get; init; } + /// + /// Gets the number of messages sent, keyed by hour of day. + /// [JsonPropertyName("hourCounts")] public Dictionary HourCounts { get; init; } = []; + /// + /// Gets the date on which these statistics were last computed. + /// [JsonPropertyName("lastComputedDate")] [JsonConverter(typeof(DateOnlyJsonConverter))] public DateOnly LastComputedDate { get; init; } + /// + /// Gets information about the longest recorded session, if any. + /// [JsonPropertyName("longestSession")] public LongestSessionInfo? LongestSession { get; init; } + /// + /// Gets the usage statistics for each model, keyed by model name. + /// [JsonPropertyName("modelUsage")] public Dictionary ModelUsage { get; init; } = []; + /// + /// Gets the total number of messages recorded. + /// [JsonPropertyName("totalMessages")] public int TotalMessages { get; init; } + /// + /// Gets the total number of sessions recorded. + /// [JsonPropertyName("totalSessions")] public int TotalSessions { get; init; } + /// + /// Gets the schema version of the cached statistics. + /// [JsonPropertyName("version")] public int Version { get; init; } } +/// +/// Represents details about the longest recorded session. +/// public sealed class LongestSessionInfo { + /// + /// Gets the duration of the session, in milliseconds. + /// [JsonPropertyName("duration")] public long Duration { get; init; } + /// + /// Gets the number of messages exchanged during the session. + /// [JsonPropertyName("messageCount")] public int MessageCount { get; init; } + /// + /// Gets the unique identifier of the session. + /// [JsonPropertyName("sessionId")] public string SessionId { get; init; } = ""; + /// + /// Gets the timestamp at which the session occurred. + /// [JsonPropertyName("timestamp")] public string Timestamp { get; init; } = ""; } \ No newline at end of file diff --git a/src/AIUsageMonitor.Core/Providers/Claude/RecentActivityBuilder.cs b/src/AIUsageMonitor.Core/Providers/Claude/RecentActivityBuilder.cs index 99588b7..9aea9e3 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/RecentActivityBuilder.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/RecentActivityBuilder.cs @@ -8,8 +8,17 @@ namespace AIUsageMonitor.Core.Providers.Claude; /// summary, since only tracks hour-of-day and /// per-day buckets and cannot answer a trailing-window query. /// +/// The cost calculator used to estimate costs based on token usage. +/// The session file cache used to retrieve session messages from session files. public sealed class RecentActivityBuilder(SessionFileCache sessionFileCache, CostCalculator costCalculator) { + /// + /// Builds a recent activity summary for the specified session files within the given time window. + /// + /// A list of session file paths to process. + /// The time window for which to build the recent activity summary. + /// An optional progress reporter for tracking build progress (0-100). + /// A object representing the recent activity. public RecentActivitySummary Build(IReadOnlyList sessionFiles, TimeSpan window, IProgress? progress = null) { var now = DateTimeOffset.Now; @@ -35,9 +44,31 @@ public RecentActivitySummary Build(IReadOnlyList sessionFiles, TimeSpan } } + var estimatedCost = modelUsage.Sum(kvp => + costCalculator.EstimateCost(kvp.Key, kvp.Value.Input, kvp.Value.Output, kvp.Value.CacheRead, kvp.Value.CacheCreation)); + + var firstHour = new DateTimeOffset(since.Year, since.Month, since.Day, since.Hour, 0, 0, since.Offset); + var lastHour = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, 0, 0, now.Offset); + var hourlyTrend = new List(); + for (var hour = firstHour; hour <= lastHour; hour = hour.AddHours(1)) + { + var bucket = hourBuckets.GetValueOrDefault(hour); + hourlyTrend.Add(new(hour, bucket.Messages, bucket.Tokens)); + } + + return new( + window, + messages, + sessionIds.Count, + toolCalls, + totalTokens, + tokensByModel, + estimatedCost, + hourlyTrend); + void ProcessFile(string file) { - List parsed; + IReadOnlyList parsed; try { parsed = sessionFileCache.GetRows(file); @@ -57,7 +88,7 @@ void ProcessFile(string file) } sessionId ??= parsed.FirstOrDefault(m => m.SessionId is not null)?.SessionId - ?? Path.GetFileNameWithoutExtension(file); + ?? Path.GetFileNameWithoutExtension(file); if (msg.Type is not "user" and not "assistant") { @@ -79,7 +110,7 @@ void ProcessFile(string file) if (model != "") { var tokens = usage.InputTokens + usage.OutputTokens - + usage.CacheReadInputTokens + usage.CacheCreationInputTokens; + + usage.CacheReadInputTokens + usage.CacheCreationInputTokens; totalTokens += tokens; tokensByModel[model] = tokensByModel.GetValueOrDefault(model) + tokens; bucket.Tokens += tokens; @@ -96,27 +127,5 @@ void ProcessFile(string file) hourBuckets[hourStart] = bucket; } } - - var estimatedCost = modelUsage.Sum(kvp => - costCalculator.EstimateCost(kvp.Key, kvp.Value.Input, kvp.Value.Output, kvp.Value.CacheRead, kvp.Value.CacheCreation)); - - var firstHour = new DateTimeOffset(since.Year, since.Month, since.Day, since.Hour, 0, 0, since.Offset); - var lastHour = new DateTimeOffset(now.Year, now.Month, now.Day, now.Hour, 0, 0, now.Offset); - var hourlyTrend = new List(); - for (var hour = firstHour; hour <= lastHour; hour = hour.AddHours(1)) - { - var bucket = hourBuckets.GetValueOrDefault(hour); - hourlyTrend.Add(new(hour, bucket.Messages, bucket.Tokens)); - } - - return new( - window, - messages, - sessionIds.Count, - toolCalls, - totalTokens, - tokensByModel, - estimatedCost, - hourlyTrend); } -} +} \ No newline at end of file diff --git a/src/AIUsageMonitor.Core/Providers/Claude/SessionFileCache.cs b/src/AIUsageMonitor.Core/Providers/Claude/SessionFileCache.cs index 595f767..4fb2bb4 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/SessionFileCache.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/SessionFileCache.cs @@ -1,18 +1,34 @@ -using System.Collections.Concurrent; using AIUsageMonitor.Core.Providers.Claude.Models; +using System.Collections.Concurrent; namespace AIUsageMonitor.Core.Providers.Claude; /// /// Caches each session transcript's parsed rows keyed by file path + last-write time, so -/// re-aggregating stats after a cache invalidation only re-parses files that actually changed. +/// re-aggregating stats after a cache invalidation only reparses files that actually changed. /// +/// The used to parse session transcript files. public sealed class SessionFileCache(SessionParser sessionParser) { - private readonly ConcurrentDictionary Rows)> _cache = new(); + /// + /// A thread-safe dictionary that caches parsed session transcript rows, keyed by file path and last-write time. + /// + private readonly ConcurrentDictionary Rows)> _cache = new(); - public List GetRows(string filePath) + /// + /// Gets the parsed rows for a given session transcript file. If the file has not changed since the last read, returns the cached rows; otherwise, reparses the file and updates the cache. + /// + /// The path to the session transcript file. + /// A list of objects representing the parsed rows of the session transcript. + public IReadOnlyList GetRows(string filePath) { + if (!File.Exists(filePath)) + { + Remove(filePath); + + return []; + } + var lastWriteUtc = File.GetLastWriteTimeUtc(filePath); if (_cache.TryGetValue(filePath, out var entry) && entry.LastWriteUtc == lastWriteUtc) { @@ -21,18 +37,55 @@ public List GetRows(string filePath) var rows = sessionParser.ParseFile(filePath).ToList(); _cache[filePath] = (lastWriteUtc, rows); + return rows; } - public void Prune(IReadOnlyCollection liveFiles) + /// + /// Removes the cached entry for a specific session transcript file, if it exists. This can be used to manually invalidate the cache for a particular file. + /// + /// The path to the session transcript file. + public void Remove(string filePath) + { + _cache.TryRemove(filePath, out _); + } + + /// + /// Adds or updates the cached entry for a specific session transcript file. + /// + /// The path to the session transcript file. + public void Set(string filePath) + { + if (!File.Exists(filePath)) + { + Remove(filePath); + + return; + } + + var lastWriteUtc = File.GetLastWriteTimeUtc(filePath); + if (_cache.TryGetValue(filePath, out var entry) && entry.LastWriteUtc == lastWriteUtc) + { + return; + } + + var rows = sessionParser.ParseFile(filePath).ToList(); + _cache[filePath] = (lastWriteUtc, rows); + } + + /// + /// Removes cached entries for files that are no longer present in . + /// + /// The set of session transcript file paths that currently exist on disk. + public void Prune(IReadOnlyCollection currentFiles) { - var liveSet = liveFiles.ToHashSet(); + var currentSet = currentFiles.ToHashSet(); foreach (var key in _cache.Keys) { - if (!liveSet.Contains(key)) + if (!currentSet.Contains(key)) { _cache.TryRemove(key, out _); } } } -} +} \ No newline at end of file diff --git a/src/AIUsageMonitor.Core/Providers/Claude/SessionMessageAnalysis.cs b/src/AIUsageMonitor.Core/Providers/Claude/SessionMessageAnalysis.cs index 79bf6af..897dd31 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/SessionMessageAnalysis.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/SessionMessageAnalysis.cs @@ -1,10 +1,18 @@ -using System.Text.Json; using AIUsageMonitor.Core.Providers.Claude.Models; +using System.Text.Json; namespace AIUsageMonitor.Core.Providers.Claude; +/// +/// Provides utility methods for analyzing session messages, such as counting the number of tool calls within a message. +/// internal static class SessionMessageAnalysis { + /// + /// Counts the number of tool calls in a given session message. A tool call is identified by a block in the message content that has a "type" property with the value "tool_use". + /// + /// The session message to analyze. + /// The number of tool calls in the session message. public static int CountToolCalls(SessionMessage msg) { if (msg.Message?.Content is not { ValueKind: JsonValueKind.Array } content) @@ -26,4 +34,4 @@ public static int CountToolCalls(SessionMessage msg) return count; } -} +} \ No newline at end of file diff --git a/src/AIUsageMonitor.Core/Providers/Claude/SessionParser.cs b/src/AIUsageMonitor.Core/Providers/Claude/SessionParser.cs index 15831be..1d2c3dd 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/SessionParser.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/SessionParser.cs @@ -1,12 +1,21 @@ -using System.Text.Json; using AIUsageMonitor.Core.Models; using AIUsageMonitor.Core.Providers.Claude.Models; using Microsoft.Extensions.Logging; +using System.Text.Json; namespace AIUsageMonitor.Core.Providers.Claude; +/// +/// Parses session messages from a file and provides methods to analyze the session, such as calculating the total number of tokens used and the duration of the session. +/// +/// The logger instance used for logging warnings and errors during parsing. public sealed class SessionParser(ILogger logger) { + /// + /// Parses a file containing session messages in JSON format and yields each message as a object. + /// + /// The path to the file containing the session messages. + /// An enumerable of objects parsed from the file. public IEnumerable ParseFile(string filePath) { foreach (var line in File.ReadLines(filePath)) @@ -34,6 +43,11 @@ public IEnumerable ParseFile(string filePath) } } + /// + /// Parses a file containing session messages and returns a summary of the session, including the session ID, project, start and end times, duration, message count, total tokens used, and tokens used by model. + /// + /// The path to the file containing the session messages. + /// A object containing the summary of the session, or null if no messages were found. public SessionSummary? ParseSessionSummary(string filePath) { var messages = ParseFile(filePath).ToList(); @@ -61,7 +75,7 @@ public IEnumerable ParseFile(string filePath) var endTime = timestamps[^1]; var assistantMessages = messages - .Where(m => m.Type == "assistant" && m.Message?.Usage is not null) + .Where(m => m is { Type: "assistant", Message.Usage: not null }) .ToList(); long totalTokens = 0; @@ -88,4 +102,4 @@ public IEnumerable ParseFile(string filePath) totalTokens, tokensByModel); } -} +} \ No newline at end of file diff --git a/src/AIUsageMonitor.Core/Providers/Claude/StatsCacheBuilder.cs b/src/AIUsageMonitor.Core/Providers/Claude/StatsCacheBuilder.cs index df88765..971b067 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/StatsCacheBuilder.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/StatsCacheBuilder.cs @@ -7,8 +7,15 @@ namespace AIUsageMonitor.Core.Providers.Claude; /// projects/*.jsonl, for use when Claude Code hasn't written (or has removed) its own /// stats-cache.json. /// +/// The session file cache used to retrieve and prune session files. public sealed class StatsCacheBuilder(SessionFileCache sessionFileCache) { + /// + /// Builds a by aggregating the raw session transcripts under + /// + /// The list of session files to process. + /// An optional progress reporter to report the progress of processing the session files. + /// A object containing the aggregated statistics. public StatsCache Build(IReadOnlyList sessionFiles, IProgress? progress = null) { sessionFileCache.Prune(sessionFiles); @@ -38,7 +45,7 @@ public StatsCache Build(IReadOnlyList sessionFiles, IProgress? prog void ProcessFile(string file) { - List messages; + IReadOnlyList messages; try { messages = sessionFileCache.GetRows(file); diff --git a/src/AIUsageMonitor.Core/Providers/Claude/StatsCacheParser.cs b/src/AIUsageMonitor.Core/Providers/Claude/StatsCacheParser.cs index 44507ea..a044030 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/StatsCacheParser.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/StatsCacheParser.cs @@ -4,12 +4,21 @@ namespace AIUsageMonitor.Core.Providers.Claude; +/// +/// Parses a stats cache file in JSON format and returns a object. +/// public sealed class StatsCacheParser { + /// + /// Parses a stats cache file in JSON format and returns a object. + /// + /// The path to the stats cache file to parse. + /// A object representing the parsed data. + /// Thrown if the file cannot be deserialized into a object. public StatsCache Parse(string filePath) { var json = File.ReadAllText(filePath); return JsonSerializer.Deserialize(json, CoreJsonContext.Default.StatsCache) ?? throw new InvalidOperationException($"Failed to deserialize {filePath}"); } -} +} \ No newline at end of file diff --git a/src/AIUsageMonitor.Core/Providers/IUsageProvider.cs b/src/AIUsageMonitor.Core/Providers/IUsageProvider.cs index 5237e2a..386774d 100644 --- a/src/AIUsageMonitor.Core/Providers/IUsageProvider.cs +++ b/src/AIUsageMonitor.Core/Providers/IUsageProvider.cs @@ -3,13 +3,35 @@ namespace AIUsageMonitor.Core.Providers; +/// +/// Defines an interface for a usage provider that can retrieve hourly activity, recent activity summary, and stats cache data. +/// public interface IUsageProvider { + /// + /// Gets the name of the usage provider. + /// string Name { get; } + /// + /// Retrieves a list of hourly activity data. + /// + /// An optional progress reporter. + /// A list of objects. List GetHourlyActivity(IProgress? progress = null); + /// + /// Retrieves a summary of recent activity within the specified time window. + /// + /// The time window for which to retrieve recent activity. + /// An optional progress reporter. + /// A object. RecentActivitySummary GetRecentActivity(TimeSpan window, IProgress? progress = null); + /// + /// Retrieves a stats cache containing various usage statistics. + /// + /// An optional progress reporter. + /// A object. StatsCache GetStatsCache(IProgress? progress = null); } \ No newline at end of file diff --git a/src/AIUsageMonitor.Core/Services/DataService.cs b/src/AIUsageMonitor.Core/Services/DataService.cs index 2b728bf..686b03f 100644 --- a/src/AIUsageMonitor.Core/Services/DataService.cs +++ b/src/AIUsageMonitor.Core/Services/DataService.cs @@ -1,27 +1,82 @@ -using System.Runtime.Caching; using AIUsageMonitor.Core.Analytics; using AIUsageMonitor.Core.Models; using AIUsageMonitor.Core.Providers; +using AIUsageMonitor.Core.Providers.Claude; using AIUsageMonitor.Core.Providers.Claude.Models; +using Microsoft.Extensions.Logging; +using System.Runtime.Caching; namespace AIUsageMonitor.Core.Services; +/// +/// Represents a service that provides data related to AI usage, including daily summaries, hourly activity, model distribution, period summaries, recent activity, session stats, and stats cache. The service uses an to retrieve data and a to analyze the data. It also caches the stats cache for improved performance and monitors changes to relevant files using instances. +/// public sealed class DataService : IDisposable { + /// + /// The key used to store and retrieve the stats cache from the memory cache. This constant is used to ensure consistent access to the cached stats cache across different methods in the class. + /// private const string StatsCacheKey = "StatsCache"; + /// + /// The usage analyzer used to analyze AI usage data. This field is initialized in the constructor and is used to perform various analyses on the stats cache, such as generating daily summaries, model distributions, period summaries, and session statistics. + /// private readonly UsageAnalyzer _analyzer; + + /// + /// The memory cache used to store the stats cache for improved performance. This field is initialized with a unique name and is used to cache the stats cache retrieved from the usage provider, allowing for faster access to the data without needing to repeatedly read from disk or perform expensive computations. + /// private readonly MemoryCache _cache = new("StatsCacheCache"); + + /// + /// The expiration time for the cached stats cache. This field is initialized with a default value of 10 minutes and is used to determine how long the stats cache should be kept in memory before being considered stale and needing to be refreshed from the usage provider. + /// + private readonly TimeSpan _cacheExpiration = TimeSpan.FromMinutes(10); + + /// + /// The logger instance used for logging warnings and errors. This field is initialized in the constructor and is used to log important information, such as file changes detected by the file system watchers, to help with debugging and monitoring the behavior of the class. + /// + private readonly ILogger _logger; + + /// + /// The usage provider used to retrieve AI usage data. This field is initialized in the constructor and is responsible for providing access to the underlying data sources, such as session transcripts and stats cache files, allowing the to retrieve and analyze usage data as needed. + /// private readonly IUsageProvider _provider; + + /// + /// The session file cache used to cache parsed session transcript rows. This field is initialized in the constructor and is used to store the results of parsing session transcript files, allowing for faster access to the data without needing to repeatedly read and parse the files from disk. + /// + private readonly SessionFileCache _sessionFileCache; + + /// + /// The file system watcher used to monitor changes to session transcript files. This field is initialized in the constructor if the usage provider is a Claude usage provider and the stats cache file does not exist. The watcher listens for changes to JSONL files in the projects directory and clears the cached stats cache when changes are detected, ensuring that the service always has access to up-to-date data. + /// private readonly FileSystemWatcher? _sessionsWatcher; + + /// + /// The file system watcher used to monitor changes to the stats cache file. This field is initialized in the constructor if the usage provider is a Claude usage provider and the stats cache file exists. The watcher listens for changes to the stats-cache.json file and clears the cached stats cache when changes are detected, ensuring that the service always has access to up-to-date data. + /// private readonly FileSystemWatcher? _statsCacheWatcher; - public DataService(IUsageProvider provider, UsageAnalyzer analyzer) + /// + /// Initializes a new instance of the class with the specified usage provider and usage analyzer. The constructor sets up file system watchers to monitor changes to relevant files, such as the stats cache and session transcripts, and clears the cached stats cache when changes are detected. + /// + /// The usage provider used to retrieve AI usage data. + /// The usage analyzer used to analyze AI usage data. + /// The session file cache used to cache parsed session transcript rows. + /// The logger instance used for logging warnings and errors. + public DataService( + IUsageProvider provider, + UsageAnalyzer analyzer, + SessionFileCache sessionFileCache, + ILogger logger) { _provider = provider; _analyzer = analyzer; + _sessionFileCache = sessionFileCache; + _logger = logger; - if (provider is Providers.Claude.ClaudeUsageProvider claudeProvider) + if (provider is ClaudeUsageProvider claudeProvider) { var cacheDir = Path.GetDirectoryName(claudeProvider.StatsCachePath); if (cacheDir is not null && Directory.Exists(cacheDir)) @@ -32,25 +87,27 @@ public DataService(IUsageProvider provider, UsageAnalyzer analyzer) EnableRaisingEvents = true }; _statsCacheWatcher.Changed += (_, _) => _cache.Remove(StatsCacheKey); + _statsCacheWatcher.Error += (_, e) => _logger.LogWarning(e.GetException(), "Error watching stats-cache.json"); } - if (!File.Exists(claudeProvider.StatsCachePath)) + var projectsDir = claudeProvider.ProjectsDir; + if (Directory.Exists(projectsDir)) { - var projectsDir = claudeProvider.ProjectsDir; - if (Directory.Exists(projectsDir)) + _sessionsWatcher = new(projectsDir, "*.jsonl") { - _sessionsWatcher = new(projectsDir, "*.jsonl") - { - NotifyFilter = NotifyFilters.LastWrite, - IncludeSubdirectories = true, - EnableRaisingEvents = true - }; - _sessionsWatcher.Changed += (_, _) => _cache.Remove(StatsCacheKey); - } + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.CreationTime, + IncludeSubdirectories = true, + EnableRaisingEvents = true + }; + _sessionsWatcher.Changed += SessionsWatcher_Changed; + _sessionsWatcher.Error += (_, e) => _logger.LogWarning(e.GetException(), "Error watching session files"); } } } + /// + /// Disposes of the resources used by the instance, including the file system watchers and the memory cache. This method should be called when the service is no longer needed to release unmanaged resources and prevent memory leaks. + /// public void Dispose() { _statsCacheWatcher?.Dispose(); @@ -58,36 +115,75 @@ public void Dispose() _cache.Dispose(); } + /// + /// Gets the daily summary for the specified date, using the cached stats cache if available. If the stats cache is not cached, it retrieves it from the usage provider and caches it for future use. The method also allows for progress reporting during the retrieval of the stats cache. + /// + /// The date for which to retrieve the daily summary. + /// An optional progress reporter to report the progress of the operation. + /// The for the specified date, or if no activity was recorded for that date. public DailySummary? GetDailySummary(DateOnly date, IProgress? progress = null) { return _analyzer.GetDailySummary(GetStatsCache(progress), date); } + /// + /// Gets the hourly activity, using the cached stats cache if available. If the stats cache is not cached, it retrieves it from the usage provider and caches it for future use. The method also allows for progress reporting during the retrieval of the stats cache. + /// + /// An optional progress reporter to report the progress of the operation. + /// A list of representing the hourly activity. public List GetHourlyActivity(IProgress? progress = null) { return _provider.GetHourlyActivity(progress); } + /// + /// Gets the model distribution, using the cached stats cache if available. If the stats cache is not cached, it retrieves it from the usage provider and caches it for future use. The method also allows for progress reporting during the retrieval of the stats cache. + /// + /// An optional progress reporter to report the progress of the operation. + /// A list of representing the model distribution. public List GetModelDistribution(IProgress? progress = null) { return _analyzer.GetModelDistribution(GetStatsCache(progress)); } + /// + /// Gets the period summary for the specified date range, using the cached stats cache if available. If the stats cache is not cached, it retrieves it from the usage provider and caches it for future use. The method also allows for progress reporting during the retrieval of the stats cache. + /// + /// The start date of the period. + /// The end date of the period. + /// An optional progress reporter to report the progress of the operation. + /// The for the specified date range. public PeriodSummary GetPeriodSummary(DateOnly from, DateOnly to, IProgress? progress = null) { return _analyzer.GetPeriodSummary(GetStatsCache(progress), from, to); } + /// + /// Gets the recent activity summary for the specified time window, using the cached stats cache if available. If the stats cache is not cached, it retrieves it from the usage provider and caches it for future use. The method also allows for progress reporting during the retrieval of the stats cache. + /// + /// The time window for which to retrieve recent activity. + /// An optional progress reporter to report the progress of the operation. + /// The for the specified time window. public RecentActivitySummary GetRecentActivity(TimeSpan window, IProgress? progress = null) { return _provider.GetRecentActivity(window, progress); } + /// + /// Gets the session statistics, using the cached stats cache if available. If the stats cache is not cached, it retrieves it from the usage provider and caches it for future use. The method also allows for progress reporting during the retrieval of the stats cache. + /// + /// An optional progress reporter to report the progress of the operation. + /// The representing the session statistics. public SessionStats GetSessionStats(IProgress? progress = null) { return _analyzer.GetSessionStats(GetStatsCache(progress)); } + /// + /// Gets the stats cache, either from the memory cache or by retrieving it from the usage provider if not cached. The method also allows for progress reporting during the retrieval of the stats cache. + /// + /// An optional progress reporter to report the progress of the operation. + /// The representing the stats cache. public StatsCache GetStatsCache(IProgress? progress = null) { if (_cache.Get(StatsCacheKey) is StatsCache cached) @@ -97,7 +193,29 @@ public StatsCache GetStatsCache(IProgress? progress = null) } var stats = _provider.GetStatsCache(progress); - _cache.Set(StatsCacheKey, stats, DateTimeOffset.UtcNow.AddMinutes(1)); + _cache.Set(StatsCacheKey, stats, DateTimeOffset.UtcNow.Add(_cacheExpiration)); return stats; } + + /// + /// Handles changes to session transcript files by updating the session file cache accordingly. When a session transcript file is changed or created, it is added to the cache, and when a session transcript file is deleted, it is removed from the cache. This ensures that the session file cache remains up-to-date with the latest session transcript data. + /// + /// The source of the event. + /// A that contains the event data. + private void SessionsWatcher_Changed(object sender, FileSystemEventArgs e) + { + _logger.LogTrace("Session file change detected: {ChangeType} - {FullPath}", e.ChangeType, e.FullPath); + + switch (e.ChangeType) + { + case WatcherChangeTypes.Changed: + case WatcherChangeTypes.Created: + _sessionFileCache.Set(e.FullPath); + break; + + case WatcherChangeTypes.Deleted: + _sessionFileCache.Remove(e.FullPath); + break; + } + } } \ No newline at end of file diff --git a/src/AIUsageMonitor.Core/Services/ServiceCollectionExtensions.cs b/src/AIUsageMonitor.Core/Services/ServiceCollectionExtensions.cs index a4306c3..a8aa2b9 100644 --- a/src/AIUsageMonitor.Core/Services/ServiceCollectionExtensions.cs +++ b/src/AIUsageMonitor.Core/Services/ServiceCollectionExtensions.cs @@ -5,8 +5,16 @@ namespace AIUsageMonitor.Core.Services; +/// +/// Provides extension methods for registering core services related to Claude usage monitoring in an . +/// public static class ServiceCollectionExtensions { + /// + /// Adds the core services related to Claude usage monitoring to the specified . This includes services for data location, parsing, caching, cost calculation, activity building, and usage analysis. + /// + /// The to which the services will be added. + /// The updated . public static IServiceCollection AddClaudeUsageCore(this IServiceCollection services) { services.AddSingleton(); @@ -22,6 +30,7 @@ public static IServiceCollection AddClaudeUsageCore(this IServiceCollection serv services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); + return services; } -} +} \ No newline at end of file