Files
bundesmessenger-ios/Riot/Modules/Analytics/Analytics.swift
JanNiklas Grabowski 6c22fa37f1 Merge commit '56d9e1f6a55a93dc71149ae429eaa615a98de0d5' into feature/6076_foss_merge
* commit '56d9e1f6a55a93dc71149ae429eaa615a98de0d5': (79 commits)
  finish version++
  version++
  Translated using Weblate (Hungarian)
  Translated using Weblate (Italian)
  Translated using Weblate (Ukrainian)
  Translated using Weblate (Hungarian)
  Translated using Weblate (Slovak)
  Translated using Weblate (Swedish)
  Translated using Weblate (Indonesian)
  Translated using Weblate (Albanian)
  Translated using Weblate (Estonian)
  Translated using Weblate (Estonian)
  updated the submodule
  updated SDK
  Update the SDK. (#7819)
  Prepare for new sprint
  finish version++
  version++
  fix
  Changelog.
  ...

# Conflicts:
#	Config/AppVersion.xcconfig
#	Podfile
#	Podfile.lock
#	Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved
#	Riot/Managers/Settings/RiotSettings.swift
#	Riot/Modules/Analytics/Analytics.swift
#	Riot/Modules/Analytics/DecryptionFailure.swift
#	Riot/Modules/Analytics/PHGPostHogConfiguration.swift
#	Riot/Modules/Room/RoomInfo/RoomInfoList/RoomInfoListViewAction.swift
#	Riot/Modules/Room/RoomInfo/RoomInfoList/RoomInfoListViewModel.swift
#	Riot/Modules/Room/Views/Title/Preview/PreviewRoomTitleView.m
#	Riot/Modules/Settings/SettingsViewController.m
#	Riot/Utils/EventFormatter.m
#	Riot/Utils/Tools.m
#	RiotNSE/target.yml
#	fastlane/Fastfile
#	project.yml
2024-08-19 12:52:38 +02:00

475 lines
18 KiB
Swift

//
// 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.
//
#if POSTHOG
import PostHog
#endif
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()
#if POSTHOG
/// The analytics client to send events with.
private var client: AnalyticsClientProtocol = PostHogAnalyticsClient.shared
#endif
#if SENTRY
/// The monitoring client to track crashes, issues and performance
private var monitoringClient = SentryMonitoringClient()
#endif
/// 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 {
#if POSTHOG
client.isRunning
#else
false
#endif
}
/// 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 && BWIBuildSettings.shared.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()
#if POSTHOG
client.stop()
#endif
#if SENTRY
monitoringClient.stop()
#endif
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 }
#if POSTHOG
client.start()
#endif
#if SENTRY
monitoringClient.start()
#endif
// Sanity check in case something went wrong.
#if POSTHOG
guard client.isRunning else { return }
#else
return
#endif
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() {
#if POSTHOG
client.reset()
#endif
#if SENTRY
monitoringClient.reset()
#endif
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() {
#if POSTHOG
client.flush()
#endif
}
// 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
}
#if POSTHOG
client.identify(id: id)
#endif
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) {
#if POSTHOG
client.capture(event)
#endif
}
/// 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)
#if POSTHOG
client.updateUserProperties(userProperties)
#endif
}
/// 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)
#if POSTHOG
client.screen(event)
#endif
}
/// 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)
#if POSTHOG
client.capture(event)
#endif
}
/// 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)
#if POSTHOG
client.capture(event)
#endif
}
/// 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 {
#if SENTRY
return monitoringClient.startPerformanceTracking(name: name, operation: operation)
#else
return {}
#endif
}
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]?) {
#if SENTRY
monitoringClient?.trackNonFatalIssue(issue, details: details)
#endif
}
}
/// 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] = [:]
}
}