diff --git a/.github/workflows/build-examples.yml b/.github/workflows/build-examples.yml index b7cd8c57f..d89723f4d 100644 --- a/.github/workflows/build-examples.yml +++ b/.github/workflows/build-examples.yml @@ -8,7 +8,7 @@ on: - "**/*.md" jobs: build: - runs-on: macos-13 + runs-on: macos-14 steps: - uses: actions/checkout@v4 - uses: maxim-lobanov/setup-xcode@v1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 165c2d94f..8b08b676b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,7 +8,7 @@ on: - "**/*.md" jobs: build: - runs-on: macos-13 + runs-on: macos-14 steps: - uses: actions/checkout@v4 - uses: maxim-lobanov/setup-xcode@v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 581f97c36..1ea2851ac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ on: - "**/*.md" jobs: test: - runs-on: macos-13 + runs-on: macos-14 steps: - uses: actions/checkout@v4 - uses: maxim-lobanov/setup-xcode@v1 diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 000000000..f3e0de94f --- /dev/null +++ b/.swiftformat @@ -0,0 +1,96 @@ +--acronyms ID,URL,UUID +--allman false +--anonymousforeach convert +--assetliterals visual-width +--asynccapturing +--beforemarks +--binarygrouping none +--callsiteparen default +--categorymark "MARK: %c" +--classthreshold 0 +--closingparen balanced +--closurevoid remove +--commas always +--complexattrs preserve +--computedvarattrs preserve +--condassignment after-property +--conflictmarkers reject +--dateformat system +--decimalgrouping ignore +--doccomments before-declarations +--elseposition same-line +--emptybraces no-space +--enumnamespaces always +--enumthreshold 0 +--exponentcase lowercase +--exponentgrouping disabled +--extensionacl on-extension +--extensionlength 0 +--extensionmark "MARK: - %t + %c" +--fractiongrouping disabled +--fragment false +--funcattributes preserve +--generictypes +--groupedextension "MARK: %c" +--guardelse auto +--header ignore +--hexgrouping 4,8 +--hexliteralcase uppercase +--ifdef indent +--importgrouping alpha +--indent 4 +--indentcase false +--indentstrings false +--initcodernil false +--lifecycle +--lineaftermarks true +--linebreaks lf +--markcategories true +--markextensions always +--marktypes always +--maxwidth none +--modifierorder +--nevertrailing +--nilinit remove +--noncomplexattrs +--nospaceoperators +--nowrapoperators +--octalgrouping none +--onelineforeach ignore +--operatorfunc spaced +--organizationmode visibility +--organizetypes actor,class,enum,struct +--patternlet hoist +--ranges spaced +--redundanttype infer-locals-only +--self remove +--selfrequired +--semicolons never +--shortoptionals except-properties +--smarttabs enabled +--someany true +--storedvarattrs preserve +--stripunusedargs always +--structthreshold 0 +--tabwidth unspecified +--throwcapturing +--timezone system +--trailingclosures +--trimwhitespace always +--typeattributes preserve +--typeblanklines remove +--typedelimiter space-after +--typemark "MARK: - %t" +--voidtype void +--wraparguments preserve +--wrapcollections preserve +--wrapconditions preserve +--wrapeffects preserve +--wrapenumcases always +--wrapparameters default +--wrapreturntype preserve +--wrapternary default +--wraptypealiases preserve +--xcodeindentation disabled +--yodaswap always +--hexgrouping ignore \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 0406c681f..05626174f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## Next +- feat: ability to manually start and stop session recordings ([#276](https://github.com/PostHog/posthog-ios/pull/276)) - feat: change screenshot encoding format from JPEG to WebP ([#273](https://github.com/PostHog/posthog-ios/pull/273)) ## 3.18.0 - 2024-12-27 diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index 41888460c..ce14a6d64 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -140,6 +140,9 @@ DA1044C92D0B2CAC00C4ACF3 /* huffman_utils.h in Headers */ = {isa = PBXBuildFile; fileRef = DA1044C82D0B2CAC00C4ACF3 /* huffman_utils.h */; settings = {ATTRIBUTES = (Public, ); }; }; DA1044D42D0B34F200C4ACF3 /* PostHog.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; }; DA1044D52D0B34F200C4ACF3 /* PostHog.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 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 */; }; DA4AF61F2D1195D20053EA38 /* PostHog.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; }; DA4AF6202D1195D20053EA38 /* PostHog.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -274,6 +277,9 @@ DAD5DD0C2CB6DEF30087387B /* PostHogMaskViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD5DD072CB6DEE70087387B /* PostHogMaskViewModifier.swift */; }; DAF95F512D072F04001E82BB /* UIImage+WebP.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF95F502D072F04001E82BB /* UIImage+WebP.swift */; }; DAF95F612D077C21001E82BB /* PostHogWebPTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF95F602D077C1C001E82BB /* PostHogWebPTest.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 */ @@ -578,6 +584,9 @@ DA1044C42D0B2C2700C4ACF3 /* vp8li_dec.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = vp8li_dec.h; sourceTree = ""; }; DA1044C62D0B2C5900C4ACF3 /* webpi_dec.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = webpi_dec.h; sourceTree = ""; }; DA1044C82D0B2CAC00C4ACF3 /* huffman_utils.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = huffman_utils.h; 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 = ""; }; DA5AA7132CE245CD004EFB99 /* UIApplication+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+.swift"; sourceTree = ""; }; DA5B85872CD21CBB00686389 /* AutocaptureEventProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocaptureEventProcessing.swift; sourceTree = ""; }; @@ -704,6 +713,9 @@ DAD5DD072CB6DEE70087387B /* PostHogMaskViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogMaskViewModifier.swift; sourceTree = ""; }; DAF95F502D072F04001E82BB /* UIImage+WebP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+WebP.swift"; sourceTree = ""; }; DAF95F602D077C1C001E82BB /* PostHogWebPTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogWebPTest.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 */ @@ -780,6 +792,7 @@ 3A62646829C9E37A007E8C07 /* TestUtils */ = { isa = PBXGroup; children = ( + DAF79A242D1309BE0078A3C9 /* TestError.swift */, 3A62646929C9E385007E8C07 /* MockPostHogServer.swift */, 3A62647429CB0168007E8C07 /* TestPostHog.swift */, 3A580B4229E489D000C5C6F3 /* URLSession+body.swift */, @@ -813,6 +826,7 @@ 3AA4C09B2988315D006C4731 /* Utils */ = { isa = PBXGroup; children = ( + DA1D29582D10B7A6003A31DA /* ApplicationLifecyclePublisher.swift */, DA0CA6F02CFF6B6300AF9500 /* UIWindow+.swift */, DA5AA7132CE245CD004EFB99 /* UIApplication+.swift */, 3AE3FB422992985A00AFFC18 /* Reachability.swift */, @@ -871,6 +885,7 @@ children = ( DAB06F9C2D09A744005B1C9B /* PostHog.modulemap */, 3AC745B8296D6FE60025C109 /* PostHog.h */, + DA1D29612D115E13003A31DA /* DI.swift */, DA26419B2CC0499300CB427B /* Autocapture */, 69EE82B82BA9C4DA00EB9542 /* Replay */, 69BA38E62B893F2200AA69D6 /* Resources */, @@ -904,6 +919,7 @@ isa = PBXGroup; children = ( DAF95F5B2D077BCC001E82BB /* Resources */, + DA1D295F2D10C80D003A31DA /* PostHogSessionManagerTest.swift */, 3A62646829C9E37A007E8C07 /* TestUtils */, 3AE3FB4A2993A68500AFFC18 /* PostHogStorageTest.swift */, 3A62647029CAF67B007E8C07 /* PostHogStorageManagerTest.swift */, @@ -1745,6 +1761,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 */, 69261D232AD9784200232EC7 /* PostHogVersion.swift in Sources */, @@ -1873,6 +1890,7 @@ DA5AA7192CE245D2004EFB99 /* UIApplication+.swift in Sources */, DA1044902D0B1D4300C4ACF3 /* AssociatedKeys.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 */, @@ -1903,6 +1921,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 */, @@ -1910,6 +1929,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/PostHogFeatureFlags.swift b/PostHog/PostHogFeatureFlags.swift index ccff706af..4477751b3 100644 --- a/PostHog/PostHogFeatureFlags.swift +++ b/PostHog/PostHogFeatureFlags.swift @@ -31,10 +31,10 @@ class PostHogFeatureFlags { self.storage = storage self.api = api - preloadSesssionReplayFlag() + preloadSessionReplayFlag() } - private func preloadSesssionReplayFlag() { + private func preloadSessionReplayFlag() { var sessionReplay: [String: Any]? var featureFlags: [String: Any]? featureFlagsLock.withLock { @@ -70,7 +70,7 @@ class PostHogFeatureFlags { // check for multi flag variant (any) // if let linkedFlag = sessionRecording["linkedFlag"] as? String, // featureFlags[linkedFlag] != nil - // is also a valid check bbut since we cannot check the value of the flag, + // is also a valid check but since we cannot check the value of the flag, // we consider session recording is active return recordingActive @@ -117,7 +117,7 @@ class PostHogFeatureFlags { } else if let sessionRecording = data?["sessionRecording"] as? [String: Any] { // keeps the value from config.sessionReplay since having sessionRecording // means its enabled on the project settings, but its only enabled - // when local config.sessionReplay is also enabled + // when local replay integration is enabled/active if let endpoint = sessionRecording["endpoint"] as? String { self.config.snapshotEndpoint = endpoint } @@ -242,7 +242,7 @@ class PostHogFeatureFlags { hedgeLog("Error parsing the object \(String(describing: value)): \(error)") } - // fallbak to original value if not possible to serialize + // fallback to original value if not possible to serialize return value } 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 f90272584..5b8be0c24 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -46,8 +46,8 @@ let maxRetryDelay = 30.0 private var context: PostHogContext? private static var apiKeys = Set() private var capturedAppInstalled = false + private var didRegisterNotifications = false private var appFromBackground = false - private var isInBackground = false #if os(iOS) private var replayIntegration: PostHogReplayIntegration? #endif @@ -191,7 +191,7 @@ let maxRetryDelay = 30.0 return nil } - return PostHogSessionManager.shared.getSessionId() + return PostHogSessionManager.shared.getSessionId(readOnly: true) } @objc public func startSession() { @@ -199,9 +199,7 @@ let maxRetryDelay = 30.0 return } - PostHogSessionManager.shared.startSession { - self.resetViews() - } + PostHogSessionManager.shared.startSession() } @objc public func endSession() { @@ -209,9 +207,7 @@ let maxRetryDelay = 30.0 return } - PostHogSessionManager.shared.endSession { - self.resetViews() - } + PostHogSessionManager.shared.endSession() } // EVENT CAPTURE @@ -281,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] = [:] @@ -322,19 +319,29 @@ 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) - if !appendSharedProps, config.sessionReplay { + if !appendSharedProps, isSessionReplayActive() { props["$window_id"] = sessionId } #endif } // 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 @@ -365,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 { @@ -376,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 [:] @@ -593,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, @@ -620,9 +604,18 @@ let maxRetryDelay = 30.0 userProperties: sanitizeDictionary(userProperties), userPropertiesSetOnce: sanitizeDictionary(userPropertiesSetOnce), groups: groups, - appendSharedProps: !snapshotEvent) + appendSharedProps: !isSnapshotEvent, + timestamp: timestamp) let sanitizedProperties = sanitizeProperties(properties) + // if this is a $snapshot event and $session_id is missing, don't process then event + if isSnapshotEvent, sanitizedProperties["$session_id"] == nil { + return + } + + // Session Replay has its own queue + let targetQueue = isSnapshotEvent ? replayQueue : queue + let posthogEvent = PostHogEvent( event: event, distinctId: eventDistinctId, @@ -630,16 +623,7 @@ let maxRetryDelay = 30.0 timestamp: eventTimestamp ) - // Session Replay has its own queue - if snapshotEvent { - guard let replayQueue else { - return - } - replayQueue.add(posthogEvent) - return - } - - queue.add(posthogEvent) + targetQueue?.add(posthogEvent) } @objc public func screen(_ screenTitle: String) { @@ -1013,18 +997,84 @@ 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 is disabled in your project settings + + ## Note: + - Calling this method 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 is disabled in your project settings + + - 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 else { + return + } + + if resumeCurrent, replayIntegration.isActive() { + // nothing to resume, already active + return + } + + guard let featureFlags, featureFlags.isSessionReplayFlagActive() else { + return hedgeLog("Could not start recording. Session replay feature flag is disabled.") + } + + 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 + */ + @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) @@ -1032,6 +1082,8 @@ let maxRetryDelay = 30.0 } private func unregisterNotifications() { + didRegisterNotifications = false + let defaultCenter = NotificationCenter.default #if os(iOS) || os(tvOS) @@ -1052,6 +1104,9 @@ let maxRetryDelay = 30.0 } private func registerNotifications() { + guard !didRegisterNotifications else { return } + didRegisterNotifications = true + let defaultCenter = NotificationCenter.default #if os(iOS) || os(tvOS) @@ -1168,11 +1223,6 @@ let maxRetryDelay = 30.0 } @objc func handleAppDidBecomeActive() { - PostHogSessionManager.shared.rotateSessionIdIfRequired { - self.resetViews() - } - - isInBackground = false captureAppOpened() } @@ -1205,10 +1255,6 @@ let maxRetryDelay = 30.0 @objc func handleAppDidEnterBackground() { captureAppBackgrounded() - - PostHogSessionManager.shared.updateSessionLastTime() - - isInBackground = true } private func captureAppBackgrounded() { @@ -1218,21 +1264,19 @@ 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, let featureFlags else { + return false + } + + return replayIntegration.isActive() + && !PostHogSessionManager.shared.getSessionId(readOnly: true).isNilOrEmpty + && featureFlags.isSessionReplayFlagActive() } #endif diff --git a/PostHog/PostHogSessionManager.swift b/PostHog/PostHogSessionManager.swift index 1d4ef2e10..97f2d89c6 100644 --- a/PostHog/PostHogSessionManager.swift +++ b/PostHog/PostHogSessionManager.swift @@ -8,115 +8,213 @@ 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, at: now(), 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 timestamp = timeNow.timeIntervalSince1970 + let (currentSessionId, lastActive, sessionStart, isBackgrounded) = sessionLock.withLock { + (sessionId, sessionActivityTimestamp, sessionStartTimestamp, isAppInBackground) } - } - private func isExpired(_ timeNow: TimeInterval, _ sessionLastTimestamp: TimeInterval) -> Bool { - timeNow - sessionLastTimestamp > sessionChangeThreshold - } + // RN manages its own session, just return session id + guard isNotReactNative(), !readOnly else { + return currentSessionId + } - private func isNotReactNative() -> Bool { - // for the RN SDK, the session is handled by the RN SDK itself - postHogSdkName != "posthog-react-native" + // Create a new session id if empty + if currentSessionId.isNilOrEmpty, !isBackgrounded { + return rotateSession(force: true, at: timeNow, reason: .sessionIdEmpty) + } + + // Check if session has passed maximum inactivity length + if let lastActive, isExpired(timestamp, lastActive, sessionActivityThreshold) { + return isBackgrounded + ? clearSession(reason: .sessionTimeout) + : rotateSession(at: timeNow, reason: .sessionTimeout) + } + + // Check if session has passed maximum session length + if let sessionStart, isExpired(timestamp, sessionStart, sessionMaxLengthThreshold) { + return isBackgrounded + ? clearSession(reason: .sessionPastMaximumLength) + : rotateSession(at: timeNow, reason: .sessionPastMaximumLength) + } + + return currentSessionId } - func resetSessionIfExpired(_ completion: () -> Void) { + func getNextSessionId() -> String? { + // if this is RN, return the current session id guard isNotReactNative() else { - return + return sessionLock.withLock { sessionId } } - sessionLock.withLock { - let timeNow = now().timeIntervalSince1970 - if sessionId != nil, - let sessionLastTimestamp = sessionLastTimestamp, - isExpired(timeNow, sessionLastTimestamp) - { - sessionId = nil - completion() - } - } + return rotateSession(force: true, at: now(), reason: .sessionStart) } - private func rotateSession(_ completion: (() -> Void)?) { - let newSessionId = UUID.v7().uuidString - let newSessionLastTimestamp = now().timeIntervalSince1970 + /// Creates a new session id and sets timestamps + func startSession(_ completion: (() -> Void)? = nil) { + guard isNotReactNative() else { return } - sessionId = newSessionId - sessionLastTimestamp = newSessionLastTimestamp + rotateSession(force: true, at: now(), reason: .sessionStart) completion?() } - func startSession(_ completion: (() -> Void)? = nil) { + /// Clears current session id and timestamps + func endSession(_ completion: (() -> Void)? = nil) { + guard isNotReactNative() else { return } + + clearSession(reason: .sessionEnd) + completion?() + } + + /// Resets current session id and timestamps + func resetSession() { + guard isNotReactNative() else { return } + + rotateSession(force: true, at: now(), 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 { - // only start if there is no session if sessionId != nil { - return + sessionActivityTimestamp = timestamp } - rotateSession(completion) } } - func rotateSessionIdIfRequired(_ completion: @escaping (() -> Void)) { - guard isNotReactNative() else { - return + /** + 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, at timestamp: Date, reason: SessionIDChangeReason) -> String? { + // only rotate when session is empty + if !force { + let currentSessionId = sessionLock.withLock { sessionId } + if currentSessionId.isNilOrEmpty { + return currentSessionId + } } + let newSessionId = UUID.v7().uuidString + setSessionIdInternal(newSessionId, at: timestamp, reason: reason) + return newSessionId + } + + @discardableResult private func clearSession(reason: SessionIDChangeReason) -> String? { + setSessionIdInternal(nil, at: nil, reason: reason) + return nil + } + + private func setSessionIdInternal(_ sessionId: String?, at timestamp: Date?, reason: SessionIDChangeReason) { + let timestamp = timestamp?.timeIntervalSince1970 + sessionLock.withLock { - let timeNow = now().timeIntervalSince1970 + self.sessionId = sessionId + self.sessionStartTimestamp = timestamp + self.sessionActivityTimestamp = timestamp + } - 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? - sessionLock.withLock { - sessionLastTimestamp = now().timeIntervalSince1970 + 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 + } + 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 } } - 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..d722b6af4 100644 --- a/PostHog/PostHogStorage.swift +++ b/PostHog/PostHogStorage.swift @@ -15,7 +15,11 @@ 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 + return url.appendingPathComponent(Bundle.main.bundleIdentifier!) + #endif } class PostHogStorage { diff --git a/PostHog/Replay/NetworkSample.swift b/PostHog/Replay/NetworkSample.swift index 842d0ad06..5c6bf4d35 100644 --- a/PostHog/Replay/NetworkSample.swift +++ b/PostHog/Replay/NetworkSample.swift @@ -9,6 +9,7 @@ import Foundation struct NetworkSample { + let sessionId: String let timeOrigin: Date let entryType = "resource" var name: String? @@ -18,8 +19,9 @@ var duration: Int64? var decodedBodySize: Int64? - init(timeOrigin: Date, url: String? = nil) { + init(sessionId: String, timeOrigin: Date, url: String? = nil) { self.timeOrigin = timeOrigin + self.sessionId = sessionId name = url } diff --git a/PostHog/Replay/PostHogReplayIntegration.swift b/PostHog/Replay/PostHogReplayIntegration.swift index a8e83e47f..b0ebce7a0 100644 --- a/PostHog/Replay/PostHogReplayIntegration.swift +++ b/PostHog/Replay/PostHogReplayIntegration.swift @@ -110,6 +110,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() { @@ -130,30 +132,43 @@ 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() } private func generateSnapshot(_ window: UIWindow, _ screenName: String? = nil) { var hasChanges = false - let timestamp = Date().toMillis() + guard let wireframe = config.sessionReplayConfig.screenshotMode ? toScreenshotWireframe(window) : toWireframe(window) else { + return + } + + // capture timestamp after snapshot was taken + let timestampDate = Date() + let timestamp = timestampDate.toMillis() + let snapshotStatus = windowViews.object(forKey: window) ?? ViewTreeSnapshotStatus() - guard let wireframe = config.sessionReplayConfig.screenshotMode ? toScreenshotWireframe(window) : toWireframe(window) else { + // always make sure we have a fresh session id at correct timestamp + guard let sessionId = PostHogSessionManager.shared.getSessionId(at: timestampDate) else { return } @@ -190,7 +205,15 @@ let snapshotData: [String: Any] = ["type": 2, "data": data, "timestamp": timestamp] snapshotsData.append(snapshotData) - PostHogSDK.shared.capture("$snapshot", properties: ["$snapshot_source": "mobile", "$snapshot_data": snapshotsData]) + PostHogSDK.shared.capture( + "$snapshot", + 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 1fe95b413..ded02cd9c 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 = 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 @@ -87,7 +93,15 @@ snapshotsData.append(data) } if !snapshotsData.isEmpty { - PostHogSDK.shared.capture("$snapshot", properties: ["$snapshot_source": "mobile", "$snapshot_data": snapshotsData]) + PostHogSDK.shared.capture( + "$snapshot", + properties: [ + "$snapshot_source": "mobile", + "$snapshot_data": snapshotsData, + "$session_id": sessionId, + ], + timestamp: date + ) } } } @@ -96,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 da94325de..f7a9a20ab 100644 --- a/PostHog/Replay/URLSessionExtension.swift +++ b/PostHog/Replay/URLSessionExtension.swift @@ -25,13 +25,24 @@ let timestamp = Date() let startMillis = getMonotonicTimeInMilliseconds() var endMillis: UInt64? + let sessionId = PostHogSessionManager.shared.getSessionId(at: timestamp) do { let (data, response) = try await action() endMillis = getMonotonicTimeInMilliseconds() - captureData(request: request, response: response, timestamp: timestamp, start: startMillis, end: endMillis) + captureData(request: request, + response: response, + sessionId: sessionId, + timestamp: timestamp, + start: startMillis, + end: endMillis) return (data, response) } catch { - captureData(request: request, response: nil, timestamp: timestamp, start: startMillis, end: endMillis) + captureData(request: request, + response: nil, + sessionId: sessionId, + timestamp: timestamp, + start: startMillis, + end: endMillis) throw error } } @@ -42,13 +53,24 @@ let timestamp = Date() let startMillis = getMonotonicTimeInMilliseconds() var endMillis: UInt64? + let sessionId = PostHogSessionManager.shared.getSessionId(at: timestamp) do { let (url, response) = try await action() endMillis = getMonotonicTimeInMilliseconds() - captureData(request: request, response: response, timestamp: timestamp, start: startMillis, end: endMillis) + captureData(request: request, + response: response, + sessionId: sessionId, + timestamp: timestamp, + start: startMillis, + end: endMillis) return (url, response) } catch { - captureData(request: request, response: nil, timestamp: timestamp, start: startMillis, end: endMillis) + captureData(request: request, + response: nil, + sessionId: sessionId, + timestamp: timestamp, + start: startMillis, + end: endMillis) throw error } } @@ -106,10 +128,17 @@ // 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 + private func captureData( + request: URLRequest? = nil, + response: URLResponse? = nil, + sessionId: String?, + timestamp: Date, + start: UInt64, + end: UInt64? = nil + ) { + // we don't check config.sessionReplayConfig.captureNetworkTelemetry here since this extension // has to be called manually anyway - if !PostHogSDK.shared.isSessionReplayActive() { + guard let sessionId, PostHogSDK.shared.isSessionReplayActive() else { return } let currentEnd = end ?? getMonotonicTimeInMilliseconds() @@ -140,7 +169,15 @@ let recordingData: [String: Any] = ["type": 6, "data": pluginData, "timestamp": timestamp.toMillis()] snapshotsData.append(recordingData) - PostHogSDK.shared.capture("$snapshot", properties: ["$snapshot_source": "mobile", "$snapshot_data": snapshotsData]) + PostHogSDK.shared.capture( + "$snapshot", + 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 c9857a497..7fb7581df 100644 --- a/PostHog/Replay/URLSessionInterceptor.swift +++ b/PostHog/Replay/URLSessionInterceptor.swift @@ -38,9 +38,18 @@ return } - let date = Date() + let date = now() + + guard let sessionId = PostHogSessionManager.shared.getSessionId(at: date) else { + return + } + queue.async { - let sample = NetworkSample(timeOrigin: date, url: url.absoluteString) + let sample = NetworkSample( + sessionId: sessionId, + timeOrigin: date, + url: url.absoluteString + ) self.tasksLock.withLock { self.samplesByTask[task] = sample @@ -122,16 +131,30 @@ } private func finish(task: URLSessionTask, sample: NetworkSample) { + let timestamp = sample.timeOrigin + 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]) + PostHogSDK.shared.capture( + "$snapshot", + properties: [ + "$snapshot_source": "mobile", + "$snapshot_data": snapshotsData, + "$session_id": sample.sessionId, + ], + timestamp: sample.timeOrigin + ) tasksLock.withLock { _ = samplesByTask.removeValue(forKey: task) 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..652d466f6 100644 --- a/PostHogObjCExample/AppDelegate.m +++ b/PostHogObjCExample/AppDelegate.m @@ -136,6 +136,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 c4c0b8e4b..6554b0dde 100644 --- a/PostHogTests/PostHogAutocaptureEventTrackerSpec.swift +++ b/PostHogTests/PostHogAutocaptureEventTrackerSpec.swift @@ -19,7 +19,6 @@ let view = UIView() let eventData = view.eventData! - expect(eventData.viewHierarchy.first?.targetClass).to(equal("UIView")) expect(eventData.viewHierarchy.count).to(equal(1)) } @@ -29,7 +28,6 @@ superview.addSubview(button) let eventData = button.eventData! - expect(eventData.viewHierarchy.first?.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 10aefb2ef..a543f134c 100644 --- a/PostHogTests/PostHogAutocaptureIntegrationSpec.swift +++ b/PostHogTests/PostHogAutocaptureIntegrationSpec.swift @@ -123,8 +123,8 @@ import Quick .init( text: "Test Button", targetClass: "UIButton", - baseClass: nil, - label: "Custom label" + baseClass: "UIControl", + label: 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..d2c2b5347 100644 --- a/PostHogTests/PostHogSDKTest.swift +++ b/PostHogTests/PostHogSDKTest.swift @@ -766,43 +766,6 @@ class PostHogSDKTest: QuickSpec { sut.close() } - it("rotates to a new sessionId only after > 30 mins in the background") { - let sut = self.getSut(captureApplicationLifecycleEvents: true, flushAt: 5) - let mockNow = MockDate() - now = { mockNow.date } - - sut.handleAppDidEnterBackground() // Background "timer": 0 mins - - mockNow.date.addTimeInterval(60 * 15) // Background "timer": 15 mins - - sut.capture("event captured while in background") - - mockNow.date.addTimeInterval(60 * 14) // Background "timer": 29 mins - - sut.handleAppDidBecomeActive() // Background "timer": Resets back to 0 mins on next backgrounding - - mockNow.date.addTimeInterval(60) - - sut.handleAppDidEnterBackground() // Background "timer": 0 mins - - mockNow.date.addTimeInterval(30 * 60 + 1) // Background "timer": 30 mins 1 second - - sut.handleAppDidBecomeActive() // New sessionId created - - let events = getBatchedEvents(server) - expect(events.count) == 5 - - let sessionId1 = events[0].properties["$session_id"] as? String - expect(sessionId1).toNot(beNil()) // Background "timer": 0 mins - expect(events[1].properties["$session_id"] as? String).to(equal(sessionId1)) // Background "timer": 15 mins - expect(events[2].properties["$session_id"] as? String).to(equal(sessionId1)) // Background "timer": 29 mins - expect(events[3].properties["$session_id"] as? String).to(equal(sessionId1)) // Background "timer": 0 mins - expect(events[4].properties["$session_id"] as? String).toNot(equal(sessionId1)) // Background "timer": 30 mins 1 second - - sut.reset() - sut.close() - } - it("clears sessionId for background events after 30 mins in background") { let sut = self.getSut(captureApplicationLifecycleEvents: true, flushAt: 2) let mockNow = MockDate() @@ -999,7 +962,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..0f747b116 --- /dev/null +++ b/PostHogTests/PostHogSessionManagerTest.swift @@ -0,0 +1,535 @@ +// +// 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.simulateAppDidBecomeActive() + + // 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() + mockAppLifecycle.simulateAppDidBecomeActive() + + // 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() + mockAppLifecycle.simulateAppDidBecomeActive() + + // 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) + } + } + + @Suite("Test React Native session management") + struct ReactNativeTests { + let mockAppLifecycle: MockApplicationLifecyclePublisher + + init() { + postHogSdkName = "posthog-react-native" + mockAppLifecycle = MockApplicationLifecyclePublisher() + DI.main.appLifecyclePublisher = mockAppLifecycle + DI.main.sessionManager = PostHogSessionManager() + } + + @Test("Session id is NOT cleared after 30 min of background time") + func testSessionNotClearedBackgrounded() throws { + let mockNow = MockDate() + now = { mockNow.date } + + // RN sets custom session id + let rnSessionId = UUID().uuidString + PostHogSessionManager.shared.setSessionId(rnSessionId) + + PostHogSessionManager.shared.touchSession() + var newSessionId: String? + + newSessionId = PostHogSessionManager.shared.getSessionId() + + #expect(newSessionId == rnSessionId) + + mockAppLifecycle.simulateAppDidEnterBackground() + mockNow.date.addTimeInterval(60 * 30) // +30 minutes (session should not rotate) + newSessionId = PostHogSessionManager.shared.getSessionId() + + #expect(newSessionId == rnSessionId) + + mockNow.date.addTimeInterval(60 * 1) // past 30 minutes (session should clear) + newSessionId = PostHogSessionManager.shared.getSessionId() + + #expect(newSessionId == rnSessionId) + } + + @Test("Session id is NOT rotated after 30 min of inactivity") + func testSessionNotRotatedWhenInactive() throws { + let mockNow = MockDate() + now = { mockNow.date } + + // RN sets custom session id + let rnSessionId = UUID().uuidString + PostHogSessionManager.shared.setSessionId(rnSessionId) + + // app foregrounded + mockAppLifecycle.simulateAppDidBecomeActive() + + // 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 == rnSessionId) + + mockNow.date.addTimeInterval(20) // past 30 minutes of inactivity (session should rotate) + newSessionId = PostHogSessionManager.shared.getSessionId() + + #expect(newSessionId == rnSessionId) + } + + @Test("Session id is NOT rotated after max session length is reached") + func testSessionNotRotatedWhenPastMaxSessionLength() throws { + let mockNow = MockDate() + now = { mockNow.date } + + // RN sets custom session id + let rnSessionId = UUID().uuidString + PostHogSessionManager.shared.setSessionId(rnSessionId) + + // app foregrounded + mockAppLifecycle.simulateAppDidBecomeActive() + + 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 == rnSessionId) + + mockNow.date.addTimeInterval(60 * 10) // +10 minutes (session should not rotate) + newSessionId = PostHogSessionManager.shared.getSessionId() + + #expect(newSessionId == rnSessionId) + + mockNow.date.addTimeInterval(60 * 10) // +10 minutes (session should rotate) + newSessionId = PostHogSessionManager.shared.getSessionId() + + #expect(newSessionId == rnSessionId) + } + + @Test("Session id is NOT cleared when startSession() is called") + func testSessionNotRotatedWhenStartSessionCalled() throws { + let mockNow = MockDate() + now = { mockNow.date } + + // RN sets custom session id + let rnSessionId = UUID().uuidString + PostHogSessionManager.shared.setSessionId(rnSessionId) + + var newSessionId = PostHogSessionManager.shared.getSessionId() + + #expect(newSessionId == rnSessionId) + + PostHogSessionManager.shared.startSession() + + newSessionId = PostHogSessionManager.shared.getSessionId() + + #expect(newSessionId == rnSessionId) + } + + @Test("Session id is NOT cleared when endSession() is called") + func testSessionNotRotatedWhenEndSessionCalled() throws { + let mockNow = MockDate() + now = { mockNow.date } + + // RN sets custom session id + let rnSessionId = UUID().uuidString + PostHogSessionManager.shared.setSessionId(rnSessionId) + + var newSessionId = PostHogSessionManager.shared.getSessionId() + + #expect(newSessionId == rnSessionId) + + PostHogSessionManager.shared.endSession() + + newSessionId = PostHogSessionManager.shared.getSessionId() + + #expect(newSessionId == rnSessionId) + } + + @Test("Session id is NOT rotated when resetSession() is called") + func testSessionNotRotatedWhenResetSessionCalled() throws { + let mockNow = MockDate() + now = { mockNow.date } + + // RN sets custom session id + let rnSessionId = UUID().uuidString + PostHogSessionManager.shared.setSessionId(rnSessionId) + + var newSessionId = PostHogSessionManager.shared.getSessionId() + + #expect(newSessionId == rnSessionId) + + PostHogSessionManager.shared.resetSession() + + newSessionId = PostHogSessionManager.shared.getSessionId() + + #expect(newSessionId == rnSessionId) + } + } +} + +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() +}