Set up Analytics to track data per session (#780)

This commit is contained in:
Nicolas Mauri
2023-04-18 09:33:32 +02:00
committed by GitHub
parent d01349a60e
commit 5b7ec6c9e2
58 changed files with 953 additions and 375 deletions
@@ -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))
}