diff --git a/apple/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsEnvironmentProvider.swift b/apple/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsEnvironmentProvider.swift index 7602cbe4dc..7a7d273aaf 100644 --- a/apple/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsEnvironmentProvider.swift +++ b/apple/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsEnvironmentProvider.swift @@ -63,6 +63,14 @@ public protocol AnalyticsEnvironmentProvider: AnyObject { /// Seconds between scheduled update checks, or nil when checks are off or unavailable. var updateCheckInterval: Int? { get } + + /// The kinds of AI provider configured while AI is on (e.g. ["claude", "ollama"]), or nil when + /// AI is off or has none. Kinds only: never a name, model, endpoint or key. + var aiProviders: [String]? { get } + + /// When the user last sent a message in the AI chat on this device, or nil if never. + /// Overwritten on every message, unlike the activation timestamps above. + var aiChatSentAt: Date? { get } } public extension AnalyticsEnvironmentProvider { @@ -71,4 +79,6 @@ public extension AnalyticsEnvironmentProvider { var firstQueryExecutedAt: Date? { nil } var updateInstallMode: String? { nil } var updateCheckInterval: Int? { nil } + var aiProviders: [String]? { nil } + var aiChatSentAt: Date? { nil } } diff --git a/apple/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsHeartbeatService.swift b/apple/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsHeartbeatService.swift index 0b00324355..fd91b40e89 100644 --- a/apple/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsHeartbeatService.swift +++ b/apple/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsHeartbeatService.swift @@ -137,7 +137,9 @@ public final class AnalyticsHeartbeatService { connectionSucceededAt: provider.connectionSucceededAt, firstQueryExecutedAt: provider.firstQueryExecutedAt, updateInstallMode: provider.updateInstallMode, - updateCheckInterval: provider.updateCheckInterval + updateCheckInterval: provider.updateCheckInterval, + aiProviders: provider.aiProviders, + aiChatSentAt: provider.aiChatSentAt ) } diff --git a/apple/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsPayload.swift b/apple/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsPayload.swift index d3b782adcb..b1e23271dc 100644 --- a/apple/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsPayload.swift +++ b/apple/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsPayload.swift @@ -22,6 +22,8 @@ public struct AnalyticsPayload: Encodable, Sendable { public let firstQueryExecutedAt: Date? public let updateInstallMode: String? public let updateCheckInterval: Int? + public let aiProviders: [String]? + public let aiChatSentAt: Date? public init( machineId: String, @@ -37,7 +39,9 @@ public struct AnalyticsPayload: Encodable, Sendable { connectionSucceededAt: Date? = nil, firstQueryExecutedAt: Date? = nil, updateInstallMode: String? = nil, - updateCheckInterval: Int? = nil + updateCheckInterval: Int? = nil, + aiProviders: [String]? = nil, + aiChatSentAt: Date? = nil ) { self.machineId = machineId self.platform = platform @@ -53,5 +57,7 @@ public struct AnalyticsPayload: Encodable, Sendable { self.firstQueryExecutedAt = firstQueryExecutedAt self.updateInstallMode = updateInstallMode self.updateCheckInterval = updateCheckInterval + self.aiProviders = aiProviders + self.aiChatSentAt = aiChatSentAt } } diff --git a/apple/Packages/TableProCore/Tests/TableProAnalyticsTests/AnalyticsHeartbeatPayloadTests.swift b/apple/Packages/TableProCore/Tests/TableProAnalyticsTests/AnalyticsHeartbeatPayloadTests.swift index fbcd2374bf..4b2c9bf778 100644 --- a/apple/Packages/TableProCore/Tests/TableProAnalyticsTests/AnalyticsHeartbeatPayloadTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProAnalyticsTests/AnalyticsHeartbeatPayloadTests.swift @@ -213,3 +213,69 @@ struct AnalyticsUpdateFieldTests { #expect(provider.updateCheckInterval == nil) } } + +@MainActor +struct AnalyticsAIUsageFieldTests { + private final class StubProvider: AnalyticsEnvironmentProvider { + var machineId = "machine-1" + var appVersion: String? = "1.0.0" + var osVersion = "macOS 15.1.0" + var architecture = "arm64" + var platform = "macos" + var locale = "en" + var isAnalyticsEnabled = true + var hasLicense = false + var activeDatabaseTypes: [String] = [] + var activeConnectionCount = 0 + var hmacSecret: String? + } + + private func encoded(providers: [String]?, sentAt: Date?) throws -> [String: Any] { + let payload = AnalyticsPayload( + machineId: "machine-1", + platform: "macos", + appVersion: "1.0.0", + osVersion: "macOS 15.1.0", + architecture: "arm64", + locale: "en", + databaseTypes: nil, + connectionCount: 0, + hasLicense: false, + aiProviders: providers, + aiChatSentAt: sentAt + ) + let service = AnalyticsHeartbeatService( + provider: StubProvider(), + heartbeatInterval: 60, + initialDelay: 60, + cooldownInterval: 0 + ) + let data = try service.makeEncodedBodyForTesting(payload: payload) + return try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + @Test("Encodes the AI usage fields in snake_case") + func encodesAIUsageFields() throws { + let sentAt = Date(timeIntervalSince1970: 1_700_000_000) + let body = try encoded(providers: ["claude", "ollama"], sentAt: sentAt) + + #expect(body["ai_providers"] as? [String] == ["claude", "ollama"]) + #expect(body["ai_chat_sent_at"] as? String == ISO8601DateFormatter().string(from: sentAt)) + } + + @Test("Omits both when AI is off and the chat was never used") + func omitsAIUsageFieldsWhenAbsent() throws { + let body = try encoded(providers: nil, sentAt: nil) + + #expect(body["ai_providers"] == nil) + #expect(body["ai_chat_sent_at"] == nil) + } + + @Test("A provider that implements neither field sends neither") + func defaultsAreNil() { + let provider = StubProvider() + + #expect(provider.aiProviders == nil) + #expect(provider.aiChatSentAt == nil) + } +} diff --git a/apple/TablePro/Core/Services/Infrastructure/MacAnalyticsProvider.swift b/apple/TablePro/Core/Services/Infrastructure/MacAnalyticsProvider.swift index 5801d13f9d..3b4db31e82 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MacAnalyticsProvider.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MacAnalyticsProvider.swift @@ -14,6 +14,7 @@ final class MacAnalyticsProvider: AnalyticsEnvironmentProvider { static let connectionAttemptedAt = "com.TablePro.analytics.connectionAttemptedAt" static let connectionSucceededAt = "com.TablePro.analytics.connectionSucceededAt" static let firstQueryExecutedAt = "com.TablePro.analytics.firstQueryExecutedAt" + static let aiChatSentAt = "com.TablePro.analytics.aiChatSentAt" } init(defaults: UserDefaults = AppStorageEnvironment.shared.defaults) { @@ -96,6 +97,24 @@ final class MacAnalyticsProvider: AnalyticsEnvironmentProvider { writeOnceDate(Keys.firstQueryExecutedAt, label: "firstQueryExecutedAt") } + var aiProviders: [String]? { + Self.aiProviderKinds(AppSettingsStorage.shared.loadAI()) + } + + nonisolated static func aiProviderKinds(_ ai: AISettings) -> [String]? { + guard ai.enabled else { return nil } + let kinds = Set(ai.providers.map(\.type.rawValue)).sorted() + return kinds.isEmpty ? nil : kinds + } + + var aiChatSentAt: Date? { + defaults.object(forKey: Keys.aiChatSentAt) as? Date + } + + func markAIChatSent() { + defaults.set(Date(), forKey: Keys.aiChatSentAt) + } + private func writeOnceDate(_ key: String, label: String) { guard defaults.object(forKey: key) == nil else { return } defaults.set(Date(), forKey: key) diff --git a/apple/TablePro/ViewModels/AIChatViewModel+Streaming.swift b/apple/TablePro/ViewModels/AIChatViewModel+Streaming.swift index 25000d75a0..b2175f624e 100644 --- a/apple/TablePro/ViewModels/AIChatViewModel+Streaming.swift +++ b/apple/TablePro/ViewModels/AIChatViewModel+Streaming.swift @@ -75,6 +75,8 @@ extension AIChatViewModel { } } + MacAnalyticsProvider.shared.markAIChatSent() + guard let resolved else { beginOnDeviceTurn() return diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+History.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+History.swift index 5415915038..8caead141f 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+History.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+History.swift @@ -10,6 +10,9 @@ extension MainContentCoordinator { if #available(macOS 14.0, *) { FeatureTipSignals.editorQueryRan() } + if request.wasSuccessful { + MacAnalyticsProvider.shared.markFirstQueryExecuted() + } } let recorder = services.queryHistoryManager return Task(priority: .utility) { diff --git a/apple/TableProTests/Services/MacAnalyticsProviderTests.swift b/apple/TableProTests/Services/MacAnalyticsProviderTests.swift index 1c733da737..90348e2ca4 100644 --- a/apple/TableProTests/Services/MacAnalyticsProviderTests.swift +++ b/apple/TableProTests/Services/MacAnalyticsProviderTests.swift @@ -61,6 +61,35 @@ struct MacAnalyticsProviderTests { #expect(provider.firstQueryExecutedAt == first) } + @Test("markAIChatSent keeps the latest message, overwriting the one before") + func aiChatSentIsOverwritten() throws { + #expect(provider.aiChatSentAt == nil) + let earlier = seed(MacAnalyticsProvider.Keys.aiChatSentAt) + + provider.markAIChatSent() + + let latest = try #require(provider.aiChatSentAt) + #expect(latest > earlier) + } + + @Test("Reports each configured provider kind once, sorted, and only while AI is on") + func aiProviderKinds() { + var ai = AISettings.default + ai.providers = [ + AIProviderConfig(name: "Local", type: .ollama), + AIProviderConfig(name: "Work", type: .claude), + AIProviderConfig(name: "Laptop", type: .ollama) + ] + #expect(MacAnalyticsProvider.aiProviderKinds(ai) == ["claude", "ollama"]) + + ai.enabled = false + #expect(MacAnalyticsProvider.aiProviderKinds(ai) == nil) + + ai.enabled = true + ai.providers = [] + #expect(MacAnalyticsProvider.aiProviderKinds(ai) == nil) + } + @Test("markConnectionAttempted does not affect connectionSucceededAt") func attemptedDoesNotAffectSucceeded() { provider.markConnectionAttempted() diff --git a/apple/TableProTests/Views/Main/FirstQueryAnalyticsTests.swift b/apple/TableProTests/Views/Main/FirstQueryAnalyticsTests.swift new file mode 100644 index 0000000000..05ae4ddff7 --- /dev/null +++ b/apple/TableProTests/Views/Main/FirstQueryAnalyticsTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing + +@testable import TablePro + +/// The heartbeat's first-query timestamp lost its only caller when `DatabaseManager.execute(query:)` +/// was removed, so it stayed nil on every Mac. Recording an editor run's history is where it is set now. +@MainActor +struct FirstQueryAnalyticsTests { + private func request(_ source: QueryHistorySource, succeeded: Bool, connectionId: UUID) -> QueryHistoryRecordRequest { + QueryHistoryRecordRequest( + query: "SELECT 1", + connectionId: connectionId, + databaseName: "db", + databaseType: .postgresql, + source: source, + executionTime: 0.01, + rowCount: 1, + wasSuccessful: succeeded, + errorMessage: succeeded ? nil : "syntax error" + ) + } + + // The mark is written before recordHistory returns its task, so every assertion runs before the + // first suspension and no other suite can touch the shared defaults in between. + @Test("Only a successful editor query records the first-query timestamp") + func successfulEditorQueryMarksFirstQuery() async { + AppStorageEnvironment.shared.defaults.removeObject(forKey: MacAnalyticsProvider.Keys.firstQueryExecutedAt) + let fixture = CoordinatorFixture() + let connectionId = fixture.connection.id + + let failed = fixture.coordinator.recordHistory(request(.editor, succeeded: false, connectionId: connectionId)) + let browsed = fixture.coordinator.recordHistory(request(.tableBrowse, succeeded: true, connectionId: connectionId)) + #expect(MacAnalyticsProvider.shared.firstQueryExecutedAt == nil) + + let ran = fixture.coordinator.recordHistory(request(.editor, succeeded: true, connectionId: connectionId)) + #expect(MacAnalyticsProvider.shared.firstQueryExecutedAt != nil) + + _ = await (failed.value, browsed.value, ran.value) + } +} diff --git a/apple/changelog.d/3853.changed.md b/apple/changelog.d/3853.changed.md new file mode 100644 index 0000000000..06288fa10b --- /dev/null +++ b/apple/changelog.d/3853.changed.md @@ -0,0 +1 @@ +The usage report now says which kinds of AI provider you set up and when you last used the AI chat.