Skip to content

Commit 3ab9b79

Browse files
feat(replay): Record segment names
Co-Authored-By: Gino Buenaflor <giancarlo.buenaflor@sentry.io>
1 parent bcd3e76 commit 3ab9b79

11 files changed

Lines changed: 147 additions & 15 deletions

File tree

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,13 @@ public class ReplayIntegration(
297297
captureStrategy?.registerTraceId(traceId)
298298
}
299299

300+
override fun registerSegmentName(segmentName: String) {
301+
if (!isEnabled.get() || !isRecording()) {
302+
return
303+
}
304+
captureStrategy?.registerSegmentName(segmentName)
305+
}
306+
300307
private fun pauseInternal() {
301308
lifecycleLock.acquire().use {
302309
if (!isEnabled.get() || !lifecycle.isAllowed(PAUSED)) {

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

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ internal abstract class BaseCaptureStrategy(
5454
internal companion object {
5555
private const val TAG = "CaptureStrategy"
5656
// https://github.com/getsentry/sentry-javascript/blob/30eb68fff5077211c30c61ba74625e66ab514870/packages/replay-internal/src/coreHandlers/handleAfterSendEvent.ts#L41
57-
private const val MAX_TRACE_IDS = 100
57+
private const val MAX_CONTEXT_VALUES = 100
5858
}
5959

6060
private val gestureConverter = ReplayGestureConverter(dateProvider)
@@ -97,8 +97,9 @@ internal abstract class BaseCaptureStrategy(
9797
persistableAtomic(initialValue = false, propertyName = SEGMENT_KEY_FLUSHED)
9898

9999
protected val currentEvents: Deque<RRWebEvent> = ConcurrentLinkedDeque()
100-
private val traceIdsLock = Any()
101-
private val currentTraceIds: MutableList<String> = mutableListOf()
100+
private val replayContextLock = Any()
101+
private val currentTraceIds: MutableSet<String> = linkedSetOf()
102+
private val currentSegmentNames: MutableSet<String> = linkedSetOf()
102103

103104
override fun start(segmentId: Int, replayId: SentryId, replayType: ReplayType?) {
104105
cache = replayCacheProvider?.invoke(replayId) ?: ReplayCache(options, replayId)
@@ -139,11 +140,12 @@ internal abstract class BaseCaptureStrategy(
139140
breadcrumbs: List<Breadcrumb>? = null,
140141
events: Deque<RRWebEvent> = this.currentEvents,
141142
): ReplaySegment {
142-
val traceIds =
143-
synchronized(traceIdsLock) {
144-
val ids = currentTraceIds.toList()
143+
val (traceIds, segmentNames) =
144+
synchronized(replayContextLock) {
145+
val context = currentTraceIds.toList() to currentSegmentNames.toList()
145146
currentTraceIds.clear()
146-
ids
147+
currentSegmentNames.clear()
148+
context
147149
}
148150
return createSegment(
149151
scopes,
@@ -162,6 +164,7 @@ internal abstract class BaseCaptureStrategy(
162164
breadcrumbs,
163165
events,
164166
traceIds,
167+
segmentNames,
165168
)
166169
}
167170

@@ -180,12 +183,19 @@ internal abstract class BaseCaptureStrategy(
180183

181184
override fun registerTraceId(traceId: SentryId) {
182185
if (traceId != SentryId.EMPTY_ID) {
183-
synchronized(traceIdsLock) {
184-
if (currentTraceIds.size < MAX_TRACE_IDS) {
185-
val id = traceId.toString()
186-
if (!currentTraceIds.contains(id)) {
187-
currentTraceIds.add(id)
188-
}
186+
synchronized(replayContextLock) {
187+
if (currentTraceIds.size < MAX_CONTEXT_VALUES) {
188+
currentTraceIds.add(traceId.toString())
189+
}
190+
}
191+
}
192+
}
193+
194+
override fun registerSegmentName(segmentName: String) {
195+
if (segmentName.isNotEmpty()) {
196+
synchronized(replayContextLock) {
197+
if (currentSegmentNames.size < MAX_CONTEXT_VALUES) {
198+
currentSegmentNames.add(segmentName)
189199
}
190200
}
191201
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ internal interface CaptureStrategy {
5656

5757
fun registerTraceId(traceId: SentryId)
5858

59+
fun registerSegmentName(segmentName: String)
60+
5961
companion object {
6062
private fun Breadcrumb?.isNetworkAvailable(): Boolean =
6163
this != null &&
@@ -88,6 +90,7 @@ internal interface CaptureStrategy {
8890
breadcrumbs: List<Breadcrumb>?,
8991
events: Deque<RRWebEvent>,
9092
traceIds: List<String> = emptyList(),
93+
segmentNames: List<String> = emptyList(),
9194
): ReplaySegment {
9295
val generatedVideo =
9396
cache?.createVideoOf(
@@ -127,6 +130,7 @@ internal interface CaptureStrategy {
127130
replayBreadcrumbs,
128131
events,
129132
traceIds,
133+
segmentNames,
130134
)
131135
}
132136

@@ -147,6 +151,7 @@ internal interface CaptureStrategy {
147151
breadcrumbs: List<Breadcrumb>,
148152
events: Deque<RRWebEvent>,
149153
traceIds: List<String>,
154+
segmentNames: List<String>,
150155
): ReplaySegment {
151156
val endTimestamp = DateUtils.getDateTime(segmentTimestamp.time + videoDuration)
152157
val replay =
@@ -159,6 +164,7 @@ internal interface CaptureStrategy {
159164
this.replayType = replayType
160165
this.videoFile = video
161166
this.traceIds = traceIds
167+
this.segmentNames = segmentNames
162168
}
163169

164170
val recordingPayload = mutableListOf<RRWebEvent>()

sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,4 +562,58 @@ class SessionCaptureStrategyTest {
562562
any(),
563563
)
564564
}
565+
@Test
566+
fun `registerSegmentName includes unique segment names in next segment and clears them`() {
567+
val now =
568+
System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5)
569+
val strategy = fixture.getSut(dateProvider = { now })
570+
strategy.start()
571+
strategy.onConfigurationChanged(fixture.recorderConfig)
572+
573+
strategy.registerSegmentName("GET /users/:id")
574+
strategy.registerSegmentName("GET /users/:id")
575+
strategy.registerSegmentName("POST /items")
576+
577+
strategy.onScreenshotRecorded(mock<Bitmap>()) {}
578+
579+
verify(fixture.scopes)
580+
.captureReplay(
581+
argThat { event ->
582+
event is SentryReplayEvent &&
583+
event.segmentNames == listOf("GET /users/:id", "POST /items")
584+
},
585+
any(),
586+
)
587+
588+
strategy.onScreenshotRecorded(mock<Bitmap>()) {}
589+
590+
verify(fixture.scopes)
591+
.captureReplay(
592+
argThat { event ->
593+
event is SentryReplayEvent && event.segmentId == 1 && event.segmentNames.isNullOrEmpty()
594+
},
595+
any(),
596+
)
597+
}
598+
599+
@Test
600+
fun `registerSegmentName ignores empty names and limits names to 100`() {
601+
val now =
602+
System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5)
603+
val strategy = fixture.getSut(dateProvider = { now })
604+
strategy.start()
605+
strategy.onConfigurationChanged(fixture.recorderConfig)
606+
607+
strategy.registerSegmentName("")
608+
repeat(101) { strategy.registerSegmentName("segment-$it") }
609+
610+
strategy.onScreenshotRecorded(mock<Bitmap>()) {}
611+
612+
verify(fixture.scopes)
613+
.captureReplay(
614+
argThat { event -> event is SentryReplayEvent && event.segmentNames?.size == 100 },
615+
any(),
616+
)
617+
}
618+
565619
}

sentry/src/main/java/io/sentry/NoOpReplayController.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,7 @@ public void disableDebugMaskingOverlay() {}
6060

6161
@Override
6262
public void registerTraceId(@NotNull SentryId traceId) {}
63+
64+
@Override
65+
public void registerSegmentName(@NotNull String segmentName) {}
6366
}

sentry/src/main/java/io/sentry/ReplayController.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,7 @@ public interface ReplayController extends IReplayApi {
3636
* @param traceId the trace ID to associate with the current replay
3737
*/
3838
void registerTraceId(@NotNull SentryId traceId);
39+
40+
/** Registers a segment name to be associated with the current replay segment. */
41+
void registerSegmentName(@NotNull String segmentName);
3942
}

sentry/src/main/java/io/sentry/SentryClient.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1079,6 +1079,10 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint
10791079
if (trace != null) {
10801080
options.getReplayController().registerTraceId(trace.getTraceId());
10811081
}
1082+
final @Nullable String segmentName = transaction.getTransaction();
1083+
if (segmentName != null && !segmentName.isEmpty()) {
1084+
options.getReplayController().registerSegmentName(segmentName);
1085+
}
10821086
}
10831087

10841088
return sentryId;

sentry/src/main/java/io/sentry/SentryReplayEvent.java

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ public static final class Deserializer implements JsonDeserializer<ReplayType> {
4949
private @Nullable List<String> urls;
5050
private @Nullable List<String> errorIds;
5151
private @Nullable List<String> traceIds;
52+
private @Nullable List<String> segmentNames;
5253
private @Nullable Map<String, Object> unknown;
5354

5455
public SentryReplayEvent() {
@@ -58,6 +59,7 @@ public SentryReplayEvent() {
5859
this.replayType = ReplayType.SESSION;
5960
this.errorIds = new ArrayList<>();
6061
this.traceIds = new ArrayList<>();
62+
this.segmentNames = new ArrayList<>();
6163
this.urls = new ArrayList<>();
6264
timestamp = DateUtils.getCurrentDateTime();
6365
}
@@ -142,6 +144,15 @@ public void setTraceIds(final @Nullable List<String> traceIds) {
142144
this.traceIds = traceIds;
143145
}
144146

147+
@Nullable
148+
public List<String> getSegmentNames() {
149+
return segmentNames;
150+
}
151+
152+
public void setSegmentNames(final @Nullable List<String> segmentNames) {
153+
this.segmentNames = segmentNames;
154+
}
155+
145156
@NotNull
146157
public ReplayType getReplayType() {
147158
return replayType;
@@ -162,12 +173,13 @@ public boolean equals(Object o) {
162173
&& Objects.equals(replayId, that.replayId)
163174
&& Objects.equals(urls, that.urls)
164175
&& Objects.equals(errorIds, that.errorIds)
165-
&& Objects.equals(traceIds, that.traceIds);
176+
&& Objects.equals(traceIds, that.traceIds)
177+
&& Objects.equals(segmentNames, that.segmentNames);
166178
}
167179

168180
@Override
169181
public int hashCode() {
170-
return Objects.hash(type, replayType, replayId, segmentId, urls, errorIds, traceIds);
182+
return Objects.hash(type, replayType, replayId, segmentId, urls, errorIds, traceIds, segmentNames);
171183
}
172184

173185
// region json
@@ -181,6 +193,7 @@ public static final class JsonKeys {
181193
public static final String URLS = "urls";
182194
public static final String ERROR_IDS = "error_ids";
183195
public static final String TRACE_IDS = "trace_ids";
196+
public static final String SEGMENT_NAMES = "segment_names";
184197
}
185198

186199
@Override
@@ -207,6 +220,9 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger
207220
if (traceIds != null) {
208221
writer.name(JsonKeys.TRACE_IDS).value(logger, traceIds);
209222
}
223+
if (segmentNames != null) {
224+
writer.name(JsonKeys.SEGMENT_NAMES).value(logger, segmentNames);
225+
}
210226

211227
new SentryBaseEvent.Serializer().serialize(this, writer, logger);
212228

@@ -250,6 +266,7 @@ public static final class Deserializer implements JsonDeserializer<SentryReplayE
250266
@Nullable List<String> urls = null;
251267
@Nullable List<String> errorIds = null;
252268
@Nullable List<String> traceIds = null;
269+
@Nullable List<String> segmentNames = null;
253270

254271
reader.beginObject();
255272
while (reader.peek() == JsonToken.NAME) {
@@ -282,6 +299,9 @@ public static final class Deserializer implements JsonDeserializer<SentryReplayE
282299
case JsonKeys.TRACE_IDS:
283300
traceIds = (List<String>) reader.nextObjectOrNull();
284301
break;
302+
case JsonKeys.SEGMENT_NAMES:
303+
segmentNames = (List<String>) reader.nextObjectOrNull();
304+
break;
285305
default:
286306
if (!baseEventDeserializer.deserializeValue(replay, nextName, reader, logger)) {
287307
if (unknown == null) {
@@ -311,6 +331,7 @@ public static final class Deserializer implements JsonDeserializer<SentryReplayE
311331
replay.setUrls(urls);
312332
replay.setErrorIds(errorIds);
313333
replay.setTraceIds(traceIds);
334+
replay.setSegmentNames(segmentNames);
314335
replay.setUnknown(unknown);
315336
return replay;
316337
}

sentry/src/test/java/io/sentry/SentryClientTest.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2130,6 +2130,8 @@ class SentryClientTest {
21302130
override fun registerTraceId(traceId: SentryId) {
21312131
registeredTraceId = traceId
21322132
}
2133+
2134+
override fun registerSegmentName(segmentName: String) {}
21332135
}
21342136
)
21352137
val sut = fixture.getSut()
@@ -2139,6 +2141,23 @@ class SentryClientTest {
21392141
assertEquals(sentryTracer.spanContext.traceId, registeredTraceId)
21402142
}
21412143

2144+
@Test
2145+
fun `captureTransaction registers segment name with replay controller`() {
2146+
var registeredSegmentName: String? = null
2147+
fixture.sentryOptions.setReplayController(
2148+
object : ReplayController by NoOpReplayController.getInstance() {
2149+
override fun registerSegmentName(segmentName: String) {
2150+
registeredSegmentName = segmentName
2151+
}
2152+
}
2153+
)
2154+
val sut = fixture.getSut()
2155+
val sentryTracer = SentryTracer(TransactionContext("GET /users/:id", "op"), fixture.scopes)
2156+
val transaction = SentryTransaction(sentryTracer)
2157+
sut.captureTransaction(transaction, sentryTracer.traceContext())
2158+
assertEquals("GET /users/:id", registeredSegmentName)
2159+
}
2160+
21422161
@Test
21432162
fun `when exception type is ignored, capturing event does not send it`() {
21442163
fixture.sentryOptions.addIgnoredExceptionForType(IllegalStateException::class.java)

sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class SentryReplayEventSerializationTest {
2626
urls = listOf("ScreenOne")
2727
errorIds = listOf("ab3a347a4cc14fd4b4cf1dc56b670c5b")
2828
traceIds = listOf("340cfef948204549ac07c3b353c81c50")
29+
segmentNames = listOf("GET /users/:id")
2930
SentryBaseEventSerializationTest.Fixture().update(this)
3031
// irrelevant for replay
3132
serverName = null

0 commit comments

Comments
 (0)