Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SR] Add SessionReplayOptions #3283

Merged
merged 11 commits into from
Apr 3, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,7 @@ internal class ReplayCache(
private val encoderCreator: (File) -> SimpleVideoEncoder = { videoFile ->
SimpleVideoEncoder(
options,
MuxerConfig(
file = videoFile,
recorderConfig = recorderConfig,
frameRate = recorderConfig.frameRate.toFloat(),
bitrate = 20 * 1000
)
MuxerConfig(file = videoFile, recorderConfig = recorderConfig)
).also { it.start() }
}
) : Closeable {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,9 @@ package io.sentry.android.replay

import android.content.Context
import android.graphics.Bitmap
import android.graphics.Point
import android.graphics.Rect
import android.os.Build
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import android.view.WindowManager
import io.sentry.DateUtils
import io.sentry.Hint
import io.sentry.IHub
Expand All @@ -33,7 +30,6 @@ import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import kotlin.LazyThreadSafetyMode.NONE
import kotlin.math.roundToInt

class ReplayIntegration(
private val context: Context,
Expand All @@ -59,28 +55,11 @@ class ReplayIntegration(
private val saver =
Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory())

private val screenBounds by lazy(NONE) {
// PixelCopy takes screenshots including system bars, so we have to get the real size here
val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
if (VERSION.SDK_INT >= VERSION_CODES.R) {
wm.currentWindowMetrics.bounds
} else {
val screenBounds = Point()
@Suppress("DEPRECATION")
wm.defaultDisplay.getRealSize(screenBounds)
Rect(0, 0, screenBounds.x, screenBounds.y)
}
}

private val aspectRatio by lazy(NONE) {
screenBounds.bottom.toFloat() / screenBounds.right.toFloat()
}

private val recorderConfig by lazy(NONE) {
ScreenshotRecorderConfig(
recordingWidth = (720 / aspectRatio).roundToInt(),
recordingHeight = 720,
scaleFactor = 720f / screenBounds.bottom
ScreenshotRecorderConfig.from(
context,
targetHeight = 720,
options._experimental.sessionReplayOptions
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,34 @@
package io.sentry.android.replay

import android.annotation.TargetApi
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Bitmap.Config.ARGB_8888
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Point
import android.graphics.Rect
import android.graphics.RectF
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import android.os.Handler
import android.os.HandlerThread
import android.os.Looper
import android.view.PixelCopy
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.view.WindowManager
import io.sentry.SentryLevel.DEBUG
import io.sentry.SentryLevel.INFO
import io.sentry.SentryOptions
import io.sentry.SessionReplayOptions
import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode
import java.lang.ref.WeakReference
import java.util.WeakHashMap
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.roundToInt
import kotlin.system.measureTimeMillis

@TargetApi(26)
Expand Down Expand Up @@ -217,8 +224,33 @@ internal data class ScreenshotRecorderConfig(
val recordingWidth: Int,
val recordingHeight: Int,
val scaleFactor: Float,
val frameRate: Int = 2
)
val frameRate: Int,
val bitRate: Int
) {
companion object {
fun from(context: Context, targetHeight: Int, sessionReplayOptions: SessionReplayOptions): ScreenshotRecorderConfig {
// PixelCopy takes screenshots including system bars, so we have to get the real size here
val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
val screenBounds = if (VERSION.SDK_INT >= VERSION_CODES.R) {
wm.currentWindowMetrics.bounds
} else {
val screenBounds = Point()
@Suppress("DEPRECATION")
wm.defaultDisplay.getRealSize(screenBounds)
Rect(0, 0, screenBounds.x, screenBounds.y)
}
val aspectRatio = screenBounds.bottom.toFloat() / screenBounds.right.toFloat()

return ScreenshotRecorderConfig(
recordingWidth = (targetHeight / aspectRatio).roundToInt(),
recordingHeight = targetHeight,
scaleFactor = targetHeight.toFloat() / screenBounds.bottom,
frameRate = sessionReplayOptions.frameRate,
bitRate = sessionReplayOptions.bitRate
)
}
}
}

interface ScreenshotRecorderCallback {
fun onScreenshotRecorded(bitmap: Bitmap)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ internal class SimpleVideoEncoder(
MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
)
format.setInteger(MediaFormat.KEY_BIT_RATE, muxerConfig.bitrate)
format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.frameRate)
format.setInteger(MediaFormat.KEY_BIT_RATE, muxerConfig.recorderConfig.bitRate)
format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.recorderConfig.frameRate.toFloat())
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 10)

format
Expand All @@ -79,7 +79,7 @@ internal class SimpleVideoEncoder(
}

private val bufferInfo: MediaCodec.BufferInfo = MediaCodec.BufferInfo()
private val frameMuxer = muxerConfig.frameMuxer
private val frameMuxer = SimpleMp4FrameMuxer(muxerConfig.file.absolutePath, muxerConfig.recorderConfig.frameRate.toFloat())
val duration get() = frameMuxer.getVideoTime()

private var surface: Surface? = null
Expand Down Expand Up @@ -187,8 +187,5 @@ internal class SimpleVideoEncoder(
internal data class MuxerConfig(
val file: File,
val recorderConfig: ScreenshotRecorderConfig,
val bitrate: Int = 20_000,
val frameRate: Float = recorderConfig.frameRate.toFloat(),
val mimeType: String = MediaFormat.MIMETYPE_VIDEO_AVC,
val frameMuxer: SimpleFrameMuxer = SimpleMp4FrameMuxer(file.absolutePath, frameRate)
val mimeType: String = MediaFormat.MIMETYPE_VIDEO_AVC
)
20 changes: 20 additions & 0 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,12 @@ public abstract interface class io/sentry/EventProcessor {
public fun process (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryTransaction;
}

public final class io/sentry/ExperimentalOptions {
public fun <init> ()V
public fun getSessionReplayOptions ()Lio/sentry/SessionReplayOptions;
public fun setSessionReplayOptions (Lio/sentry/SessionReplayOptions;)V
}

public final class io/sentry/ExternalOptions {
public fun <init> ()V
public fun addBundleId (Ljava/lang/String;)V
Expand Down Expand Up @@ -2315,6 +2321,7 @@ public class io/sentry/SentryOptions {
public fun getTransportFactory ()Lio/sentry/ITransportFactory;
public fun getTransportGate ()Lio/sentry/transport/ITransportGate;
public final fun getViewHierarchyExporters ()Ljava/util/List;
public fun get_experimental ()Lio/sentry/ExperimentalOptions;
public fun isAttachServerName ()Z
public fun isAttachStacktrace ()Z
public fun isAttachThreads ()Z
Expand Down Expand Up @@ -2686,6 +2693,19 @@ public final class io/sentry/Session$State : java/lang/Enum {
public static fun values ()[Lio/sentry/Session$State;
}

public final class io/sentry/SessionReplayOptions {
public fun <init> ()V
public fun <init> (Ljava/lang/Double;Ljava/lang/Double;)V
public fun getBitRate ()I
public fun getErrorReplayDuration ()J
public fun getErrorSampleRate ()Ljava/lang/Double;
public fun getFrameRate ()I
public fun getSessionSampleRate ()Ljava/lang/Double;
public fun getSessionSegmentDuration ()J
public fun setErrorSampleRate (Ljava/lang/Double;)V
public fun setSessionSampleRate (Ljava/lang/Double;)V
}

public final class io/sentry/ShutdownHookIntegration : io/sentry/Integration, java/io/Closeable {
public fun <init> ()V
public fun <init> (Ljava/lang/Runtime;)V
Expand Down
16 changes: 16 additions & 0 deletions sentry/src/main/java/io/sentry/ExperimentalOptions.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package io.sentry;

import org.jetbrains.annotations.NotNull;

public final class ExperimentalOptions {
private @NotNull SessionReplayOptions sessionReplayOptions = new SessionReplayOptions();

@NotNull
public SessionReplayOptions getSessionReplayOptions() {
return sessionReplayOptions;
}

public void setSessionReplayOptions(final @NotNull SessionReplayOptions sessionReplayOptions) {
this.sessionReplayOptions = sessionReplayOptions;
}
}
7 changes: 7 additions & 0 deletions sentry/src/main/java/io/sentry/SentryOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,8 @@ public class SentryOptions {
*/
private int profilingTracesHz = 101;

private final @NotNull ExperimentalOptions _experimental = new ExperimentalOptions();

/**
* Adds an event processor
*
Expand Down Expand Up @@ -2274,6 +2276,11 @@ public void setSessionFlushTimeoutMillis(final long sessionFlushTimeoutMillis) {
this.sessionFlushTimeoutMillis = sessionFlushTimeoutMillis;
}

@NotNull
public ExperimentalOptions get_experimental() {
return _experimental;
}

/** The BeforeSend callback */
public interface BeforeSendCallback {

Expand Down
85 changes: 85 additions & 0 deletions sentry/src/main/java/io/sentry/SessionReplayOptions.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package io.sentry;

import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.Nullable;

public final class SessionReplayOptions {

/**
* Indicates the percentage in which the replay for the session will be created. Specifying 0
* means never, 1.0 means always. The value needs to be >= 0.0 and <= 1.0 The default is null
* (disabled).
*/
private @Nullable Double sessionSampleRate;

/**
* Indicates the percentage in which a 30 seconds replay will be send with error events.
* Specifying 0 means never, 1.0 means always. The value needs to be >= 0.0 and <= 1.0. The
* default is null (disabled).
*/
private @Nullable Double errorSampleRate;

/**
* Defines the quality of the session replay. Higher bit rates have better replay quality, but
* also affect the final payload size to transfer. The default value is 20kbps;
*/
private int bitRate = 20_000;

/**
* Number of frames per second of the replay. The bigger the number, the more accurate the replay
* will be, but also more data to transfer and more CPU load.
*/
private int frameRate = 1;

/** The maximum duration of replays for error events. */
private long errorReplayDuration = 30_000L;

/** The maximum duration of the segment of a session replay. */
private long sessionSegmentDuration = 5000L;

public SessionReplayOptions() {}

public SessionReplayOptions(
final @Nullable Double sessionSampleRate, final @Nullable Double errorSampleRate) {
this.sessionSampleRate = sessionSampleRate;
this.errorSampleRate = errorSampleRate;
}

@Nullable
public Double getErrorSampleRate() {
return errorSampleRate;
}

public void setErrorSampleRate(final @Nullable Double errorSampleRate) {
this.errorSampleRate = errorSampleRate;
}

@Nullable
public Double getSessionSampleRate() {
return sessionSampleRate;
}

public void setSessionSampleRate(final @Nullable Double sessionSampleRate) {
this.sessionSampleRate = sessionSampleRate;
}

@ApiStatus.Internal
public int getBitRate() {
return bitRate;
}

@ApiStatus.Internal
public int getFrameRate() {
return frameRate;
}

@ApiStatus.Internal
public long getErrorReplayDuration() {
return errorReplayDuration;
}

@ApiStatus.Internal
public long getSessionSegmentDuration() {
return sessionSegmentDuration;
}
}
Loading