Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ open class BaseApplication : Application() {

val sessionReplayPlugin = SessionReplay(
options = ReplayOptions(
enabled = true,
privacyProfile = PrivacyProfile(
maskText = false,
maskWebViews = true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,14 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
Expand All @@ -51,6 +53,7 @@ import com.example.androidobservability.ui.theme.AndroidObservabilityTheme
import com.example.androidobservability.ui.theme.DangerRed
import com.example.androidobservability.ui.theme.IdentifyBgColor
import com.example.androidobservability.ui.theme.IdentifyTextColor
import com.launchdarkly.observability.sdk.LDReplay

class MainActivity : ComponentActivity() {

Expand All @@ -67,43 +70,79 @@ class MainActivity : ComponentActivity() {
.fillMaxSize()
.imePadding()
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
Text(
text = "Masking",
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.padding(bottom = 8.dp)
)
HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp))

MaskingButtons()

Text(
text = "Observability",
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.padding(bottom = 8.dp)
)
HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp))

IdentifyButtons(viewModel = viewModel)

InstrumentationButtons(viewModel = viewModel)

MetricButtons(viewModel = viewModel)

CustomerApiButtons(viewModel = viewModel)
}
MainScreen(viewModel, innerPadding)
}
}
}
}
}

@Composable
private fun MainScreen(viewModel: ViewModel, innerPadding: PaddingValues) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
Text(
text = "Masking",
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.padding(bottom = 8.dp)
)
HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp))

MaskingButtons()

Text(
text = "Observability",
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.padding(bottom = 8.dp)
)
HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp))

SessionReplayToggle()

IdentifyButtons(viewModel = viewModel)

InstrumentationButtons(viewModel = viewModel)

MetricButtons(viewModel = viewModel)

CustomerApiButtons(viewModel = viewModel)
}
}

@Composable
private fun SessionReplayToggle() {
var isSessionReplayEnabled by rememberSaveable { mutableStateOf(true) }

Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Session Replay",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold)
)
Switch(
checked = isSessionReplayEnabled,
onCheckedChange = { enabled ->
isSessionReplayEnabled = enabled
if (enabled) {
LDReplay.start()
} else {
LDReplay.stop()
}
}
)
}
}

@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun MetricButtons(viewModel: ViewModel) {
Expand Down Expand Up @@ -271,7 +310,7 @@ private fun MaskingRow(name: String, ctx: Context, activity1: Class<out Activity
}
}

private fun goToActivity(ctx: Context, activity: Class<out Activity>??){
private fun goToActivity(ctx: Context, activity: Class<out Activity>?){
activity?.let {
ctx.startActivity(
Intent(ctx, it)
Expand Down
19 changes: 19 additions & 0 deletions sdk/@launchdarkly/observability-android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,25 @@ Notes:
- SessionReplay depends on Observability. If Observability is missing or listed after SessionReplay, the plugin logs an error and stays inactive.
- Observability runs fine without SessionReplay; adding SessionReplay extends the Observability pipeline to include session recording.

#### Delay Start

If you need to begin recording after login or later in the app lifecycle, disable automatic capture and start it later:

```kotlin
import com.launchdarkly.observability.replay.ReplayOptions
import com.launchdarkly.observability.replay.plugin.SessionReplay
import com.launchdarkly.observability.sdk.LDReplay

val sessionReplay = SessionReplay(
ReplayOptions(enabled = false)
)

// After login:
LDReplay.start()
```

Call `LDReplay.stop()` to pause recording.

#### Masking sensitive UI

Use `ldMask()` to mark views that should be masked in session replay. There are helpers for both XML-based Views and Jetpack Compose.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import com.launchdarkly.observability.replay.exporter.InteractionItemPayload
import com.launchdarkly.observability.replay.exporter.SessionReplayExporter
import com.launchdarkly.observability.replay.transport.BatchWorker
import com.launchdarkly.observability.replay.transport.EventQueue
import com.launchdarkly.observability.sdk.ReplayControl
import com.launchdarkly.sdk.LDContext
import io.opentelemetry.android.instrumentation.InstallationContext
import io.opentelemetry.android.session.SessionManager
Expand All @@ -25,6 +26,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
Expand Down Expand Up @@ -62,7 +64,7 @@ private const val INSTRUMENTATION_SCOPE_NAME = "com.launchdarkly.observability.r
class ReplayInstrumentation(
private val options: ReplayOptions = ReplayOptions(),
private val observabilityContext: ObservabilityContext
) : LDExtendedInstrumentation {
) : LDExtendedInstrumentation, ReplayControl {

private lateinit var sessionManager: SessionManager
private val logger: LDLogger = observabilityContext.logger
Expand All @@ -73,9 +75,12 @@ class ReplayInstrumentation(
private val instrumentationScope = CoroutineScope(DispatcherProviderHolder.current.default + SupervisorJob())
private var captureJob: Job? = null
private val shouldCapture = MutableStateFlow(false)
private val isEnabled = MutableStateFlow(options.enabled)
private var processLifecycleObserver: DefaultLifecycleObserver? = null
private var isInstalled: Boolean = false
private var exporter: SessionReplayExporter? = null
private val pendingIdentifyLock = Any()
private var pendingIdentify: IdentifyItemPayload? = null

override val name: String = INSTRUMENTATION_SCOPE_NAME

Expand Down Expand Up @@ -122,13 +127,15 @@ class ReplayInstrumentation(
// Images collector
instrumentationScope.launch {
captureSource?.captureFlow?.collect { capture ->
if (!isEnabled.value) return@collect
eventQueue.send(ImageItemPayload(capture))
}
}

// Interactions collector
instrumentationScope.launch {
interactionSource?.captureFlow?.collect { interaction ->
if (!isEnabled.value) return@collect
eventQueue.send(InteractionItemPayload(interaction))
}
}
Expand All @@ -140,11 +147,12 @@ class ReplayInstrumentation(
*/
private fun startCaptureStateObserver() {
instrumentationScope.launch {
shouldCapture.collect { shouldRun ->
val running = captureJob?.isActive == true
if (shouldRun == running) return@collect
if (shouldRun) doRunCapture() else doPauseCapture()
}
combine(shouldCapture, isEnabled) { shouldRun, enabled -> shouldRun && enabled }
.collect { shouldRun ->
val running = captureJob?.isActive == true
if (shouldRun == running) return@collect
if (shouldRun) doRunCapture() else doPauseCapture()
}
}
}

Expand Down Expand Up @@ -175,16 +183,27 @@ class ReplayInstrumentation(
logger.debug("Session replay capture paused")
}

// TODO: O11Y-622 - implement mechanism for customer code to invoke this method
fun runCapture() {
private fun runCapture() {
shouldCapture.value = true
}

// TODO: O11Y-622 - implement mechanism for customer code to invoke this method
fun pauseCapture() {
private fun pauseCapture() {
shouldCapture.value = false
}

override fun start() {
isEnabled.value = true
flushPendingIdentify()
}
Comment thread
abelonogov-ld marked this conversation as resolved.

override fun stop() {
isEnabled.value = false
}

override fun flush() {
batchWorker.flush()
}

private fun startProcessLifecycleObserver() {
if (processLifecycleObserver != null) return

Expand Down Expand Up @@ -224,6 +243,27 @@ class ReplayInstrumentation(
processLifecycleObserver = null
}

/**
* Sends the most recent identify cached while replay was disabled.
*
* Clears the pending identify atomically to avoid races with concurrent identify calls,
* and updates its sessionId to the current session before sending.
*/
private fun flushPendingIdentify() {
if (!this::sessionManager.isInitialized) return
val exporterSnapshot = exporter ?: return

val pending = synchronized(pendingIdentifyLock) {
pendingIdentify.also { pendingIdentify = null }
} ?: return

val pendingUpdated = pending.copy(sessionId = sessionManager.getSessionId())
instrumentationScope.launch {
exporterSnapshot.sendIdentifyAndCache(pendingUpdated)
eventQueue.send(pendingUpdated)
}
}

suspend fun identifySession(
ldContext: LDContext,
timestamp: Long = System.currentTimeMillis()
Expand All @@ -242,7 +282,19 @@ class ReplayInstrumentation(
sessionId = sessionId
)

exporter?.identifyEventAndUpdate(event)
// When replay is disabled, cache the identify payload for later session init without sending it now.
if (!isEnabled.value) {
synchronized(pendingIdentifyLock) {
pendingIdentify = event
}
exporter?.cacheIdentify(event)
return
}
Comment thread
cursor[bot] marked this conversation as resolved.

synchronized(pendingIdentifyLock) {
pendingIdentify = null
}
exporter?.sendIdentifyAndCache(event)
eventQueue.send(event)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ package com.launchdarkly.observability.replay
* @property debug enables verbose logging if true as well as other debug functionality. Defaults to false.
* @property privacyProfile privacy profile that controls masking behavior
* @property capturePeriodMillis period between captures
* @property enabled controls whether session replay starts capturing immediately
*/
data class ReplayOptions(
val enabled: Boolean = true,
val debug: Boolean = false,
val privacyProfile: PrivacyProfile = PrivacyProfile(),
val capturePeriodMillis: Long = 1000, // defaults to ever 1 second
val capturePeriodMillis: Long = 1000,
// TODO O11Y-623 - Add storage options
)
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import com.launchdarkly.observability.replay.transport.EventExporting
import com.launchdarkly.observability.replay.transport.EventQueueItem
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.math.log

// size limit of accumulated continues canvas operations on the RRWeb player
private const val RRWEB_CANVAS_BUFFER_LIMIT = 10_000_000 // ~10mb
Expand Down Expand Up @@ -134,7 +133,7 @@ class SessionReplayExporter(
}
}

suspend fun identifyEventAndUpdate(newIdentifyEvent: IdentifyItemPayload) {
suspend fun sendIdentifyAndCache(newIdentifyEvent: IdentifyItemPayload) {
exportMutex.withLock {
val sessionId = newIdentifyEvent.sessionId
if (sessionId != null) {
Expand All @@ -148,6 +147,12 @@ class SessionReplayExporter(
}
}

internal suspend fun cacheIdentify(newIdentifyEvent: IdentifyItemPayload) {
exportMutex.withLock {
identifyItemPayload = newIdentifyEvent
}
}

fun nextPayloadId(): Int {
payloadIdCounter++
return payloadIdCounter
Expand Down
Loading
Loading