diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index 1ae79dfd4..b8c4d2a39 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -121,6 +121,9 @@ 69F518382BB2BA0100F52C14 /* PostHogSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F518372BB2BA0100F52C14 /* PostHogSwizzler.swift */; }; 69F5183A2BB2BA8300F52C14 /* UIApplicationTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F518392BB2BA8300F52C14 /* UIApplicationTracker.swift */; }; DA0CA6F12CFF6B6300AF9500 /* UIWindow+.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0CA6F02CFF6B6300AF9500 /* UIWindow+.swift */; }; + DA1D295E2D10B7B2003A31DA /* ApplicationLifecyclePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA1D29582D10B7A6003A31DA /* ApplicationLifecyclePublisher.swift */; }; + DA1D29602D10C810003A31DA /* PostHogSessionManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA1D295F2D10C80D003A31DA /* PostHogSessionManagerTest.swift */; }; + DA1D29622D115E17003A31DA /* DI.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA1D29612D115E13003A31DA /* DI.swift */; }; DA26419C2CC0499300CB427B /* PostHogAutocaptureEventTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA26419A2CC0499300CB427B /* PostHogAutocaptureEventTracker.swift */; }; DA4932FD2D0102950092C213 /* AssociatedKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA4932FC2D0102910092C213 /* AssociatedKeys.swift */; }; DA5AA7192CE245D2004EFB99 /* UIApplication+.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5AA7132CE245CD004EFB99 /* UIApplication+.swift */; }; @@ -132,6 +135,7 @@ DAD5DD0C2CB6DEF30087387B /* PostHogMaskViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD5DD072CB6DEE70087387B /* PostHogMaskViewModifier.swift */; }; DAD76A212D006AEE003E1A43 /* UIView+PostHogLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD76A1B2D006AE8003E1A43 /* UIView+PostHogLabel.swift */; }; DAD76A242D006C15003E1A43 /* View+PostHogLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD76A232D006C0B003E1A43 /* View+PostHogLabel.swift */; }; + DAF79A2A2D1309C00078A3C9 /* TestError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF79A242D1309BE0078A3C9 /* TestError.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -397,6 +401,9 @@ 69F518372BB2BA0100F52C14 /* PostHogSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSwizzler.swift; sourceTree = ""; }; 69F518392BB2BA8300F52C14 /* UIApplicationTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationTracker.swift; sourceTree = ""; }; DA0CA6F02CFF6B6300AF9500 /* UIWindow+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIWindow+.swift"; sourceTree = ""; }; + DA1D29582D10B7A6003A31DA /* ApplicationLifecyclePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationLifecyclePublisher.swift; sourceTree = ""; }; + DA1D295F2D10C80D003A31DA /* PostHogSessionManagerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSessionManagerTest.swift; sourceTree = ""; }; + DA1D29612D115E13003A31DA /* DI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DI.swift; sourceTree = ""; }; DA26419A2CC0499300CB427B /* PostHogAutocaptureEventTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAutocaptureEventTracker.swift; sourceTree = ""; }; DA4932FC2D0102910092C213 /* AssociatedKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssociatedKeys.swift; sourceTree = ""; }; DA5AA7132CE245CD004EFB99 /* UIApplication+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+.swift"; sourceTree = ""; }; @@ -409,6 +416,7 @@ DAD5DD072CB6DEE70087387B /* PostHogMaskViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogMaskViewModifier.swift; sourceTree = ""; }; DAD76A1B2D006AE8003E1A43 /* UIView+PostHogLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+PostHogLabel.swift"; sourceTree = ""; }; DAD76A232D006C0B003E1A43 /* View+PostHogLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+PostHogLabel.swift"; sourceTree = ""; }; + DAF79A242D1309BE0078A3C9 /* TestError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestError.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -485,6 +493,7 @@ 3A62646829C9E37A007E8C07 /* TestUtils */ = { isa = PBXGroup; children = ( + DAF79A242D1309BE0078A3C9 /* TestError.swift */, 3A62646929C9E385007E8C07 /* MockPostHogServer.swift */, 3A62647429CB0168007E8C07 /* TestPostHog.swift */, 3A580B4229E489D000C5C6F3 /* URLSession+body.swift */, @@ -518,6 +527,7 @@ 3AA4C09B2988315D006C4731 /* Utils */ = { isa = PBXGroup; children = ( + DA1D29582D10B7A6003A31DA /* ApplicationLifecyclePublisher.swift */, DA0CA6F02CFF6B6300AF9500 /* UIWindow+.swift */, DA5AA7132CE245CD004EFB99 /* UIApplication+.swift */, 3AE3FB422992985A00AFFC18 /* Reachability.swift */, @@ -572,6 +582,7 @@ 3AC745B7296D6FE60025C109 /* PostHog */ = { isa = PBXGroup; children = ( + DA1D29612D115E13003A31DA /* DI.swift */, DA26419B2CC0499300CB427B /* Autocapture */, 69EE82B82BA9C4DA00EB9542 /* Replay */, 69BA38E62B893F2200AA69D6 /* Resources */, @@ -606,6 +617,7 @@ 3AC745C3296D6FE60025C109 /* PostHogTests */ = { isa = PBXGroup; children = ( + DA1D295F2D10C80D003A31DA /* PostHogSessionManagerTest.swift */, 3A62646829C9E37A007E8C07 /* TestUtils */, 3AE3FB4A2993A68500AFFC18 /* PostHogStorageTest.swift */, 3A62647029CAF67B007E8C07 /* PostHogStorageManagerTest.swift */, @@ -1168,6 +1180,7 @@ 69F23A762BB308AE001194F6 /* URLSessionInterceptor.swift in Sources */, 690FF0BF2AEFA97F00A0B06B /* FileUtils.swift in Sources */, 69261D252AD9787A00232EC7 /* PostHogExtensions.swift in Sources */, + DA1D295E2D10B7B2003A31DA /* ApplicationLifecyclePublisher.swift in Sources */, 3AE3FB4E2993D1D600AFFC18 /* PostHogStorageManager.swift in Sources */, 3AE3FB49299391DF00AFFC18 /* PostHogStorage.swift in Sources */, DAD76A212D006AEE003E1A43 /* UIView+PostHogLabel.swift in Sources */, @@ -1208,6 +1221,7 @@ 69EE82BC2BA9C53000EB9542 /* PostHogSessionReplayConfig.swift in Sources */, DA5AA7192CE245D2004EFB99 /* UIApplication+.swift in Sources */, 69EE82CE2BAAC76000EB9542 /* ViewTreeSnapshotStatus.swift in Sources */, + DA1D29622D115E17003A31DA /* DI.swift in Sources */, 69ED1AD42C90A0F100FE7A91 /* URLSessionExtension.swift in Sources */, 69ED1A9F2C8F451B00FE7A91 /* PostHogPersonProfiles.swift in Sources */, 69EE82BA2BA9C50400EB9542 /* PostHogReplayIntegration.swift in Sources */, @@ -1236,6 +1250,7 @@ 3A62646A29C9E385007E8C07 /* MockPostHogServer.swift in Sources */, 690FF0BB2AEF8B8200A0B06B /* PostHogContextTest.swift in Sources */, 690FF0E32AEFD12900A0B06B /* PostHogConfigTest.swift in Sources */, + DAF79A2A2D1309C00078A3C9 /* TestError.swift in Sources */, 3A62647129CAF67B007E8C07 /* PostHogStorageManagerTest.swift in Sources */, 693E977D2C6257F9004B1030 /* ExampleSanitizer.swift in Sources */, 690FF0DF2AEFBC5700A0B06B /* PostHogLegacyQueueTest.swift in Sources */, @@ -1243,6 +1258,7 @@ 690FF0E92AEFD3BD00A0B06B /* PostHogQueueTest.swift in Sources */, 3AE3FB4B2993A68500AFFC18 /* PostHogStorageTest.swift in Sources */, 3A580B4329E489D000C5C6F3 /* URLSession+body.swift in Sources */, + DA1D29602D10C810003A31DA /* PostHogSessionManagerTest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/PostHog/Autocapture/PostHogAutocaptureEventTracker.swift b/PostHog/Autocapture/PostHogAutocaptureEventTracker.swift index 879c01ca6..a14ab7818 100644 --- a/PostHog/Autocapture/PostHogAutocaptureEventTracker.swift +++ b/PostHog/Autocapture/PostHogAutocaptureEventTracker.swift @@ -68,7 +68,7 @@ private static func unswizzle() { guard hasSwizzled else { return } hasSwizzled = false - swizzleMethods() // swizzling again will excahnge implementations back to original + swizzleMethods() // swizzling again will exchange implementations back to original unregisterNotifications() } diff --git a/PostHog/DI.swift b/PostHog/DI.swift new file mode 100644 index 000000000..05a91e5f6 --- /dev/null +++ b/PostHog/DI.swift @@ -0,0 +1,16 @@ +// +// DI.swift +// PostHog +// +// Created by Yiannis Josephides on 17/12/2024. +// + +// swiftlint:disable:next type_name +enum DI { + static var main = Container() + + final class Container { + lazy var appLifecyclePublisher: AppLifecyclePublishing = ApplicationLifecyclePublisher.shared + lazy var sessionManager: PostHogSessionManager = .init() + } +} diff --git a/PostHog/PostHogQueue.swift b/PostHog/PostHogQueue.swift index 6dbb7d100..bd3fd3b49 100644 --- a/PostHog/PostHogQueue.swift +++ b/PostHog/PostHogQueue.swift @@ -13,7 +13,7 @@ import Foundation The queue uses File persistence. This allows us to 1. Only send events when we have a network connection 2. Ensure that we can survive app closing or offline situations - 3. Not hold too much in mempory + 3. Not hold too much in memory */ diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 6e1226927..149e8a2a8 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -48,7 +48,6 @@ let maxRetryDelay = 30.0 private var capturedAppInstalled = false private var didRegisterNotifications = false private var appFromBackground = false - private var isInBackground = false #if os(iOS) private var replayIntegration: PostHogReplayIntegration? #endif @@ -150,8 +149,8 @@ let maxRetryDelay = 30.0 PostHogSessionManager.shared.startSession() #if os(iOS) - if config.sessionReplay { - replayIntegration?.start() + if config.sessionReplay, config.sessionReplayConfig.startMode == .automatic { + startSessionRecording() } #endif @@ -192,7 +191,7 @@ let maxRetryDelay = 30.0 return nil } - return PostHogSessionManager.shared.getSessionId() + return PostHogSessionManager.shared.getSessionId(readOnly: true) } @objc public func startSession() { @@ -200,9 +199,7 @@ let maxRetryDelay = 30.0 return } - PostHogSessionManager.shared.startSession { - self.resetViews() - } + PostHogSessionManager.shared.startSession() } @objc public func endSession() { @@ -210,9 +207,7 @@ let maxRetryDelay = 30.0 return } - PostHogSessionManager.shared.endSession { - self.resetViews() - } + PostHogSessionManager.shared.endSession() } // EVENT CAPTURE @@ -282,7 +277,8 @@ let maxRetryDelay = 30.0 userProperties: [String: Any]? = nil, userPropertiesSetOnce: [String: Any]? = nil, groups: [String: String]? = nil, - appendSharedProps: Bool = true) -> [String: Any] + appendSharedProps: Bool = true, + timestamp: Date? = nil) -> [String: Any] { var props: [String: Any] = [:] @@ -323,8 +319,18 @@ let maxRetryDelay = 30.0 props = props.merging(sdkInfo ?? [:]) { current, _ in current } } - if let sessionId = PostHogSessionManager.shared.getSessionId() { - props["$session_id"] = sessionId + // use existing session id if already present in props + // for session replay, we attach the session id on the event as early as possible to avoid sending snapshots to a wrong session + // if not present, get a current or new session id at event timestamp + let propSessionId = props["$session_id"] as? String + let sessionId: String? = propSessionId.isNilOrEmpty + ? PostHogSessionManager.shared.getSessionId(at: timestamp ?? now()) + : propSessionId + + if let sessionId { + if propSessionId.isNilOrEmpty { + props["$session_id"] = sessionId + } // only Session replay requires $window_id, so we set as the same as $session_id. // the backend might fallback to $session_id if $window_id is not present next. #if os(iOS) @@ -335,7 +341,7 @@ let maxRetryDelay = 30.0 } // only Session Replay needs distinct_id also in the props - // remove after https://github.com/PostHog/posthog/pull/18954 gets merged + // remove after https://github.com/PostHog/posthog/issues/23275 gets merged let propDistinctId = props["distinct_id"] as? String if !appendSharedProps, propDistinctId == nil || propDistinctId?.isEmpty == true { props["distinct_id"] = distinctId @@ -366,10 +372,7 @@ let maxRetryDelay = 30.0 flagCallReportedLock.withLock { flagCallReported.removeAll() } - PostHogSessionManager.shared.endSession { - self.resetViews() - } - PostHogSessionManager.shared.startSession() + PostHogSessionManager.shared.resetSession() // reload flags as anon user if shouldReloadFlagsForTesting { @@ -377,14 +380,6 @@ let maxRetryDelay = 30.0 } } - private func resetViews() { - #if os(iOS) - if config.sessionReplay, featureFlags?.isSessionReplayFlagActive() ?? false { - replayIntegration?.resetViews() - } - #endif - } - private func getGroups() -> [String: String] { guard let groups = storage?.getDictionary(forKey: .groups) as? [String: String] else { return [:] @@ -594,20 +589,8 @@ let maxRetryDelay = 30.0 return } - var snapshotEvent = false - if event == "$snapshot" { - snapshotEvent = true - } - - // If events fire in the background after the threshold, they should no longer have a sessionId - if isInBackground { - PostHogSessionManager.shared.resetSessionIfExpired { - self.resetViews() - } - } - - let eventTimestamp = timestamp ?? Date() - + let isSnapshotEvent = event == "$snapshot" + let eventTimestamp = timestamp ?? now() let eventDistinctId = distinctId ?? getDistinctId() // if the user isn't identified but passed userProperties, userPropertiesSetOnce or groups, @@ -621,7 +604,8 @@ let maxRetryDelay = 30.0 userProperties: sanitizeDictionary(userProperties), userPropertiesSetOnce: sanitizeDictionary(userPropertiesSetOnce), groups: groups, - appendSharedProps: !snapshotEvent) + appendSharedProps: !isSnapshotEvent, + timestamp: timestamp) let sanitizedProperties = sanitizeProperties(properties) let posthogEvent = PostHogEvent( @@ -632,7 +616,7 @@ let maxRetryDelay = 30.0 ) // Session Replay has its own queue - if snapshotEvent { + if isSnapshotEvent { guard let replayQueue else { return } @@ -992,7 +976,7 @@ let maxRetryDelay = 30.0 queue?.stop() replayQueue?.stop() #if os(iOS) - replayIntegration?.stop() + stopSessionRecording() replayIntegration = nil #endif #if os(iOS) || targetEnvironment(macCatalyst) @@ -1014,18 +998,77 @@ let maxRetryDelay = 30.0 flagCallReported.removeAll() } context = nil - PostHogSessionManager.shared.endSession { - self.resetViews() - } + PostHogSessionManager.shared.endSession() unregisterNotifications() capturedAppInstalled = false - appFromBackground = false - isInBackground = false toggleHedgeLog(false) shouldReloadFlagsForTesting = true } } + #if os(iOS) + /** + Starts session recording. + This method will have no effect if PostHog is not enabled or if session replay integration is not available. + + ## Note: + - This will resume the current session or create a new one if it doesn't exist + */ + @objc(startSessionRecording) + public func startSessionRecording() { + startSessionRecording(resumeCurrent: true) + } + + /** + Starts session recording. + This method will have no effect if PostHog is not enabled or if session replay integration is not available. + - Parameter resumeCurrent: + Whether to resume recording of current session (true) or start a new session (false). + */ + @objc(startSessionRecordingWithResumeCurrent:) + public func startSessionRecording(resumeCurrent: Bool) { + if !isEnabled() { + return + } + + guard let replayIntegration, !replayIntegration.isActive() else { + return + } + + guard config.sessionReplay else { + return hedgeLog("Could not resume recording. Session replay is disabled in config.") + } + + let sessionId = resumeCurrent + ? PostHogSessionManager.shared.getSessionId() + : PostHogSessionManager.shared.getNextSessionId() + + guard let sessionId else { + return hedgeLog("Could not start recording. Missing session id.") + } + + replayIntegration.start() + hedgeLog("Session replay recording started. Session id is \(sessionId)") + } + + /** + Stops the current session recording if one is in progress. + This method will have no effect if PostHog is not enabled or if session replay integration is not available. + */ + @objc public func stopSessionRecording() { + if !isEnabled() { + return + } + + guard let replayIntegration, replayIntegration.isActive() else { + return + } + + replayIntegration.stop() + hedgeLog("Session replay recording stopped.") + } + #endif + @objc public static func with(_ config: PostHogConfig) -> PostHogSDK { let postHog = PostHogSDK(config) postHog.setup(config) @@ -1174,11 +1217,6 @@ let maxRetryDelay = 30.0 } @objc func handleAppDidBecomeActive() { - PostHogSessionManager.shared.rotateSessionIdIfRequired { - self.resetViews() - } - - isInBackground = false captureAppOpened() } @@ -1211,10 +1249,6 @@ let maxRetryDelay = 30.0 @objc func handleAppDidEnterBackground() { captureAppBackgrounded() - - PostHogSessionManager.shared.updateSessionLastTime() - - isInBackground = true } private func captureAppBackgrounded() { @@ -1224,21 +1258,20 @@ let maxRetryDelay = 30.0 capture("Application Backgrounded") } - func isSessionActive() -> Bool { - if !isEnabled() { - return false - } - - return PostHogSessionManager.shared.isSessionActive() - } - #if os(iOS) @objc public func isSessionReplayActive() -> Bool { if !isEnabled() { return false } - return config.sessionReplay && isSessionActive() && (featureFlags?.isSessionReplayFlagActive() ?? false) + guard let replayIntegration else { + return false + } + + return config.sessionReplay + && !PostHogSessionManager.shared.getSessionId(readOnly: true).isNilOrEmpty + && replayIntegration.isActive() + && (featureFlags?.isSessionReplayFlagActive() ?? false) } #endif diff --git a/PostHog/PostHogSessionManager.swift b/PostHog/PostHogSessionManager.swift index 1d4ef2e10..53afd35c8 100644 --- a/PostHog/PostHogSessionManager.swift +++ b/PostHog/PostHogSessionManager.swift @@ -8,115 +8,209 @@ import Foundation // only for internal use +// Do we need to expose this as public API? Could be internal static instead? @objc public class PostHogSessionManager: NSObject { - @objc public static let shared = PostHogSessionManager() + enum SessionIDChangeReason: String { + case sessionIdEmpty = "Session id was empty" + case sessionStart = "Session started" + case sessionEnd = "Session ended" + case sessionReset = "Session was reset" + case sessionTimeout = "Session timed out" + case sessionPastMaximumLength = "Session past maximum length" + case customSessionId = "Custom session set" + } + + @objc public static var shared: PostHogSessionManager { + DI.main.sessionManager + } // Private initializer to prevent multiple instances - override private init() {} + override init() { + super.init() + registerNotifications() + } private var sessionId: String? - private var sessionLastTimestamp: TimeInterval? + private var sessionStartTimestamp: TimeInterval? + private var sessionActivityTimestamp: TimeInterval? private let sessionLock = NSLock() + private var isAppInBackground = true // 30 minutes in seconds - private let sessionChangeThreshold: TimeInterval = 60 * 30 + private let sessionActivityThreshold: TimeInterval = 60 * 30 + // 24 hours in seconds + private let sessionMaxLengthThreshold: TimeInterval = 24 * 60 * 60 + // Called when session id is cleared or changes + var onSessionIDChanged: () -> Void = {} - func getSessionId() -> String? { - var tempSessionId: String? - sessionLock.withLock { - tempSessionId = sessionId - } - return tempSessionId + @objc public func setSessionId(_ sessionId: String) { + setSessionIdInternal(sessionId, reason: .customSessionId) } - @objc public func setSessionId(_ sessionId: String) { - sessionLock.withLock { - self.sessionId = sessionId - } + private func isNotReactNative() -> Bool { + // for the RN SDK, the session is handled by the RN SDK itself + postHogSdkName != "posthog-react-native" } - func endSession(_ completion: () -> Void) { - sessionLock.withLock { - sessionId = nil - sessionLastTimestamp = nil - completion() + /** + Returns the current session id, and manages id rotation logic + + In addition, this method handles core session cycling logic including: + - Creates a new session id when none exists (but only if app is foregrounded) + - if `readOnly` is false + - Rotates session after *30 minutes* of inactivity + - Clears session after *30 minutes* of inactivity (when app is backgrounded) + - Enforces a maximum session duration of *24 hours* + + - Parameters: + - timeNow: Reference timestamp used for evaluating session expiry rules. + Defaults to current system time. + - readOnly: When true, bypasses all session management logic and returns + the current session id without modifications. + Defaults to false. + + - Returns: Returns the existing session id, or a new one after performing validity checks + */ + func getSessionId( + at timeNow: Date = now(), + readOnly: Bool = false + ) -> String? { + let timeNow = timeNow.timeIntervalSince1970 + let (currentSessionId, lastActive, sessionStart, isBackgrounded) = sessionLock.withLock { + (sessionId, sessionActivityTimestamp, sessionStartTimestamp, isAppInBackground) } + + // RN manages its own session, just return session id + guard isNotReactNative(), !readOnly else { + return currentSessionId + } + + // Create a new session id if empty + if currentSessionId.isNilOrEmpty, !isBackgrounded { + return rotateSession(force: true, reason: .sessionIdEmpty) + } + + // Check if session has passed maximum inactivity length + if let lastActive, isExpired(timeNow, lastActive, sessionActivityThreshold) { + return isBackgrounded + ? clearSession(reason: .sessionTimeout) + : rotateSession(reason: .sessionTimeout) + } + + // Check if session has passed maximum session length + if let sessionStart, isExpired(timeNow, sessionStart, sessionMaxLengthThreshold) { + return isBackgrounded + ? clearSession(reason: .sessionPastMaximumLength) + : rotateSession(reason: .sessionPastMaximumLength) + } + + return currentSessionId } - private func isExpired(_ timeNow: TimeInterval, _ sessionLastTimestamp: TimeInterval) -> Bool { - timeNow - sessionLastTimestamp > sessionChangeThreshold + func getNextSessionId() -> String? { + rotateSession(force: true, reason: .sessionStart) } - private func isNotReactNative() -> Bool { - // for the RN SDK, the session is handled by the RN SDK itself - postHogSdkName != "posthog-react-native" + /// Creates a new session id and sets timestamps + func startSession(_ completion: (() -> Void)? = nil) { + rotateSession(force: true, reason: .sessionStart) + completion?() } - func resetSessionIfExpired(_ completion: () -> Void) { + /// Clears current session id and timestamps + func endSession(_ completion: (() -> Void)? = nil) { + clearSession(reason: .sessionEnd) + completion?() + } + + /// Resets current session id and timestamps + func resetSession() { + rotateSession(force: true, reason: .sessionReset) + } + + /// Call this method to mark any user activity on this session + func touchSession() { guard isNotReactNative() else { return } + let timestamp = now().timeIntervalSince1970 sessionLock.withLock { - let timeNow = now().timeIntervalSince1970 - if sessionId != nil, - let sessionLastTimestamp = sessionLastTimestamp, - isExpired(timeNow, sessionLastTimestamp) - { - sessionId = nil - completion() + if sessionId != nil { + sessionActivityTimestamp = timestamp } } } - private func rotateSession(_ completion: (() -> Void)?) { - let newSessionId = UUID.v7().uuidString - let newSessionLastTimestamp = now().timeIntervalSince1970 + /** + Rotates the current session id + + - Parameters: + - force: When true, creates a new session ID if current one is empty + - reason: The underlying reason behind this session ID rotation + - Returns: a new session id + */ + @discardableResult private func rotateSession(force: Bool = false, reason: SessionIDChangeReason) -> String? { + // only rotate when session is empty + if !force { + let currentSessionId = sessionLock.withLock { sessionId } + if currentSessionId.isNilOrEmpty { + return currentSessionId + } + } - sessionId = newSessionId - sessionLastTimestamp = newSessionLastTimestamp - completion?() + let newSessionId = UUID.v7().uuidString + setSessionIdInternal(newSessionId, reason: reason) + return newSessionId } - func startSession(_ completion: (() -> Void)? = nil) { - sessionLock.withLock { - // only start if there is no session - if sessionId != nil { - return - } - rotateSession(completion) - } + @discardableResult private func clearSession(reason: SessionIDChangeReason) -> String? { + setSessionIdInternal(nil, reason: reason) + return nil } - func rotateSessionIdIfRequired(_ completion: @escaping (() -> Void)) { - guard isNotReactNative() else { - return - } + private func setSessionIdInternal(_ sessionId: String?, reason: SessionIDChangeReason) { + let newTimestamp = sessionId != nil ? now().timeIntervalSince1970 : nil sessionLock.withLock { - let timeNow = now().timeIntervalSince1970 + self.sessionId = sessionId + self.sessionStartTimestamp = newTimestamp + self.sessionActivityTimestamp = newTimestamp + } - guard sessionId != nil, let sessionLastTimestamp = sessionLastTimestamp else { - rotateSession(completion) - return - } + onSessionIDChanged() - if isExpired(timeNow, sessionLastTimestamp) { - rotateSession(completion) - } + if let sessionId { + hedgeLog("New session id created \(sessionId) (\(reason))") + } else { + hedgeLog("Session id cleared - reason: (\(reason))") } } - func updateSessionLastTime() { - guard isNotReactNative() else { - return + var didBecomeActiveToken: RegistrationToken? + var didEnterBackgroundToken: RegistrationToken? + var didFinishLaunchingToken: RegistrationToken? + + private func registerNotifications() { + let lifecyclePublisher = DI.main.appLifecyclePublisher + didBecomeActiveToken = lifecyclePublisher.onDidBecomeActive { [weak self] in + guard let self, isAppInBackground else { return } + // we consider foregrounding an app an activity on the current session + touchSession() + self.isAppInBackground = false } - - sessionLock.withLock { - sessionLastTimestamp = now().timeIntervalSince1970 + didEnterBackgroundToken = lifecyclePublisher.onDidEnterBackground { [weak self] in + guard let self, !isAppInBackground else { return } + // we consider backgrounding the app an activity on the current session + touchSession() + self.isAppInBackground = true + } + didFinishLaunchingToken = lifecyclePublisher.onDidFinishLaunching { [weak self] in + guard let self, isAppInBackground else { return } + self.isAppInBackground = false } } - func isSessionActive() -> Bool { - getSessionId() != nil + private func isExpired(_ timeNow: TimeInterval, _ timeThen: TimeInterval, _ threshold: TimeInterval) -> Bool { + max(timeNow - timeThen, 0) > threshold } } diff --git a/PostHog/PostHogStorage.swift b/PostHog/PostHogStorage.swift index e474160ca..27b663aa2 100644 --- a/PostHog/PostHogStorage.swift +++ b/PostHog/PostHogStorage.swift @@ -15,7 +15,12 @@ import Foundation */ func applicationSupportDirectoryURL() -> URL { let url = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - return url.appendingPathComponent(Bundle.main.bundleIdentifier!) + #if canImport(XCTest) // only visible to test targets + return url.appendingPathComponent(Bundle.main.bundleIdentifier ?? "com.posthog.test") + #else + // TODO: Should we be using a fallback temp directory instead of force unwrapping here? + return url.appendingPathComponent(Bundle.main.bundleIdentifier!) + #endif } class PostHogStorage { diff --git a/PostHog/Replay/PostHogReplayIntegration.swift b/PostHog/Replay/PostHogReplayIntegration.swift index 8b7fde505..216208aca 100644 --- a/PostHog/Replay/PostHogReplayIntegration.swift +++ b/PostHog/Replay/PostHogReplayIntegration.swift @@ -105,6 +105,8 @@ func start() { stopTimer() + // reset views when session id changes (or is cleared) so we can re-send new metadata (or full snapshot in the future) + PostHogSessionManager.shared.onSessionIDChanged = resetViews // flutter captures snapshots, so we don't need to capture them here if isNotFlutter() { @@ -125,20 +127,25 @@ func stop() { stopTimer() + resetViews() + PostHogSessionManager.shared.onSessionIDChanged = {} + ViewLayoutTracker.unSwizzleLayoutSubviews() - windowViews.removeAllObjects() UIApplicationTracker.unswizzleSendEvent() - sessionSwizzler?.unswizzle() urlInterceptor.stop() } + func isActive() -> Bool { + timer != nil + } + private func stopTimer() { timer?.invalidate() timer = nil } - func resetViews() { + private func resetViews() { windowViews.removeAllObjects() } @@ -149,6 +156,11 @@ let timestamp = timestampDate.toMillis() let snapshotStatus = windowViews.object(forKey: window) ?? ViewTreeSnapshotStatus() + // always make sure we have a fresh session id as early as possible + guard let sessionId: String = PostHogSessionManager.shared.getSessionId(at: timestampDate) else { + return + } + guard let wireframe = config.sessionReplayConfig.screenshotMode ? toScreenshotWireframe(window) : toWireframe(window) else { return } @@ -191,6 +203,7 @@ properties: [ "$snapshot_source": "mobile", "$snapshot_data": snapshotsData, + "$session_id": sessionId, ], timestamp: timestampDate ) diff --git a/PostHog/Replay/String+Util.swift b/PostHog/Replay/String+Util.swift index ea8d1fe6f..142a78a22 100644 --- a/PostHog/Replay/String+Util.swift +++ b/PostHog/Replay/String+Util.swift @@ -12,3 +12,9 @@ extension String { String(repeating: "*", count: count) } } + +extension Optional where Wrapped == String { + var isNilOrEmpty: Bool { + (self ?? "").isEmpty + } +} diff --git a/PostHog/Replay/UIApplicationTracker.swift b/PostHog/Replay/UIApplicationTracker.swift index 10bc6e8a9..adb17ade5 100644 --- a/PostHog/Replay/UIApplicationTracker.swift +++ b/PostHog/Replay/UIApplicationTracker.swift @@ -28,9 +28,10 @@ return } + // swizzling twice will exchange implementations back to original swizzle(forClass: UIApplication.self, - original: #selector(UIApplication.sendEventOverride), - new: #selector(UIApplication.sendEvent(_:))) + original: #selector(UIApplication.sendEvent(_:)), + new: #selector(UIApplication.sendEventOverride)) hasSwizzled = false } } @@ -51,6 +52,11 @@ return } + // always make sure we have a fresh session id as early as possible + guard let sessionId: String = PostHogSessionManager.shared.getSessionId(at: date) else { + return + } + // capture necessary touch information on the main thread before performing any asynchronous operations // - this ensures that UITouch associated objects like UIView, UIWindow, or [UIGestureRecognizer] are still valid. // - these objects may be released or erased by the system if accessed asynchronously, resulting in invalid/zeroed-out touch coordinates @@ -92,6 +98,7 @@ properties: [ "$snapshot_source": "mobile", "$snapshot_data": snapshotsData, + "$session_id": sessionId, ], timestamp: date ) @@ -103,8 +110,10 @@ // touch.timestamp is since boot time so we need to get the current time, best effort let date = Date() captureEvent(event, date: date) - sendEventOverride(event) + // update "last active" session + // we want to keep track of the idle time, so we need to maintain a timestamp on the last interactions of the user with the app. UIEvents are a good place to do so since it means that the user is actively interacting with the app (e.g not just noise background activity) + PostHogSessionManager.shared.touchSession() } } #endif diff --git a/PostHog/Replay/URLSessionExtension.swift b/PostHog/Replay/URLSessionExtension.swift index 182faa2e8..59cf5b185 100644 --- a/PostHog/Replay/URLSessionExtension.swift +++ b/PostHog/Replay/URLSessionExtension.swift @@ -107,13 +107,18 @@ // MARK: Private methods private func captureData(request: URLRequest? = nil, response: URLResponse? = nil, timestamp: Date, start: UInt64, end: UInt64? = nil) { - // we dont check config.sessionReplayConfig.captureNetworkTelemetry here since this extension + // we don't check config.sessionReplayConfig.captureNetworkTelemetry here since this extension // has to be called manually anyway if !PostHogSDK.shared.isSessionReplayActive() { return } let currentEnd = end ?? getMonotonicTimeInMilliseconds() + // always make sure we have a fresh session id as early as possible + guard let sessionId: String = PostHogSessionManager.shared.getSessionId(at: timestamp) else { + return + } + PostHogReplayIntegration.dispatchQueue.async { var snapshotsData: [Any] = [] @@ -142,9 +147,11 @@ PostHogSDK.shared.capture( "$snapshot", - properties: - ["$snapshot_source": "mobile", - "$snapshot_data": snapshotsData], + properties: [ + "$snapshot_source": "mobile", + "$snapshot_data": snapshotsData, + "$session_id": sessionId, + ], timestamp: timestamp ) } diff --git a/PostHog/Replay/URLSessionInterceptor.swift b/PostHog/Replay/URLSessionInterceptor.swift index 2a7bc3a62..995869e96 100644 --- a/PostHog/Replay/URLSessionInterceptor.swift +++ b/PostHog/Replay/URLSessionInterceptor.swift @@ -122,20 +122,32 @@ } private func finish(task: URLSessionTask, sample: NetworkSample) { + let timestamp = sample.timeOrigin + + // always make sure we have a fresh session id as early as possible + guard let sessionId: String = PostHogSessionManager.shared.getSessionId(at: timestamp) else { + return + } + var snapshotsData: [Any] = [] let requestsData = [sample.toDict()] let payloadData: [String: Any] = ["requests": requestsData] let pluginData: [String: Any] = ["plugin": "rrweb/network@1", "payload": payloadData] - let data: [String: Any] = ["type": 6, "data": pluginData, "timestamp": sample.timeOrigin.toMillis()] + let data: [String: Any] = [ + "type": 6, + "data": pluginData, + "timestamp": timestamp.toMillis(), + ] snapshotsData.append(data) PostHogSDK.shared.capture( "$snapshot", properties: [ "$snapshot_source": "mobile", - "$snapshot_data": snapshotsData + "$snapshot_data": snapshotsData, + "$session_id": sessionId, ], timestamp: sample.timeOrigin ) diff --git a/PostHog/Replay/ViewLayoutTracker.swift b/PostHog/Replay/ViewLayoutTracker.swift index ed75f41db..1ab45e62c 100644 --- a/PostHog/Replay/ViewLayoutTracker.swift +++ b/PostHog/Replay/ViewLayoutTracker.swift @@ -29,8 +29,8 @@ return } swizzle(forClass: UIView.self, - original: #selector(UIView.layoutSubviewsOverride), - new: #selector(UIView.layoutSubviews)) + original: #selector(UIView.layoutSubviews), + new: #selector(UIView.layoutSubviewsOverride)) hasSwizzled = false } } diff --git a/PostHog/Utils/ApplicationLifecyclePublisher.swift b/PostHog/Utils/ApplicationLifecyclePublisher.swift new file mode 100644 index 000000000..cd780fb76 --- /dev/null +++ b/PostHog/Utils/ApplicationLifecyclePublisher.swift @@ -0,0 +1,174 @@ +// +// ApplicationLifecyclePublisher.swift +// PostHog +// +// Created by Yiannis Josephides on 16/12/2024. +// + +#if os(iOS) || os(tvOS) + import UIKit +#elseif os(macOS) + import AppKit +#elseif os(watchOS) + import WatchKit +#endif + +typealias AppLifecycleHandler = () -> Void + +protocol AppLifecyclePublishing: AnyObject { + /// Registers a callback for the `didBecomeActive` event. + func onDidBecomeActive(_ callback: @escaping AppLifecycleHandler) -> RegistrationToken + /// Registers a callback for the `didEnterBackground` event. + func onDidEnterBackground(_ callback: @escaping AppLifecycleHandler) -> RegistrationToken + /// Registers a callback for the `didFinishLaunching` event. + func onDidFinishLaunching(_ callback: @escaping AppLifecycleHandler) -> RegistrationToken +} + +/** + A publisher that handles application lifecycle events and allows registering callbacks for them. + + This class provides a way to observe application lifecycle events like when the app becomes active, + enters background, or finishes launching. Callbacks can be registered for each event type and will + be automatically unregistered when their registration token is deallocated. + + Example usage: + ``` + let token = ApplicationLifecyclePublisher.shared.onDidBecomeActive { + // App became active logic + } + // Keep `token` in memory to keep the registration active + // When token is deallocated, the callback will be automatically unregistered + ``` + */ +final class ApplicationLifecyclePublisher: BaseApplicationLifecyclePublisher { + /// Shared instance to allow easy access across the app. + static let shared = ApplicationLifecyclePublisher() + + override private init() { + super.init() + + let defaultCenter = NotificationCenter.default + + #if os(iOS) || os(tvOS) + defaultCenter.addObserver(self, + selector: #selector(appDidFinishLaunching), + name: UIApplication.didFinishLaunchingNotification, + object: nil) + defaultCenter.addObserver(self, + selector: #selector(appDidEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil) + defaultCenter.addObserver(self, + selector: #selector(appDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil) + #elseif os(macOS) + defaultCenter.addObserver(self, + selector: #selector(appDidFinishLaunching), + name: NSApplication.didFinishLaunchingNotification, + object: nil) + // macOS does not have didEnterBackgroundNotification, so we use didResignActiveNotification + defaultCenter.addObserver(self, + selector: #selector(appDidEnterBackground), + name: NSApplication.didResignActiveNotification, + object: nil) + defaultCenter.addObserver(self, + selector: #selector(appDidBecomeActive), + name: NSApplication.didBecomeActiveNotification, + object: nil) + #elseif os(watchOS) + if #available(watchOS 7.0, *) { + NotificationCenter.default.addObserver(self, + selector: #selector(appDidBecomeActive), + name: WKApplication.didBecomeActiveNotification, + object: nil) + } else { + NotificationCenter.default.addObserver(self, + selector: #selector(appDidBecomeActive), + name: .init("UIApplicationDidBecomeActiveNotification"), + object: nil) + } + #endif + } + + // MARK: - Handlers + + @objc private func appDidEnterBackground() { + for handler in didEnterBackgroundCallbacks.values { + notifyHander(handler) + } + } + + @objc private func appDidBecomeActive() { + for handler in didBecomeActiveCallbacks.values { + notifyHander(handler) + } + } + + @objc private func appDidFinishLaunching() { + for handler in didFinishLaunchingCallbacks.values { + notifyHander(handler) + } + } + + private func notifyHander(_ handler: @escaping AppLifecycleHandler) { + if Thread.isMainThread { + handler() + } else { + DispatchQueue.main.async(execute: handler) + } + } +} + +class BaseApplicationLifecyclePublisher: AppLifecyclePublishing { + private let registrationLock = NSLock() + + var didBecomeActiveCallbacks: [UUID: AppLifecycleHandler] = [:] + var didEnterBackgroundCallbacks: [UUID: AppLifecycleHandler] = [:] + var didFinishLaunchingCallbacks: [UUID: AppLifecycleHandler] = [:] + + /// Registers a callback for the `didBecomeActive` event. + func onDidBecomeActive(_ callback: @escaping AppLifecycleHandler) -> RegistrationToken { + register(handler: callback, on: \.didBecomeActiveCallbacks) + } + + /// Registers a callback for the `didEnterBackground` event. + func onDidEnterBackground(_ callback: @escaping AppLifecycleHandler) -> RegistrationToken { + register(handler: callback, on: \.didEnterBackgroundCallbacks) + } + + /// Registers a callback for the `didFinishLaunching` event. + func onDidFinishLaunching(_ callback: @escaping AppLifecycleHandler) -> RegistrationToken { + register(handler: callback, on: \.didFinishLaunchingCallbacks) + } + + func register( + handler callback: @escaping AppLifecycleHandler, + on keyPath: ReferenceWritableKeyPath + ) -> RegistrationToken { + let id = UUID() + registrationLock.withLock { + self[keyPath: keyPath][id] = callback + } + + return RegistrationToken { [weak self] in + // Registration token deallocated here + guard let self else { return } + self.registrationLock.withLock { + self[keyPath: keyPath][id] = nil + } + } + } +} + +final class RegistrationToken { + private let onDealloc: () -> Void + + init(_ onDealloc: @escaping () -> Void) { + self.onDealloc = onDealloc + } + + deinit { + onDealloc() + } +} diff --git a/PostHogExample/ContentView.swift b/PostHogExample/ContentView.swift index 39c2f33aa..35b1d9d26 100644 --- a/PostHogExample/ContentView.swift +++ b/PostHogExample/ContentView.swift @@ -66,6 +66,7 @@ struct ContentView: View { @State private var name: String = "Max" @State private var showingSheet = false @State private var showingRedactedSheet = false + @State private var refreshStatusID = UUID() @StateObject var api = Api() @StateObject var signInViewModel = SignInViewModel() @@ -92,6 +93,32 @@ struct ContentView: View { var body: some View { NavigationStack { List { + Section("Manual Session Recording Control") { + Text("\(sessionRecordingStatus) SID: \(PostHogSDK.shared.getSessionId() ?? "NA")") + .lineLimit(1) + .truncationMode(.middle) + .multilineTextAlignment(.leading) + .id(refreshStatusID) + + Button("Stop") { + PostHogSDK.shared.stopSessionRecording() + DispatchQueue.main.async { + refreshStatusID = UUID() + } + } + Button("Resume") { + PostHogSDK.shared.startSessionRecording() + DispatchQueue.main.async { + refreshStatusID = UUID() + } + } + Button("Start New Session") { + PostHogSDK.shared.startSessionRecording(resumeCurrent: false) + DispatchQueue.main.async { + refreshStatusID = UUID() + } + } + } Section("General") { NavigationLink { ContentView() @@ -216,6 +243,10 @@ struct ContentView: View { }) } } + + private var sessionRecordingStatus: String { + PostHogSDK.shared.isSessionReplayActive() ? "🟢" : "🔴" + } } struct ContentView_Previews: PreviewProvider { diff --git a/PostHogObjCExample/AppDelegate.m b/PostHogObjCExample/AppDelegate.m index bd470af76..3b44bddca 100644 --- a/PostHogObjCExample/AppDelegate.m +++ b/PostHogObjCExample/AppDelegate.m @@ -29,6 +29,7 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( PostHogConfig *config = [[PostHogConfig alloc] apiKey:@"_6SG-F7I1vCuZ-HdJL3VZQqjBlaSb1_20hDPwqMNnGI"]; config.preloadFeatureFlags = YES; + config.sessionReplayConfig.startMode = PostHogSessionReplayStartModeManual; [[PostHogSDK shared] debug:YES]; [[PostHogSDK shared] setup:config]; @@ -136,6 +137,11 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( [postHog capture:@"theCapture"]; + [[PostHogSDK shared] startSessionRecording]; + [[PostHogSDK shared] stopSessionRecording]; + [[PostHogSDK shared] startSessionRecordingWithResumeCurrent:TRUE]; + [[PostHogSDK shared] stopSessionRecording]; + return YES; } diff --git a/PostHogTests/PostHogAutocaptureEventTrackerSpec.swift b/PostHogTests/PostHogAutocaptureEventTrackerSpec.swift index 6cc925354..6554b0dde 100644 --- a/PostHogTests/PostHogAutocaptureEventTrackerSpec.swift +++ b/PostHogTests/PostHogAutocaptureEventTrackerSpec.swift @@ -19,7 +19,6 @@ let view = UIView() let eventData = view.eventData! - expect(eventData.targetClass).to(equal("UIView")) expect(eventData.viewHierarchy.count).to(equal(1)) } @@ -29,7 +28,6 @@ superview.addSubview(button) let eventData = button.eventData! - expect(eventData.targetClass).to(equal("UIButton")) expect(eventData.viewHierarchy.count).to(equal(2)) expect(eventData.screenName).to(beNil()) } diff --git a/PostHogTests/PostHogAutocaptureIntegrationSpec.swift b/PostHogTests/PostHogAutocaptureIntegrationSpec.swift index 8c64fba4f..a543f134c 100644 --- a/PostHogTests/PostHogAutocaptureIntegrationSpec.swift +++ b/PostHogTests/PostHogAutocaptureIntegrationSpec.swift @@ -120,11 +120,13 @@ import Quick value: nil, screenName: "TestScreen", viewHierarchy: [ - .init(text: "Test Button", targetClass: "UIButton", index: 0, subviewCount: 0), + .init( + text: "Test Button", + targetClass: "UIButton", + baseClass: "UIControl", + label: nil + ), ], - targetClass: "UIButton", - accessibilityLabel: nil, - accessibilityIdentifier: nil, debounceInterval: debounceInterval ) } diff --git a/PostHogTests/PostHogContextTest.swift b/PostHogTests/PostHogContextTest.swift index fc11b7edb..086b4b87a 100644 --- a/PostHogTests/PostHogContextTest.swift +++ b/PostHogTests/PostHogContextTest.swift @@ -50,10 +50,6 @@ class PostHogContextTest: QuickSpec { let context = sut.dynamicContext() - #if os(iOS) || os(tvOS) - expect(context["$screen_width"] as? Float) != nil - expect(context["$screen_height"] as? Float) != nil - #endif expect(context["$locale"] as? String) != nil expect(context["$timezone"] as? String) != nil expect(context["$network_wifi"] as? Bool) != nil diff --git a/PostHogTests/PostHogSDKPersonProfilesTest.swift b/PostHogTests/PostHogSDKPersonProfilesTest.swift index aff6d19c5..c007ebdbc 100644 --- a/PostHogTests/PostHogSDKPersonProfilesTest.swift +++ b/PostHogTests/PostHogSDKPersonProfilesTest.swift @@ -254,7 +254,3 @@ class PostHogSDKPersonProfilesTest: QuickSpec { } } } - -private class MockDate { - var date = Date() -} diff --git a/PostHogTests/PostHogSDKTest.swift b/PostHogTests/PostHogSDKTest.swift index 39d7665c5..04096bd08 100644 --- a/PostHogTests/PostHogSDKTest.swift +++ b/PostHogTests/PostHogSDKTest.swift @@ -999,7 +999,3 @@ class PostHogSDKTest: QuickSpec { #endif } } - -private class MockDate { - var date = Date() -} diff --git a/PostHogTests/PostHogSessionManagerTest.swift b/PostHogTests/PostHogSessionManagerTest.swift new file mode 100644 index 000000000..d0e91b29c --- /dev/null +++ b/PostHogTests/PostHogSessionManagerTest.swift @@ -0,0 +1,370 @@ +// +// PostHogSessionManagerTest.swift +// PostHog +// +// Created by Yiannis Josephides on 16/12/2024. +// + +import Foundation +import Testing + +@testable import PostHog +import XCTest + +@Suite(.serialized) +enum PostHogSessionManagerTests { + @Suite("Test session id rotation logic") + struct SessionRotation { + let mockAppLifecycle: MockApplicationLifecyclePublisher + + init() { + mockAppLifecycle = MockApplicationLifecyclePublisher() + DI.main.appLifecyclePublisher = mockAppLifecycle + DI.main.sessionManager = PostHogSessionManager() + } + + @Test("Session id is cleared after 30 min of background time") + func testSessionClearedBackgrounded() throws { + let mockNow = MockDate() + now = { mockNow.date } + + let originalSessionId = PostHogSessionManager.shared.getNextSessionId() + + try #require(originalSessionId != nil) + + PostHogSessionManager.shared.touchSession() + var newSessionId: String? + + newSessionId = PostHogSessionManager.shared.getSessionId() + + #expect(newSessionId == originalSessionId) + + mockAppLifecycle.simulateAppDidEnterBackground() + mockNow.date.addTimeInterval(60 * 30) // +30 minutes (session should not rotate) + newSessionId = PostHogSessionManager.shared.getSessionId() + + #expect(newSessionId == originalSessionId) + + mockNow.date.addTimeInterval(60 * 1) // past 30 minutes (session should clear) + newSessionId = PostHogSessionManager.shared.getSessionId() + + #expect(newSessionId == nil) + } + + @Test("Session id is rotated after 30 min of inactivity") + func testSessionRotatedWhenInactive() throws { + let mockNow = MockDate() + now = { mockNow.date } + + // session start + let originalSessionId = PostHogSessionManager.shared.getNextSessionId() + // app foregrounded + mockAppLifecycle.simulateAppDidBecomeActive() + + try #require(originalSessionId != nil) + + // activity + PostHogSessionManager.shared.touchSession() + var newSessionId: String? + + // inactivity + mockNow.date.addTimeInterval(60 * 30) // 30 minutes inactivity (session should not rotate) + newSessionId = PostHogSessionManager.shared.getSessionId() + + #expect(newSessionId == originalSessionId) + + mockNow.date.addTimeInterval(20) // past 30 minutes of inactivity (session should rotate) + newSessionId = PostHogSessionManager.shared.getSessionId() + + #expect(newSessionId != nil) + #expect(newSessionId != originalSessionId) + } + + @Test("Session id is rotated after max session length is reached") + func testSessionRotatedWhenPastMaxSessionLength() throws { + let mockNow = MockDate() + now = { mockNow.date } + + // session start + let originalSessionId = PostHogSessionManager.shared.getNextSessionId() + // app foregrounded + mockAppLifecycle.simulateAppDidBecomeActive() + + try #require(originalSessionId != nil) + + var newSessionId: String? + + for _ in 0 ..< 49 { + // activity + mockNow.date.addTimeInterval(60 * 29) // +23 hours, 40 minutes (session should not rotate) + PostHogSessionManager.shared.touchSession() + } + + newSessionId = PostHogSessionManager.shared.getSessionId() + + #expect(newSessionId == originalSessionId) + + mockNow.date.addTimeInterval(60 * 10) // +10 minutes (session should not rotate) + newSessionId = PostHogSessionManager.shared.getSessionId() + + #expect(newSessionId == originalSessionId) + + mockNow.date.addTimeInterval(60 * 10) // +10 minutes (session should rotate) + newSessionId = PostHogSessionManager.shared.getSessionId() + + #expect(newSessionId != originalSessionId) + } + } + + @Suite("Test $session_id property in events") + class PostHogSDKEvents { + let mockAppLifecycle: MockApplicationLifecyclePublisher + var server: MockPostHogServer! + + init() { + mockAppLifecycle = MockApplicationLifecyclePublisher() + DI.main.appLifecyclePublisher = mockAppLifecycle + DI.main.sessionManager = PostHogSessionManager() + + server = MockPostHogServer() + server.start() + + // important! + deleteSafely(applicationSupportDirectoryURL()) + } + + deinit { + now = { Date() } + server.stop() + server = nil + PostHogSessionManager.shared.endSession {} + } + + func getSut( + preloadFeatureFlags: Bool = false, + sendFeatureFlagEvent: Bool = false, + captureApplicationLifecycleEvents: Bool = false, + flushAt: Int = 1, + optOut: Bool = false, + propertiesSanitizer: PostHogPropertiesSanitizer? = nil, + personProfiles: PostHogPersonProfiles = .identifiedOnly + ) -> PostHogSDK { + let config = PostHogConfig(apiKey: "123", host: "http://localhost:9001") + config.flushAt = flushAt + config.preloadFeatureFlags = preloadFeatureFlags + config.sendFeatureFlagEvent = sendFeatureFlagEvent + config.disableReachabilityForTesting = true + config.disableQueueTimerForTesting = true + config.captureApplicationLifecycleEvents = captureApplicationLifecycleEvents + config.optOut = optOut + config.propertiesSanitizer = propertiesSanitizer + config.personProfiles = personProfiles + config.maxBatchSize = max(flushAt, config.maxBatchSize) + return PostHogSDK.with(config) + } + + @Test("Clears $session_id after 30 mins of background inactivity") + func testSessionClearedAfterBackgroundInactivity() async throws { + let sut = getSut(flushAt: 2) + let mockNow = MockDate() + now = { mockNow.date } + + defer { + sut.reset() + sut.close() + } + + // open app + mockAppLifecycle.simulateAppDidFinishLaunching() + + // some activity + PostHogSessionManager.shared.touchSession() + sut.capture("event captured", timestamp: mockNow.date) + + // background app + mockAppLifecycle.simulateAppDidEnterBackground() + + mockNow.date.addTimeInterval(60 * 31) // +31 mins of inactivity + sut.capture("event captured after 31 mins in background", timestamp: mockNow.date) + + let events = try await getServerEvents(server) + + #expect(events.count == 2) + #expect(events[0].event == "event captured") + #expect(events[1].event == "event captured after 31 mins in background") + #expect(events[0].properties["$session_id"] != nil) + #expect(events[1].properties["$session_id"] == nil) // no session + } + + @Test("Rotates $session_id after 30 mins of inactivity") + func testSessionRotatedAfterInactivity() async throws { + let sut = getSut(flushAt: 2) + let mockNow = MockDate() + now = { mockNow.date } + + defer { + sut.reset() + sut.close() + } + + // open app + mockAppLifecycle.simulateAppDidFinishLaunching() + + // some activity + PostHogSessionManager.shared.touchSession() + sut.capture("event captured") + + mockNow.date.addTimeInterval(60 * 31) // +31 mins of inactivity + sut.capture("event captured after 31 mins in background") + + let events = try await getServerEvents(server) + + #expect(events.count == 2) + + let sessionId1 = events[0].properties["$session_id"] as? String + let sessionId2 = events[1].properties["$session_id"] as? String + + try #require(sessionId1 != nil) + try #require(sessionId2 != nil) + + #expect(sessionId1 != sessionId2) + + sut.reset() + sut.close() + } + + @Test("Rotates $session_id after max session length of 24 hours") + func testSessionRotatedAfterMaxSessionLength() async throws { + let sut = getSut(flushAt: 52) + let mockNow = MockDate() + var compoundedTime: TimeInterval = 0 + now = { mockNow.date } + + defer { + sut.reset() + sut.close() + } + + // open app + mockAppLifecycle.simulateAppDidFinishLaunching() + + // activity + PostHogSessionManager.shared.touchSession() + sut.capture("event 0 captured", timestamp: mockNow.date) + + let originalSessionId = PostHogSessionManager.shared.getSessionId(readOnly: true) + + // 23 hours, 41 minutes worth of activity + for i in 0 ..< 49 { + // activity + compoundedTime += 60 * 29 + mockNow.date.addTimeInterval(60 * 29) + PostHogSessionManager.shared.touchSession() + sut.capture("event \(i) captured", timestamp: mockNow.date) + } + + compoundedTime += 60 * 10 + mockNow.date.addTimeInterval(60 * 10) + PostHogSessionManager.shared.touchSession() + sut.capture("event 51 captured", timestamp: mockNow.date) + + compoundedTime += 60 * 10 + mockNow.date.addTimeInterval(60 * 10) + PostHogSessionManager.shared.touchSession() + sut.capture("event 52 captured", timestamp: mockNow.date) + + let events = try await getServerEvents(server) + + try #require(events.count == 52) + + let firstEvent = events[0] + let nextToLastEvent = events[50] + let lastEvent = events[51] + + try #require(firstEvent != nil) + try #require(nextToLastEvent != nil) + try #require(lastEvent != nil) + + let firstEventId = firstEvent.properties["$session_id"] as? String + let nextToLastEventId = nextToLastEvent.properties["$session_id"] as? String + let lastEventId = lastEvent.properties["$session_id"] as? String + + try #require(firstEventId != nil) + try #require(nextToLastEventId != nil) + try #require(lastEventId != nil) + + #expect(firstEvent.event == "event 0 captured") + #expect(nextToLastEvent.event == "event 51 captured") + #expect(lastEvent.event == "event 52 captured") + + #expect(firstEventId == originalSessionId) + #expect(lastEventId != firstEventId) + #expect(nextToLastEventId == firstEventId) + } + } + + @Suite("Test utility classes") + struct UtilityTests { + class LifeCycleSub { + let token: RegistrationToken + + init(_ publisher: MockApplicationLifecyclePublisher) { + token = publisher.onDidBecomeActive { + // handle here + } + } + } + + @Test("ApplicationLifecyclePublisher handles token deallocation correctly") + func testApplicationLifecyclePublisherHandlesTokenDeallocationCorrectly() { + let sut = MockApplicationLifecyclePublisher() + + var registrations = [ + LifeCycleSub(sut), + LifeCycleSub(sut), + LifeCycleSub(sut), + LifeCycleSub(sut), + LifeCycleSub(sut), + ] + + #expect(sut.didBecomeActiveCallbacks.count == 5) + registrations.removeFirst(2) + #expect(sut.didBecomeActiveCallbacks.count == 3) + registrations.removeAll() + #expect(sut.didBecomeActiveCallbacks.isEmpty) + } + } +} + +final class MockApplicationLifecyclePublisher: BaseApplicationLifecyclePublisher { + func simulateAppDidEnterBackground() { + didEnterBackgroundCallbacks.values.forEach { $0() } + } + + func simulateAppDidBecomeActive() { + didBecomeActiveCallbacks.values.forEach { $0() } + } + + func simulateAppDidFinishLaunching() { + didFinishLaunchingCallbacks.values.forEach { $0() } + } +} + +func getServerEvents(_ server: MockPostHogServer) async throws -> [PostHogEvent] { + guard let expectation = server.batchExpectation else { + throw InternalPostHogError(description: "Server is not properly configured with a batch expectation.") + } + + return try await withCheckedThrowingContinuation { continuation in + let result = XCTWaiter.wait(for: [expectation], timeout: 15) + + switch result { + case .completed: + continuation.resume(returning: server.batchRequests.flatMap { server.parsePostHogEvents($0) }) + case .timedOut: + continuation.resume(throwing: TestError("Timeout occurred while waiting for server events.")) + default: + continuation.resume(throwing: TestError("Unexpected XCTWaiter result: \(result).")) + } + } +} diff --git a/PostHogTests/TestUtils/TestError.swift b/PostHogTests/TestUtils/TestError.swift new file mode 100644 index 000000000..3e767d254 --- /dev/null +++ b/PostHogTests/TestUtils/TestError.swift @@ -0,0 +1,18 @@ +// +// TestError.swift +// PostHog +// +// Created by Yiannis Josephides on 18/12/2024. +// + +struct TestError: Error, ExpressibleByStringLiteral, CustomStringConvertible { + let description: String + + init(_ description: String) { + self.description = description + } + + init(stringLiteral value: StringLiteralType) { + description = value + } +} diff --git a/PostHogTests/TestUtils/TestPostHog.swift b/PostHogTests/TestUtils/TestPostHog.swift index 8351616d9..47805625c 100644 --- a/PostHogTests/TestUtils/TestPostHog.swift +++ b/PostHogTests/TestUtils/TestPostHog.swift @@ -44,3 +44,7 @@ func getDecideRequest(_ server: MockPostHogServer) -> [[String: Any]] { return requests } + +final class MockDate { + var date = Date() +}