diff --git a/CHANGELOG.md b/CHANGELOG.md index b57e3a5..e62f1ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ The format has been adjusted to include a release Title. This has been inserted ## [Unreleased] +### Changed +- Moved to TelemetryDeck for private telemetry. + ### Fixed - Moved files are now properly trackeed. diff --git a/Config.local.example.xcconfig b/Config.local.example.xcconfig index 2e8188f..1be22ad 100644 --- a/Config.local.example.xcconfig +++ b/Config.local.example.xcconfig @@ -21,8 +21,5 @@ DEVELOPMENT_TEAM = // e.g. abc123def456@o1234567.ingest.us.sentry.io/7654321 SENTRY_DSN = -// e.g. phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -POSTHOG_PROJECT_TOKEN = - -// e.g. us.i.posthog.com -POSTHOG_HOST = +// e.g. XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX +TELEMETRY_DECK_ID = diff --git a/Config.xcconfig b/Config.xcconfig index 488786f..b67fe0a 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -20,7 +20,7 @@ DEVELOPMENT_TEAM = // MARK: - Telemetry // -// Minna ships with crash reporting (Sentry) and privacy-focused product analytics (PostHog). Both are DISABLED when these values are blank, which is the default for all contributor builds — a source build reports nothing, anywhere, unless you deliberately opt in below. +// Minna ships with crash reporting (Sentry) and privacy-focused product analytics (TelemetryDeck). Both are DISABLED when these values are blank, which is the default for all contributor builds — a source build reports nothing, anywhere, unless you deliberately opt in below. // // NOTE: xcconfig treats "//" as the start of a comment, so URLs here are stored WITHOUT their "https://" scheme. Minna re-adds the scheme at runtime. Do not paste a full URL into these values. @@ -29,11 +29,8 @@ DEVELOPMENT_TEAM = // Store as: @.ingest.us.sentry.io/ SENTRY_DSN = -// PostHog project token (starts with "phc_"). -POSTHOG_PROJECT_TOKEN = - -// PostHog host with the leading "https://" removed, e.g. us.i.posthog.com -POSTHOG_HOST = +// TelemetryDeck project token (starts with "XXXX-"). +TELEMETRY_DECK_ID = // MARK: - Local overrides // diff --git a/Minna.xcodeproj/project.pbxproj b/Minna.xcodeproj/project.pbxproj index 0e3cf84..c703f45 100644 --- a/Minna.xcodeproj/project.pbxproj +++ b/Minna.xcodeproj/project.pbxproj @@ -27,9 +27,9 @@ DB9696AA2FF492BF00CB037A /* DatabaseSchema in Frameworks */ = {isa = PBXBuildFile; productRef = DB9696A92FF492BF00CB037A /* DatabaseSchema */; }; DB9696AC2FF492BF00CB037A /* MinnaChat in Frameworks */ = {isa = PBXBuildFile; productRef = DB9696AB2FF492BF00CB037A /* MinnaChat */; }; DB9696AE2FF492C000CB037A /* ModelManager in Frameworks */ = {isa = PBXBuildFile; productRef = DB9696AD2FF492C000CB037A /* ModelManager */; }; - DBA6E66130254545002A94F2 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = DBA6E66030254545002A94F2 /* PostHog */; }; DBD7CF502FF5689400A2CA0A /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = DBD7CF4F2FF5689400A2CA0A /* KeychainSwift */; }; DBD7CFBB2FF59FEC00A2CA0A /* ModernSettingsWindow in Frameworks */ = {isa = PBXBuildFile; productRef = DBD7CFBA2FF59FEC00A2CA0A /* ModernSettingsWindow */; }; + DBFCD2C2303F922000A99DC6 /* TelemetryDeck in Frameworks */ = {isa = PBXBuildFile; productRef = DBFCD2C1303F922000A99DC6 /* TelemetryDeck */; }; DBFD0AA8300EA44F0060744B /* LookAtMe in Frameworks */ = {isa = PBXBuildFile; productRef = DBFD0AA7300EA44F0060744B /* LookAtMe */; }; /* End PBXBuildFile section */ @@ -168,7 +168,6 @@ DB0CF59F2FEDCD1D0036306B /* SentrySPM in Frameworks */, DB0CF5892FEDCB9E0036306B /* ViewStorageClient in Frameworks */, DB2BB96530052C7D00900E62 /* IrisSearch in Frameworks */, - DBA6E66130254545002A94F2 /* PostHog in Frameworks */, DBFD0AA8300EA44F0060744B /* LookAtMe in Frameworks */, DBD7CF502FF5689400A2CA0A /* KeychainSwift in Frameworks */, DB9695562FF440F200CB037A /* Textual in Frameworks */, @@ -180,6 +179,7 @@ DB6E4A823013CB6F00487D86 /* AppleIntelligenceEmbedder in Frameworks */, DB9696AA2FF492BF00CB037A /* DatabaseSchema in Frameworks */, DB9696AC2FF492BF00CB037A /* MinnaChat in Frameworks */, + DBFCD2C2303F922000A99DC6 /* TelemetryDeck in Frameworks */, DB2BB96130052C7D00900E62 /* Digester in Frameworks */, DB2BB96330052C7D00900E62 /* IrisCommon in Frameworks */, DB6E4FAB301415E300487D86 /* ModelCDN in Frameworks */, @@ -312,8 +312,8 @@ DB6E4A813013CB6F00487D86 /* AppleIntelligenceEmbedder */, DB6E4A833013CB6F00487D86 /* CoreMLEmbedder */, DB6E4FAA301415E300487D86 /* ModelCDN */, - DBA6E66030254545002A94F2 /* PostHog */, DB00BAE43026861500B94A32 /* Logging */, + DBFCD2C1303F922000A99DC6 /* TelemetryDeck */, ); productName = Minna; productReference = DB0CF4D02FEDCA6B0036306B /* Minna.app */; @@ -435,8 +435,8 @@ DB9695542FF440F200CB037A /* XCRemoteSwiftPackageReference "textual" */, DBD7CF4E2FF5689400A2CA0A /* XCRemoteSwiftPackageReference "keychain-swift" */, DBD7CFB92FF59FEC00A2CA0A /* XCRemoteSwiftPackageReference "ModernSettings" */, - DBA6E65F30254545002A94F2 /* XCRemoteSwiftPackageReference "posthog-ios" */, DB00BAE33026861500B94A32 /* XCRemoteSwiftPackageReference "swift-log" */, + DBFCD2C0303F922000A99DC6 /* XCRemoteSwiftPackageReference "SwiftSDK" */, ); preferredProjectObjectVersion = 77; productRefGroup = DB0CF4D12FEDCA6B0036306B /* Products */; @@ -1505,14 +1505,6 @@ minimumVersion = 0.5.0; }; }; - DBA6E65F30254545002A94F2 /* XCRemoteSwiftPackageReference "posthog-ios" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/PostHog/posthog-ios.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 3.69.2; - }; - }; DBD7CF4E2FF5689400A2CA0A /* XCRemoteSwiftPackageReference "keychain-swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/evgenyneu/keychain-swift"; @@ -1529,6 +1521,14 @@ minimumVersion = 1.0.0; }; }; + DBFCD2C0303F922000A99DC6 /* XCRemoteSwiftPackageReference "SwiftSDK" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/TelemetryDeck/SwiftSDK"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.14.2; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1617,11 +1617,6 @@ isa = XCSwiftPackageProductDependency; productName = ModelManager; }; - DBA6E66030254545002A94F2 /* PostHog */ = { - isa = XCSwiftPackageProductDependency; - package = DBA6E65F30254545002A94F2 /* XCRemoteSwiftPackageReference "posthog-ios" */; - productName = PostHog; - }; DBD7CF4F2FF5689400A2CA0A /* KeychainSwift */ = { isa = XCSwiftPackageProductDependency; package = DBD7CF4E2FF5689400A2CA0A /* XCRemoteSwiftPackageReference "keychain-swift" */; @@ -1637,6 +1632,11 @@ package = DB0CF5A12FEDD0970036306B /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */; productName = "plugin:SwiftLintBuildToolPlugin"; }; + DBFCD2C1303F922000A99DC6 /* TelemetryDeck */ = { + isa = XCSwiftPackageProductDependency; + package = DBFCD2C0303F922000A99DC6 /* XCRemoteSwiftPackageReference "SwiftSDK" */; + productName = TelemetryDeck; + }; DBFD0AA7300EA44F0060744B /* LookAtMe */ = { isa = XCSwiftPackageProductDependency; productName = LookAtMe; diff --git a/Minna.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Minna.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index bf453a3..d286ccb 100644 --- a/Minna.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Minna.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1d84db8793aeb2a1f256271089c7a2a869cb093234d0af61a18dfc6ac5ce8f8a", + "originHash" : "17ea74d33e418df11f2b6289a62b8f53f9f85ee6e63836c80eb5894c94d53c9b", "pins" : [ { "identity" : "anylanguagemodel", @@ -91,15 +91,6 @@ "version" : "1.0.0" } }, - { - "identity" : "posthog-ios", - "kind" : "remoteSourceControl", - "location" : "https://github.com/PostHog/posthog-ios.git", - "state" : { - "revision" : "21f0162f47b4f4d775cdde5f6a74bfe665333e65", - "version" : "3.69.2" - } - }, { "identity" : "sentry-cocoa", "kind" : "remoteSourceControl", @@ -280,6 +271,15 @@ "version" : "0.65.0" } }, + { + "identity" : "swiftsdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/TelemetryDeck/SwiftSDK", + "state" : { + "revision" : "58f436299d3f6710bcedc18aff26480aaf1879fc", + "version" : "2.14.2" + } + }, { "identity" : "swiftsoup", "kind" : "remoteSourceControl", diff --git a/Minna/Database/FrontendDatabase.swift b/Minna/Database/FrontendDatabase.swift index d9c070d..bad14e2 100644 --- a/Minna/Database/FrontendDatabase.swift +++ b/Minna/Database/FrontendDatabase.swift @@ -82,7 +82,7 @@ class FrontendDatabase: Database { private func sendAnalytics() throws { let fileCount = (try? context.fetchCount(FetchDescriptor())) ?? 0 let askMinnaCount = (try? context.fetchCount(FetchDescriptor())) ?? 0 - TelemetryWrapper.startup(fileCount: fileCount, askMinnaCount: askMinnaCount) + TelemetryWrapper.shared.startup(fileCount: fileCount, askMinnaCount: askMinnaCount) } private func populateStartupData() throws { diff --git a/Minna/Info.plist b/Minna/Info.plist index 22c6f20..a38b252 100644 --- a/Minna/Info.plist +++ b/Minna/Info.plist @@ -36,10 +36,8 @@ - POSTHOG_HOST - $(POSTHOG_HOST) - POSTHOG_PROJECT_TOKEN - $(POSTHOG_PROJECT_TOKEN) + TELEMETRY_DECK_ID + $(TELEMETRY_DECK_ID) SENTRY_DSN $(SENTRY_DSN) SUEnableInstallerLauncherService diff --git a/Minna/IrisApp.swift b/Minna/IrisApp.swift index 50bd82f..d2600a1 100644 --- a/Minna/IrisApp.swift +++ b/Minna/IrisApp.swift @@ -13,7 +13,6 @@ import ModelManager import ModernSettingsWindow import Logging import SFSafeSymbols -import PostHog #if SPARKLE import Sparkle @@ -77,13 +76,9 @@ struct MinnaApp: App { } } } - - // Product analytics are opt-in via Config.xcconfig on the same terms. - if let projectToken = BuildConfiguration.postHogProjectToken, - let host = BuildConfiguration.postHogHost { - let config = PostHogConfig(projectToken: projectToken, host: host) - PostHogSDK.shared.setup(config) - } + + // Initialize the telemetry wrapper + TelemetryWrapper.shared.didLaunch() #if SPARKLE // Don't start the sparkle updater under XCTest. Unit tests on CI will fail since sparkle opens a popup asking when to update which hangs the process. diff --git a/Minna/TelemetryWrapper.swift b/Minna/TelemetryWrapper.swift index 5284ed4..df55b7c 100644 --- a/Minna/TelemetryWrapper.swift +++ b/Minna/TelemetryWrapper.swift @@ -5,35 +5,78 @@ // Created by Taylor Lineman on 8/6/26. // -import PostHog +import TelemetryDeck +import Logging fileprivate extension String { static let startup = "startup" static let chat = "chat" + static let didLaunch = "didLaunch" + static let onboardingStage = "onboardingStage" } -struct TelemetryWrapper { +final class TelemetryWrapper { enum ChatLocation: String { case askMinna case askDoc } - static func startup(fileCount: Int, askMinnaCount: Int) { + enum OnboardingLocation: String { + case intro + case overview + case modelQuestionnaire + case inferenceDownload + case inferenceDownloadSkipped + case providerSetup + } + + static let shared: TelemetryWrapper = TelemetryWrapper() + + var initialized: Bool = false + + private init() { + // Product analytics are opt-in via Config.xcconfig on the same terms. + guard let appID = BuildConfiguration.telemetryDeckID else { + Log.logger.warning("Not initializing telemetry: Could not find App ID") + return + } + + let config = TelemetryDeck.Config(appID: appID) + TelemetryDeck.initialize(config: config) + initialized = true + } + + /// Used to track daily active users + func didLaunch() { + guard initialized else { Log.logger.warning("Telemetry not initialized!"); return } + TelemetryDeck.signal(.didLaunch) + } + + func startup(fileCount: Int, askMinnaCount: Int) { + guard initialized else { Log.logger.warning("Telemetry not initialized!"); return } let payload = [ - "files": fileCount, - "askMinna": askMinnaCount + "files": "\(fileCount)", + "askMinna": "\(askMinnaCount)" ] - PostHogSDK.shared.capture(.startup, properties: payload) + TelemetryDeck.signal(.startup, parameters: payload) } - static func chat(model: String, location: ChatLocation) { + func chat(model: String, location: ChatLocation) { + guard initialized else { Log.logger.warning("Telemetry not initialized!"); return } let payload = [ "model": model, "location": location.rawValue ] - PostHogSDK.shared.capture(.chat, properties: payload) + TelemetryDeck.signal(.chat, parameters: payload) } -} + + func onboardingStage(stage: TelemetryWrapper.OnboardingLocation) { + guard initialized else { Log.logger.warning("Telemetry not initialized!"); return } + let payload = ["stage": stage.rawValue] + + TelemetryDeck.signal(.onboardingStage, parameters: payload) + } +} diff --git a/Minna/Utility/BuildConfiguration.swift b/Minna/Utility/BuildConfiguration.swift index dd1573f..81ca88d 100644 --- a/Minna/Utility/BuildConfiguration.swift +++ b/Minna/Utility/BuildConfiguration.swift @@ -20,22 +20,9 @@ enum BuildConfiguration { value(for: "SENTRY_DSN").map { "https://\($0)" } } - /// The PostHog project token, or `nil` when analytics should stay disabled. - /// - Authored by: Claude Opus 5 (Anthropic) - static var postHogProjectToken: String? { - value(for: "POSTHOG_PROJECT_TOKEN") - } - - /// The PostHog host, or `nil` when analytics should stay disabled. - /// - Authored by: Claude Opus 5 (Anthropic) - static var postHogHost: String? { - value(for: "POSTHOG_HOST").map { "https://\($0)" } - } - - /// Whether product analytics are configured for this build. - /// - Authored by: Claude Opus 5 (Anthropic) - static var isAnalyticsEnabled: Bool { - postHogProjectToken != nil && postHogHost != nil + /// The TelemetryDeck unique app id, or `nil` when analytics should stay disabled. + static var telemetryDeckID: String? { + value(for: "TELEMETRY_DECK_ID") } /// Reads a string from `Info.plist`, treating unconfigured values as absent. diff --git a/Minna/Views/Onboarding/DownloadingModelView.swift b/Minna/Views/Onboarding/DownloadingModelView.swift index cb627db..296cef7 100644 --- a/Minna/Views/Onboarding/DownloadingModelView.swift +++ b/Minna/Views/Onboarding/DownloadingModelView.swift @@ -61,6 +61,7 @@ struct DownloadingModelView: View { let stream = NotificationCenter.default.messages(of: modelManager, for: DownloadDidFinish.self) for await download in stream where download.identifier == modelIdentifier { + TelemetryWrapper.shared.onboardingStage(stage: .inferenceDownload) onboardingRouter.inferenceFinished(modelManager: modelManager) } } @@ -74,6 +75,7 @@ struct DownloadingModelView: View { } .onAppear { if modelManager.doesModelExistOnDisk(identifier: modelIdentifier) { + TelemetryWrapper.shared.onboardingStage(stage: .inferenceDownload) onboardingRouter.inferenceFinished(modelManager: modelManager) } } @@ -84,6 +86,7 @@ struct DownloadingModelView: View { Group { if canSkipDownload { Button("Skip Downloading") { + TelemetryWrapper.shared.onboardingStage(stage: .inferenceDownloadSkipped) onboardingRouter.inferenceFinished(modelManager: modelManager) } } else { @@ -127,6 +130,7 @@ struct DownloadingModelView: View { if canSkipDownload { Button("Next") { + TelemetryWrapper.shared.onboardingStage(stage: .inferenceDownloadSkipped) onboardingRouter.inferenceFinished(modelManager: modelManager) } .controlSize(.extraLarge) diff --git a/Minna/Views/Onboarding/FeatureOverview.swift b/Minna/Views/Onboarding/FeatureOverview.swift index 1c6b35a..78eae40 100644 --- a/Minna/Views/Onboarding/FeatureOverview.swift +++ b/Minna/Views/Onboarding/FeatureOverview.swift @@ -33,6 +33,7 @@ struct FeatureOverview: View { ForEach(FeatureOverview.pages) { page in HStack { OnboardingCard(page: page) { + TelemetryWrapper.shared.onboardingStage(stage: .overview) onboardingRouter.overviewFinished() } next: { withAnimation { @@ -41,6 +42,7 @@ struct FeatureOverview: View { if nextIndex < FeatureOverview.pages.count { self.currentPage = FeatureOverview.pages[nextIndex] } else { + TelemetryWrapper.shared.onboardingStage(stage: .overview) onboardingRouter.overviewFinished() } } diff --git a/Minna/Views/Onboarding/IntroVideoView.swift b/Minna/Views/Onboarding/IntroVideoView.swift index 5cd8495..04476b5 100644 --- a/Minna/Views/Onboarding/IntroVideoView.swift +++ b/Minna/Views/Onboarding/IntroVideoView.swift @@ -19,10 +19,12 @@ struct IntroVideoView: View { HStack { if let url { EndAnnouncingVideoPlayer(url: url) { + TelemetryWrapper.shared.onboardingStage(stage: .intro) onboardingRouter.introFinished() } doneLoading: { showVideo = true } failedToLoad: { + TelemetryWrapper.shared.onboardingStage(stage: .intro) onboardingRouter.introFinished() } .opacity(showVideo ? 1 : 0) @@ -30,6 +32,7 @@ struct IntroVideoView: View { } else { ContentUnavailableView("Could not load intro video", systemSymbol: .videoSlash) .task { + TelemetryWrapper.shared.onboardingStage(stage: .intro) onboardingRouter.introFinished() } } diff --git a/Minna/Views/Onboarding/ModelSetupView.swift b/Minna/Views/Onboarding/ModelSetupView.swift index 5420b21..98cf9c9 100644 --- a/Minna/Views/Onboarding/ModelSetupView.swift +++ b/Minna/Views/Onboarding/ModelSetupView.swift @@ -29,11 +29,13 @@ struct ModelSetupView: View { .frame(width: 350) HStack { Button("With my own AI Providers") { + TelemetryWrapper.shared.onboardingStage(stage: .modelQuestionnaire) onboardingRouter.inferenceQuestionnaire(result: .providers, modelManager: modelManager) } .controlSize(.extraLarge) Button("Completely On Device") { + TelemetryWrapper.shared.onboardingStage(stage: .modelQuestionnaire) onboardingRouter.inferenceQuestionnaire(result: .onDevice, modelManager: modelManager) } .controlSize(.extraLarge) diff --git a/Minna/Views/Onboarding/ProviderSetupView.swift b/Minna/Views/Onboarding/ProviderSetupView.swift index 2a24997..e64c219 100644 --- a/Minna/Views/Onboarding/ProviderSetupView.swift +++ b/Minna/Views/Onboarding/ProviderSetupView.swift @@ -49,6 +49,7 @@ struct ProviderSetupView: View { .padding(.vertical) Button("Next") { + TelemetryWrapper.shared.onboardingStage(stage: .providerSetup) onboardingRouter.providersFinished(modelManager: modelManager) } .controlSize(.extraLarge) diff --git a/Minna/Views/Preview/FileChat.swift b/Minna/Views/Preview/FileChat.swift index 0d85656..4653635 100644 --- a/Minna/Views/Preview/FileChat.swift +++ b/Minna/Views/Preview/FileChat.swift @@ -161,9 +161,9 @@ struct FileChat: View { Task { do { if let model = chatter?.selectedModel { - TelemetryWrapper.chat(model: model.id, location: .askDoc) + TelemetryWrapper.shared.chat(model: model.id, location: .askDoc) } else { - TelemetryWrapper.chat(model: "unknown", location: .askDoc) + TelemetryWrapper.shared.chat(model: "unknown", location: .askDoc) } try await chatter?.submit() diff --git a/Minna/Views/Search/AskMinnaView.swift b/Minna/Views/Search/AskMinnaView.swift index 0ed2431..4e65600 100644 --- a/Minna/Views/Search/AskMinnaView.swift +++ b/Minna/Views/Search/AskMinnaView.swift @@ -252,9 +252,9 @@ struct AskMinnaView: View { Task { do { if let model = chatter.selectedModel { - TelemetryWrapper.chat(model: model.id, location: .askMinna) + TelemetryWrapper.shared.chat(model: model.id, location: .askMinna) } else { - TelemetryWrapper.chat(model: "unknown", location: .askMinna) + TelemetryWrapper.shared.chat(model: "unknown", location: .askMinna) } try await chatter.submit() diff --git a/README.md b/README.md index 7c0b10f..ba3b8ff 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ https://github.com/user-attachments/assets/d038c2bb-67a2-4760-bc72-8a670a0915a8 Minna takes privacy **very** seriously. We believe that your files are your own, and shouldn't be seen by anyone else. Any conversations with an AI go directly to the provide *you* chose, with *your* key. On-device models (MLX & Apple Foundation Models) make no network calls at all. All LLM provider configurations are encrpyted in the macOS keychain. For more information see [SECURITY.md](SECURITY.md). ## A note on Telemetry -Minna uses [PostHog](https://posthog.com/) for app telemetry. The goal of this telemetry is to get a *broad* understanding of Minna's userbase, and is never used for tracking. Personally Identifiable Information is stripped before being sent to PostHog, and the remote configurations are set to drop IP addresses. We do not want your data, so we do not store it. +Minna uses [TelemetryDeck](https://telemetrydeck.com/) for app telemetry. The goal of this telemetry is to get a *broad* understanding of Minna's userbase, and is never used for tracking. Personally Identifiable Information is not sent to TelemetryDeck, and the remote configurations are set to drop IP addresses. We do not want your data, so we do not store it. # Installation