Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/session-replay-v2-trigger-groups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"posthog": minor
"posthog-android": minor
---

Support session replay v2 trigger groups from remote config
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import com.posthog.android.replay.internal.ViewTreeSnapshotStatus
import com.posthog.android.replay.internal.WindowDrawState
import com.posthog.android.replay.internal.isAlive
import com.posthog.android.replay.internal.isAliveAndAttachedToWindow
import com.posthog.internal.PostHogRemoteConfig
import com.posthog.internal.PostHogSessionManager
import com.posthog.internal.PostHogThreadFactory
import com.posthog.internal.replay.PostHogSessionReplayHandler
Expand Down Expand Up @@ -2494,6 +2495,13 @@ public class PostHogReplayIntegration(
return
}

if (shouldWaitForTriggerGroups()) {
config.logger.log(
"[Session Replay] Trigger groups configured. Integration will not start until one activates for the current session.",
)
return
}

val currentSessionId = postHog?.getSessionId()?.toString()

// Keep producer commits and the bitmap buffer lifecycle on the same recording run.
Expand Down Expand Up @@ -2595,6 +2603,15 @@ public class PostHogReplayIntegration(
)
props["\$sdk_debug_replay_linked_flag_trigger_status"] = linkedFlagStatus

config.remoteConfigHolder?.let { remoteConfig ->
if (remoteConfig.hasSessionRecordingTriggerGroups()) {
// Read-only like above: getSessionId() can rotate an idle session mid-snapshot.
PostHogSessionManager.peekSessionId()?.toString()?.let { sessionId ->
props.putAll(remoteConfig.sessionRecordingTriggerGroupsDebugProperties(sessionId))
}
}
}

val pendingConditions =
listOfNotNull(
"event_trigger".takeIf { eventTriggerStatus == "trigger_pending" },
Expand Down Expand Up @@ -2635,6 +2652,12 @@ public class PostHogReplayIntegration(

val currentSessionId = postHog.getSessionId()?.toString() ?: return

val remoteConfig = config.remoteConfigHolder
if (remoteConfig?.hasSessionRecordingTriggerGroups() == true) {
onTriggerGroupsEvent(remoteConfig, currentSessionId, event, properties)
return
}

val triggers = config.remoteConfigHolder?.getEventTriggers()

// No triggers configured, nothing to do
Expand Down Expand Up @@ -2667,6 +2690,27 @@ public class PostHogReplayIntegration(
}
}

private fun onTriggerGroupsEvent(
remoteConfig: PostHogRemoteConfig,
currentSessionId: String,
event: String,
properties: Map<String, Any>?,
) {
val newlyActivated = remoteConfig.onSessionRecordingTriggerEvent(currentSessionId, event, properties)
if (!newlyActivated) return

updateCachedMinimumDuration()
if (!isRecordingPermittedForCurrentSession()) {
config.logger.log(
"[Session Replay] Trigger group activated by event: $event, but recording is not permitted for session $currentSessionId.",
)
return
}
config.logger.log("[Session Replay] Trigger group activated by event: $event. Starting replay for session $currentSessionId.")
// Do not call start(): only an explicit request may establish manual-start provenance.
startRecording(resumeCurrent = true)
}

/**
* Called when the session ID changes. Stops recording if event triggers are configured
* and the new session hasn't been activated yet, or re-initialises recording so the
Expand All @@ -2681,12 +2725,22 @@ public class PostHogReplayIntegration(
// Invalidate immediately; reinitialization may be queued behind UI work.
if (replaySessionId != currentSessionId) stopRecording()
resetSessionStateIfNeeded(currentSessionId)
updateCachedMinimumDuration()

val remoteConfig = config.remoteConfigHolder
val triggers = remoteConfig?.getEventTriggers()

val activatedSession = synchronized(eventTriggersLock) { triggerActivatedSessionId }

if (remoteConfig?.hasSessionRecordingTriggerGroups() == true) {
if (currentSessionId != null && remoteConfig.hasPendingSessionRecordingTriggerGroups(currentSessionId)) {
if (isSessionReplayActive) {
config.logger.log("[Session Replay] Session changed. Stopping until a trigger group activates.")
stopRecording()
}
return
}
}
if (!triggers.isNullOrEmpty() && activatedSession != currentSessionId) {
if (isSessionReplayActive) {
config.logger.log("[Session Replay] Session changed. Stopping until trigger is matched.")
Expand Down Expand Up @@ -2719,6 +2773,12 @@ public class PostHogReplayIntegration(
if (isSessionReplayActive) stopRecording()
return@post
}
if (remoteConfig.hasSessionRecordingTriggerGroups()) {
if (!remoteConfig.isSessionRecordingPermittedByTriggerGroups(currentSessionId)) {
if (isSessionReplayActive) stopRecording()
return@post
}
}
if (remoteConfig.makeSamplingDecision(currentSessionId).not()) {
if (isSessionReplayActive) stopRecording()
return@post
Expand All @@ -2739,6 +2799,14 @@ public class PostHogReplayIntegration(
return state.configured && !state.activated
}

private fun shouldWaitForTriggerGroups(): Boolean {
val postHog = this.postHog ?: return false
val remoteConfig = config.remoteConfigHolder ?: return false
if (!remoteConfig.hasSessionRecordingTriggerGroups()) return false
val currentSessionId = postHog.getSessionId()?.toString() ?: return false
return remoteConfig.hasPendingSessionRecordingTriggerGroups(currentSessionId)
}

private data class EventTriggerState(val configured: Boolean, val activated: Boolean)

private fun eventTriggerState(sessionId: String?): EventTriggerState {
Expand Down Expand Up @@ -2948,6 +3016,9 @@ public class PostHogReplayIntegration(
return false
}
val currentSessionId = postHog?.getSessionId()?.toString() ?: return false
if (remoteConfig.hasSessionRecordingTriggerGroups()) {
return remoteConfig.isSessionRecordingPermittedByTriggerGroups(currentSessionId)
}
return remoteConfig.makeSamplingDecision(currentSessionId)
}

Expand Down Expand Up @@ -2978,6 +3049,15 @@ public class PostHogReplayIntegration(
}

val currentSessionId = postHog.getSessionId()?.toString() ?: return
if (remoteConfig.hasSessionRecordingTriggerGroups()) {
if (remoteConfig.hasPendingSessionRecordingTriggerGroups(currentSessionId)) {
return
}
if (!remoteConfig.isSessionRecordingPermittedByTriggerGroups(currentSessionId)) {
stopIfActive("Remote config trigger groups decided against this session. Stopping.")
return
}
}
if (!remoteConfig.makeSamplingDecision(currentSessionId)) {
stopIfActive("Remote config sampled this session out. Stopping.")
return
Expand Down Expand Up @@ -3027,7 +3107,14 @@ public class PostHogReplayIntegration(
}

private fun updateCachedMinimumDuration() {
val minimumDuration = config.remoteConfigHolder?.getRecordingMinimumDurationMs()
val remoteConfig = config.remoteConfigHolder
val minimumDuration =
if (remoteConfig?.hasSessionRecordingTriggerGroups() == true) {
val sessionId = PostHogSessionManager.peekSessionId()?.toString()
sessionId?.let { remoteConfig.getSessionRecordingTriggerGroupsMinimumDurationMs(it) }
} else {
remoteConfig?.getRecordingMinimumDurationMs()
}
synchronized(bufferingLock) {
cachedMinimumDurationMs = minimumDuration
}
Expand Down
6 changes: 6 additions & 0 deletions posthog/api/posthog.api
Original file line number Diff line number Diff line change
Expand Up @@ -1063,24 +1063,30 @@ public final class com/posthog/internal/PostHogRemoteConfig : com/posthog/intern
public final fun getRecordingMinimumDurationMs ()Ljava/lang/Long;
public fun getRequestId (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/String;
public final fun getSessionRecordingSampleRate ()Ljava/lang/Double;
public final fun getSessionRecordingTriggerGroupsMinimumDurationMs (Ljava/lang/String;)Ljava/lang/Long;
public final fun getSurveys ()Ljava/util/List;
public final fun hasCachedErrorTrackingConfig ()Z
public final fun hasPendingSessionRecordingTriggerGroups (Ljava/lang/String;)Z
public final fun hasRemoteConfigFetched ()Z
public final fun hasSessionRecordingTriggerGroups ()Z
public final fun isAutocaptureExceptionsEnabled ()Z
public final fun isCaptureNetworkTimingEnabled ()Z
public final fun isConsoleLogRecordingEnabled ()Z
public fun isMinimalFlagCalledEventsEnabled ()Z
public final fun isNativeCrashCaptureEnabled ()Z
public final fun isSessionRecordingPermittedByTriggerGroups (Ljava/lang/String;)Z
public final fun isSessionReplayFlagActive ()Z
public final fun loadFeatureFlags (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lcom/posthog/PostHogOnFeatureFlags;Lcom/posthog/PostHogOnFeatureFlags;)V
public static synthetic fun loadFeatureFlags$default (Lcom/posthog/internal/PostHogRemoteConfig;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lcom/posthog/PostHogOnFeatureFlags;Lcom/posthog/PostHogOnFeatureFlags;ILjava/lang/Object;)V
public final fun loadRemoteConfig (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lcom/posthog/PostHogOnFeatureFlags;Lcom/posthog/PostHogOnFeatureFlags;)V
public static synthetic fun loadRemoteConfig$default (Lcom/posthog/internal/PostHogRemoteConfig;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lcom/posthog/PostHogOnFeatureFlags;Lcom/posthog/PostHogOnFeatureFlags;ILjava/lang/Object;)V
public final fun makeSamplingDecision (Ljava/lang/String;)Z
public final fun onSessionRecordingTriggerEvent (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)Z
public final fun persistPushConfigCache ()V
public final fun resetGroupPropertiesForFlags (Ljava/lang/String;)V
public static synthetic fun resetGroupPropertiesForFlags$default (Lcom/posthog/internal/PostHogRemoteConfig;Ljava/lang/String;ILjava/lang/Object;)V
public final fun resetPersonPropertiesForFlags ()V
public final fun sessionRecordingTriggerGroupsDebugProperties (Ljava/lang/String;)Ljava/util/Map;
public final fun sessionReplayLinkedFlagSnapshot ()Lcom/posthog/internal/PostHogRemoteConfig$SessionReplayLinkedFlagSnapshot;
public final fun setGroupPropertiesForFlags (Ljava/lang/String;Ljava/util/Map;)V
public final fun setPersonPropertiesForFlags (Ljava/util/Map;)V
Expand Down
105 changes: 101 additions & 4 deletions posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import com.posthog.internal.PostHogPreferences.Companion.MINIMAL_FLAG_CALLED_EVE
import com.posthog.internal.PostHogPreferences.Companion.PUSH
import com.posthog.internal.PostHogPreferences.Companion.SESSION_REPLAY
import com.posthog.internal.PostHogPreferences.Companion.SURVEYS
import com.posthog.internal.replay.PostHogTriggerGroupsConfig
import com.posthog.internal.replay.PostHogTriggerGroupsEvaluator
import com.posthog.internal.replay.parseTriggerGroupsConfig
import com.posthog.surveys.Survey
import java.util.concurrent.ExecutorService
import java.util.concurrent.atomic.AtomicBoolean
Expand Down Expand Up @@ -179,6 +182,12 @@ public class PostHogRemoteConfig(
@Volatile
private var sessionRecordingMinimumDurationMs: Long? = null

// Null while version is missing or 1, so the v1 fields above decide.
@Volatile
private var sessionRecordingTriggerGroups: PostHogTriggerGroupsConfig? = null

private val triggerGroupsEvaluator = PostHogTriggerGroupsEvaluator()

init {
preloadSessionRecordingConfig()
preloadSurveys()
Expand Down Expand Up @@ -505,6 +514,23 @@ public class PostHogRemoteConfig(
return milliseconds
}

private fun applyTriggerGroupsConfig(sessionRecording: Map<String, Any?>): PostHogTriggerGroupsConfig? {
val parsed =
parseTriggerGroupsConfig(sessionRecording) { message ->
config.logger.log(message)
}
sessionRecordingTriggerGroups = parsed
if (parsed != null) {
triggerGroupsEvaluator.onConfig(parsed.groups)
}
return parsed
}

private fun linkedFlagFreeConfig(
sessionRecording: Map<String, Any?>,
triggerGroups: PostHogTriggerGroupsConfig?,
): Map<String, Any?> = if (triggerGroups != null) sessionRecording - "linkedFlag" else sessionRecording

// Restores the full recording config from cache (survives reset), re-evaluated against current flags.
// recordingConfig is read from the cache by the caller (off featureFlagsLock, since that read can
// hit disk); this only re-evaluates it in memory, so it is safe to call while holding the lock.
Expand All @@ -531,6 +557,7 @@ public class PostHogRemoteConfig(
sessionReplayLinkedFlagConfigured = false
}
consoleLogRecordingEnabled = false
sessionRecordingTriggerGroups = null

if (!sessionRecording) {
config.cachePreferences?.remove(SESSION_REPLAY)
Expand All @@ -548,16 +575,19 @@ public class PostHogRemoteConfig(
config.snapshotEndpoint = it["endpoint"] as? String
?: config.snapshotEndpoint

val triggerGroups = applyTriggerGroupsConfig(it)
val decisionConfig = linkedFlagFreeConfig(it, triggerGroups)

// Resolved before the lock: isRecordingActive fires the feature-flag-called
// callback for a linked flag, which captures an event synchronously, and that
// must not run while featureFlagsLock is held.
val recordingActive = isRecordingActive(this.featureFlags ?: mapOf(), it)
val recordingActive = isRecordingActive(this.featureFlags ?: mapOf(), decisionConfig)

// Both fields under featureFlagsLock so sessionReplayLinkedFlagSnapshot() never
// observes a torn pair. The /flags re-arm path already holds it (reentrant); the
// /config path holds only remoteConfigLock, so this is load-bearing there.
synchronized(featureFlagsLock) {
sessionReplayLinkedFlagConfigured = it["linkedFlag"] != null
sessionReplayLinkedFlagConfigured = decisionConfig["linkedFlag"] != null
sessionReplayFlagActive = recordingActive
}

Expand Down Expand Up @@ -1134,8 +1164,11 @@ public class PostHogRemoteConfig(
val flags = preferences.getValue(FEATURE_FLAGS) as? Map<String, Any>

if (sessionRecording != null) {
sessionReplayLinkedFlagConfigured = sessionRecording["linkedFlag"] != null
sessionReplayFlagActive = isRecordingActive(flags ?: mapOf(), sessionRecording)
val triggerGroups = applyTriggerGroupsConfig(sessionRecording)
val decisionConfig = linkedFlagFreeConfig(sessionRecording, triggerGroups)

sessionReplayLinkedFlagConfigured = decisionConfig["linkedFlag"] != null
sessionReplayFlagActive = isRecordingActive(flags ?: mapOf(), decisionConfig)

config.snapshotEndpoint = sessionRecording["endpoint"] as? String
?: config.snapshotEndpoint
Expand Down Expand Up @@ -1382,6 +1415,8 @@ public class PostHogRemoteConfig(
* @return true if this session should be recorded, false otherwise
*/
public fun makeSamplingDecision(sessionId: String): Boolean {
if (sessionRecordingTriggerGroups != null) return true

// Local config takes precedence over remote config
val localSampleRate = parseSampleRate(config.sampleRateProvider?.invoke())
val sampleRate = localSampleRate ?: sessionRecordingSampleRate ?: return true
Expand Down Expand Up @@ -1409,9 +1444,12 @@ public class PostHogRemoteConfig(
* React Native evaluates event triggers in its JS layer; RN-captured events never reach the
* native capture() pipeline, so the native gate can't be satisfied. Returns null for RN — the
* JS layer owns them (linkedFlag and sampling gates still apply).
*
* Returns null while v2 trigger groups are in effect.
*/
public fun getEventTriggers(): Set<String>? {
if (PostHogSessionManager.isReactNative) return null
if (sessionRecordingTriggerGroups != null) return null
return sessionRecordingEventTriggers
}

Expand All @@ -1422,6 +1460,65 @@ public class PostHogRemoteConfig(
*/
public fun getRecordingMinimumDurationMs(): Long? = sessionRecordingMinimumDurationMs

/** While true, the v2 trigger groups replace the v1 recording gates. */
public fun hasSessionRecordingTriggerGroups(): Boolean = sessionRecordingTriggerGroups != null

/** Returns true when the event newly activated at least one trigger group. */
public fun onSessionRecordingTriggerEvent(
sessionId: String,
eventName: String,
properties: Map<String, Any?>?,
): Boolean =
triggerGroupsEvaluator.onEvent(
sessionId,
eventName,
properties,
getPersonPropertiesForFlags(),
)

/** True when some trigger group is activated and sampled in for [sessionId]. */
public fun isSessionRecordingPermittedByTriggerGroups(sessionId: String): Boolean = decideTriggerGroups(sessionId).shouldRecord

/** True when no trigger group records yet but some group is still pending. */
public fun hasPendingSessionRecordingTriggerGroups(sessionId: String): Boolean =
decideTriggerGroups(sessionId).let { !it.shouldRecord && it.hasPendingGroups }

/** Lowest minDurationMs among the activated trigger groups for [sessionId]. */
public fun getSessionRecordingTriggerGroupsMinimumDurationMs(sessionId: String): Long? = decideTriggerGroups(sessionId).minDurationMs

public fun sessionRecordingTriggerGroupsDebugProperties(sessionId: String): Map<String, Any> {
val decision = decideTriggerGroups(sessionId)
return mapOf(
"\$sdk_debug_replay_remote_trigger_matching_config" to "v2_trigger_groups",
"\$sdk_debug_replay_trigger_groups_count" to decision.groupsCount,
"\$sdk_debug_replay_matched_recording_trigger_groups" to
decision.matchedGroups.map { group ->
mapOf(
"id" to group.id,
"name" to group.name,
"matched" to true,
"sampled" to group.sampled,
)
},
)
}

private fun decideTriggerGroups(sessionId: String) =
triggerGroupsEvaluator.evaluate(
sessionId,
// Snapshotted before the evaluator lock: processSessionRecordingConfig pushes config
// to the evaluator while holding featureFlagsLock, so the reverse order deadlocks.
currentFeatureFlags(),
getPersonPropertiesForFlags(),
// RN events are captured in JS and never reach this evaluator.
eventLegsReliable = !PostHogSessionManager.isReactNative,
)

private fun currentFeatureFlags(): Map<String, Any?>? =
synchronized(featureFlagsLock) {
featureFlags?.toMap()
}

override fun getRequestId(
distinctId: String?,
groups: Map<String, String>?,
Expand Down
Loading