mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 12:07:55 +00:00
Set up Analytics to track data per session (#780)
This commit is contained in:
@@ -31,18 +31,13 @@ import PostHog
|
||||
/// into `main`, update the AnalyticsEvents Swift package in `project.yml`.
|
||||
///
|
||||
class Analytics {
|
||||
/// 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()
|
||||
|
||||
// /// 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 let client: AnalyticsClientProtocol
|
||||
|
||||
init(client: AnalyticsClientProtocol) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// Whether or not the object is enabled and sending events to the server.
|
||||
var isRunning: Bool { client.isRunning }
|
||||
|
||||
@@ -53,13 +48,9 @@ class Analytics {
|
||||
}
|
||||
|
||||
/// Opts in to analytics tracking with the supplied user session.
|
||||
/// - Parameter userSession: The user session to use to when reading/generating the analytics ID.
|
||||
/// The session will be ignored if not running.
|
||||
func optIn(with userSession: UserSessionProtocol) {
|
||||
func optIn() {
|
||||
ServiceLocator.shared.settings.enableAnalytics = true
|
||||
startIfEnabled()
|
||||
|
||||
Task { await useAnalyticsSettings(from: userSession) }
|
||||
}
|
||||
|
||||
/// Stops analytics tracking and calls `reset` to clear any IDs and event queues.
|
||||
@@ -69,8 +60,7 @@ class Analytics {
|
||||
// The order is important here. PostHog ignores the reset if stopped.
|
||||
reset()
|
||||
client.stop()
|
||||
// monitoringClient.stop()
|
||||
|
||||
ServiceLocator.shared.bugReportService.stop()
|
||||
MXLog.info("Stopped.")
|
||||
}
|
||||
|
||||
@@ -79,38 +69,12 @@ class Analytics {
|
||||
guard ServiceLocator.shared.settings.enableAnalytics, !isRunning else { return }
|
||||
|
||||
client.start()
|
||||
// monitoringClient.start()
|
||||
|
||||
ServiceLocator.shared.bugReportService.start()
|
||||
|
||||
// Sanity check in case something went wrong.
|
||||
guard client.isRunning else { return }
|
||||
|
||||
MXLog.info("Started.")
|
||||
|
||||
// Catch and log crashes
|
||||
// MXLogger.logCrashes(true)
|
||||
// MXLogger.setBuildVersion(Bundle.bundleShortVersionString)
|
||||
}
|
||||
|
||||
/// Use the analytics settings from the supplied user session to configure analytics.
|
||||
/// For now this is only used for (pseudonymous) identification.
|
||||
/// - Parameter userSession: The user session to read analytics settings from.
|
||||
func useAnalyticsSettings(from userSession: UserSessionProtocol) async {
|
||||
guard
|
||||
ServiceLocator.shared.settings.enableAnalytics,
|
||||
!ServiceLocator.shared.settings.isIdentifiedForAnalytics
|
||||
else { return }
|
||||
|
||||
let service = AnalyticsService(userSession: userSession)
|
||||
self.service = service
|
||||
|
||||
switch await service.settings() {
|
||||
case .success(let settings):
|
||||
identify(with: settings)
|
||||
self.service = nil
|
||||
case .failure:
|
||||
MXLog.error("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
|
||||
@@ -119,13 +83,8 @@ class Analytics {
|
||||
/// Note: **MUST** be called before stopping PostHog or the reset is ignored.
|
||||
func reset() {
|
||||
client.reset()
|
||||
// monitoringClient.reset()
|
||||
|
||||
ServiceLocator.shared.bugReportService.reset()
|
||||
MXLog.info("Reset.")
|
||||
ServiceLocator.shared.settings.isIdentifiedForAnalytics = false
|
||||
|
||||
// Stop collecting crash logs
|
||||
// MXLogger.logCrashes(false)
|
||||
}
|
||||
|
||||
/// Flushes the event queue in the analytics client, uploading all pending events.
|
||||
@@ -137,33 +96,23 @@ class Analytics {
|
||||
|
||||
// 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("identify(with:) called before an ID has been generated.")
|
||||
return
|
||||
}
|
||||
|
||||
client.identify(id: id)
|
||||
MXLog.info("Identified.")
|
||||
ServiceLocator.shared.settings.isIdentifiedForAnalytics = true
|
||||
}
|
||||
|
||||
/// Capture an event in the `client`.
|
||||
/// - Parameter event: The event to capture.
|
||||
private func capture(event: AnalyticsEventProtocol) {
|
||||
MXLog.debug("\(event)")
|
||||
client.capture(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 { }
|
||||
|
||||
// MARK: - MXAnalyticsDelegate
|
||||
|
||||
// extension Analytics: MXAnalyticsDelegate {
|
||||
// }
|
||||
extension Analytics {
|
||||
/// 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: AnalyticsScreen, duration milliseconds: Int? = nil) {
|
||||
MXLog.debug("\(screen)")
|
||||
let event = AnalyticsEvent.MobileScreen(durationMs: milliseconds, screenName: screen.screenName)
|
||||
client.screen(event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,11 +23,7 @@ protocol AnalyticsClientProtocol {
|
||||
|
||||
/// Starts the analytics client reporting data.
|
||||
func start()
|
||||
|
||||
/// Associate the client with an ID. This is persisted until `reset` is called.
|
||||
/// - Parameter id: The ID to associate with the user.
|
||||
func identify(id: String)
|
||||
|
||||
|
||||
/// Reset all stored properties and any event queues on the client. Note that
|
||||
/// the client will remain active, but in a fresh unidentified state.
|
||||
func reset()
|
||||
@@ -54,3 +50,6 @@ protocol AnalyticsClientProtocol {
|
||||
/// as part of the next event that gets captured.
|
||||
func updateUserProperties(_ userProperties: AnalyticsEvent.UserProperties)
|
||||
}
|
||||
|
||||
// sourcery: AutoMockable
|
||||
extension AnalyticsClientProtocol { }
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// 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 AnalyticsEvents
|
||||
import Foundation
|
||||
|
||||
enum AnalyticsScreen: Int {
|
||||
case welcome
|
||||
case login
|
||||
case register
|
||||
case forgotPassword
|
||||
case sidebar
|
||||
case home
|
||||
case favourites
|
||||
case people
|
||||
case rooms
|
||||
case searchRooms
|
||||
case searchMessages
|
||||
case searchPeople
|
||||
case searchFiles
|
||||
case room
|
||||
case roomPreview
|
||||
case roomDetails
|
||||
case roomMembers
|
||||
case user
|
||||
case roomSearch
|
||||
case roomUploads
|
||||
case roomSettings
|
||||
case roomNotifications
|
||||
case roomDirectory
|
||||
case switchDirectory
|
||||
case startChat
|
||||
case createRoom
|
||||
case settings
|
||||
case settingsSecurity
|
||||
case settingsDefaultNotifications
|
||||
case settingsMentionsAndKeywords
|
||||
case settingsNotifications
|
||||
case deactivateAccount
|
||||
case inviteFriends
|
||||
case threadList
|
||||
case spaceMenu
|
||||
case spaceMembers
|
||||
case spaceExploreRooms
|
||||
case dialpad
|
||||
case spaceBottomSheet
|
||||
case invites
|
||||
case createSpace
|
||||
|
||||
/// The screen name reported to the AnalyticsEvent.
|
||||
var screenName: AnalyticsEvent.MobileScreen.ScreenName {
|
||||
switch self {
|
||||
case .welcome:
|
||||
return .Welcome
|
||||
case .login:
|
||||
return .Login
|
||||
case .register:
|
||||
return .Register
|
||||
case .forgotPassword:
|
||||
return .ForgotPassword
|
||||
case .sidebar:
|
||||
return .Sidebar
|
||||
case .home:
|
||||
return .Home
|
||||
case .favourites:
|
||||
return .Favourites
|
||||
case .people:
|
||||
return .People
|
||||
case .rooms:
|
||||
return .Rooms
|
||||
case .searchRooms:
|
||||
return .SearchRooms
|
||||
case .searchMessages:
|
||||
return .SearchMessages
|
||||
case .searchPeople:
|
||||
return .SearchPeople
|
||||
case .searchFiles:
|
||||
return .SearchFiles
|
||||
case .room:
|
||||
return .Room
|
||||
case .roomDetails:
|
||||
return .RoomDetails
|
||||
case .roomMembers:
|
||||
return .RoomMembers
|
||||
case .user:
|
||||
return .User
|
||||
case .roomPreview:
|
||||
return .RoomPreview
|
||||
case .roomSearch:
|
||||
return .RoomSearch
|
||||
case .roomUploads:
|
||||
return .RoomUploads
|
||||
case .roomSettings:
|
||||
return .RoomSettings
|
||||
case .roomNotifications:
|
||||
return .RoomNotifications
|
||||
case .roomDirectory:
|
||||
return .RoomDirectory
|
||||
case .switchDirectory:
|
||||
return .SwitchDirectory
|
||||
case .startChat:
|
||||
return .StartChat
|
||||
case .createRoom:
|
||||
return .CreateRoom
|
||||
case .settings:
|
||||
return .Settings
|
||||
case .settingsSecurity:
|
||||
return .SettingsSecurity
|
||||
case .settingsDefaultNotifications:
|
||||
return .SettingsDefaultNotifications
|
||||
case .settingsMentionsAndKeywords:
|
||||
return .SettingsMentionsAndKeywords
|
||||
case .settingsNotifications:
|
||||
return .SettingsNotifications
|
||||
case .deactivateAccount:
|
||||
return .DeactivateAccount
|
||||
case .inviteFriends:
|
||||
return .InviteFriends
|
||||
case .threadList:
|
||||
return .ThreadList
|
||||
case .spaceMenu:
|
||||
return .SpaceMenu
|
||||
case .spaceMembers:
|
||||
return .SpaceMembers
|
||||
case .spaceExploreRooms:
|
||||
return .SpaceExploreRooms
|
||||
case .dialpad:
|
||||
return .Dialpad
|
||||
case .spaceBottomSheet:
|
||||
return .SpaceBottomSheet
|
||||
case .invites:
|
||||
return .Invites
|
||||
case .createSpace:
|
||||
return .CreateSpace
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
enum AnalyticsServiceError: Error {
|
||||
/// The user session supplied to the service does not have a state of `MXSessionStateRunning`.
|
||||
case sessionIsNotRunning
|
||||
/// The service failed to get or update the analytics settings event from the user's account data.
|
||||
case accountDataFailure
|
||||
}
|
||||
|
||||
/// A service responsible for handling the `im.vector.analytics` event from the user's account data.
|
||||
class AnalyticsService {
|
||||
let userSession: UserSessionProtocol
|
||||
|
||||
/// Creates an analytics service with the supplied user session.
|
||||
/// - Parameter userSession: The user session to use when reading analytics settings from account data.
|
||||
init(userSession: UserSessionProtocol) {
|
||||
self.userSession = userSession
|
||||
}
|
||||
|
||||
/// The analytics settings for the current user. Calling this method will check whether the settings already
|
||||
/// contain an `id` property and if not, will add one to the account data before calling the completion.
|
||||
/// - Parameter completion: A completion handler that will be called when the request completes.
|
||||
///
|
||||
/// The request will fail if the service's session does not have the `MXSessionStateRunning` state.
|
||||
func settings() async -> Result<AnalyticsSettings, AnalyticsServiceError> {
|
||||
// Only use the session if it is running otherwise we could wipe out an existing analytics ID.
|
||||
fatalWithoutUnreachableCodeWarning()
|
||||
// guard userSession.state == .running else {
|
||||
// MXLog.warning("Aborting attempt to read analytics settings. The session may not be up-to-date.")
|
||||
// return .failure(.sessionIsNotRunning)
|
||||
// }
|
||||
|
||||
let result: Result<AnalyticsSettings?, ClientProxyError> = await userSession.clientProxy.accountDataEvent(type: AnalyticsSettings.eventType)
|
||||
switch result {
|
||||
case .failure:
|
||||
return .failure(.accountDataFailure)
|
||||
case .success(let settings):
|
||||
// The id has already be set so we are done here.
|
||||
if let settings, settings.id != nil {
|
||||
return .success(settings)
|
||||
}
|
||||
|
||||
let newSettings = AnalyticsSettings.new(currentEvent: settings)
|
||||
switch await userSession.clientProxy.setAccountData(content: newSettings, type: AnalyticsSettings.eventType) {
|
||||
case .failure:
|
||||
MXLog.error("Failed to update analytics settings.")
|
||||
return .failure(.accountDataFailure)
|
||||
case .success:
|
||||
MXLog.debug("Successfully updated analytics settings in account data.")
|
||||
return .success(newSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Silences a warning on some intentionally unreachable code.
|
||||
func fatalWithoutUnreachableCodeWarning() {
|
||||
fatalError("Missing running state detection.")
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
/// An analytics settings event from the user's account data.
|
||||
struct AnalyticsSettings: Codable {
|
||||
static let eventType = "im.vector.analytics"
|
||||
|
||||
/// A randomly generated analytics token for this user.
|
||||
/// This is suggested to be a UUID string.
|
||||
let id: String?
|
||||
|
||||
/// Whether the user has opted in on web or not. This is unused on iOS but necessary
|
||||
/// to store here so that it's value is preserved when updating the account data if we
|
||||
/// generated an ID on iOS.
|
||||
///
|
||||
/// `true` if opted in on web, `false` if opted out on web and `nil` if the web prompt is not yet seen.
|
||||
private let webOptIn: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case webOptIn = "pseudonymousAnalyticsOptIn"
|
||||
}
|
||||
}
|
||||
|
||||
extension AnalyticsSettings {
|
||||
/// Generates a new AnalyticsSettings value (inc an ID if necessary) based upon an
|
||||
/// existing value. This is the only way the type should be created so as to avoid wiping
|
||||
/// out the `webOptIn` value that the user may already have set.
|
||||
///
|
||||
/// **Note:** Please don't pass a `nil` literal to this method.
|
||||
static func new(currentEvent: AnalyticsSettings?) -> AnalyticsSettings {
|
||||
AnalyticsSettings(id: currentEvent?.id ?? UUID().uuidString,
|
||||
webOptIn: currentEvent?.webOptIn)
|
||||
}
|
||||
}
|
||||
@@ -38,16 +38,6 @@ class PostHogAnalyticsClient: AnalyticsClientProtocol {
|
||||
postHog?.enable()
|
||||
}
|
||||
|
||||
func identify(id: String) {
|
||||
if let userProperties = pendingUserProperties {
|
||||
// As user properties overwrite old ones, compactMap the dictionary to avoid resetting any missing properties
|
||||
postHog?.identify(id, properties: userProperties.properties.compactMapValues { $0 })
|
||||
pendingUserProperties = nil
|
||||
} else {
|
||||
postHog?.identify(id)
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
postHog?.reset()
|
||||
pendingUserProperties = nil
|
||||
@@ -65,10 +55,12 @@ class PostHogAnalyticsClient: AnalyticsClientProtocol {
|
||||
}
|
||||
|
||||
func capture(_ event: AnalyticsEventProtocol) {
|
||||
guard isRunning else { return }
|
||||
postHog?.capture(event.eventName, properties: attachUserProperties(to: event.properties))
|
||||
}
|
||||
|
||||
func screen(_ event: AnalyticsScreenProtocol) {
|
||||
guard isRunning else { return }
|
||||
postHog?.screen(event.screenName.rawValue, properties: attachUserProperties(to: event.properties))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user