#106: Begin adding the Analytics class from EI.

No UI or SDK support to enable/disable yet and no tracking is implemented.
This commit is contained in:
Doug
2022-09-02 10:09:02 +01:00
committed by GitHub
parent 7c37d2f143
commit df68174a32
22 changed files with 919 additions and 28 deletions
@@ -0,0 +1,173 @@
//
// 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 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 Analytics {
// 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()
/// 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?
/// 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.
!ElementSettings.shared.hasSeenAnalyticsPrompt && BuildSettings.analyticsConfiguration.isEnabled
}
// 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: UserSessionProtocol) {
ElementSettings.shared.enableAnalytics = true
startIfEnabled()
Task { await useAnalyticsSettings(from: session) }
}
/// Stops analytics tracking and calls `reset` to clear any IDs and event queues.
func optOut() {
ElementSettings.shared.enableAnalytics = false
// The order is important here. PostHog ignores the reset if stopped.
reset()
client.stop()
// monitoringClient.stop()
MXLog.debug("Stopped.")
}
/// Starts the analytics client if the user has opted in, otherwise does nothing.
func startIfEnabled() {
guard ElementSettings.shared.enableAnalytics, !isRunning else { return }
client.start()
// monitoringClient.start()
// Sanity check in case something went wrong.
guard client.isRunning else { return }
MXLog.debug("Started.")
// Catch and log crashes
// MXLogger.logCrashes(true)
// MXLogger.setBuildVersion(ElementInfoPlist.cfBundleShortVersionString)
}
/// 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: UserSessionProtocol) async {
guard
ElementSettings.shared.enableAnalytics,
!ElementSettings.shared.isIdentifiedForAnalytics
else { return }
let service = AnalyticsService(session: session)
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
/// 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("Reset.")
ElementSettings.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("identify(with:) called before an ID has been generated.")
return
}
client.identify(id: id)
MXLog.debug("Identified.")
ElementSettings.shared.isIdentifiedForAnalytics = true
}
/// Capture an event in the `client`.
/// - Parameter event: The event to capture.
private func capture(event: AnalyticsEventProtocol) {
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 {
// }
@@ -0,0 +1,56 @@
//
// 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
/// A protocol representing an analytics client.
protocol AnalyticsClientProtocol {
/// Whether the analytics client is currently reporting data or ignoring it.
var isRunning: Bool { get }
/// 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()
/// Stop the analytics client reporting data.
func stop()
/// Send any queued events immediately.
func flush()
/// Capture the supplied analytics event.
/// - Parameter event: The event to capture.
func capture(_ event: AnalyticsEventProtocol)
/// Capture the supplied analytics screen event.
/// - Parameter event: The screen event to capture.
func screen(_ event: AnalyticsScreenProtocol)
/// Updates any user properties to help with creating cohorts.
/// - Parameter userProperties: The user properties to be updated.
///
/// Only non-nil properties will be updated when calling this method. There might
/// be a delay when updating user properties as these are cached to be included
/// as part of the next event that gets captured.
func updateUserProperties(_ userProperties: AnalyticsEvent.UserProperties)
}
@@ -0,0 +1,29 @@
//
// Copyright 2022 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
/// A type that represents how to set up the analytics module in the app.
struct AnalyticsConfiguration {
/// Whether or not analytics should be enabled.
let isEnabled: Bool
/// The host to use for PostHog analytics.
let host: String
/// The public key for submitting analytics.
let apiKey: String
/// The URL to open with more information about analytics terms.
let termsURL: URL
}
@@ -0,0 +1,75 @@
//
// 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 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 session: UserSessionProtocol
/// Creates an analytics service with the supplied session.
/// - Parameter session: The session to use when reading analytics settings from account data.
init(session: UserSessionProtocol) {
self.session = session
}
/// 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 session.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 session.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, settings.id != nil {
return .success(settings)
}
let newSettings = AnalyticsSettings.new(currentEvent: settings)
switch await session.clientProxy.setAccountData(content: newSettings, type: AnalyticsSettings.eventType) {
case .failure:
MXLog.warning("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.")
}
}
@@ -0,0 +1,50 @@
//
// 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)
}
}
@@ -0,0 +1,36 @@
//
// 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
extension AnalyticsEvent.JoinedRoom.RoomSize {
init?(memberCount: UInt) {
switch memberCount {
case 2:
self = .Two
case 3...10:
self = .ThreeToTen
case 11...100:
self = .ElevenToOneHundred
case 101...1000:
self = .OneHundredAndOneToAThousand
case 1001...:
self = .MoreThanAThousand
default:
return nil
}
}
}
@@ -0,0 +1,33 @@
//
// 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
public extension AnalyticsEvent.UserProperties {
// Initializer for Element. Strips all Web properties.
init(ftueUseCaseSelection: FtueUseCaseSelection?, numFavouriteRooms: Int?, numSpaces: Int?, allChatsActiveFilter: AllChatsActiveFilter?) {
self.init(WebMetaSpaceFavouritesEnabled: nil,
WebMetaSpaceHomeAllRooms: nil,
WebMetaSpaceHomeEnabled: nil,
WebMetaSpaceOrphansEnabled: nil,
WebMetaSpacePeopleEnabled: nil,
allChatsActiveFilter: nil,
ftueUseCaseSelection: ftueUseCaseSelection,
numFavouriteRooms: numFavouriteRooms,
numSpaces: numSpaces)
}
}
@@ -0,0 +1,29 @@
//
// 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 PostHog
extension PHGPostHogConfiguration {
static var standard: PHGPostHogConfiguration? {
let analyticsConfiguration = BuildSettings.analyticsConfiguration
guard analyticsConfiguration.isEnabled else { return nil }
let postHogConfiguration = PHGPostHogConfiguration(apiKey: analyticsConfiguration.apiKey, host: analyticsConfiguration.host)
postHogConfiguration.shouldSendDeviceID = false
return postHogConfiguration
}
}
@@ -0,0 +1,104 @@
//
// 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 PostHog
/// An analytics client that reports events to a PostHog server.
class PostHogAnalyticsClient: AnalyticsClientProtocol {
/// The PHGPostHog object used to report events.
private var postHog: PHGPostHog?
/// Any user properties to be included with the next captured event.
private(set) var pendingUserProperties: AnalyticsEvent.UserProperties?
var isRunning: Bool { postHog?.enabled ?? false }
func start() {
// Only start if analytics have been configured in BuildSettings
guard let configuration = PHGPostHogConfiguration.standard else { return }
if postHog == nil {
postHog = PHGPostHog(configuration: configuration)
}
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
}
func stop() {
postHog?.disable()
// As of PostHog 1.4.4, setting the client to nil here doesn't release
// it. Keep it around to avoid having multiple instances if the user re-enables
}
func flush() {
postHog?.flush()
}
func capture(_ event: AnalyticsEventProtocol) {
postHog?.capture(event.eventName, properties: attachUserProperties(to: event.properties))
}
func screen(_ event: AnalyticsScreenProtocol) {
postHog?.screen(event.screenName.rawValue, properties: attachUserProperties(to: event.properties))
}
func updateUserProperties(_ userProperties: AnalyticsEvent.UserProperties) {
guard let pendingUserProperties = pendingUserProperties else {
pendingUserProperties = userProperties
return
}
// Merge the updated user properties with the existing ones
self.pendingUserProperties = AnalyticsEvent.UserProperties(ftueUseCaseSelection: userProperties.ftueUseCaseSelection ?? pendingUserProperties.ftueUseCaseSelection,
numFavouriteRooms: userProperties.numFavouriteRooms ?? pendingUserProperties.numFavouriteRooms,
numSpaces: userProperties.numSpaces ?? pendingUserProperties.numSpaces,
allChatsActiveFilter: userProperties.allChatsActiveFilter ?? pendingUserProperties.allChatsActiveFilter)
}
// MARK: - Private
/// Given a dictionary containing properties from an event, this method will return those properties
/// with any pending user properties included under the `$set` key.
/// - Parameter properties: A dictionary of properties from an event.
/// - Returns: The `properties` dictionary with any user properties included.
private func attachUserProperties(to properties: [String: Any]) -> [String: Any] {
guard isRunning, let userProperties = pendingUserProperties else { return properties }
var properties = properties
// As user properties overwrite old ones via $set, compactMap the dictionary to avoid resetting any missing properties
properties["$set"] = userProperties.properties.compactMapValues { $0 }
pendingUserProperties = nil
return properties
}
}