diff --git a/.claude/rules/data-sync-storage.md b/.claude/rules/data-sync-storage.md index 7c5ca1422c..abd611cd4e 100644 --- a/.claude/rules/data-sync-storage.md +++ b/.claude/rules/data-sync-storage.md @@ -124,7 +124,6 @@ paths: - "apple/Packages/TableProCore/Sources/TableProConnectionLibrary/RecentConnectionsLedger.swift" - "apple/Packages/TableProCore/Sources/TableProSnowflakeCore/SnowflakeAccount.swift" - "apple/Packages/TableProCore/Sources/TableProSnowflakeCore/SnowflakeTokenCache.swift" - - "apple/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsHeartbeatService.swift" - "apple/Packages/TableProCore/Sources/TableProTheme/ThemeFileFormat.swift" - "apple/Packages/TableProCore/Sources/TableProTheme/ThemeDefinition.swift" - "apple/Packages/TableProCore/Sources/TableProTheme/ThemeSlot.swift" diff --git a/apple/CONTRIBUTING.md b/apple/CONTRIBUTING.md index aa54c7e4bc..e2a513674b 100644 --- a/apple/CONTRIBUTING.md +++ b/apple/CONTRIBUTING.md @@ -130,7 +130,7 @@ The architecture is in the area rules under `.claude/rules/`, and its invariants | TableProOracle | `Packages/`, wraps a pinned fork of OracleNIO | Oracle wire protocol | | Sparkle | SPM | Updates, EdDSA-signed | | Yams | SPM | YAML for project folder import | -| posthog-ios | SPM | Usage data, `Core/Services/Telemetry`; a build sends nothing unless `POSTHOG_PROJECT_KEY` is set | +| posthog-ios | SPM | Usage data in both apps: `TablePro/Core/Services/Telemetry` and `TableProMobile/TableProMobile/Platform/Telemetry.swift`; only a Release build with `POSTHOG_PROJECT_KEY` set sends anything | | swift-nio, swift-nio-ssl, zstd | SPM | The Kafka plugin's transport, TLS and zstd codec | | swift-openapi-generator, swift-openapi-runtime, swift-openapi-urlsession | SPM, through `Packages/TableProMacCore` | `TableProCloudAPI`, the client generated from tablepro-cloud's `api/v2.json`, sending over `URLSession`; `scripts/update-cloud-api.sh` refreshes its copy and the entitlement test vectors | diff --git a/apple/Configs/Base.xcconfig b/apple/Configs/Base.xcconfig index 4817113c7a..20fa6e0242 100644 --- a/apple/Configs/Base.xcconfig +++ b/apple/Configs/Base.xcconfig @@ -69,7 +69,8 @@ TABLEPRO_APP_BUNDLE_IDENTIFIER = com.TablePro // Only the app target reads this, so a profile passed here never reaches a plugin or package target. TABLEPRO_PROVISIONING_PROFILE = -// The PostHog project the macOS app sends usage data to. Empty sends nothing; a release sets it in Secrets.xcconfig. +// The PostHog project both apps send usage data to. Empty sends nothing; a release sets it in Secrets.xcconfig, and +// Debug.xcconfig empties it again. POSTHOG_PROJECT_KEY = #include? "Secrets.xcconfig" diff --git a/apple/Configs/Debug.xcconfig b/apple/Configs/Debug.xcconfig index 34dca48f9d..584fcd744e 100644 --- a/apple/Configs/Debug.xcconfig +++ b/apple/Configs/Debug.xcconfig @@ -9,3 +9,6 @@ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE ONLY_ACTIVE_ARCH = YES SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG $(inherited) SWIFT_OPTIMIZATION_LEVEL = -Onone + +// Local runs, tests, UI tests and previews never send to the production project, whatever Secrets.xcconfig sets. +POSTHOG_PROJECT_KEY = diff --git a/apple/Configs/Secrets.xcconfig.example b/apple/Configs/Secrets.xcconfig.example index 990aeb75a1..45b008fec8 100644 --- a/apple/Configs/Secrets.xcconfig.example +++ b/apple/Configs/Secrets.xcconfig.example @@ -2,10 +2,8 @@ // so nothing here ever reaches a commit. Every value is optional: Configs/Base.xcconfig // includes this file with #include?, so the build works without it. -// Signs the iPhone and iPad app's usage report. Leave empty for local development. -ANALYTICS_HMAC_SECRET = - -// The PostHog project usage data goes to. Leave empty: a build with a key sends to that project. +// The PostHog project usage data goes to. Leave empty: a Release build with a key sends to that project. Debug builds +// never carry it. POSTHOG_PROJECT_KEY = // Your Apple Developer team, for building under your own signing identity. diff --git a/apple/Packages/TableProCore/Package.swift b/apple/Packages/TableProCore/Package.swift index 263a5a4a13..d756a12c8c 100644 --- a/apple/Packages/TableProCore/Package.swift +++ b/apple/Packages/TableProCore/Package.swift @@ -26,7 +26,6 @@ let package = Package( .library(name: "TableProQuery", targets: ["TableProQuery"]), .library(name: "TableProSyncTransport", targets: ["TableProSyncTransport"]), .library(name: "TableProSync", targets: ["TableProSync"]), - .library(name: "TableProAnalytics", targets: ["TableProAnalytics"]), .library(name: "TableProMSSQLCore", targets: ["TableProMSSQLCore"]), .library(name: "TableProKerberos", targets: ["TableProKerberos"]), .library(name: "TableProTeradataCore", targets: ["TableProTeradataCore"]), @@ -150,11 +149,6 @@ let package = Package( dependencies: ["TableProSyncTransport", "TableProModels", "TableProCoreTypes", "TableProConnectionLibrary"], path: "Sources/TableProSync" ), - .target( - name: "TableProAnalytics", - dependencies: [], - path: "Sources/TableProAnalytics" - ), .target( name: "TableProMSSQLCore", dependencies: ["TableProCoreTypes", "TableProSQLGrammar", "TableProSQLQuoting"], @@ -535,11 +529,6 @@ let package = Package( path: "Tests/TableProAutocompleteTests", swiftSettings: pluginCoreSettings ), - .testTarget( - name: "TableProAnalyticsTests", - dependencies: ["TableProAnalytics"], - path: "Tests/TableProAnalyticsTests" - ), .testTarget( name: "TableProMSSQLCoreTests", dependencies: ["TableProMSSQLCore", "TableProSQLGrammar", "TableProTestSupport"], diff --git a/apple/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsEnvironmentProvider.swift b/apple/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsEnvironmentProvider.swift deleted file mode 100644 index 7602cbe4dc..0000000000 --- a/apple/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsEnvironmentProvider.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// AnalyticsEnvironmentProvider.swift -// TableProAnalytics -// - -import Foundation - -/// Protocol that platform-specific apps conform to, providing all environment data for analytics heartbeats. -/// -/// macOS and iOS each implement this with platform-specific data sources (IOKit vs UIDevice, -/// DatabaseManager vs AppState, etc.). The heartbeat service reads these properties at send time -/// to build a fresh payload. -@MainActor -public protocol AnalyticsEnvironmentProvider: AnyObject { - /// SHA256-hashed machine/device identifier (64 hex chars) - var machineId: String { get } - - /// App version string (e.g. "1.2.0") from CFBundleShortVersionString - var appVersion: String? { get } - - /// OS version string (e.g. "macOS 15.1.0" or "iOS 18.2.0") - var osVersion: String { get } - - /// CPU architecture (e.g. "arm64", "x86_64") - var architecture: String { get } - - /// Platform identifier sent to backend ("macos" or "ios") - var platform: String { get } - - /// User locale preference (e.g. "en", "vi", "system") - var locale: String { get } - - /// Whether the user has opted in to analytics - var isAnalyticsEnabled: Bool { get } - - /// Whether the user has a valid license - var hasLicense: Bool { get } - - /// Database type identifiers for active connections (e.g. ["mysql", "postgresql"]) - var activeDatabaseTypes: [String] { get } - - /// Number of active database connections - var activeConnectionCount: Int { get } - - /// HMAC-SHA256 shared secret for request signing (from Info.plist build setting) - var hmacSecret: String? { get } - - /// Timestamp of the first connection attempt the user made on this device, or nil if never attempted. - /// Set once and never overwritten, the heartbeat sends the original value forever. - var connectionAttemptedAt: Date? { get } - - /// Timestamp of the first successful connection on this device, or nil if no connection ever succeeded. - /// Set once and never overwritten. - var connectionSucceededAt: Date? { get } - - /// Timestamp of the first query the user successfully executed on this device, or nil if no query has run. - /// Set once and never overwritten. - var firstQueryExecutedAt: Date? { get } - - /// How updates install on this device: "automatic", "notify" or "off". Nil where the platform - /// has no updater of its own, which is every App Store build. - var updateInstallMode: String? { get } - - /// Seconds between scheduled update checks, or nil when checks are off or unavailable. - var updateCheckInterval: Int? { get } -} - -public extension AnalyticsEnvironmentProvider { - var connectionAttemptedAt: Date? { nil } - var connectionSucceededAt: Date? { nil } - var firstQueryExecutedAt: Date? { nil } - var updateInstallMode: String? { nil } - var updateCheckInterval: Int? { nil } -} diff --git a/apple/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsHeartbeatService.swift b/apple/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsHeartbeatService.swift deleted file mode 100644 index 0b00324355..0000000000 --- a/apple/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsHeartbeatService.swift +++ /dev/null @@ -1,159 +0,0 @@ -// -// AnalyticsHeartbeatService.swift -// TableProAnalytics -// - -import CryptoKit -import Foundation -import os - -/// Shared heartbeat service for macOS and iOS. Sends anonymous usage data to the analytics API. -/// -/// Platform-specific data is injected via `AnalyticsEnvironmentProvider`. The service handles: -/// encoding, HMAC-SHA256 signing, HTTP transport, heartbeat scheduling, and cooldown persistence. -@MainActor -public final class AnalyticsHeartbeatService { - private static let logger = Logger(subsystem: "com.TablePro", category: "AnalyticsHeartbeat") - - private let provider: AnalyticsEnvironmentProvider - - private let analyticsUrl: URL - - private let heartbeatInterval: TimeInterval - private let initialDelay: TimeInterval - - /// Minimum elapsed time before sending another heartbeat. - /// Prevents duplicate sends on iOS when the app cycles between foreground/background. - private let cooldownInterval: TimeInterval - - private static let lastHeartbeatKey = "com.TablePro.analytics.lastHeartbeatDate" - - /// Injected so a sandboxed run keeps its cooldown stamp out of the real defaults domain. - private let defaults: UserDefaults - - private let session: URLSession = { - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 15 - config.timeoutIntervalForResource = 30 - config.waitsForConnectivity = true - return URLSession(configuration: config) - }() - - private let encoder: JSONEncoder = { - let encoder = JSONEncoder() - encoder.keyEncodingStrategy = .convertToSnakeCase - encoder.dateEncodingStrategy = .iso8601 - return encoder - }() - - public init( - provider: AnalyticsEnvironmentProvider, - analyticsUrl: URL = URL(string: "https://api.tablepro.app/v1/analytics")!, - heartbeatInterval: TimeInterval = 24 * 60 * 60, - initialDelay: TimeInterval = 10, - cooldownInterval: TimeInterval = 20 * 60 * 60, - defaults: UserDefaults = .standard - ) { - self.provider = provider - self.analyticsUrl = analyticsUrl - self.heartbeatInterval = heartbeatInterval - self.initialDelay = initialDelay - self.cooldownInterval = cooldownInterval - self.defaults = defaults - } - - // MARK: - Public API - - /// Start the periodic heartbeat loop. Returns a cancellable Task. - /// The caller owns the Task lifecycle (cancel on deinit or background). - public func startPeriodicHeartbeat() -> Task { - Task { [weak self] in - guard let delay = self?.initialDelay else { return } - try? await Task.sleep(for: .seconds(delay)) - - while !Task.isCancelled { - guard let target = self else { return } - await target.sendHeartbeat() - try? await Task.sleep(for: .seconds(target.heartbeatInterval)) - } - } - } - - /// Send a single heartbeat. Respects opt-out and cooldown. - public func sendHeartbeat() async { - guard provider.isAnalyticsEnabled else { - Self.logger.trace("Analytics disabled by user, skipping heartbeat") - return - } - - guard isCooldownElapsed() else { - Self.logger.trace("Analytics cooldown not elapsed, skipping heartbeat") - return - } - - let payload = buildPayload() - - do { - var request = URLRequest(url: analyticsUrl) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try encoder.encode(payload) - - if let body = request.httpBody, - let secret = provider.hmacSecret, !secret.isEmpty { - let key = SymmetricKey(data: Data(secret.utf8)) - let signature = HMAC.authenticationCode(for: body, using: key) - let signatureHex = signature.map { String(format: "%02x", $0) }.joined() - request.setValue(signatureHex, forHTTPHeaderField: "X-Signature") - } - - let (_, response) = try await session.data(for: request) - - if let httpResponse = response as? HTTPURLResponse { - Self.logger.trace("Analytics heartbeat sent, status: \(httpResponse.statusCode)") - } - - recordHeartbeatTimestamp() - } catch { - Self.logger.trace("Analytics heartbeat failed: \(error.localizedDescription)") - } - } - - // MARK: - Private - - private func buildPayload() -> AnalyticsPayload { - let types = provider.activeDatabaseTypes - return AnalyticsPayload( - machineId: provider.machineId, - platform: provider.platform, - appVersion: provider.appVersion, - osVersion: provider.osVersion, - architecture: provider.architecture, - locale: provider.locale, - databaseTypes: types.isEmpty ? nil : types, - connectionCount: provider.activeConnectionCount, - hasLicense: provider.hasLicense, - connectionAttemptedAt: provider.connectionAttemptedAt, - connectionSucceededAt: provider.connectionSucceededAt, - firstQueryExecutedAt: provider.firstQueryExecutedAt, - updateInstallMode: provider.updateInstallMode, - updateCheckInterval: provider.updateCheckInterval - ) - } - - /// Exposed for tests so they can verify the encoded body without touching `sendHeartbeat()`. - public func makeEncodedBodyForTesting(payload: AnalyticsPayload) throws -> Data { - try encoder.encode(payload) - } - - private func isCooldownElapsed() -> Bool { - guard let last = defaults.object(forKey: Self.lastHeartbeatKey) as? Date else { - return true - } - return Date().timeIntervalSince(last) >= cooldownInterval - } - - private func recordHeartbeatTimestamp() { - defaults.set(Date(), forKey: Self.lastHeartbeatKey) - } -} diff --git a/apple/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsPayload.swift b/apple/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsPayload.swift deleted file mode 100644 index d3b782adcb..0000000000 --- a/apple/Packages/TableProCore/Sources/TableProAnalytics/AnalyticsPayload.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// AnalyticsPayload.swift -// TableProAnalytics -// - -import Foundation - -/// Anonymous heartbeat payload sent to the analytics API every 24 hours. -/// Encoded with snake_case keys to match backend expectations. -public struct AnalyticsPayload: Encodable, Sendable { - public let machineId: String - public let platform: String - public let appVersion: String? - public let osVersion: String - public let architecture: String - public let locale: String - public let databaseTypes: [String]? - public let connectionCount: Int - public let hasLicense: Bool - public let connectionAttemptedAt: Date? - public let connectionSucceededAt: Date? - public let firstQueryExecutedAt: Date? - public let updateInstallMode: String? - public let updateCheckInterval: Int? - - public init( - machineId: String, - platform: String, - appVersion: String?, - osVersion: String, - architecture: String, - locale: String, - databaseTypes: [String]?, - connectionCount: Int, - hasLicense: Bool, - connectionAttemptedAt: Date? = nil, - connectionSucceededAt: Date? = nil, - firstQueryExecutedAt: Date? = nil, - updateInstallMode: String? = nil, - updateCheckInterval: Int? = nil - ) { - self.machineId = machineId - self.platform = platform - self.appVersion = appVersion - self.osVersion = osVersion - self.architecture = architecture - self.locale = locale - self.databaseTypes = databaseTypes - self.connectionCount = connectionCount - self.hasLicense = hasLicense - self.connectionAttemptedAt = connectionAttemptedAt - self.connectionSucceededAt = connectionSucceededAt - self.firstQueryExecutedAt = firstQueryExecutedAt - self.updateInstallMode = updateInstallMode - self.updateCheckInterval = updateCheckInterval - } -} diff --git a/apple/Packages/TableProCore/Tests/TableProAnalyticsTests/AnalyticsHeartbeatPayloadTests.swift b/apple/Packages/TableProCore/Tests/TableProAnalyticsTests/AnalyticsHeartbeatPayloadTests.swift deleted file mode 100644 index fbcd2374bf..0000000000 --- a/apple/Packages/TableProCore/Tests/TableProAnalyticsTests/AnalyticsHeartbeatPayloadTests.swift +++ /dev/null @@ -1,215 +0,0 @@ -// -// AnalyticsHeartbeatPayloadTests.swift -// TableProAnalyticsTests -// - -import CryptoKit -import Foundation -import Testing - -@testable import TableProAnalytics - -@MainActor -@Suite("AnalyticsHeartbeatService payload encoding") -struct AnalyticsHeartbeatPayloadTests { - 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? - var connectionAttemptedAt: Date? - var connectionSucceededAt: Date? - var firstQueryExecutedAt: Date? - var updateInstallMode: String? - var updateCheckInterval: Int? - } - - private func makeService(provider: StubProvider) -> AnalyticsHeartbeatService { - AnalyticsHeartbeatService( - provider: provider, - heartbeatInterval: 60, - initialDelay: 60, - cooldownInterval: 0 - ) - } - - private func makePayload(provider: StubProvider) -> AnalyticsPayload { - AnalyticsPayload( - machineId: provider.machineId, - platform: provider.platform, - appVersion: provider.appVersion, - osVersion: provider.osVersion, - architecture: provider.architecture, - locale: provider.locale, - databaseTypes: provider.activeDatabaseTypes.isEmpty ? nil : provider.activeDatabaseTypes, - connectionCount: provider.activeConnectionCount, - hasLicense: provider.hasLicense, - connectionAttemptedAt: provider.connectionAttemptedAt, - connectionSucceededAt: provider.connectionSucceededAt, - firstQueryExecutedAt: provider.firstQueryExecutedAt, - updateInstallMode: provider.updateInstallMode, - updateCheckInterval: provider.updateCheckInterval - ) - } - - @Test("Encodes new timestamp fields as ISO 8601 strings in snake_case keys") - func encodesTimestampsIso8601() throws { - let provider = StubProvider() - let attempted = Date(timeIntervalSince1970: 1_700_000_000) - let succeeded = Date(timeIntervalSince1970: 1_700_000_300) - let queried = Date(timeIntervalSince1970: 1_700_001_000) - provider.connectionAttemptedAt = attempted - provider.connectionSucceededAt = succeeded - provider.firstQueryExecutedAt = queried - - let service = makeService(provider: provider) - let body = try service.makeEncodedBodyForTesting(payload: makePayload(provider: provider)) - let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any]) - - #expect(json["connection_attempted_at"] as? String == ISO8601DateFormatter().string(from: attempted)) - #expect(json["connection_succeeded_at"] as? String == ISO8601DateFormatter().string(from: succeeded)) - #expect(json["first_query_executed_at"] as? String == ISO8601DateFormatter().string(from: queried)) - } - - @Test("Omits timestamp fields when provider returns nil") - func omitsNilTimestampFields() throws { - let provider = StubProvider() - let service = makeService(provider: provider) - let body = try service.makeEncodedBodyForTesting(payload: makePayload(provider: provider)) - let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any]) - - #expect(json["connection_attempted_at"] == nil) - #expect(json["connection_succeeded_at"] == nil) - #expect(json["first_query_executed_at"] == nil) - } - - @Test("Includes existing payload fields with snake_case keys") - func encodesExistingFields() throws { - let provider = StubProvider() - provider.activeDatabaseTypes = ["mysql", "postgresql"] - provider.activeConnectionCount = 2 - provider.hasLicense = true - - let service = makeService(provider: provider) - let body = try service.makeEncodedBodyForTesting(payload: makePayload(provider: provider)) - let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any]) - - #expect(json["machine_id"] as? String == "machine-1") - #expect(json["app_version"] as? String == "1.0.0") - #expect(json["os_version"] as? String == "macOS 15.1.0") - #expect(json["connection_count"] as? Int == 2) - #expect(json["has_license"] as? Bool == true) - } - - @Test("HMAC signature covers the encoded body including new fields") - func hmacCoversNewFields() throws { - let provider = StubProvider() - provider.hmacSecret = "test-secret" - - let service = makeService(provider: provider) - let basePayload = makePayload(provider: provider) - let baseBody = try service.makeEncodedBodyForTesting(payload: basePayload) - - provider.firstQueryExecutedAt = Date(timeIntervalSince1970: 1_700_001_000) - let withTimestampPayload = makePayload(provider: provider) - let withTimestampBody = try service.makeEncodedBodyForTesting(payload: withTimestampPayload) - - let key = SymmetricKey(data: Data("test-secret".utf8)) - let baseSig = HMAC.authenticationCode(for: baseBody, using: key) - .map { String(format: "%02x", $0) } - .joined() - let withSig = HMAC.authenticationCode(for: withTimestampBody, using: key) - .map { String(format: "%02x", $0) } - .joined() - - #expect(baseSig != withSig, "Signature must change when payload contents change") - #expect(baseBody != withTimestampBody, "Body must differ when a new timestamp is included") - } -} - -@MainActor -@Suite("AnalyticsHeartbeatService update fields") -struct AnalyticsUpdateFieldTests { - 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(mode: String?, interval: Int?) 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, - updateInstallMode: mode, - updateCheckInterval: interval - ) - let service = AnalyticsHeartbeatService( - provider: StubProvider(), - heartbeatInterval: 60, - initialDelay: 60, - cooldownInterval: 0 - ) - let data = try service.makeEncodedBodyForTesting(payload: payload) - let object = try JSONSerialization.jsonObject(with: data) - return try #require(object as? [String: Any]) - } - - /// The whole point of these two fields is measuring whether a default that was flipped for - /// every existing install was accepted, so the wire keys have to match what the backend reads. - @Test("Encodes the update fields in snake_case") - func encodesUpdateFields() throws { - let body = try encoded(mode: "automatic", interval: 86_400) - - #expect(body["update_install_mode"] as? String == "automatic") - #expect(body["update_check_interval"] as? Int == 86_400) - } - - @Test("Omits both when the platform has no updater") - func omitsWhenAbsent() throws { - let body = try encoded(mode: nil, interval: nil) - - #expect(body["update_install_mode"] == nil) - #expect(body["update_check_interval"] == nil) - } - - /// Checks off means there is no interval to report, and the mode still says so. - @Test("Reports the mode with no interval when checks are off") - func reportsModeWithoutInterval() throws { - let body = try encoded(mode: "off", interval: nil) - - #expect(body["update_install_mode"] as? String == "off") - #expect(body["update_check_interval"] == nil) - } - - /// A provider that implements neither gets nil from the protocol default, which is what keeps - /// the iOS provider compiling untouched. - @Test("A provider that implements neither reports neither") - func defaultsToNil() { - let provider = StubProvider() - - #expect(provider.updateInstallMode == nil) - #expect(provider.updateCheckInterval == nil) - } -} diff --git a/apple/RELEASING.md b/apple/RELEASING.md index 1b726d9228..cc0bb53481 100644 --- a/apple/RELEASING.md +++ b/apple/RELEASING.md @@ -165,8 +165,8 @@ gh workflow run withdraw.yml --ref v -f version=X.Y.Z By hand, on a Mac with the team's signing identity. 1. A PR raises `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` in `Configs/Version-iOS.xcconfig`. The build goes up on every upload. -2. `apple/scripts/download-libs.sh && apple/scripts/generate-project.sh ios`, then archive the `TableProMobile` scheme for Any iOS Device in Xcode. -3. Organizer: Distribute App to App Store Connect. +2. `apple/scripts/download-libs.sh && apple/scripts/generate-project.sh ios`, then archive the `TableProMobile` scheme for Any iOS Device in Xcode. `Configs/Secrets.xcconfig` sets `POSTHOG_PROJECT_KEY` to the repository secret of that name; without it the build sends no usage data. +3. Organizer: Distribute App to App Store Connect. App Privacy in App Store Connect lists the data types `TableProMobile/TableProMobile/PrivacyInfo.xcprivacy` declares. 4. TestFlight: test with internal testers. 5. Submit for App Store review, and release once approved. 6. `git tag -s ios-vX.Y.Z -m ios-vX.Y.Z && git push origin ios-vX.Y.Z`. Never a `v*` tag: that starts `release.yml` and moves the PluginKit ABI baseline. diff --git a/apple/TablePro/Core/Services/Telemetry/PostHogTelemetryClient.swift b/apple/TablePro/Core/Services/Telemetry/PostHogTelemetryClient.swift index 92b2089711..ec9a85c82c 100644 --- a/apple/TablePro/Core/Services/Telemetry/PostHogTelemetryClient.swift +++ b/apple/TablePro/Core/Services/Telemetry/PostHogTelemetryClient.swift @@ -1,23 +1,41 @@ -// -// PostHogTelemetryClient.swift -// TablePro -// - import Foundation +import os import PostHog +// Both apps compile this file, so what the SDK may collect is set once. +@MainActor +internal protocol TelemetryClient: AnyObject { + func identify(_ accountID: String, personProperties: [String: Any]) + func capture(_ event: String, properties: [String: Any]) + func reset() + func optIn() + func optOut() +} + // The SDK keeps its queue in files and sends what is left after the next setup, so quitting needs no flush. @MainActor internal final class PostHogTelemetryClient: TelemetryClient { - private let sdk: PostHogSDK + nonisolated static let host = "https://eu.i.posthog.com" + + private let projectKey: String + private var sdk: PostHogSDK? init(projectKey: String) { + self.projectKey = projectKey + PostHogTransportGate.isOpen = true sdk = PostHogSDK.with(Self.configuration(projectKey: projectKey)) } + // An unexpanded "$(POSTHOG_PROJECT_KEY)" means the build setting was never defined. + nonisolated static func projectKey(from infoValue: Any?) -> String? { + guard let value = infoValue as? String else { return nil } + let key = value.trimmingCharacters(in: .whitespacesAndNewlines) + return key.isEmpty || key.hasPrefix("$(") ? nil : key + } + // Only the events Telemetry captures: nothing the SDK would collect, load or swizzle on its own. nonisolated static func configuration(projectKey: String) -> PostHogConfig { - let config = PostHogConfig(projectToken: projectKey, host: Telemetry.host) + let config = PostHogConfig(projectToken: projectKey, host: host) config.captureApplicationLifecycleEvents = false config.captureScreenViews = false config.enableSwizzling = false @@ -26,28 +44,70 @@ internal final class PostHogTelemetryClient: TelemetryClient { config.sendFeatureFlagEvent = false config.errorTrackingConfig.autoCapture = false config.personProfiles = .identifiedOnly + let session = URLSessionConfiguration.default + session.protocolClasses = [PostHogTransportGate.self] + (session.protocolClasses ?? []) + config.urlSessionConfiguration = session // Batched, an event captured just before an opt-out still went out at the next flush, after it. config.flushAt = 1 + #if os(iOS) + // iOS-only integrations, each on by default; surveys would put up the SDK's own sheets. + config.surveys = false + config.capturePushNotificationSubscriptions = false + config.rageClickConfig.enabled = false + #endif return config } func identify(_ accountID: String, personProperties: [String: Any]) { - sdk.identify(accountID, userProperties: personProperties) + sdk?.identify(accountID, userProperties: personProperties) } func capture(_ event: String, properties: [String: Any]) { - sdk.capture(event, properties: properties) + sdk?.capture(event, properties: properties) } func reset() { - sdk.reset() + sdk?.reset() } func optIn() { - sdk.optIn() + PostHogTransportGate.isOpen = true + if sdk == nil { + sdk = PostHogSDK.with(Self.configuration(projectKey: projectKey)) + } + sdk?.optIn() } + // Closing stops the SDK's queue, which keeps what it holds on disk until the next opt-in. func optOut() { - sdk.optOut() + PostHogTransportGate.isOpen = false + sdk?.optOut() + sdk?.close() + sdk = nil + } +} + +// Refuses every PostHog request while usage data is off. The SDK's optOut() stops captures but not uploads, and its +// close() flushes before it stops, so an event queued offline could otherwise leave after the switch went off. +nonisolated internal final class PostHogTransportGate: URLProtocol, @unchecked Sendable { + private static let state = OSAllocatedUnfairLock(initialState: false) + + static var isOpen: Bool { + get { state.withLock { $0 } } + set { state.withLock { $0 = newValue } } } + + override class func canInit(with request: URLRequest) -> Bool { + !isOpen + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet)) + } + + override func stopLoading() {} } diff --git a/apple/TablePro/Core/Services/Telemetry/Telemetry.swift b/apple/TablePro/Core/Services/Telemetry/Telemetry.swift index 4f7e13f321..35c4f58d57 100644 --- a/apple/TablePro/Core/Services/Telemetry/Telemetry.swift +++ b/apple/TablePro/Core/Services/Telemetry/Telemetry.swift @@ -1,21 +1,7 @@ -// -// Telemetry.swift -// TablePro -// - import Combine import Foundation import os -@MainActor -internal protocol TelemetryClient: AnyObject { - func identify(_ accountID: String, personProperties: [String: Any]) - func capture(_ event: String, properties: [String: Any]) - func reset() - func optIn() - func optOut() -} - internal struct TelemetryEnvironment { var appVersion: String var osVersion: String @@ -34,13 +20,12 @@ internal struct TelemetryEnvironment { @MainActor internal final class Telemetry { static let shared = Telemetry( - projectKey: projectKey(from: Bundle.main.object(forInfoDictionaryKey: "PostHogProjectKey")), + projectKey: PostHogTelemetryClient.projectKey(from: Bundle.main.object(forInfoDictionaryKey: "PostHogProjectKey")), defaults: AppStorageEnvironment.shared.defaults, makeClient: { PostHogTelemetryClient(projectKey: $0) }, environment: { .current() } ) - nonisolated static let host = "https://eu.i.posthog.com" // Restored connections open first, so app_opened counts them. static let appOpenedDelay: Duration = .seconds(10) static let appOpenedCheckInterval: Duration = .seconds(60 * 60) @@ -83,7 +68,7 @@ internal final class Telemetry { now: @escaping () -> Date = { Date() }, calendar: Calendar = .current ) { - self.projectKey = Self.projectKey(from: projectKey) + self.projectKey = PostHogTelemetryClient.projectKey(from: projectKey) self.defaults = defaults self.makeClient = makeClient self.environment = environment @@ -92,13 +77,6 @@ internal final class Telemetry { self.calendar = calendar } - // An unexpanded "$(POSTHOG_PROJECT_KEY)" means the build setting was never defined. - nonisolated static func projectKey(from infoValue: Any?) -> String? { - guard let value = infoValue as? String else { return nil } - let key = value.trimmingCharacters(in: .whitespacesAndNewlines) - return key.isEmpty || key.hasPrefix("$(") ? nil : key - } - func start( sharesUsageData: AnyPublisher, account: AccountServices diff --git a/apple/TablePro/Resources/ThirdPartyLicenses/licenses.yml b/apple/TablePro/Resources/ThirdPartyLicenses/licenses.yml index 42d0a07be1..ece2ba0632 100644 --- a/apple/TablePro/Resources/ThirdPartyLicenses/licenses.yml +++ b/apple/TablePro/Resources/ThirdPartyLicenses/licenses.yml @@ -534,8 +534,8 @@ textFile: texts/posthog-ios.txt source: spm:posthog-ios patched: false - platforms: [macos] - notes: 'The PostHog product, linked statically into the app for usage data. LICENSE is MIT for PostHog''s + platforms: [macos, ios] + notes: 'The PostHog product, linked statically into both apps for usage data. LICENSE is MIT for PostHog''s code plus an MIT section for the files taken from getsentry/sentry-cocoa. The package also compiles two vendored targets into the app, listed in vendor/UPSTREAM.md: PHPLCrashReporter (PLCrashReporter 1.12.2, MIT, with protobuf-c 1.4.0 and PLCrashLogWriterEncoding.c under Apache-2.0) and phlibwebp (libwebp diff --git a/apple/TableProMobile/TableProMobile.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apple/TableProMobile/TableProMobile.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index fce127506d..00bb980a7b 100644 --- a/apple/TableProMobile/TableProMobile.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/apple/TableProMobile/TableProMobile.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "fcb1fbfb1cf41d99fb533f3d60a346045e6e48c2906e97b28b4f53ce4285e6f8", + "originHash" : "b5a141e279807cef1b3d969b58652dfdc000eed2a6585cf53d5c3b8ce3b4b3c0", "pins" : [ { "identity" : "bigint", @@ -18,6 +18,15 @@ "revision" : "045a44674e9f9da3904e0f2b87995490a7cc88e4" } }, + { + "identity" : "posthog-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/PostHog/posthog-ios", + "state" : { + "revision" : "2b6b6b4301c14c3b51cb1a7827c954bb17d1b083", + "version" : "3.92.7" + } + }, { "identity" : "swift-asn1", "kind" : "remoteSourceControl", diff --git a/apple/TableProMobile/TableProMobile/Acknowledgements/Acknowledgements.json b/apple/TableProMobile/TableProMobile/Acknowledgements/Acknowledgements.json index dd2b708e0c..6c962b4fac 100644 --- a/apple/TableProMobile/TableProMobile/Acknowledgements/Acknowledgements.json +++ b/apple/TableProMobile/TableProMobile/Acknowledgements/Acknowledgements.json @@ -138,6 +138,22 @@ "homepageURL": "https://www.postgresql.org", "textFile": "texts/postgresql-libpq.txt" }, + { + "id": "posthog-ios", + "name": "PostHog iOS SDK", + "version": "3.92.7", + "spdx": "MIT AND BSD-3-Clause AND Apache-2.0", + "copyrights": [ + "Copyright (c) 2023 PostHog", + "Copyright (c) 2015 Sentry", + "Copyright (c) Microsoft Corporation.", + "Copyright (c) 2008 - 2014 Plausible Labs Cooperative, Inc.", + "Copyright 2008, Dave Benson.", + "Copyright (c) 2010, Google Inc." + ], + "homepageURL": "https://github.com/PostHog/posthog-ios", + "textFile": "texts/posthog-ios.txt" + }, { "id": "swift-asn1", "name": "swift-asn1", diff --git a/apple/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift b/apple/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift index 73a16b9146..0e4f7ae3ce 100644 --- a/apple/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift +++ b/apple/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift @@ -285,7 +285,7 @@ final class ConnectionCoordinator { // failing cannot put the prompt up again and again. That retry dials with the new credentials // rather than join a dial another window started without them. private func connectFresh(token: UUID, allowSignIn: Bool = true) async { - IOSAnalyticsProvider.shared.markConnectionAttempted() + Telemetry.shared.connectionAttempted(databaseType: connection.type.rawValue) do { let outcome = try await appState.sessionRedialer.redial( @@ -313,7 +313,7 @@ final class ConnectionCoordinator { guard attemptToken == token else { return } phase = .connected appState.libraryPreferences.recordConnected(connection.id) - IOSAnalyticsProvider.shared.markConnectionSucceeded() + Telemetry.shared.connectionSucceeded(databaseType: connection.type.rawValue) navigateToPendingTable() reportDroppedTransaction(unsettledLoss) } catch { diff --git a/apple/TableProMobile/TableProMobile/Info.plist b/apple/TableProMobile/TableProMobile/Info.plist index a933e1cfb3..8428493002 100644 --- a/apple/TableProMobile/TableProMobile/Info.plist +++ b/apple/TableProMobile/TableProMobile/Info.plist @@ -2,8 +2,8 @@ - AnalyticsHMACSecret - $(ANALYTICS_HMAC_SECRET) + PostHogProjectKey + $(POSTHOG_PROJECT_KEY) AppIdentifierPrefix $(AppIdentifierPrefix) NSAppTransportSecurity diff --git a/apple/TableProMobile/TableProMobile/Localizable.xcstrings b/apple/TableProMobile/TableProMobile/Localizable.xcstrings index b4fa50c5f8..80019d348c 100644 --- a/apple/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/apple/TableProMobile/TableProMobile/Localizable.xcstrings @@ -6,6 +6,24 @@ }, "A document field could not be read" : { + }, + "A random identifier for this install" : { + + }, + "Help decide what to improve next. Nothing is sent unless you choose Share Usage Data." : { + + }, + "Off until you turn it on. Sent to PostHog with a random ID: app and iOS versions, device model, screen size, language, time zone, network type, database types, connection count, and first connection and query. PostHog derives an approximate location from your IP address. Never hostnames, credentials, queries, or rows." : { + + }, + "PostHog derives an approximate location from your IP address. Nothing sent contains a hostname, username, password, query, or any data from your databases. You can change this in Settings." : { + + }, + "Sent to PostHog:" : { + + }, + "The app and iOS versions, device model, screen size, language, time zone, and network type" : { + }, "The result has no stored documents" : { @@ -248,6 +266,9 @@ "the SSH host, user or port of “%@” is not one TablePro allows" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "When you first connect and first run a query" : { + }, "“%@” has an open transaction. Deleting the connection rolls it back and discards its uncommitted changes." : { diff --git a/apple/TableProMobile/TableProMobile/Onboarding/FirstRunSheet.swift b/apple/TableProMobile/TableProMobile/Onboarding/FirstRunSheet.swift index 4b251abf5c..a56e3b8189 100644 --- a/apple/TableProMobile/TableProMobile/Onboarding/FirstRunSheet.swift +++ b/apple/TableProMobile/TableProMobile/Onboarding/FirstRunSheet.swift @@ -139,7 +139,7 @@ private struct UsageDataPage: View { OnboardingPageLayout { OnboardingHeader( title: "Share Usage Data?", - message: "Help decide what to improve next by sending one small report a day.", + message: "Help decide what to improve next. Nothing is sent unless you choose Share Usage Data.", image: .symbol("chart.bar.xaxis") ) } content: { @@ -161,16 +161,16 @@ private struct UsageDataPage: View { struct UsageDataDisclosure: View { var body: some View { VStack(alignment: .leading, spacing: 12) { - Text("The report contains:") + Text("Sent to PostHog:") .font(.subheadline.weight(.semibold)) VStack(alignment: .leading, spacing: 6) { - Label("A hashed identifier for this device", systemImage: "number") - Label("The app and iOS versions, and your language", systemImage: "info.circle") + Label("A random identifier for this install", systemImage: "number") + Label("The app and iOS versions, device model, screen size, language, time zone, and network type", systemImage: "info.circle") Label("Which database types you connect to, and how many connections are open", systemImage: "cylinder") - Label("When you first connected and first ran a query", systemImage: "calendar") + Label("When you first connect and first run a query", systemImage: "calendar") } .font(.subheadline) - Text("It never contains a hostname, username, password, query, or any data from your databases. You can change this in Settings.") + Text("PostHog derives an approximate location from your IP address. Nothing sent contains a hostname, username, password, query, or any data from your databases. You can change this in Settings.") .font(.footnote) .foregroundStyle(.secondary) } diff --git a/apple/TableProMobile/TableProMobile/Platform/AppPreferences.swift b/apple/TableProMobile/TableProMobile/Platform/AppPreferences.swift index 109d9da615..8d4c76673c 100644 --- a/apple/TableProMobile/TableProMobile/Platform/AppPreferences.swift +++ b/apple/TableProMobile/TableProMobile/Platform/AppPreferences.swift @@ -16,10 +16,6 @@ nonisolated enum AppPreferences { UserDefaults.standard.bool(forKey: cloudSyncEnabledKey) } - static var isUsageDataEnabled: Bool { - UserDefaults.standard.bool(forKey: usageDataKey) - } - static var syncsPasswords: Bool { isCloudSyncEnabled && UserDefaults.standard.bool(forKey: syncPasswordsKey) } diff --git a/apple/TableProMobile/TableProMobile/Platform/IOSAnalyticsProvider.swift b/apple/TableProMobile/TableProMobile/Platform/IOSAnalyticsProvider.swift deleted file mode 100644 index 38f54e0f29..0000000000 --- a/apple/TableProMobile/TableProMobile/Platform/IOSAnalyticsProvider.swift +++ /dev/null @@ -1,126 +0,0 @@ -import Foundation -import os -import TableProAnalytics -import TableProDatabase -import TableProModels -import UIKit - -@MainActor -final class IOSAnalyticsProvider: AnalyticsEnvironmentProvider { - static let shared = IOSAnalyticsProvider() - - private static let logger = Logger(subsystem: "com.TablePro", category: "IOSAnalyticsProvider") - - private weak var appState: AppState? - - private let defaults: UserDefaults - - enum Keys { - static let connectionAttemptedAt = "com.TablePro.analytics.connectionAttemptedAt" - static let connectionSucceededAt = "com.TablePro.analytics.connectionSucceededAt" - static let firstQueryExecutedAt = "com.TablePro.analytics.firstQueryExecutedAt" - static let successfulConnectionCount = "com.TablePro.analytics.successfulConnectionCount" - } - - init(defaults: UserDefaults = .standard) { - self.defaults = defaults - } - - func attach(appState: AppState) { - self.appState = appState - } - - var machineId: String { - let stableKey = "com.TablePro.analytics.stableDeviceId" - if let stable = defaults.string(forKey: stableKey) { - return stable - } - let id: String - if let vendorId = UIDevice.current.identifierForVendor?.uuidString { - id = vendorId.sha256 - } else { - id = UUID().uuidString.sha256 - } - defaults.set(id, forKey: stableKey) - return id - } - - var appVersion: String? { - Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String - } - - var osVersion: String { - let version = ProcessInfo.processInfo.operatingSystemVersion - return "iOS \(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" - } - - var architecture: String { "arm64" } - - var platform: String { "ios" } - - var locale: String { - Locale.current.language.languageCode?.identifier ?? "en" - } - - var isAnalyticsEnabled: Bool { - defaults.bool(forKey: AppPreferences.usageDataKey) - } - - var hasLicense: Bool { false } - - var activeDatabaseTypes: [String] { - guard let appState else { return [] } - let active = appState.connections.filter { conn in - appState.connectionManager.session(for: conn.id) != nil - } - return Array(Set(active.map { $0.type.rawValue })) - } - - var activeConnectionCount: Int { - guard let appState else { return 0 } - return appState.connections.filter { conn in - appState.connectionManager.session(for: conn.id) != nil - }.count - } - - var hmacSecret: String? { - guard let value = Bundle.main.object(forInfoDictionaryKey: "AnalyticsHMACSecret") as? String, - !value.isEmpty, - !value.hasPrefix("$(") else { - return nil - } - return value - } - - var connectionAttemptedAt: Date? { - defaults.object(forKey: Keys.connectionAttemptedAt) as? Date - } - - var connectionSucceededAt: Date? { - defaults.object(forKey: Keys.connectionSucceededAt) as? Date - } - - var firstQueryExecutedAt: Date? { - defaults.object(forKey: Keys.firstQueryExecutedAt) as? Date - } - - func markConnectionAttempted() { - writeOnceDate(Keys.connectionAttemptedAt, label: "connectionAttemptedAt") - } - - func markConnectionSucceeded() { - writeOnceDate(Keys.connectionSucceededAt, label: "connectionSucceededAt") - let next = defaults.integer(forKey: Keys.successfulConnectionCount) + 1 - defaults.set(next, forKey: Keys.successfulConnectionCount) - } - - func markFirstQueryExecuted() { - writeOnceDate(Keys.firstQueryExecutedAt, label: "firstQueryExecutedAt") - } - - private func writeOnceDate(_ key: String, label: String) { - guard defaults.object(forKey: key) == nil else { return } - defaults.set(Date(), forKey: key) - Self.logger.info("Recorded \(label, privacy: .public) for first time") - } -} diff --git a/apple/TableProMobile/TableProMobile/Platform/Telemetry.swift b/apple/TableProMobile/TableProMobile/Platform/Telemetry.swift new file mode 100644 index 0000000000..e40d327227 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Platform/Telemetry.swift @@ -0,0 +1,156 @@ +import Foundation +import os + +// Usage data for PostHog (clients.md, iOS), sent only once the user chooses Share Usage Data. A build without a project +// key never makes a client, and while the setting is off a client made earlier gets no call but optOut(). +@MainActor +final class Telemetry { + static let shared = Telemetry( + projectKey: PostHogTelemetryClient.projectKey(from: Bundle.main.object(forInfoDictionaryKey: "PostHogProjectKey")), + defaults: .standard, + makeClient: { PostHogTelemetryClient(projectKey: $0) } + ) + + // The app reconnects on activation, so app_opened waits for the connections to be open again. + static let appOpenedDelay: Duration = .seconds(10) + static let appOpenedCheckInterval: Duration = .seconds(60 * 60) + + enum Keys { + static let appOpenedAt = "com.TablePro.telemetry.appOpenedAt" + // The heartbeat's write-once dates, kept so an updated device does not report its first use again. + static let connectionAttemptedAt = "com.TablePro.analytics.connectionAttemptedAt" + static let connectionSucceededAt = "com.TablePro.analytics.connectionSucceededAt" + static let firstQueryExecutedAt = "com.TablePro.analytics.firstQueryExecutedAt" + } + + private static let logger = Logger(subsystem: "com.TablePro", category: "Telemetry") + + private let projectKey: String? + private let defaults: UserDefaults + private let makeClient: (String) -> any TelemetryClient + private let clock: any Clock + private let now: () -> Date + private let calendar: Calendar + + private var client: (any TelemetryClient)? + private var isStarted = false + private var isEnabled = false + private var isActive = false + private var openConnectionTypes: () -> [String] = { [] } + private var appOpenedTask: Task? + + init( + projectKey: String?, + defaults: UserDefaults, + makeClient: @escaping (String) -> any TelemetryClient, + clock: any Clock = ContinuousClock(), + now: @escaping () -> Date = { Date() }, + calendar: Calendar = .current + ) { + self.projectKey = PostHogTelemetryClient.projectKey(from: projectKey) + self.defaults = defaults + self.makeClient = makeClient + self.clock = clock + self.now = now + self.calendar = calendar + } + + // openConnectionTypes gives one database type per open connection. + func start(sharesUsageData: Bool, openConnectionTypes: @escaping () -> [String]) { + guard !isStarted else { return } + isStarted = true + self.openConnectionTypes = openConnectionTypes + guard projectKey != nil else { + Self.logger.info("This build has no PostHog project key; usage data is off") + return + } + setEnabled(sharesUsageData) + } + + func setEnabled(_ enabled: Bool) { + guard isStarted, let projectKey, enabled != isEnabled else { return } + isEnabled = enabled + if enabled { + if client == nil { + client = makeClient(projectKey) + } + client?.optIn() + scheduleAppOpened() + } else { + cancelAppOpened() + client?.optOut() + } + } + + func sceneBecameActive() { + isActive = true + scheduleAppOpened() + } + + func sceneEnteredBackground() { + isActive = false + cancelAppOpened() + } + + func connectionAttempted(databaseType: String) { + captureOnce(Keys.connectionAttemptedAt, "connection_attempted", ["database_type": databaseType]) + } + + func connectionSucceeded(databaseType: String) { + captureOnce(Keys.connectionSucceededAt, "connection_succeeded", ["database_type": databaseType]) + } + + func queryExecuted(databaseType: String) { + captureOnce(Keys.firstQueryExecutedAt, "first_query_executed", ["database_type": databaseType]) + } + + // MARK: - Private + + // A launch in the background, such as a sync refresh, is not an app open. + private func scheduleAppOpened() { + guard isEnabled, client != nil, isActive else { return } + appOpenedTask?.cancel() + appOpenedTask = Task { [weak self, clock] in + do { + try await clock.sleep(for: Self.appOpenedDelay) + while !Task.isCancelled { + self?.captureAppOpenedIfDue() + try await clock.sleep(for: Self.appOpenedCheckInterval) + } + } catch { + return + } + } + } + + private func cancelAppOpened() { + appOpenedTask?.cancel() + appOpenedTask = nil + } + + private func captureAppOpenedIfDue() { + guard isEnabled, client != nil else { return } + let today = now() + if let last = defaults.object(forKey: Keys.appOpenedAt) as? Date, calendar.isDate(last, inSameDayAs: today) { + return + } + let types = openConnectionTypes() + capture("app_opened", [ + "database_types": Set(types).sorted(), + "connection_count": types.count + ]) + defaults.set(today, forKey: Keys.appOpenedAt) + } + + // The date is written with the setting off too, so turning it on later does not report an old first use. + private func captureOnce(_ key: String, _ event: String, _ properties: [String: Any]) { + guard defaults.object(forKey: key) == nil else { return } + defaults.set(now(), forKey: key) + capture(event, properties) + } + + private func capture(_ event: String, _ properties: [String: Any] = [:]) { + guard isEnabled, let client else { return } + client.capture(event, properties: properties) + } +} diff --git a/apple/TableProMobile/TableProMobile/PrivacyInfo.xcprivacy b/apple/TableProMobile/TableProMobile/PrivacyInfo.xcprivacy index 4caefbbb49..49ff64bfa3 100644 --- a/apple/TableProMobile/TableProMobile/PrivacyInfo.xcprivacy +++ b/apple/TableProMobile/TableProMobile/PrivacyInfo.xcprivacy @@ -12,7 +12,7 @@ NSPrivacyCollectedDataType NSPrivacyCollectedDataTypeDeviceID NSPrivacyCollectedDataTypeLinked - + NSPrivacyCollectedDataTypeTracking NSPrivacyCollectedDataTypePurposes @@ -24,7 +24,43 @@ NSPrivacyCollectedDataType NSPrivacyCollectedDataTypeProductInteraction NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeOtherUsageData + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeCoarseLocation + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeOtherDiagnosticData + NSPrivacyCollectedDataTypeLinked + NSPrivacyCollectedDataTypeTracking NSPrivacyCollectedDataTypePurposes diff --git a/apple/TableProMobile/TableProMobile/TableProMobileApp.swift b/apple/TableProMobile/TableProMobile/TableProMobileApp.swift index caa5b85d71..d4d054dadf 100644 --- a/apple/TableProMobile/TableProMobile/TableProMobileApp.swift +++ b/apple/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -2,7 +2,6 @@ import AppIntents import BackgroundTasks import os import SwiftUI -import TableProAnalytics import TableProDatabase import TableProModels import TableProSyncTransport @@ -13,15 +12,19 @@ struct TableProMobileApp: App { private static let backgroundLogger = Logger(subsystem: "com.TablePro", category: "BackgroundSync") @UIApplicationDelegateAdaptor(TableProAppDelegate.self) private var appDelegate - @State private var appState = AppState() + @State private var appState: AppState @State private var lockState: AppLockState @State private var syncTask: Task? - @State private var heartbeatService: AnalyticsHeartbeatService? - @State private var heartbeatTask: Task? @Environment(\.scenePhase) private var scenePhase init() { ConnectionListTips.configure() + let appState = AppState() + _appState = State(initialValue: appState) + // At launch, so a connection opened before the scene first turns active still sends its first-use event. + if !TestRuntime.isActive { + Self.startTelemetry(appState: appState) + } let lockState = AppLockState() _lockState = State(initialValue: lockState) AppDependencyManager.shared.add(dependency: lockState) @@ -54,13 +57,13 @@ struct TableProMobileApp: App { await appState.syncCoordinator.sync(.activation) } } - startHeartbeatIfConsented() + Telemetry.shared.sceneBecameActive() case .inactive: appState.backgroundRelease.prepareForSuspension() case .background: syncTask?.cancel() syncTask = nil - stopHeartbeat() + Telemetry.shared.sceneEnteredBackground() Task { let released = await appState.backgroundRelease.releaseForSuspension() for connectionId in released { @@ -74,30 +77,19 @@ struct TableProMobileApp: App { } .onChange(of: appState.onboarding.usageDataChoice) { _, choice in guard !TestRuntime.isActive else { return } - guard choice == true else { - stopHeartbeat() - return - } - startHeartbeatIfConsented() + Telemetry.shared.setEnabled(choice == true) } .backgroundTask(.appRefresh(Self.backgroundSyncIdentifier)) { await runBackgroundSync() } } - private func startHeartbeatIfConsented() { - guard heartbeatTask == nil, appState.onboarding.isUsageDataEnabled else { return } - let provider = IOSAnalyticsProvider.shared - provider.attach(appState: appState) - let service = AnalyticsHeartbeatService(provider: provider) - heartbeatService = service - heartbeatTask = service.startPeriodicHeartbeat() - } - - private func stopHeartbeat() { - heartbeatTask?.cancel() - heartbeatTask = nil - heartbeatService = nil + private static func startTelemetry(appState: AppState) { + Telemetry.shared.start(sharesUsageData: appState.onboarding.isUsageDataEnabled) { + appState.connections + .filter { appState.connectionManager.session(for: $0.id) != nil } + .map(\.type.rawValue) + } } private func scheduleBackgroundSync() { diff --git a/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 0f0fe9019b..b2b420f9ac 100644 --- a/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -893,7 +893,7 @@ struct QueryEditorView: View { executionTime = viewModel.executionTime if outcome == .completed { hapticSuccess.toggle() - IOSAnalyticsProvider.shared.markFirstQueryExecuted() + Telemetry.shared.queryExecuted(databaseType: databaseType.rawValue) } recordHistory( query: trimmed, diff --git a/apple/TableProMobile/TableProMobile/Views/SettingsView.swift b/apple/TableProMobile/TableProMobile/Views/SettingsView.swift index b9adc9f25f..d5b8957505 100644 --- a/apple/TableProMobile/TableProMobile/Views/SettingsView.swift +++ b/apple/TableProMobile/TableProMobile/Views/SettingsView.swift @@ -149,7 +149,7 @@ struct SettingsView: View { } header: { Text("Privacy") } footer: { - Text("One report a day: hashed device ID, versions, language, database types, first-use dates. Never hostnames, credentials, queries, or data.") + Text("Off until you turn it on. Sent to PostHog with a random ID: app and iOS versions, device model, screen size, language, time zone, network type, database types, connection count, and first connection and query. PostHog derives an approximate location from your IP address. Never hostnames, credentials, queries, or rows.") } } diff --git a/apple/TableProMobile/TableProMobileTests/Platform/TelemetryTests.swift b/apple/TableProMobile/TableProMobileTests/Platform/TelemetryTests.swift new file mode 100644 index 0000000000..e63d3b16ec --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Platform/TelemetryTests.swift @@ -0,0 +1,309 @@ +import Foundation +import PostHog +@testable import TableProMobile +import TableProTestSupport +import Testing + +@MainActor +final class RecordingTelemetryClient: TelemetryClient { + enum Call: Equatable { + case identify(String) + case capture(String, [String: String]) + case reset + case optIn + case optOut + } + + private(set) var calls: [Call] = [] + + func identify(_ accountID: String, personProperties: [String: Any]) { + calls.append(.identify(accountID)) + } + + func capture(_ event: String, properties: [String: Any]) { + calls.append(.capture(event, properties.mapValues { "\($0)" })) + } + + func reset() { calls.append(.reset) } + func optIn() { calls.append(.optIn) } + func optOut() { calls.append(.optOut) } + + var captured: [String] { + calls.compactMap { call in + if case .capture(let event, _) = call { return event } + return nil + } + } +} + +@MainActor +final class TelemetryFixture { + let defaults: UserDefaults + let clock = ManualClock() + var now = Date(timeIntervalSince1970: 1_793_000_000) + var openConnectionTypes = ["PostgreSQL", "MySQL", "PostgreSQL"] + private(set) var clients: [RecordingTelemetryClient] = [] + private(set) var telemetry: Telemetry! + + init(projectKey: String? = "phc_test") throws { + defaults = try #require(UserDefaults(suiteName: "com.TablePro.tests.Telemetry.\(UUID().uuidString)")) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC") ?? .current + telemetry = Telemetry( + projectKey: projectKey, + defaults: defaults, + makeClient: { [unowned self] _ in + let client = RecordingTelemetryClient() + clients.append(client) + return client + }, + clock: clock, + now: { [unowned self] in now }, + calendar: calendar + ) + } + + var client: RecordingTelemetryClient? { clients.last } + + func start(sharesUsageData: Bool) { + telemetry.start(sharesUsageData: sharesUsageData) { [unowned self] in openConnectionTypes } + } + + func passLaunchDelay() async throws { + await clock.waitUntilSleeping() + clock.advance(by: Telemetry.appOpenedDelay) + try await waitUntil { self.client?.captured.contains("app_opened") == true } + } + + func appOpenedCount() -> Int { + client?.captured.filter { $0 == "app_opened" }.count ?? 0 + } +} + +@MainActor +struct TelemetryTests { + @Test("A device that opted in to the old daily report keeps sharing, and app_opened goes once the app is in front") + func optedInBeforeStaysOn() async throws { + let fixture = try TelemetryFixture() + fixture.defaults.set(true, forKey: AppPreferences.usageDataKey) + fixture.start(sharesUsageData: OnboardingPreferences(defaults: fixture.defaults).isUsageDataEnabled) + fixture.telemetry.sceneBecameActive() + + try await fixture.passLaunchDelay() + + #expect(fixture.client?.calls == [ + .optIn, + .capture("app_opened", ["database_types": "[\"MySQL\", \"PostgreSQL\"]", "connection_count": "3"]) + ]) + } + + @Test("Nothing is sent until the user chooses Share Usage Data, whether they declined or never answered") + func notChosenMakesNoClient() throws { + for stored in [nil, false] as [Bool?] { + let fixture = try TelemetryFixture() + if let stored { fixture.defaults.set(stored, forKey: AppPreferences.usageDataKey) } + fixture.start(sharesUsageData: OnboardingPreferences(defaults: fixture.defaults).isUsageDataEnabled) + fixture.telemetry.sceneBecameActive() + + fixture.telemetry.connectionAttempted(databaseType: "PostgreSQL") + fixture.clock.advance(by: .seconds(48 * 60 * 60)) + + #expect(fixture.clients.isEmpty, "stored \(String(describing: stored))") + #expect(fixture.clock.pendingSleeperCount == 0, "stored \(String(describing: stored))") + } + } + + @Test("Choosing Share Usage Data on first launch starts sending, app_opened included") + func choosingShareStartsSending() async throws { + let fixture = try TelemetryFixture() + let preferences = OnboardingPreferences(defaults: fixture.defaults) + fixture.start(sharesUsageData: preferences.isUsageDataEnabled) + fixture.telemetry.sceneBecameActive() + + preferences.setUsageDataChoice(true) + fixture.telemetry.setEnabled(preferences.isUsageDataEnabled) + try await fixture.passLaunchDelay() + + #expect(fixture.client?.captured == ["app_opened"]) + } + + @Test("A build without a project key makes no client") + func noKeySendsNothing() throws { + for key in [nil, "", " ", "$(POSTHOG_PROJECT_KEY)"] { + let fixture = try TelemetryFixture(projectKey: key) + fixture.start(sharesUsageData: true) + fixture.telemetry.sceneBecameActive() + fixture.telemetry.setEnabled(true) + + fixture.telemetry.connectionAttempted(databaseType: "PostgreSQL") + fixture.telemetry.queryExecuted(databaseType: "PostgreSQL") + + #expect(fixture.clients.isEmpty, "key \(key ?? "nil")") + #expect(fixture.clock.pendingSleeperCount == 0, "key \(key ?? "nil")") + } + } + + @Test("Turning Share Usage Data off opts out and sends nothing more, even the next day") + func turningOffStopsEverything() async throws { + let fixture = try TelemetryFixture() + fixture.start(sharesUsageData: true) + fixture.telemetry.sceneBecameActive() + try await fixture.passLaunchDelay() + + fixture.telemetry.setEnabled(false) + try await waitUntil { fixture.clock.pendingSleeperCount == 0 } + fixture.telemetry.connectionAttempted(databaseType: "PostgreSQL") + fixture.now = fixture.now.addingTimeInterval(24 * 60 * 60) + fixture.clock.advance(by: .seconds(48 * 60 * 60)) + + let client = try #require(fixture.client) + #expect(client.calls.last == .optOut) + #expect(client.captured == ["app_opened"]) + } + + @Test("A first use while the setting is off is not reported once it is turned on") + func firstUseWhileOffIsNotSentLater() throws { + let fixture = try TelemetryFixture() + fixture.start(sharesUsageData: false) + fixture.telemetry.connectionAttempted(databaseType: "PostgreSQL") + + fixture.telemetry.setEnabled(true) + fixture.telemetry.connectionAttempted(databaseType: "PostgreSQL") + + #expect(fixture.client?.calls == [.optIn]) + } + + @Test("A launch in the background, such as a sync refresh, sends no app_opened") + func backgroundLaunchIsNotAnOpen() async throws { + let fixture = try TelemetryFixture() + fixture.start(sharesUsageData: true) + + #expect(fixture.client?.calls == [.optIn]) + #expect(fixture.clock.pendingSleeperCount == 0) + + fixture.telemetry.sceneBecameActive() + try await fixture.passLaunchDelay() + } + + @Test("Going to the background before the delay ends sends no app_opened") + func backgroundCancelsAppOpened() async throws { + let fixture = try TelemetryFixture() + fixture.start(sharesUsageData: true) + fixture.telemetry.sceneBecameActive() + await fixture.clock.waitUntilSleeping() + + fixture.telemetry.sceneEnteredBackground() + try await waitUntil { fixture.clock.pendingSleeperCount == 0 } + fixture.clock.advance(by: Telemetry.appOpenedDelay) + + #expect(fixture.appOpenedCount() == 0) + } + + @Test("app_opened goes once per local day") + func appOpenedOncePerDay() async throws { + let fixture = try TelemetryFixture() + fixture.start(sharesUsageData: true) + fixture.telemetry.sceneBecameActive() + try await fixture.passLaunchDelay() + + fixture.now = fixture.now.addingTimeInterval(60 * 60) + await fixture.clock.waitUntilSleeping() + fixture.clock.advance(by: Telemetry.appOpenedCheckInterval) + await fixture.clock.waitUntilSleeping() + #expect(fixture.appOpenedCount() == 1, "same day") + + fixture.telemetry.sceneEnteredBackground() + fixture.telemetry.sceneBecameActive() + await fixture.clock.waitUntilSleeping() + fixture.clock.advance(by: Telemetry.appOpenedDelay) + await fixture.clock.waitUntilSleeping() + #expect(fixture.appOpenedCount() == 1, "back in front the same day") + + fixture.now = fixture.now.addingTimeInterval(24 * 60 * 60) + fixture.clock.advance(by: Telemetry.appOpenedCheckInterval) + try await waitUntil { fixture.appOpenedCount() == 2 } + } + + @Test("First connection attempt, success and query go once per install with the database type") + func firstUseOncePerInstall() throws { + let fixture = try TelemetryFixture() + fixture.start(sharesUsageData: true) + + for _ in 0..<2 { + fixture.telemetry.connectionAttempted(databaseType: "SQL Server") + fixture.telemetry.connectionSucceeded(databaseType: "SQL Server") + fixture.telemetry.queryExecuted(databaseType: "MySQL") + } + + #expect(fixture.client?.calls == [ + .optIn, + .capture("connection_attempted", ["database_type": "SQL Server"]), + .capture("connection_succeeded", ["database_type": "SQL Server"]), + .capture("first_query_executed", ["database_type": "MySQL"]) + ]) + } + + @Test("A device updated from the daily report keeps its first-use dates and does not send them again") + func heartbeatDatesCarryOver() throws { + let fixture = try TelemetryFixture() + for key in [ + Telemetry.Keys.connectionAttemptedAt, Telemetry.Keys.connectionSucceededAt, Telemetry.Keys.firstQueryExecutedAt + ] { + fixture.defaults.set(Date(timeIntervalSince1970: 1_780_000_000), forKey: key) + } + fixture.start(sharesUsageData: true) + + fixture.telemetry.connectionAttempted(databaseType: "PostgreSQL") + fixture.telemetry.connectionSucceeded(databaseType: "PostgreSQL") + fixture.telemetry.queryExecuted(databaseType: "PostgreSQL") + + #expect(fixture.client?.calls == [.optIn]) + } + + #if DEBUG + @Test("A Debug build carries no PostHog project key, so local runs and tests never make a client") + func debugBuildHasNoProjectKey() { + #expect(PostHogTelemetryClient.projectKey(from: Bundle.main.object(forInfoDictionaryKey: "PostHogProjectKey")) == nil) + } + #endif + + @Test("The PostHog client on iOS collects nothing on its own and shows nothing") + func postHogConfiguration() { + let config = PostHogTelemetryClient.configuration(projectKey: "phc_test") + + #expect(config.host.absoluteString == "https://eu.i.posthog.com") + #expect(config.projectToken == "phc_test") + #expect(!config.captureApplicationLifecycleEvents) + #expect(!config.captureScreenViews) + #expect(!config.enableSwizzling) + #expect(!config.capturePushNotificationOpened) + #expect(!config.capturePushNotificationSubscriptions) + #expect(!config.captureElementInteractions) + #expect(!config.rageClickConfig.enabled) + #expect(!config.sessionReplay) + #expect(!config.surveys) + #expect(!config.preloadFeatureFlags) + #expect(!config.sendFeatureFlagEvent) + #expect(!config.errorTrackingConfig.autoCapture) + #expect(config.personProfiles == .identifiedOnly) + #expect(config.flushAt == 1) + let protocols = config.urlSessionConfiguration?.protocolClasses ?? [] + #expect(protocols.first.map(ObjectIdentifier.init) == ObjectIdentifier(PostHogTransportGate.self)) + } + + @Test("While usage data is off, a request through the PostHog session fails before it reaches the network") + func transportGateRefusesWhileOff() async throws { + let session = try #require(PostHogTelemetryClient.configuration(projectKey: "phc_test").urlSessionConfiguration) + let request = URLRequest(url: try #require(URL(string: "https://eu.i.posthog.com/batch"))) + let wasOpen = PostHogTransportGate.isOpen + defer { PostHogTransportGate.isOpen = wasOpen } + + PostHogTransportGate.isOpen = false + await #expect(throws: URLError.self) { + _ = try await URLSession(configuration: session).data(for: request) + } + + PostHogTransportGate.isOpen = true + #expect(!PostHogTransportGate.canInit(with: request)) + } +} diff --git a/apple/TableProMobile/project.yml b/apple/TableProMobile/project.yml index 7855c9e846..9116d12957 100644 --- a/apple/TableProMobile/project.yml +++ b/apple/TableProMobile/project.yml @@ -30,6 +30,10 @@ packages: path: ../Packages/TableProCore TableProOracle: path: ../Packages/TableProOracle + # Usage data (clients.md, iOS). The same pin as ../project.yml; a build sends it only with POSTHOG_PROJECT_KEY set. + PostHog: + url: https://github.com/PostHog/posthog-ios + exactVersion: 3.92.7 targets: @@ -69,6 +73,8 @@ targets: - ../Plugins/Shared/PluginQueryTimeout.swift # The toolbar glyph rule both apps follow on and before the 26 releases. - ../TablePro/Core/Services/Infrastructure/Toolbar/ToolbarSymbols.swift + # The PostHog client and what it may collect, the same in both apps. + - ../TablePro/Core/Services/Telemetry/PostHogTelemetryClient.swift # The ClickHouse plugin driver runs whole behind PluginBackedDriver, so every file of the plugin is compiled in. - path: ../Plugins/ClickHouseDriverPlugin excludes: @@ -136,7 +142,6 @@ targets: destination: plugins - package: TableProCore products: - - TableProAnalytics - TableProAutocomplete - TableProConnectionLibrary - TableProCoreTypes @@ -164,6 +169,7 @@ targets: - TableProTLSClientIdentity - package: TableProOracle product: TableProOracleCore + - package: PostHog - framework: ../Libs/ios/DuckDB.xcframework embed: false - framework: ../Libs/ios/FreeTDS.xcframework @@ -267,6 +273,8 @@ targets: - target: TableProMobile - package: TableProCore product: TableProTLSTestFixtures + - package: TableProCore + product: TableProTestSupport settings: base: CURRENT_PROJECT_VERSION: 1 diff --git a/apple/TableProTests/Core/Services/Telemetry/TelemetryTests.swift b/apple/TableProTests/Core/Services/Telemetry/TelemetryTests.swift index 442341d615..55622df7f7 100644 --- a/apple/TableProTests/Core/Services/Telemetry/TelemetryTests.swift +++ b/apple/TableProTests/Core/Services/Telemetry/TelemetryTests.swift @@ -171,14 +171,21 @@ struct TelemetryTests { @Test("The Info.plist value reads as no key when empty or when the build setting was never defined") func projectKeyFromInfoPlist() { - #expect(Telemetry.projectKey(from: nil) == nil) - #expect(Telemetry.projectKey(from: "") == nil) - #expect(Telemetry.projectKey(from: " \n") == nil) - #expect(Telemetry.projectKey(from: "$(POSTHOG_PROJECT_KEY)") == nil) - #expect(Telemetry.projectKey(from: 42) == nil) - #expect(Telemetry.projectKey(from: " phc_abc ") == "phc_abc") + #expect(PostHogTelemetryClient.projectKey(from: nil) == nil) + #expect(PostHogTelemetryClient.projectKey(from: "") == nil) + #expect(PostHogTelemetryClient.projectKey(from: " \n") == nil) + #expect(PostHogTelemetryClient.projectKey(from: "$(POSTHOG_PROJECT_KEY)") == nil) + #expect(PostHogTelemetryClient.projectKey(from: 42) == nil) + #expect(PostHogTelemetryClient.projectKey(from: " phc_abc ") == "phc_abc") } + #if DEBUG + @Test("A Debug build carries no PostHog project key, so local runs and tests never make a client") + func debugBuildHasNoProjectKey() { + #expect(PostHogTelemetryClient.projectKey(from: Bundle.main.object(forInfoDictionaryKey: "PostHogProjectKey")) == nil) + } + #endif + @Test("With Share Usage Data off no client is made, so PostHog is never contacted") func optedOutMakesNoClient() async throws { let fixture = try TelemetryFixture(sharesUsageData: false) @@ -497,5 +504,7 @@ struct TelemetryTests { #expect(!config.errorTrackingConfig.autoCapture) #expect(config.personProfiles == .identifiedOnly) #expect(config.flushAt == 1) + let protocols = config.urlSessionConfiguration?.protocolClasses ?? [] + #expect(protocols.first.map(ObjectIdentifier.init) == ObjectIdentifier(PostHogTransportGate.self)) } } diff --git a/apple/changelog.d/4058.changed.md b/apple/changelog.d/4058.changed.md new file mode 100644 index 0000000000..273e06eb52 --- /dev/null +++ b/apple/changelog.d/4058.changed.md @@ -0,0 +1 @@ +On iOS, usage data goes to PostHog instead of TablePro's server, and only after you choose Share Usage Data.