Track usage independent of conversation save state; fix Analytics toolbar button
Conversation text is still only persisted when explicitly saved (⌘S), but tokens/cost/model/provider are now logged to a new usage_events table for every completed AI response regardless — the Analytics view now reads from this table instead of messages, so it reflects real usage even for conversations that were never saved. Adds a By Provider chart mode alongside Over Time/By Model. Also fixes the Analytics entry point: ToolbarItem(placement: .navigation) silently doesn't render in a plain .sheet-presented NavigationStack on macOS. Moved the button inline next to the segmented picker, matching the codebase's existing convention (e.g. the model-favorites star filter).
This commit is contained in:
@@ -1,6 +1,40 @@
|
|||||||
{
|
{
|
||||||
"sourceLanguage" : "en",
|
"sourceLanguage" : "en",
|
||||||
"strings" : {
|
"strings" : {
|
||||||
|
"By Provider" : {
|
||||||
|
"localizations" : {
|
||||||
|
"da" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Efter udbyder"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Nach Anbieter"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Par fournisseur"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nb" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Etter leverandør"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sv" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Efter leverantör"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Today" : {
|
"Today" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"da" : {
|
"da" : {
|
||||||
|
|||||||
@@ -108,6 +108,47 @@ struct ModelUsageStat: Identifiable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Usage grouped by provider (openrouter/anthropic/openai/ollama/appleOnDevice), from `usage_events`.
|
||||||
|
struct ProviderUsageStat: Identifiable, Sendable {
|
||||||
|
var id: String { provider }
|
||||||
|
let provider: String
|
||||||
|
var questionCount: Int
|
||||||
|
var totalTokens: Int
|
||||||
|
var totalCost: Double
|
||||||
|
var hasCostData: Bool
|
||||||
|
var lastUsed: Date
|
||||||
|
|
||||||
|
nonisolated init(
|
||||||
|
provider: String,
|
||||||
|
questionCount: Int,
|
||||||
|
totalTokens: Int,
|
||||||
|
totalCost: Double,
|
||||||
|
hasCostData: Bool,
|
||||||
|
lastUsed: Date
|
||||||
|
) {
|
||||||
|
self.provider = provider
|
||||||
|
self.questionCount = questionCount
|
||||||
|
self.totalTokens = totalTokens
|
||||||
|
self.totalCost = totalCost
|
||||||
|
self.hasCostData = hasCostData
|
||||||
|
self.lastUsed = lastUsed
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalTokensDisplay: String {
|
||||||
|
if totalTokens >= 1_000_000 {
|
||||||
|
return String(format: "%.1fM", Double(totalTokens) / 1_000_000)
|
||||||
|
} else if totalTokens >= 1000 {
|
||||||
|
return String(format: "%.1fK", Double(totalTokens) / 1000)
|
||||||
|
} else {
|
||||||
|
return "\(totalTokens)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCostDisplay: String {
|
||||||
|
hasCostData ? String(format: "$%.4f", totalCost) : "N/A"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One day's usage totals, for time-series charting in the Analytics view.
|
/// One day's usage totals, for time-series charting in the Analytics view.
|
||||||
struct DailyUsageStat: Identifiable, Sendable {
|
struct DailyUsageStat: Identifiable, Sendable {
|
||||||
var id: Date { day }
|
var id: Date { day }
|
||||||
|
|||||||
@@ -65,6 +65,19 @@ struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
|||||||
var modelId: String?
|
var modelId: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct UsageEventRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||||
|
static let databaseTableName = "usage_events"
|
||||||
|
|
||||||
|
var id: String
|
||||||
|
var timestamp: String
|
||||||
|
var provider: String
|
||||||
|
var modelId: String
|
||||||
|
var promptTokens: Int?
|
||||||
|
var completionTokens: Int?
|
||||||
|
var cost: Double?
|
||||||
|
var conversationId: String?
|
||||||
|
}
|
||||||
|
|
||||||
struct SettingRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
struct SettingRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
|
||||||
static let databaseTableName = "settings"
|
static let databaseTableName = "settings"
|
||||||
|
|
||||||
@@ -404,6 +417,26 @@ final class DatabaseService: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
migrator.registerMigration("v13") { db in
|
||||||
|
// Usage tracking independent of conversation persistence: conversation *text* is only
|
||||||
|
// ever saved when the user explicitly saves (⌘S), but usage (tokens/cost/model/provider)
|
||||||
|
// should be recorded for every completed AI response regardless — one row per finished
|
||||||
|
// generation. conversationId is a best-effort link (set only if the conversation had
|
||||||
|
// already been saved at least once at logging time) and is not a foreign key, since the
|
||||||
|
// referenced conversation may never exist.
|
||||||
|
try db.create(table: "usage_events") { t in
|
||||||
|
t.column("id", .text).primaryKey()
|
||||||
|
t.column("timestamp", .text).notNull()
|
||||||
|
t.column("provider", .text).notNull()
|
||||||
|
t.column("modelId", .text).notNull()
|
||||||
|
t.column("promptTokens", .integer)
|
||||||
|
t.column("completionTokens", .integer)
|
||||||
|
t.column("cost", .double)
|
||||||
|
t.column("conversationId", .text)
|
||||||
|
}
|
||||||
|
try db.create(index: "idx_usage_events_timestamp", on: "usage_events", columns: ["timestamp"])
|
||||||
|
}
|
||||||
|
|
||||||
return migrator
|
return migrator
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -959,6 +992,204 @@ final class DatabaseService: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Usage Events (save-independent usage tracking)
|
||||||
|
|
||||||
|
/// Records one completed AI generation's usage — independent of whether the conversation it
|
||||||
|
/// belongs to is ever explicitly saved. `conversationId` is a best-effort link, set only if the
|
||||||
|
/// conversation had already been saved at least once when this was logged.
|
||||||
|
nonisolated func logUsageEvent(
|
||||||
|
provider: String,
|
||||||
|
modelId: String,
|
||||||
|
promptTokens: Int?,
|
||||||
|
completionTokens: Int?,
|
||||||
|
cost: Double?,
|
||||||
|
conversationId: UUID?
|
||||||
|
) throws {
|
||||||
|
let record = UsageEventRecord(
|
||||||
|
id: UUID().uuidString,
|
||||||
|
timestamp: Self.isoString(from: Date()),
|
||||||
|
provider: provider,
|
||||||
|
modelId: modelId,
|
||||||
|
promptTokens: promptTokens,
|
||||||
|
completionTokens: completionTokens,
|
||||||
|
cost: cost,
|
||||||
|
conversationId: conversationId?.uuidString
|
||||||
|
)
|
||||||
|
try dbQueue.write { db in
|
||||||
|
try record.insert(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overall usage-event totals (each row = one completed generation = one "question").
|
||||||
|
nonisolated func getUsageEventTotals(from: Date? = nil, to: Date? = nil) throws -> UsageStats {
|
||||||
|
try dbQueue.read { db in
|
||||||
|
let (whereSQL, arguments) = Self.timestampRangeClause(from: from, to: to)
|
||||||
|
let sql = """
|
||||||
|
SELECT COUNT(*) AS cnt,
|
||||||
|
COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens,
|
||||||
|
COALESCE(SUM(cost), 0) AS cost,
|
||||||
|
COUNT(cost) AS costCount,
|
||||||
|
MIN(timestamp) AS minTs,
|
||||||
|
MAX(timestamp) AS maxTs
|
||||||
|
FROM usage_events
|
||||||
|
\(whereSQL)
|
||||||
|
"""
|
||||||
|
guard let row = try Row.fetchOne(db, sql: sql, arguments: StatementArguments(arguments))
|
||||||
|
else {
|
||||||
|
return UsageStats()
|
||||||
|
}
|
||||||
|
|
||||||
|
let costCount: Int = row["costCount"]
|
||||||
|
let cnt: Int = row["cnt"]
|
||||||
|
let minTs: String? = row["minTs"]
|
||||||
|
let maxTs: String? = row["maxTs"]
|
||||||
|
|
||||||
|
return UsageStats(
|
||||||
|
totalMessages: cnt,
|
||||||
|
totalQuestions: cnt,
|
||||||
|
totalTokens: row["tokens"],
|
||||||
|
totalCost: row["cost"],
|
||||||
|
hasCostData: costCount > 0,
|
||||||
|
firstMessageDate: minTs.flatMap { Self.isoDate(from: $0) },
|
||||||
|
lastMessageDate: maxTs.flatMap { Self.isoDate(from: $0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Usage events grouped by model.
|
||||||
|
nonisolated func getUsageEventsByModel(from: Date? = nil, to: Date? = nil) throws -> [ModelUsageStat] {
|
||||||
|
try dbQueue.read { db in
|
||||||
|
let (whereSQL, arguments) = Self.timestampRangeClause(from: from, to: to)
|
||||||
|
let sql = """
|
||||||
|
SELECT modelId,
|
||||||
|
COUNT(*) AS cnt,
|
||||||
|
COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens,
|
||||||
|
COALESCE(SUM(cost), 0) AS cost,
|
||||||
|
COUNT(cost) AS costCount,
|
||||||
|
MAX(timestamp) AS lastUsed
|
||||||
|
FROM usage_events
|
||||||
|
\(whereSQL)
|
||||||
|
GROUP BY modelId
|
||||||
|
"""
|
||||||
|
let rows = try Row.fetchAll(db, sql: sql, arguments: StatementArguments(arguments))
|
||||||
|
|
||||||
|
let stats: [ModelUsageStat] = rows.compactMap { row in
|
||||||
|
guard let modelId: String = row["modelId"],
|
||||||
|
let lastUsedString: String = row["lastUsed"],
|
||||||
|
let lastUsed = Self.isoDate(from: lastUsedString)
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
let costCount: Int = row["costCount"]
|
||||||
|
let cnt: Int = row["cnt"]
|
||||||
|
return ModelUsageStat(
|
||||||
|
modelId: modelId,
|
||||||
|
messageCount: cnt,
|
||||||
|
questionCount: cnt,
|
||||||
|
totalTokens: row["tokens"],
|
||||||
|
totalCost: row["cost"],
|
||||||
|
hasCostData: costCount > 0,
|
||||||
|
lastUsed: lastUsed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats.sorted { lhs, rhs in
|
||||||
|
if lhs.hasCostData || rhs.hasCostData {
|
||||||
|
return lhs.totalCost > rhs.totalCost
|
||||||
|
}
|
||||||
|
return lhs.totalTokens > rhs.totalTokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Usage events grouped by provider.
|
||||||
|
nonisolated func getUsageEventsByProvider(from: Date? = nil, to: Date? = nil) throws -> [ProviderUsageStat] {
|
||||||
|
try dbQueue.read { db in
|
||||||
|
let (whereSQL, arguments) = Self.timestampRangeClause(from: from, to: to)
|
||||||
|
let sql = """
|
||||||
|
SELECT provider,
|
||||||
|
COUNT(*) AS cnt,
|
||||||
|
COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens,
|
||||||
|
COALESCE(SUM(cost), 0) AS cost,
|
||||||
|
COUNT(cost) AS costCount,
|
||||||
|
MAX(timestamp) AS lastUsed
|
||||||
|
FROM usage_events
|
||||||
|
\(whereSQL)
|
||||||
|
GROUP BY provider
|
||||||
|
"""
|
||||||
|
let rows = try Row.fetchAll(db, sql: sql, arguments: StatementArguments(arguments))
|
||||||
|
|
||||||
|
let stats: [ProviderUsageStat] = rows.compactMap { row in
|
||||||
|
guard let provider: String = row["provider"],
|
||||||
|
let lastUsedString: String = row["lastUsed"],
|
||||||
|
let lastUsed = Self.isoDate(from: lastUsedString)
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
let costCount: Int = row["costCount"]
|
||||||
|
return ProviderUsageStat(
|
||||||
|
provider: provider,
|
||||||
|
questionCount: row["cnt"],
|
||||||
|
totalTokens: row["tokens"],
|
||||||
|
totalCost: row["cost"],
|
||||||
|
hasCostData: costCount > 0,
|
||||||
|
lastUsed: lastUsed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats.sorted { lhs, rhs in
|
||||||
|
if lhs.hasCostData || rhs.hasCostData {
|
||||||
|
return lhs.totalCost > rhs.totalCost
|
||||||
|
}
|
||||||
|
return lhs.totalTokens > rhs.totalTokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Usage events bucketed by calendar day (GMT, matching `isoStyle`), for the Analytics view.
|
||||||
|
nonisolated func getDailyUsageEvents(from: Date, to: Date) throws -> [DailyUsageStat] {
|
||||||
|
try dbQueue.read { db in
|
||||||
|
let sql = """
|
||||||
|
SELECT substr(timestamp, 1, 10) AS day,
|
||||||
|
COUNT(*) AS cnt,
|
||||||
|
COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens,
|
||||||
|
COALESCE(SUM(cost), 0) AS cost,
|
||||||
|
COUNT(cost) AS costCount
|
||||||
|
FROM usage_events
|
||||||
|
WHERE timestamp >= ? AND timestamp <= ?
|
||||||
|
GROUP BY day
|
||||||
|
ORDER BY day
|
||||||
|
"""
|
||||||
|
let rows = try Row.fetchAll(
|
||||||
|
db, sql: sql,
|
||||||
|
arguments: [Self.isoString(from: from), Self.isoString(from: to)]
|
||||||
|
)
|
||||||
|
|
||||||
|
var gmtCalendar = Calendar(identifier: .gregorian)
|
||||||
|
gmtCalendar.timeZone = TimeZone(identifier: "GMT")!
|
||||||
|
|
||||||
|
return rows.compactMap { row -> DailyUsageStat? in
|
||||||
|
guard let dayString: String = row["day"] else { return nil }
|
||||||
|
let parts = dayString.split(separator: "-").compactMap { Int($0) }
|
||||||
|
guard parts.count == 3 else { return nil }
|
||||||
|
var components = DateComponents()
|
||||||
|
components.year = parts[0]
|
||||||
|
components.month = parts[1]
|
||||||
|
components.day = parts[2]
|
||||||
|
guard let day = gmtCalendar.date(from: components) else { return nil }
|
||||||
|
|
||||||
|
let costCount: Int = row["costCount"]
|
||||||
|
let cnt: Int = row["cnt"]
|
||||||
|
return DailyUsageStat(
|
||||||
|
day: day,
|
||||||
|
messageCount: cnt,
|
||||||
|
questionCount: cnt,
|
||||||
|
totalTokens: row["tokens"],
|
||||||
|
totalCost: row["cost"],
|
||||||
|
hasCostData: costCount > 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated func getUsageByConversation(limit: Int = 20) throws -> [ConversationUsageStat] {
|
nonisolated func getUsageByConversation(limit: Int = 20) throws -> [ConversationUsageStat] {
|
||||||
try dbQueue.read { db in
|
try dbQueue.read { db in
|
||||||
let rows = try Row.fetchAll(db, sql: """
|
let rows = try Row.fetchAll(db, sql: """
|
||||||
|
|||||||
@@ -1153,6 +1153,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
let cost = Self.resolveCost(usage: usage, pricing: model.pricing)
|
let cost = Self.resolveCost(usage: usage, pricing: model.pricing)
|
||||||
messages[index].cost = cost
|
messages[index].cost = cost
|
||||||
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
||||||
|
logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1216,6 +1217,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
let cost = Self.resolveCost(usage: usage, pricing: model.pricing)
|
let cost = Self.resolveCost(usage: usage, pricing: model.pricing)
|
||||||
messages[index].cost = cost
|
messages[index].cost = cost
|
||||||
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
||||||
|
logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1572,6 +1574,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
let cost = usage.rawCostUSD
|
let cost = usage.rawCostUSD
|
||||||
messages[index].cost = cost
|
messages[index].cost = cost
|
||||||
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost)
|
||||||
|
logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1863,6 +1866,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
outputTokens: usage.completionTokens,
|
outputTokens: usage.completionTokens,
|
||||||
cost: cost
|
cost: cost
|
||||||
)
|
)
|
||||||
|
logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let assistantMessage = Message(
|
let assistantMessage = Message(
|
||||||
@@ -1890,6 +1894,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
outputTokens: usage.completionTokens,
|
outputTokens: usage.completionTokens,
|
||||||
cost: cost
|
cost: cost
|
||||||
)
|
)
|
||||||
|
logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2610,6 +2615,24 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
return calculateCost(usage: usage, pricing: pricing)
|
return calculateCost(usage: usage, pricing: pricing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Records usage independent of whether this conversation ever gets explicitly saved (⌘S) —
|
||||||
|
/// conversation *text* is opt-in, but tokens/cost/model/provider are tracked for every
|
||||||
|
/// completed generation regardless, so the Analytics view reflects real usage either way.
|
||||||
|
private func logUsageEvent(modelId: String, promptTokens: Int?, completionTokens: Int?, cost: Double?) {
|
||||||
|
do {
|
||||||
|
try DatabaseService.shared.logUsageEvent(
|
||||||
|
provider: currentProvider.rawValue,
|
||||||
|
modelId: modelId,
|
||||||
|
promptTokens: promptTokens,
|
||||||
|
completionTokens: completionTokens,
|
||||||
|
cost: cost,
|
||||||
|
conversationId: currentConversationId
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
Log.db.error("Failed to log usage event: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Summarize a chunk of messages into a concise summary
|
/// Summarize a chunk of messages into a concise summary
|
||||||
private func summarizeMessageChunk(_ messages: [Message]) async -> String? {
|
private func summarizeMessageChunk(_ messages: [Message]) async -> String? {
|
||||||
guard let provider = providerRegistry.getProvider(for: currentProvider),
|
guard let provider = providerRegistry.getProvider(for: currentProvider),
|
||||||
|
|||||||
@@ -43,12 +43,21 @@ struct StatsView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Picker("", selection: $selectedTab) {
|
HStack(spacing: 8) {
|
||||||
Text("Session").tag(StatsTab.session)
|
Picker("", selection: $selectedTab) {
|
||||||
Text("All-Time").tag(StatsTab.allTime)
|
Text("Session").tag(StatsTab.session)
|
||||||
|
Text("All-Time").tag(StatsTab.allTime)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.labelsHidden()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showAnalytics = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chart.bar.xaxis")
|
||||||
|
}
|
||||||
|
.help("View detailed usage analytics")
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.labelsHidden()
|
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
.padding(.bottom, 4)
|
.padding(.bottom, 4)
|
||||||
@@ -64,14 +73,6 @@ struct StatsView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("Statistics")
|
.navigationTitle("Statistics")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigation) {
|
|
||||||
Button {
|
|
||||||
showAnalytics = true
|
|
||||||
} label: {
|
|
||||||
Label("Analytics", systemImage: "chart.bar.xaxis")
|
|
||||||
}
|
|
||||||
.help("View detailed usage analytics")
|
|
||||||
}
|
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("Done") {
|
Button("Done") {
|
||||||
dismiss()
|
dismiss()
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ private enum AnalyticsMetric: String, CaseIterable, Identifiable {
|
|||||||
private enum AnalyticsChartMode: String, CaseIterable, Identifiable {
|
private enum AnalyticsChartMode: String, CaseIterable, Identifiable {
|
||||||
case overTime
|
case overTime
|
||||||
case byModel
|
case byModel
|
||||||
|
case byProvider
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
}
|
}
|
||||||
@@ -64,6 +65,7 @@ struct UsageAnalyticsView: View {
|
|||||||
|
|
||||||
@State private var overallStats = UsageStats()
|
@State private var overallStats = UsageStats()
|
||||||
@State private var modelStats: [ModelUsageStat] = []
|
@State private var modelStats: [ModelUsageStat] = []
|
||||||
|
@State private var providerStats: [ProviderUsageStat] = []
|
||||||
@State private var dailyStats: [DailyUsageStat] = []
|
@State private var dailyStats: [DailyUsageStat] = []
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -85,6 +87,10 @@ struct UsageAnalyticsView: View {
|
|||||||
if chartMode == .byModel && !modelStats.isEmpty {
|
if chartMode == .byModel && !modelStats.isEmpty {
|
||||||
modelBreakdownList
|
modelBreakdownList
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if chartMode == .byProvider && !providerStats.isEmpty {
|
||||||
|
providerBreakdownList
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
}
|
}
|
||||||
@@ -156,10 +162,11 @@ struct UsageAnalyticsView: View {
|
|||||||
Picker("", selection: $chartMode) {
|
Picker("", selection: $chartMode) {
|
||||||
Text("Over Time").tag(AnalyticsChartMode.overTime)
|
Text("Over Time").tag(AnalyticsChartMode.overTime)
|
||||||
Text("By Model").tag(AnalyticsChartMode.byModel)
|
Text("By Model").tag(AnalyticsChartMode.byModel)
|
||||||
|
Text("By Provider").tag(AnalyticsChartMode.byProvider)
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.frame(maxWidth: 280)
|
.frame(maxWidth: 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -169,6 +176,8 @@ struct UsageAnalyticsView: View {
|
|||||||
timeSeriesChart
|
timeSeriesChart
|
||||||
case .byModel:
|
case .byModel:
|
||||||
byModelChart
|
byModelChart
|
||||||
|
case .byProvider:
|
||||||
|
byProviderChart
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +218,26 @@ struct UsageAnalyticsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var byProviderChart: some View {
|
||||||
|
Group {
|
||||||
|
if providerStats.isEmpty {
|
||||||
|
emptyChartPlaceholder
|
||||||
|
} else {
|
||||||
|
Chart(providerStats) { stat in
|
||||||
|
SectorMark(
|
||||||
|
angle: .value("Value", providerValue(for: stat)),
|
||||||
|
innerRadius: .ratio(0.55),
|
||||||
|
angularInset: 1.5
|
||||||
|
)
|
||||||
|
.foregroundStyle(by: .value("Provider", providerDisplayName(stat.provider)))
|
||||||
|
.cornerRadius(4)
|
||||||
|
}
|
||||||
|
.frame(height: 240)
|
||||||
|
.chartLegend(position: .bottom, alignment: .center, spacing: 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var emptyChartPlaceholder: some View {
|
private var emptyChartPlaceholder: some View {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
Image(systemName: "chart.bar.xaxis")
|
Image(systemName: "chart.bar.xaxis")
|
||||||
@@ -252,6 +281,40 @@ struct UsageAnalyticsView: View {
|
|||||||
.cornerRadius(10)
|
.cornerRadius(10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - By Provider List
|
||||||
|
|
||||||
|
private var providerBreakdownList: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("By Provider")
|
||||||
|
.font(.headline)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
|
ForEach(providerStats) { stat in
|
||||||
|
HStack {
|
||||||
|
Text(providerDisplayName(stat.provider))
|
||||||
|
.font(.body)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
Text(providerValueDisplay(for: stat))
|
||||||
|
.font(.body.monospacedDigit())
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
|
||||||
|
if stat.id != providerStats.last?.id {
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color.confabSurface.opacity(0.5))
|
||||||
|
.cornerRadius(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func providerDisplayName(_ rawProvider: String) -> String {
|
||||||
|
Settings.Provider(rawValue: rawProvider)?.displayName ?? rawProvider
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Value helpers
|
// MARK: - Value helpers
|
||||||
|
|
||||||
private func dailyValue(for stat: DailyUsageStat) -> Double {
|
private func dailyValue(for stat: DailyUsageStat) -> Double {
|
||||||
@@ -278,15 +341,34 @@ struct UsageAnalyticsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func providerValue(for stat: ProviderUsageStat) -> Double {
|
||||||
|
switch metric {
|
||||||
|
case .tokens: return Double(stat.totalTokens)
|
||||||
|
case .questions: return Double(stat.questionCount)
|
||||||
|
case .cost: return stat.totalCost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func providerValueDisplay(for stat: ProviderUsageStat) -> String {
|
||||||
|
switch metric {
|
||||||
|
case .tokens: return stat.totalTokensDisplay
|
||||||
|
case .questions: return "\(stat.questionCount)"
|
||||||
|
case .cost: return stat.totalCostDisplay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Data Loading
|
// MARK: - Data Loading
|
||||||
|
|
||||||
|
/// Sourced from `usage_events`, not `messages` — reflects usage for every completed
|
||||||
|
/// generation regardless of whether the conversation it belongs to was ever saved.
|
||||||
private func loadStats() {
|
private func loadStats() {
|
||||||
let range = timeframe.dateRange()
|
let range = timeframe.dateRange()
|
||||||
overallStats = (try? DatabaseService.shared.getOverallUsageStats(from: range.start, to: range.end)) ?? UsageStats()
|
overallStats = (try? DatabaseService.shared.getUsageEventTotals(from: range.start, to: range.end)) ?? UsageStats()
|
||||||
modelStats = (try? DatabaseService.shared.getUsageByModel(from: range.start, to: range.end)) ?? []
|
modelStats = (try? DatabaseService.shared.getUsageEventsByModel(from: range.start, to: range.end)) ?? []
|
||||||
|
providerStats = (try? DatabaseService.shared.getUsageEventsByProvider(from: range.start, to: range.end)) ?? []
|
||||||
|
|
||||||
let dailyFrom = range.start ?? overallStats.firstMessageDate ?? range.end
|
let dailyFrom = range.start ?? overallStats.firstMessageDate ?? range.end
|
||||||
dailyStats = (try? DatabaseService.shared.getDailyUsage(from: dailyFrom, to: range.end)) ?? []
|
dailyStats = (try? DatabaseService.shared.getDailyUsageEvents(from: dailyFrom, to: range.end)) ?? []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ struct DatabaseServiceMigrationTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("v13 adds the usage_events table with the expected columns")
|
||||||
|
func v13AddsUsageEventsTable() {
|
||||||
|
let db = DatabaseService.makeInMemory()
|
||||||
|
#expect(db.tableExists("usage_events"))
|
||||||
|
let columns = Set(db.columnNames(in: "usage_events"))
|
||||||
|
let expected: Set<String> = [
|
||||||
|
"id", "timestamp", "provider", "modelId", "promptTokens", "completionTokens", "cost", "conversationId",
|
||||||
|
]
|
||||||
|
#expect(expected.isSubset(of: columns))
|
||||||
|
}
|
||||||
|
|
||||||
@Test("v4 adds modelId to messages and primaryModel to conversations")
|
@Test("v4 adds modelId to messages and primaryModel to conversations")
|
||||||
func v4AddsModelColumns() {
|
func v4AddsModelColumns() {
|
||||||
let db = DatabaseService.makeInMemory()
|
let db = DatabaseService.makeInMemory()
|
||||||
@@ -299,6 +310,80 @@ struct DatabaseServiceUsageStatsTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suite("DatabaseService usage events, against a throwaway in-memory queue")
|
||||||
|
struct DatabaseServiceUsageEventsTests {
|
||||||
|
|
||||||
|
@Test("Logged usage events are readable regardless of any saved conversation")
|
||||||
|
func logUsageEventPersistsWithNoConversation() throws {
|
||||||
|
let db = DatabaseService.makeInMemory()
|
||||||
|
try db.logUsageEvent(
|
||||||
|
provider: "openrouter", modelId: "gpt-5", promptTokens: 100, completionTokens: 50,
|
||||||
|
cost: 0.02, conversationId: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
let totals = try db.getUsageEventTotals()
|
||||||
|
#expect(totals.totalQuestions == 1)
|
||||||
|
#expect(totals.totalTokens == 150)
|
||||||
|
#expect(totals.hasCostData == true)
|
||||||
|
#expect(abs(totals.totalCost - 0.02) < 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Usage events respect an optional date range")
|
||||||
|
func usageEventTotalsRespectsDateRange() throws {
|
||||||
|
let db = DatabaseService.makeInMemory()
|
||||||
|
let now = Date()
|
||||||
|
let old = now.addingTimeInterval(-10 * 24 * 3600)
|
||||||
|
|
||||||
|
try db.logUsageEvent(provider: "openrouter", modelId: "gpt-5", promptTokens: 10, completionTokens: 10, cost: 0.01, conversationId: nil)
|
||||||
|
// Directly insert an old-dated row via the private record path isn't exposed, so approximate
|
||||||
|
// "old" coverage by asserting the fresh row is excluded when the range is set entirely in the past.
|
||||||
|
let rangeExcludingNow = try db.getUsageEventTotals(from: old, to: old.addingTimeInterval(3600))
|
||||||
|
#expect(rangeExcludingNow.totalQuestions == 0)
|
||||||
|
|
||||||
|
let rangeIncludingNow = try db.getUsageEventTotals(from: now.addingTimeInterval(-3600), to: now.addingTimeInterval(3600))
|
||||||
|
#expect(rangeIncludingNow.totalQuestions == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Usage events group by model and by provider independently")
|
||||||
|
func usageEventsGroupByModelAndProvider() throws {
|
||||||
|
let db = DatabaseService.makeInMemory()
|
||||||
|
try db.logUsageEvent(provider: "openrouter", modelId: "gpt-5", promptTokens: 10, completionTokens: 10, cost: 0.01, conversationId: nil)
|
||||||
|
try db.logUsageEvent(provider: "openrouter", modelId: "claude-sonnet", promptTokens: 5, completionTokens: 5, cost: 0.02, conversationId: nil)
|
||||||
|
try db.logUsageEvent(provider: "anthropic", modelId: "claude-sonnet", promptTokens: 20, completionTokens: 20, cost: 0.03, conversationId: nil)
|
||||||
|
|
||||||
|
let byModel = try db.getUsageEventsByModel()
|
||||||
|
#expect(byModel.count == 2)
|
||||||
|
let sonnet = byModel.first { $0.modelId == "claude-sonnet" }
|
||||||
|
#expect(sonnet?.questionCount == 2)
|
||||||
|
#expect(sonnet?.totalTokens == 50)
|
||||||
|
|
||||||
|
let byProvider = try db.getUsageEventsByProvider()
|
||||||
|
#expect(byProvider.count == 2)
|
||||||
|
let openrouter = byProvider.first { $0.provider == "openrouter" }
|
||||||
|
#expect(openrouter?.questionCount == 2)
|
||||||
|
#expect(openrouter?.totalTokens == 30)
|
||||||
|
let anthropic = byProvider.first { $0.provider == "anthropic" }
|
||||||
|
#expect(anthropic?.questionCount == 1)
|
||||||
|
#expect(anthropic?.totalTokens == 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Daily usage events bucket by calendar day (GMT, matching stored timestamps)")
|
||||||
|
func dailyUsageEventsBucketByDay() throws {
|
||||||
|
let db = DatabaseService.makeInMemory()
|
||||||
|
try db.logUsageEvent(provider: "openrouter", modelId: "gpt-5", promptTokens: 10, completionTokens: 10, cost: 0.01, conversationId: nil)
|
||||||
|
|
||||||
|
var gmtCalendar = Calendar(identifier: .gregorian)
|
||||||
|
gmtCalendar.timeZone = TimeZone(identifier: "GMT")!
|
||||||
|
let farPast = gmtCalendar.date(from: DateComponents(year: 2020, month: 1, day: 1))!
|
||||||
|
let farFuture = gmtCalendar.date(from: DateComponents(year: 2030, month: 1, day: 1))!
|
||||||
|
|
||||||
|
let daily = try db.getDailyUsageEvents(from: farPast, to: farFuture)
|
||||||
|
#expect(daily.count == 1)
|
||||||
|
#expect(daily.first?.totalTokens == 20)
|
||||||
|
#expect(daily.first?.questionCount == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Suite("DatabaseService folders, against a throwaway in-memory queue")
|
@Suite("DatabaseService folders, against a throwaway in-memory queue")
|
||||||
struct DatabaseServiceFolderTests {
|
struct DatabaseServiceFolderTests {
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user