Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -53,5 +57,7 @@ public struct AnalyticsPayload: Encodable, Sendable {
self.firstQueryExecutedAt = firstQueryExecutedAt
self.updateInstallMode = updateInstallMode
self.updateCheckInterval = updateCheckInterval
self.aiProviders = aiProviders
self.aiChatSentAt = aiChatSentAt
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions apple/TablePro/ViewModels/AIChatViewModel+Streaming.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ extension AIChatViewModel {
}
}

MacAnalyticsProvider.shared.markAIChatSent()

guard let resolved else {
beginOnDeviceTurn()
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
29 changes: 29 additions & 0 deletions apple/TableProTests/Services/MacAnalyticsProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
41 changes: 41 additions & 0 deletions apple/TableProTests/Views/Main/FirstQueryAnalyticsTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions apple/changelog.d/3853.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The usage report now says which kinds of AI provider you set up and when you last used the AI chat.
Loading