Skip to content
Draft
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/humble-boxes-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"posthog-android": minor
"posthog": minor
---

feat: support session replay minimum recording duration
12 changes: 11 additions & 1 deletion posthog-android/api/posthog-android.api
Original file line number Diff line number Diff line change
Expand Up @@ -62,20 +62,30 @@ public final class com/posthog/android/replay/PostHogMaskModifier {
public static synthetic fun postHogUnmask$default (Lcom/posthog/android/replay/PostHogMaskModifier;Landroidx/compose/ui/Modifier;ZILjava/lang/Object;)Landroidx/compose/ui/Modifier;
}

public final class com/posthog/android/replay/PostHogReplayIntegration : com/posthog/PostHogIntegration, com/posthog/internal/replay/PostHogSessionReplayHandler {
public final class com/posthog/android/replay/PostHogReplayIntegration : com/posthog/PostHogIntegration, com/posthog/android/replay/PostHogReplayBufferDelegate, com/posthog/internal/replay/PostHogSessionReplayHandler {
public static final field ANDROID_COMPOSE_VIEW Ljava/lang/String;
public static final field ANDROID_COMPOSE_VIEW_CLASS_NAME Ljava/lang/String;
public static final field PH_NO_CAPTURE_LABEL Ljava/lang/String;
public static final field PH_NO_MASK_LABEL Ljava/lang/String;
public fun <init> (Landroid/content/Context;Lcom/posthog/android/PostHogAndroidConfig;Lcom/posthog/android/internal/MainHandler;)V
public fun install (Lcom/posthog/PostHogInterface;)V
public fun isActive ()Z
public fun isBuffering ()Z
public fun onRemoteConfig ()V
public fun onReplayBufferSnapshot (Lcom/posthog/android/replay/PostHogReplayQueue;)V
public fun start (Z)V
public fun stop ()V
public fun uninstall ()V
}

public final class com/posthog/android/replay/PostHogReplayQueue : com/posthog/internal/PostHogQueueInterface {
public fun add (Lcom/posthog/PostHogEvent;)V
public fun clear ()V
public fun flush ()V
public fun start ()V
public fun stop ()V
}

public final class com/posthog/android/replay/PostHogSessionReplayConfig {
public fun <init> ()V
public fun <init> (Z)V
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.posthog.android

import com.posthog.PostHogConfig
import com.posthog.android.replay.PostHogReplayQueue
import com.posthog.android.replay.PostHogSessionReplayConfig
import com.posthog.internal.PostHogApiEndpoint
import com.posthog.internal.PostHogQueue

/**
* The SDK Config
Expand All @@ -19,4 +22,19 @@ public open class PostHogAndroidConfig
public var captureDeepLinks: Boolean = true,
public var captureScreenViews: Boolean = true,
public var sessionReplayConfig: PostHogSessionReplayConfig = PostHogSessionReplayConfig(),
) : PostHogConfig(apiKey = apiKey, host = host)
) : PostHogConfig(
apiKey = apiKey,
host = host,
queueProvider = { config, api, endpoint, storagePrefix, executor ->
val defaultQueue = PostHogQueue(config, api, endpoint, storagePrefix, executor)
if (endpoint == PostHogApiEndpoint.SNAPSHOT) {
val replayQueue = PostHogReplayQueue(config, defaultQueue, storagePrefix)
(config as? PostHogAndroidConfig)?.replayQueueHolder = replayQueue
replayQueue
} else {
defaultQueue
}
},
) {
internal var replayQueueHolder: PostHogReplayQueue? = null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.posthog.android.replay

/**
* Delegate interface for controlling session replay buffering behavior.
*
* The replay queue is passive: it checks [isBuffering] on every `add()` and `flush()`,
* and notifies the delegate after buffering a snapshot.
*/
internal interface PostHogReplayBufferDelegate {
/**
* Whether the replay queue should buffer snapshots instead of sending directly.
* Checked on every `queue.add()` and `queue.flush()`.
*/
val isBuffering: Boolean

/**
* Called after a snapshot was added to the buffer.
* The delegate should check threshold conditions and call
* `replayQueue.migrateBufferToQueue()` when the minimum duration has been met.
*/
fun onReplayBufferSnapshot(replayQueue: PostHogReplayQueue)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package com.posthog.android.replay

import com.posthog.PostHogConfig
import com.posthog.PostHogEvent
import com.posthog.internal.PostHogQueue
import com.posthog.internal.PostHogQueueInterface
import com.posthog.vendor.uuid.TimeBasedEpochGenerator
import java.io.File
import java.util.UUID

/**
* A disk-based buffer queue for session replay snapshots.
*
* Uses UUID v7 filenames so timestamps can be extracted from filenames
* for duration calculations.
*/
internal class PostHogReplayBufferQueue(
private val config: PostHogConfig,
private val bufferDir: File,
) {
private val items = mutableListOf<String>()
private val itemsLock = Any()

val depth: Int
get() = synchronized(itemsLock) { items.size }

/**
* Returns the time span (in millis) between the oldest and newest buffered items,
* based on the UUID v7 embedded timestamps.
*/
val bufferDurationMs: Long?
get() =
synchronized(itemsLock) {
val oldest = items.firstOrNull() ?: return@synchronized null
val newest = items.lastOrNull() ?: return@synchronized null
val oldestTs = timestampFromUUIDv7(oldest) ?: return@synchronized null
val newestTs = timestampFromUUIDv7(newest) ?: return@synchronized null
maxOf(newestTs - oldestTs, 0)
}

init {
setup()
}

private fun setup() {
// Clear any leftover buffer from previous sessions — if they're still here,
// they didn't meet the minimum duration threshold and should be discarded.
deleteDirectorySafely(bufferDir)

try {
bufferDir.mkdirs()
} catch (e: Throwable) {
config.logger.log("Error trying to create replay buffer folder: $e")
}

synchronized(itemsLock) {
items.clear()
}
}

private fun deleteDirectorySafely(dir: File) {
try {
if (dir.exists()) {
dir.deleteRecursively()
}
} catch (e: Throwable) {
config.logger.log("Error deleting replay buffer directory: $e")
}
}

fun add(event: PostHogEvent) {
try {
val filename = "${TimeBasedEpochGenerator.generate()}.event"
val file = File(bufferDir, filename)
val os = config.encryption?.encrypt(file.outputStream()) ?: file.outputStream()
os.use { output ->
config.serializer.serialize(event, output.writer().buffered())
}
synchronized(itemsLock) { items.add(filename) }
} catch (e: Throwable) {
config.logger.log("Could not write replay buffer file: $e")
}
}

/**
* Migrates all buffered items to the target queue.
*
* Migration is supported for [PostHogQueue] targets by moving files on disk
* and reloading the target queue from disk.
*
* Returns the number of events successfully migrated.
*/
fun migrateAllTo(targetQueue: PostHogQueueInterface): Int {
if (targetQueue !is PostHogQueue) {
config.logger.log("Replay buffer migration skipped: target queue is not PostHogQueue")
return 0
}

val targetDir = targetQueue.queueDirectory
if (targetDir == null) {
config.logger.log("Replay queue has no disk directory configured. Skipping buffer migration.")
return 0
}

val itemsToMigrate: List<String> =
synchronized(itemsLock) {
val copy = items.toList()
items.clear()
copy
}

try {
targetDir.mkdirs()
} catch (e: Throwable) {
config.logger.log("Error creating replay target queue directory: $e")
}

var migratedCount = 0
for (item in itemsToMigrate) {
val sourceFile = File(bufferDir, item)
if (!sourceFile.exists()) {
continue
}
val targetFile = File(targetDir, item)
try {
if (targetFile.exists()) {
sourceFile.delete()
continue
}

if (sourceFile.renameTo(targetFile)) {
migratedCount++
} else {
config.logger.log("Failed to move replay buffer item $item")
}
} catch (e: Throwable) {
config.logger.log("Failed to migrate replay buffer item $item: $e")
}
}

targetQueue.reloadFromDisk()
return migratedCount
}

/**
* Removes all buffered items from disk and memory.
*/
fun clear() {
setup()
}

companion object {
/**
* Extracts the millisecond epoch timestamp from a UUID v7 filename.
*
* UUID v7 encodes Unix milliseconds in the first 48 bits.
* The filename format is `<uuid>.event`.
*
* We parse the UUID and extract millis via `mostSignificantBits ushr 16`.
*
* @return millis since epoch, or null if parsing fails
*/
internal fun timestampFromUUIDv7(filename: String): Long? {
return try {
val uuidString = filename.removeSuffix(".event")
val uuid = UUID.fromString(uuidString)
uuid.mostSignificantBits ushr 16
} catch (_: Throwable) {
null
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ public class PostHogReplayIntegration(
private val context: Context,
private val config: PostHogAndroidConfig,
private val mainHandler: MainHandler,
) : PostHogIntegration, PostHogSessionReplayHandler {
) : PostHogIntegration, PostHogSessionReplayHandler, PostHogReplayBufferDelegate {
private val decorViews = WeakHashMap<View, ViewTreeSnapshotStatus>()

private val passwordInputTypes =
Expand Down Expand Up @@ -161,6 +161,14 @@ public class PostHogReplayIntegration(
get() = (config.sdkName != "posthog-flutter")

private var postHog: PostHogInterface? = null
private var replayQueue: PostHogReplayQueue? = null

// Minimum duration buffering state
private val bufferingLock = Any()

@Volatile
private var hasPassedMinimumDuration: Boolean = false
private var cachedMinimumDurationMs: Long? = null

@Volatile
private var isOnDrawnCalled: Boolean = false
Expand Down Expand Up @@ -383,6 +391,13 @@ public class PostHogReplayIntegration(
integrationInstalled = true
this.postHog = postHog

// Wire up as buffer delegate for the replay queue
replayQueue = config.replayQueueHolder
replayQueue?.bufferDelegate = this

// Load cached minimum duration from remote config (if available)
updateCachedMinimumDuration()

// workaround for react native that is started after the window is added
// Curtains.rootViews should be empty for normal apps yet
Curtains.rootViews.forEach { view ->
Expand All @@ -400,6 +415,11 @@ public class PostHogReplayIntegration(
try {
integrationInstalled = false
this.postHog = null

// Clear buffer delegate
replayQueue?.bufferDelegate = null
replayQueue = null

Curtains.onRootViewsChangedListeners -= onRootViewsChangedListener

decorViews.entries.forEach {
Expand Down Expand Up @@ -1587,6 +1607,8 @@ public class PostHogReplayIntegration(
override fun start(resumeCurrent: Boolean) {
if (!resumeCurrent) {
clearSnapshotStates()
// Reset minimum duration buffering state for the new session
resetBufferingState()
}

isSessionReplayActive = true
Expand All @@ -1608,6 +1630,71 @@ public class PostHogReplayIntegration(
return isSessionReplayActive
}

// MARK: - PostHogReplayBufferDelegate

override val isBuffering: Boolean
get() {
synchronized(bufferingLock) {
val minimumDuration = cachedMinimumDurationMs
if (minimumDuration == null || minimumDuration <= 0) {
return false
}
return !hasPassedMinimumDuration
}
}

override fun onReplayBufferSnapshot(replayQueue: PostHogReplayQueue) {
val minimumDurationMs: Long? = synchronized(bufferingLock) { cachedMinimumDurationMs }
if (minimumDurationMs == null || minimumDurationMs <= 0) {
// No minimum duration configured: should not be buffering, migrate immediately.
synchronized(bufferingLock) { hasPassedMinimumDuration = true }
replayQueue.migrateBufferToQueue()
return
}

// Check buffer content duration (oldest to newest snapshot)
val bufferDurationMs = replayQueue.bufferDurationMs ?: 0

// Keep buffered snapshots intact until threshold is reached.
// Session replay payloads may include metadata snapshots required by the player,
// so buffering follows an all-or-nothing migration strategy.
if (bufferDurationMs >= minimumDurationMs) {
config.logger.log(
"[Session Replay] Minimum duration met. Migrating ${replayQueue.bufferDepth} buffered events to replay queue.",
)
// Flip state before migration so new snapshots don't keep entering the buffer during long-running migrations.
synchronized(bufferingLock) { hasPassedMinimumDuration = true }
replayQueue.migrateBufferToQueue()
}
}

// MARK: - Remote Config

override fun onRemoteConfig() {
updateCachedMinimumDuration()
}

private fun updateCachedMinimumDuration() {
val minimumDuration = config.remoteConfigHolder?.getRecordingMinimumDurationMs()
synchronized(bufferingLock) {
cachedMinimumDurationMs = minimumDuration
}
}

// MARK: - Buffering State

/**
* Resets buffering state for a new session — clears the buffer and marks
* as not yet passed minimum duration.
*/
private fun resetBufferingState() {
synchronized(bufferingLock) {
hasPassedMinimumDuration = false
}
// Clear any buffered events from previous session
replayQueue?.clearBuffer()
}

internal companion object {
const val PH_NO_CAPTURE_LABEL: String = "ph-no-capture"
const val PH_NO_MASK_LABEL: String = "ph-no-mask"
Expand Down
Loading