-
Notifications
You must be signed in to change notification settings - Fork 48
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: manual start/stop session recording #276
base: main
Are you sure you want to change the base?
Conversation
// | ||
|
||
// swiftlint:disable:next type_name | ||
enum DI { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added this purely for testing purposes, so that we can easily swap out singletons in the codebase for testing. We can slowly start porting dependencies here
@@ -1052,6 +1098,9 @@ let maxRetryDelay = 30.0 | |||
} | |||
|
|||
private func registerNotifications() { | |||
guard !didRegisterNotifications else { return } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just a simple test to not register for the same notification twice. When talking to a customer they mentioned calling .setup() multiple times
- This will resume the current session or create a new one if it doesn't exist | ||
*/ | ||
@objc(startSessionRecording) | ||
public func startSessionRecording() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Had to opt out from default params for Objc interop, so two methods here
@@ -1013,25 +998,86 @@ let maxRetryDelay = 30.0 | |||
flagCallReported.removeAll() | |||
} | |||
context = nil | |||
PostHogSessionManager.shared.endSession { | |||
self.resetViews() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
replay integration will handle this directly by using the new PostHogSessionManager.shared.onSessionIDChanged
callback
@@ -1205,10 +1249,6 @@ let maxRetryDelay = 30.0 | |||
|
|||
@objc func handleAppDidEnterBackground() { | |||
captureAppBackgrounded() | |||
|
|||
PostHogSessionManager.shared.updateSessionLastTime() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We instead keep a sessionActivityTimestamp
when the user interacts with the app. This is set when PostHogSessionManager.shared.touchSession()
is called
@@ -8,115 +8,209 @@ | |||
import Foundation | |||
|
|||
// only for internal use | |||
// Do we need to expose this as public API? Could be internal static instead? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tried not to change the public API on this, but is this used somewhere? RN or Flutter maybe?
/// Whether to start session replay automatically or manually during the SDK setup | ||
/// Options are: | ||
/// - .automatic: Start session replay automatically during the SDK setup | ||
/// - .manual: Start/Stop session replay manually by calling `startSessionReplay()` and `stopSessionReplay()` | ||
/// Default is .automatic | ||
@objc public var startMode: PostHogSessionReplayStartMode = .automatic |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The JS SDK does not have this since it is automatic by default, but if sessionReplay
is disabled, they can just call startSessionRecording
Any reason to be different?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hm yeah that's another an. Could be a bit awkward though switching the config.sessionReplay
behind the scenes for the user? In my mind, that config is for the integration itself? But having less configuration options is for sure more desirable
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could be a bit awkward though switching the config.sessionReplay behind the scenes for the user?
we won't do this, the user chooses to sessionReplay: true
for everything automatically, or keep it false
and start/stop manually, this is how the Web works.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We use config.sessionReplay
throughout though to add some checks to replay integration. This means, config.sessionReplay
will be used just in setup() code to start the integration or not, and we rework those checks. Okay i'll have a look
@@ -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") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A test was silently failing on me when force unwrapping nil value here. Possibly because of new swift-testing framework but should be safe to keep with a compiler directive
/// Whether to start session replay automatically or manually during the SDK setup | ||
/// Options are: | ||
/// - .automatic: Start session replay automatically during the SDK setup | ||
/// - .manual: Start/Stop session replay manually by calling `startSessionReplay()` and `stopSessionReplay()` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/// - .manual: Start/Stop session replay manually by calling `startSessionReplay()` and `stopSessionReplay()` | |
/// - .manual: Start/Stop session replay manually by calling `startSessionRecording()` and `stopSessionRecording()` |
@@ -28,9 +28,10 @@ | |||
return | |||
} | |||
|
|||
// swizzling twice will exchange implementations back to original | |||
swizzle(forClass: UIApplication.self, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we were unswizzling wrong here. Swizzling the same methods again should restore original
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() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the main point of keeping taps on whether the user is interacting with the app (thus session is active) or not
original: #selector(UIView.layoutSubviews), | ||
new: #selector(UIView.layoutSubviewsOverride)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That feel wrong? because the selectors are inverted after swizzle
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah, so we need to swap back
(custom) UIView.layoutSubviews -> (UIView.layoutSubviewsOverride under the hood)
(original) UIView.layoutSubviewsOverride -> (UIView.layoutSubviews under the hood)
I tested this locally and breakpoints in layoutSubviewsOverride
don't fire when session replay is stopped which is what we need
@@ -92,6 +93,32 @@ struct ContentView: View { | |||
var body: some View { | |||
NavigationStack { | |||
List { | |||
Section("Manual Session Recording Control") { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can run PostHogExample and play around with manual session start/stop
#if os(iOS) || os(tvOS) | ||
expect(context["$screen_width"] as? Float) != nil | ||
expect(context["$screen_height"] as? Float) != nil | ||
#endif |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why did we remove this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is always nil because of a previous refactor of how we fetch screen_width and screen_height async - it requires a UIApplication instance which is not present in unit tests
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if this is nil
, the tests would always fail and CI is happy right now, so I don't follow?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CI doesn't run these tests actually cause of #if os(iOS)
.
make test
(swift test) runs on current machine, so tests run on macOS by default
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could think of running make testOniOSSimulator
instead though
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
True, I think I had issues with simulators back then and have gone with a purely swift test, but it seems like the time to do it if many tests depend on the simulator infrastructure, create an issue?
@@ -19,7 +19,6 @@ | |||
let view = UIView() | |||
let eventData = view.eventData! | |||
|
|||
expect(eventData.targetClass).to(equal("UIView")) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any reason to touch autocapture files in this PR? feels unrelated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// | ||
|
||
import Foundation | ||
import Testing |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Failing in CI, will probably need Xcode 16 for this
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
any reason to switch from Nimble, and Quick that works across all tooling versions?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just wanted to test out the new native framework, and to be honest I like it so far - it's expressive and works with existing tests. I thought we could slowly start porting (and revisiting) tests and eventually remove quick/nimble dependencies.
I don't have a strong preference here tbh, so I'll leave the call to you here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if this is failing CI and it's not an easy fix, I'd rather keep the test infrastructure that works across different tooling versions.
No preference either as long as everything works.
#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? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If for some reason this can be nil, I'd fall back to in-memory storage but we don't have this yet.
We cannot cache everything in the applicationSupportDirectory that is visible for all the apps, this has to be sandboxed for only this app.
💡 Motivation and Context
#skip-changelog
Closes #262 and #227
Docs PR: Pending
Added the following:
startMode
in replay config - This controls if the SDK setup will start session replay if enabled or notstartSessionRecording(resumeCurrent: Bool)
stopSessionRecording()
Session Management
Related discussion: https://posthog.slack.com/archives/C03PB072FMJ/p1733146678787689?thread_ts=1732810962.491699&cid=C03PB072FMJ
Based on the discussion above, I refactored how we manager and rotate replay sessions. Quite a big change but the main points of the change are:
getSessionId
changes form and is now responsible on running validation rules and rotates or clears the session if needed before returninggetSessionId
can be called withreadOnly
to skip validation and just return current session id. In both cases, a new id will be created if it doesn't exist but only when the app is foregrounded.now
is used. For replay, we capture the timestamp of the event as soon as possible, so we need to run session validation checks on that timestamp to account for delayed captures$snapshot
events add their own$session_id
property which is acquired as early as possible andcapture()
method will respect that $session_id$snapshot
events always callcapture()
with a given timestamp, acquired as early as possiblereason
on hedgeLog messages related to session id changes for debugging purposesTODO:
💚 How did you test it?
📝 Checklist