418 lines
17 KiB
Swift
418 lines
17 KiB
Swift
//
|
|
// Copyright 2021-2024 New Vector Ltd.
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
// Please see LICENSE in the repository root for full details.
|
|
//
|
|
|
|
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)
|
|
client.updateSuperProperties(.init(appPlatform: .EI,
|
|
cryptoSDK: .Rust,
|
|
cryptoSDKVersion: session.crypto.version))
|
|
}
|
|
|
|
/// 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.client.updateSuperProperties(
|
|
AnalyticsEvent.SuperProperties(
|
|
appPlatform: .EI,
|
|
cryptoSDK: .Rust,
|
|
cryptoSDKVersion: session.crypto.version
|
|
)
|
|
)
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc
|
|
protocol E2EAnalytics {
|
|
func trackE2EEError(_ failure: DecryptionFailure)
|
|
}
|
|
|
|
|
|
@objc extension Analytics: E2EAnalytics {
|
|
|
|
/// Track an E2EE error that occurred
|
|
/// - Parameters:
|
|
/// - reason: The error that occurred.
|
|
/// - context: Additional context of the error that occured
|
|
func trackE2EEError(_ failure: DecryptionFailure) {
|
|
let event = failure.toAnalyticsEvent()
|
|
capture(event: event)
|
|
}
|
|
|
|
}
|
|
|
|
// 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,
|
|
recoveryState: nil,
|
|
verificationState: nil)
|
|
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 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, cryptoSDK: nil, domain: .VOIP, eventLocalAgeMillis: nil,
|
|
isFederated: nil, isMatrixDotOrg: nil, name: reason.errorName, timeToDecryptMillis: nil, userTrustsOwnIdentity: nil, wasVisibleToUser: nil)
|
|
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,
|
|
messageType: .Text,
|
|
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] = [:]
|
|
}
|
|
}
|