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 `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

View file

@ -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)
}

View file

@ -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:

View file

@ -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
}
}
}

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.
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
}
}

View file

@ -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)
}
}
}

View file

@ -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,

View file

@ -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)
}

View file

@ -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))
)
}

View file

@ -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()

View file

@ -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
View file

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