-
Notifications
You must be signed in to change notification settings - Fork 38
fix: 24-hour max + 30-min inactivity session rotation, align with iOS #494
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
Changes from all commits
b2a522d
6cfd32b
d431746
faa882b
b01bd5f
ff9b2de
3067a99
31c41df
2ab7e43
aac7323
114616b
53182b3
396fc37
d230339
e4206b7
a367781
a7fed1d
3a62ee0
7733fff
ca24cd2
78e39fa
1c7f81c
15548f7
ceea09b
55cf017
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| 'posthog': patch | ||
| 'posthog-android': patch | ||
| --- | ||
|
|
||
| Enforce 24-hour maximum session duration and 30-minute inactivity rotation with automatic session rotation, mirroring iOS. Note: `PostHogSessionManager.isAppInBackground` now defaults to `true` until the first lifecycle `onStart` flips it; downstream wrappers (Flutter, RN) that exercise the manager directly in tests may need to call `setAppInBackground(false)` to simulate a foregrounded process. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| package com.posthog.android.internal | ||
|
|
||
| import android.os.Build | ||
| import com.posthog.PostHogIntegration | ||
| import com.posthog.PostHogInterface | ||
| import com.posthog.android.PostHogAndroidConfig | ||
| import com.posthog.internal.PostHogSessionManager | ||
| import curtains.Curtains | ||
| import curtains.OnRootViewsChangedListener | ||
| import curtains.TouchEventInterceptor | ||
| import curtains.phoneWindow | ||
| import curtains.touchEventInterceptors | ||
|
|
||
| /** | ||
| * Marks user touches as session activity by calling [PostHogSessionManager.touchSession] | ||
| * on every dispatched MotionEvent. | ||
| * | ||
| * Decoupled from session replay so apps with replay disabled or sampled out still get | ||
| * touch-driven inactivity rotation; otherwise session-id rotation behaviour would depend | ||
| * on whether the user happened to be sampled, which session metrics rely on being stable. | ||
| */ | ||
| internal class PostHogTouchActivityIntegration( | ||
| private val config: PostHogAndroidConfig, | ||
| ) : PostHogIntegration { | ||
| private companion object { | ||
| @Volatile | ||
| private var integrationInstalled = false | ||
| } | ||
|
|
||
| private val touchInterceptor = | ||
| TouchEventInterceptor { motionEvent, dispatch -> | ||
| try { | ||
| PostHogSessionManager.touchSession() | ||
| } catch (e: Throwable) { | ||
| config.logger.log("PostHogTouchActivityIntegration touchSession failed: $e.") | ||
| } | ||
| dispatch(motionEvent) | ||
| } | ||
|
|
||
| private val onRootViewsChangedListener = | ||
| OnRootViewsChangedListener { view, added -> | ||
| try { | ||
| val window = view.phoneWindow ?: return@OnRootViewsChangedListener | ||
| if (added) { | ||
| if (touchInterceptor !in window.touchEventInterceptors) { | ||
| window.touchEventInterceptors += touchInterceptor | ||
| } | ||
| } else { | ||
| window.touchEventInterceptors -= touchInterceptor | ||
| } | ||
| } catch (e: Throwable) { | ||
| config.logger.log("PostHogTouchActivityIntegration root view changed failed: $e.") | ||
| } | ||
| } | ||
|
|
||
| override fun install(postHog: PostHogInterface) { | ||
| if (integrationInstalled || !isSupported()) { | ||
| return | ||
| } | ||
| integrationInstalled = true | ||
| try { | ||
| Curtains.rootViews.forEach { view -> | ||
| view.phoneWindow?.let { window -> | ||
| if (touchInterceptor !in window.touchEventInterceptors) { | ||
| window.touchEventInterceptors += touchInterceptor | ||
| } | ||
| } | ||
| } | ||
| Curtains.onRootViewsChangedListeners += onRootViewsChangedListener | ||
| } catch (e: Throwable) { | ||
| config.logger.log("PostHogTouchActivityIntegration install failed: $e.") | ||
| } | ||
| } | ||
|
|
||
| override fun uninstall() { | ||
| try { | ||
| Curtains.onRootViewsChangedListeners -= onRootViewsChangedListener | ||
| Curtains.rootViews.forEach { view -> | ||
| view.phoneWindow?.let { window -> | ||
| window.touchEventInterceptors -= touchInterceptor | ||
| } | ||
| } | ||
| } catch (e: Throwable) { | ||
| config.logger.log("PostHogTouchActivityIntegration uninstall failed: $e.") | ||
| } finally { | ||
| integrationInstalled = false | ||
| } | ||
| } | ||
|
|
||
| private fun isSupported(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -276,7 +276,6 @@ public class PostHogReplayIntegration( | |
| private val onTouchEventListener = | ||
| TouchEventInterceptor { motionEvent, dispatch -> | ||
| val timestamp = config.dateProvider.currentTimeMillis() | ||
|
|
||
| try { | ||
| val state = dispatch(motionEvent) | ||
| try { | ||
|
|
@@ -1606,6 +1605,15 @@ public class PostHogReplayIntegration( | |
| } | ||
|
|
||
| isSessionReplayActive = true | ||
|
|
||
| if (!resumeCurrent) { | ||
| // Without this, on a static UI the first user-driven onDraw can be tens of seconds | ||
| // away β and incremental events (type:3) would ship under the new session before | ||
| // the meta + full-snapshot keyframes (type:4 + type:2) needed to render them. | ||
| mainHandler.handler.post { | ||
| decorViews.keys.forEach { it.postInvalidate() } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private fun clearSnapshotStates() { | ||
|
|
@@ -1662,22 +1670,60 @@ public class PostHogReplayIntegration( | |
|
|
||
| /** | ||
| * Called when the session ID changes. Stops recording if event triggers are configured | ||
| * and the new session hasn't been activated yet. | ||
| * and the new session hasn't been activated yet, or re-initialises recording so the | ||
| * new session gets fresh meta + full wireframe events. | ||
| */ | ||
| override fun onSessionIdChanged() { | ||
| val postHog = this.postHog ?: return | ||
| if (this.postHog == null) return | ||
|
|
||
| val currentSessionId = postHog.getSessionId()?.toString() | ||
| // Read-only: getActiveSessionId() can rotate the session and would re-fire this listener. | ||
| val currentSessionId = PostHogSessionManager.peekSessionId()?.toString() | ||
|
|
||
| val triggers = config.remoteConfigHolder?.getEventTriggers() | ||
| val remoteConfig = config.remoteConfigHolder | ||
| val triggers = remoteConfig?.getEventTriggers() | ||
| val activatedSession = synchronized(eventTriggersLock) { triggerActivatedSessionId } | ||
|
|
||
| // If triggers are configured and this session hasn't been activated, stop the integration | ||
| if (!triggers.isNullOrEmpty() && activatedSession != currentSessionId) { | ||
| if (isSessionReplayActive) { | ||
| config.logger.log("[Session Replay] Session changed. Stopping until trigger is matched.") | ||
| stop() | ||
| } | ||
| return | ||
| } | ||
|
|
||
| // The listener can fire from any thread that calls capture(); replay state writes | ||
| // (snapshot WeakHashMap, isSessionReplayActive) must happen on main. | ||
| if (currentSessionId == null) { | ||
| if (isSessionReplayActive) { | ||
| config.logger.log("[Session Replay] Session cleared. Stopping recording.") | ||
| mainHandler.handler.post { stop() } | ||
| } | ||
| return | ||
| } | ||
|
|
||
| // Run regardless of isSessionReplayActive: the prior session may have been sampled out | ||
| // and the new one may now pass. Sampling is re-evaluated for the (already-current) | ||
| // session without rotating the id (the silent rotation that fired this already | ||
| // rotated; going through PostHog.startSessionReplay(false) would double-rotate). | ||
| config.logger.log("[Session Replay] Session changed. Re-initializing recording for new session.") | ||
| mainHandler.handler.post { | ||
| // config.sessionReplay is the customer-facing master switch: a config-level disable | ||
| // must not be overridden by remote flag + sampling. Manual PostHog.startSessionReplay | ||
| // calls and trigger-matched starts go through different code paths and are unaffected. | ||
| if (!config.sessionReplay) { | ||
| if (isSessionReplayActive) stop() | ||
| return@post | ||
| } | ||
| if (remoteConfig?.isSessionReplayFlagActive() != true) { | ||
| if (isSessionReplayActive) stop() | ||
| return@post | ||
| } | ||
| if (remoteConfig.makeSamplingDecision(currentSessionId).not()) { | ||
| if (isSessionReplayActive) stop() | ||
| return@post | ||
| } | ||
| if (isSessionReplayActive) stop() | ||
| start(resumeCurrent = false) | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In iOS, session replay is only started automatically if On main, we don't start automatically recording at any point unless This
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Worth a test case or two
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added a check for config.sessionReplay plus a 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.
Will this return false on RN (since any session rotation logic should be no-op for RN)?
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.
Good catch β added the RN bypass (returns false when isReactNative)