Skip to content

Commit f8f5698

Browse files
committed
Introduce CaptureStrategy for buffer and session modes
1 parent 5e33e95 commit f8f5698

File tree

6 files changed

+301
-207
lines changed

6 files changed

+301
-207
lines changed

sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt

Lines changed: 23 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -5,37 +5,24 @@ import android.content.Context
55
import android.content.res.Configuration
66
import android.graphics.Bitmap
77
import android.os.Build
8-
import io.sentry.DateUtils
98
import io.sentry.Hint
109
import io.sentry.IHub
1110
import io.sentry.Integration
1211
import io.sentry.ReplayController
13-
import io.sentry.ReplayRecording
1412
import io.sentry.SentryEvent
1513
import io.sentry.SentryIntegrationPackageStorage
1614
import io.sentry.SentryLevel.DEBUG
1715
import io.sentry.SentryLevel.INFO
1816
import io.sentry.SentryOptions
19-
import io.sentry.SentryReplayEvent
20-
import io.sentry.SentryReplayEvent.ReplayType
21-
import io.sentry.SentryReplayEvent.ReplayType.BUFFER
22-
import io.sentry.SentryReplayEvent.ReplayType.SESSION
2317
import io.sentry.android.replay.capture.BufferCaptureStrategy
2418
import io.sentry.android.replay.capture.CaptureStrategy
2519
import io.sentry.android.replay.capture.SessionCaptureStrategy
26-
import io.sentry.android.replay.util.gracefullyShutdown
2720
import io.sentry.android.replay.util.sample
28-
import io.sentry.android.replay.util.submitSafely
2921
import io.sentry.protocol.SentryId
30-
import io.sentry.rrweb.RRWebMetaEvent
31-
import io.sentry.rrweb.RRWebVideoEvent
3222
import io.sentry.transport.ICurrentDateProvider
33-
import io.sentry.util.FileUtils
3423
import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion
3524
import java.io.Closeable
36-
import java.io.File
3725
import java.security.SecureRandom
38-
import java.util.Date
3926
import java.util.concurrent.atomic.AtomicBoolean
4027

4128
class ReplayIntegration(
@@ -49,7 +36,6 @@ class ReplayIntegration(
4936
private val random by lazy { SecureRandom() }
5037

5138
// TODO: probably not everything has to be thread-safe here
52-
private val isFullSession = AtomicBoolean(false)
5339
private val isEnabled = AtomicBoolean(false)
5440
private val isRecording = AtomicBoolean(false)
5541
private var captureStrategy: CaptureStrategy? = null
@@ -71,22 +57,9 @@ class ReplayIntegration(
7157
return
7258
}
7359

74-
isFullSession.set(random.sample(options.experimental.sessionReplay.sessionSampleRate))
75-
if (!isFullSession.get() &&
76-
!options.experimental.sessionReplay.isSessionReplayForErrorsEnabled
77-
) {
78-
options.logger.log(INFO, "Session replay is disabled, full session was not sampled and errorSampleRate is not specified")
79-
return
80-
}
81-
8260
this.hub = hub
8361
recorder = WindowRecorder(options, this)
8462
isEnabled.set(true)
85-
captureStrategy = if (isFullSession.get()) {
86-
SessionCaptureStrategy(options, hub, dateProvider)
87-
} else {
88-
BufferCaptureStrategy(options, hub, dateProvider, random)
89-
}
9063

9164
try {
9265
context.registerComponentCallbacks(this)
@@ -115,7 +88,19 @@ class ReplayIntegration(
11588
return
11689
}
11790

91+
val isFullSession = random.sample(options.experimental.sessionReplay.sessionSampleRate)
92+
if (!isFullSession && !options.experimental.sessionReplay.isSessionReplayForErrorsEnabled) {
93+
options.logger.log(INFO, "Session replay is not started, full session was not sampled and errorSampleRate is not specified")
94+
return
95+
}
96+
11897
recorderConfig = ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay)
98+
captureStrategy = if (isFullSession) {
99+
SessionCaptureStrategy(options, hub, dateProvider, recorderConfig)
100+
} else {
101+
BufferCaptureStrategy(options, hub, dateProvider, recorderConfig, random)
102+
}
103+
119104
captureStrategy?.start()
120105
recorder?.startRecording(recorderConfig)
121106
}
@@ -139,8 +124,12 @@ class ReplayIntegration(
139124
return
140125
}
141126

142-
captureStrategy?.sendReplayForEvent(event, hint)
143-
isFullSession.set(true)
127+
if (SentryId.EMPTY_ID.equals(captureStrategy?.currentReplayId?.get())) {
128+
options.logger.log(DEBUG, "Replay id is not set, not capturing for event %s", event.eventId)
129+
return
130+
}
131+
132+
captureStrategy?.sendReplayForEvent(event, hint, onSegmentSent = { captureStrategy?.currentSegment?.getAndIncrement()})
144133
captureStrategy = captureStrategy?.convert()
145134
}
146135

@@ -158,72 +147,14 @@ class ReplayIntegration(
158147
return
159148
}
160149

161-
val now = dateProvider.currentTimeMillis
162-
val currentSegmentTimestamp = segmentTimestamp.get()
163-
val segmentId = currentSegment.get()
164-
val duration = now - currentSegmentTimestamp.time
165-
val replayId = currentReplayId.get()
166-
val replayCacheDir = cache?.replayCacheDir
167-
val height = recorderConfig.recordingHeight
168-
val width = recorderConfig.recordingWidth
169-
replayExecutor.submitSafely(options, "$TAG.stop") {
170-
// we don't flush the segment, but we still wanna clean up the folder for buffer mode
171-
if (isFullSession.get()) {
172-
createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId, height, width)
173-
}
174-
FileUtils.deleteRecursively(replayCacheDir)
175-
}
176-
177150
recorder?.stopRecording()
178-
cache?.close()
179-
currentSegment.set(0)
180-
replayStartTimestamp.set(0)
181-
segmentTimestamp.set(null)
182-
currentReplayId.set(SentryId.EMPTY_ID)
183-
hub?.configureScope { it.replayId = SentryId.EMPTY_ID }
151+
captureStrategy?.stop()
184152
isRecording.set(false)
153+
captureStrategy = null
185154
}
186155

187156
override fun onScreenshotRecorded(bitmap: Bitmap) {
188-
// have to do it before submitting, otherwise if the queue is busy, the timestamp won't be
189-
// reflecting the exact time of when it was captured
190-
val frameTimestamp = dateProvider.currentTimeMillis
191-
val height = recorderConfig.recordingHeight
192-
val width = recorderConfig.recordingWidth
193-
replayExecutor.submitSafely(options, "$TAG.add_frame") {
194-
cache?.addFrame(bitmap, frameTimestamp)
195-
196-
val now = dateProvider.currentTimeMillis
197-
if (isFullSession.get() &&
198-
(now - segmentTimestamp.get().time >= options.experimental.sessionReplay.sessionSegmentDuration)
199-
) {
200-
val currentSegmentTimestamp = segmentTimestamp.get()
201-
val segmentId = currentSegment.get()
202-
val replayId = currentReplayId.get()
203-
204-
val videoDuration =
205-
createAndCaptureSegment(
206-
options.experimental.sessionReplay.sessionSegmentDuration,
207-
currentSegmentTimestamp,
208-
replayId,
209-
segmentId,
210-
height,
211-
width
212-
)
213-
if (videoDuration != null) {
214-
currentSegment.getAndIncrement()
215-
// set next segment timestamp as close to the previous one as possible to avoid gaps
216-
segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + videoDuration))
217-
}
218-
} else if (isFullSession.get() &&
219-
(now - replayStartTimestamp.get() >= options.experimental.sessionReplay.sessionDuration)
220-
) {
221-
stop()
222-
options.logger.log(INFO, "Session replay deadline exceeded (1h), stopping recording")
223-
} else if (!isFullSession.get()) {
224-
cache?.rotate(now - options.experimental.sessionReplay.errorReplayDuration)
225-
}
226-
}
157+
captureStrategy?.onScreenshotRecorded(bitmap)
227158
}
228159

229160
override fun close() {
@@ -247,26 +178,10 @@ class ReplayIntegration(
247178

248179
recorder?.stopRecording()
249180

250-
// TODO: support buffer mode and breadcrumb/rrweb_event
251-
if (isFullSession.get()) {
252-
val now = dateProvider.currentTimeMillis
253-
val currentSegmentTimestamp = segmentTimestamp.get()
254-
val segmentId = currentSegment.get()
255-
val duration = now - currentSegmentTimestamp.time
256-
val replayId = currentReplayId.get()
257-
val height = recorderConfig.recordingHeight
258-
val width = recorderConfig.recordingWidth
259-
replayExecutor.submitSafely(options, "$TAG.onConfigurationChanged") {
260-
val videoDuration =
261-
createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId, height, width)
262-
if (videoDuration != null) {
263-
currentSegment.getAndIncrement()
264-
}
265-
}
266-
}
267-
268181
// refresh config based on new device configuration
269182
recorderConfig = ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay)
183+
captureStrategy?.onConfigurationChanged(recorderConfig)
184+
270185
recorder?.startRecording(recorderConfig)
271186
}
272187

sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ internal class WindowRecorder(
3232
private val rootViews = ArrayList<WeakReference<View>>()
3333
private var recorder: ScreenshotRecorder? = null
3434
private var capturingTask: ScheduledFuture<*>? = null
35-
private val capturer = Executors.newSingleThreadScheduledExecutor(RecorderExecutorServiceThreadFactory())
35+
private val capturer by lazy {
36+
Executors.newSingleThreadScheduledExecutor(RecorderExecutorServiceThreadFactory())
37+
}
3638

3739
private val onRootViewsChangedListener = OnRootViewsChangedListener { root, added ->
3840
if (added) {

sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt

Lines changed: 54 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,14 @@ package io.sentry.android.replay.capture
22

33
import io.sentry.DateUtils
44
import io.sentry.Hint
5+
import io.sentry.IHub
56
import io.sentry.ReplayRecording
6-
import io.sentry.SentryEvent
77
import io.sentry.SentryOptions
88
import io.sentry.SentryReplayEvent
99
import io.sentry.SentryReplayEvent.ReplayType
1010
import io.sentry.SentryReplayEvent.ReplayType.SESSION
1111
import io.sentry.android.replay.ReplayCache
12-
import io.sentry.android.replay.ReplayIntegration
13-
import io.sentry.android.replay.ReplayIntegration.Companion
14-
import io.sentry.android.replay.ReplayIntegration.ReplayExecutorServiceThreadFactory
12+
import io.sentry.android.replay.ScreenshotRecorderConfig
1513
import io.sentry.android.replay.util.gracefullyShutdown
1614
import io.sentry.android.replay.util.submitSafely
1715
import io.sentry.protocol.SentryId
@@ -22,28 +20,31 @@ import io.sentry.util.FileUtils
2220
import java.io.File
2321
import java.util.Date
2422
import java.util.concurrent.Executors
23+
import java.util.concurrent.ScheduledExecutorService
2524
import java.util.concurrent.ThreadFactory
2625
import java.util.concurrent.atomic.AtomicInteger
2726
import java.util.concurrent.atomic.AtomicLong
2827
import java.util.concurrent.atomic.AtomicReference
2928

3029
abstract class BaseCaptureStrategy(
3130
private val options: SentryOptions,
32-
private val dateProvider: ICurrentDateProvider
31+
private val dateProvider: ICurrentDateProvider,
32+
protected var recorderConfig: ScreenshotRecorderConfig,
33+
executor: ScheduledExecutorService? = null
3334
) : CaptureStrategy {
3435

3536
internal companion object {
3637
private const val TAG = "CaptureStrategy"
3738
}
3839

3940
protected var cache: ReplayCache? = null
40-
protected val currentReplayId = AtomicReference(SentryId.EMPTY_ID)
4141
protected val segmentTimestamp = AtomicReference<Date>()
4242
protected val replayStartTimestamp = AtomicLong()
43-
protected val currentSegment = AtomicInteger(0)
43+
override val currentReplayId = AtomicReference(SentryId.EMPTY_ID)
44+
override val currentSegment = AtomicInteger(0)
4445

45-
protected val replayExecutor by lazy {
46-
Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory())
46+
protected val replayExecutor: ScheduledExecutorService by lazy {
47+
executor ?: Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory())
4748
}
4849

4950
override fun start(segmentId: Int, replayId: SentryId, cleanupOldReplays: Boolean) {
@@ -81,26 +82,35 @@ abstract class BaseCaptureStrategy(
8182
segmentTimestamp.set(DateUtils.getCurrentDateTime())
8283
}
8384

84-
protected fun createAndCaptureSegment(
85+
override fun pause() = Unit
86+
87+
override fun stop() {
88+
cache?.close()
89+
currentSegment.set(0)
90+
replayStartTimestamp.set(0)
91+
segmentTimestamp.set(null)
92+
currentReplayId.set(SentryId.EMPTY_ID)
93+
}
94+
95+
protected fun createSegment(
8596
duration: Long,
8697
currentSegmentTimestamp: Date,
8798
replayId: SentryId,
8899
segmentId: Int,
89100
height: Int,
90101
width: Int,
91102
replayType: ReplayType = SESSION,
92-
hint: Hint? = null
93-
): Long? {
103+
): ReplaySegment {
94104
val generatedVideo = cache?.createVideoOf(
95105
duration,
96106
currentSegmentTimestamp.time,
97107
segmentId,
98108
height,
99109
width
100-
) ?: return null
110+
) ?: return ReplaySegment.Failed
101111

102112
val (video, frameCount, videoDuration) = generatedVideo
103-
captureReplay(
113+
return buildReplay(
104114
video,
105115
replayId,
106116
currentSegmentTimestamp,
@@ -110,12 +120,10 @@ abstract class BaseCaptureStrategy(
110120
frameCount,
111121
videoDuration,
112122
replayType,
113-
hint
114123
)
115-
return videoDuration
116124
}
117125

118-
private fun captureReplay(
126+
private fun buildReplay(
119127
video: File,
120128
currentReplayId: SentryId,
121129
segmentTimestamp: Date,
@@ -125,16 +133,13 @@ abstract class BaseCaptureStrategy(
125133
frameCount: Int,
126134
duration: Long,
127135
replayType: ReplayType,
128-
hint: Hint? = null
129-
) {
136+
): ReplaySegment {
130137
val replay = SentryReplayEvent().apply {
131138
eventId = currentReplayId
132139
replayId = currentReplayId
133140
this.segmentId = segmentId
134141
this.timestamp = DateUtils.getDateTime(segmentTimestamp.time + duration)
135-
if (segmentId == 0) {
136-
replayStartTimestamp = segmentTimestamp
137-
}
142+
replayStartTimestamp = segmentTimestamp
138143
this.replayType = replayType
139144
videoFile = video
140145
}
@@ -163,7 +168,11 @@ abstract class BaseCaptureStrategy(
163168
)
164169
}
165170

166-
// hub?.captureReplay(replay, (hint ?: Hint()).apply { replayRecording = recording })
171+
return ReplaySegment.Created(videoDuration = duration, replay = replay, recording = recording)
172+
}
173+
174+
override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) {
175+
this.recorderConfig = recorderConfig
167176
}
168177

169178
override fun close() {
@@ -178,4 +187,26 @@ abstract class BaseCaptureStrategy(
178187
return ret
179188
}
180189
}
190+
191+
protected sealed class ReplaySegment {
192+
object Failed : ReplaySegment()
193+
data class Created(
194+
val videoDuration: Long,
195+
val replay: SentryReplayEvent,
196+
val recording: ReplayRecording
197+
): ReplaySegment() {
198+
fun capture(hub: IHub?, hint: Hint = Hint()) {
199+
hub?.captureReplay(replay, hint.apply { replayRecording = recording })
200+
}
201+
202+
fun setSegmentId(segmentId: Int) {
203+
replay.segmentId = segmentId
204+
recording.payload?.forEach {
205+
when (it) {
206+
is RRWebVideoEvent -> it.segmentId = segmentId
207+
}
208+
}
209+
}
210+
}
211+
}
181212
}

0 commit comments

Comments
 (0)