Merge pull request #5591 from vector-im/doug/5590_ftue_analytics

Add support for UserProperties to analytics and capture FTUE use case selection.
This commit is contained in:
Doug 2022-02-18 10:49:06 +00:00 committed by GitHub
commit 4a0a67ff73
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 201 additions and 33 deletions

View file

@ -171,6 +171,14 @@ import AnalyticsEvents
// The following methods are exposed for compatibility with Objective-C as // The following methods are exposed for compatibility with Objective-C as
// the `capture` method and the generated events cannot be bridged from Swift. // the `capture` method and the generated events cannot be bridged from Swift.
extension Analytics { 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) {
let userProperties = AnalyticsEvent.UserProperties(ftueUseCaseSelection: ftueUseCase?.analyticsName, numSpaces: nil)
client.updateUserProperties(userProperties)
}
/// Track the presentation of a screen /// Track the presentation of a screen
/// - Parameters: /// - Parameters:
/// - screen: The screen that was shown. /// - screen: The screen that was shown.
@ -186,20 +194,21 @@ extension Analytics {
trackScreen(screen, duration: nil) trackScreen(screen, duration: nil)
} }
/// Track an element that has been tapped /// Track an element that has been interacted with
/// - Parameters: /// - Parameters:
/// - tap: The element that was tapped /// - 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 /// - index: The index of the element, if it's in a list of elements
func trackTap(_ tap: AnalyticsUIElement, index: Int?) { func trackInteraction(_ uiElement: AnalyticsUIElement, interactionType: AnalyticsEvent.Interaction.InteractionType, index: Int?) {
let event = AnalyticsEvent.Click(index: index, name: tap.elementName) let event = AnalyticsEvent.Interaction(index: index, interactionType: interactionType, name: uiElement.name)
client.capture(event) client.capture(event)
} }
/// Track an element that has been tapped without including an index /// Track an element that has been tapped without including an index
/// - Parameters: /// - Parameters:
/// - tap: The element that was tapped /// - uiElement: The element that was tapped
func trackTap(_ tap: AnalyticsUIElement) { func trackInteraction(_ uiElement: AnalyticsUIElement) {
trackTap(tap, index: nil) trackInteraction(uiElement, interactionType: .Touch, index: nil)
} }
/// Track an E2EE error that occurred /// Track an E2EE error that occurred

View file

@ -45,4 +45,12 @@ protocol AnalyticsClientProtocol {
/// Capture the supplied analytics screen event. /// Capture the supplied analytics screen event.
/// - Parameter event: The screen event to capture. /// - Parameter event: The screen event to capture.
func screen(_ event: AnalyticsScreenProtocol) 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)
} }

View file

@ -18,6 +18,10 @@ import Foundation
import AnalyticsEvents import AnalyticsEvents
@objc enum AnalyticsScreen: Int { @objc enum AnalyticsScreen: Int {
case welcome
case login
case register
case forgotPassword
case sidebar case sidebar
case home case home
case favourites case favourites
@ -51,6 +55,14 @@ import AnalyticsEvents
/// The screen name reported to the AnalyticsEvent. /// The screen name reported to the AnalyticsEvent.
var screenName: AnalyticsEvent.Screen.ScreenName { var screenName: AnalyticsEvent.Screen.ScreenName {
switch self { switch self {
case .welcome:
return .Welcome
case .login:
return .Login
case .register:
return .Register
case .forgotPassword:
return .ForgotPassword
case .sidebar: case .sidebar:
return .MobileSidebar return .MobileSidebar
case .home: case .home:

View file

@ -16,17 +16,17 @@
import AnalyticsEvents import AnalyticsEvents
/// A tappable UI element that can be track in Analytics. /// A tappable UI element that can be tracked in Analytics.
@objc enum AnalyticsUIElement: Int { @objc enum AnalyticsUIElement: Int {
case sendMessageButton case removeMe
/// The element name reported to the AnalyticsEvent. /// The element name reported to the AnalyticsEvent.
var elementName: AnalyticsEvent.Click.Name { var name: AnalyticsEvent.Interaction.Name {
switch self { switch self {
// Note: This is a test element that doesn't need to be captured. // Note: This is a test element that doesn't need to be captured.
// It will likely be removed when the AnalyticsEvent.Click is updated. // It can be removed when some elements are added that relate to mobile.
case .sendMessageButton: case .removeMe:
return .SendMessageButton return .WebRoomSettingsLeaveButton
} }
} }
} }

View file

@ -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 Foundation
import AnalyticsEvents
extension UserSessionProperties.UseCase {
var analyticsName: AnalyticsEvent.UserProperties.FtueUseCaseSelection {
switch self {
case .personalMessaging:
return .PersonalMessaging
case .workMessaging:
return .WorkMessaging
case .communityMessaging:
return .CommunityMessaging
case .skipped:
return .Skip
}
}
}

View file

@ -22,6 +22,9 @@ class PostHogAnalyticsClient: AnalyticsClientProtocol {
/// The PHGPostHog object used to report events. /// The PHGPostHog object used to report events.
private var postHog: PHGPostHog? 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 } var isRunning: Bool { postHog?.enabled ?? false }
func start() { func start() {
@ -36,11 +39,18 @@ class PostHogAnalyticsClient: AnalyticsClientProtocol {
} }
func identify(id: String) { 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) postHog?.identify(id)
} }
}
func reset() { func reset() {
postHog?.reset() postHog?.reset()
pendingUserProperties = nil
} }
func stop() { func stop() {
@ -55,11 +65,38 @@ class PostHogAnalyticsClient: AnalyticsClientProtocol {
} }
func capture(_ event: AnalyticsEventProtocol) { func capture(_ event: AnalyticsEventProtocol) {
postHog?.capture(event.eventName, properties: event.properties) postHog?.capture(event.eventName, properties: attachUserProperties(to: event.properties))
} }
func screen(_ event: AnalyticsScreenProtocol) { func screen(_ event: AnalyticsScreenProtocol) {
postHog?.screen(event.screenName.rawValue, properties: event.properties) 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,
numSpaces: userProperties.numSpaces ?? pendingUserProperties.numSpaces)
}
// 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
}
} }

View file

@ -147,10 +147,10 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
@available(iOS 14.0, *) @available(iOS 14.0, *)
/// Show the use case screen for new users. /// Show the use case screen for new users.
private func showUseCaseSelectionScreen() { private func showUseCaseSelectionScreen() {
let coordinator = OnboardingUseCaseCoordinator() let coordinator = OnboardingUseCaseSelectionCoordinator()
coordinator.completion = { [weak self, weak coordinator] result in coordinator.completion = { [weak self, weak coordinator] result in
guard let self = self, let coordinator = coordinator else { return } guard let self = self, let coordinator = coordinator else { return }
self.useCaseCoordinator(coordinator, didCompleteWith: result) self.useCaseSelectionCoordinator(coordinator, didCompleteWith: result)
} }
coordinator.start() coordinator.start()
@ -166,7 +166,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
} }
/// Displays the next view in the flow after the use case screen. /// Displays the next view in the flow after the use case screen.
private func useCaseCoordinator(_ coordinator: OnboardingUseCaseCoordinator, didCompleteWith result: OnboardingUseCaseViewModelResult) { private func useCaseSelectionCoordinator(_ coordinator: OnboardingUseCaseSelectionCoordinator, didCompleteWith result: OnboardingUseCaseViewModelResult) {
useCaseResult = result useCaseResult = result
showAuthenticationScreen() showAuthenticationScreen()
} }
@ -222,12 +222,15 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
completion?() completion?()
isShowingAuthentication = false isShowingAuthentication = false
// Handle the chosen use case if appropriate // Handle the chosen use case where applicable
if authenticationType == MXKAuthenticationTypeRegister, if authenticationType == MXKAuthenticationTypeRegister,
let useCaseResult = useCaseResult, let useCase = useCaseResult?.userSessionPropertyValue,
let userSession = UserSessionsService.shared.mainUserSession { let userSession = UserSessionsService.shared.mainUserSession {
// Store the value in the user's session // Store the value in the user's session
userSession.userProperties.useCase = useCaseResult.userSessionPropertyValue userSession.userProperties.useCase = useCase
// Update the analytics user properties with the use case
Analytics.shared.updateUserProperties(ftueUseCase: useCase)
} }
} }
} }

View file

@ -20,7 +20,7 @@ import Foundation
@available(iOS 14.0, *) @available(iOS 14.0, *)
enum MockAppScreens { enum MockAppScreens {
static let appScreens: [MockScreenState.Type] = [ static let appScreens: [MockScreenState.Type] = [
MockOnboardingUseCaseScreenState.self, MockOnboardingUseCaseSelectionScreenState.self,
MockOnboardingSplashScreenScreenState.self, MockOnboardingSplashScreenScreenState.self,
MockLocationSharingScreenState.self, MockLocationSharingScreenState.self,
MockAnalyticsPromptScreenState.self, MockAnalyticsPromptScreenState.self,

View file

@ -16,7 +16,7 @@
import SwiftUI import SwiftUI
final class OnboardingUseCaseCoordinator: Coordinator, Presentable { final class OnboardingUseCaseSelectionCoordinator: Coordinator, Presentable {
// MARK: - Properties // MARK: - Properties
@ -36,7 +36,7 @@ final class OnboardingUseCaseCoordinator: Coordinator, Presentable {
@available(iOS 14.0, *) @available(iOS 14.0, *)
init() { init() {
let viewModel = OnboardingUseCaseViewModel() let viewModel = OnboardingUseCaseViewModel()
let view = OnboardingUseCase(viewModel: viewModel.context) let view = OnboardingUseCaseSelectionScreen(viewModel: viewModel.context)
onboardingUseCaseViewModel = viewModel onboardingUseCaseViewModel = viewModel
let hostingController = VectorHostingController(rootView: view) let hostingController = VectorHostingController(rootView: view)
@ -47,9 +47,9 @@ final class OnboardingUseCaseCoordinator: Coordinator, Presentable {
// MARK: - Public // MARK: - Public
func start() { func start() {
MXLog.debug("[OnboardingUseCaseCoordinator] did start.") MXLog.debug("[OnboardingUseCaseSelectionCoordinator] did start.")
onboardingUseCaseViewModel.completion = { [weak self] result in onboardingUseCaseViewModel.completion = { [weak self] result in
MXLog.debug("[OnboardingUseCaseCoordinator] OnboardingUseCaseViewModel did complete with result: \(result).") MXLog.debug("[OnboardingUseCaseSelectionCoordinator] OnboardingUseCaseViewModel did complete with result: \(result).")
guard let self = self else { return } guard let self = self else { return }
self.completion?(result) self.completion?(result)
} }

View file

@ -20,7 +20,7 @@ import SwiftUI
/// Using an enum for the screen allows you define the different state cases with /// Using an enum for the screen allows you define the different state cases with
/// the relevant associated data for each case. /// the relevant associated data for each case.
@available(iOS 14.0, *) @available(iOS 14.0, *)
enum MockOnboardingUseCaseScreenState: MockScreenState, CaseIterable { enum MockOnboardingUseCaseSelectionScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent // A case for each state you want to represent
// with specific, minimal associated data that will allow you // with specific, minimal associated data that will allow you
// mock that screen. // mock that screen.
@ -28,11 +28,11 @@ enum MockOnboardingUseCaseScreenState: MockScreenState, CaseIterable {
/// The associated screen /// The associated screen
var screenType: Any.Type { var screenType: Any.Type {
OnboardingUseCase.self OnboardingUseCaseSelectionScreen.self
} }
/// A list of screen state definitions /// A list of screen state definitions
static var allCases: [MockOnboardingUseCaseScreenState] { static var allCases: [MockOnboardingUseCaseSelectionScreenState] {
// Each of the presence statuses // Each of the presence statuses
[.default] [.default]
} }
@ -45,7 +45,7 @@ enum MockOnboardingUseCaseScreenState: MockScreenState, CaseIterable {
return ( return (
[self, viewModel], [self, viewModel],
AnyView(OnboardingUseCase(viewModel: viewModel.context) AnyView(OnboardingUseCaseSelectionScreen(viewModel: viewModel.context)
.addDependency(MockAvatarService.example)) .addDependency(MockAvatarService.example))
) )
} }

View file

@ -18,7 +18,7 @@ import SwiftUI
@available(iOS 14.0, *) @available(iOS 14.0, *)
/// The screen shown to a new user to select their use case for the app. /// The screen shown to a new user to select their use case for the app.
struct OnboardingUseCase: View { struct OnboardingUseCaseSelectionScreen: View {
// MARK: - Properties // MARK: - Properties
@ -119,7 +119,7 @@ struct OnboardingUseCase: View {
@available(iOS 14.0, *) @available(iOS 14.0, *)
struct OnboardingUseCase_Previews: PreviewProvider { struct OnboardingUseCase_Previews: PreviewProvider {
static let stateRenderer = MockOnboardingUseCaseScreenState.stateRenderer static let stateRenderer = MockOnboardingUseCaseSelectionScreenState.stateRenderer
static var previews: some View { static var previews: some View {
NavigationView { NavigationView {
stateRenderer.screenGroup() stateRenderer.screenGroup()

View file

@ -16,6 +16,7 @@
import XCTest import XCTest
@testable import Riot @testable import Riot
import AnalyticsEvents
class AnalyticsTests: XCTestCase { class AnalyticsTests: XCTestCase {
func testAnalyticsPromptNewUser() { func testAnalyticsPromptNewUser() {
@ -70,4 +71,68 @@ class AnalyticsTests: XCTestCase {
// Then no prompt should be shown. // Then no prompt should be shown.
XCTAssertFalse(showPrompt, "A prompt should not be shown any more.") XCTAssertFalse(showPrompt, "A prompt should not be shown any more.")
} }
func testAddingUserProperties() {
// Given a client with no user properties set
let client = PostHogAnalyticsClient()
XCTAssertNil(client.pendingUserProperties, "No user properties should have been set yet.")
// When updating the user properties
client.updateUserProperties(AnalyticsEvent.UserProperties(ftueUseCaseSelection: .PersonalMessaging, numSpaces: 5))
// Then the properties should be cached
XCTAssertNotNil(client.pendingUserProperties, "The user properties should be cached.")
XCTAssertEqual(client.pendingUserProperties?.ftueUseCaseSelection, .PersonalMessaging, "The use case selection should match.")
XCTAssertEqual(client.pendingUserProperties?.numSpaces, 5, "The number of spaces should match.")
}
func testMergingUserProperties() {
// Given a client with a cached use case user properties
let client = PostHogAnalyticsClient()
client.updateUserProperties(AnalyticsEvent.UserProperties(ftueUseCaseSelection: .PersonalMessaging, numSpaces: nil))
XCTAssertNotNil(client.pendingUserProperties, "The user properties should be cached.")
XCTAssertEqual(client.pendingUserProperties?.ftueUseCaseSelection, .PersonalMessaging, "The use case selection should match.")
XCTAssertNil(client.pendingUserProperties?.numSpaces, "The number of spaces should not be set.")
// When updating the number of spaced
client.updateUserProperties(AnalyticsEvent.UserProperties(ftueUseCaseSelection: nil, numSpaces: 5))
// Then the new properties should be updated and the existing properties should remain unchanged
XCTAssertNotNil(client.pendingUserProperties, "The user properties should be cached.")
XCTAssertEqual(client.pendingUserProperties?.ftueUseCaseSelection, .PersonalMessaging, "The use case selection shouldn't have changed.")
XCTAssertEqual(client.pendingUserProperties?.numSpaces, 5, "The number of spaces should have been updated.")
}
func testSendingUserProperties() {
// Given a client with user properties set
let client = PostHogAnalyticsClient()
client.updateUserProperties(AnalyticsEvent.UserProperties(ftueUseCaseSelection: .PersonalMessaging, numSpaces: nil))
client.start()
XCTAssertNotNil(client.pendingUserProperties, "The user properties should be cached.")
XCTAssertEqual(client.pendingUserProperties?.ftueUseCaseSelection, .PersonalMessaging, "The use case selection should match.")
// When sending an event (tests run under Debug configuration so this is sent to the development instance)
client.screen(AnalyticsEvent.Screen(durationMs: nil, screenName: .Home))
// Then the properties should be cleared
XCTAssertNil(client.pendingUserProperties, "The user properties should be cleared.")
}
func testSendingUserPropertiesWithIdentify() {
// Given a client with user properties set
let client = PostHogAnalyticsClient()
client.updateUserProperties(AnalyticsEvent.UserProperties(ftueUseCaseSelection: .PersonalMessaging, numSpaces: nil))
client.start()
XCTAssertNotNil(client.pendingUserProperties, "The user properties should be cached.")
XCTAssertEqual(client.pendingUserProperties?.ftueUseCaseSelection, .PersonalMessaging, "The use case selection should match.")
// When calling identify (tests run under Debug configuration so this is sent to the development instance)
client.identify(id: UUID().uuidString)
// Then the properties should be cleared
XCTAssertNil(client.pendingUserProperties, "The user properties should be cleared.")
}
} }

1
changelog.d/5590.change Normal file
View file

@ -0,0 +1 @@
Add support for UserProperties to analytics and capture FTUE use case selection.