// // Copyright 2021 New Vector Ltd // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // import PostHog import AnalyticsEvents /// A class responsible for managing a variety of analytics clients /// and sending events through these clients. /// /// Events may include user activity, or app health data such as crashes, /// non-fatal issues and performance. `Analytics` class serves as a façade /// to all these use cases. /// /// Events are managed in a shared repo for all Element clients /// https://github.com/matrix-org/matrix-analytics-events and integrated via SwiftPM /// @objcMembers class Analytics: NSObject { // MARK: - Properties /// The singleton instance to be used within the Riot target. static let shared = Analytics() /// The analytics client to send events with. private var client: AnalyticsClientProtocol = PostHogAnalyticsClient.shared /// The monitoring client to track crashes, issues and performance private var monitoringClient = SentryMonitoringClient() /// The service used to interact with account data settings. private var service: AnalyticsService? private var viewRoomActiveSpace: AnalyticsViewRoomActiveSpace = .home /// Whether or not the object is enabled and sending events to the server. var isRunning: Bool { client.isRunning } /// Whether to show the user the analytics opt in prompt. var shouldShowAnalyticsPrompt: Bool { // Only show the prompt once, and when analytics are enabled in BuildSettings. !RiotSettings.shared.hasSeenAnalyticsPrompt && BuildSettings.analyticsConfiguration.isEnabled } /// Indicates whether the user previously accepted Matomo analytics and should be shown the upgrade prompt. var promptShouldDisplayUpgradeMessage: Bool { RiotSettings.shared.hasAcceptedMatomoAnalytics } /// Used to defined the trigger of the next potential `JoinedRoom` event var joinedRoomTrigger: AnalyticsJoinedRoomTrigger = .unknown /// Used to defined the trigger of the next potential `ViewRoom` event var viewRoomTrigger: AnalyticsViewRoomTrigger = .unknown /// Used to defined the actual space activated by the user. var activeSpace: MXSpace? { didSet { updateViewRoomActiveSpace() } } /// Used to defined the currently visible space in explore rooms. var exploringSpace: MXSpace? { didSet { updateViewRoomActiveSpace() } } // MARK: - Private /// keep an instance of `AnalyticsSpaceTracker` to track space metrics when space graph is built. private let spaceTracker: AnalyticsSpaceTracker = AnalyticsSpaceTracker() // MARK: - Public /// Opts in to analytics tracking with the supplied session. /// - Parameter session: An optional session to use to when reading/generating the analytics ID. /// The session will be ignored if not running. func optIn(with session: MXSession?) { RiotSettings.shared.enableAnalytics = true startIfEnabled() guard let session = session else { return } useAnalyticsSettings(from: session) } /// Stops analytics tracking and calls `reset` to clear any IDs and event queues. func optOut() { RiotSettings.shared.enableAnalytics = false // The order is important here. PostHog ignores the reset if stopped. reset() client.stop() monitoringClient.stop() MXLog.debug("[Analytics] Stopped.") } /// Starts the analytics client if the user has opted in, otherwise does nothing. func startIfEnabled() { guard RiotSettings.shared.enableAnalytics, !isRunning else { return } client.start() monitoringClient.start() // Sanity check in case something went wrong. guard client.isRunning else { return } MXLog.debug("[Analytics] Started.") if Bundle.main.isShareExtension { // Don't log crashes in the share extension } else { // Catch and log crashes MXLogger.logCrashes(true) } MXLogger.setBuildVersion(AppInfo.current.buildInfo.readableBuildVersion) } /// Use the analytics settings from the supplied session to configure analytics. /// For now this is only used for (pseudonymous) identification. /// - Parameter session: The session to read analytics settings from. func useAnalyticsSettings(from session: MXSession) { guard RiotSettings.shared.enableAnalytics, !RiotSettings.shared.isIdentifiedForAnalytics else { return } let service = AnalyticsService(session: session) self.service = service service.settings { [weak self] result in guard let self = self else { return } switch result { case .success(let settings): self.identify(with: settings) self.service = nil case .failure: MXLog.error("[Analytics] Failed to use analytics settings. Will continue to run without analytics ID.") self.service = nil } } } /// Resets the any IDs and event queues in the analytics client. This method should /// be called on sign-out to maintain opt-in status, whilst ensuring the next /// account used isn't associated with the previous one. /// Note: **MUST** be called before stopping PostHog or the reset is ignored. func reset() { client.reset() monitoringClient.reset() MXLog.debug("[Analytics] Reset.") RiotSettings.shared.isIdentifiedForAnalytics = false // Stop collecting crash logs MXLogger.logCrashes(false) } /// Flushes the event queue in the analytics client, uploading all pending events. /// Normally events are sent in batches. Call this method when you need an event /// to be sent immediately. func forceUpload() { client.flush() } // MARK: - Private /// Identify (pseudonymously) any future events with the ID from the analytics account data settings. /// - Parameter settings: The settings to use for identification. The ID must be set *before* calling this method. private func identify(with settings: AnalyticsSettings) { guard let id = settings.id else { MXLog.error("[Analytics] identify(with:) called before an ID has been generated.") return } client.identify(id: id) MXLog.debug("[Analytics] Identified.") RiotSettings.shared.isIdentifiedForAnalytics = true } /// Capture an event in the `client`. /// - Parameter event: The event to capture. private func capture(event: AnalyticsEventProtocol) { client.capture(event) } /// Update `viewRoomActiveSpace` property according to the current value of `exploringSpace` and `activeSpace` properties. private func updateViewRoomActiveSpace() { let space = exploringSpace ?? activeSpace guard let spaceRoom = space?.room else { viewRoomActiveSpace = .home return } spaceRoom.state { roomState in self.viewRoomActiveSpace = roomState?.isJoinRulePublic == true ? .public : .private } } } // MARK: - Public tracking methods // The following methods are exposed for compatibility with Objective-C as // the `capture` method and the generated events cannot be bridged from Swift. extension Analytics { /// Updates any user properties to help with creating cohorts. /// /// Only non-nil properties will be updated when calling this method. func updateUserProperties(ftueUseCase: UserSessionProperties.UseCase? = nil, numFavouriteRooms: Int? = nil, numSpaces: Int? = nil, allChatsActiveFilter: UserSessionProperties.AllChatsActiveFilter? = nil) { let userProperties = AnalyticsEvent.UserProperties(allChatsActiveFilter: allChatsActiveFilter?.analyticsName, ftueUseCaseSelection: ftueUseCase?.analyticsName, numFavouriteRooms: numFavouriteRooms, numSpaces: numSpaces) client.updateUserProperties(userProperties) } /// Track the registration of a new user. /// - Parameter authenticationType: The type of authentication that was used. func trackSignup(authenticationType: AnalyticsEvent.Signup.AuthenticationType) { let event = AnalyticsEvent.Signup(authenticationType: authenticationType) capture(event: event) } /// Track the presentation of a screen /// - Parameters: /// - screen: The screen that was shown. /// - milliseconds: An optional value representing how long the screen was shown for in milliseconds. func trackScreen(_ screen: AnalyticsScreen, duration milliseconds: Int?) { let event = AnalyticsEvent.MobileScreen(durationMs: milliseconds, screenName: screen.screenName) client.screen(event) } /// The the presentation of a screen without including a duration /// - Parameter screen: The screen that was shown func trackScreen(_ screen: AnalyticsScreen) { trackScreen(screen, duration: nil) } /// Track an element that has been interacted with /// - Parameters: /// - uiElement: The element that was interacted with /// - interactionType: The way in with the element was interacted with /// - index: The index of the element, if it's in a list of elements func trackInteraction(_ uiElement: AnalyticsUIElement, interactionType: AnalyticsEvent.Interaction.InteractionType, index: Int?) { let event = AnalyticsEvent.Interaction(index: index, interactionType: interactionType, name: uiElement.name) client.capture(event) } /// Track an element that has been tapped without including an index /// - Parameters: /// - uiElement: The element that was tapped func trackInteraction(_ uiElement: AnalyticsUIElement) { trackInteraction(uiElement, interactionType: .Touch, index: nil) } /// Track an E2EE error that occurred /// - Parameters: /// - reason: The error that occurred. /// - context: Additional context of the error that occured func trackE2EEError(_ reason: DecryptionFailureReason, context: String) { let event = AnalyticsEvent.Error( context: context, cryptoModule: .Rust, domain: .E2EE, name: reason.errorName ) capture(event: event) } /// Track when a user becomes unauthenticated without pressing the `sign out` button. /// - Parameters: /// - softLogout: Wether it was a soft/hard logout that was triggered. /// - refreshTokenAuth: Wether it was either an access-token-based or refresh-token-based auth mechanism enabled. /// - errorCode: The error code as returned by the homeserver that triggered the logout. /// - errorReason: The reason for the error as returned by the homeserver that triggered the logout. func trackAuthUnauthenticatedError(softLogout: Bool, refreshTokenAuth: Bool, errorCode: String, errorReason: String) { let errorCode = AnalyticsEvent.UnauthenticatedError.ErrorCode(rawValue: errorCode) ?? .M_UNKNOWN let event = AnalyticsEvent.UnauthenticatedError(errorCode: errorCode, errorReason: errorReason, refreshTokenAuth: refreshTokenAuth, softLogout: softLogout) client.capture(event) } /// Track whether the user accepted or declined the terms to an identity server. /// **Note** This method isn't currently implemented. /// - Parameter accepted: Whether the terms were accepted. func trackIdentityServerAccepted(_ accepted: Bool) { // Do we still want to track this? } /// Track view room event triggered when the user changes rooms. /// - Parameters: /// - room: the room being viewed func trackViewRoom(_ room: MXRoom) { trackViewRoom(asDM: room.isDirect, isSpace: room.summary?.roomType == .space) } /// Track view room event triggered when the user changes rooms. /// - Parameters: /// - isDM: Whether the room is a DM. /// - isSpace: Whether the room is a Space. func trackViewRoom(asDM isDM: Bool, isSpace: Bool) { let event = AnalyticsEvent.ViewRoom(activeSpace: viewRoomActiveSpace.space, isDM: isDM, isSpace: isSpace, trigger: viewRoomTrigger.trigger, viaKeyboard: nil) viewRoomTrigger = .unknown capture(event: event) } func trackCryptoSDKEnabled() { let event = AnalyticsEvent.CryptoSDKEnabled() capture(event: event) } } // MARK: - MXAnalyticsDelegate extension Analytics: MXAnalyticsDelegate { func trackDuration(_ milliseconds: Int, name: MXTaskProfileName, units: UInt) { guard let analyticsName = name.analyticsName else { MXLog.warning("[Analytics] Attempt to capture unknown profile task: \(name.rawValue)") return } let event = AnalyticsEvent.PerformanceTimer(context: nil, itemCount: Int(units), name: analyticsName, timeMs: milliseconds) capture(event: event) } func startDurationTracking(forName name: String, operation: String) -> StopDurationTracking { return monitoringClient.startPerformanceTracking(name: name, operation: operation) } func trackCallStarted(withVideo isVideo: Bool, numberOfParticipants: Int, incoming isIncoming: Bool) { let event = AnalyticsEvent.CallStarted(isVideo: isVideo, numParticipants: numberOfParticipants, placed: !isIncoming) capture(event: event) } func trackCallEnded(withDuration duration: Int, video isVideo: Bool, numberOfParticipants: Int, incoming isIncoming: Bool) { let event = AnalyticsEvent.CallEnded(durationMs: duration, isVideo: isVideo, numParticipants: numberOfParticipants, placed: !isIncoming) capture(event: event) } func trackCallError(with reason: __MXCallHangupReason, video isVideo: Bool, numberOfParticipants: Int, incoming isIncoming: Bool) { let callEvent = AnalyticsEvent.CallError(isVideo: isVideo, numParticipants: numberOfParticipants, placed: !isIncoming) let event = AnalyticsEvent.Error(context: nil, cryptoModule: nil, domain: .VOIP, name: reason.errorName) capture(event: callEvent) capture(event: event) } func trackCreatedRoom(asDM isDM: Bool) { let event = AnalyticsEvent.CreatedRoom(isDM: isDM) capture(event: event) } func trackJoinedRoom(asDM isDM: Bool, isSpace: Bool, memberCount: UInt) { guard let roomSize = AnalyticsEvent.JoinedRoom.RoomSize(memberCount: memberCount) else { MXLog.warning("[Analytics] Attempt to capture joined room with invalid member count: \(memberCount)") return } let event = AnalyticsEvent.JoinedRoom(isDM: isDM, isSpace: isSpace, roomSize: roomSize, trigger: joinedRoomTrigger.trigger) capture(event: event) self.joinedRoomTrigger = .unknown } /// **Note** This method isn't currently implemented. func trackContactsAccessGranted(_ granted: Bool) { // Do we still want to track this? } func trackComposerEvent(inThread: Bool, isEditing: Bool, isReply: Bool, startsThread: Bool) { let event = AnalyticsEvent.Composer(inThread: inThread, isEditing: isEditing, isReply: isReply, startsThread: startsThread) capture(event: event) } func trackNonFatalIssue(_ issue: String, details: [String: Any]?) { monitoringClient.trackNonFatalIssue(issue, details: details) } } /// iOS-specific analytics event triggered when users select the Crypto SDK labs option /// /// Due to this event being iOS only, and temporary during gradual rollout of Crypto SDK, /// this event is not added into the shared analytics schema extension AnalyticsEvent { struct CryptoSDKEnabled: AnalyticsEventProtocol { let eventName = "CryptoSDKEnabled" let properties: [String: Any] = [:] } }