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] GA Session replay #4017

Merged
merged 33 commits into from
Jan 2, 2025
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
07b758b
Get rid of the lock on touch events
romtsn Dec 17, 2024
e3df539
pre-allocate some things for gesture converter
romtsn Dec 17, 2024
fe15278
have one less thread switch for re]play
romtsn Dec 18, 2024
c65f9b6
update
romtsn Dec 18, 2024
14e8155
Merge branch 'main' into rz/fix/session-replay-anr-ontouchevent
romtsn Dec 18, 2024
bc2e9d6
Changelog
romtsn Dec 18, 2024
fb832b2
Add option to disable orientation change tracking for session replay
romtsn Dec 19, 2024
55dbeec
Make RecorderConfig lazier
romtsn Dec 20, 2024
01b5a88
Fix tests
romtsn Dec 20, 2024
e963707
Changelog
romtsn Dec 20, 2024
c48094f
Allow overriding SdkVersion for replay events only
romtsn Dec 30, 2024
94586a7
Send replay recording options
romtsn Dec 30, 2024
26ccb69
Merge branch 'main' into rz/feat/session-replay-override-sdk-version
romtsn Dec 30, 2024
870fdd8
Changelog
romtsn Dec 30, 2024
fdcae96
Merge branch 'rz/feat/session-replay-override-sdk-version' into rz/fe…
romtsn Dec 30, 2024
cba3c72
Changelog
romtsn Dec 30, 2024
4bf8acd
Add a comment
romtsn Dec 30, 2024
8869e77
Merge branch 'rz/feat/session-replay-override-sdk-version' into rz/fe…
romtsn Dec 30, 2024
35313ba
Add a test
romtsn Dec 30, 2024
fa37ca3
Change joinToString to serializable list for options
romtsn Dec 30, 2024
a5f1ba5
Reduce heap allocations in screenshot recorder
romtsn Jan 2, 2025
c709c50
Formatting
romtsn Jan 2, 2025
cd14384
Add test
romtsn Jan 2, 2025
fa3ae2e
Merge branch 'main' into rz/fix/delete-corrupted-frames
romtsn Jan 2, 2025
179088d
Fix
romtsn Jan 2, 2025
036e373
Changelog
romtsn Jan 2, 2025
d812f11
GA Session replay
romtsn Jan 2, 2025
6649e9b
Format code
getsentry-bot Jan 2, 2025
c468058
Changelog
romtsn Jan 2, 2025
c6257b8
Api dump
romtsn Jan 2, 2025
6805ced
Update CHANGELOG.md
romtsn Jan 2, 2025
665d47e
Update CHANGELOG.md
romtsn Jan 2, 2025
78746fb
Merge branch 'main' into rz/feat/session-replay-ga
romtsn Jan 2, 2025
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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,45 @@

## Unreleased

### Features

- Session Replay GA ([#4017](https://github.com/getsentry/sentry-java/pull/4017))

To enable Replay use the `sessionReplay.sessionSampleRate` or `sessionReplay.errorSampleRate` options.
romtsn marked this conversation as resolved.
Show resolved Hide resolved

```kotlin
import io.sentry.SentryReplayOptions
import io.sentry.android.core.SentryAndroid

SentryAndroid.init(context) { options ->

options.sessionReplay.sessionSampleRate = 1.0
options.sessionReplay.errorSampleRate = 1.0
romtsn marked this conversation as resolved.
Show resolved Hide resolved

// To change default redaction behavior (defaults to true)
options.sessionReplay.redactAllImages = true
options.sessionReplay.redactAllText = true

// To change quality of the recording (defaults to MEDIUM)
options.sessionReplay.quality = SentryReplayOptions.SentryReplayQuality.MEDIUM // (LOW|MEDIUM|HIGH)
}
```

### Fixes

- Fix warm start detection ([#3937](https://github.com/getsentry/sentry-java/pull/3937))
- Session Replay: Reduce memory allocations, disk space consumption, and payload size ([#4016](https://github.com/getsentry/sentry-java/pull/4016))
- Session Replay: Do not try to encode corrupted frames multiple times ([#4016](https://github.com/getsentry/sentry-java/pull/4016))

### Internal

- Session Replay: Allow overriding `SdkVersion` for replay events ([#4014](https://github.com/getsentry/sentry-java/pull/4014))
- Session Replay: Send replay options as tags ([#4015](https://github.com/getsentry/sentry-java/pull/4015))

### Breaking changes

- Session Replay options were moved from under `experimental` to the main `options` object ([#4017](https://github.com/getsentry/sentry-java/pull/4017))

## 7.19.1

### Fixes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -399,28 +399,26 @@ static void applyMetadata(
options.setEnableMetrics(
readBool(metadata, logger, ENABLE_METRICS, options.isEnableMetrics()));

if (options.getExperimental().getSessionReplay().getSessionSampleRate() == null) {
if (options.getSessionReplay().getSessionSampleRate() == null) {
final Double sessionSampleRate =
readDouble(metadata, logger, REPLAYS_SESSION_SAMPLE_RATE);
if (sessionSampleRate != -1) {
options.getExperimental().getSessionReplay().setSessionSampleRate(sessionSampleRate);
options.getSessionReplay().setSessionSampleRate(sessionSampleRate);
}
}

if (options.getExperimental().getSessionReplay().getOnErrorSampleRate() == null) {
if (options.getSessionReplay().getOnErrorSampleRate() == null) {
final Double onErrorSampleRate = readDouble(metadata, logger, REPLAYS_ERROR_SAMPLE_RATE);
if (onErrorSampleRate != -1) {
options.getExperimental().getSessionReplay().setOnErrorSampleRate(onErrorSampleRate);
options.getSessionReplay().setOnErrorSampleRate(onErrorSampleRate);
}
}

options
.getExperimental()
.getSessionReplay()
.setMaskAllText(readBool(metadata, logger, REPLAYS_MASK_ALL_TEXT, true));

options
.getExperimental()
.getSessionReplay()
.setMaskAllImages(readBool(metadata, logger, REPLAYS_MASK_ALL_IMAGES, true));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1459,22 +1459,22 @@ class ManifestMetadataReaderTest {
ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider)

// Assert
assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.onErrorSampleRate)
assertEquals(expectedSampleRate.toDouble(), fixture.options.sessionReplay.onErrorSampleRate)
}

@Test
fun `applyMetadata does not override replays onErrorSampleRate from options`() {
// Arrange
val expectedSampleRate = 0.99f
fixture.options.experimental.sessionReplay.onErrorSampleRate = expectedSampleRate.toDouble()
fixture.options.sessionReplay.onErrorSampleRate = expectedSampleRate.toDouble()
val bundle = bundleOf(ManifestMetadataReader.REPLAYS_ERROR_SAMPLE_RATE to 0.1f)
val context = fixture.getContext(metaData = bundle)

// Act
ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider)

// Assert
assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.onErrorSampleRate)
assertEquals(expectedSampleRate.toDouble(), fixture.options.sessionReplay.onErrorSampleRate)
}

@Test
Expand All @@ -1486,7 +1486,7 @@ class ManifestMetadataReaderTest {
ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider)

// Assert
assertNull(fixture.options.experimental.sessionReplay.onErrorSampleRate)
assertNull(fixture.options.sessionReplay.onErrorSampleRate)
}

@Test
Expand All @@ -1499,8 +1499,8 @@ class ManifestMetadataReaderTest {
ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider)

// Assert
assertTrue(fixture.options.experimental.sessionReplay.unmaskViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME))
assertTrue(fixture.options.experimental.sessionReplay.unmaskViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME))
assertTrue(fixture.options.sessionReplay.unmaskViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME))
assertTrue(fixture.options.sessionReplay.unmaskViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME))
}

@Test
Expand All @@ -1512,8 +1512,8 @@ class ManifestMetadataReaderTest {
ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider)

// Assert
assertTrue(fixture.options.experimental.sessionReplay.maskViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME))
assertTrue(fixture.options.experimental.sessionReplay.maskViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME))
assertTrue(fixture.options.sessionReplay.maskViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME))
assertTrue(fixture.options.sessionReplay.maskViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME))
}

@Test
Expand Down Expand Up @@ -1562,7 +1562,7 @@ class ManifestMetadataReaderTest {
assertEquals(expectedSampleRate.toDouble(), fixture.options.sampleRate)
assertEquals(expectedSampleRate.toDouble(), fixture.options.tracesSampleRate)
assertEquals(expectedSampleRate.toDouble(), fixture.options.profilesSampleRate)
assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.sessionSampleRate)
assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.onErrorSampleRate)
assertEquals(expectedSampleRate.toDouble(), fixture.options.sessionReplay.sessionSampleRate)
assertEquals(expectedSampleRate.toDouble(), fixture.options.sessionReplay.onErrorSampleRate)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ class SentryAndroidTest {
options.release = "prod"
options.dsn = "https://[email protected]/123"
options.isEnableAutoSessionTracking = true
options.experimental.sessionReplay.onErrorSampleRate = 1.0
options.sessionReplay.onErrorSampleRate = 1.0
optionsConfig(options)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ class ReplayTest : BaseUiTest() {
activityScenario.moveToState(Lifecycle.State.RESUMED)

initSentry {
it.experimental.sessionReplay.sessionSampleRate = 1.0
it.sessionReplay.sessionSampleRate = 1.0

it.beforeSendReplay =
SentryOptions.BeforeSendReplayCallback { event, _ ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public class ReplayCache(
it.createNewFile()
}
screenshot.outputStream().use {
bitmap.compress(JPEG, 80, it)
bitmap.compress(JPEG, options.sessionReplay.quality.screenshotQuality, it)
it.flush()
}

Expand Down Expand Up @@ -162,7 +162,7 @@ public class ReplayCache(

val step = 1000 / frameRate.toLong()
var frameCount = 0
var lastFrame: ReplayFrame = frames.first()
var lastFrame: ReplayFrame? = frames.first()
for (timestamp in from until (from + (duration)) step step) {
val iter = frames.iterator()
while (iter.hasNext()) {
Expand All @@ -182,6 +182,12 @@ public class ReplayCache(
// to respect the video duration
if (encode(lastFrame)) {
frameCount++
} else if (lastFrame != null) {
// if we failed to encode the frame, we delete the screenshot right away as the
// likelihood of it being able to be encoded later is low
deleteFile(lastFrame.screenshot)
frames.remove(lastFrame)
lastFrame = null
}
}

Expand All @@ -206,7 +212,10 @@ public class ReplayCache(
return GeneratedVideo(videoFile, frameCount, videoDuration)
}

private fun encode(frame: ReplayFrame): Boolean {
private fun encode(frame: ReplayFrame?): Boolean {
if (frame == null) {
return false
}
return try {
val bitmap = BitmapFactory.decodeFile(frame.screenshot.absolutePath)
synchronized(encoderLock) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,8 @@ public class ReplayIntegration(
return
}

if (!options.experimental.sessionReplay.isSessionReplayEnabled &&
!options.experimental.sessionReplay.isSessionReplayForErrorsEnabled
if (!options.sessionReplay.isSessionReplayEnabled &&
!options.sessionReplay.isSessionReplayForErrorsEnabled
) {
options.logger.log(INFO, "Session replay is disabled, no sample rate specified")
return
Expand All @@ -132,7 +132,7 @@ public class ReplayIntegration(

options.connectionStatusProvider.addConnectionStatusObserver(this)
hub.rateLimiter?.addRateLimitObserver(this)
if (options.experimental.sessionReplay.isTrackOrientationChange) {
if (options.sessionReplay.isTrackOrientationChange) {
try {
context.registerComponentCallbacks(this)
} catch (e: Throwable) {
Expand Down Expand Up @@ -167,13 +167,13 @@ public class ReplayIntegration(
return
}

val isFullSession = random.sample(options.experimental.sessionReplay.sessionSampleRate)
if (!isFullSession && !options.experimental.sessionReplay.isSessionReplayForErrorsEnabled) {
val isFullSession = random.sample(options.sessionReplay.sessionSampleRate)
if (!isFullSession && !options.sessionReplay.isSessionReplayForErrorsEnabled) {
options.logger.log(INFO, "Session replay is not started, full session was not sampled and onErrorSampleRate is not specified")
return
}

val recorderConfig = recorderConfigProvider?.invoke(false) ?: ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay)
val recorderConfig = recorderConfigProvider?.invoke(false) ?: ScreenshotRecorderConfig.from(context, options.sessionReplay)
captureStrategy = replayCaptureStrategyProvider?.invoke(isFullSession) ?: if (isFullSession) {
SessionCaptureStrategy(options, hub, dateProvider, replayExecutor, replayCacheProvider)
} else {
Expand Down Expand Up @@ -264,7 +264,7 @@ public class ReplayIntegration(

options.connectionStatusProvider.removeConnectionStatusObserver(this)
hub?.rateLimiter?.removeRateLimitObserver(this)
if (options.experimental.sessionReplay.isTrackOrientationChange) {
if (options.sessionReplay.isTrackOrientationChange) {
try {
context.unregisterComponentCallbacks(this)
} catch (ignored: Throwable) {
Expand All @@ -285,7 +285,7 @@ public class ReplayIntegration(
recorder?.stop()

// refresh config based on new device configuration
val recorderConfig = recorderConfigProvider?.invoke(true) ?: ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay)
val recorderConfig = recorderConfigProvider?.invoke(true) ?: ScreenshotRecorderConfig.from(context, options.sessionReplay)
captureStrategy?.onConfigurationChanged(recorderConfig)

recorder?.start(recorderConfig)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ 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.Color
import android.graphics.Matrix
Expand Down Expand Up @@ -54,9 +53,14 @@ internal class ScreenshotRecorder(
Bitmap.createBitmap(
1,
1,
Bitmap.Config.ARGB_8888
Bitmap.Config.RGB_565
)
}
private val screenshot = Bitmap.createBitmap(
config.recordingWidth,
config.recordingHeight,
Bitmap.Config.RGB_565
)
private val singlePixelBitmapCanvas: Canvas by lazy(NONE) { Canvas(singlePixelBitmap) }
private val prescaledMatrix by lazy(NONE) {
Matrix().apply {
Expand All @@ -65,22 +69,18 @@ internal class ScreenshotRecorder(
}
private val contentChanged = AtomicBoolean(false)
private val isCapturing = AtomicBoolean(true)
private var lastScreenshot: Bitmap? = null
private val lastCaptureSuccessful = AtomicBoolean(false)

fun capture() {
if (!isCapturing.get()) {
options.logger.log(DEBUG, "ScreenshotRecorder is paused, not capturing screenshot")
return
}

if (!contentChanged.get() && lastScreenshot != null && !lastScreenshot!!.isRecycled) {
if (!contentChanged.get() && lastCaptureSuccessful.get()) {
options.logger.log(DEBUG, "Content hasn't changed, repeating last known frame")

lastScreenshot?.let {
screenshotRecorderCallback?.onScreenshotRecorded(
it.copy(ARGB_8888, false)
)
}
screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
return
}

Expand All @@ -96,38 +96,33 @@ internal class ScreenshotRecorder(
return
}

val bitmap = Bitmap.createBitmap(
config.recordingWidth,
config.recordingHeight,
Bitmap.Config.ARGB_8888
)

// postAtFrontOfQueue to ensure the view hierarchy and bitmap are ase close in-sync as possible
mainLooperHandler.post {
try {
contentChanged.set(false)
PixelCopy.request(
window,
bitmap,
screenshot,
{ copyResult: Int ->
if (copyResult != PixelCopy.SUCCESS) {
options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult)
bitmap.recycle()
lastCaptureSuccessful.set(false)
return@request
}

// TODO: handle animations with heuristics (e.g. if we fall under this condition 2 times in a row, we should capture)
if (contentChanged.get()) {
options.logger.log(INFO, "Failed to determine view hierarchy, not capturing")
bitmap.recycle()
lastCaptureSuccessful.set(false)
return@request
}

// TODO: disableAllMasking here and dont traverse?
val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options)
root.traverse(viewHierarchy, options)

recorder.submitSafely(options, "screenshot_recorder.mask") {
val canvas = Canvas(bitmap)
val canvas = Canvas(screenshot)
canvas.setMatrix(prescaledMatrix)
viewHierarchy.traverse { node ->
if (node.shouldMask && (node.width > 0 && node.height > 0)) {
Expand All @@ -141,7 +136,7 @@ internal class ScreenshotRecorder(
val (visibleRects, color) = when (node) {
is ImageViewHierarchyNode -> {
listOf(node.visibleRect) to
bitmap.dominantColorForRect(node.visibleRect)
screenshot.dominantColorForRect(node.visibleRect)
}

is TextViewHierarchyNode -> {
Expand All @@ -168,20 +163,16 @@ internal class ScreenshotRecorder(
return@traverse true
}

val screenshot = bitmap.copy(ARGB_8888, false)
screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
lastScreenshot?.recycle()
lastScreenshot = screenshot
lastCaptureSuccessful.set(true)
contentChanged.set(false)

bitmap.recycle()
}
},
mainLooperHandler.handler
)
} catch (e: Throwable) {
options.logger.log(WARNING, "Failed to capture replay recording", e)
bitmap.recycle()
lastCaptureSuccessful.set(false)
}
}
}
Expand Down Expand Up @@ -226,7 +217,7 @@ internal class ScreenshotRecorder(
fun close() {
unbind(rootView?.get())
rootView?.clear()
lastScreenshot?.recycle()
screenshot.recycle()
isCapturing.set(false)
}

Expand Down
Loading
Loading