diff --git a/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Analytics/AnalyticsClient.swift b/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Analytics/AnalyticsClient.swift index 51fb69a888..c7db089487 100644 --- a/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Analytics/AnalyticsClient.swift +++ b/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Analytics/AnalyticsClient.swift @@ -31,6 +31,7 @@ public protocol AnalyticsClientBehaviour: Actor { onSubmit: SubmitResult?) @discardableResult func submitEvents() async throws -> [PinpointEvent] + func update(_ session: PinpointSession) async throws nonisolated func createAppleMonetizationEvent(with transaction: SKPaymentTransaction, with product: SKProduct) -> PinpointEvent @@ -262,6 +263,10 @@ actor AnalyticsClient: AnalyticsClientBehaviour { func submitEvents() async throws -> [PinpointEvent] { return try await eventRecorder.submitAllEvents() } + + func update(_ session: PinpointSession) async throws { + try await eventRecorder.update(session) + } func setAutomaticSubmitEventsInterval(_ interval: TimeInterval, onSubmit: SubmitResult?) { diff --git a/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Analytics/EventRecorder.swift b/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Analytics/EventRecorder.swift index 0beb869b77..0291d2f412 100644 --- a/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Analytics/EventRecorder.swift +++ b/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Analytics/EventRecorder.swift @@ -28,6 +28,10 @@ protocol AnalyticsEventRecording: Actor { func updateAttributesOfEvents(ofType: String, withSessionId: PinpointSession.SessionId, setAttributes: [String: String]) throws + + /// Updates the session information of the events that match the same sessionId. + /// - Parameter session: The session to update + func update(_ session: PinpointSession) throws /// Submit all locally stored events /// - Returns: A collection of events submitted to Pinpoint @@ -78,6 +82,10 @@ actor EventRecorder: AnalyticsEventRecording { setAttributes: attributes) } + func update(_ session: PinpointSession) throws { + try storage.updateSession(session) + } + /// Submit all locally stored events in batches. If a previous submission is in progress, it waits until it's completed before proceeding. /// When the submission for an event is accepted, the event is removed from local storage /// When the submission for an event is rejected, the event retry count is incremented in the local storage. Events that exceed the maximum retry count (3) are purged. diff --git a/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Analytics/LocalStorage/AnalyticsEventSQLStorage.swift b/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Analytics/LocalStorage/AnalyticsEventSQLStorage.swift index 1c7363627c..c0597a6e98 100644 --- a/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Analytics/LocalStorage/AnalyticsEventSQLStorage.swift +++ b/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Analytics/LocalStorage/AnalyticsEventSQLStorage.swift @@ -115,6 +115,22 @@ class AnalyticsEventSQLStorage: AnalyticsEventStorage { sessionId, eventType]) } + + func updateSession(_ session: PinpointSession) throws { + let updateStatement = """ + UPDATE Event + SET sessionStartTime = ?, sessionStopTime = ? + WHERE sessionId = ? + """ + _ = try dbAdapter.executeQuery( + updateStatement, + [ + session.startTime.asISO8601String, + session.stopTime?.asISO8601String, + session.sessionId + ] + ) + } /// Get the oldest event with limit /// - Parameter limit: The number of query result to limit diff --git a/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Analytics/LocalStorage/AnalyticsEventStorage.swift b/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Analytics/LocalStorage/AnalyticsEventStorage.swift index e74a7e35b0..d5aea17167 100644 --- a/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Analytics/LocalStorage/AnalyticsEventStorage.swift +++ b/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Analytics/LocalStorage/AnalyticsEventStorage.swift @@ -29,6 +29,10 @@ protocol AnalyticsEventStorage { func updateEvents(ofType: String, withSessionId: PinpointSession.SessionId, setAttributes: [String: String]) throws + + /// Updates the session information of the events that match the same sessionId. + /// - Parameter session: The session to update + func updateSession(_ session: PinpointSession) throws /// Get the oldest event with limit /// - Parameter limit: The number of query result to limit diff --git a/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Analytics/PinpointEvent+PinpointClientTypes.swift b/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Analytics/PinpointEvent+PinpointClientTypes.swift index a916d7dc2b..b198b5e45d 100644 --- a/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Analytics/PinpointEvent+PinpointClientTypes.swift +++ b/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Analytics/PinpointEvent+PinpointClientTypes.swift @@ -10,31 +10,36 @@ import AWSPluginsCore import Foundation extension PinpointEvent { - var clientTypeSession: PinpointClientTypes.Session? { - #if os(watchOS) - // If the session duration cannot be represented by Int, return a nil session instead. - // This is extremely unlikely to happen since a session's stopTime is set when the app is closed - if let duration = session.duration, duration > Int.max { - return nil + private var clientTypeSession: PinpointClientTypes.Session? { + var sessionDuration: Int? = nil + if let duration = session.duration { + // If the session duration cannot be represented by Int, return a nil session instead. + // This is extremely unlikely to happen since a session's stopTime is set when the app is closed + guard let intDuration = Int(exactly: duration) else { return nil } + sessionDuration = intDuration } - #endif - return PinpointClientTypes.Session(duration: Int(session.duration), - id: session.sessionId, - startTimestamp: session.startTime.asISO8601String, - stopTimestamp: session.stopTime?.asISO8601String) + + return .init( + duration: sessionDuration, + id: session.sessionId, + startTimestamp: session.startTime.asISO8601String, + stopTimestamp: session.stopTime?.asISO8601String + ) } var clientTypeEvent: PinpointClientTypes.Event { - return PinpointClientTypes.Event(appPackageName: Bundle.main.appPackageName, - appTitle: Bundle.main.appName, - appVersionCode: Bundle.main.appVersion, - attributes: attributes, - clientSdkVersion: AmplifyAWSServiceConfiguration.amplifyVersion, - eventType: eventType, - metrics: metrics, - sdkName: AmplifyAWSServiceConfiguration.platformName, - session: clientTypeSession, - timestamp: eventDate.asISO8601String) + return .init( + appPackageName: Bundle.main.appPackageName, + appTitle: Bundle.main.appName, + appVersionCode: Bundle.main.appVersion, + attributes: attributes, + clientSdkVersion: AmplifyAWSServiceConfiguration.amplifyVersion, + eventType: eventType, + metrics: metrics, + sdkName: AmplifyAWSServiceConfiguration.platformName, + session: clientTypeSession, + timestamp: eventDate.asISO8601String + ) } } @@ -55,12 +60,3 @@ extension Bundle { object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "" } } - -private extension Int { - init?(_ value: Int64?) { - guard let value = value else { - return nil - } - self.init(value) - } -} diff --git a/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Session/PinpointSession.swift b/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Session/PinpointSession.swift index 819d76bc43..3a43fc8bce 100644 --- a/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Session/PinpointSession.swift +++ b/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Session/PinpointSession.swift @@ -8,19 +8,33 @@ import Foundation @_spi(InternalAWSPinpoint) -public class PinpointSession: Codable { +public struct PinpointSession: Codable { + private enum State: Codable { + case active + case paused(date: Date) + case stopped(date: Date) + } typealias SessionId = String let sessionId: SessionId let startTime: Date - private(set) var stopTime: Date? + var stopTime: Date? { + switch state { + case .active: + return nil + case .paused(let stopTime), + .stopped(let stopTime): + return stopTime + } + } + + private var state: State = .active init(appId: String, uniqueId: String) { sessionId = Self.generateSessionId(appId: appId, uniqueId: uniqueId) startTime = Date() - stopTime = nil } init(sessionId: SessionId, @@ -28,33 +42,45 @@ public class PinpointSession: Codable { stopTime: Date?) { self.sessionId = sessionId self.startTime = startTime - self.stopTime = stopTime + if let stopTime { + self.state = .stopped(date: stopTime) + } } var isPaused: Bool { - return stopTime != nil + if case .paused = state { + return true + } + + return false + } + + var isStopped: Bool { + if case .stopped = state { + return true + } + + return false } var duration: Date.Millisecond? { /// According to Pinpoint's documentation, `duration` is only required if `stopTime` is not nil. - guard let endTime = stopTime else { - return nil - } - return endTime.millisecondsSince1970 - startTime.millisecondsSince1970 + guard let stopTime else { return nil } + return stopTime.millisecondsSince1970 - startTime.millisecondsSince1970 } - func stop() { - guard stopTime == nil else { return } - stopTime = Date() + mutating func stop() { + guard !isStopped else { return } + state = .stopped(date: stopTime ?? Date()) } - func pause() { + mutating func pause() { guard !isPaused else { return } - stopTime = Date() + state = .paused(date: Date()) } - func resume() { - stopTime = nil + mutating func resume() { + state = .active } private static func generateSessionId(appId: String, diff --git a/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Session/SessionClient.swift b/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Session/SessionClient.swift index 4f653d69d1..6d1fb27a03 100644 --- a/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Session/SessionClient.swift +++ b/AmplifyPlugins/Internal/Sources/InternalAWSPinpoint/Session/SessionClient.swift @@ -6,6 +6,7 @@ // import Amplify +import AWSPluginsCore import Foundation @_spi(InternalAWSPinpoint) @@ -14,7 +15,6 @@ public protocol SessionClientBehaviour: AnyObject { var analyticsClient: AnalyticsClientBehaviour? { get set } func startPinpointSession() - func validateOrRetrieveSession(_ session: PinpointSession?) -> PinpointSession func startTrackingSessions(backgroundTimeout: TimeInterval) } @@ -34,6 +34,7 @@ class SessionClient: SessionClientBehaviour { private let configuration: SessionClientConfiguration private let sessionClientQueue = DispatchQueue(label: Constants.queue, attributes: .concurrent) + private let analyticsTaskQueue = TaskQueue() private let userDefaults: UserDefaultsBehaviour private var sessionBackgroundTimeout: TimeInterval = .zero @@ -49,14 +50,16 @@ class SessionClient: SessionClientBehaviour { self.configuration = configuration self.endpointClient = endpointClient self.userDefaults = userDefaults - session = Self.retrieveStoredSession(from: userDefaults, using: archiver) ?? PinpointSession.invalid + session = Self.retrieveStoredSession(from: userDefaults, using: archiver) ?? .none } var currentSession: PinpointSession { - if session == PinpointSession.invalid { - startNewSession() + sessionClientQueue.sync(flags: .barrier) { + if session == .none { + startNewSession() + } + return session } - return session } func startPinpointSession() { @@ -65,9 +68,11 @@ class SessionClient: SessionClientBehaviour { return } + log.verbose("Starting a new Pinpoint Session") sessionClientQueue.sync(flags: .barrier) { - if session != PinpointSession.invalid { - endSession() + if session != .none { + log.verbose("There is a previous session") + endSession(andSave: false) } startNewSession() } @@ -77,7 +82,7 @@ class SessionClient: SessionClientBehaviour { sessionBackgroundTimeout = backgroundTimeout activityTracker.backgroundTrackingTimeout = backgroundTimeout activityTracker.beginActivityTracking { [weak self] newState in - guard let self = self else { return } + guard let self else { return } self.log.verbose("New state received: \(newState)") self.sessionClientQueue.sync(flags: .barrier) { self.respond(to: newState) @@ -85,20 +90,6 @@ class SessionClient: SessionClientBehaviour { } } - func validateOrRetrieveSession(_ session: PinpointSession?) -> PinpointSession { - if let session = session, !session.sessionId.isEmpty { - return session - } - - if let storedSession = Self.retrieveStoredSession(from: userDefaults, using: archiver) { - return storedSession - } - - return PinpointSession(sessionId: PinpointSession.Constants.defaultSessionId, - startTime: Date(), - stopTime: Date()) - } - private static func retrieveStoredSession(from userDefaults: UserDefaultsBehaviour, using archiver: AmplifyArchiverBehaviour) -> PinpointSession? { guard let sessionData = userDefaults.data(forKey: Constants.sessionKey), @@ -117,10 +108,11 @@ class SessionClient: SessionClientBehaviour { log.info("Session Started.") // Update Endpoint and record Session Start event - Task { - try? await endpointClient.updateEndpointProfile() - log.verbose("Firing Session Event: Start") - record(eventType: Constants.Events.start) + analyticsTaskQueue.async { [weak self] in + guard let self else { return } + try? await self.endpointClient.updateEndpointProfile() + self.log.verbose("Firing Session Event: Start") + await self.record(eventType: Constants.Events.start) } } @@ -134,22 +126,33 @@ class SessionClient: SessionClientBehaviour { } private func pauseSession() { + log.verbose("Attempting to pause session") session.pause() saveSession() log.info("Session Paused.") - log.verbose("Firing Session Event: Pause") - record(eventType: Constants.Events.pause) + analyticsTaskQueue.async { [weak self] in + guard let self else { return } + self.log.verbose("Firing Session Event: Pause") + await self.record(eventType: Constants.Events.pause) + } } private func resumeSession() { + log.verbose("Attempting to resume session") + if session.isStopped { + log.verbose("Session has been stopped. Starting a new one...") + startNewSession() + return + } + guard session.isPaused else { - log.verbose("Session Resume Failed: Session is already runnning.") + log.verbose("Session Resume Failed: Session is not paused") return } guard !isSessionExpired(session) else { log.verbose("Session has expired. Starting a fresh one...") - endSession() + endSession(andSave: false) startNewSession() return } @@ -157,20 +160,38 @@ class SessionClient: SessionClientBehaviour { session.resume() saveSession() log.info("Session Resumed.") - - log.verbose("Firing Session Event: Resume") - record(eventType: Constants.Events.resume) + analyticsTaskQueue.async { [weak self] in + guard let self else { return } + self.log.verbose("Firing Session Event: Resume") + await self.record(eventType: Constants.Events.resume) + } } - private func endSession() { + private func endSession(andSave shouldSave: Bool = true) { + log.verbose("Attempting to end session") + guard !session.isStopped else { + log.verbose("Session End Failed: Session is already stopped") + return + } session.stop() log.info("Session Stopped.") + analyticsTaskQueue.async { [weak self, session] in + guard let self = self, + let analyticsClient = self.analyticsClient else { + return + } + self.log.verbose("Removing remote global attributes") + await analyticsClient.removeAllRemoteGlobalAttributes() + + self.log.verbose("Updating session for existing events") + try? await analyticsClient.update(session) - Task { - log.verbose("Removing remote global attributes") - await analyticsClient?.removeAllRemoteGlobalAttributes() - log.verbose("Firing Session Event: Stop") - record(eventType: Constants.Events.stop) + self.log.verbose("Firing Session Event: Stop") + await self.record(eventType: Constants.Events.stop) + + if shouldSave { + self.saveSession() + } } } @@ -183,16 +204,14 @@ class SessionClient: SessionClientBehaviour { return now - stopTime > sessionBackgroundTimeout } - private func record(eventType: String) { + private func record(eventType: String) async { guard let analyticsClient = analyticsClient else { log.error("Pinpoint Analytics is disabled.") return } let event = analyticsClient.createEvent(withEventType: eventType) - Task { - try? await analyticsClient.record(event) - } + try? await analyticsClient.record(event) } private func respond(to newState: ApplicationState) { @@ -203,8 +222,8 @@ class SessionClient: SessionClientBehaviour { case .runningInBackground(let isStale): if isStale { endSession() - Task { - try? await analyticsClient?.submitEvents() + analyticsTaskQueue.async { [weak self] in + _ = try? await self?.analyticsClient?.submitEvents() } } else { pauseSession() @@ -243,5 +262,5 @@ extension SessionClient { } extension PinpointSession { - static var invalid = PinpointSession(sessionId: "InvalidId", startTime: Date(), stopTime: nil) + static var none = PinpointSession(sessionId: "InvalidId", startTime: Date(), stopTime: nil) } diff --git a/AmplifyPlugins/Internal/Tests/InternalAWSPinpointUnitTests/Mocks/MockAnalyticsClient.swift b/AmplifyPlugins/Internal/Tests/InternalAWSPinpointUnitTests/Mocks/MockAnalyticsClient.swift index 72c48c74c5..307fb0775e 100644 --- a/AmplifyPlugins/Internal/Tests/InternalAWSPinpointUnitTests/Mocks/MockAnalyticsClient.swift +++ b/AmplifyPlugins/Internal/Tests/InternalAWSPinpointUnitTests/Mocks/MockAnalyticsClient.swift @@ -102,6 +102,11 @@ actor MockAnalyticsClient: AnalyticsClientBehaviour { return [] } + var updateSessionCount = 0 + func update(_ session: InternalAWSPinpoint.PinpointSession) async throws { + updateSessionCount += 1 + } + func resetCounters() { recordCount = 0 submitEventsCount = 0 diff --git a/AmplifyPlugins/Internal/Tests/InternalAWSPinpointUnitTests/Mocks/MockAnalyticsEventStorage.swift b/AmplifyPlugins/Internal/Tests/InternalAWSPinpointUnitTests/Mocks/MockAnalyticsEventStorage.swift index 97d0a99d72..ebc515a641 100644 --- a/AmplifyPlugins/Internal/Tests/InternalAWSPinpointUnitTests/Mocks/MockAnalyticsEventStorage.swift +++ b/AmplifyPlugins/Internal/Tests/InternalAWSPinpointUnitTests/Mocks/MockAnalyticsEventStorage.swift @@ -75,4 +75,9 @@ class MockAnalyticsEventStorage: AnalyticsEventStorage { func checkDiskSize(limit: Byte) throws { checkDiskSizeCallCount += 1 } + + var updateSessionCount = 0 + func updateSession(_ session: PinpointSession) throws { + updateSessionCount += 1 + } } diff --git a/AmplifyPlugins/Internal/Tests/InternalAWSPinpointUnitTests/Mocks/MockEventRecorder.swift b/AmplifyPlugins/Internal/Tests/InternalAWSPinpointUnitTests/Mocks/MockEventRecorder.swift index e73512324b..50044a2a55 100644 --- a/AmplifyPlugins/Internal/Tests/InternalAWSPinpointUnitTests/Mocks/MockEventRecorder.swift +++ b/AmplifyPlugins/Internal/Tests/InternalAWSPinpointUnitTests/Mocks/MockEventRecorder.swift @@ -34,4 +34,9 @@ actor MockEventRecorder: AnalyticsEventRecording { submitCount += 1 return [] } + + var updateSessionCount = 0 + func update(_ session: InternalAWSPinpoint.PinpointSession) throws { + updateSessionCount += 1 + } } diff --git a/AmplifyPlugins/Internal/Tests/InternalAWSPinpointUnitTests/SessionClientTests.swift b/AmplifyPlugins/Internal/Tests/InternalAWSPinpointUnitTests/SessionClientTests.swift index c48b4f76aa..9799d3a172 100644 --- a/AmplifyPlugins/Internal/Tests/InternalAWSPinpointUnitTests/SessionClientTests.swift +++ b/AmplifyPlugins/Internal/Tests/InternalAWSPinpointUnitTests/SessionClientTests.swift @@ -114,47 +114,6 @@ class SessionClientTests: XCTestCase { XCTAssertEqual(userDefaults.saveCount, 0) } - func testValidateSession_withValidSession_andStoredSession_shouldReturnValidSession() async { - storeSession() - await resetCounters() - let session = PinpointSession(sessionId: "valid", startTime: Date(), stopTime: nil) - let retrievedSession = client.validateOrRetrieveSession(session) - - XCTAssertEqual(userDefaults.dataForKeyCount, 0) - XCTAssertEqual(archiver.decodeCount, 0) - XCTAssertEqual(retrievedSession.sessionId, "valid") - } - - func testValidateSession_withInvalidSession_andStoredSession_shouldReturnStoredSession() async { - storeSession() - await resetCounters() - let session = PinpointSession(sessionId: "", startTime: Date(), stopTime: nil) - let retrievedSession = client.validateOrRetrieveSession(session) - - XCTAssertEqual(userDefaults.dataForKeyCount, 1) - XCTAssertEqual(archiver.decodeCount, 1) - XCTAssertEqual(retrievedSession.sessionId, "stored") - } - - func testValidateSession_withInvalidSession_andWithoutStoredSession_shouldCreateDefaultSession() async { - await resetCounters() - let session = PinpointSession(sessionId: "", startTime: Date(), stopTime: nil) - let retrievedSession = client.validateOrRetrieveSession(session) - - XCTAssertEqual(userDefaults.dataForKeyCount, 1) - XCTAssertEqual(archiver.decodeCount, 0) - XCTAssertEqual(retrievedSession.sessionId, PinpointSession.Constants.defaultSessionId) - } - - func testValidateSession_withNilSession_andWithoutStoredSession_shouldCreateDefaultSession() async { - await resetCounters() - let retrievedSession = client.validateOrRetrieveSession(nil) - - XCTAssertEqual(userDefaults.dataForKeyCount, 1) - XCTAssertEqual(archiver.decodeCount, 0) - XCTAssertEqual(retrievedSession.sessionId, PinpointSession.Constants.defaultSessionId) - } - func testStartPinpointSession_shouldRecordStartEvent() async { await resetCounters() let expectationStartSession = expectation(description: "Start event for new session") @@ -218,7 +177,7 @@ class SessionClientTests: XCTestCase { XCTAssertEqual(event.eventType, SessionClient.Constants.Events.pause) } - func testApplicationMovedToBackground_stale_shouldRecordStopEvent_andSubmit() async { + func testApplicationMovedToBackground_stale_shouldRecordStopEvent_andSaveSession_andSubmitEvents() async { let expectationStartSession = expectation(description: "Start event for new session") client.startPinpointSession() client.startTrackingSessions(backgroundTimeout: sessionTimeout) @@ -233,8 +192,8 @@ class SessionClientTests: XCTestCase { activityTracker.callback?(.runningInBackground(isStale: true)) await fulfillment(of: [expectationStopSession, expectationSubmitEvents], timeout: 1) - XCTAssertEqual(archiver.encodeCount, 0) - XCTAssertEqual(userDefaults.saveCount, 0) + XCTAssertEqual(archiver.encodeCount, 1) + XCTAssertEqual(userDefaults.saveCount, 1) let createCount = await analyticsClient.createEventCount XCTAssertEqual(createCount, 1) let recordCount = await analyticsClient.recordCount @@ -327,7 +286,7 @@ class SessionClientTests: XCTestCase { XCTAssertNotNil(events.first(where: { $0.eventType == SessionClient.Constants.Events.start })) } #endif - func testApplicationTerminated_shouldRecordStopEvent() async { + func testApplicationTerminated_shouldRecordStopEvent_andSaveSession() async { let expectationStart = expectation(description: "Start event for new session") await analyticsClient.setRecordExpectation(expectationStart) client.startPinpointSession() @@ -340,8 +299,8 @@ class SessionClientTests: XCTestCase { activityTracker.callback?(.terminated) await fulfillment(of: [expectationStop], timeout: 1) - XCTAssertEqual(archiver.encodeCount, 0) - XCTAssertEqual(userDefaults.saveCount, 0) + XCTAssertEqual(archiver.encodeCount, 1) + XCTAssertEqual(userDefaults.saveCount, 1) let createCount = await analyticsClient.createEventCount XCTAssertEqual(createCount, 1) let recordCount = await analyticsClient.recordCount