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
4 changes: 4 additions & 0 deletions detekt_custom_safe_calls_third_party.yml
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,9 @@ datadog:
- "java.util.concurrent.atomic.AtomicReference.compareAndSet(kotlin.String?, kotlin.String?)"
- "java.util.concurrent.atomic.AtomicReference.compareAndSet(kotlin.collections.Set?, kotlin.collections.Set?)"
- "java.util.concurrent.atomic.AtomicReference.compareAndSet(UNKNOWN, UNKNOWN)"
- "java.util.concurrent.atomic.AtomicReference.compareAndSet(com.datadog.android.sessionreplay.internal.composition.CaptureGenerationContext.State?, com.datadog.android.sessionreplay.internal.composition.CaptureGenerationContext.State?)"
- "java.util.concurrent.atomic.AtomicReference.constructor()"
- "java.util.concurrent.atomic.AtomicReference.constructor(com.datadog.android.sessionreplay.internal.composition.CaptureGenerationContext.State?)"
- "java.util.concurrent.atomic.AtomicReference.constructor(android.app.Application.ActivityLifecycleCallbacks?)"
- "java.util.concurrent.atomic.AtomicReference.constructor(com.datadog.android.api.SdkCore?)"
- "java.util.concurrent.atomic.AtomicReference.constructor(com.datadog.android.api.feature.FeatureEventReceiver?)"
Expand Down Expand Up @@ -497,6 +499,7 @@ datadog:
- "kotlin.collections.List.filterIsInstance()"
- "kotlin.collections.List.filterNot(kotlin.Function1)"
- "kotlin.collections.List.filterNotNull(kotlin.Function1)"
- "kotlin.collections.MutableSet.filterNot(kotlin.Function1)"
- "kotlin.collections.List.findFirstForType(java.lang.Class)"
- "kotlin.collections.List.firstOrNull()"
- "kotlin.collections.List.firstOrNull(kotlin.Function1)"
Expand Down Expand Up @@ -759,6 +762,7 @@ datadog:
- "kotlin.collections.listOf(com.datadog.android.sessionreplay.internal.recorder.DefaultOptionSelectorDetector)"
- "kotlin.collections.listOf(com.datadog.android.sessionreplay.material.internal.MaterialDrawableToColorMapper)"
- "kotlin.collections.listOf(com.datadog.android.sessionreplay.material.internal.MaterialOptionSelectorDetector)"
- "kotlin.collections.listOf(com.datadog.android.sessionreplay.model.MobileSegment.MobileRecord.MobileFullSnapshotRecord)"
- "kotlin.collections.listOf(com.datadog.android.sessionreplay.model.MobileSegment.MobileRecord.ViewEndRecord)"
- "kotlin.collections.listOf(com.datadog.android.sessionreplay.model.MobileSegment.Wireframe)"
- "kotlin.collections.listOf(com.datadog.android.sessionreplay.model.MobileSegment.Wireframe.PlaceholderWireframe)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,9 @@ data class SessionReplayConfiguration internal constructor(
*
* Disabled by default.
*
* Heatmap recording ([setHeatmapsEnabled]) is not supported by this pipeline yet; combining
* the two will log a warning and no heatmap data will be recorded.
*
* @param enabled whether composition-tree recording should be used.
*/
@ExperimentalSessionReplayApi
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,36 @@ import android.widget.SeekBar
import android.widget.TextView
import androidx.appcompat.widget.ActionBarContainer
import androidx.appcompat.widget.SwitchCompat
import com.datadog.android.api.InternalLogger
import com.datadog.android.api.feature.FeatureSdkCore
import com.datadog.android.internal.utils.ImageViewUtils
import com.datadog.android.sessionreplay.ImagePrivacy
import com.datadog.android.sessionreplay.MapperTypeWrapper
import com.datadog.android.sessionreplay.SessionReplayInternalCallback
import com.datadog.android.sessionreplay.TextAndInputPrivacy
import com.datadog.android.sessionreplay.internal.composition.ActiveWindowSource
import com.datadog.android.sessionreplay.internal.composition.AndroidSnapshotCaptureLifecycle
import com.datadog.android.sessionreplay.internal.composition.CapturePipelineSelector
import com.datadog.android.sessionreplay.internal.composition.CaptureSkippedFrameNotifier
import com.datadog.android.sessionreplay.internal.composition.CaptureTimeBudget
import com.datadog.android.sessionreplay.internal.composition.CapturedSnapshotProducer
import com.datadog.android.sessionreplay.internal.composition.CompositionCapturePipeline
import com.datadog.android.sessionreplay.internal.composition.CompositionChangeListener
import com.datadog.android.sessionreplay.internal.composition.CompositionChangeset
import com.datadog.android.sessionreplay.internal.composition.CompositionViewOnDrawInterceptor
import com.datadog.android.sessionreplay.internal.composition.DefaultSnapshotCompletionProcessor
import com.datadog.android.sessionreplay.internal.composition.HandlerCaptureMainThreadExecutor
import com.datadog.android.sessionreplay.internal.composition.HandlerCaptureTaskScheduler
import com.datadog.android.sessionreplay.internal.composition.ImmediateCapturedSnapshotProcessor
import com.datadog.android.sessionreplay.internal.composition.ScheduledExecutorCaptureTaskScheduler
import com.datadog.android.sessionreplay.internal.composition.SnapshotCaptureOrchestrator
import com.datadog.android.sessionreplay.internal.composition.SnapshotCompletionQueue
import com.datadog.android.sessionreplay.internal.composition.TimeBankCaptureTimeBudget
import com.datadog.android.sessionreplay.internal.composition.TimeProviderCaptureTimeProvider
import com.datadog.android.sessionreplay.internal.recorder.Recorder
import com.datadog.android.sessionreplay.internal.recorder.RecordingTimeBank
import com.datadog.android.sessionreplay.internal.recorder.SessionReplayRecorder
import com.datadog.android.sessionreplay.internal.recorder.TimeBank
import com.datadog.android.sessionreplay.internal.recorder.mapper.ActionBarContainerMapper
import com.datadog.android.sessionreplay.internal.recorder.mapper.ButtonMapper
import com.datadog.android.sessionreplay.internal.recorder.mapper.CheckBoxMapper
Expand Down Expand Up @@ -71,7 +91,11 @@ internal class DefaultRecorderProvider(
private val internalCallback: SessionReplayInternalCallback,
private val heatmapsEnabled: Boolean,
private val compositionTreeRecordingEnabled: Boolean,
private val compositionPipelineFactory: () -> Recorder = { CompositionCapturePipeline() }
private val compositionPipelineFactory: (() -> Recorder)? = null,
private val compositionSnapshotProducerFactory: (ActiveWindowSource) -> CapturedSnapshotProducer = {
CapturedSnapshotProducer { _, _ -> null }
},
private val recordingTimeBankFactory: () -> TimeBank = { RecordingTimeBank() }
) : RecorderProvider {

override fun provideSessionReplayRecorder(
Expand All @@ -81,10 +105,23 @@ internal class DefaultRecorderProvider(
rumContextProvider: RumContextProvider,
application: Application
): Recorder {
if (heatmapsEnabled && compositionTreeRecordingEnabled) {
sdkCore.internalLogger.log(
InternalLogger.Level.WARN,
InternalLogger.Target.USER,
{ HEATMAPS_UNSUPPORTED_WITH_COMPOSITION_RECORDING_MESSAGE }
)
}
val heatmapIdentifierRegistry = if (heatmapsEnabled) LazyHeatmapIdentifierRegistry(sdkCore) else null
return CapturePipelineSelector(
compositionEnabled = compositionTreeRecordingEnabled,
compositionFactory = compositionPipelineFactory,
compositionFactory = {
compositionPipelineFactory?.invoke() ?: createCompositionPipeline(
recordWriter,
rumContextProvider,
application
)
},
legacyFactory = {
SessionReplayRecorder(
application,
Expand All @@ -108,6 +145,64 @@ internal class DefaultRecorderProvider(
).create()
}

private fun createCompositionPipeline(
recordWriter: RecordWriter,
rumContextProvider: RumContextProvider,
application: Application
): Recorder {
val internalLogger = sdkCore.internalLogger
val timeProvider = TimeProviderCaptureTimeProvider(sdkCore.timeProvider)
val skippedFrameNotifier = CaptureSkippedFrameNotifier(sdkCore)
val windowSource = ActiveWindowSource()
val completionQueue = SnapshotCompletionQueue(
executorService = sdkCore.createSingleThreadExecutorService("sr-composition-processing"),
processor = DefaultSnapshotCompletionProcessor(
rumContextProvider = rumContextProvider,
recordWriter = recordWriter,
internalLogger = internalLogger
),
internalLogger = internalLogger
)
val orchestrator = SnapshotCaptureOrchestrator(
producer = compositionSnapshotProducerFactory(windowSource),
processor = ImmediateCapturedSnapshotProcessor(),
consumer = completionQueue,
timeProvider = timeProvider,
captureScheduler = HandlerCaptureTaskScheduler(),
mainThreadExecutor = HandlerCaptureMainThreadExecutor(),
expiryScheduler = ScheduledExecutorCaptureTaskScheduler(
executorService = sdkCore.createScheduledExecutorService("sr-composition-expiry"),
internalLogger = internalLogger
),
timeBudget = if (dynamicOptimizationEnabled) {
TimeBankCaptureTimeBudget(
recordingTimeBankFactory(),
skippedFrameNotifier::notifySkippedFrame
)
} else {
CaptureTimeBudget.UNLIMITED
},
internalLogger = internalLogger
)
val interceptor = CompositionViewOnDrawInterceptor(
windowSource = windowSource,
onWindowsChanged = CompositionChangeListener { windows ->
orchestrator.requestCapture(CompositionChangeset.of(windows))
},
internalLogger = internalLogger
)
return CompositionCapturePipeline(
orchestrator = orchestrator,
lifecycle = AndroidSnapshotCaptureLifecycle(
application = application,
interceptor = interceptor,
internalLogger = internalLogger,
currentActivity = internalCallback.getCurrentActivity()
),
completionQueue = completionQueue
)
}

@Suppress("LongMethod")
private fun builtInMappers(): List<MapperTypeWrapper<*>> {
val viewIdentifierResolver: ViewIdentifierResolver = DefaultViewIdentifierResolver
Expand Down Expand Up @@ -265,4 +360,10 @@ internal class DefaultRecorderProvider(
null
}
}

internal companion object {
internal const val HEATMAPS_UNSUPPORTED_WITH_COMPOSITION_RECORDING_MESSAGE = "Heatmaps are not " +
"supported by the composition-tree recording pipeline yet. No heatmap data will be recorded " +
"for this session."
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2016-Present Datadog, Inc.
*/

package com.datadog.android.sessionreplay.internal.composition

import android.os.Handler
import android.os.Looper
import com.datadog.android.api.InternalLogger
import com.datadog.android.core.internal.utils.scheduleSafe
import com.datadog.android.internal.time.TimeProvider
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit

internal class HandlerCaptureMainThreadExecutor(
private val handler: Handler = Handler(Looper.getMainLooper())
) : CaptureMainThreadExecutor {
override fun execute(task: () -> Unit): CancellableCaptureWork {
val runnable = Runnable(task)
return if (handler.post(runnable)) {
CancellableCaptureWork { handler.removeCallbacks(runnable) }
} else {
CancellableCaptureWork.NONE
}
}
}

internal class HandlerCaptureTaskScheduler(
private val handler: Handler = Handler(Looper.getMainLooper())
) : CaptureTaskScheduler {
override fun schedule(delayNs: Long, task: () -> Unit): CancellableCaptureWork {
val runnable = Runnable(task)
val posted = if (delayNs <= 0) {
handler.post(runnable)
} else {
handler.postDelayed(runnable, delayNs.toDelayMillis())
}
return if (posted) {
CancellableCaptureWork { handler.removeCallbacks(runnable) }
} else {
CancellableCaptureWork.NONE
}
}

private fun Long.toDelayMillis(): Long {
val delayMs = TimeUnit.NANOSECONDS.toMillis(this)
return if (delayMs < 1L) 1L else delayMs
}
}

/** Expiry runs off the main looper so asynchronous generations can time out while UI is busy. */
internal class ScheduledExecutorCaptureTaskScheduler(
private val executorService: ScheduledExecutorService,
private val internalLogger: InternalLogger
) : CaptureTaskScheduler {
override fun schedule(delayNs: Long, task: () -> Unit): CancellableCaptureWork {
val future = executorService.scheduleSafe(
EXPIRY_CONTEXT,
delayNs,
TimeUnit.NANOSECONDS,
internalLogger,
Runnable(task)
) ?: return CancellableCaptureWork.NONE
return CancellableCaptureWork { future.cancel(false) }
}

override fun shutdown() {
executorService.shutdownNow()
}

private companion object {
const val EXPIRY_CONTEXT = "Session Replay composition generation expiry"
}
}

internal class TimeProviderCaptureTimeProvider(
private val timeProvider: TimeProvider
) : CaptureTimeProvider {
override fun elapsedRealtimeNanos(): Long = timeProvider.getDeviceElapsedTimeNanos()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2016-Present Datadog, Inc.
*/

package com.datadog.android.sessionreplay.internal.composition

import android.app.Activity
import android.app.Application
import android.os.Handler
import android.os.Looper
import android.view.View
import android.view.Window
import androidx.annotation.MainThread
import com.datadog.android.api.InternalLogger
import com.datadog.android.sessionreplay.internal.SessionReplayLifecycleCallback
import com.datadog.android.sessionreplay.internal.recorder.WindowInspector
import com.datadog.android.sessionreplay.internal.recorder.callback.OnWindowRefreshedCallback

internal interface CompositionCaptureLifecycle {
fun registerCallbacks()
fun unregisterCallbacks()
fun start()
fun stop()
}

internal class AndroidSnapshotCaptureLifecycle(
private val application: Application,
private val interceptor: CompositionViewOnDrawInterceptor,
private val internalLogger: InternalLogger,
currentActivity: Activity? = null,
private val uiHandler: Handler = Handler(Looper.getMainLooper()),
private val windowProvider: () -> List<View> = {
WindowInspector.getGlobalWindowViews(internalLogger)
}
) : CompositionCaptureLifecycle, OnWindowRefreshedCallback {
private val lifecycleCallback = SessionReplayLifecycleCallback(this)
private var isRunning = false

init {
currentActivity?.let {
lifecycleCallback.setCurrentWindow(it)
lifecycleCallback.registerFragmentLifecycleCallbacks(it)
}
}

override fun registerCallbacks() {
application.registerActivityLifecycleCallbacks(lifecycleCallback)
}

override fun unregisterCallbacks() {
application.unregisterActivityLifecycleCallbacks(lifecycleCallback)
}

@Suppress("ThreadSafety") // Handler posts this block onto the main looper.
override fun start() {
uiHandler.post {
isRunning = true
interceptor.intercept(windowProvider())
}
}

@Suppress("ThreadSafety") // Handler posts this block onto the main looper.
override fun stop() {
uiHandler.post {
isRunning = false
interceptor.stop()
}
}

@MainThread
override fun onWindowsAdded(windows: List<Window>) = refreshWindows()

@MainThread
override fun onWindowsRemoved(windows: List<Window>) = refreshWindows()

@MainThread
private fun refreshWindows() {
if (isRunning) interceptor.intercept(windowProvider())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2016-Present Datadog, Inc.
*/

package com.datadog.android.sessionreplay.internal.composition

internal fun interface CancellableCaptureWork {
fun cancel()

companion object {
val NONE = CancellableCaptureWork {}
}
}
Loading
Loading