Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Fixes

- Session Replay: Backfill release, environment and SDK info for replays sent on the next launch after the app was killed or crashed ([#6253](https://github.com/getsentry/sentry-java/pull/6253))
- Fix SDK callback error handling ([#6140](https://github.com/getsentry/sentry-java/pull/6140))
- Drop telemetry when a `beforeSend*` callback throws and report it using `DiscardReason.CALLBACK_ERROR`.
- Drop transactions when `tracesSampler` throws instead of inheriting the parent sampling decision or falling back to `tracesSampleRate` ([#6163](https://github.com/getsentry/sentry-java/pull/6163)).
Expand Down
2 changes: 0 additions & 2 deletions sentry-android-replay/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,6 @@
<ID>ForbiddenComment:Persistable.kt$PersistableLinkedList$// TODO: enable this back after we are able to serialize individual touches to disk to not overload</ID>
<ID>ForbiddenComment:PixelCopyStrategy.kt$PixelCopyStrategy$// TODO: disableAllMasking here and dont traverse?</ID>
<ID>ForbiddenComment:ReplayCache.kt$ReplayCache$// TODO: it's awful, choose a better serialization format</ID>
<ID>ForbiddenComment:ReplayIntegration.kt$ReplayIntegration$// TODO: previous run and set them directly to the ReplayEvent so they don't get overwritten in</ID>
<ID>ForbiddenComment:ReplayIntegration.kt$ReplayIntegration$// TODO: read persisted options/scope values form the</ID>
<ID>ForbiddenComment:ReplaySmokeTest.kt$ReplaySmokeTest$// TODO: figure out why there's more than 10</ID>
<ID>ForbiddenComment:SimpleVideoEncoder.kt$SimpleVideoEncoder$// TODO: because the screenshot config has to match the video config</ID>
<ID>ForbiddenComment:SimpleVideoEncoder.kt$SimpleVideoEncoder$// TODO: if this ever becomes a problem, move this to ScreenshotRecorderConfig.from()</ID>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import io.sentry.SentryReplayEvent.ReplayType
import io.sentry.SentryReplayEvent.ReplayType.SESSION
import io.sentry.android.replay.video.MuxerConfig
import io.sentry.android.replay.video.SimpleVideoEncoder
import io.sentry.protocol.SdkVersion
import io.sentry.protocol.SentryId
import io.sentry.rrweb.RRWebEvent
import io.sentry.util.AutoClosableReentrantLock
Expand Down Expand Up @@ -321,6 +322,19 @@ public class ReplayCache(private val options: SentryOptions, private val replayI
internal const val SEGMENT_KEY_REPLAY_RECORDING = "replay.recording"
internal const val SEGMENT_KEY_ID = "segment.id"
internal const val SEGMENT_KEY_FLUSHED = "replay.flushed"
internal const val SEGMENT_KEY_SDK_NAME = "replay.sdk-name"
internal const val SEGMENT_KEY_SDK_VERSION = "replay.sdk-version"
internal const val SEGMENT_KEY_RELEASE = "replay.release"
internal const val SEGMENT_KEY_ENVIRONMENT = "replay.environment"
internal const val SEGMENT_KEY_DIST = "replay.dist"
private val RECORDING_METADATA_KEYS =
listOf(
SEGMENT_KEY_RELEASE,
SEGMENT_KEY_ENVIRONMENT,
SEGMENT_KEY_DIST,
SEGMENT_KEY_SDK_NAME,
SEGMENT_KEY_SDK_VERSION,
)

/**
* Deletes [replayCacheDir], but keeps the segment videos it holds. A captured segment's video
Expand Down Expand Up @@ -475,6 +489,24 @@ public class ReplayCache(private val options: SentryOptions, private val replayI
replayType = replayType,
screenAtStart = lastSegment[SEGMENT_KEY_REPLAY_SCREEN_AT_START],
events = events.sortedBy { it.timestamp },
metadata = recordingMetadata(lastSegment),
)
}

private fun recordingMetadata(lastSegment: Map<String, String>): RecordingMetadata? {
if (RECORDING_METADATA_KEYS.none { it in lastSegment }) {
// recorded by an older SDK version
return null
}
val sdkName = lastSegment[SEGMENT_KEY_SDK_NAME]
val sdkVersion = lastSegment[SEGMENT_KEY_SDK_VERSION]
return RecordingMetadata(
release = lastSegment[SEGMENT_KEY_RELEASE],
environment = lastSegment[SEGMENT_KEY_ENVIRONMENT],
dist = lastSegment[SEGMENT_KEY_DIST],
sdkVersion =
if (sdkName.isNullOrEmpty() || sdkVersion.isNullOrEmpty()) null
else SdkVersion(sdkName, sdkVersion),
)
}
}
Expand All @@ -489,6 +521,19 @@ internal data class LastSegmentData(
val replayType: ReplayType,
val screenAtStart: String?,
val events: List<RRWebEvent>,
/** Null for replays recorded by SDK versions that didn't persist it. */
val metadata: RecordingMetadata?,
)

/**
* Event metadata of the run that recorded a replay, persisted when recording starts so a replay
* sent on the next launch describes the run that recorded it.
*/
internal data class RecordingMetadata(
val release: String?,
val environment: String?,
val dist: String?,
val sdkVersion: SdkVersion?,
)

internal data class ReplayFrame(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import io.sentry.SentryLevel.DEBUG
import io.sentry.SentryLevel.ERROR
import io.sentry.SentryLevel.INFO
import io.sentry.SentryOptions
import io.sentry.SentryReplayEvent
import io.sentry.TypeCheckHint
import io.sentry.android.replay.ReplayLifecycleState.CLOSED
import io.sentry.android.replay.ReplayLifecycleState.PAUSED
Expand All @@ -39,6 +40,10 @@ import io.sentry.android.replay.util.ReplayExecutorService
import io.sentry.android.replay.util.appContext
import io.sentry.android.replay.util.sample
import io.sentry.android.replay.util.submitSafely
import io.sentry.cache.PersistingOptionsObserver
import io.sentry.cache.PersistingOptionsObserver.DIST_FILENAME
import io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME
import io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME
import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME
import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME
import io.sentry.hints.Backfillable
Expand Down Expand Up @@ -653,10 +658,6 @@ public class ReplayIntegration(
}

private fun finalizePreviousReplay() {
// TODO: read persisted options/scope values form the
// TODO: previous run and set them directly to the ReplayEvent so they don't get overwritten in
// MainEventProcessor

options.executorService.submitSafely(options, "ReplayIntegration.finalize_previous_replay") {
val persistingScopeObserver = options.findPersistingScopeObserver()
val previousReplayIdString =
Expand Down Expand Up @@ -701,6 +702,7 @@ public class ReplayIntegration(
)

if (segment is ReplaySegment.Created) {
segment.replay.applyRecordingMetadata(lastSegment.metadata, options)
val hint = HintUtils.createWithTypeCheckHint(PreviousReplayHint())
segment.capture(scopes, hint)
}
Expand Down Expand Up @@ -781,3 +783,30 @@ public class ReplayIntegration(
}
}
}

/**
* Replays from the previous run are sent with a [Backfillable] hint, so MainEventProcessor does not
* apply release, environment, dist or SDK from the current run. Use the values persisted with the
* replay when it started recording instead.
*
* Replays recorded by older SDK versions don't have them stored. For those, read release,
* environment and dist from the options persisted by the previous run: this runs on the SDK
* executor before Sentry.init persists the current options, so they still describe that run. The
* SDK falls back to the one MainEventProcessor applies to current-run segments rather than none.
*/
private fun SentryReplayEvent.applyRecordingMetadata(
metadata: RecordingMetadata?,
options: SentryOptions,
) {
if (metadata != null) {
release = metadata.release
environment = metadata.environment
dist = metadata.dist
sdk = metadata.sdkVersion ?: options.sessionReplay.sdkVersion ?: options.sdkVersion
} else {
release = PersistingOptionsObserver.read(options, RELEASE_FILENAME, String::class.java)
Comment thread
sentry-junior[bot] marked this conversation as resolved.
environment = PersistingOptionsObserver.read(options, ENVIRONMENT_FILENAME, String::class.java)
dist = PersistingOptionsObserver.read(options, DIST_FILENAME, String::class.java)
sdk = options.sessionReplay.sdkVersion ?: options.sdkVersion
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,21 @@ import io.sentry.SentryOptions
import io.sentry.SentryReplayEvent.ReplayType
import io.sentry.SentryReplayEvent.ReplayType.BUFFER
import io.sentry.SentryReplayEvent.ReplayType.SESSION
import io.sentry.android.replay.RecordingMetadata
import io.sentry.android.replay.ReplayCache
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_DIST
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ENVIRONMENT
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FLUSHED
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_RELEASE
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_ID
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_SCREEN_AT_START
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_SDK_NAME
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_SDK_VERSION
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH
import io.sentry.android.replay.ScreenshotRecorderConfig
Expand Down Expand Up @@ -80,6 +86,14 @@ internal abstract class BaseCaptureStrategy(
)
}
protected val replayStartTimestamp = AtomicLong()
private var recordingMetadata: RecordingMetadata? by
persistableAtomicNullable(propertyName = "") { _, _, newValue ->
persistMetadataValue(SEGMENT_KEY_RELEASE, newValue?.release)
persistMetadataValue(SEGMENT_KEY_ENVIRONMENT, newValue?.environment)
persistMetadataValue(SEGMENT_KEY_DIST, newValue?.dist)
persistMetadataValue(SEGMENT_KEY_SDK_NAME, newValue?.sdkVersion?.name)
persistMetadataValue(SEGMENT_KEY_SDK_VERSION, newValue?.sdkVersion?.version)
}
protected var screenAtStart by
persistableAtomicNullable<String>(propertyName = SEGMENT_KEY_REPLAY_SCREEN_AT_START)
override var currentReplayId: SentryId by
Expand Down Expand Up @@ -110,6 +124,19 @@ internal abstract class BaseCaptureStrategy(

segmentTimestamp = DateUtils.getCurrentDateTime()
replayStartTimestamp.set(dateProvider.currentTimeMillis)
// the same values MainEventProcessor applies to current-run segments
recordingMetadata =
RecordingMetadata(
release = options.release,
environment = options.environment,
dist = options.dist,
sdkVersion = options.sessionReplay.sdkVersion ?: options.sdkVersion,
)
}

private fun persistMetadataValue(key: String, value: String?) {
// the segment file stores one key=value per line, so values with line breaks can't be stored
cache?.persistSegmentValues(key, value?.takeIf { '\n' !in it && '\r' !in it })
}

override fun resume() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package io.sentry.android.replay

import android.content.Context
import android.graphics.Bitmap
import android.graphics.Bitmap.CompressFormat.JPEG
import android.graphics.Bitmap.Config.ARGB_8888
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.sentry.EventProcessor
import io.sentry.Hint
import io.sentry.Sentry
import io.sentry.SentryReplayEvent
import io.sentry.android.core.performance.AppStartMetrics
import io.sentry.android.replay.ReplayCache.Companion.ONGOING_SEGMENT
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP
import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH
import io.sentry.android.replay.util.ReplayShadowMediaCodec
import io.sentry.cache.PersistingOptionsObserver.ENVIRONMENT_FILENAME
import io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE
import io.sentry.cache.PersistingOptionsObserver.RELEASE_FILENAME
import io.sentry.cache.PersistingScopeObserver.REPLAY_FILENAME
import io.sentry.cache.PersistingScopeObserver.SCOPE_CACHE
import io.sentry.protocol.SentryId
import java.io.File
import java.util.concurrent.atomic.AtomicReference
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import org.awaitility.kotlin.await
import org.awaitility.kotlin.untilNotNull
import org.junit.Rule
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.annotation.Config

@RunWith(AndroidJUnit4::class)
@Config(sdk = [30], shadows = [ReplayShadowMediaCodec::class])
class PreviousReplayIntegrationTest {
@get:Rule val tmpDir = TemporaryFolder()

private lateinit var context: Context

@BeforeTest
fun `set up`() {
ReplayShadowMediaCodec.framesToEncode = 1
Sentry.close()
AppStartMetrics.getInstance().clear()
context = ApplicationProvider.getApplicationContext()
}

// Replays recorded by older SDK versions don't store their metadata, so the previous replay is
// backfilled from the options cache. That only works while ReplayIntegration reads the cache
// before Sentry.init overwrites it with the current options.
@Test
fun `previous replay without stored metadata is backfilled from the previous run options`() {
val cacheDir = tmpDir.newFolder().absolutePath
val replayId = SentryId()
val capturedReplay = AtomicReference<SentryReplayEvent>()

initForTest(context) {
it.dsn = "https://key@sentry.io/123"
it.cacheDirPath = cacheDir
it.release = "io.sentry.sample@2.0.0+2"
it.environment = "current-env"
it.addEventProcessor(
object : EventProcessor {
override fun process(event: SentryReplayEvent, hint: Hint): SentryReplayEvent {
capturedReplay.set(event)
return event
}
}
)

// have to do it after the cacheDir is set to options, because it adds a dsn hash after
val optionsCache = File(it.cacheDirPath!!, OPTIONS_CACHE).also { dir -> dir.mkdirs() }
File(optionsCache, RELEASE_FILENAME).writeText("\"io.sentry.sample@1.0.0+1\"")
File(optionsCache, ENVIRONMENT_FILENAME).writeText("\"previous-env\"")

val scopeCache = File(it.cacheDirPath!!, SCOPE_CACHE).also { dir -> dir.mkdirs() }
File(scopeCache, REPLAY_FILENAME).writeText("\"$replayId\"")

val replayFolder = File(it.cacheDirPath!!, "replay_$replayId").also { dir -> dir.mkdirs() }
File(replayFolder, ONGOING_SEGMENT)
.writeText(
"""
$SEGMENT_KEY_HEIGHT=912
$SEGMENT_KEY_WIDTH=416
$SEGMENT_KEY_FRAME_RATE=1
$SEGMENT_KEY_BIT_RATE=75000
$SEGMENT_KEY_ID=0
$SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z
$SEGMENT_KEY_REPLAY_TYPE=BUFFER
"""
.trimIndent()
)
File(replayFolder, "1720693523997.jpg").outputStream().use { os ->
Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, os)
os.flush()
}
}

val replay = await.untilNotNull { capturedReplay.get() }
assertEquals(replayId, replay.replayId)
assertEquals("io.sentry.sample@1.0.0+1", replay.release)
assertEquals("previous-env", replay.environment)
}
}
Loading
Loading