mirror of
https://github.com/vector-im/element-ios.git
synced 2024-09-28 23:32:41 +00:00
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:
commit
4a0a67ff73
13 changed files with 201 additions and 33 deletions
|
@ -171,6 +171,14 @@ import AnalyticsEvents
|
|||
// 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 {
|
||||
/// 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
|
||||
/// - Parameters:
|
||||
/// - screen: The screen that was shown.
|
||||
|
@ -186,20 +194,21 @@ extension Analytics {
|
|||
trackScreen(screen, duration: nil)
|
||||
}
|
||||
|
||||
/// Track an element that has been tapped
|
||||
/// Track an element that has been interacted with
|
||||
/// - 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
|
||||
func trackTap(_ tap: AnalyticsUIElement, index: Int?) {
|
||||
let event = AnalyticsEvent.Click(index: index, name: tap.elementName)
|
||||
func trackInteraction(_ uiElement: AnalyticsUIElement, interactionType: AnalyticsEvent.Interaction.InteractionType, index: Int?) {
|
||||
let event = AnalyticsEvent.Interaction(index: index, interactionType: interactionType, name: uiElement.name)
|
||||
client.capture(event)
|
||||
}
|
||||
|
||||
/// Track an element that has been tapped without including an index
|
||||
/// - Parameters:
|
||||
/// - tap: The element that was tapped
|
||||
func trackTap(_ tap: AnalyticsUIElement) {
|
||||
trackTap(tap, index: nil)
|
||||
/// - uiElement: The element that was tapped
|
||||
func trackInteraction(_ uiElement: AnalyticsUIElement) {
|
||||
trackInteraction(uiElement, interactionType: .Touch, index: nil)
|
||||
}
|
||||
|
||||
/// Track an E2EE error that occurred
|
||||
|
|
|
@ -45,4 +45,12 @@ protocol AnalyticsClientProtocol {
|
|||
/// 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)
|
||||
}
|
||||
|
|
|
@ -18,6 +18,10 @@ import Foundation
|
|||
import AnalyticsEvents
|
||||
|
||||
@objc enum AnalyticsScreen: Int {
|
||||
case welcome
|
||||
case login
|
||||
case register
|
||||
case forgotPassword
|
||||
case sidebar
|
||||
case home
|
||||
case favourites
|
||||
|
@ -51,6 +55,14 @@ import AnalyticsEvents
|
|||
/// The screen name reported to the AnalyticsEvent.
|
||||
var screenName: AnalyticsEvent.Screen.ScreenName {
|
||||
switch self {
|
||||
case .welcome:
|
||||
return .Welcome
|
||||
case .login:
|
||||
return .Login
|
||||
case .register:
|
||||
return .Register
|
||||
case .forgotPassword:
|
||||
return .ForgotPassword
|
||||
case .sidebar:
|
||||
return .MobileSidebar
|
||||
case .home:
|
||||
|
|
|
@ -16,17 +16,17 @@
|
|||
|
||||
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 {
|
||||
case sendMessageButton
|
||||
case removeMe
|
||||
|
||||
/// The element name reported to the AnalyticsEvent.
|
||||
var elementName: AnalyticsEvent.Click.Name {
|
||||
var name: AnalyticsEvent.Interaction.Name {
|
||||
switch self {
|
||||
// Note: This is a test element that doesn't need to be captured.
|
||||
// It will likely be removed when the AnalyticsEvent.Click is updated.
|
||||
case .sendMessageButton:
|
||||
return .SendMessageButton
|
||||
// It can be removed when some elements are added that relate to mobile.
|
||||
case .removeMe:
|
||||
return .WebRoomSettingsLeaveButton
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -22,6 +22,9 @@ 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() {
|
||||
|
@ -36,11 +39,18 @@ class PostHogAnalyticsClient: AnalyticsClientProtocol {
|
|||
}
|
||||
|
||||
func identify(id: String) {
|
||||
postHog?.identify(id)
|
||||
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() {
|
||||
|
@ -55,11 +65,38 @@ class PostHogAnalyticsClient: AnalyticsClientProtocol {
|
|||
}
|
||||
|
||||
func capture(_ event: AnalyticsEventProtocol) {
|
||||
postHog?.capture(event.eventName, properties: event.properties)
|
||||
postHog?.capture(event.eventName, properties: attachUserProperties(to: event.properties))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -147,10 +147,10 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
|
|||
@available(iOS 14.0, *)
|
||||
/// Show the use case screen for new users.
|
||||
private func showUseCaseSelectionScreen() {
|
||||
let coordinator = OnboardingUseCaseCoordinator()
|
||||
let coordinator = OnboardingUseCaseSelectionCoordinator()
|
||||
coordinator.completion = { [weak self, weak coordinator] result in
|
||||
guard let self = self, let coordinator = coordinator else { return }
|
||||
self.useCaseCoordinator(coordinator, didCompleteWith: result)
|
||||
self.useCaseSelectionCoordinator(coordinator, didCompleteWith: result)
|
||||
}
|
||||
|
||||
coordinator.start()
|
||||
|
@ -166,7 +166,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
|
|||
}
|
||||
|
||||
/// 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
|
||||
showAuthenticationScreen()
|
||||
}
|
||||
|
@ -222,12 +222,15 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
|
|||
completion?()
|
||||
isShowingAuthentication = false
|
||||
|
||||
// Handle the chosen use case if appropriate
|
||||
// Handle the chosen use case where applicable
|
||||
if authenticationType == MXKAuthenticationTypeRegister,
|
||||
let useCaseResult = useCaseResult,
|
||||
let useCase = useCaseResult?.userSessionPropertyValue,
|
||||
let userSession = UserSessionsService.shared.mainUserSession {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ import Foundation
|
|||
@available(iOS 14.0, *)
|
||||
enum MockAppScreens {
|
||||
static let appScreens: [MockScreenState.Type] = [
|
||||
MockOnboardingUseCaseScreenState.self,
|
||||
MockOnboardingUseCaseSelectionScreenState.self,
|
||||
MockOnboardingSplashScreenScreenState.self,
|
||||
MockLocationSharingScreenState.self,
|
||||
MockAnalyticsPromptScreenState.self,
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
import SwiftUI
|
||||
|
||||
final class OnboardingUseCaseCoordinator: Coordinator, Presentable {
|
||||
final class OnboardingUseCaseSelectionCoordinator: Coordinator, Presentable {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
|
@ -36,7 +36,7 @@ final class OnboardingUseCaseCoordinator: Coordinator, Presentable {
|
|||
@available(iOS 14.0, *)
|
||||
init() {
|
||||
let viewModel = OnboardingUseCaseViewModel()
|
||||
let view = OnboardingUseCase(viewModel: viewModel.context)
|
||||
let view = OnboardingUseCaseSelectionScreen(viewModel: viewModel.context)
|
||||
onboardingUseCaseViewModel = viewModel
|
||||
|
||||
let hostingController = VectorHostingController(rootView: view)
|
||||
|
@ -47,9 +47,9 @@ final class OnboardingUseCaseCoordinator: Coordinator, Presentable {
|
|||
|
||||
// MARK: - Public
|
||||
func start() {
|
||||
MXLog.debug("[OnboardingUseCaseCoordinator] did start.")
|
||||
MXLog.debug("[OnboardingUseCaseSelectionCoordinator] did start.")
|
||||
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 }
|
||||
self.completion?(result)
|
||||
}
|
|
@ -20,7 +20,7 @@ import SwiftUI
|
|||
/// Using an enum for the screen allows you define the different state cases with
|
||||
/// the relevant associated data for each case.
|
||||
@available(iOS 14.0, *)
|
||||
enum MockOnboardingUseCaseScreenState: MockScreenState, CaseIterable {
|
||||
enum MockOnboardingUseCaseSelectionScreenState: MockScreenState, CaseIterable {
|
||||
// A case for each state you want to represent
|
||||
// with specific, minimal associated data that will allow you
|
||||
// mock that screen.
|
||||
|
@ -28,11 +28,11 @@ enum MockOnboardingUseCaseScreenState: MockScreenState, CaseIterable {
|
|||
|
||||
/// The associated screen
|
||||
var screenType: Any.Type {
|
||||
OnboardingUseCase.self
|
||||
OnboardingUseCaseSelectionScreen.self
|
||||
}
|
||||
|
||||
/// A list of screen state definitions
|
||||
static var allCases: [MockOnboardingUseCaseScreenState] {
|
||||
static var allCases: [MockOnboardingUseCaseSelectionScreenState] {
|
||||
// Each of the presence statuses
|
||||
[.default]
|
||||
}
|
||||
|
@ -45,7 +45,7 @@ enum MockOnboardingUseCaseScreenState: MockScreenState, CaseIterable {
|
|||
|
||||
return (
|
||||
[self, viewModel],
|
||||
AnyView(OnboardingUseCase(viewModel: viewModel.context)
|
||||
AnyView(OnboardingUseCaseSelectionScreen(viewModel: viewModel.context)
|
||||
.addDependency(MockAvatarService.example))
|
||||
)
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ import SwiftUI
|
|||
|
||||
@available(iOS 14.0, *)
|
||||
/// The screen shown to a new user to select their use case for the app.
|
||||
struct OnboardingUseCase: View {
|
||||
struct OnboardingUseCaseSelectionScreen: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
|
@ -119,7 +119,7 @@ struct OnboardingUseCase: View {
|
|||
|
||||
@available(iOS 14.0, *)
|
||||
struct OnboardingUseCase_Previews: PreviewProvider {
|
||||
static let stateRenderer = MockOnboardingUseCaseScreenState.stateRenderer
|
||||
static let stateRenderer = MockOnboardingUseCaseSelectionScreenState.stateRenderer
|
||||
static var previews: some View {
|
||||
NavigationView {
|
||||
stateRenderer.screenGroup()
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
import XCTest
|
||||
@testable import Riot
|
||||
import AnalyticsEvents
|
||||
|
||||
class AnalyticsTests: XCTestCase {
|
||||
func testAnalyticsPromptNewUser() {
|
||||
|
@ -70,4 +71,68 @@ class AnalyticsTests: XCTestCase {
|
|||
// Then no prompt should be shown.
|
||||
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
1
changelog.d/5590.change
Normal file
|
@ -0,0 +1 @@
|
|||
Add support for UserProperties to analytics and capture FTUE use case selection.
|
Loading…
Reference in a new issue