Make AnalyticsService mockable via `AnalyticsServiceProtocol (#5599)

* Create AnalyticsServiceProtocol and conform AnalyticsService

* Replace AnalyticsService properties and arguments with AnalyticsServiceProtocol

* Inline AnalyticsServiceMock replacing old mock

* handle more complex AnalyticsServiceMock callsites

* move documentation from AnalyticsService to AnalyticsServiceProtocol
This commit is contained in:
Michael
2026-05-20 00:23:58 -05:00
committed by GitHub
parent 946cb53559
commit 5a07c98afc
118 changed files with 1218 additions and 505 deletions
@@ -10,26 +10,11 @@ import AnalyticsEvents
import Combine
import PostHog
/// 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.
///
/// ## Creating Analytics Events
///
/// Events are managed in a shared repo for all Element clients https://github.com/matrix-org/matrix-analytics-events
/// To add a new event create a PR to that repo with the new/updated schema. Once merged
/// into `main`, update the AnalyticsEvents Swift package in `project.yml`.
///
class AnalyticsService {
class AnalyticsService: AnalyticsServiceProtocol {
/// The analytics client to send events with.
private let client: AnalyticsClientProtocol
private let appSettings: AppSettings
/// A signpost client for performance testing the app. This client doesn't respect the
/// `isRunning` state or behave any differently when `start`/`reset` are called.
let signpost = Signposter()
init(client: AnalyticsClientProtocol, appSettings: AppSettings) {
@@ -37,7 +22,6 @@ class AnalyticsService {
self.appSettings = appSettings
}
/// 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.
appSettings.analyticsConsentState == .unknown && appSettings.canPromptForAnalytics
@@ -47,13 +31,11 @@ class AnalyticsService {
appSettings.analyticsConsentState == .optedIn
}
/// Opts in to analytics tracking with the supplied user session.
func optIn() {
appSettings.analyticsConsentState = .optedIn
startIfEnabled()
}
/// Stops analytics tracking and calls `reset` to clear any IDs and event queues.
func optOut() {
appSettings.analyticsConsentState = .optedOut
@@ -64,7 +46,6 @@ class AnalyticsService {
MXLog.info("Stopped.")
}
/// Starts the analytics client if the user has opted in, otherwise does nothing.
func startIfEnabled() {
guard isEnabled, !client.isRunning, let configuration = appSettings.analyticsConfiguration else { return }
@@ -76,16 +57,11 @@ class AnalyticsService {
MXLog.info("Started.")
}
/// 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()
MXLog.info("Reset.")
}
/// Reset the consent state for analytics
func resetConsentState() {
MXLog.warning("Resetting consent state for analytics.")
appSettings.analyticsConsentState = .unknown
@@ -104,9 +80,6 @@ class AnalyticsService {
// MARK: - Public tracking methods
extension AnalyticsService {
/// Track the presentation of a screen
/// - Parameter screen: The screen that was shown
/// - Parameter duration: An optional value representing how long the screen was shown for in milliseconds.
func track(screen: AnalyticsEvent.MobileScreen.ScreenName, duration milliseconds: Int? = nil) {
MXLog.debug("\(screen)")
let event = AnalyticsEvent.MobileScreen(durationMs: milliseconds, screenName: screen)
@@ -117,12 +90,6 @@ extension AnalyticsService {
capture(event: AnalyticsEvent.Interaction(index: index, interactionType: .Touch, name: name))
}
/// Track the presentation of a screen
/// - Parameter context: To provide additional context or description for the error
/// - Parameter domain: The domain to which the error belongs to.
/// - Parameter name: The name of the error
/// - Parameter timeToDecryptMillis: The time it took to decrypt the event in milliseconds, needs to be used only to track UTD errors, otherwise if the error is nort related to UTD it should be nil.
/// Can be found in `UnableToDecryptInfo`. In case the `UnableToDecryptInfo` contains the value as nil, pass it as `-1`
func trackError(context: String?, domain: AnalyticsEvent.Error.Domain,
name: AnalyticsEvent.Error.Name,
timeToDecryptMillis: Int? = nil,
@@ -145,19 +112,10 @@ extension AnalyticsService {
wasVisibleToUser: wasVisibleToUser))
}
/// Track the creation of a room
/// - Parameter isDM: true if the created room is a direct message, false otherwise
func trackCreatedRoom(isDM: Bool) {
capture(event: AnalyticsEvent.CreatedRoom(isDM: isDM))
}
/// Track the composer
/// - Parameters:
/// - inThread: whether the composer is used in a Thread
/// - isEditing: whether the composer is used to edit a message
/// - isReply: whether the composer is used to reply a message
/// - messageType: the type of the message
/// - startsThread: whether the composer is used to start a new thread
func trackComposer(inThread: Bool,
isEditing: Bool,
isReply: Bool,
@@ -170,19 +128,10 @@ extension AnalyticsService {
startsThread: startsThread))
}
/// Track the presentation of a room
/// - Parameters:
/// - isDM: whether the room is a direct message
/// - isSpace: whether the room is a space
func trackViewRoom(isDM: Bool, isSpace: Bool) {
capture(event: AnalyticsEvent.ViewRoom(activeSpace: nil, isDM: isDM, isSpace: isSpace, trigger: nil, viaKeyboard: nil))
}
/// Track the action of joining a room
/// - Parameters:
/// - isDM: whether the room is a direct message
/// - isSpace: whether the room is a space
/// - activeMemberCount: the number of active members in the room
func trackJoinedRoom(isDM: Bool, isSpace: Bool, activeMemberCount: UInt) {
guard let roomSize = AnalyticsEvent.JoinedRoom.RoomSize(memberCount: activeMemberCount) else {
MXLog.error("invalid room size")
@@ -191,27 +140,20 @@ extension AnalyticsService {
capture(event: AnalyticsEvent.JoinedRoom(isDM: isDM, isSpace: isSpace, roomSize: roomSize, trigger: nil))
}
/// Track the action of creating a poll
/// - Parameters:
/// - isUndisclosed: whether the poll is undisclosed
/// - numberOfAnswers: the number of options in the poll
func trackPollCreated(isUndisclosed: Bool, numberOfAnswers: Int) {
capture(event: AnalyticsEvent.PollCreation(action: .Create,
isUndisclosed: isUndisclosed,
numberOfAnswers: numberOfAnswers))
}
/// Track the action of voting on a poll
func trackPollVote() {
capture(event: AnalyticsEvent.PollVote(doNotUse: nil))
}
/// Track the action of ending a poll
func trackPollEnd() {
capture(event: AnalyticsEvent.PollEnd(doNotUse: nil))
}
/// Track a room moderation action.
func trackRoomModeration(action: AnalyticsEvent.RoomModeration.Action, role: RoomRole?) {
let role = role.map(AnalyticsEvent.RoomModeration.Role.init)
capture(event: AnalyticsEvent.RoomModeration(action: action, role: role))
@@ -0,0 +1,160 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import AnalyticsEvents
/// A service responsible for managing analytics clients and sending events through them.
///
/// Events may include user activity, or app health data such as crashes,
/// non-fatal issues and performance.
///
/// ## Creating Analytics Events
///
/// Events are managed in a shared repo for all Element clients https://github.com/matrix-org/matrix-analytics-events
/// To add a new event create a PR to that repo with the new/updated schema. Once merged
/// into `main`, update the AnalyticsEvents Swift package in `project.yml`.
protocol AnalyticsServiceProtocol: AnyObject {
/// A signpost client for performance testing the app. This client doesn't respect the
/// `isRunning` state or behave any differently when `start`/`reset` are called.
var signpost: Signposter { get }
/// Whether to show the user the analytics opt in prompt.
var shouldShowAnalyticsPrompt: Bool { get }
/// Whether analytics tracking is currently enabled.
var isEnabled: Bool { get }
/// Opts in to analytics tracking.
func optIn()
/// Stops analytics tracking and calls `reset` to clear any IDs and event queues.
func optOut()
/// Starts the analytics client if the user has opted in, otherwise does nothing.
func startIfEnabled()
/// Resets 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()
/// Resets the consent state for analytics.
func resetConsentState()
/// Track the presentation of a screen.
/// - Parameter screen: The screen that was shown.
/// - Parameter milliseconds: An optional value representing how long the screen was shown for in milliseconds.
func track(screen: AnalyticsEvent.MobileScreen.ScreenName, duration milliseconds: Int?)
func trackInteraction(index: Int?, name: AnalyticsEvent.Interaction.Name)
/// Track an analytics error event.
/// - Parameter context: Additional context or description for the error.
/// - Parameter domain: The domain to which the error belongs.
/// - Parameter name: The name of the error.
/// - Parameter timeToDecryptMillis: The time it took to decrypt the event in milliseconds. Only used for UTD errors; pass `nil` otherwise.
/// Can be found in `UnableToDecryptInfo`. If `UnableToDecryptInfo` contains `nil` for this value, pass `-1`.
func trackError(context: String?, domain: AnalyticsEvent.Error.Domain,
name: AnalyticsEvent.Error.Name,
timeToDecryptMillis: Int?,
eventLocalAgeMillis: Int?,
isFederated: Bool?,
isMatrixDotOrg: Bool?,
userTrustsOwnIdentity: Bool?,
wasVisibleToUser: Bool?)
/// Track the creation of a room.
/// - Parameter isDM: `true` if the created room is a direct message, `false` otherwise.
func trackCreatedRoom(isDM: Bool)
/// Track the composer.
/// - Parameters:
/// - inThread: Whether the composer is used in a thread.
/// - isEditing: Whether the composer is used to edit a message.
/// - isReply: Whether the composer is used to reply to a message.
/// - messageType: The type of the message.
/// - startsThread: Whether the composer is used to start a new thread.
func trackComposer(inThread: Bool,
isEditing: Bool,
isReply: Bool,
messageType: AnalyticsEvent.Composer.MessageType,
startsThread: Bool?)
/// Track the presentation of a room.
/// - Parameters:
/// - isDM: Whether the room is a direct message.
/// - isSpace: Whether the room is a space.
func trackViewRoom(isDM: Bool, isSpace: Bool)
/// Track the action of joining a room.
/// - Parameters:
/// - isDM: Whether the room is a direct message.
/// - isSpace: Whether the room is a space.
/// - activeMemberCount: The number of active members in the room.
func trackJoinedRoom(isDM: Bool, isSpace: Bool, activeMemberCount: UInt)
/// Track the action of creating a poll.
/// - Parameters:
/// - isUndisclosed: Whether the poll is undisclosed.
/// - numberOfAnswers: The number of options in the poll.
func trackPollCreated(isUndisclosed: Bool, numberOfAnswers: Int)
/// Track the action of voting on a poll.
func trackPollVote()
/// Track the action of ending a poll.
func trackPollEnd()
/// Track a room moderation action.
func trackRoomModeration(action: AnalyticsEvent.RoomModeration.Action, role: RoomRole?)
func trackSessionSecurityState(_ state: SessionSecurityState)
func updateUserProperties(_ userProperties: AnalyticsEvent.UserProperties)
func trackPinUnpinEvent(_ event: AnalyticsEvent.PinUnpinAction)
}
// sourcery: AutoMockable
extension AnalyticsServiceProtocol {
func track(screen: AnalyticsEvent.MobileScreen.ScreenName) {
track(screen: screen, duration: nil)
}
func trackInteraction(name: AnalyticsEvent.Interaction.Name) {
trackInteraction(index: nil, name: name)
}
@_disfavoredOverload // make sure this doesn't cause infinite recursion
func trackError(context: String?,
domain: AnalyticsEvent.Error.Domain,
name: AnalyticsEvent.Error.Name,
timeToDecryptMillis: Int? = nil,
eventLocalAgeMillis: Int? = nil,
isFederated: Bool? = nil,
isMatrixDotOrg: Bool? = nil,
userTrustsOwnIdentity: Bool? = nil,
wasVisibleToUser: Bool? = nil) {
trackError(context: context,
domain: domain,
name: name,
timeToDecryptMillis: timeToDecryptMillis,
eventLocalAgeMillis: eventLocalAgeMillis,
isFederated: isFederated,
isMatrixDotOrg: isMatrixDotOrg,
userTrustsOwnIdentity: userTrustsOwnIdentity,
wasVisibleToUser: wasVisibleToUser)
}
func trackComposer(inThread: Bool,
isEditing: Bool,
isReply: Bool,
startsThread: Bool?) {
trackComposer(inThread: inThread,
isEditing: isEditing,
isReply: isReply,
messageType: .Text,
startsThread: startsThread)
}
}
@@ -9,5 +9,5 @@
import SwiftUI
extension EnvironmentValues {
@Entry var analyticsService: AnalyticsService?
@Entry var analyticsService: AnalyticsServiceProtocol?
}
@@ -17,7 +17,7 @@ class ClientProxy: ClientProxyProtocol {
private let client: ClientProtocol
private let networkMonitor: NetworkMonitorProtocol
private let appSettings: AppSettings
private let analyticsService: AnalyticsService
private let analyticsService: AnalyticsServiceProtocol
let mediaLoader: MediaLoaderProtocol
private let clientQueue: DispatchQueue
@@ -202,7 +202,7 @@ class ClientProxy: ClientProxyProtocol {
init(client: ClientProtocol,
networkMonitor: NetworkMonitorProtocol,
appSettings: AppSettings,
analyticsService: AnalyticsService) async throws {
analyticsService: AnalyticsServiceProtocol) async throws {
self.client = client
self.networkMonitor = networkMonitor
self.appSettings = appSettings
@@ -9,10 +9,10 @@
import Foundation
class PollInteractionHandler: PollInteractionHandlerProtocol {
let analyticsService: AnalyticsService
let analyticsService: AnalyticsServiceProtocol
let timelineController: TimelineControllerProtocol
init(analyticsService: AnalyticsService, timelineController: TimelineControllerProtocol) {
init(analyticsService: AnalyticsServiceProtocol, timelineController: TimelineControllerProtocol) {
self.analyticsService = analyticsService
self.timelineController = timelineController
}
@@ -15,7 +15,7 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
private let roomListService: RoomListServiceProtocol
private let room: RoomProtocol
private let appSettings: AppSettings
private let analyticsService: AnalyticsService
private let analyticsService: AnalyticsServiceProtocol
private let eventStringBuilder: RoomEventStringBuilder
// periphery:ignore - required for instance retention in the rust codebase
@@ -79,7 +79,7 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
init(roomListService: RoomListServiceProtocol,
room: RoomProtocol,
appSettings: AppSettings,
analyticsService: AnalyticsService,
analyticsService: AnalyticsServiceProtocol,
eventStringBuilder: RoomEventStringBuilder) async throws {
self.roomListService = roomListService
self.room = room
@@ -12,7 +12,7 @@ import MatrixRustSDK
class UserSessionStore: UserSessionStoreProtocol {
private let keychainController: KeychainControllerProtocol
private let appSettings: AppSettings
private let analyticsService: AnalyticsService
private let analyticsService: AnalyticsServiceProtocol
private let networkMonitor: NetworkMonitorProtocol
private let appHooks: AppHooks
@@ -32,7 +32,7 @@ class UserSessionStore: UserSessionStoreProtocol {
init(keychainController: KeychainControllerProtocol,
appSettings: AppSettings,
analyticsService: AnalyticsService,
analyticsService: AnalyticsServiceProtocol,
appHooks: AppHooks,
networkMonitor: NetworkMonitorProtocol) {
self.keychainController = keychainController