diff --git a/.changeset/session-replay-v2-trigger-groups.md b/.changeset/session-replay-v2-trigger-groups.md new file mode 100644 index 000000000..590ca0a0e --- /dev/null +++ b/.changeset/session-replay-v2-trigger-groups.md @@ -0,0 +1,6 @@ +--- +"posthog": minor +"posthog-android": minor +--- + +Support session replay v2 trigger groups from remote config diff --git a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt index af6d4b885..2a58a0abb 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt @@ -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 @@ -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. @@ -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" }, @@ -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 @@ -2667,6 +2690,27 @@ public class PostHogReplayIntegration( } } + private fun onTriggerGroupsEvent( + remoteConfig: PostHogRemoteConfig, + currentSessionId: String, + event: String, + properties: Map?, + ) { + 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 @@ -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.") @@ -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 @@ -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 { @@ -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) } @@ -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 @@ -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 } diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index fe483974a..26739c84d 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -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 diff --git a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt index 27ae6874d..0ae7411b2 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt @@ -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 @@ -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() @@ -505,6 +514,23 @@ public class PostHogRemoteConfig( return milliseconds } + private fun applyTriggerGroupsConfig(sessionRecording: Map): 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, + triggerGroups: PostHogTriggerGroupsConfig?, + ): Map = 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. @@ -531,6 +557,7 @@ public class PostHogRemoteConfig( sessionReplayLinkedFlagConfigured = false } consoleLogRecordingEnabled = false + sessionRecordingTriggerGroups = null if (!sessionRecording) { config.cachePreferences?.remove(SESSION_REPLAY) @@ -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 } @@ -1134,8 +1164,11 @@ public class PostHogRemoteConfig( val flags = preferences.getValue(FEATURE_FLAGS) as? Map 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 @@ -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 @@ -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? { if (PostHogSessionManager.isReactNative) return null + if (sessionRecordingTriggerGroups != null) return null return sessionRecordingEventTriggers } @@ -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?, + ): 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 { + 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? = + synchronized(featureFlagsLock) { + featureFlags?.toMap() + } + override fun getRequestId( distinctId: String?, groups: Map?, diff --git a/posthog/src/main/java/com/posthog/internal/replay/PostHogTriggerGroups.kt b/posthog/src/main/java/com/posthog/internal/replay/PostHogTriggerGroups.kt new file mode 100644 index 000000000..e27233819 --- /dev/null +++ b/posthog/src/main/java/com/posthog/internal/replay/PostHogTriggerGroups.kt @@ -0,0 +1,142 @@ +package com.posthog.internal.replay + +// Session recording v2 trigger groups (posthog-js V2TriggerGroupStrategy). +internal data class PostHogTriggerGroupsConfig( + val groups: List, +) + +internal data class PostHogTriggerGroup( + val id: String, + val name: String, + val sampleRate: Double?, + val minDurationMs: Long?, + val conditions: PostHogTriggerConditions, +) + +internal enum class PostHogTriggerMatchType { + ANY, + ALL, +} + +internal data class PostHogTriggerConditions( + val matchType: PostHogTriggerMatchType, + val events: List, + val urls: List, + val flag: PostHogTriggerFlag?, + val properties: List, +) + +internal data class PostHogTriggerEvent( + val name: String, + val properties: List, +) + +internal data class PostHogTriggerFlag( + val flag: String, + val variant: String?, +) + +internal data class PostHogTriggerPropertyFilter( + val key: String, + val type: String?, + val operator: String?, + val value: Any?, +) + +// Null means v1: posthog-js keeps the v1 strategy unless version is 2 and a group parses. +internal fun parseTriggerGroupsConfig( + sessionRecording: Map, + log: (String) -> Unit = {}, +): PostHogTriggerGroupsConfig? { + val version = (sessionRecording["version"] as? Number)?.toInt() ?: 1 + if (version != 2) return null + + val rawGroups = sessionRecording["triggerGroups"] as? List<*> ?: return null + + val groups = + rawGroups.mapNotNull { raw -> + val map = raw as? Map<*, *> + val id = map?.get("id") as? String + if (map == null || id.isNullOrBlank()) { + log("Session recording trigger group without an id was ignored.") + return@mapNotNull null + } + PostHogTriggerGroup( + id = id, + name = map["name"] as? String ?: "", + sampleRate = (map["sampleRate"] as? Number)?.toDouble(), + minDurationMs = (map["minDurationMs"] as? Number)?.toLong(), + conditions = parseTriggerConditions(map["conditions"]), + ) + } + + if (groups.isEmpty()) return null + + return PostHogTriggerGroupsConfig(groups) +} + +private fun parseTriggerConditions(conditions: Any?): PostHogTriggerConditions = + PostHogTriggerConditions( + matchType = + if ((conditions as? Map<*, *>)?.get("matchType") == "any") { + PostHogTriggerMatchType.ANY + } else { + PostHogTriggerMatchType.ALL + }, + events = parseTriggerEvents((conditions as? Map<*, *>)?.get("events")), + urls = parseTriggerUrls((conditions as? Map<*, *>)?.get("urls")), + flag = parseTriggerFlag((conditions as? Map<*, *>)?.get("flag")), + properties = parseTriggerPropertyFilters((conditions as? Map<*, *>)?.get("properties")), + ) + +private fun parseTriggerEvents(events: Any?): List = + (events as? List<*>).orEmpty().mapNotNull { raw -> + val map = raw as? Map<*, *> + val name = map?.get("name") as? String + if (name == null) { + null + } else { + PostHogTriggerEvent( + name = name, + properties = parseTriggerPropertyFilters(map["properties"]), + ) + } + } + +private fun parseTriggerUrls(urls: Any?): List = + (urls as? List<*>).orEmpty().mapNotNull { raw -> + val map = raw as? Map<*, *> + val pattern = map?.get("url") as? String ?: return@mapNotNull null + if (map["matching"] != "regex") return@mapNotNull null + try { + Regex(pattern) + } catch (e: Throwable) { + null + } + } + +private fun parseTriggerFlag(flag: Any?): PostHogTriggerFlag? = + when (flag) { + is String -> PostHogTriggerFlag(flag, null) + is Map<*, *> -> + (flag["flag"] as? String)?.let { name -> + PostHogTriggerFlag(name, flag["variant"] as? String) + } + else -> null + } + +internal fun parseTriggerPropertyFilters(filters: Any?): List = + (filters as? List<*>).orEmpty().mapNotNull { raw -> + val map = raw as? Map<*, *> + val key = map?.get("key") as? String + if (key == null) { + null + } else { + PostHogTriggerPropertyFilter( + key = key, + type = map["type"] as? String, + operator = map["operator"] as? String, + value = map["value"], + ) + } + } diff --git a/posthog/src/main/java/com/posthog/internal/replay/PostHogTriggerGroupsEvaluator.kt b/posthog/src/main/java/com/posthog/internal/replay/PostHogTriggerGroupsEvaluator.kt new file mode 100644 index 000000000..f5410b6f5 --- /dev/null +++ b/posthog/src/main/java/com/posthog/internal/replay/PostHogTriggerGroupsEvaluator.kt @@ -0,0 +1,273 @@ +package com.posthog.internal.replay + +import kotlin.math.abs + +internal data class PostHogTriggerGroupMatch( + val id: String, + val name: String, + val sampled: Boolean, +) + +// Mirrors posthog-js triggerGroupsMatchSessionRecordingStatus. +internal data class PostHogTriggerGroupsDecision( + val shouldRecord: Boolean, + val hasPendingGroups: Boolean, + val minDurationMs: Long?, + val groupsCount: Int, + val matchedGroups: List, +) + +// Sampling decisions are kept in memory only: they depend on (sessionId, groupId, sampleRate) +// alone, so a restart re-derives the same decisions. +internal class PostHogTriggerGroupsEvaluator { + private val lock = Any() + + private var groups: List = emptyList() + + private var activationSessionId: String? = null + private val eventActivatedGroupIds = mutableSetOf() + private val screenActivatedGroupIds = mutableSetOf() + + private val samplingDecisions = mutableMapOf() + + private data class StoredSamplingDecision( + val sessionId: String, + val sampleRate: Double?, + val sampled: Boolean, + ) + + fun onConfig(newGroups: List) { + synchronized(lock) { + if (groups == newGroups) return + samplingDecisions.keys.retainAll(newGroups.map { it.id }.toSet()) + groups = newGroups + } + } + + // Returns true when at least one group newly activated. + fun onEvent( + sessionId: String, + eventName: String, + eventProperties: Map?, + personProperties: Map?, + ): Boolean { + synchronized(lock) { + resetActivationForNewSessionLocked(sessionId) + + var anyNewlyActivated = false + for (group in groups) { + val conditions = group.conditions + + val eventMatched = + conditions.events.isNotEmpty() && + group.id !in eventActivatedGroupIds && + matchesEventLeg(conditions, eventName, eventProperties, personProperties) + if (eventMatched) { + eventActivatedGroupIds.add(group.id) + anyNewlyActivated = true + } + + if (eventName == SCREEN_EVENT_NAME) { + val screenName = eventProperties?.get(SCREEN_NAME_PROPERTY) as? String + // Screens stand in for the web url leg. + val screenMatched = + conditions.urls.isNotEmpty() && + screenName != null && + group.id !in screenActivatedGroupIds && + conditions.urls.any { it.containsMatchIn(screenName) } && + matchTriggerPropertyFilters(conditions.properties, eventProperties, personProperties) + if (screenMatched) { + screenActivatedGroupIds.add(group.id) + anyNewlyActivated = true + } + } + } + return anyNewlyActivated + } + } + + // eventLegsReliable is false for React Native: its events are captured in JS and never + // reach this evaluator, so event and screen legs would otherwise stay pending forever. + fun evaluate( + sessionId: String, + flags: Map?, + personProperties: Map?, + eventLegsReliable: Boolean = true, + ): PostHogTriggerGroupsDecision { + synchronized(lock) { + resetActivationForNewSessionLocked(sessionId) + + var shouldRecord = false + var hasPendingGroups = false + var minDurationMs: Long? = null + val matchedGroups = mutableListOf() + + for (group in groups) { + when (groupStatusLocked(group, flags, eventLegsReliable)) { + PostHogTriggerStatus.ACTIVATED -> { + val sampled = samplingDecisionLocked(group, sessionId) + matchedGroups.add(PostHogTriggerGroupMatch(group.id, group.name, sampled)) + if (sampled) { + shouldRecord = true + } + val duration = group.minDurationMs + if (duration != null && (minDurationMs == null || duration < minDurationMs)) { + minDurationMs = duration + } + } + PostHogTriggerStatus.PENDING -> hasPendingGroups = true + PostHogTriggerStatus.DISABLED -> Unit + } + } + + return PostHogTriggerGroupsDecision( + shouldRecord = shouldRecord, + hasPendingGroups = hasPendingGroups, + minDurationMs = minDurationMs, + groupsCount = groups.size, + matchedGroups = matchedGroups, + ) + } + } + + private enum class PostHogTriggerStatus { + ACTIVATED, + PENDING, + DISABLED, + } + + private fun groupStatusLocked( + group: PostHogTriggerGroup, + flags: Map?, + eventLegsReliable: Boolean, + ): PostHogTriggerStatus { + val conditions = group.conditions + val hasEvents = conditions.events.isNotEmpty() + val hasUrls = conditions.urls.isNotEmpty() + val hasFlag = conditions.flag != null + + if (!hasEvents && !hasUrls && !hasFlag) return PostHogTriggerStatus.ACTIVATED + + val eventLeg = + when { + !hasEvents || !eventLegsReliable -> PostHogTriggerStatus.DISABLED + group.id in eventActivatedGroupIds -> PostHogTriggerStatus.ACTIVATED + else -> PostHogTriggerStatus.PENDING + } + val screenLeg = + when { + !hasUrls || !eventLegsReliable -> PostHogTriggerStatus.DISABLED + group.id in screenActivatedGroupIds -> PostHogTriggerStatus.ACTIVATED + else -> PostHogTriggerStatus.PENDING + } + val flagLeg = flagLegStatus(conditions.flag, flags) + + return if (conditions.matchType == PostHogTriggerMatchType.ANY) { + orTriggerStatus(eventLeg, screenLeg, flagLeg) + } else { + andTriggerStatus(eventLeg, screenLeg, flagLeg) + } + } + + private fun flagLegStatus( + flag: PostHogTriggerFlag?, + flags: Map?, + ): PostHogTriggerStatus { + if (flag == null) return PostHogTriggerStatus.DISABLED + if (flags == null) return PostHogTriggerStatus.PENDING + + val value = flags[flag.flag] + val matches = + when (value) { + is Boolean -> value + is String -> + if (flag.variant != null) { + value == flag.variant + } else { + value.isNotEmpty() + } + else -> false + } + return if (matches) PostHogTriggerStatus.ACTIVATED else PostHogTriggerStatus.PENDING + } + + private fun orTriggerStatus(vararg statuses: PostHogTriggerStatus): PostHogTriggerStatus = + when { + PostHogTriggerStatus.ACTIVATED in statuses -> PostHogTriggerStatus.ACTIVATED + PostHogTriggerStatus.PENDING in statuses -> PostHogTriggerStatus.PENDING + else -> PostHogTriggerStatus.DISABLED + } + + private fun andTriggerStatus(vararg statuses: PostHogTriggerStatus): PostHogTriggerStatus { + val enabled = statuses.filter { it != PostHogTriggerStatus.DISABLED }.distinct() + return when (enabled.size) { + 0 -> PostHogTriggerStatus.DISABLED + 1 -> enabled.first() + else -> PostHogTriggerStatus.PENDING + } + } + + private fun matchesEventLeg( + conditions: PostHogTriggerConditions, + eventName: String, + eventProperties: Map?, + personProperties: Map?, + ): Boolean { + val namedEntries = conditions.events.filter { it.name == eventName } + if (namedEntries.isEmpty()) return false + + val entryMatched = + namedEntries.any { entry -> + entry.properties.isEmpty() || + matchTriggerPropertyFilters(entry.properties, eventProperties, personProperties) + } + if (!entryMatched) return false + + return matchTriggerPropertyFilters(conditions.properties, eventProperties, personProperties) + } + + private fun samplingDecisionLocked( + group: PostHogTriggerGroup, + sessionId: String, + ): Boolean { + val stored = samplingDecisions[group.id] + if (stored != null && stored.sessionId == sessionId && stored.sampleRate == group.sampleRate) { + return stored.sampled + } + + // A missing rate samples in, like the web clampToRange fallback. + val sampled = + group.sampleRate?.let { rate -> sampleOnTriggerProperty(sessionId + group.id, rate) } ?: true + + samplingDecisions[group.id] = StoredSamplingDecision(sessionId, group.sampleRate, sampled) + return sampled + } + + private fun resetActivationForNewSessionLocked(sessionId: String) { + if (activationSessionId != sessionId) { + activationSessionId = sessionId + eventActivatedGroupIds.clear() + screenActivatedGroupIds.clear() + } + } + + private companion object { + const val SCREEN_EVENT_NAME: String = "\$screen" + const val SCREEN_NAME_PROPERTY: String = "\$screen_name" + } +} + +// Same hash and threshold as posthog-js sampleOnProperty. +internal fun sampleOnTriggerProperty( + input: String, + percent: Double, +): Boolean = simpleTriggerHash(input) % 100 < (percent * 100).coerceIn(0.0, 100.0) + +internal fun simpleTriggerHash(str: String): Long { + var hash = 0 + for (char in str) { + hash = hash * 31 + char.code + } + // JS Math.abs returns a double, so Int.MIN_VALUE maps to 2147483648 rather than to itself. + return if (hash == Int.MIN_VALUE) 2147483648L else abs(hash.toLong()) +} diff --git a/posthog/src/main/java/com/posthog/internal/replay/PostHogTriggerPropertyFilters.kt b/posthog/src/main/java/com/posthog/internal/replay/PostHogTriggerPropertyFilters.kt new file mode 100644 index 000000000..839cd30b4 --- /dev/null +++ b/posthog/src/main/java/com/posthog/internal/replay/PostHogTriggerPropertyFilters.kt @@ -0,0 +1,91 @@ +package com.posthog.internal.replay + +import kotlin.math.abs + +// Mirrors posthog-js matchTriggerPropertyFilters and @posthog/core propertyComparisons. +internal fun matchTriggerPropertyFilters( + filters: List?, + eventProperties: Map?, + personProperties: Map?, +): Boolean { + if (filters.isNullOrEmpty()) return true + + return filters.all { filter -> + val source = if (filter.type == "person") personProperties else eventProperties + val propertyValue = source?.get(filter.key) + val operator = filter.operator ?: "exact" + + if (propertyValue == null) return@all operator in NEGATIVE_OPERATORS + + val comparison = PROPERTY_COMPARISONS[operator] ?: return@all false + if (filter.value == null) return@all false + + comparison(stringifyValues(filter.value), stringifyValues(propertyValue)) + } +} + +private val NEGATIVE_OPERATORS = setOf("is_not", "not_icontains", "not_regex") + +private val PROPERTY_COMPARISONS: Map, List) -> Boolean> = + mapOf( + "exact" to { targets, values -> values.any { value -> targets.any { it == value } } }, + "is_not" to { targets, values -> values.all { value -> targets.all { it != value } } }, + "regex" to { targets, values -> values.any { value -> targets.any { matchesRegex(value, it) } } }, + "not_regex" to { targets, values -> values.all { value -> targets.all { !matchesRegex(value, it) } } }, + "icontains" to { targets, values -> + values.any { value -> targets.any { value.contains(it, ignoreCase = true) } } + }, + "not_icontains" to { targets, values -> + values.all { value -> targets.none { value.contains(it, ignoreCase = true) } } + }, + "gt" to { targets, values -> + values.any { value -> + val n = jsParseDouble(value) ?: return@any false + targets.any { n > (jsParseDouble(it) ?: Double.NaN) } + } + }, + "lt" to { targets, values -> + values.any { value -> + val n = jsParseDouble(value) ?: return@any false + targets.any { n < (jsParseDouble(it) ?: Double.NaN) } + } + }, + ) + +private fun matchesRegex( + value: String, + pattern: String, +): Boolean = + try { + Regex(pattern).containsMatchIn(value) + } catch (e: Throwable) { + false + } + +private fun stringifyValues(value: Any): List = + when (value) { + is List<*> -> value.map { jsToString(it) } + else -> listOf(jsToString(value)) + } + +// Gson decodes 100 as 100.0; render integral doubles like JS String() so they still compare equal. +private fun jsToString(value: Any?): String = + when (value) { + is Double -> + if (value.isFinite() && value == value.toLong().toDouble() && abs(value) < 1e21) { + value.toLong().toString() + } else { + value.toString() + } + is Float -> jsToString(value.toDouble()) + else -> value.toString() + } + +// JS parseFloat semantics: gt/lt read the leading number of values like "10items". +private fun jsParseDouble(value: String): Double? { + val trimmed = value.trimStart() + val match = JS_NUMBER_PREFIX.find(trimmed) ?: return null + return match.value.toDoubleOrNull() +} + +private val JS_NUMBER_PREFIX = Regex("^[+-]?(Infinity|\\d+\\.?\\d*([eE][+-]?\\d+)?|\\.\\d+([eE][+-]?\\d+)?)") diff --git a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt index 213c37211..d011cbfd4 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt @@ -3084,4 +3084,130 @@ internal class PostHogRemoteConfigTest { sut.clear() http.shutdown() } + + @Test + fun `v2 trigger groups replace the v1 gates once loaded from remote config`() { + val response = File("src/test/resources/json/remote-config-v2-trigger-groups.json").readText() + val http = mockHttp(response = MockResponse().setBody(response)) + val sut = getSut(host = http.url("/").toString()) + + assertFalse(sut.hasSessionRecordingTriggerGroups()) + assertNull(sut.getEventTriggers()) + + sut.loadRemoteConfig("my_identify", anonymousId = "anonId", emptyMap()) + executor.shutdownAndAwaitTermination() + + assertTrue(sut.hasSessionRecordingTriggerGroups()) + + // the v1 event-trigger gate stands down even though the config also carries eventTriggers + assertNull(sut.getEventTriggers()) + // per-group sampling replaces the global gate, so the v1 decision must not also apply + assertTrue(sut.makeSamplingDecision("any-session")) + + // group-a has empty conditions and is fully sampled, group-b waits for a purchase + // group-a already records, so recording does not wait on the pending group-b + assertTrue(sut.isSessionRecordingPermittedByTriggerGroups("session-1")) + assertFalse(sut.hasPendingSessionRecordingTriggerGroups("session-1")) + assertEquals(30000L, sut.getSessionRecordingTriggerGroupsMinimumDurationMs("session-1")) + + assertFalse(sut.onSessionRecordingTriggerEvent("session-1", "purchase", mapOf("amount" to 50))) + + assertTrue(sut.onSessionRecordingTriggerEvent("session-1", "purchase", mapOf("amount" to 150))) + assertFalse(sut.hasPendingSessionRecordingTriggerGroups("session-1")) + assertTrue(sut.isSessionRecordingPermittedByTriggerGroups("session-1")) + assertEquals(5000L, sut.getSessionRecordingTriggerGroupsMinimumDurationMs("session-1")) + + val debugProps = sut.sessionRecordingTriggerGroupsDebugProperties("session-1") + assertEquals("v2_trigger_groups", debugProps["\$sdk_debug_replay_remote_trigger_matching_config"]) + assertEquals(2, debugProps["\$sdk_debug_replay_trigger_groups_count"]) + assertEquals( + listOf( + mapOf("id" to "group-a", "name" to "Always on", "matched" to true, "sampled" to true), + mapOf("id" to "group-b", "name" to "Big purchases", "matched" to true, "sampled" to true), + ), + debugProps["\$sdk_debug_replay_matched_recording_trigger_groups"], + ) + + sut.clear() + http.shutdown() + } + + @Test + fun `v2 trigger groups wait while no group records and some group is pending`() { + val response = + File("src/test/resources/json/remote-config-v2-trigger-groups.json").readText() + .replaceFirst("\"sampleRate\": 1.0", "\"sampleRate\": 0.0") + val http = mockHttp(response = MockResponse().setBody(response)) + val sut = getSut(host = http.url("/").toString()) + + sut.loadRemoteConfig("my_identify", anonymousId = "anonId", emptyMap()) + executor.shutdownAndAwaitTermination() + + assertFalse(sut.isSessionRecordingPermittedByTriggerGroups("session-1")) + assertTrue(sut.hasPendingSessionRecordingTriggerGroups("session-1")) + + assertTrue(sut.onSessionRecordingTriggerEvent("session-1", "purchase", mapOf("amount" to 150))) + assertTrue(sut.isSessionRecordingPermittedByTriggerGroups("session-1")) + assertFalse(sut.hasPendingSessionRecordingTriggerGroups("session-1")) + + sut.clear() + http.shutdown() + } + + @Test + fun `v2 trigger groups are preloaded from the cache`() { + preferences.setValue( + SESSION_REPLAY, + mapOf( + "version" to 2, + "triggerGroups" to + listOf( + mapOf( + "id" to "group-a", + "name" to "Always on", + "sampleRate" to 0.0, + "conditions" to emptyMap(), + ), + ), + ), + ) + val sut = getSut(host = "http://localhost:1/") + + assertTrue(sut.hasSessionRecordingTriggerGroups()) + // activated immediately, but sampled out by its rate + assertFalse(sut.isSessionRecordingPermittedByTriggerGroups("session-1")) + assertFalse(sut.hasPendingSessionRecordingTriggerGroups("session-1")) + + sut.clear() + } + + @Test + fun `v2 trigger groups ignore a leftover linkedFlag`() { + preferences.setValue( + SESSION_REPLAY, + mapOf( + "version" to 2, + "linkedFlag" to "replay-flag", + "triggerGroups" to + listOf( + mapOf( + "id" to "group-a", + "name" to "Always on", + "sampleRate" to 1.0, + "conditions" to emptyMap(), + ), + ), + ), + ) + val sut = getSut(host = "http://localhost:1/") + + assertTrue(sut.hasSessionRecordingTriggerGroups()) + // per-group flag conditions replace the v1 linkedFlag gate, so an unmatched linkedFlag + // must not disable recording for a v2 config + assertTrue(sut.isSessionReplayFlagActive()) + assertFalse(sut.sessionReplayLinkedFlagSnapshot().configured) + assertTrue(sut.isSessionRecordingPermittedByTriggerGroups("session-1")) + + sut.clear() + } } diff --git a/posthog/src/test/java/com/posthog/internal/replay/PostHogTriggerGroupsEvaluatorTest.kt b/posthog/src/test/java/com/posthog/internal/replay/PostHogTriggerGroupsEvaluatorTest.kt new file mode 100644 index 000000000..2357cc8c6 --- /dev/null +++ b/posthog/src/test/java/com/posthog/internal/replay/PostHogTriggerGroupsEvaluatorTest.kt @@ -0,0 +1,423 @@ +package com.posthog.internal.replay + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +internal class PostHogTriggerGroupsEvaluatorTest { + private val evaluator = PostHogTriggerGroupsEvaluator() + + private fun configure(vararg groups: Map) { + val config = + parseTriggerGroupsConfig( + mapOf( + "version" to 2, + "triggerGroups" to groups.toList(), + ), + ) + assertTrue(config != null) + evaluator.onConfig(config.groups) + } + + private fun group( + id: String, + sampleRate: Any? = 1.0, + minDurationMs: Any? = null, + conditions: Map = emptyMap(), + ): Map = + mapOf( + "id" to id, + "name" to id, + "sampleRate" to sampleRate, + "minDurationMs" to minDurationMs, + "conditions" to conditions, + ) + + private fun eventLeg(vararg events: Map) = mapOf("events" to events.toList()) + + private fun urlLeg(pattern: String) = mapOf("urls" to listOf(mapOf("url" to pattern, "matching" to "regex"))) + + private fun flagLeg(flag: Any) = mapOf("flag" to flag) + + private fun onEvent( + sessionId: String = "session-1", + event: String, + properties: Map? = null, + ): Boolean = evaluator.onEvent(sessionId, event, properties, null) + + private fun screen( + sessionId: String = "session-1", + name: String, + ): Boolean = + onEvent( + sessionId = sessionId, + event = "\$screen", + properties = mapOf("\$screen_name" to name), + ) + + private fun decide( + sessionId: String = "session-1", + flags: Map? = emptyMap(), + ) = evaluator.evaluate(sessionId, flags, null) + + @Test + fun `any combines activated legs and ignores disabled legs`() { + configure( + group( + id = "g1", + conditions = + mapOf("matchType" to "any", "events" to listOf(mapOf("name" to "purchase"))) + urlLeg("^/checkout"), + ), + ) + // no flag configured: only event and url legs, both pending + var decision = decide() + assertFalse(decision.shouldRecord) + assertTrue(decision.hasPendingGroups) + + assertTrue(screen(name = "/checkout")) + decision = decide() + assertTrue(decision.shouldRecord) + assertFalse(decision.hasPendingGroups) + assertEquals(listOf("g1"), decision.matchedGroups.map { it.id }) + } + + @Test + fun `all drops disabled legs and requires every configured leg`() { + configure( + group( + id = "g1", + conditions = + mapOf( + "matchType" to "all", + "events" to listOf(mapOf("name" to "purchase")), + "flag" to mapOf("flag" to "replay-flag", "variant" to "beta"), + ), + ), + ) + + // flag leg activated, event leg pending -> pending + var decision = decide(flags = mapOf("replay-flag" to "beta")) + assertFalse(decision.shouldRecord) + assertTrue(decision.hasPendingGroups) + + // event leg activated too -> activated + assertTrue(onEvent(event = "purchase")) + decision = decide(flags = mapOf("replay-flag" to "beta")) + assertTrue(decision.shouldRecord) + } + + @Test + fun `all with a single configured leg follows that leg`() { + configure(group(id = "g1", conditions = mapOf("matchType" to "all", "events" to listOf(mapOf("name" to "purchase"))))) + + var decision = decide() + assertTrue(decision.hasPendingGroups) + assertTrue(onEvent(event = "purchase")) + decision = decide() + assertTrue(decision.shouldRecord) + } + + @Test + fun `a leg configured but unmatched stays pending under any`() { + configure(group(id = "g1", conditions = mapOf("matchType" to "any") + flagLeg("replay-flag"))) + + val decision = decide(flags = mapOf("replay-flag" to false)) + assertFalse(decision.shouldRecord) + assertTrue(decision.hasPendingGroups) + } + + @Test + fun `empty conditions activate immediately`() { + configure(group(id = "g1", conditions = mapOf("properties" to listOf(mapOf("key" to "region", "value" to "EU"))))) + + val decision = decide() + assertTrue(decision.shouldRecord) + assertFalse(decision.hasPendingGroups) + assertEquals(listOf(PostHogTriggerGroupMatch("g1", "g1", sampled = true)), decision.matchedGroups) + } + + @Test + fun `event leg matches per-event property filters`() { + configure( + group( + id = "g1", + conditions = + eventLeg( + mapOf( + "name" to "purchase", + "properties" to listOf(mapOf("key" to "amount", "operator" to "gt", "value" to 100)), + ), + ), + ), + ) + + assertFalse(onEvent(event = "purchase", properties = mapOf("amount" to 50))) + assertFalse(onEvent(event = "purchase", properties = mapOf("amount" to "free"))) + assertTrue(decide().hasPendingGroups) + assertTrue(onEvent(event = "purchase", properties = mapOf("amount" to 150))) + assertTrue(decide().shouldRecord) + } + + @Test + fun `event leg applies exact icontains regex and is_not filters`() { + val cases = + listOf( + // exact + listOf(mapOf("key" to "plan", "value" to "pro")) to mapOf("plan" to "pro"), + // icontains + listOf(mapOf("key" to "email", "operator" to "icontains", "value" to "@corp")) to + mapOf("email" to "a@CORP.io"), + // regex + listOf(mapOf("key" to "page", "operator" to "regex", "value" to "^/checkout")) to + mapOf("page" to "/checkout/step-2"), + // is_not on a missing property + listOf(mapOf("key" to "tier", "operator" to "is_not", "value" to "enterprise")) to + mapOf("unrelated" to 1), + ) + for ((filters, properties) in cases) { + val evaluator = PostHogTriggerGroupsEvaluator() + val config = + parseTriggerGroupsConfig( + mapOf( + "version" to 2, + "triggerGroups" to + listOf( + group(id = "g1", conditions = eventLeg(mapOf("name" to "purchase", "properties" to filters))), + ), + ), + ) + evaluator.onConfig(config!!.groups) + assertTrue( + evaluator.onEvent("session-1", "purchase", properties, null), + "filters $filters must match $properties", + ) + } + } + + @Test + fun `same-name event entries are a disjunction`() { + configure( + group( + id = "g1", + conditions = + eventLeg( + mapOf( + "name" to "purchase", + "properties" to listOf(mapOf("key" to "amount", "operator" to "gt", "value" to 100)), + ), + mapOf( + "name" to "purchase", + "properties" to listOf(mapOf("key" to "vip", "value" to true)), + ), + ), + ), + ) + + assertFalse(onEvent(event = "purchase", properties = mapOf("amount" to 50))) + assertTrue(onEvent(event = "purchase", properties = mapOf("amount" to 50, "vip" to true))) + } + + @Test + fun `group-level property filters gate the event leg`() { + configure( + group( + id = "g1", + conditions = + mapOf( + "events" to listOf(mapOf("name" to "purchase")), + "properties" to listOf(mapOf("key" to "region", "value" to "EU")), + ), + ), + ) + + assertFalse(onEvent(event = "purchase", properties = mapOf("region" to "US"))) + assertTrue(onEvent(event = "purchase", properties = mapOf("region" to "EU"))) + } + + @Test + fun `screen names match url regexes anywhere`() { + configure(group(id = "g1", conditions = urlLeg("checkout"))) + + assertFalse(screen(name = "/home")) + assertTrue(decide().hasPendingGroups) + assertTrue(screen(name = "/en/checkout/step-2")) + assertTrue(decide().shouldRecord) + } + + @Test + fun `a non screen event never activates the url leg`() { + configure(group(id = "g1", conditions = urlLeg("checkout"))) + + assertFalse(onEvent(event = "checkout", properties = mapOf("\$screen_name" to "/checkout"))) + assertFalse(decide().shouldRecord) + } + + @Test + fun `flag leg matches boolean flags and variants`() { + configure(group(id = "g1", conditions = flagLeg("replay-flag"))) + + assertFalse(decide(flags = null).shouldRecord) + assertTrue(decide().hasPendingGroups) + assertFalse(decide(flags = mapOf("replay-flag" to false)).shouldRecord) + assertFalse(decide(flags = mapOf("other-flag" to true)).shouldRecord) + assertTrue(decide(flags = mapOf("replay-flag" to true)).shouldRecord) + } + + @Test + fun `flag leg with variant matches the variant or a boolean true`() { + configure(group(id = "g1", conditions = flagLeg(mapOf("flag" to "replay-flag", "variant" to "beta")))) + + assertFalse(decide(flags = mapOf("replay-flag" to "alpha")).shouldRecord) + assertTrue(decide(flags = mapOf("replay-flag" to "beta")).shouldRecord) + // a boolean true satisfies a variant flag, matching web LinkedFlagMatching + assertTrue(decide(flags = mapOf("replay-flag" to true)).shouldRecord) + } + + @Test + fun `flag leg without variant matches any non-empty variant`() { + configure(group(id = "g1", conditions = flagLeg("replay-flag"))) + + assertTrue(decide(flags = mapOf("replay-flag" to "beta")).shouldRecord) + assertFalse(decide(flags = mapOf("replay-flag" to "")).shouldRecord) + } + + @Test + fun `activation is sticky within a session and resets on a new session`() { + configure(group(id = "g1", conditions = eventLeg(mapOf("name" to "purchase")))) + + assertTrue(onEvent(event = "purchase")) + // a later non-matching event does not deactivate (and activates nothing new) + assertFalse(onEvent(event = "other")) + assertTrue(decide().shouldRecord) + + var decision = decide(sessionId = "session-2") + assertFalse(decision.shouldRecord) + assertTrue(decision.hasPendingGroups) + // the previous session's activation must not leak into the new one + assertFalse(onEvent(sessionId = "session-2", event = "other")) + decision = decide(sessionId = "session-2") + assertFalse(decision.shouldRecord) + assertTrue(onEvent(sessionId = "session-2", event = "purchase")) + assertTrue(decide(sessionId = "session-2").shouldRecord) + } + + @Test + fun `sampling decision is deterministic per session and group`() { + // java "session-1g-empty".hashCode() -> bucket 23; "session-2g-empty" -> bucket 12 + configure(group(id = "g-empty", sampleRate = 0.2)) + assertFalse(decide().shouldRecord) + // same session: the stored decision is reused + assertFalse(decide().shouldRecord) + + // a new session re-decides + assertTrue(decide(sessionId = "session-2").shouldRecord) + } + + @Test + fun `a sample rate change re-decides the same session`() { + configure(group(id = "group-1", sampleRate = 0.61)) + assertFalse(decide().shouldRecord) + + configure(group(id = "group-1", sampleRate = 0.62)) + assertTrue(decide().shouldRecord) + } + + @Test + fun `a missing sample rate is fully sampled`() { + configure(group(id = "group-1", sampleRate = null)) + val decision = decide() + assertTrue(decision.shouldRecord) + assertEquals(listOf(true), decision.matchedGroups.map { it.sampled }) + } + + @Test + fun `union records when any activated group is sampled in`() { + configure( + group(id = "group-a", sampleRate = 0.0), + group(id = "group-b", sampleRate = 1.0), + ) + + val decision = decide() + assertTrue(decision.shouldRecord) + assertEquals( + listOf( + PostHogTriggerGroupMatch("group-a", "group-a", sampled = false), + PostHogTriggerGroupMatch("group-b", "group-b", sampled = true), + ), + decision.matchedGroups, + ) + } + + @Test + fun `union keeps waiting while any group is pending`() { + configure( + group(id = "group-a", sampleRate = 0.0), + group(id = "group-b", conditions = eventLeg(mapOf("name" to "purchase"))), + ) + + var decision = decide() + assertFalse(decision.shouldRecord) + assertTrue(decision.hasPendingGroups) + + assertTrue(onEvent(event = "purchase")) + decision = decide() + // group-b activated with rate 1.0, so the union records and stops waiting + assertTrue(decision.shouldRecord) + assertFalse(decision.hasPendingGroups) + } + + @Test + fun `union stops waiting once every group resolved`() { + configure( + group(id = "group-a", sampleRate = 0.0), + group(id = "group-b", sampleRate = 0.0), + ) + + val decision = decide() + assertFalse(decision.shouldRecord) + assertFalse(decision.hasPendingGroups) + } + + @Test + fun `minimum duration is the lowest among activated groups`() { + configure( + group(id = "group-a", minDurationMs = 5000), + group(id = "group-b", minDurationMs = 1000, conditions = eventLeg(mapOf("name" to "purchase"))), + ) + + var decision = decide() + assertEquals(5000L, decision.minDurationMs) + + assertTrue(onEvent(event = "purchase")) + decision = decide() + assertEquals(1000L, decision.minDurationMs) + } + + @Test + fun `minimum duration is null without an activated group setting one`() { + configure( + group(id = "group-a", conditions = eventLeg(mapOf("name" to "purchase"))), + group(id = "group-b", minDurationMs = 1000, conditions = eventLeg(mapOf("name" to "purchase"))), + ) + + val decision = decide() + assertEquals(null, decision.minDurationMs) + } + + @Test + fun `unreliable event legs are disabled so they neither record nor wait`() { + // React Native captures events in JS, so the event/screen legs can never fire natively: + // this group has no other leg, so it resolves to disabled instead of pending forever. + configure(group(id = "g1", conditions = eventLeg(mapOf("name" to "purchase")))) + + var decision = evaluator.evaluate("session-1", mapOf("replay-flag" to false), null, eventLegsReliable = false) + assertFalse(decision.shouldRecord) + assertFalse(decision.hasPendingGroups) + + // flag legs still decide under unreliable event legs + configure(group(id = "g2", conditions = mapOf("matchType" to "any") + flagLeg("replay-flag"))) + decision = evaluator.evaluate("session-1", mapOf("replay-flag" to true), null, eventLegsReliable = false) + assertTrue(decision.shouldRecord) + } +} diff --git a/posthog/src/test/java/com/posthog/internal/replay/PostHogTriggerGroupsTest.kt b/posthog/src/test/java/com/posthog/internal/replay/PostHogTriggerGroupsTest.kt new file mode 100644 index 000000000..07dd718f5 --- /dev/null +++ b/posthog/src/test/java/com/posthog/internal/replay/PostHogTriggerGroupsTest.kt @@ -0,0 +1,199 @@ +package com.posthog.internal.replay + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +internal class PostHogTriggerGroupsTest { + @Test + fun `version missing or 1 keeps the v1 config`() { + assertNull(parseTriggerGroupsConfig(mapOf("sampleRate" to 0.5, "eventTriggers" to listOf("purchase")))) + assertNull( + parseTriggerGroupsConfig(mapOf("version" to 1, "triggerGroups" to listOf(group(id = "g1")))), + ) + } + + @Test + fun `version 2 without usable groups keeps the v1 config`() { + assertNull(parseTriggerGroupsConfig(mapOf("version" to 2))) + assertNull(parseTriggerGroupsConfig(mapOf("version" to 2, "triggerGroups" to emptyList()))) + // a group without an id cannot be identified, so it is dropped and v1 stays in effect + assertNull( + parseTriggerGroupsConfig(mapOf("version" to 2, "triggerGroups" to listOf(mapOf("name" to "no id")))), + ) + } + + @Test + fun `parses group sampling fields and conditions`() { + val config = + config( + group( + id = "group-1", + name = "Checkout", + sampleRate = 0.42, + minDurationMs = 5000, + conditions = + mapOf( + "matchType" to "any", + "events" to listOf(mapOf("name" to "purchase")), + "urls" to listOf(mapOf("url" to "^/checkout", "matching" to "regex")), + "flag" to mapOf("flag" to "replay-flag", "variant" to "beta"), + "properties" to listOf(mapOf("key" to "region", "value" to "EU")), + ), + ), + ) + + val group = config.groups.single() + assertEquals("group-1", group.id) + assertEquals("Checkout", group.name) + assertEquals(0.42, group.sampleRate) + assertEquals(5000L, group.minDurationMs) + assertEquals(PostHogTriggerMatchType.ANY, group.conditions.matchType) + assertEquals(listOf(PostHogTriggerEvent("purchase", emptyList())), group.conditions.events) + assertEquals(listOf("^/checkout"), group.conditions.urls.map { it.pattern }) + assertEquals(PostHogTriggerFlag("replay-flag", "beta"), group.conditions.flag) + assertEquals(listOf(PostHogTriggerPropertyFilter("region", null, null, "EU")), group.conditions.properties) + } + + @Test + fun `matchType defaults to all`() { + val config = config(group(conditions = mapOf("events" to listOf(mapOf("name" to "purchase"))))) + assertEquals(PostHogTriggerMatchType.ALL, config.groups.single().conditions.matchType) + } + + @Test + fun `events parse with per-event property filters and drop nameless entries`() { + val config = + config( + group( + conditions = + mapOf( + "events" to + listOf( + mapOf( + "name" to "purchase", + "properties" to + listOf( + // remote-config numbers arrive as Doubles (Gson) + mapOf("key" to "amount", "operator" to "gt", "value" to 100.0), + ), + ), + mapOf("properties" to listOf(mapOf("key" to "orphan"))), + ), + ), + ), + ) + + val events = config.groups.single().conditions.events + assertEquals(1, events.size) + assertEquals( + listOf(PostHogTriggerPropertyFilter("amount", null, "gt", 100.0)), + events.single().properties, + ) + } + + @Test + fun `urls keep only valid regex entries`() { + val config = + config( + group( + conditions = + mapOf( + "urls" to + listOf( + mapOf("url" to "^/checkout", "matching" to "regex"), + mapOf("url" to "exact-but-unsupported", "matching" to "icontains"), + mapOf("url" to "([invalid", "matching" to "regex"), + mapOf("matching" to "regex"), + ), + ), + ), + ) + + val urls = config.groups.single().conditions.urls + assertEquals(1, urls.size) + assertTrue(urls.single().containsMatchIn("/checkout/step-2")) + assertFalse(urls.single().containsMatchIn("/home")) + } + + @Test + fun `flag accepts a bare string or a flag and variant map`() { + val bare = config(group(conditions = mapOf("flag" to "replay-flag"))) + assertEquals(PostHogTriggerFlag("replay-flag", null), bare.groups.single().conditions.flag) + + val withVariant = + config( + group( + conditions = + mapOf( + "flag" to mapOf("flag" to "replay-flag", "variant" to "beta"), + ), + ), + ) + assertEquals(PostHogTriggerFlag("replay-flag", "beta"), withVariant.groups.single().conditions.flag) + + val noFlagName = + config( + group( + conditions = + mapOf( + "flag" to mapOf("variant" to "beta"), + ), + ), + ) + assertNull(noFlagName.groups.single().conditions.flag) + } + + @Test + fun `a missing or non-number sample rate parses to null`() { + val config = config(group(sampleRate = null), group(id = "g2", sampleRate = "0.5")) + assertEquals(null, config.groups[0].sampleRate) + assertEquals(null, config.groups[1].sampleRate) + } + + @Test + fun `simple hash matches the web helper`() { + // java "abc".hashCode() == 96354 + assertEquals(96354L, simpleTriggerHash("abc")) + // java "posthog".hashCode() == -391202912; js Math.abs of the int32 yields 391202912 + assertEquals(391202912L, simpleTriggerHash("posthog")) + } + + @Test + fun `sampling uses the hash bucket like the web helper`() { + // java "session-1group-1".hashCode() == 365459561; 365459561 % 100 == 61 + assertFalse(sampleOnTriggerProperty("session-1group-1", 0.61)) + assertTrue(sampleOnTriggerProperty("session-1group-1", 0.62)) + assertTrue(sampleOnTriggerProperty("session-1group-1", 1.0)) + assertFalse(sampleOnTriggerProperty("session-1group-1", 0.0)) + } + + private fun config(vararg groups: Map): PostHogTriggerGroupsConfig { + val config = + parseTriggerGroupsConfig( + mapOf( + "version" to 2, + "triggerGroups" to groups.toList(), + ), + ) + assertTrue(config != null) + return config + } +} + +private fun group( + id: String = "g1", + name: String = "Group 1", + sampleRate: Any? = 1.0, + minDurationMs: Any? = null, + conditions: Map = emptyMap(), +): Map = + mapOf( + "id" to id, + "name" to name, + "sampleRate" to sampleRate, + "minDurationMs" to minDurationMs, + "conditions" to conditions, + ) diff --git a/posthog/src/test/java/com/posthog/internal/replay/PostHogTriggerPropertyFiltersTest.kt b/posthog/src/test/java/com/posthog/internal/replay/PostHogTriggerPropertyFiltersTest.kt new file mode 100644 index 000000000..8dbcece58 --- /dev/null +++ b/posthog/src/test/java/com/posthog/internal/replay/PostHogTriggerPropertyFiltersTest.kt @@ -0,0 +1,187 @@ +package com.posthog.internal.replay + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +internal class PostHogTriggerPropertyFiltersTest { + private fun filter( + key: String = "plan", + value: Any? = "pro", + operator: String? = null, + type: String? = null, + ): PostHogTriggerPropertyFilter = PostHogTriggerPropertyFilter(key, type, operator, value) + + @Test + fun `empty or missing filter list matches`() { + assertTrue(matchTriggerPropertyFilters(null, mapOf("plan" to "free"), null)) + assertTrue(matchTriggerPropertyFilters(emptyList(), null, null)) + } + + @Test + fun `exact matches case sensitively`() { + assertTrue(matchTriggerPropertyFilters(listOf(filter()), mapOf("plan" to "pro"), null)) + assertFalse(matchTriggerPropertyFilters(listOf(filter()), mapOf("plan" to "PRO"), null)) + assertFalse(matchTriggerPropertyFilters(listOf(filter()), mapOf("plan" to "free"), null)) + } + + @Test + fun `exact compares numbers with js string semantics`() { + // Remote-config numbers arrive as Double (Gson) while event properties are often Int; + // JS String(100) === String(100.0), so the string forms must match too. + assertTrue(matchTriggerPropertyFilters(listOf(filter(key = "amount", value = 100.0)), mapOf("amount" to 100), null)) + assertTrue(matchTriggerPropertyFilters(listOf(filter(key = "amount", value = 100)), mapOf("amount" to 100.0), null)) + assertFalse(matchTriggerPropertyFilters(listOf(filter(key = "amount", value = 100.5)), mapOf("amount" to 100), null)) + } + + @Test + fun `exact matches any element of array values`() { + assertTrue( + matchTriggerPropertyFilters( + listOf(filter(value = listOf("free", "pro"))), + mapOf("plan" to "pro"), + null, + ), + ) + assertFalse( + matchTriggerPropertyFilters( + listOf(filter(value = listOf("free", "enterprise"))), + mapOf("plan" to "pro"), + null, + ), + ) + } + + @Test + fun `is_not matches when the property is missing or null`() { + assertTrue(matchTriggerPropertyFilters(listOf(filter(operator = "is_not")), null, null)) + assertTrue(matchTriggerPropertyFilters(listOf(filter(operator = "is_not")), mapOf("other" to 1), null)) + assertTrue(matchTriggerPropertyFilters(listOf(filter(operator = "is_not")), mapOf("plan" to null), null)) + assertTrue(matchTriggerPropertyFilters(listOf(filter(operator = "is_not")), mapOf("plan" to "free"), null)) + assertFalse(matchTriggerPropertyFilters(listOf(filter(operator = "is_not")), mapOf("plan" to "pro"), null)) + } + + @Test + fun `positive operators do not match a missing property`() { + val operators = listOf(null, "exact", "icontains", "regex", "gt", "lt") + for (operator in operators) { + assertFalse( + matchTriggerPropertyFilters(listOf(filter(operator = operator)), emptyMap(), null), + "operator $operator must not match a missing property", + ) + } + } + + @Test + fun `negative operators match a missing property`() { + for (operator in listOf("is_not", "not_icontains", "not_regex")) { + assertTrue( + matchTriggerPropertyFilters(listOf(filter(operator = operator)), emptyMap(), null), + "operator $operator must match a missing property", + ) + } + } + + @Test + fun `icontains is a case-insensitive substring match`() { + assertTrue( + matchTriggerPropertyFilters( + listOf(filter(key = "email", operator = "icontains", value = "@CORP")), + mapOf("email" to "marc@corp.com"), + null, + ), + ) + assertFalse( + matchTriggerPropertyFilters( + listOf(filter(key = "email", operator = "icontains", value = "@corp")), + mapOf("email" to "marc@example.com"), + null, + ), + ) + } + + @Test + fun `not_icontains requires no element to contain the target`() { + val filter = listOf(filter(key = "email", operator = "not_icontains", value = "@corp")) + assertTrue(matchTriggerPropertyFilters(filter, mapOf("email" to "marc@example.com"), null)) + assertFalse(matchTriggerPropertyFilters(filter, mapOf("email" to "a@CORP.io"), null)) + } + + @Test + fun `regex searches the property value`() { + val filter = listOf(filter(key = "page", operator = "regex", value = "checkout/step-\\d+")) + assertTrue(matchTriggerPropertyFilters(filter, mapOf("page" to "/en/checkout/step-2"), null)) + assertFalse(matchTriggerPropertyFilters(filter, mapOf("page" to "/home"), null)) + assertFalse(matchTriggerPropertyFilters(filter, mapOf("page" to "checkout step x"), null)) + } + + @Test + fun `an invalid regex never matches and its negation always does`() { + val invalid = listOf(filter(operator = "regex", value = "([")) + assertFalse(matchTriggerPropertyFilters(invalid, mapOf("plan" to "pro"), null)) + val invalidNot = listOf(filter(operator = "not_regex", value = "([")) + assertTrue(matchTriggerPropertyFilters(invalidNot, mapOf("plan" to "pro"), null)) + } + + @Test + fun `gt and lt compare numerically with js parseFloat semantics`() { + val gt = listOf(filter(key = "amount", operator = "gt", value = 100)) + assertTrue(matchTriggerPropertyFilters(gt, mapOf("amount" to 150), null)) + assertFalse(matchTriggerPropertyFilters(gt, mapOf("amount" to 100), null)) + assertFalse(matchTriggerPropertyFilters(gt, mapOf("amount" to "free"), null)) + // parseFloat("10items") is 10, like on web + assertFalse(matchTriggerPropertyFilters(gt, mapOf("amount" to "10items"), null)) + assertTrue( + matchTriggerPropertyFilters( + listOf(filter(key = "amount", operator = "gt", value = 9)), + mapOf("amount" to "10items"), + null, + ), + ) + + val lt = listOf(filter(key = "amount", operator = "lt", value = "50.5")) + assertTrue(matchTriggerPropertyFilters(lt, mapOf("amount" to 50), null)) + assertFalse(matchTriggerPropertyFilters(lt, mapOf("amount" to 50.5), null)) + assertFalse( + matchTriggerPropertyFilters( + listOf(filter(key = "amount", operator = "lt", value = "40")), + mapOf("amount" to "50items"), + null, + ), + ) + } + + @Test + fun `unknown operator does not match`() { + assertFalse(matchTriggerPropertyFilters(listOf(filter(operator = "regex_ish")), mapOf("plan" to "pro"), null)) + } + + @Test + fun `missing filter value does not match`() { + assertFalse(matchTriggerPropertyFilters(listOf(filter(value = null)), mapOf("plan" to "pro"), null)) + assertFalse( + matchTriggerPropertyFilters(listOf(filter(value = null, operator = "is_not")), mapOf("plan" to "pro"), null), + ) + } + + @Test + fun `person type reads person properties and event type reads event properties`() { + val filter = listOf(filter(type = "person")) + assertTrue(matchTriggerPropertyFilters(filter, mapOf("plan" to "free"), mapOf("plan" to "pro"))) + assertFalse(matchTriggerPropertyFilters(filter, mapOf("plan" to "pro"), mapOf("plan" to "free"))) + assertFalse(matchTriggerPropertyFilters(filter, mapOf("plan" to "pro"), null)) + + assertTrue(matchTriggerPropertyFilters(listOf(filter()), mapOf("plan" to "pro"), mapOf("plan" to "free"))) + } + + @Test + fun `all filters must match`() { + val filters = + listOf( + filter(key = "plan", value = "pro"), + filter(key = "region", value = "EU"), + ) + assertTrue(matchTriggerPropertyFilters(filters, mapOf("plan" to "pro", "region" to "EU"), null)) + assertFalse(matchTriggerPropertyFilters(filters, mapOf("plan" to "pro", "region" to "US"), null)) + } +} diff --git a/posthog/src/test/resources/json/remote-config-v2-trigger-groups.json b/posthog/src/test/resources/json/remote-config-v2-trigger-groups.json new file mode 100644 index 000000000..64551aa79 --- /dev/null +++ b/posthog/src/test/resources/json/remote-config-v2-trigger-groups.json @@ -0,0 +1,81 @@ +{ + "token": "phc_QFbR1y41s5sxnNTZoyKG2NJo2RlsCIWkUfdpawgb40D", + "supportedCompression": [ + "gzip", + "gzip-js" + ], + "hasFeatureFlags": false, + "captureDeadClicks": false, + "capturePerformance": { + "network_timing": true, + "web_vitals": false, + "web_vitals_allowed_metrics": null + }, + "autocapture_opt_out": true, + "autocaptureExceptions": false, + "errorTracking": { + "autocaptureExceptions": true + }, + "analytics": { + "endpoint": "/i/v0/e/" + }, + "elementsChainAsString": true, + "sessionRecording": { + "endpoint": "/s/", + "consoleLogRecordingEnabled": true, + "recorderVersion": "v2", + "version": 2, + "sampleRate": null, + "minimumDurationMilliseconds": 1000, + "linkedFlag": null, + "networkPayloadCapture": { + "recordBody": true, + "recordHeaders": true + }, + "masking": null, + "urlTriggers": [], + "urlBlocklist": [], + "eventTriggers": ["legacy-trigger"], + "scriptConfig": null, + "recordCanvas": true, + "canvasFps": 3, + "canvasQuality": "0.4", + "triggerGroups": [ + { + "id": "group-a", + "name": "Always on", + "sampleRate": 1.0, + "minDurationMs": 30000, + "conditions": { + "matchType": "all" + } + }, + { + "id": "group-b", + "name": "Big purchases", + "sampleRate": 1.0, + "minDurationMs": 5000, + "conditions": { + "matchType": "any", + "events": [ + { + "name": "purchase", + "properties": [ + { + "key": "amount", + "type": "event", + "operator": "gt", + "value": 100 + } + ] + } + ] + } + } + ] + }, + "heatmaps": false, + "surveys": false, + "defaultIdentifiedOnly": true, + "siteApps": [] +}